litellm/tests/unit/harness/test_endpoint.py
Fábio Antunes 0ca29c7d84
feat(harness): add pi harness for litellm.agent() (#45467)
* feat(harness): add pi harness for litellm.agent()

Adds Harness.PI, which drives the pi coding agent CLI (`pi --mode json`) once per turn
against the per-session model endpoint. The endpoint is declared as a `litellm`
openai-completions provider in the models.json of a LiteLLM-owned PI_CODING_AGENT_DIR, so
the user's own pi settings, auth, extensions and MCP servers are never read, and the token
travels only through an env var referenced as `$LITELLM_HARNESS_TOKEN`

pi exits 0 after a failed provider call, so a turn fails when the last assistant message
ends with stopReason error/aborted. Permission modes map to a `--tools` allowlist,
disable_tools to `--exclude-tools`, and sessions resume with `--session` from a persisted
session dir. build_instructions and turn_prompt move from the OpenCode config into the
shared harness utils so both configs use them

Unit tests run on JSONL recorded from pi 1.1.0

* test(harness): align pi harness tests with the other CLI harness suites

Mirror the OpenCode suite's layout, fakes and helpers, and add the cases every CLI harness
covers: turn before start, early close kills the process, persist failure, endpoint model
fallback, long JSONL lines, skills without SKILL.md and a missing endpoint. Add an
endpoint_requests.jsonl fixture recording what pi sends to the session endpoint, and
re-record the turn fixtures in one run so their ids agree

* fix(harness): parametrize harness e2e only over harnesses with a model group

harness_params() iterated every Harness member, so Harness.TOOL_LOOP, which has no entry in
BINARIES or DEFAULT_MODEL_GROUPS, raised KeyError and the whole suite failed to collect

* feat(harness): accept pi config and MCP servers through PiOptions.config

PiOptions gains `config`, like the other harness options. `mcpServers` is written to the
private agent dir's mcp.json and every other key to its settings.json. Keys that would reroute
model calls, bypass permissions=, or load code as the host user (defaultProvider, defaultModel,
defaultTools, defaultProjectTrust, extensions, packages, ...) raise OptionsMismatch

A plain-name `--tools` allowlist drops MCP tools in pi 1.1.0, so when servers are configured
the allowlist also gets `mcp__*` plus the tool each exposure is reached through (`codemode` by
default, `tool_search` for `deferred`). disable_tools still excludes individual MCP tools

* refactor(harness): give harness options immutable mapping defaults

`field(default_factory=dict)` left every config/env default typed as
`dict[Unknown, Unknown]`, which basedpyright reports as
reportUnknownVariableType under strict mode: 9 errors across the five
options classes, two of them added by PiOptions. A shared module-level
`MappingProxyType({})` default is accepted by frozen dataclasses, matches
the no-mutation rule the rest of the harness follows, and drops the file
to zero errors so the rule shrinks against the merge base instead of
growing.

* fix(harness): reject managed env keys and a missing model in the pi harness

PiOptions.env could name PI_CODING_AGENT_DIR or LITELLM_HARNESS_TOKEN and
the value was dropped in silence, because transform_session_setup merges
the managed keys last. The ordering stays as defense in depth, but
validate_environment now rejects those keys up front the way the Claude
Code config does, so a bad option is an error rather than a setting that
appears to apply and does not.

transform_turn_request also stringified the model it resolved, so a call
with neither model= nor an endpoint model would have put the literal
"None" on pi's --model. Both call sites now go through session_model(),
which raises the same ValueError transform_session_setup already raised.

* test(harness): escape the regex dot in the pi skill manifest match

* fix(harness): keep harness options importable on Python 3.11

Python 3.11 rejects an unhashable dataclass default, and MappingProxyType
only became hashable in 3.12, so the shared MappingProxyType({}) defaults
made importing litellm.harness.options raise ValueError and broke every
harness on 3.11. The defaults now come from typed default factories that
still return read-only mappings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(harness): address review feedback on the pi harness tests

Drop the endpoint-request fixture test, which only read a recorded file and
could not fail when PiHarnessConfig changed, along with its now unused
fixture. Remove the section-divider comments and a comment that restated
MANAGED_ENV_KEYS, and type the test helpers and fakes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(harness): cover the last uncovered pi harness lines

Assert that a tool_execution_end event without a result yields an empty
ToolResult output, and that a PiOptions.config value JSON cannot encode
raises TypeError. The Python 3.11 default check now asserts on the list of
unhashable defaults instead of relying on hash() raising, which the
test-quality gate flagged as an assertion-free test

* test(harness): annotate harness option tests and stop rebinding helper params

Mark the option test locals Final and check read-only defaults through the
MutableMapping interface instead of writing into them. In the pi suite,
type the parametrized test arguments, and have started() and parse_all()
bind new names instead of reassigning their sandbox and state parameters

* test(harness): test harness option defaults by constructing the options

Replace the field-introspection check with one that builds each options
class twice and asserts every mapping default is empty, read-only and not
shared between instances. Sharing one MappingProxyType across instances is
what broke class creation on Python 3.11, and that sharing is observable on
every Python version, so the test fails on the old code without inspecting
dataclass declarations

* test(harness): stop mutating test helper inputs and assert whole values

make_ctx took model and endpoint out of **kwargs with pop, which mutated
its own parameter; they are explicit parameters now, with a frozen default
endpoint. assistant_end builds its message in one expression instead of
adding errorMessage afterwards. Two docstrings that explained an assertion
became that assertion's message, and the options test compares whole
values so a failure names the offending fields

* ci(unit): run the pi harness unit tests in the llms-h-to-z shard

main now lists every tests/unit/llms provider directory per shard, so the new
tests/unit/llms/pi directory was claimed by no shard and assert_ci_coverage
failed on both the shard and the test-file checks

* test(harness): mark pi test locals Final and freeze the test output model

Every single-assignment local and module constant in the pi suite is now
Final. The name-mapping loop became one comprehension compared as a whole
list, test_start_persists_sessions_dir takes the sandbox by index instead of
binding _ twice, and the Answer output model is frozen like every other
pydantic model

* fix(harness): send PiOptions.thinking to the gateway as reasoning_effort

pi clamps --thinking to "off" for a model that does not declare reasoning,
and the generated models.json only declared {"id": model}, so every
thinking level was dropped and the gateway never saw reasoning_effort.
xhigh and max are also only offered when thinkingLevelMap names them. The
model now declares reasoning plus that map whenever a level other than
"off" is requested, and nothing when thinking is unset, so pi does not send
its default effort on every call

The pi tests also drop **kwargs from make_ctx and started in favour of
explicit parameters, and annotate their helpers and the fake sandbox with
read-only types; the fake records calls by rebinding tuples instead of
appending to lists

* docs(harness): list pi among the harnesses in the module and agent() docstrings

* fix(harness): keep pi calls provider-agnostic and harness option defaults copyable

Declare supportsStore false so pi stops sending store, take contextWindow and maxTokens from the model map, treat a length stop or a silent exit as an error, and give every options class plain dict defaults again so deepcopy, pickle and asdict work

* fix(harness): look up pi gateway groups as litellm_proxy and ask pi for cache_control marks

A gateway session's model is the bare group name, so the model map lookup
now passes custom_llm_provider=litellm_proxy, the way the SDK prices it.
That keeps an unmapped group from printing get_llm_provider's provider
list banner into the caller's stdout.

pi only adds Anthropic cache_control marks for OpenRouter Anthropic models,
so every Claude call through the harness missed the prompt cache. Models
the map says support prompt caching now declare cacheControlFormat
anthropic, which LiteLLM translates for each provider.

* fix(harness): cap pi's maxTokens at its own default and fork sessions saved in another directory

pi sends maxTokens as max_completion_tokens on every call, and the gateway reserves that many
tokens against the key's TPM limit, so passing the model's full output cap (128000 for
claude-haiku-5-5) made keys with lower limits answer 429. maxTokens is now the lower of the
model's cap and pi's own 16384 default.

agent_resume() into another sandbox directory stopped at pi's interactive fork prompt. The
native session id now carries the directory pi saved the session in, and a resume from a
different directory passes --fork, so the conversation continues in the new sandbox.

* fix(harness): leave pi room for the thinking budget and price cached prompt tokens

pi's maxTokens now adds LiteLLM's thinking budget for the requested level to pi's 16384 default, within the model's output cap, so Anthropic budget thinking at max no longer answers 400. Harness turns now pass cache read and write tokens to cost_per_token, so cached prompts are priced at their cache rates on every harness.

* test(harness): cover cached prompt cost on the gateway and non-stream routes

* test(harness): mark the cost test constants Final

* test(harness): type the cached cost test helpers without Any

* fix(harness): price OpenAI cache write tokens in harness cost

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2026-10-20 09:01:40 +00:00

537 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 (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