litellm/tests/unit/harness/test_endpoint.py
alberto f768fb32ef
fix(streaming)!: send the include_usage chunk with an empty choices array (#47109)
* fix: Send the include_usage chunk with an empty choices array

Signed-off-by: Alberto Perdomo <aperdomo@redhat.com>

* test: Skip chunks with no choices when reading streams

Signed-off-by: Alberto Perdomo <aperdomo@redhat.com>

* fix: Strip usage from plain-iterator async stream chunks

Signed-off-by: Alberto Perdomo <aperdomo@redhat.com>

* test: Expect empty choices on the usage chunk in the gemma tracing test

Signed-off-by: Alberto Perdomo <aperdomo@redhat.com>

* fix(streaming): send the empty-choices usage chunk only to callers that asked for include_usage

The usage chunk now has choices=[] only when the caller set
stream_options.include_usage itself. When the OpenAI handler, the proxy's
always_include_stream_usage setting, or the harness added include_usage on the
caller's behalf, the chunk keeps its legacy single empty-delta choice, so
clients that read choices[0] on every chunk keep working.

The proxy and harness mark their injection with the private
_litellm_stream_usage_injected flag, and the proxy scrubs a client-sent copy.
Plain-iterator streams keep usage on their chunk when usage was not requested,
and the LLM Shield trailing flush ignores chunks with no choices.

* fix(proxy): mark the default stream usage injection so hooks keep the legacy usage chunk shape

The proxy's default strip-mode injection adds include_usage for clients that did not ask and drops the usage chunk before it reaches them, but streaming iterator hooks still saw it with choices=[]. Marking that injection with _litellm_stream_usage_injected keeps the old single empty choice for them.

* fix(router): treat deployment and router-default stream_options as injected, and drop the client flag on the queue route

* refactor(router): move the stream usage injection check into router_utils

* fix(router): read stream_options from pydantic deployment params when marking injected usage

Update the mantle, openinference, and text-completion integration tests to expect the include_usage chunk with empty choices.

* fix(proxy): keep the stream usage injection marker out of stored request bodies

* test(streaming): cover who asked for stream usage on chat, queue, deployment and spend log paths

* test: type the queue flag test, pin the logging start time, and build the deployment usage config without mutation

---------

Signed-off-by: Alberto Perdomo <aperdomo@redhat.com>
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-21 02:26:05 +00:00

538 lines
20 KiB
Python

import json
import sys
from collections.abc import AsyncIterator
from typing import Any, Final
import httpx
import pytest
import litellm
from litellm.harness import endpoint as endpoint_module
from litellm.harness.endpoint import (
ModelEndpoint,
SSEUsageParser,
UsageTracker,
compute_cost,
usage_from_mapping,
usage_mapping_of_body,
)
from litellm.harness.errors import HarnessInstallFailed
from litellm.harness.context import GatewayTarget
from litellm.harness.types import Harness, Usage
from litellm.types.utils import ModelResponse, ModelResponseStream
REAL_COST_PER_TOKEN: Final = litellm.cost_per_token
CACHED_MODEL: Final = "claude-haiku-5-5"
GATEWAY = GatewayTarget(api_base="https://gw.example.com", api_key="sk-gateway-secret")
ANTHROPIC_SSE = (
b"event: message_start\n"
b'data: {"type":"message_start","message":{"usage":{"input_tokens":11,"output_tokens":1}}}\n\n'
b"event: content_block_delta\n"
b'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"hi"}}\n\n'
b"event: message_delta\n"
b'data: {"type":"message_delta","usage":{"output_tokens":7}}\n\n'
b"event: message_stop\n"
b'data: {"type":"message_stop"}\n\n'
)
CHAT_SSE = (
b'data: {"choices":[{"delta":{"content":"hi"}}]}\n\n'
b'data: {"choices":[],"usage":{"prompt_tokens":5,"completion_tokens":3}}\n\n'
b"data: [DONE]\n\n"
)
RESPONSES_SSE = (
b"event: response.output_text.delta\n"
b'data: {"type":"response.output_text.delta","delta":"hi"}\n\n'
b"event: response.completed\n"
b'data: {"type":"response.completed","response":{"usage":{"input_tokens":20,"output_tokens":4}}}\n\n'
)
class Recorder:
def __init__(self, response: httpx.Response) -> None:
self.response = response
self.requests: list[httpx.Request] = []
def __call__(self, request: httpx.Request) -> httpx.Response:
self.requests.append(request)
return self.response
def sse_response(body: bytes, headers: dict[str, str] | None = None) -> httpx.Response:
return httpx.Response(
200,
content=body,
headers={"content-type": "text/event-stream", **(headers or {})},
)
def gateway_endpoint(recorder: Recorder, **kwargs: Any) -> ModelEndpoint:
return ModelEndpoint(
Harness.CLAUDE_CODE,
kwargs.pop("model", "claude-sonnet"),
GATEWAY,
client=httpx.AsyncClient(transport=httpx.MockTransport(recorder)),
**kwargs,
)
def auth(ep: ModelEndpoint) -> dict[str, str]:
return {"authorization": f"Bearer {ep.token}"}
@pytest.fixture(autouse=True)
def no_real_cost(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
litellm,
"cost_per_token",
lambda model, prompt_tokens, completion_tokens, **cache_tokens: (
prompt_tokens * 0.001,
completion_tokens * 0.002,
),
)
async def test_rejects_bad_token_and_accepts_both_header_styles() -> None:
recorder = Recorder(httpx.Response(200, json={"usage": {}}))
async with gateway_endpoint(recorder) as ep:
assert ep.url == f"http://127.0.0.1:{ep.port}" and ep.port > 0
async with httpx.AsyncClient(base_url=ep.url) as client:
missing = await client.post("/v1/messages", json={})
wrong = await client.post(
"/v1/messages", json={}, headers={"x-api-key": "nope"}
)
bearer = await client.post("/v1/messages", json={}, headers=auth(ep))
api_key = await client.post(
"/messages", json={}, headers={"x-api-key": ep.token}
)
assert missing.status_code == 401
assert wrong.status_code == 401
assert "error" in wrong.json()
assert bearer.status_code == 200
assert api_key.status_code == 200
assert len(recorder.requests) == 2
async def test_gateway_rewrites_headers_and_model() -> None:
recorder = Recorder(
httpx.Response(
200,
json={"id": "m", "usage": {"input_tokens": 3, "output_tokens": 2}},
)
)
async with gateway_endpoint(recorder, metadata={"run": "abc"}) as ep:
async with httpx.AsyncClient(base_url=ep.url) as client:
resp = await client.post(
"/v1/messages",
json={"model": "whatever", "max_tokens": 5},
headers={
"x-api-key": ep.token,
"anthropic-version": "2023-06-01",
"anthropic-beta": "tools-2024",
},
)
assert resp.status_code == 200
sent = recorder.requests[0]
assert str(sent.url) == "https://gw.example.com/v1/messages"
assert sent.headers["authorization"] == "Bearer sk-gateway-secret"
assert "x-api-key" not in sent.headers
assert sent.headers["x-litellm-tags"] == "harness,claude_code"
assert json.loads(sent.headers["x-litellm-spend-logs-metadata"]) == {"run": "abc"}
assert sent.headers["anthropic-version"] == "2023-06-01"
assert sent.headers["anthropic-beta"] == "tools-2024"
assert json.loads(sent.content)["model"] == "claude-sonnet"
assert ep.usage.input_tokens == 3 and ep.usage.output_tokens == 2
assert ep.usage.calls == 1
@pytest.mark.parametrize(
"path,body,expected",
[
("/v1/messages", ANTHROPIC_SSE, (11, 7)),
("/v1/chat/completions", CHAT_SSE, (5, 3)),
("/responses", RESPONSES_SSE, (20, 4)),
],
)
async def test_gateway_sse_passthrough_and_usage(
path: str, body: bytes, expected: tuple[int, int]
) -> None:
recorder = Recorder(sse_response(body))
async with gateway_endpoint(recorder) as ep:
async with httpx.AsyncClient(base_url=ep.url) as client:
resp = await client.post(path, json={"stream": True}, headers=auth(ep))
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/event-stream")
assert resp.content == body
assert (ep.usage.input_tokens, ep.usage.output_tokens) == expected
expected_cost = expected[0] * 0.001 + expected[1] * 0.002
assert ep.usage.cost == pytest.approx(expected_cost)
async def test_cost_header_preferred_over_computed() -> None:
recorder = Recorder(
httpx.Response(
200,
json={"usage": {"prompt_tokens": 100, "completion_tokens": 100}},
headers={"x-litellm-response-cost": "0.42"},
)
)
async with gateway_endpoint(recorder) as ep:
async with httpx.AsyncClient(base_url=ep.url) as client:
await client.post("/v1/chat/completions", json={}, headers=auth(ep))
assert ep.usage.cost == pytest.approx(0.42)
assert ep.usage.snapshot() == Usage(input_tokens=100, output_tokens=100, calls=1)
async def test_gateway_error_status_preserved_and_not_counted() -> None:
recorder = Recorder(httpx.Response(429, json={"error": "rate limited"}))
async with gateway_endpoint(recorder) as ep:
async with httpx.AsyncClient(base_url=ep.url) as client:
resp = await client.post("/v1/chat/completions", json={}, headers=auth(ep))
assert resp.status_code == 429
assert ep.usage.calls == 0
async def test_models_route() -> None:
recorder = Recorder(httpx.Response(200))
async with gateway_endpoint(recorder) as ep:
async with httpx.AsyncClient(base_url=ep.url) as client:
with_model = await client.get("/v1/models", headers=auth(ep))
unauth = await client.get("/models")
assert unauth.status_code == 401
assert with_model.json()["object"] == "list"
assert [m["id"] for m in with_model.json()["data"]] == ["claude-sonnet"]
async with ModelEndpoint(Harness.CODEX, None, None) as ep:
async with httpx.AsyncClient(base_url=ep.url) as client:
empty = await client.get("/models", headers=auth(ep))
assert empty.json() == {"object": "list", "data": []}
async def test_sdk_chat_non_stream(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[dict[str, Any]] = []
async def fake_acompletion(**kwargs: Any) -> ModelResponse:
calls.append(kwargs)
response = ModelResponse(
model="gpt-x",
choices=[{"message": {"role": "assistant", "content": "hello"}}],
usage={"prompt_tokens": 9, "completion_tokens": 4, "total_tokens": 13},
)
response._hidden_params["response_cost"] = 0.5
return response
monkeypatch.setattr(litellm, "acompletion", fake_acompletion)
async with ModelEndpoint(
Harness.OPENCODE, "openai/gpt-x", None, api_key="sk-real", api_base="https://x"
) as ep:
async with httpx.AsyncClient(base_url=ep.url) as client:
resp = await client.post(
"/v1/chat/completions",
json={
"model": "ignored",
"messages": [{"role": "user", "content": "hi"}],
},
headers=auth(ep),
)
assert resp.status_code == 200
assert resp.json()["choices"][0]["message"]["content"] == "hello"
assert calls[0]["model"] == "openai/gpt-x"
assert calls[0]["api_key"] == "sk-real"
assert calls[0]["api_base"] == "https://x"
assert (ep.usage.input_tokens, ep.usage.output_tokens) == (9, 4)
assert ep.usage.cost == pytest.approx(0.5)
async def fake_chat_stream() -> AsyncIterator[ModelResponseStream]:
yield ModelResponseStream(choices=[{"delta": {"content": "he"}}])
yield ModelResponseStream(choices=[{"delta": {"content": "llo"}}])
final = ModelResponseStream(choices=[])
final.usage = litellm.Usage(prompt_tokens=6, completion_tokens=2, total_tokens=8)
yield final
async def test_sdk_chat_stream(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[dict[str, Any]] = []
async def fake_acompletion(**kwargs: Any) -> AsyncIterator[ModelResponseStream]:
calls.append(kwargs)
return fake_chat_stream()
monkeypatch.setattr(litellm, "acompletion", fake_acompletion)
async with ModelEndpoint(Harness.OPENCODE, "openai/gpt-x", None) as ep:
async with httpx.AsyncClient(base_url=ep.url) as client:
resp = await client.post(
"/chat/completions",
json={"messages": [], "stream": True},
headers=auth(ep),
)
assert resp.headers["content-type"].startswith("text/event-stream")
lines = [line for line in resp.text.split("\n") if line.startswith("data: ")]
assert lines[-1] == "data: [DONE]"
assert json.loads(lines[0][6:])["choices"][0]["delta"]["content"] == "he"
assert calls[0]["stream_options"] == {"include_usage": True}
assert calls[0]["_litellm_stream_usage_injected"] is True
assert (ep.usage.input_tokens, ep.usage.output_tokens) == (6, 2)
assert ep.usage.cost == pytest.approx(6 * 0.001 + 2 * 0.002)
async def fake_anthropic_stream() -> AsyncIterator[Any]:
yield {"type": "message_start", "message": {"usage": {"input_tokens": 4}}}
yield b'event: message_delta\ndata: {"type":"message_delta","usage":{"output_tokens":9}}\n\n'
async def test_sdk_messages_stream_handles_dicts_and_bytes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def fake_acreate(**kwargs: Any) -> AsyncIterator[Any]:
return fake_anthropic_stream()
monkeypatch.setattr(litellm.anthropic.messages, "acreate", fake_acreate)
async with ModelEndpoint(Harness.CLAUDE_CODE, "anthropic/claude", None) as ep:
async with httpx.AsyncClient(base_url=ep.url) as client:
resp = await client.post(
"/v1/messages", json={"stream": True}, headers=auth(ep)
)
assert "event: message_start" in resp.text
assert "event: message_delta" in resp.text
assert (ep.usage.input_tokens, ep.usage.output_tokens) == (4, 9)
async def chunks_of(chunks: list[dict[str, object]]) -> AsyncIterator[dict[str, object]]:
for chunk in chunks:
yield chunk
@pytest.mark.parametrize(
("path", "sdk", "chunks"),
[
(
"/v1/messages",
(litellm.anthropic.messages, "acreate"),
[
{
"type": "message_start",
"message": {
"usage": {
"input_tokens": 100,
"cache_read_input_tokens": 800,
"cache_creation_input_tokens": 100,
"output_tokens": 1,
}
},
},
{"type": "message_delta", "usage": {"output_tokens": 10}},
],
),
(
"/chat/completions",
(litellm, "acompletion"),
[
{
"choices": [],
"usage": {
"prompt_tokens": 1000,
"completion_tokens": 10,
"prompt_tokens_details": {"cached_tokens": 800, "cache_creation_tokens": 100},
},
}
],
),
(
"/v1/responses",
(litellm, "aresponses"),
[
{
"type": "response.completed",
"response": {
"usage": {
"input_tokens": 1000,
"output_tokens": 10,
"input_tokens_details": {"cached_tokens": 800, "cache_creation_tokens": 100},
}
},
}
],
),
(
"/chat/completions",
(litellm, "acompletion"),
[
{
"choices": [],
"usage": {
"prompt_tokens": 1000,
"completion_tokens": 10,
"prompt_tokens_details": {"cached_tokens": 800, "cache_write_tokens": 100},
},
}
],
),
(
"/v1/responses",
(litellm, "aresponses"),
[
{
"type": "response.completed",
"response": {
"usage": {
"input_tokens": 1000,
"output_tokens": 10,
"input_tokens_details": {"cached_tokens": 800, "cache_write_tokens": 100},
}
},
}
],
),
],
ids=["messages", "chat", "responses", "chat cache_write_tokens", "responses cache_write_tokens"],
)
async def test_sdk_stream_cost_prices_cached_prompt_tokens_like_litellm(
monkeypatch: pytest.MonkeyPatch, path: str, sdk: tuple[object, str], chunks: list[dict[str, object]]
) -> None:
async def fake_sdk(**kwargs: object) -> AsyncIterator[dict[str, object]]:
return chunks_of(chunks)
monkeypatch.setattr(litellm, "cost_per_token", REAL_COST_PER_TOKEN)
monkeypatch.setattr(*sdk, fake_sdk)
async with ModelEndpoint(Harness.PI, CACHED_MODEL, None) as ep:
async with httpx.AsyncClient(base_url=ep.url) as client:
await client.post(path, json={"stream": True}, headers=auth(ep))
cached_price: Final = sum(
REAL_COST_PER_TOKEN(
model=CACHED_MODEL,
prompt_tokens=1000,
completion_tokens=10,
usage_object=litellm.Usage(
prompt_tokens=1000,
completion_tokens=10,
total_tokens=1010,
prompt_tokens_details={"cached_tokens": 800, "cache_creation_tokens": 100},
),
)
)
assert cached_price < sum(REAL_COST_PER_TOKEN(model=CACHED_MODEL, prompt_tokens=1000, completion_tokens=10))
assert ep.usage.cost == pytest.approx(cached_price), (
"SDK mode priced cached prompt tokens at the full input price, overstating the cost of every cached turn"
)
ANTHROPIC_CACHED_USAGE: Final = {
"input_tokens": 100,
"cache_read_input_tokens": 800,
"cache_creation_input_tokens": 100,
"output_tokens": 10,
}
def anthropic_cached_sse() -> bytes:
start: Final = {"type": "message_start", "message": {"usage": {**ANTHROPIC_CACHED_USAGE, "output_tokens": 1}}}
delta: Final = {"type": "message_delta", "usage": {"output_tokens": 10}}
return (
f"event: message_start\ndata: {json.dumps(start)}\n\n"
f"event: message_delta\ndata: {json.dumps(delta)}\n\n"
).encode()
async def anthropic_cached_message(**kwargs: object) -> dict[str, object]:
return {"type": "message", "content": [], "usage": ANTHROPIC_CACHED_USAGE}
@pytest.mark.parametrize("route", ["gateway stream", "gateway json", "sdk json"])
async def test_claude_code_cost_counts_anthropic_cached_prompt_tokens(
monkeypatch: pytest.MonkeyPatch, route: str
) -> None:
monkeypatch.setattr(litellm, "cost_per_token", REAL_COST_PER_TOKEN)
monkeypatch.setattr(litellm.anthropic.messages, "acreate", anthropic_cached_message)
upstream: Final = (
sse_response(anthropic_cached_sse())
if route == "gateway stream"
else httpx.Response(200, json={"usage": ANTHROPIC_CACHED_USAGE})
)
endpoint: Final = (
ModelEndpoint(Harness.CLAUDE_CODE, CACHED_MODEL, None)
if route == "sdk json"
else gateway_endpoint(Recorder(upstream), model=CACHED_MODEL)
)
async with endpoint as ep:
async with httpx.AsyncClient(base_url=ep.url) as client:
await client.post("/v1/messages", json={"stream": route == "gateway stream"}, headers=auth(ep))
cached_price: Final = sum(
REAL_COST_PER_TOKEN(
model=CACHED_MODEL,
prompt_tokens=1000,
completion_tokens=10,
cache_read_input_tokens=800,
cache_creation_input_tokens=100,
)
)
assert cached_price > sum(REAL_COST_PER_TOKEN(model=CACHED_MODEL, prompt_tokens=100, completion_tokens=10))
assert ep.usage.cost == pytest.approx(cached_price), (
"Anthropic counts cached prompt tokens outside input_tokens, so leaving them out understates the turn's cost"
)
async def test_sdk_error_is_sanitized(monkeypatch: pytest.MonkeyPatch) -> None:
async def failing(**kwargs: Any) -> Any:
raise litellm.RateLimitError(
message="too many requests for key sk-real",
llm_provider="openai",
model="gpt-x",
)
monkeypatch.setattr(litellm, "aresponses", failing)
async with ModelEndpoint(Harness.CODEX, "gpt-x", None, api_key="sk-real") as ep:
async with httpx.AsyncClient(base_url=ep.url) as client:
resp = await client.post("/v1/responses", json={}, headers=auth(ep))
assert resp.status_code == 429
assert "sk-real" not in resp.text
assert resp.json()["error"]["type"] == "RateLimitError"
async def test_missing_server_deps_raises_install_failed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def missing() -> Any:
raise HarnessInstallFailed(endpoint_module.MISSING_DEPS_MESSAGE)
monkeypatch.setattr(endpoint_module, "_load_server_deps", missing)
with pytest.raises(HarnessInstallFailed, match="pip install starlette uvicorn"):
async with ModelEndpoint(Harness.CODEX, None, None):
pass
def test_load_server_deps_maps_import_error(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setitem(sys.modules, "uvicorn", None)
with pytest.raises(HarnessInstallFailed, match="starlette and uvicorn"):
endpoint_module._load_server_deps()
def test_usage_helpers() -> None:
assert usage_from_mapping(usage_mapping_of_body({"usage": {"prompt_tokens": 1, "completion_tokens": 2}})) == (
1,
2,
)
assert usage_from_mapping(usage_mapping_of_body({"response": {"usage": {"input_tokens": 3}}})) == (3, 0)
assert usage_from_mapping(usage_mapping_of_body("nope")) == (0, 0)
parser = SSEUsageParser()
for i in range(0, len(ANTHROPIC_SSE), 7): # split across arbitrary chunk borders
parser.feed(ANTHROPIC_SSE[i : i + 7])
parser.close()
assert (parser.input_tokens, parser.output_tokens) == (11, 7)
tracker = UsageTracker()
tracker.add(1, 2, 0.1)
tracker.add(3, 4, 0.2)
assert tracker.snapshot() == Usage(input_tokens=4, output_tokens=6, calls=2)
assert tracker.cost == pytest.approx(0.3)
def test_compute_cost_never_raises(monkeypatch: pytest.MonkeyPatch) -> None:
def boom(**kwargs: Any) -> Any:
raise ValueError("unknown model")
monkeypatch.setattr(litellm, "cost_per_token", boom)
assert compute_cost("mystery", 10, 10) == 0.0
assert compute_cost(None, 10, 10) == 0.0