litellm/tests/unit/llms/opencode/chat/test_transformation.py
Kerry Lu 9b7f2c8dea
fix(opencode): bill OpenCode Go requests from the reported list_cost (#48254)
* fix(opencode): bill OpenCode Go requests from the reported list_cost

OpenCode returns usage.opencode.list_cost on every response. Read it into the provider-reported cost the cost calculator checks before the price map, for chat and Responses, streamed and not. Keep the static rows as the fallback, and stop the generic usage.cost path from billing Go requests at 0 (usage.cost is what hit the prepaid balance, 0 inside the plan).

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(core): resolve reported cost breakdowns without recursion

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(opencode): cover reported list_cost spend through the proxy

Replaces the recursive _reported_cost_value with a flat _cost_number helper
so the code-quality gate passes, and adds an integration test that runs the
proxy against a scripted OpenCode upstream for a non-stream and a stream
chat completion, asserting the response cost header and the spend log row

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(opencode): bill native /v1/messages from the reported list_cost

The native Anthropic Messages route priced OpenCode Go bodies from the price
rows twice over: the non-stream body is a TypedDict the response-cost header
path priced as-is, and the stream assembled its ModelResponse with the rows
cost already stamped. Both now read usage.opencode.list_cost, the non-stream
body through billable_result and the stream from the final message_delta.

The integration test now covers chat, responses and messages, stream and
non-stream, through the proxy against a scripted OpenCode upstream

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(opencode): keep the stream cost stamp inside the lint gate

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(opencode): read list_cost off dict chunks and trust it only from OpenCode on /v1/messages

Stream builders receive chunks both as mappings and as ModelResponseStream
objects, so the list_cost scan now reads usage through chunk_usage. The
native Messages route only trusts a reported cost from opencode_go, every
other upstream keeps the price map, which also keeps a zero usage.cost from
being billed as free. Served bodies without stop_sequence still price, and
the new code stays inside the basedpyright gate

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-24 16:44:29 -07:00

582 lines
24 KiB
Python

import asyncio
import json
from collections.abc import Mapping
from datetime import datetime
from typing import Final
import httpx
import pytest
import respx
from pydantic import TypeAdapter
import litellm
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.core_helpers import RESPONSE_COST_HEADER
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
from litellm.llms.opencode.common_utils import go_headers
@pytest.fixture
def go_catalog(monkeypatch: pytest.MonkeyPatch) -> None:
for route in ("chat/completions", "messages", "responses", "gemini"):
monkeypatch.setitem(
litellm.model_cost,
f"opencode_go/fixture-{route.split('/')[0]}",
{
"litellm_provider": "opencode_go",
"mode": "responses" if route == "responses" else "chat",
"supported_endpoints": ["/v1beta/models" if route == "gemini" else f"/v1/{route}"],
"max_output_tokens": 128000,
"max_input_tokens": 1000000,
"input_cost_per_token": 0.000002,
"output_cost_per_token": 0.000005,
"supports_function_calling": True,
},
)
monkeypatch.delenv("OPENCODE_GO_API_BASE", raising=False)
def upstream_reply(route: str, model: str) -> Mapping[str, object]:
if route == "gemini":
return {
"candidates": [{"content": {"role": "model", "parts": [{"text": "hello"}]}, "finishReason": "STOP"}],
"usageMetadata": {"promptTokenCount": 10, "candidatesTokenCount": 5, "totalTokenCount": 15},
}
if route == "messages":
return {
"id": "msg_test",
"type": "message",
"role": "assistant",
"model": model,
"content": [{"type": "text", "text": "hello"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 10, "output_tokens": 5},
}
if route == "responses":
return {
"id": "resp_test",
"object": "response",
"created_at": 1700000000,
"model": model,
"status": "completed",
"output": [
{
"type": "message",
"id": "msg_test",
"status": "completed",
"role": "assistant",
"content": [{"type": "output_text", "text": "hello", "annotations": []}],
}
],
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
}
return {
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 1700000000,
"model": model,
"choices": [{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": "hello"}}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
}
@pytest.mark.asyncio
@pytest.mark.parametrize("upstream", ["chat/completions", "messages", "responses", "gemini"])
@pytest.mark.parametrize("ingress", ["chat", "responses", "messages"])
async def test_all_routes_use_model_protocol_and_preserve_session(
go_catalog: None, upstream: str, ingress: str
) -> None:
model: Final = f"fixture-{upstream.split('/')[0]}"
def respond(request: httpx.Request) -> httpx.Response:
family: Final = "anthropic" if upstream == "messages" else "openai"
expected_path: Final = (
f"/inference/go/google/v1beta/models/{model}:generateContent"
if upstream == "gemini"
else f"/inference/go/{family}/v1/{upstream}"
)
assert request.url.path == expected_path
assert request.headers["authorization"] == "Bearer fake-go-key"
assert request.headers["x-opencode-session"] == "conversation-1"
body: Final = json.loads(request.content)
if upstream == "gemini":
assert body["contents"][0]["parts"][0]["text"] == "hello"
else:
assert body["model"] == model
assert "input" in body if upstream == "responses" else "messages" in body
if upstream == "messages":
assert body["max_tokens"] == 128000
return httpx.Response(200, json=upstream_reply(upstream, model))
with respx.mock(using="httpx") as transport:
transport.post().mock(side_effect=respond)
kwargs: Final = {
"model": f"opencode_go/{model}",
"api_key": "fake-go-key",
"litellm_session_id": "conversation-1",
}
if ingress == "chat":
response: Final = await litellm.acompletion(messages=[{"role": "user", "content": "hello"}], **kwargs)
assert response.choices[0].message.content == "hello"
assert response.usage.total_tokens == 15
elif ingress == "responses":
response_api: Final = await litellm.aresponses(input="hello", **kwargs)
assert response_api.output[0].content[0].text == "hello"
assert response_api.usage.total_tokens == 15
else:
message: Final = await litellm.anthropic.messages.acreate(
messages=[{"role": "user", "content": "hello"}],
max_tokens=128000,
**kwargs,
)
assert message["content"][0]["text"] == "hello"
assert message["usage"]["output_tokens"] == 5
def test_go_never_falls_back_to_other_provider_credentials(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("OPENCODE_API_KEY", raising=False)
monkeypatch.delenv("OPENCODE_GO_API_KEY", raising=False)
monkeypatch.setenv("OPENAI_API_KEY", "fake-openai-key")
monkeypatch.setenv("ANTHROPIC_API_KEY", "fake-anthropic-key")
with pytest.raises(litellm.AuthenticationError, match="OPENCODE_API_KEY"):
go_headers({}, {}, None, "fixture-model")
@pytest.mark.parametrize("input_tokens,expected", [(100, 0.00025), (101, 0.000504)])
def test_go_uses_registered_context_tiers_not_underlying_provider_prices(
monkeypatch: pytest.MonkeyPatch,
input_tokens: int,
expected: float,
) -> None:
monkeypatch.setitem(
litellm.model_cost,
"opencode_go/fixture-priced",
{
"litellm_provider": "opencode_go",
"mode": "chat",
"input_cost_per_token": 0.000002,
"output_cost_per_token": 0.000005,
"tiered_pricing": [
{"range": [0, 100], "input_cost_per_token": 0.000002, "output_cost_per_token": 0.000005},
{"range": [100, 1000], "input_cost_per_token": 0.000004, "output_cost_per_token": 0.000010},
],
},
)
cost: Final = litellm.cost_per_token(
model="opencode_go/fixture-priced",
prompt_tokens=input_tokens,
completion_tokens=10,
)
assert sum(cost) == pytest.approx(expected)
@pytest.mark.parametrize("suffix", ["", "/v1", "/v1/messages", "/v1/messages/"])
def test_messages_custom_base_does_not_duplicate_version_or_route(go_catalog: None, suffix: str) -> None:
from litellm.llms.opencode.messages.transformation import OpenCodeGoMessagesConfig
assert (
OpenCodeGoMessagesConfig().get_complete_url(
f"https://gateway.example/go{suffix}",
None,
"fixture-messages",
{},
{},
)
== "https://gateway.example/go/v1/messages"
)
@pytest.mark.parametrize("base", [None, "https://gateway.example/go/v1"])
def test_discovery_returns_qualified_ids_and_accepts_response_metadata(base: str | None) -> None:
url: Final = "https://gateway.example/go/v1/models" if base else "https://opencode.ai/inference/go/v1/models"
with respx.mock(using="httpx") as transport:
transport.get(url).respond(
json={
"object": "list",
"data": [
{"id": "fixture-chat", "object": "model"},
{"id": "fixture-messages", "object": "model"},
],
}
)
assert litellm.get_valid_models(
check_provider_endpoint=True,
custom_llm_provider="opencode_go",
api_key="fake-go-key",
api_base=base,
) == [
"opencode_go/fixture-chat",
"opencode_go/fixture-messages",
]
@pytest.mark.parametrize("explicit_base", [None, "https://explicit-gateway.example/go/v1"])
def test_discovery_honors_environment_base_and_explicit_override(
monkeypatch: pytest.MonkeyPatch, explicit_base: str | None
) -> None:
from litellm.llms.opencode.chat.transformation import OpenCodeGoChatConfig
monkeypatch.setenv("OPENCODE_GO_API_BASE", "https://env-gateway.example/go/v1/")
expected_base: Final = explicit_base or "https://env-gateway.example/go/v1"
with respx.mock(using="httpx") as transport:
transport.get(f"{expected_base}/models").respond(json={"data": [{"id": "gateway-only-model"}]})
assert OpenCodeGoChatConfig().get_models(api_key="fake-go-key", api_base=explicit_base) == [
"opencode_go/gateway-only-model"
]
@pytest.mark.asyncio
@pytest.mark.parametrize("ingress", ["chat", "responses", "messages"])
async def test_gemini_streams_text_and_usage_through_all_routes(go_catalog: None, ingress: str) -> None:
def respond(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/inference/go/google/v1beta/models/fixture-gemini:streamGenerateContent"
assert request.url.query == b"alt=sse"
assert "stream" not in json.loads(request.content)
return httpx.Response(
200,
headers={"content-type": "text/event-stream"},
text="data: " + json.dumps(upstream_reply("gemini", "fixture-gemini")) + "\n\n",
)
with respx.mock(using="httpx") as transport:
transport.post().mock(side_effect=respond)
kwargs: Final = {"model": "opencode_go/fixture-gemini", "api_key": "fake-go-key", "stream": True}
if ingress == "chat":
stream: Final = await litellm.acompletion(
messages=[{"role": "user", "content": "hello"}], stream_options={"include_usage": True}, **kwargs
)
chunks: Final = [chunk async for chunk in stream]
assert "".join(chunk.choices[0].delta.content or "" for chunk in chunks if chunk.choices) == "hello"
assert chunks[-1].usage.total_tokens == 15
elif ingress == "responses":
responses_stream: Final = await litellm.aresponses(input="hello", **kwargs)
events: Final = [event async for event in responses_stream]
assert "".join(event.delta for event in events if event.type == "response.output_text.delta") == "hello"
assert events[-1].response.usage.total_tokens == 15
else:
messages_stream: Final = await litellm.anthropic.messages.acreate(
messages=[{"role": "user", "content": "hello"}], max_tokens=128000, **kwargs
)
wire_events: Final = b"".join([event async for event in messages_stream]).decode()
message_events: Final = [
json.loads(line[6:]) for line in wire_events.splitlines() if line.startswith("data: ")
]
assert (
"".join(
event["delta"].get("text", "") for event in message_events if event["type"] == "content_block_delta"
)
== "hello"
)
assert (
next(event["usage"]["output_tokens"] for event in message_events if event["type"] == "message_delta")
== 5
)
@pytest.mark.asyncio
@pytest.mark.parametrize("factory_path", [False, True])
@pytest.mark.parametrize("async_stream", [False, True])
async def test_gemini_streams_with_dispatcher_and_factory_configs(
go_catalog: None, factory_path: bool, async_stream: bool
) -> None:
messages: Final = [{"role": "user", "content": "hello"}]
logging_obj: Final = Logging(
model="fixture-gemini",
messages=messages,
stream=True,
call_type="acompletion" if async_stream else "completion",
start_time=datetime(2026, 1, 1),
litellm_call_id="go-stream-regression",
function_id="go-stream-regression",
)
logging_obj.update_environment_variables(
model="fixture-gemini",
user="",
optional_params={"stream_options": {"include_usage": True}},
litellm_params={},
custom_llm_provider="opencode_go",
stream_options={"include_usage": True},
)
with respx.mock(using="httpx") as transport:
transport.post(
"https://opencode.ai/inference/go/google/v1beta/models/fixture-gemini:streamGenerateContent?alt=sse"
).respond(
headers={"content-type": "text/event-stream"},
text="data: " + json.dumps(upstream_reply("gemini", "fixture-gemini")) + "\n\n",
)
if factory_path:
result: Final = BaseLLMHTTPHandler().completion(
model="fixture-gemini",
messages=messages,
api_base=None,
custom_llm_provider="opencode_go",
model_response=litellm.ModelResponse(),
encoding=None,
logging_obj=logging_obj,
optional_params={"stream_options": {"include_usage": True}},
timeout=30,
litellm_params={},
acompletion=async_stream,
stream=True,
api_key="fake-go-key",
)
else:
completion: Final = litellm.acompletion if async_stream else litellm.completion
result: Final = completion(
model="opencode_go/fixture-gemini",
messages=messages,
stream=True,
stream_options={"include_usage": True},
api_key="fake-go-key",
)
if async_stream:
stream: Final = await result
chunks: Final = [chunk async for chunk in stream]
else:
chunks: Final = tuple(result)
assert "".join(chunk.choices[0].delta.content or "" for chunk in chunks if chunk.choices) == "hello"
assert chunks[-1].usage.total_tokens == 15
@pytest.mark.parametrize("suffix", ["", "/v1beta", "/v1beta/models", "/v1beta/models/"])
def test_gemini_custom_base_normalizes_version_and_models(go_catalog: None, suffix: str) -> None:
with respx.mock(using="httpx") as transport:
transport.post("https://gateway.example/google/v1beta/models/fixture-gemini:generateContent").respond(
json=upstream_reply("gemini", "fixture-gemini")
)
response: Final = litellm.completion(
model="opencode_go/fixture-gemini",
api_key="fake-go-key",
api_base=f"https://gateway.example/google{suffix}",
messages=[{"role": "user", "content": "hello"}],
)
assert response.choices[0].message.content == "hello"
def test_gemini_sync_stream_preserves_tool_calls_and_usage(go_catalog: None) -> None:
chunk: Final = {
"candidates": [
{
"content": {
"role": "model",
"parts": [
{
"functionCall": {"name": "get_weather", "args": {"city": "Paris"}},
"thoughtSignature": "synthetic-signature",
}
],
},
"finishReason": "STOP",
}
],
"usageMetadata": {"promptTokenCount": 10, "candidatesTokenCount": 5, "totalTokenCount": 15},
}
with respx.mock(using="httpx") as transport:
transport.post(
"https://opencode.ai/inference/go/google/v1beta/models/fixture-gemini:streamGenerateContent?alt=sse"
).respond(headers={"content-type": "text/event-stream"}, text="data: " + json.dumps(chunk) + "\n\n")
stream: Final = litellm.completion(
model="opencode_go/fixture-gemini",
api_key="fake-go-key",
stream=True,
messages=[{"role": "user", "content": "Weather in Paris?"}],
tools=[
{
"type": "function",
"function": {
"name": "get_weather",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
}
],
stream_options={"include_usage": True},
)
chunks: Final = tuple(stream)
tool: Final = next(
c.choices[0].delta.tool_calls[0] for c in chunks if c.choices and c.choices[0].delta.tool_calls
)
assert tool.function.name == "get_weather"
assert json.loads(tool.function.arguments) == {"city": "Paris"}
assert any(c.choices[0].finish_reason == "tool_calls" for c in chunks if c.choices)
assert chunks[-1].usage.total_tokens == 15
GO_REPORTED_USAGE: Final = {
"cost": 0,
"opencode": {"billing_source": "go", "list_cost": 0.00412, "allowance": 0.00412},
}
ROW_PRICE_FOR_FIXTURE_USAGE: Final = 10 * 0.000002 + 5 * 0.000005
def go_priced_reply(route: str, model: str, usage_extra: Mapping[str, object]) -> Mapping[str, object]:
reply: Final = upstream_reply(route, model)
key: Final = "usageMetadata" if route == "gemini" else "usage"
usage: Final = TypeAdapter(Mapping[str, object]).validate_python(reply[key])
return {**reply, key: {**usage, **usage_extra}}
class CostRecorder(CustomLogger):
def __init__(self) -> None:
super().__init__()
self.costs: list[float | None] = [] # mutable-ok: test probe accumulates logged costs
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time) -> None: # noqa: ANN001
self.costs.append(kwargs.get("response_cost"))
async def logged_cost(recorder: CostRecorder) -> float | None:
for _ in range(50):
if recorder.costs:
return recorder.costs[-1]
await asyncio.sleep(0.05)
raise AssertionError("success callback never ran")
@pytest.fixture
def cost_recorder(monkeypatch: pytest.MonkeyPatch) -> CostRecorder:
recorder: Final = CostRecorder()
monkeypatch.setattr(litellm, "callbacks", [recorder])
return recorder
@pytest.mark.asyncio
@pytest.mark.parametrize("upstream", ["chat/completions", "messages", "responses", "gemini"])
@pytest.mark.parametrize("ingress", ["chat", "responses"])
async def test_reported_list_cost_is_billed_instead_of_rows(
go_catalog: None, cost_recorder: CostRecorder, upstream: str, ingress: str
) -> None:
model: Final = f"fixture-{upstream.split('/')[0]}"
with respx.mock(using="httpx") as transport:
transport.post().mock(
return_value=httpx.Response(200, json=go_priced_reply(upstream, model, GO_REPORTED_USAGE))
)
kwargs: Final = {"model": f"opencode_go/{model}", "api_key": "fake-go-key"}
if ingress == "chat":
response: Final = await litellm.acompletion(messages=[{"role": "user", "content": "hello"}], **kwargs)
else:
response = await litellm.aresponses(input="hello", **kwargs)
assert response._hidden_params["additional_headers"][RESPONSE_COST_HEADER] == pytest.approx(0.00412)
assert await logged_cost(cost_recorder) == pytest.approx(0.00412)
@pytest.mark.asyncio
@pytest.mark.parametrize("usage_extra", [{}, {"cost": 0}, {"opencode": {"billing_source": "go"}}])
async def test_rows_price_the_request_when_no_list_cost_is_reported(
go_catalog: None, cost_recorder: CostRecorder, usage_extra: Mapping[str, object]
) -> None:
with respx.mock(using="httpx") as transport:
transport.post().mock(
return_value=httpx.Response(200, json=go_priced_reply("chat/completions", "fixture-chat", usage_extra))
)
response: Final = await litellm.acompletion(
model="opencode_go/fixture-chat", api_key="fake-go-key", messages=[{"role": "user", "content": "hello"}]
)
assert RESPONSE_COST_HEADER not in response._hidden_params.get("additional_headers", {})
assert await logged_cost(cost_recorder) == pytest.approx(ROW_PRICE_FOR_FIXTURE_USAGE)
def sse(*events: Mapping[str, object]) -> str:
return "".join(f"data: {json.dumps(event)}\n\n" for event in events) + "data: [DONE]\n\n"
@pytest.mark.asyncio
async def test_chat_stream_bills_the_final_usage_chunks_list_cost(
go_catalog: None, cost_recorder: CostRecorder
) -> None:
chunk: Final = {
"id": "chatcmpl-test",
"object": "chat.completion.chunk",
"created": 1700000000,
"model": "fixture-chat",
}
with respx.mock(using="httpx") as transport:
transport.post().mock(
return_value=httpx.Response(
200,
headers={"content-type": "text/event-stream"},
text=sse(
{**chunk, "choices": [{"index": 0, "delta": {"role": "assistant", "content": "hello"}}]},
{**chunk, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]},
{
**chunk,
"choices": [],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15, **GO_REPORTED_USAGE},
},
),
)
)
stream: Final = await litellm.acompletion(
model="opencode_go/fixture-chat",
api_key="fake-go-key",
messages=[{"role": "user", "content": "hello"}],
stream=True,
stream_options={"include_usage": True},
)
chunks: Final = [piece async for piece in stream]
assert "".join(piece.choices[0].delta.content or "" for piece in chunks if piece.choices) == "hello"
assert await logged_cost(cost_recorder) == pytest.approx(0.00412)
@pytest.mark.asyncio
async def test_responses_stream_bills_the_completed_events_list_cost(
go_catalog: None, cost_recorder: CostRecorder
) -> None:
completed: Final = go_priced_reply("responses", "fixture-responses", GO_REPORTED_USAGE)
with respx.mock(using="httpx") as transport:
transport.post().mock(
return_value=httpx.Response(
200,
headers={"content-type": "text/event-stream"},
text=sse(
{
"type": "response.output_text.delta",
"delta": "hello",
"item_id": "msg_test",
"output_index": 0,
"content_index": 0,
"sequence_number": 1,
},
{"type": "response.completed", "response": completed, "sequence_number": 2},
),
)
)
stream: Final = await litellm.aresponses(
model="opencode_go/fixture-responses", api_key="fake-go-key", input="hello", stream=True
)
events: Final = [event async for event in stream]
assert events[-1].type == "response.completed"
assert events[-1].response._hidden_params["additional_headers"][RESPONSE_COST_HEADER] == pytest.approx(0.00412)
assert await logged_cost(cost_recorder) == pytest.approx(0.00412)
def test_stream_chunk_builder_reads_list_cost_from_dict_chunks():
from litellm.litellm_core_utils.litellm_logging import Logging
logging_obj = Logging(
model="opencode_go/glm-5.3",
messages=[{"role": "user", "content": "hi"}],
stream=True,
call_type="completion",
start_time=datetime.now(),
litellm_call_id="dict-chunks",
function_id="1",
)
logging_obj.model_call_details["custom_llm_provider"] = "opencode_go"
chunk = {
"id": "chatcmpl-go",
"object": "chat.completion.chunk",
"created": 1,
"model": "glm-5.3",
"choices": [{"index": 0, "delta": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
}
final_usage = {**chunk, "choices": [], "usage": dict(GO_REPORTED_USAGE)}
assembled = litellm.stream_chunk_builder(chunks=[chunk, final_usage], logging_obj=logging_obj)
assert assembled is not None
assert assembled._hidden_params["response_cost"] == GO_REPORTED_USAGE["opencode"]["list_cost"]