mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-29 06:33:28 +00:00
* fix(proxy)!: count prompt-only token_counter requests with the provider when call_endpoint is set (#46839) * fix(proxy): count prompt-only token_counter requests with the provider when call_endpoint is set * fix(proxy): keep the local count for a prompt-only token_counter request when the provider count fails * fix(proxy): log a prompt-only token count fallback as a one-line warning * fix(proxy): send a prompt-only token_counter request to the provider only on Gemini and Vertex AI * fix(proxy): count a token_counter prompt sent next to an empty messages or contents list * fix(proxy): count a prompt next to messages on the local token count instead of answering 500 * fix(proxy): answer 400 for a token count prompt next to a conversation unless drop_params is set With drop_params the conversation is counted without the prompt. On the local count, a prompt sent with system or tools is counted as a user message with them, like the messages form. * test(proxy): count tools with a prompt in the token counter fallback test * test(proxy): cover prompt counting on /utils/token_counter in the Gemini count wire tests * test(proxy): name the token counter fallback test by what it checks (cherry picked from commit2dc6aafa6b) * fix(count_tokens): apply context_management edits in the Gemini /v1/messages/count_tokens count (#46843) * fix(count_tokens): apply context_management edits in the Gemini /v1/messages/count_tokens count /v1/messages runs clear_tool_uses context edits before it sends an Anthropic body to a Gemini or Vertex AI deployment, but /v1/messages/count_tokens counted the body unedited, so a request with older tool results cleared counted about 2.8x what inference bills. The count now runs the same history and context-edit step inference runs, honors additional_drop_params on the deployment, and skips compact edits so a count never makes a paid summary call. * fix(count_tokens): match the Gemini count to inference for compact edits and drop opt-outs Run compact edits in the count without the summary call so trigger checks and history slicing match /v1/messages, honor a request-level additional_drop_params, keep the full body when any deployment in the model group drops context_management, and answer 400 naming a block Gemini cannot count when an edit trigger fails on it * fix(count_tokens): count the unedited body when an edit fails and honor deployment ids in the drop check * fix(count_tokens): log a failed edit in the count at debug, since the send already logs it * fix(count_tokens): resolve a deployment id before wildcard routes in the drop check, as the router does * fix(count_tokens): honor the router-wide additional_drop_params opt-out in the drop check The router merges default_litellm_params between the deployment and the request, so a router-wide context_management opt-out makes /v1/messages send the full history. The count now reads it in the same order. Adds the audit's wire tests for the count's context edits. * fix(count_tokens): read the opt-out of a deployment the router serves by its litellm model name * fix(count_tokens): read the opt-out and drop_params from the deployments the router and completion_model send to * fix(messages): drop an invalid context_management spec under drop_params so the send matches the count * fix(count_tokens): give Claude Code and Codex counts the drop_params default /v1/messages gives them The proxy turns on drop_params for Claude Code and Codex user agents on /v1/messages. The count now applies the same default, so an invalid context_management spec drops on both endpoints instead of the count answering 400 while the send answers 200. The wire test also covers default_fallbacks, model_group_alias, and completion_model routes. * test(count_tokens): cover a coding agent that turns drop_params off and stop two count tests running as Claude Code (cherry picked from commitf8217d2a03) * fix(proxy)!: answer 400 for malformed tools on count_tokens instead of crashing (#46583) * fix(proxy): answer 400 for malformed tools on count_tokens instead of crashing A non-list tools value crashed the vector store lookup during auth and the built-in tool cost lookup in the failure logging path, so the proxy answered an unhandled 500 and closed the keep-alive connection. count_tokens now answers a 400 invalid_request_error for a malformed body, drops malformed tools when drop_params is on, and both lookups skip values of the wrong shape. * fix(proxy): skip non-list tools in the tool allowlist check Also validate the count_tokens body without recursion, which the recursion detector rejects. * test(integration): restore the int tools case on count_tokens The proxy now answers a non-list tools value with a 400, so the case is deterministic again. * fix(proxy): reject malformed tools at the LLM route entry and keep vector store checks on malformed ids * fix(proxy): apply drop_params like core when dropping malformed tools, and reject malformed tool vector store ids Check tools after request setup so the Claude Code default and the logged call type apply, read the request, global, and model group drop_params the way core does, drop tool_choice and friends with tools, cover Gemini generateContent, and answer 400 for a malformed tools[].vector_store_ids with the strict flag off * fix(proxy): keep the request's call type when logging a gate rejection after request setup The body-shape fallback relabeled a /v1/messages rejection as acompletion and a list-input /v1/responses rejection as aembedding, so it now only applies when the logging object carries no known call type * fix(proxy): share the malformed tools check across tool routes and honor drop_params per entry * fix(proxy): accept null vector_store_ids, read every named store id in auth, check the responses websocket * refactor(proxy): leave the responses websocket out of the request-level tool check * fix(proxy): let a request's additional_drop_params replace the deployment list and keep compact's call type on rejections * fix(proxy): read router default drop lists and deployment ids in the malformed tools check * fix(proxy): leave malformed tools to the router when it can pick another deployment Fallbacks, auto-routers, model strings, comma batches and team model names let the router pick a deployment after the proxy checks the request, so the proxy now forwards a malformed tools field there as main does, and only rejects it when the deployment it checks is the one that serves. Token counting never reroutes, so count_tokens and input_tokens keep reading the group they count with. additional_drop_params names are read the way core reads them, so a list with a non-string entry still names its strings. * test(integration): cover malformed tools on reroutable models, router defaults and deployment ids * fix(proxy): resolve malformed tools at the deployment the router picks * fix(proxy): keep provider-repaired tool schemas, honor fallback_on_unsupported_params, skip cooldown on malformed tools Empty schemas and JSON-string schemas pass the tools check the way litellm's provider paths already read them. The rejection is a BadRequestError subclass carrying the deployment's provider, so fallback_on_unsupported_params: false keeps the 400, and the router never cools a deployment down for a request litellm rejected before calling it. Adds the audit's pre_call guardrail and cross-provider bridge cells. * test(integration): expect a 400 for an empty-string tools field on /v1/messages Real Anthropic rejects tools "" with a 400 on main too, so the case joins the malformed tools test * fix(router): keep web search routing for tools passed as a tuple * fix(router): keep a deployment's own tools when drop_params drops a malformed request tools * test(router): list _merged_tools as covered through the deployment tools merge tests * fix(router): drop the request's tool settings with its malformed tools when deployment tools replace them * fix(router): skip a tool whose type is not a string in the web search filter instead of raising TypeError * fix(prometheus): keep deployment health metrics clean on a malformed tools rejection * test: type the new malformed tools tests * test: mark new test locals Final and type kwargs dicts (cherry picked from commitd76ec3a47e) * ci(cost map): classify token_count_model as an object key in the schema generator (#47057) * ci(cost map): classify token_count_model as an object key in the schema generator * ci(cost map): require non-blank token_count_model strings and test the region rules (cherry picked from commit6e191a58f1) * ci(cost map): remove the generated model prices JSON schema (#47064) * ci(cost map): remove the generated model prices JSON schema Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(cost map): keep schema-independent cost map tests after dropping the schema Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ci(cost map): keep the schema file until main's guard stops requiring it Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ci(cost map): let bot sync PRs keep touching the schema file until it is deleted 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> (cherry picked from commitae6f7f6613) * fix(count_tokens)!: answer 400 for Vertex partner models without a count API and OpenAI-shaped provider errors on /v1/responses/input_tokens (#47163) * fix(count_tokens): answer 400 for Vertex partner models without a count API and OpenAI-shaped provider errors on /v1/responses/input_tokens * fix(count_tokens): answer 400 for Vertex Mistral counts and type the input_tokens error tests Vertex has no count-tokens route for Mistral or codestral models, so they now get the same 400 as other partner models without a count API instead of a 404 that points operators at IAM. * fix(count_tokens): unwrap prefixed provider errors on /v1/responses/input_tokens * fix(count_tokens): say LiteLLM, not Vertex, cannot count a Vertex partner model * fix(count_tokens): count a Vertex partner model with its configured custom_tokenizer and unwrap prefixed flat provider errors * fix(count_tokens): keep the 400 for contents-only Vertex partner counts with a custom_tokenizer * fix(count_tokens): pass through an OpenAI provider error that omits param or code * fix(count_tokens): keep the status-based type for Anthropic count errors on the Responses route * test(count_tokens): cover the Vertex partner count gate and Responses count error shapes on a real proxy * test(count_tokens): add regression tests for the Vertex partner count gate and Responses count error shapes (cherry picked from commitef1d3959a0) * feat(count_tokens)!: configurable token_count_framing and token_count_model, count source in the response (#46777) * feat(count_tokens): configurable token_count_framing and token_count_fallback_model, count source in the response Count endpoints now say where each number came from in litellm_count_source (provider, fallback_model, local_estimate) and name the fallback model in litellm_count_fallback_model. When the provider answers 400 or 404 for a model it cannot count, the proxy counts the same request with token_count_fallback_model. token_count_framing adds a per-model constant to local estimates. Both settings ship in the cost map for Bedrock Claude Sonnet/Haiku 5.5, Bedrock Kimi K3, and Databricks Kimi K3 and DeepSeek V4 Flash, and can be overridden in model_info. * test(count_tokens): classify the new cost map keys in the schema generator and expect count_source in exact-shape tests * test(count_tokens): e2e matrix against real inference and integration wire tests for count source, fallback and framing * fix(count_tokens): keep the provider error when local counting is disabled, name the fallback error, keep settings in cooldown Moves the cost map lookup off the event loop * ci(cost map): bound token_count_framing to non-negative and token_count_fallback_model to non-empty * fix(count_tokens): keep a failed fallback's own status, ship fallbacks only where they count, add system.ai framing A fallback model that answers 404 returns the requested model's error unchanged, and any other fallback failure answers with its own status and message. The eu, apac, au and jp Sonnet and Haiku 5.5 entries no longer ship the Opus 5.5 fallback, which AWS serves only in us-east-1. A configured empty token_count_fallback_model turns the shipped fallback off. Config framing accepts whole-number floats and rejects negatives. The Databricks system.ai Kimi K3 and DeepSeek V4 Flash names carry the same framing as their serving endpoint names, and the e2e matrix runs on Bedrock Haiku 5.5 too. * test(databricks): list the system.ai Kimi K3 and DeepSeek V4 Flash names with their published DBU rates * fix(count_tokens): skip a token count fallback model for an hour after it answers 404 on that deployment A us. or global. Sonnet 5.5 deployment outside us-east-1 paid two extra Bedrock calls and about 200 ms on every count, since Opus 5.5 cannot count there either. The Kimi K3 e2e also runs the large system prompt shape now. * test(count_tokens): use the shared integration port allocator in the fallback wire test * fix(cost map): keep the Databricks system.ai entries to framing only, so their spend stays as today * test(count_tokens): cover the fallback 404 skip, inference profile base_model, shipped framing, and deployment-scoped token count settings Adds integration cells for a fallback model that answers 404, an application inference profile ARN deployment whose base_model carries the shipped fallback, and the shipped Kimi K3 framing with token_count_framing 0 turning it off. Adds unit tests that keep token count settings off the shared backend cost map key and that store a token count setting on PATCH only when the operator changed it * fix(count_tokens): keep the counter's own 400 and the provider's error when a framed estimate cannot read the body A 400 the OpenAI or Gemini counter returns before calling the provider is the caller's answer, so it no longer tries the fallback model or a framed estimate. When the framed local estimate cannot read a content block, the client gets the provider's error instead of a 500. Adds /audit round 2's integration tests for the fallback leg's converted body, the Anthropic envelope on fallback failures, and both framing cases. * fix(count_tokens): answer the provider's error whenever a framed estimate raises A tool schema the local estimate cannot format, such as a boolean subschema or an array with no items, raised AttributeError or KeyError and answered a 500 on a framed deployment whose provider had already answered 400. Any estimate failure now answers the provider's own error. * fix(count_tokens): load the custom tokenizer inside the guarded framed estimate A framed deployment whose custom_tokenizer could not load answered a 500 or 503 where the provider had already answered 400, because the tokenizer was selected outside the estimate's error handling. Selecting it inside answers the provider's error, logs a warning with the estimate's error, and still raises the Rust extension's fork-guard errors. * fix(count_tokens): look up framing under the deployment's custom_llm_provider * fix(count_tokens): share one tokenizer download per worker and bound the wait after a provider error A framed deployment whose custom_tokenizer is not cached locally made every count wait out the Hugging Face hub's whole retry cycle when the hub could not be reached, and a burst of those loads held the default thread limiter that every count's cost map lookup also needs. Counts on a worker now share one download per minute, a failed download is reused for the rest of that minute, and after a provider error a count waits at most 5 s before answering the provider's error. The swallowed-estimate warning logs only the error type. * fix(count_tokens): keep one tokenizer download per key running and load the local route per request * test(count_tokens): type the scripted counter and wait on events instead of wall time where possible * fix(count_tokens): run framed tokenizer loads on their own capped threads and answer a tokenizer panic with the provider error Framed loads used anyio's default 40 threads, so a hub that drops packets let about 40 hanging loads stall every count on the worker. Loads now run on a per-worker pool of 4, and a count that finds 4 loads running answers the provider's error at once. Any finished load is reused for a minute after it finishes, so a load that fell back to tiktoken is not retried on every count. A pyo3 PanicException from the tokenizer, while loading or counting, now answers the provider's error instead of a plain 500 * feat(proxy): count tokens with token_count_model in its pinned region token_count_model {model, region} replaces token_count_fallback_model. Bedrock Sonnet and Haiku 5.5 count with Opus 5.5 in us-east-1, or Melbourne for au. profiles, and a failure names the region to enable * fix(count_tokens): use the renamed count model constant in the e2e test and list token_count_model with the object keys * fix(proxy): keep the upstream error envelope when adding the region hint * test(proxy): annotate the token count setting echo test * ci(cost map): require non-blank token_count_model strings and test the region rules * fix(count_tokens): answer the token_count_model's error even when framing is set * fix(count_tokens): keep the provider error for Google contents instead of a framed estimate * fix(count_tokens): estimate Google contents the route translated to messages * fix(count_tokens): keep the provider error for Google contents with non-text parts * test(proxy): drive tokenizer wait and cost map tests by events and a skippable loop clock * fix(count_tokens): check Google text-only parts without a KeysView comparison basedpyright flags * feat(proxy): count Bedrock Kimi K3 tokens exactly with its pinned tokenizer * test(bedrock): keep the Kimi K3 image check in-process * fix(count_tokens): join the region hint to an upstream message that already ends with a period Also pins that a blank token_count_model in config falls through to the cost map default * fix(count_tokens): count Gemini tool-call turns and Responses tools, and stop tokenizer retries from stalling counts The Google countTokens route estimates contents with functionCall, functionResponse and thoughtSignature parts again, and still answers the provider error for media, thought and unknown parts The Kimi K3 exact count renders Responses API and Anthropic tools through the chat tool translation the Vertex count already used, now shared from the prompt factory, and falls back to the estimate when the Converse body cannot carry a tool A failed Kimi K3 tokenizer load is retried in the background without making counts wait, and a load still running after the wait logs a warning * fix(count_tokens): count Kimi K3 tools the way Bedrock inference sends them A flat Responses API function or custom tool becomes the chat tool the Responses route sends to inference, and every other tool goes to the Converse body as is, so hosted tools Bedrock drops are left out of the count instead of being counted or turning it into an estimate. * test(count_tokens): annotate new token counter test locals with Final * fix(count_tokens): keep shipped count regions inside the deployment's geography A plain Bedrock Sonnet or Haiku 5.5 deployment in a non-US region no longer sends its count prompt to us-east-1. Global count models and configured token_count_model keep their pinned region. Also expect litellm_count_source in the Vertex Llama count test main added * fix(count_tokens): resolve count settings off the event loop (cherry picked from commit75faaeab64) --------- Co-authored-by: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
3966 lines
166 KiB
Python
3966 lines
166 KiB
Python
"""
|
|
Test for response_api_endpoints/endpoints.py
|
|
"""
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import dataclasses
|
|
import json
|
|
import unittest
|
|
from collections.abc import Mapping
|
|
from typing import Any, Final, Literal, cast
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
import respx
|
|
from fastapi.testclient import TestClient
|
|
from httpx import Response
|
|
|
|
import litellm
|
|
from litellm.proxy.proxy_server import app
|
|
from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse
|
|
from tests._master_key import MASTER_KEY
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"path,error_kind",
|
|
[
|
|
("/v1/responses", "rate_limit"),
|
|
("/v1/responses", "numeric_rate_limit"),
|
|
("/v1/responses", "server_error"),
|
|
("/v1/responses", "response_failed"),
|
|
("/v1/responses", "cyber_policy"),
|
|
("/cursor/chat/completions", "server_error"),
|
|
("/v1/chat/completions", "server_error"),
|
|
],
|
|
)
|
|
async def test_streaming_upstream_errors_keep_the_client_protocol(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
path: str,
|
|
error_kind: Literal["rate_limit", "numeric_rate_limit", "server_error", "response_failed", "cyber_policy"],
|
|
) -> None:
|
|
import litellm.proxy.proxy_server as ps
|
|
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
|
|
|
model: Final = "gpt-6-astra"
|
|
message: Final = "Upstream cannot complete this response"
|
|
code: Final = {
|
|
"rate_limit": "rate_limit_exceeded",
|
|
"numeric_rate_limit": "429",
|
|
"server_error": "server_error",
|
|
"response_failed": "server_error",
|
|
"cyber_policy": "cyber_policy",
|
|
}[error_kind]
|
|
error: Final = {"message": message, "code": code, "type": None, "param": "input"}
|
|
response: Final = {
|
|
"id": "resp_upstream",
|
|
"object": "response",
|
|
"created_at": 1,
|
|
"status": "in_progress",
|
|
"model": model,
|
|
"output": [],
|
|
"parallel_tool_calls": True,
|
|
"tool_choice": "auto",
|
|
"tools": [],
|
|
}
|
|
created: Final = {"type": "response.created", "sequence_number": 0, "response": response}
|
|
tool_added: Final = {
|
|
"type": "response.output_item.added",
|
|
"sequence_number": 1,
|
|
"output_index": 0,
|
|
"item": {
|
|
"type": "function_call",
|
|
"id": "fc_partial",
|
|
"call_id": "call_partial",
|
|
"name": "read_file",
|
|
"arguments": "",
|
|
"status": "in_progress",
|
|
},
|
|
}
|
|
tool_delta: Final = {
|
|
"type": "response.function_call_arguments.delta",
|
|
"sequence_number": 2,
|
|
"item_id": "fc_partial",
|
|
"output_index": 0,
|
|
"delta": '{"path":"partial',
|
|
}
|
|
failed: Final = (
|
|
{"type": "response.failed", "sequence_number": 9, "response": {**response, "status": "failed", "error": error}}
|
|
if error_kind in ("response_failed", "cyber_policy")
|
|
else {"type": "error", "error": error}
|
|
)
|
|
chat: Final = {
|
|
"id": "chatcmpl_partial",
|
|
"object": "chat.completion.chunk",
|
|
"created": 1,
|
|
"model": model,
|
|
"choices": [{"index": 0, "delta": {"content": "partial"}, "finish_reason": None}],
|
|
}
|
|
is_chat: Final = path == "/v1/chat/completions"
|
|
partial: Final = path != "/v1/responses" or error_kind in ("numeric_rate_limit", "response_failed", "cyber_policy")
|
|
response_events: Final = (created, tool_added, tool_delta, failed) if partial else (failed,)
|
|
upstream_events: Final = (chat, {"error": error}) if is_chat else response_events
|
|
wire: Final = "".join("data: " + json.dumps(event) + "\n\n" for event in upstream_events)
|
|
upstream_url: Final = "https://streaming.example/v1"
|
|
router: Final = litellm.Router(
|
|
model_list=[
|
|
{
|
|
"model_name": model,
|
|
"litellm_params": {"model": "openai/" + model, "api_base": upstream_url, "api_key": "fixture-key"},
|
|
}
|
|
],
|
|
num_retries=0,
|
|
)
|
|
monkeypatch.setattr(ps, "llm_router", router)
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
monkeypatch.setitem(app.dependency_overrides, user_api_key_auth, _auth_override)
|
|
with respx.mock as transport:
|
|
transport.post(upstream_url + ("/chat/completions" if is_chat else "/responses")).respond(
|
|
200, content=wire, headers={"Content-Type": "text/event-stream"}
|
|
)
|
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url="http://testserver") as client:
|
|
result: Final = await client.post(
|
|
path,
|
|
json={
|
|
"model": model,
|
|
"stream": True,
|
|
**({"messages": [{"role": "user", "content": "hello"}]} if is_chat else {"input": "hello"}),
|
|
},
|
|
)
|
|
frames: Final = tuple(frame for frame in result.text.split("\n\n") if "data: " in frame)
|
|
events: Final = tuple(
|
|
json.loads(next(line[6:] for line in frame.splitlines() if line.startswith("data: ")))
|
|
for frame in frames
|
|
if "data: [DONE]" not in frame
|
|
)
|
|
|
|
assert result.status_code == 200, result.text
|
|
assert message in result.text
|
|
if path == "/v1/responses":
|
|
assert frames[-1] == "data: [DONE]", result.text
|
|
assert frames[-2].startswith("event: response.failed\n"), result.text
|
|
if partial:
|
|
assert [event["type"] for event in events] == [
|
|
"response.created",
|
|
"response.output_item.added",
|
|
"response.function_call_arguments.delta",
|
|
"response.failed",
|
|
]
|
|
assert events[2]["delta"] == tool_delta["delta"]
|
|
assert events[-1]["sequence_number"] == events[-2]["sequence_number"] + 1
|
|
assert events[-1]["response"]["id"] == events[0]["response"]["id"]
|
|
else:
|
|
assert [event["type"] for event in events] == ["response.failed"]
|
|
assert events[0]["sequence_number"] == 0
|
|
assert events[0]["response"]["id"].startswith("resp_")
|
|
assert events[-1]["response"]["status"] == "failed"
|
|
assert (
|
|
events[-1]["response"]["error"]["code"]
|
|
== {
|
|
"rate_limit": "rate_limit_exceeded",
|
|
"numeric_rate_limit": "rate_limit_exceeded",
|
|
"server_error": "server_error",
|
|
"response_failed": "server_error",
|
|
"cyber_policy": "cyber_policy",
|
|
}[error_kind]
|
|
)
|
|
assert events[-1]["response"]["error"]["message"] == message
|
|
else:
|
|
assert events[0]["object"] == "chat.completion.chunk", result.text
|
|
assert "response.failed" not in result.text
|
|
assert "error" in events[-1]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_responses_api_background_polling_rejects_missing_input():
|
|
from fastapi import Response as FastAPIResponse
|
|
from starlette.requests import Request
|
|
|
|
from litellm.proxy._types import ProxyException, UserAPIKeyAuth
|
|
from litellm.proxy.response_api_endpoints.endpoints import responses_api
|
|
|
|
processor = MagicMock()
|
|
|
|
async def return_exception(*, e: Exception, **kwargs: object) -> Exception:
|
|
return e
|
|
|
|
processor.handle_llm_api_exception = AsyncMock(side_effect=return_exception)
|
|
processor.common_processing_pre_call_logic = AsyncMock(return_value=({"model": "gpt-4o"}, MagicMock()))
|
|
|
|
async def receive():
|
|
return {
|
|
"type": "http.request",
|
|
"body": b'{"model":"gpt-4o","background":true}',
|
|
"more_body": False,
|
|
}
|
|
|
|
request = Request(
|
|
{
|
|
"type": "http",
|
|
"method": "POST",
|
|
"path": "/v1/responses",
|
|
"headers": [(b"content-type", b"application/json")],
|
|
},
|
|
receive,
|
|
)
|
|
|
|
with (
|
|
patch( # test-quality-ok: endpoint constructs the processor directly
|
|
"litellm.proxy.response_api_endpoints.endpoints.ProxyBaseLLMRequestProcessing",
|
|
return_value=processor,
|
|
),
|
|
patch( # test-quality-ok: polling decision is imported inside the endpoint
|
|
"litellm.proxy.response_polling.polling_handler.should_use_polling_for_request",
|
|
return_value=True,
|
|
),
|
|
patch( # test-quality-ok: background task is imported inside the endpoint
|
|
"litellm.proxy.response_polling.background_streaming.background_streaming_task",
|
|
new_callable=AsyncMock,
|
|
) as mock_background_streaming_task,
|
|
patch( # test-quality-ok: polling handler is imported inside the endpoint
|
|
"litellm.proxy.response_polling.polling_handler.ResponsePollingHandler.create_initial_state",
|
|
new_callable=AsyncMock,
|
|
) as mock_create_initial_state,
|
|
):
|
|
with pytest.raises(ProxyException) as exc_info:
|
|
await responses_api(
|
|
request=request,
|
|
fastapi_response=FastAPIResponse(),
|
|
user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"),
|
|
)
|
|
|
|
assert exc_info.value.code == "400"
|
|
assert exc_info.value.param == "input"
|
|
processor.common_processing_pre_call_logic.assert_awaited_once()
|
|
mock_background_streaming_task.assert_not_called()
|
|
mock_create_initial_state.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_responses_api_background_polling_accepts_input_from_prompt_template():
|
|
from fastapi import Response as FastAPIResponse
|
|
from starlette.requests import Request
|
|
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.response_api_endpoints.endpoints import responses_api
|
|
|
|
processor = MagicMock()
|
|
processor.common_processing_pre_call_logic = AsyncMock(
|
|
return_value=({"model": "gpt-4o", "input": "hello from prompt"}, MagicMock())
|
|
)
|
|
initial_state = MagicMock()
|
|
|
|
async def receive():
|
|
return {
|
|
"type": "http.request",
|
|
"body": b'{"model":"gpt-4o","prompt_id":"greeting","background":true}',
|
|
"more_body": False,
|
|
}
|
|
|
|
request = Request(
|
|
{
|
|
"type": "http",
|
|
"method": "POST",
|
|
"path": "/v1/responses",
|
|
"headers": [(b"content-type", b"application/json")],
|
|
},
|
|
receive,
|
|
)
|
|
|
|
with (
|
|
patch( # test-quality-ok: endpoint constructs the processor directly
|
|
"litellm.proxy.response_api_endpoints.endpoints.ProxyBaseLLMRequestProcessing",
|
|
return_value=processor,
|
|
),
|
|
patch( # test-quality-ok: polling decision is imported inside the endpoint
|
|
"litellm.proxy.response_polling.polling_handler.should_use_polling_for_request",
|
|
return_value=True,
|
|
),
|
|
patch( # test-quality-ok: background task is imported inside the endpoint
|
|
"litellm.proxy.response_polling.background_streaming.background_streaming_task",
|
|
new_callable=AsyncMock,
|
|
),
|
|
patch( # test-quality-ok: avoid scheduling a background task in this unit test
|
|
"litellm.proxy.response_api_endpoints.endpoints.asyncio.create_task",
|
|
),
|
|
patch( # test-quality-ok: polling handler is imported inside the endpoint
|
|
"litellm.proxy.response_polling.polling_handler.ResponsePollingHandler.create_initial_state",
|
|
new_callable=AsyncMock,
|
|
) as mock_create_initial_state,
|
|
):
|
|
mock_create_initial_state.return_value = initial_state
|
|
result = await responses_api(
|
|
request=request,
|
|
fastapi_response=FastAPIResponse(),
|
|
user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"),
|
|
)
|
|
|
|
assert result is initial_state
|
|
processor.common_processing_pre_call_logic.assert_awaited_once()
|
|
mock_create_initial_state.assert_awaited_once()
|
|
request_data = mock_create_initial_state.await_args.kwargs["request_data"]
|
|
assert request_data["input"] == "hello from prompt"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_cache_polled_background_stream_is_priced_by_its_own_stream():
|
|
"""Cache polling runs the stream without the settlement wrapper, so a stream the managed files hook
|
|
marked as billed later must still be priced when it finishes, or the generation is never billed."""
|
|
from fastapi import Response as FastAPIResponse
|
|
|
|
from litellm.constants import MANAGED_BACKGROUND_RESPONSE_METADATA_KEY
|
|
from litellm.litellm_core_utils.internal_call_metadata import is_unbilled_non_inference_call
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.response_api_endpoints.endpoints import responses_api
|
|
|
|
marked_by_the_hook: Final = {
|
|
"model": "gpt-5.6",
|
|
"input": "hello",
|
|
"stream": True,
|
|
"background": True,
|
|
"extra_body": {"background": True},
|
|
"litellm_metadata": {MANAGED_BACKGROUND_RESPONSE_METADATA_KEY: True, "user_api_key_alias": "creator"},
|
|
}
|
|
processor: Final = MagicMock()
|
|
processor.common_processing_pre_call_logic = AsyncMock(return_value=(marked_by_the_hook, MagicMock()))
|
|
body: Final = json.dumps(
|
|
{"model": "gpt-5.6", "input": "hello", "stream": True, "background": True, "extra_body": {"background": True}}
|
|
).encode()
|
|
|
|
with (
|
|
patch( # test-quality-ok: endpoint constructs the processor directly
|
|
"litellm.proxy.response_api_endpoints.endpoints.ProxyBaseLLMRequestProcessing",
|
|
return_value=processor,
|
|
),
|
|
patch( # test-quality-ok: polling decision is imported inside the endpoint
|
|
"litellm.proxy.response_polling.polling_handler.should_use_polling_for_request",
|
|
return_value=True,
|
|
),
|
|
patch( # test-quality-ok: background task is imported inside the endpoint
|
|
"litellm.proxy.response_polling.background_streaming.background_streaming_task",
|
|
new_callable=AsyncMock,
|
|
) as polled_stream,
|
|
patch( # test-quality-ok: polling handler is imported inside the endpoint
|
|
"litellm.proxy.response_polling.polling_handler.ResponsePollingHandler.create_initial_state",
|
|
new_callable=AsyncMock,
|
|
),
|
|
):
|
|
await responses_api(
|
|
request=_request_for("POST", "/v1/responses", body),
|
|
fastapi_response=FastAPIResponse(),
|
|
user_api_key_dict=UserAPIKeyAuth(api_key="sk-creator"),
|
|
)
|
|
await asyncio.sleep(0)
|
|
|
|
polled_metadata: Final = polled_stream.call_args.kwargs["data"]["litellm_metadata"]
|
|
finished: Final = _background_response("resp_cache_polled", "completed", 100)
|
|
assert is_unbilled_non_inference_call("aresponses", marked_by_the_hook["litellm_metadata"], finished)
|
|
assert not is_unbilled_non_inference_call("aresponses", polled_metadata, finished)
|
|
assert polled_metadata["user_api_key_alias"] == "creator"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("status", ["queued", "completed"])
|
|
async def test_responses_api_background_create_persists_the_creating_keys_attribution(status: str):
|
|
"""The cost poll bills a background job off its managed object row, so the create must persist
|
|
the key hash and tags the poll bills against, the way the batch create does, plus the client's
|
|
User-Agent tags that a spend-by-client view groups the charge under. A create the provider
|
|
already finished is stored too, so a later read finds it billed instead of billing it again."""
|
|
from fastapi import Response as FastAPIResponse
|
|
from starlette.requests import Request
|
|
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.response_api_endpoints.endpoints import responses_api
|
|
|
|
queued = ResponsesAPIResponse(
|
|
id="resp_background", object="response", status=status, created_at=1, output=[], usage=None
|
|
)
|
|
queued._hidden_params = {"model_id": "deployment-1"}
|
|
processor = MagicMock()
|
|
processor.data = {
|
|
"model": "gpt-4o",
|
|
"input": "hello",
|
|
"background": True,
|
|
"litellm_metadata": {"tags": ["env:prod"]},
|
|
"proxy_server_request": {"headers": {"user-agent": "codex_cli_rs/0.44.0"}},
|
|
}
|
|
processor.base_process_llm_request = AsyncMock(return_value=queued)
|
|
managed_files = MagicMock()
|
|
managed_files.store_unified_object_id = AsyncMock()
|
|
managed_files.track_untracked_background_response = AsyncMock(return_value=False)
|
|
proxy_logging = MagicMock()
|
|
proxy_logging.get_proxy_hook = MagicMock(return_value=managed_files)
|
|
|
|
async def receive():
|
|
return {
|
|
"type": "http.request",
|
|
"body": b'{"model":"gpt-4o","input":"hello","background":true}',
|
|
"more_body": False,
|
|
}
|
|
|
|
request = Request(
|
|
{
|
|
"type": "http",
|
|
"method": "POST",
|
|
"path": "/v1/responses",
|
|
"headers": [(b"content-type", b"application/json")],
|
|
},
|
|
receive,
|
|
)
|
|
|
|
with (
|
|
patch( # test-quality-ok: endpoint constructs the processor directly
|
|
"litellm.proxy.response_api_endpoints.endpoints.ProxyBaseLLMRequestProcessing",
|
|
return_value=processor,
|
|
),
|
|
patch( # test-quality-ok: polling decision is imported inside the endpoint
|
|
"litellm.proxy.response_polling.polling_handler.should_use_polling_for_request",
|
|
return_value=False,
|
|
),
|
|
patch( # test-quality-ok: the endpoint reads the proxy singletons at call time
|
|
"litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging
|
|
),
|
|
patch( # test-quality-ok: the endpoint reads the proxy singletons at call time
|
|
"litellm.proxy.proxy_server.llm_router", MagicMock()
|
|
),
|
|
):
|
|
result = await responses_api(
|
|
request=request,
|
|
fastapi_response=FastAPIResponse(),
|
|
user_api_key_dict=UserAPIKeyAuth(api_key="sk-test"),
|
|
)
|
|
|
|
assert result is queued
|
|
store_kwargs = managed_files.store_unified_object_id.await_args.kwargs
|
|
assert store_kwargs["file_object"].status == status
|
|
assert store_kwargs["file_purpose"] == "response"
|
|
assert store_kwargs["persist_attribution"] is True
|
|
assert store_kwargs["request_tags"] == (
|
|
"env:prod",
|
|
"User-Agent: codex_cli_rs",
|
|
"User-Agent: codex_cli_rs/0.44.0",
|
|
)
|
|
|
|
|
|
class _SpendEvents:
|
|
def __init__(self) -> None:
|
|
self.payloads: tuple[Mapping[str, object], ...] = ()
|
|
|
|
async def async_log_success_event(
|
|
self, kwargs: Mapping[str, object], response_obj: object, start_time: object, end_time: object
|
|
) -> None:
|
|
payload = kwargs["standard_logging_object"]
|
|
assert isinstance(payload, Mapping)
|
|
self.payloads = (*self.payloads, payload)
|
|
|
|
|
|
async def _drain_spend_events() -> None:
|
|
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
|
|
|
await asyncio.wait_for(GLOBAL_LOGGING_WORKER.flush(), timeout=10)
|
|
|
|
|
|
def _billed_costs(payloads: tuple[Mapping[str, object], ...]) -> list[str]:
|
|
return [str(payload["response_cost"]) for payload in payloads]
|
|
|
|
|
|
def _advertised_cost_split(headers: Mapping[str, str]) -> list[tuple[float, float]]:
|
|
if "x-litellm-response-cost-original" not in headers:
|
|
return []
|
|
return [
|
|
(
|
|
float(headers["x-litellm-response-cost-original"]),
|
|
float(headers["x-litellm-response-cost-input"]) + float(headers["x-litellm-response-cost-output"]),
|
|
)
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"background, stored, status, expected_billed_events",
|
|
[(True, True, "completed", 1), (True, True, "in_progress", 0), (True, False, "completed", 0), (False, True, "completed", 0)],
|
|
)
|
|
async def test_reading_a_finished_background_response_bills_it(
|
|
monkeypatch: pytest.MonkeyPatch, background: bool, stored: bool, status: str, expected_billed_events: int
|
|
):
|
|
"""The read that first sees a background response finished bills it from the body it fetched,
|
|
since the provider may drop a store=false response before the hourly cost poll reaches it.
|
|
Billing never reads the provider a second time, other reads stay free, the charge stays on the
|
|
end user the create named, and only the read that billed reports the cost it billed, split
|
|
into the components the billed event priced."""
|
|
from types import SimpleNamespace
|
|
|
|
from fastapi import Response as FastAPIResponse
|
|
|
|
from litellm.integrations.custom_logger import CustomLogger
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.response_api_endpoints.endpoints import _bill_finished_background_response
|
|
|
|
class _CapturingLogger(_SpendEvents, CustomLogger):
|
|
pass
|
|
|
|
spend_events = _CapturingLogger()
|
|
monkeypatch.setattr(litellm, "_async_success_callback", [spend_events])
|
|
read = ResponsesAPIResponse(
|
|
id="resp_read",
|
|
object="response",
|
|
status=status,
|
|
created_at=1,
|
|
model="gpt-5.6",
|
|
output=[],
|
|
usage=ResponseAPIUsage(input_tokens=100, output_tokens=50, total_tokens=150),
|
|
background=background,
|
|
)
|
|
stored_row = SimpleNamespace(
|
|
id="job-read",
|
|
unified_object_id="resp_read",
|
|
status="queued",
|
|
file_object={"model": "gpt-5.6"},
|
|
created_by=None,
|
|
api_key=None,
|
|
team_id=None,
|
|
org_id=None,
|
|
request_tags=None,
|
|
end_user_id="customer-42",
|
|
)
|
|
fastapi_response = FastAPIResponse()
|
|
prisma_client = MagicMock()
|
|
prisma_client.db.litellm_managedobjecttable.find_first = AsyncMock(return_value=stored_row if stored else None)
|
|
prisma_client.db.litellm_managedobjecttable.update_many = AsyncMock(return_value=1)
|
|
|
|
with (
|
|
patch("litellm.proxy.proxy_server.prisma_client", prisma_client), # test-quality-ok: the endpoint reads the proxy singletons at call time
|
|
patch("litellm.proxy.proxy_server.llm_router", MagicMock()), # test-quality-ok: the endpoint reads the proxy singletons at call time
|
|
patch("litellm.aget_responses", side_effect=AssertionError("billing must not read the provider again")),
|
|
):
|
|
await _bill_finished_background_response(
|
|
response=read,
|
|
response_id="resp_read",
|
|
user_api_key_dict=UserAPIKeyAuth(end_user_id="another-reader"),
|
|
fastapi_response=fastapi_response,
|
|
read_settles_billing=True,
|
|
)
|
|
await _drain_spend_events()
|
|
|
|
assert len(spend_events.payloads) == expected_billed_events
|
|
assert all(payload["metadata"]["user_api_key_end_user_id"] == "customer-42" for payload in spend_events.payloads)
|
|
assert fastapi_response.headers.getlist("x-litellm-response-cost") == _billed_costs(spend_events.payloads)
|
|
assert _advertised_cost_split(fastapi_response.headers) == [
|
|
(pytest.approx(float(cost)), pytest.approx(float(cost))) for cost in _billed_costs(spend_events.payloads)
|
|
]
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class _ManagedObjectRow:
|
|
id: str
|
|
unified_object_id: str
|
|
model_object_id: str
|
|
status: str
|
|
file_object: Mapping[str, object]
|
|
file_purpose: str = "response"
|
|
created_by: str | None = None
|
|
api_key: str | None = None
|
|
team_id: str | None = None
|
|
org_id: str | None = None
|
|
request_tags: object = None
|
|
end_user_id: str | None = None
|
|
|
|
|
|
class _UniqueManagedObjectTable:
|
|
"""Rejects a second row for a response id and applies status claims atomically, the way the
|
|
Postgres table does, and yields on every query so concurrent reads interleave like two pods."""
|
|
|
|
def __init__(self, rows: tuple[_ManagedObjectRow, ...] = ()) -> None:
|
|
self.rows = rows
|
|
|
|
def _matches(self, row: _ManagedObjectRow, where: Mapping[str, object]) -> bool:
|
|
def holds(field: str, condition: object) -> bool:
|
|
if isinstance(condition, list):
|
|
return any(self._matches(row, alternative) for alternative in condition)
|
|
if isinstance(condition, dict):
|
|
return getattr(row, field) in condition["in"]
|
|
return getattr(row, field) == condition
|
|
|
|
return all(holds(field, condition) for field, condition in where.items())
|
|
|
|
async def find_first(self, where: Mapping[str, object]) -> _ManagedObjectRow | None:
|
|
await asyncio.sleep(0)
|
|
return next((row for row in self.rows if self._matches(row, where)), None)
|
|
|
|
async def create(self, data: Mapping[str, object]) -> _ManagedObjectRow:
|
|
from prisma.errors import UniqueViolationError
|
|
|
|
await asyncio.sleep(0)
|
|
if any(
|
|
row.unified_object_id == data["unified_object_id"] or row.model_object_id == data["model_object_id"]
|
|
for row in self.rows
|
|
):
|
|
raise UniqueViolationError({"user_facing_error": {"error_code": "P2002"}})
|
|
row = _ManagedObjectRow(
|
|
id=f"job-{len(self.rows)}",
|
|
unified_object_id=str(data["unified_object_id"]),
|
|
model_object_id=str(data["model_object_id"]),
|
|
status=str(data["status"]),
|
|
file_object=json.loads(str(data["file_object"])),
|
|
created_by=cast("str | None", data.get("created_by")),
|
|
api_key=cast("str | None", data.get("api_key")),
|
|
team_id=cast("str | None", data.get("team_id")),
|
|
org_id=cast("str | None", data.get("org_id")),
|
|
end_user_id=cast("str | None", data.get("end_user_id")),
|
|
request_tags=getattr(data.get("request_tags"), "data", None),
|
|
)
|
|
self.rows = (*self.rows, row)
|
|
return row
|
|
|
|
async def update_many(self, where: Mapping[str, object], data: Mapping[str, object]) -> int:
|
|
await asyncio.sleep(0)
|
|
matched = frozenset(row.id for row in self.rows if self._matches(row, where))
|
|
self.rows = tuple(
|
|
dataclasses.replace(row, status=str(data["status"])) if row.id in matched else row for row in self.rows
|
|
)
|
|
return len(matched)
|
|
|
|
|
|
async def _read_twice_on_two_pods(
|
|
monkeypatch: pytest.MonkeyPatch, table: _UniqueManagedObjectTable, response_id: str, reader: object
|
|
) -> tuple[Mapping[str, object], ...]:
|
|
from fastapi import Response as FastAPIResponse
|
|
from litellm_enterprise.proxy.hooks.managed_files import _PROXY_LiteLLMManagedFiles
|
|
|
|
from litellm.caching.caching import DualCache
|
|
from litellm.integrations.custom_logger import CustomLogger
|
|
from litellm.proxy.response_api_endpoints.endpoints import _bill_finished_background_response
|
|
|
|
class _CapturingLogger(_SpendEvents, CustomLogger):
|
|
pass
|
|
|
|
spend_events: Final = _CapturingLogger()
|
|
monkeypatch.setattr(litellm, "_async_success_callback", [spend_events])
|
|
prisma_client: Final = MagicMock()
|
|
prisma_client.db.litellm_managedobjecttable = table
|
|
prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=None)
|
|
prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=None)
|
|
prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=None)
|
|
proxy_logging: Final = MagicMock()
|
|
proxy_logging.get_proxy_hook = MagicMock(
|
|
return_value=_PROXY_LiteLLMManagedFiles(internal_usage_cache=DualCache(), prisma_client=prisma_client)
|
|
)
|
|
finished: Final = ResponsesAPIResponse(
|
|
id=response_id,
|
|
object="response",
|
|
status="completed",
|
|
created_at=1,
|
|
model="gpt-5.6",
|
|
output=[],
|
|
usage=None,
|
|
background=True,
|
|
)
|
|
|
|
router: Final = _configured_deployment_router(finished)
|
|
router.aget_responses = AsyncMock(side_effect=AssertionError("billing must not read the provider again"))
|
|
|
|
with (
|
|
patch("litellm.proxy.proxy_server.prisma_client", prisma_client), # test-quality-ok: the endpoint reads the proxy singletons at call time
|
|
patch("litellm.proxy.proxy_server.llm_router", router), # test-quality-ok: the endpoint reads the proxy singletons at call time
|
|
patch("litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging), # test-quality-ok: the endpoint reads the proxy singletons at call time
|
|
patch("litellm.aget_responses", side_effect=AssertionError("billing must not read the provider again")),
|
|
):
|
|
await asyncio.gather(
|
|
*(
|
|
_bill_finished_background_response(
|
|
response=finished,
|
|
response_id=response_id,
|
|
user_api_key_dict=reader,
|
|
fastapi_response=FastAPIResponse(),
|
|
read_settles_billing=True,
|
|
)
|
|
for _ in range(2)
|
|
)
|
|
)
|
|
await _drain_spend_events()
|
|
return spend_events.payloads
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_finished_response_the_create_could_not_store_is_billed_once_under_the_reader(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
"""A create that could not write its managed row (a database outage) leaves the reads marked free
|
|
and the poll blind to the job, so the first read that sees it finished stores the row under the
|
|
reading key and bills it, once even when two pods read it at the same moment."""
|
|
from litellm.proxy._types import UserAPIKeyAuth, hash_token
|
|
from litellm.responses.utils import ResponsesAPIRequestUtils
|
|
|
|
response_id: Final = ResponsesAPIRequestUtils._build_responses_api_response_id(
|
|
custom_llm_provider="openai", model_id="deployment-untracked", response_id="resp_untracked"
|
|
)
|
|
table: Final = _UniqueManagedObjectTable()
|
|
|
|
payloads: Final = await _read_twice_on_two_pods(
|
|
monkeypatch,
|
|
table,
|
|
response_id,
|
|
UserAPIKeyAuth(api_key="sk-reader", user_id="bob", team_id="team-beta", org_id="org-gamma", end_user_id="customer-42"),
|
|
)
|
|
|
|
(billed,) = payloads
|
|
assert billed["metadata"]["user_api_key_hash"] == hash_token("sk-reader")
|
|
assert billed["metadata"]["user_api_key_team_id"] == "team-beta"
|
|
assert billed["metadata"]["user_api_key_org_id"] == "org-gamma"
|
|
assert billed["metadata"]["user_api_key_end_user_id"] == "customer-42"
|
|
(row,) = table.rows
|
|
assert (row.unified_object_id, row.status) == (response_id, "completed")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("proxy_issued, stored_status", [(True, "completed"), (False, None)])
|
|
async def test_a_finished_read_never_bills_a_response_already_billed_or_billed_by_the_read_itself(
|
|
monkeypatch: pytest.MonkeyPatch, proxy_issued: bool, stored_status: str | None
|
|
):
|
|
"""A row the create stored as finished means the create billed it, and an id the proxy did not
|
|
issue is priced by the read itself, so neither read stores a row or bills the response again."""
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.responses.utils import ResponsesAPIRequestUtils
|
|
|
|
response_id: Final = (
|
|
ResponsesAPIRequestUtils._build_responses_api_response_id(
|
|
custom_llm_provider="openai", model_id="deployment-billed", response_id="resp_billed"
|
|
)
|
|
if proxy_issued
|
|
else "resp_not_issued_by_the_proxy"
|
|
)
|
|
stored: Final = (
|
|
(
|
|
_ManagedObjectRow(
|
|
id="job-billed",
|
|
unified_object_id=response_id,
|
|
model_object_id="resp_billed",
|
|
status=stored_status,
|
|
file_object={"model": "gpt-5.6"},
|
|
),
|
|
)
|
|
if stored_status is not None
|
|
else ()
|
|
)
|
|
table: Final = _UniqueManagedObjectTable(stored)
|
|
|
|
payloads: Final = await _read_twice_on_two_pods(monkeypatch, table, response_id, UserAPIKeyAuth(api_key="sk-reader"))
|
|
|
|
assert payloads == ()
|
|
assert table.rows == stored
|
|
|
|
|
|
def _configured_deployment_router(fetched: ResponsesAPIResponse) -> MagicMock:
|
|
from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo
|
|
|
|
router: Final = MagicMock()
|
|
router.get_deployment = MagicMock(
|
|
return_value=Deployment(
|
|
model_name="gpt-5.6",
|
|
litellm_params=LiteLLM_Params(model="openai/gpt-5.6"),
|
|
model_info=ModelInfo(id="deployment-read"),
|
|
)
|
|
)
|
|
router.aget_responses = AsyncMock(return_value=fetched)
|
|
return router
|
|
|
|
|
|
def _background_response(response_id: str, status: str, input_tokens: int) -> ResponsesAPIResponse:
|
|
return ResponsesAPIResponse(
|
|
id=response_id,
|
|
object="response",
|
|
status=status,
|
|
created_at=1,
|
|
model="gpt-5.6",
|
|
output=[],
|
|
usage=ResponseAPIUsage(input_tokens=input_tokens, output_tokens=50, total_tokens=input_tokens + 50),
|
|
background=True,
|
|
)
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class _StoredBackgroundResponse:
|
|
response_id: str
|
|
table: _UniqueManagedObjectTable
|
|
prisma_client: MagicMock
|
|
proxy_logging: MagicMock
|
|
|
|
|
|
def _one_pending_background_response() -> _StoredBackgroundResponse:
|
|
from litellm_enterprise.proxy.hooks.managed_files import _PROXY_LiteLLMManagedFiles
|
|
|
|
from litellm.caching.caching import DualCache
|
|
from litellm.responses.utils import ResponsesAPIRequestUtils
|
|
|
|
response_id: Final = ResponsesAPIRequestUtils._build_responses_api_response_id(
|
|
custom_llm_provider="openai", model_id="deployment-read", response_id="resp_read"
|
|
)
|
|
table: Final = _UniqueManagedObjectTable(
|
|
(
|
|
_ManagedObjectRow(
|
|
id="job-read",
|
|
unified_object_id=response_id,
|
|
model_object_id="resp_read",
|
|
status="queued",
|
|
file_object={"model": "gpt-5.6"},
|
|
),
|
|
)
|
|
)
|
|
prisma_client: Final = MagicMock()
|
|
prisma_client.db.litellm_managedobjecttable = table
|
|
prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=None)
|
|
prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=None)
|
|
prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=None)
|
|
proxy_logging: Final = MagicMock()
|
|
proxy_logging.get_proxy_hook = MagicMock(
|
|
return_value=_PROXY_LiteLLMManagedFiles(internal_usage_cache=DualCache(), prisma_client=prisma_client)
|
|
)
|
|
return _StoredBackgroundResponse(
|
|
response_id=response_id, table=table, prisma_client=prisma_client, proxy_logging=proxy_logging
|
|
)
|
|
|
|
|
|
def _request_for(method: str, path: str, body: bytes):
|
|
from starlette.requests import Request
|
|
|
|
async def receive():
|
|
return {"type": "http.request", "body": body, "more_body": False}
|
|
|
|
return Request(
|
|
{"type": "http", "method": method, "path": path, "headers": [(b"content-type", b"application/json")]},
|
|
receive,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"endpoint_name, finished_status",
|
|
[("get_response", "completed"), ("cancel_response", "cancelled")],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
"read_body, billed_input_tokens, deployment_fetches",
|
|
[(b"", 1000, 0), (b'{"api_base": "https://caller.example/v1"}', 100, 1)],
|
|
)
|
|
async def test_a_finished_read_or_cancel_bills_usage_only_the_configured_deployment_reported(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
endpoint_name: str,
|
|
finished_status: str,
|
|
read_body: bytes,
|
|
billed_input_tokens: int,
|
|
deployment_fetches: int,
|
|
):
|
|
"""A read or cancel the caller sends to its own endpoint returns whatever usage that endpoint
|
|
claims, so it bills from a fetch through the configured deployment instead, and either way the
|
|
response reports the cost it billed."""
|
|
from fastapi import Response as FastAPIResponse
|
|
|
|
from litellm.integrations.custom_logger import CustomLogger
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.response_api_endpoints import endpoints
|
|
|
|
class _CapturingLogger(_SpendEvents, CustomLogger):
|
|
pass
|
|
|
|
spend_events: Final = _CapturingLogger()
|
|
monkeypatch.setattr(litellm, "_async_success_callback", [spend_events])
|
|
stored: Final = _one_pending_background_response()
|
|
router: Final = _configured_deployment_router(_background_response(stored.response_id, finished_status, 100))
|
|
processor: Final = MagicMock()
|
|
processor.base_process_llm_request = AsyncMock(
|
|
return_value=_background_response(stored.response_id, finished_status, 1000)
|
|
)
|
|
fastapi_response: Final = FastAPIResponse()
|
|
|
|
with (
|
|
patch( # test-quality-ok: endpoint constructs the processor directly
|
|
"litellm.proxy.response_api_endpoints.endpoints.ProxyBaseLLMRequestProcessing",
|
|
return_value=processor,
|
|
),
|
|
patch("litellm.proxy.proxy_server.prisma_client", stored.prisma_client), # test-quality-ok: the endpoint reads the proxy singletons at call time
|
|
patch("litellm.proxy.proxy_server.llm_router", router), # test-quality-ok: the endpoint reads the proxy singletons at call time
|
|
patch("litellm.proxy.proxy_server.proxy_logging_obj", stored.proxy_logging), # test-quality-ok: the endpoint reads the proxy singletons at call time
|
|
patch("litellm.aget_responses", side_effect=AssertionError("the configured deployment answers billing reads")),
|
|
):
|
|
await getattr(endpoints, endpoint_name)(
|
|
response_id=stored.response_id,
|
|
request=_request_for("GET", f"/v1/responses/{stored.response_id}", read_body),
|
|
fastapi_response=fastapi_response,
|
|
user_api_key_dict=UserAPIKeyAuth(api_key="sk-reader"),
|
|
)
|
|
await _drain_spend_events()
|
|
|
|
(billed,) = spend_events.payloads
|
|
assert billed["prompt_tokens"] == billed_input_tokens
|
|
assert router.aget_responses.await_count == deployment_fetches
|
|
assert fastapi_response.headers.getlist("x-litellm-response-cost") == _billed_costs(spend_events.payloads)
|
|
(row,) = stored.table.rows
|
|
assert row.status == "completed"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deleting_a_finished_unbilled_background_response_bills_it_before_the_provider_forgets_it(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
"""A delete leaves the cost poll nothing to fetch, so a finished response no read has billed yet
|
|
is billed from the configured deployment before the delete reaches the provider."""
|
|
from fastapi import Response as FastAPIResponse
|
|
|
|
from litellm.integrations.custom_logger import CustomLogger
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.response_api_endpoints.endpoints import delete_response
|
|
from litellm.types.responses.main import DeleteResponseResult
|
|
|
|
class _CapturingLogger(_SpendEvents, CustomLogger):
|
|
pass
|
|
|
|
spend_events: Final = _CapturingLogger()
|
|
monkeypatch.setattr(litellm, "_async_success_callback", [spend_events])
|
|
stored: Final = _one_pending_background_response()
|
|
deleted: Final[list[str]] = []
|
|
|
|
async def provider_fetch(response_id: str, litellm_metadata: Mapping[str, object]) -> ResponsesAPIResponse:
|
|
if deleted:
|
|
raise litellm.NotFoundError(message="response not found", model="gpt-5.6", llm_provider="openai")
|
|
return _background_response(response_id, "completed", 100)
|
|
|
|
async def provider_delete(fastapi_response: FastAPIResponse, **_: object) -> DeleteResponseResult:
|
|
deleted.append(stored.response_id)
|
|
fastapi_response.headers["x-litellm-response-cost"] = "0.0"
|
|
return DeleteResponseResult(id=stored.response_id, object="response", deleted=True)
|
|
|
|
router: Final = _configured_deployment_router(_background_response(stored.response_id, "completed", 100))
|
|
router.aget_responses = AsyncMock(side_effect=provider_fetch)
|
|
processor: Final = MagicMock()
|
|
processor.base_process_llm_request = AsyncMock(side_effect=provider_delete)
|
|
fastapi_response: Final = FastAPIResponse()
|
|
|
|
with (
|
|
patch( # test-quality-ok: endpoint constructs the processor directly
|
|
"litellm.proxy.response_api_endpoints.endpoints.ProxyBaseLLMRequestProcessing",
|
|
return_value=processor,
|
|
),
|
|
patch("litellm.proxy.proxy_server.prisma_client", stored.prisma_client), # test-quality-ok: the endpoint reads the proxy singletons at call time
|
|
patch("litellm.proxy.proxy_server.llm_router", router), # test-quality-ok: the endpoint reads the proxy singletons at call time
|
|
patch("litellm.proxy.proxy_server.proxy_logging_obj", stored.proxy_logging), # test-quality-ok: the endpoint reads the proxy singletons at call time
|
|
):
|
|
result: Final = await delete_response(
|
|
response_id=stored.response_id,
|
|
request=_request_for("DELETE", f"/v1/responses/{stored.response_id}", b""),
|
|
fastapi_response=fastapi_response,
|
|
user_api_key_dict=UserAPIKeyAuth(api_key="sk-reader"),
|
|
)
|
|
await _drain_spend_events()
|
|
|
|
assert result.deleted is True
|
|
(billed,) = spend_events.payloads
|
|
assert billed["prompt_tokens"] == 100
|
|
assert fastapi_response.headers.getlist("x-litellm-response-cost") == _billed_costs(spend_events.payloads)
|
|
(row,) = stored.table.rows
|
|
assert row.status == "completed"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("caller_role, billed_count", [("proxy_admin", 1), ("internal_user", 0)])
|
|
async def test_a_delete_the_proxy_refuses_never_fetches_or_bills_the_response(caller_role: str, billed_count: int):
|
|
"""A delete settles the bill only for a caller the response id check lets through, so a key that
|
|
cannot address the response gets its 403 without the proxy fetching or billing it first."""
|
|
from fastapi import HTTPException
|
|
from fastapi import Response as FastAPIResponse
|
|
|
|
from litellm.integrations.custom_logger import CustomLogger
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.hooks.responses_id_security import ResponsesIDSecurity
|
|
from litellm.proxy.response_api_endpoints.endpoints import delete_response
|
|
from litellm.types.responses.main import DeleteResponseResult
|
|
|
|
class _CapturingLogger(_SpendEvents, CustomLogger):
|
|
pass
|
|
|
|
spend_events: Final = _CapturingLogger()
|
|
stored: Final = _one_pending_background_response()
|
|
managed_files: Final = stored.proxy_logging.get_proxy_hook.return_value
|
|
security: Final = ResponsesIDSecurity(general_settings_reader=lambda: {}, signing_key_reader=lambda: "salt")
|
|
stored.proxy_logging.get_proxy_hook = MagicMock(
|
|
side_effect=lambda name: security if name == "responses_id_security" else managed_files
|
|
)
|
|
router: Final = _configured_deployment_router(_background_response(stored.response_id, "completed", 100))
|
|
caller: Final = UserAPIKeyAuth(api_key="sk-caller", user_role=caller_role)
|
|
|
|
async def pipeline(**_: object) -> DeleteResponseResult:
|
|
await security.async_pre_call_hook(caller, MagicMock(), {"response_id": stored.response_id}, "adelete_responses")
|
|
return DeleteResponseResult(id=stored.response_id, object="response", deleted=True)
|
|
|
|
processor: Final = MagicMock()
|
|
processor.base_process_llm_request = AsyncMock(side_effect=pipeline)
|
|
processor.handle_llm_api_exception = AsyncMock(side_effect=lambda e, **_: e)
|
|
|
|
with (
|
|
patch.object(litellm, "_async_success_callback", [spend_events]),
|
|
patch( # test-quality-ok: endpoint constructs the processor directly
|
|
"litellm.proxy.response_api_endpoints.endpoints.ProxyBaseLLMRequestProcessing",
|
|
return_value=processor,
|
|
),
|
|
patch("litellm.proxy.proxy_server.prisma_client", stored.prisma_client), # test-quality-ok: the endpoint reads the proxy singletons at call time
|
|
patch("litellm.proxy.proxy_server.llm_router", router), # test-quality-ok: the endpoint reads the proxy singletons at call time
|
|
patch("litellm.proxy.proxy_server.proxy_logging_obj", stored.proxy_logging), # test-quality-ok: the endpoint reads the proxy singletons at call time
|
|
):
|
|
with contextlib.suppress(HTTPException):
|
|
await delete_response(
|
|
response_id=stored.response_id,
|
|
request=_request_for("DELETE", f"/v1/responses/{stored.response_id}", b""),
|
|
fastapi_response=FastAPIResponse(),
|
|
user_api_key_dict=caller,
|
|
)
|
|
await _drain_spend_events()
|
|
|
|
assert len(spend_events.payloads) == billed_count
|
|
assert router.aget_responses.await_count == billed_count
|
|
|
|
|
|
def _proxy_issued_id(provider_response_id: str) -> str:
|
|
from litellm.responses.utils import ResponsesAPIRequestUtils
|
|
|
|
return ResponsesAPIRequestUtils._build_responses_api_response_id(
|
|
custom_llm_provider="openai", model_id="deployment-read", response_id=provider_response_id
|
|
)
|
|
|
|
|
|
def _managed_files_over(table: _UniqueManagedObjectTable) -> tuple[MagicMock, MagicMock]:
|
|
from litellm_enterprise.proxy.hooks.managed_files import _PROXY_LiteLLMManagedFiles
|
|
|
|
from litellm.caching.caching import DualCache
|
|
|
|
prisma_client: Final = MagicMock()
|
|
prisma_client.db.litellm_managedobjecttable = table
|
|
prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=None)
|
|
prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(return_value=None)
|
|
prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=None)
|
|
proxy_logging: Final = MagicMock()
|
|
proxy_logging.get_proxy_hook = MagicMock(
|
|
return_value=_PROXY_LiteLLMManagedFiles(internal_usage_cache=DualCache(), prisma_client=prisma_client)
|
|
)
|
|
return prisma_client, proxy_logging
|
|
|
|
|
|
def _capture_spend_events(monkeypatch: pytest.MonkeyPatch) -> _SpendEvents:
|
|
from litellm.integrations.custom_logger import CustomLogger
|
|
|
|
class _CapturingLogger(_SpendEvents, CustomLogger):
|
|
pass
|
|
|
|
spend_events: Final = _CapturingLogger()
|
|
monkeypatch.setattr(litellm, "_async_success_callback", [spend_events])
|
|
return spend_events
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _proxy_over(prisma_client: MagicMock, proxy_logging: MagicMock, router: MagicMock):
|
|
with (
|
|
patch("litellm.proxy.proxy_server.prisma_client", prisma_client), # test-quality-ok: the endpoint reads the proxy singletons at call time
|
|
patch("litellm.proxy.proxy_server.llm_router", router), # test-quality-ok: the endpoint reads the proxy singletons at call time
|
|
patch("litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging), # test-quality-ok: the endpoint reads the proxy singletons at call time
|
|
):
|
|
yield
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("completed_after_last_event", [False, True], ids=["before_last_event", "after_last_event"])
|
|
async def test_a_streamed_background_create_bills_its_finished_response_once_under_the_creator(
|
|
monkeypatch: pytest.MonkeyPatch, completed_after_last_event: bool
|
|
):
|
|
"""A tracked streamed background create is free on its own and bills the finished response once
|
|
through its managed row, under the creating key and tags, before the client sees it finish
|
|
whenever the stream records the finished response in time, and no later read bills it again."""
|
|
from fastapi import Response as FastAPIResponse
|
|
|
|
from litellm.proxy._types import UserAPIKeyAuth, hash_token
|
|
from litellm.proxy.response_api_endpoints.endpoints import (
|
|
_bill_finished_background_response,
|
|
_streamed_background_data_generator,
|
|
)
|
|
|
|
spend_events: Final = _capture_spend_events(monkeypatch)
|
|
table: Final = _UniqueManagedObjectTable()
|
|
prisma_client, proxy_logging = _managed_files_over(table)
|
|
finished: Final = _background_response(_proxy_issued_id("resp_streamed"), "completed", 100)
|
|
stream: Final = MagicMock(completed_response=None)
|
|
|
|
async def provider_events(**_: object):
|
|
yield "event: response.created\n\n"
|
|
if not completed_after_last_event:
|
|
stream.completed_response = MagicMock(response=finished)
|
|
yield "event: response.completed\n\n"
|
|
stream.completed_response = MagicMock(response=finished)
|
|
|
|
billed_before_each_event: Final[list[int]] = []
|
|
with _proxy_over(prisma_client, proxy_logging, _configured_deployment_router(finished)):
|
|
async for _ in _streamed_background_data_generator(
|
|
provider_events,
|
|
proxy_logging,
|
|
stream,
|
|
UserAPIKeyAuth(api_key="sk-creator"),
|
|
{"litellm_metadata": {"tags": ["nightly"]}},
|
|
):
|
|
billed_before_each_event.append(len(table.rows))
|
|
await _bill_finished_background_response(
|
|
response=finished,
|
|
response_id=finished.id,
|
|
user_api_key_dict=UserAPIKeyAuth(api_key="sk-reader"),
|
|
fastapi_response=FastAPIResponse(),
|
|
read_settles_billing=True,
|
|
)
|
|
await _drain_spend_events()
|
|
|
|
assert billed_before_each_event == ([0, 0] if completed_after_last_event else [0, 1])
|
|
(billed,) = spend_events.payloads
|
|
assert billed["metadata"]["user_api_key_hash"] == hash_token("sk-creator")
|
|
assert billed["request_tags"] == ["nightly"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_read_that_bills_a_response_before_its_stream_ends_leaves_the_stream_nothing_to_bill(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
"""A read can see a streamed background response finished before a slow client lets the stream
|
|
reach its last event, and then the stream bills nothing on top of the read."""
|
|
from fastapi import Response as FastAPIResponse
|
|
|
|
from litellm.proxy._types import UserAPIKeyAuth, hash_token
|
|
from litellm.proxy.response_api_endpoints.endpoints import (
|
|
_bill_finished_background_response,
|
|
_streamed_background_data_generator,
|
|
)
|
|
|
|
spend_events: Final = _capture_spend_events(monkeypatch)
|
|
table: Final = _UniqueManagedObjectTable()
|
|
prisma_client, proxy_logging = _managed_files_over(table)
|
|
finished: Final = _background_response(_proxy_issued_id("resp_raced"), "completed", 100)
|
|
stream: Final = MagicMock(completed_response=None)
|
|
|
|
async def provider_events(**_: object):
|
|
yield "event: response.created\n\n"
|
|
await _bill_finished_background_response(
|
|
response=finished,
|
|
response_id=finished.id,
|
|
user_api_key_dict=UserAPIKeyAuth(api_key="sk-reader"),
|
|
fastapi_response=FastAPIResponse(),
|
|
read_settles_billing=True,
|
|
)
|
|
stream.completed_response = MagicMock(response=finished)
|
|
yield "event: response.completed\n\n"
|
|
|
|
with _proxy_over(prisma_client, proxy_logging, _configured_deployment_router(finished)):
|
|
async for _ in _streamed_background_data_generator(
|
|
provider_events, proxy_logging, stream, UserAPIKeyAuth(api_key="sk-creator"), {}
|
|
):
|
|
pass
|
|
await _drain_spend_events()
|
|
|
|
(billed,) = spend_events.payloads
|
|
assert billed["metadata"]["user_api_key_hash"] == hash_token("sk-reader")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"background_fields",
|
|
[{"background": True}, {"extra_body": {"background": True}}],
|
|
ids=["top_level", "extra_body"],
|
|
)
|
|
async def test_a_streamed_background_create_is_billed_wherever_it_sets_background(
|
|
monkeypatch: pytest.MonkeyPatch, background_fields: Mapping[str, object]
|
|
):
|
|
"""A streamed create that sets background inside extra_body runs as a background job all the
|
|
same, so its stream bills the finished response once."""
|
|
from fastapi import Response as FastAPIResponse
|
|
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
|
|
from litellm.proxy.response_api_endpoints.endpoints import responses_api
|
|
|
|
spend_events: Final = _capture_spend_events(monkeypatch)
|
|
table: Final = _UniqueManagedObjectTable()
|
|
prisma_client, proxy_logging = _managed_files_over(table)
|
|
finished: Final = _background_response(_proxy_issued_id("resp_streamed_create"), "completed", 100)
|
|
stream: Final = MagicMock(completed_response=None)
|
|
|
|
async def provider_events(**_: object):
|
|
stream.completed_response = MagicMock(response=finished)
|
|
yield "event: response.completed\n\n"
|
|
|
|
async def stream_to_client(select_data_generator, **_: object) -> MagicMock:
|
|
async for _ in select_data_generator(response=stream, user_api_key_dict=UserAPIKeyAuth(), request_data={}):
|
|
pass
|
|
return MagicMock()
|
|
|
|
processor: Final = MagicMock()
|
|
processor.base_process_llm_request = AsyncMock(side_effect=stream_to_client)
|
|
processor_class: Final = MagicMock(
|
|
return_value=processor,
|
|
extract_completed_responses_response=ProxyBaseLLMRequestProcessing.extract_completed_responses_response,
|
|
)
|
|
body: Final = json.dumps({"model": "gpt-5.6", "input": "hello", "stream": True, **background_fields}).encode()
|
|
|
|
with (
|
|
_proxy_over(prisma_client, proxy_logging, _configured_deployment_router(finished)),
|
|
patch( # test-quality-ok: endpoint constructs the processor directly
|
|
"litellm.proxy.response_api_endpoints.endpoints.ProxyBaseLLMRequestProcessing", processor_class
|
|
),
|
|
patch( # test-quality-ok: polling decision is imported inside the endpoint
|
|
"litellm.proxy.response_polling.polling_handler.should_use_polling_for_request",
|
|
return_value=False,
|
|
),
|
|
patch("litellm.proxy.proxy_server.select_data_generator", provider_events), # test-quality-ok: the endpoint reads the proxy singletons at call time
|
|
):
|
|
await responses_api(
|
|
request=_request_for("POST", "/v1/responses", body),
|
|
fastapi_response=FastAPIResponse(),
|
|
user_api_key_dict=UserAPIKeyAuth(api_key="sk-creator"),
|
|
)
|
|
await _drain_spend_events()
|
|
|
|
assert len(spend_events.payloads) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_retargeted_read_of_a_response_nobody_tracked_bills_it_once_from_the_deployment(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
"""A read that carries its own api_base returns whatever usage that endpoint claims, so a finished
|
|
response with no managed row is tracked and billed from a fetch through the configured deployment,
|
|
once even when two pods read it at the same moment."""
|
|
from fastapi import Response as FastAPIResponse
|
|
|
|
from litellm.proxy._types import UserAPIKeyAuth, hash_token
|
|
from litellm.proxy.response_api_endpoints.endpoints import _bill_finished_background_response
|
|
|
|
spend_events: Final = _capture_spend_events(monkeypatch)
|
|
table: Final = _UniqueManagedObjectTable()
|
|
prisma_client, proxy_logging = _managed_files_over(table)
|
|
response_id: Final = _proxy_issued_id("resp_untracked_override")
|
|
router: Final = _configured_deployment_router(_background_response(response_id, "completed", 100))
|
|
|
|
with _proxy_over(prisma_client, proxy_logging, router):
|
|
await asyncio.gather(
|
|
*(
|
|
_bill_finished_background_response(
|
|
response=_background_response(response_id, "completed", 1000),
|
|
response_id=response_id,
|
|
user_api_key_dict=UserAPIKeyAuth(api_key="sk-reader"),
|
|
fastapi_response=FastAPIResponse(),
|
|
read_settles_billing=False,
|
|
)
|
|
for _ in range(2)
|
|
)
|
|
)
|
|
await _drain_spend_events()
|
|
|
|
(billed,) = spend_events.payloads
|
|
assert billed["prompt_tokens"] == 100
|
|
assert billed["metadata"]["user_api_key_hash"] == hash_token("sk-reader")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_bill_that_fails_to_settle_is_retried_in_the_background(monkeypatch: pytest.MonkeyPatch):
|
|
"""A read is free once marked, so a bill that fails while the read settles it (a database blip)
|
|
is retried in the background instead of waiting on a cost poll that may be turned off."""
|
|
from litellm.proxy.response_api_endpoints import endpoints
|
|
|
|
spend_events: Final = _capture_spend_events(monkeypatch)
|
|
stored: Final = _one_pending_background_response()
|
|
finished: Final = _background_response(stored.response_id, "completed", 100)
|
|
attempts: Final[list[int]] = []
|
|
|
|
async def bill_after_one_failure(cost_check: endpoints._BackgroundCostCheck):
|
|
attempts.append(len(attempts))
|
|
if len(attempts) == 1:
|
|
raise RuntimeError("database unavailable")
|
|
return await cost_check.bill_if_finished(stored.response_id, finished)
|
|
|
|
with _proxy_over(stored.prisma_client, stored.proxy_logging, _configured_deployment_router(finished)):
|
|
assert await endpoints._settle_background_billing(bill_after_one_failure, retry_delays=(0.0, 0.0)) is None
|
|
await asyncio.gather(*endpoints._billing_retries)
|
|
await _drain_spend_events()
|
|
|
|
assert len(attempts) == 2
|
|
assert len(spend_events.payloads) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_background_create_the_provider_confirms_is_stored_under_its_provider_id(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
"""A create that sets background through extra_body reaches the provider as a background job
|
|
all the same, so the proxy stores it off the response, keyed by the id every re-encryption of
|
|
the client's id decrypts to."""
|
|
from fastapi import Response as FastAPIResponse
|
|
from starlette.requests import Request
|
|
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.hooks.responses_id_security import ResponsesIDSecurity
|
|
from litellm.proxy.response_api_endpoints.endpoints import responses_api
|
|
|
|
salt_key = "background-create-unit-test-salt"
|
|
monkeypatch.setenv("LITELLM_SALT_KEY", salt_key)
|
|
queued = ResponsesIDSecurity(
|
|
general_settings_reader=lambda: {}, signing_key_reader=lambda: salt_key
|
|
)._encrypt_response_id(
|
|
ResponsesAPIResponse(
|
|
id="resp_provider_job", object="response", status="queued", created_at=1, output=[], background=True
|
|
),
|
|
UserAPIKeyAuth(user_id="alice"),
|
|
)
|
|
queued._hidden_params = {"model_id": "deployment-1"}
|
|
processor = MagicMock()
|
|
processor.data = {"model": "gpt-4o", "input": "hello", "extra_body": {"background": True}}
|
|
processor.base_process_llm_request = AsyncMock(return_value=queued)
|
|
managed_files = MagicMock()
|
|
managed_files.store_unified_object_id = AsyncMock()
|
|
managed_files.track_untracked_background_response = AsyncMock(return_value=False)
|
|
proxy_logging = MagicMock()
|
|
proxy_logging.get_proxy_hook = MagicMock(return_value=managed_files)
|
|
|
|
async def receive():
|
|
return {
|
|
"type": "http.request",
|
|
"body": b'{"model":"gpt-4o","input":"hello","extra_body":{"background":true}}',
|
|
"more_body": False,
|
|
}
|
|
|
|
request = Request(
|
|
{"type": "http", "method": "POST", "path": "/v1/responses", "headers": [(b"content-type", b"application/json")]},
|
|
receive,
|
|
)
|
|
|
|
with (
|
|
patch( # test-quality-ok: endpoint constructs the processor directly
|
|
"litellm.proxy.response_api_endpoints.endpoints.ProxyBaseLLMRequestProcessing",
|
|
return_value=processor,
|
|
),
|
|
patch( # test-quality-ok: polling decision is imported inside the endpoint
|
|
"litellm.proxy.response_polling.polling_handler.should_use_polling_for_request",
|
|
return_value=False,
|
|
),
|
|
patch( # test-quality-ok: the endpoint reads the proxy singletons at call time
|
|
"litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging
|
|
),
|
|
patch( # test-quality-ok: the endpoint reads the proxy singletons at call time
|
|
"litellm.proxy.proxy_server.llm_router", MagicMock()
|
|
),
|
|
):
|
|
await responses_api(
|
|
request=request, fastapi_response=FastAPIResponse(), user_api_key_dict=UserAPIKeyAuth(api_key="sk-test")
|
|
)
|
|
|
|
store_kwargs = managed_files.store_unified_object_id.await_args.kwargs
|
|
assert store_kwargs["unified_object_id"] == queued.id
|
|
assert store_kwargs["model_object_id"] == "resp_provider_job"
|
|
|
|
|
|
class TestResponsesAPIEndpoints(unittest.TestCase):
|
|
@pytest.mark.asyncio
|
|
@patch("litellm.proxy.proxy_server.llm_router")
|
|
@patch("litellm.proxy.proxy_server.user_api_key_auth")
|
|
async def test_openai_v1_responses_route(self, mock_auth, mock_router):
|
|
"""
|
|
Test that /openai/v1/responses endpoint is correctly registered and accessible.
|
|
"""
|
|
mock_auth.return_value = MagicMock(
|
|
token="test_token",
|
|
user_id="test_user",
|
|
team_id=None,
|
|
)
|
|
|
|
mock_router.aresponses = AsyncMock(
|
|
return_value={
|
|
"id": "resp_abc123",
|
|
"object": "realtime.response",
|
|
"status": "completed",
|
|
"output": [
|
|
{
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "text", "text": "Test response"}],
|
|
}
|
|
],
|
|
}
|
|
)
|
|
|
|
client = TestClient(app)
|
|
|
|
test_data = {"model": "gpt-4o", "input": "Tell me about AI"}
|
|
|
|
response = client.post(
|
|
"/openai/v1/responses",
|
|
json=test_data,
|
|
headers={"Authorization": "Bearer sk-9876"},
|
|
)
|
|
|
|
assert response.status_code in [200, 401, 500]
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("litellm.proxy.proxy_server.llm_router")
|
|
@patch("litellm.proxy.proxy_server.user_api_key_auth")
|
|
async def test_cursor_chat_completions_route(self, mock_auth, mock_router):
|
|
"""
|
|
Test that /cursor/chat/completions endpoint:
|
|
1. Accepts Responses API input format
|
|
2. Returns chat completions format response
|
|
3. Transforms streaming responses correctly
|
|
"""
|
|
from litellm.types.llms.openai import ResponsesAPIResponse
|
|
from litellm.types.utils import ResponseOutputMessage, ResponseOutputText
|
|
|
|
mock_auth.return_value = MagicMock(
|
|
token="test_token",
|
|
user_id="test_user",
|
|
team_id=None,
|
|
)
|
|
|
|
# Mock a Responses API response
|
|
mock_responses_response = ResponsesAPIResponse(
|
|
id="resp_cursor123",
|
|
created_at=1234567890,
|
|
model="gpt-4o",
|
|
object="response",
|
|
output=[
|
|
ResponseOutputMessage(
|
|
type="message",
|
|
role="assistant",
|
|
content=[ResponseOutputText(type="output_text", text="Hello from Cursor!")],
|
|
)
|
|
],
|
|
)
|
|
|
|
mock_router.aresponses = AsyncMock(return_value=mock_responses_response)
|
|
|
|
client = TestClient(app)
|
|
|
|
# Test with Responses API input format (what Cursor sends)
|
|
test_data = {
|
|
"model": "gpt-4o",
|
|
"input": [{"role": "user", "content": "Hello"}],
|
|
}
|
|
|
|
response = client.post(
|
|
"/cursor/chat/completions",
|
|
json=test_data,
|
|
headers={"Authorization": "Bearer sk-9876"},
|
|
)
|
|
|
|
# Should return 200 (or 401/500 if auth fails)
|
|
assert response.status_code in [200, 401, 500]
|
|
|
|
# If successful, verify it returns chat completions format
|
|
if response.status_code == 200:
|
|
response_data = response.json()
|
|
# Should have chat completion structure
|
|
assert "choices" in response_data or "id" in response_data
|
|
# Should not have Responses API structure
|
|
assert "output" not in response_data or "status" not in response_data
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("litellm.proxy.proxy_server.llm_router")
|
|
@patch("litellm.proxy.proxy_server.user_api_key_auth")
|
|
async def test_responses_api_key_spend_header_includes_response_cost(self, mock_auth, mock_router):
|
|
"""
|
|
Test that x-litellm-key-spend header includes the current request's response_cost
|
|
for /v1/responses endpoint.
|
|
|
|
This ensures the spend header reflects updated spend including the current request,
|
|
even though spend tracking updates happen asynchronously after the response.
|
|
"""
|
|
from litellm.types.llms.openai import ResponsesAPIResponse
|
|
from litellm.types.utils import ResponseOutputMessage, ResponseOutputText
|
|
|
|
# Create mock user API key with initial spend
|
|
mock_user_api_key_dict = MagicMock()
|
|
mock_user_api_key_dict.token = "test_token"
|
|
mock_user_api_key_dict.user_id = "test_user"
|
|
mock_user_api_key_dict.team_id = None
|
|
mock_user_api_key_dict.spend = 0.001 # Initial spend: $0.001
|
|
mock_user_api_key_dict.tpm_limit = None
|
|
mock_user_api_key_dict.rpm_limit = None
|
|
mock_user_api_key_dict.max_budget = None
|
|
mock_user_api_key_dict.allowed_model_region = None
|
|
mock_user_api_key_dict.api_key = "sk-test-key"
|
|
mock_user_api_key_dict.metadata = {}
|
|
|
|
mock_auth.return_value = mock_user_api_key_dict
|
|
|
|
# Mock response with hidden_params containing response_cost
|
|
mock_response = ResponsesAPIResponse(
|
|
id="resp_test123",
|
|
created_at=1234567890,
|
|
model="gpt-4o",
|
|
object="response",
|
|
output=[
|
|
ResponseOutputMessage(
|
|
type="message",
|
|
role="assistant",
|
|
content=[ResponseOutputText(type="output_text", text="Test response")],
|
|
)
|
|
],
|
|
)
|
|
|
|
# Add hidden_params with response_cost to the mock response
|
|
mock_response._hidden_params = {
|
|
"response_cost": 0.0005, # Current request cost: $0.0005
|
|
"model_id": "test-model-id",
|
|
}
|
|
|
|
mock_router.aresponses = AsyncMock(return_value=mock_response)
|
|
|
|
client = TestClient(app)
|
|
|
|
test_data = {"model": "gpt-4o", "input": "Tell me about AI"}
|
|
|
|
response = client.post(
|
|
"/v1/responses",
|
|
json=test_data,
|
|
headers={"Authorization": "Bearer sk-test-key"},
|
|
)
|
|
|
|
# Verify the response was successful
|
|
assert response.status_code == 200
|
|
|
|
# Verify x-litellm-key-spend header includes current request cost
|
|
assert "x-litellm-key-spend" in response.headers
|
|
key_spend_value = float(response.headers["x-litellm-key-spend"])
|
|
expected_spend = 0.001 + 0.0005 # Initial spend + current request cost
|
|
assert key_spend_value == pytest.approx(expected_spend, abs=1e-10)
|
|
|
|
# Verify x-litellm-response-cost header is present
|
|
assert "x-litellm-response-cost" in response.headers
|
|
response_cost_value = float(response.headers["x-litellm-response-cost"])
|
|
assert response_cost_value == pytest.approx(0.0005, abs=1e-10)
|
|
|
|
|
|
class TestManagedResponsesWSFirstMessage:
|
|
@pytest.mark.asyncio
|
|
async def test_first_message_processed_before_loop(self):
|
|
"""
|
|
ManagedResponsesWebSocketHandler must process first_message before
|
|
entering its receive loop. Regression for clients that connect without
|
|
?model= (e.g. Codex) and send model inside the first response.create event.
|
|
"""
|
|
from litellm.responses.streaming_iterator import ManagedResponsesWebSocketHandler
|
|
|
|
first = json.dumps(
|
|
{
|
|
"type": "response.create",
|
|
"model": "gpt-4o-mini",
|
|
"store": False,
|
|
"input": [
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "hi"}],
|
|
}
|
|
],
|
|
}
|
|
)
|
|
|
|
ws = MagicMock()
|
|
ws.receive_text = AsyncMock(side_effect=Exception("disconnect"))
|
|
ws.send_text = AsyncMock()
|
|
|
|
processed: list = []
|
|
|
|
async def fake_process(msg: str) -> None:
|
|
processed.append(msg)
|
|
|
|
handler = ManagedResponsesWebSocketHandler(
|
|
websocket=ws,
|
|
model="gpt-4o-mini",
|
|
logging_obj=MagicMock(),
|
|
first_message=first,
|
|
)
|
|
handler._process_response_create = fake_process # type: ignore[method-assign]
|
|
|
|
await handler.run()
|
|
|
|
assert processed == [first]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_first_message_falls_through_to_loop(self):
|
|
"""When first_message is None, run() goes straight to receive_text()."""
|
|
from litellm.responses.streaming_iterator import ManagedResponsesWebSocketHandler
|
|
|
|
subsequent = json.dumps({"type": "response.create", "model": "gpt-4o-mini"})
|
|
|
|
ws = MagicMock()
|
|
ws.receive_text = AsyncMock(side_effect=[subsequent, Exception("disconnect")])
|
|
ws.send_text = AsyncMock()
|
|
|
|
processed: list = []
|
|
|
|
async def fake_process(msg: str) -> None:
|
|
processed.append(msg)
|
|
|
|
handler = ManagedResponsesWebSocketHandler(
|
|
websocket=ws,
|
|
model="gpt-4o-mini",
|
|
logging_obj=MagicMock(),
|
|
first_message=None,
|
|
)
|
|
handler._process_response_create = fake_process # type: ignore[method-assign]
|
|
|
|
await handler.run()
|
|
|
|
assert processed == [subsequent]
|
|
|
|
|
|
class TestResponsesWSStreamingFirstMessage:
|
|
@pytest.mark.asyncio
|
|
async def test_client_to_backend_replays_first_message(self):
|
|
"""
|
|
ResponsesWebSocketStreaming.client_to_backend must send first_message to
|
|
the backend before entering the receive loop.
|
|
"""
|
|
from litellm.responses.streaming_iterator import ResponsesWebSocketStreaming
|
|
|
|
first = json.dumps({"type": "response.create", "model": "gpt-4o-mini", "input": []})
|
|
|
|
ws = MagicMock()
|
|
ws.receive_text = AsyncMock(side_effect=Exception("disconnect"))
|
|
|
|
backend_ws = MagicMock()
|
|
backend_ws.send = AsyncMock()
|
|
|
|
streaming = ResponsesWebSocketStreaming(
|
|
websocket=ws,
|
|
backend_ws=backend_ws,
|
|
logging_obj=MagicMock(),
|
|
first_message=first,
|
|
)
|
|
|
|
await streaming.client_to_backend()
|
|
|
|
backend_ws.send.assert_awaited_once_with(first)
|
|
|
|
|
|
class TestWSSessionCostTracking:
|
|
@pytest.mark.asyncio
|
|
async def test_router_budget_limiter_skips_aresponses_websocket_call_type(self):
|
|
"""
|
|
RouterBudgetLimiting.async_log_success_event must not raise when
|
|
call_type='_aresponses_websocket', even when standard_logging_object is None.
|
|
Per-turn costs are tracked by individual aresponses calls inside the session;
|
|
the outer session wrapper fires with result=None.
|
|
"""
|
|
from litellm.router_strategy.budget_limiter import RouterBudgetLimiting
|
|
|
|
limiter = RouterBudgetLimiting.__new__(RouterBudgetLimiting)
|
|
kwargs = {
|
|
"call_type": "_aresponses_websocket",
|
|
"standard_logging_object": None,
|
|
"litellm_params": {"custom_llm_provider": "vertex_ai"},
|
|
}
|
|
await limiter.async_log_success_event(
|
|
kwargs=kwargs,
|
|
response_obj=None,
|
|
start_time=None,
|
|
end_time=None,
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_router_budget_limiter_skips_arealtime_call_type(self):
|
|
"""Same guard applies to _arealtime WS session wrappers."""
|
|
from litellm.router_strategy.budget_limiter import RouterBudgetLimiting
|
|
|
|
limiter = RouterBudgetLimiting.__new__(RouterBudgetLimiting)
|
|
kwargs = {
|
|
"call_type": "_arealtime",
|
|
"standard_logging_object": None,
|
|
"litellm_params": {"custom_llm_provider": "openai"},
|
|
}
|
|
await limiter.async_log_success_event(
|
|
kwargs=kwargs,
|
|
response_obj=None,
|
|
start_time=None,
|
|
end_time=None,
|
|
)
|
|
|
|
|
|
class TestWSModelExtraction:
|
|
"""Test _extract_model_from_first_ws_event for flat and nested frame formats."""
|
|
|
|
def test_flat_format_extracts_model(self):
|
|
from litellm.proxy.response_api_endpoints.endpoints import (
|
|
_extract_model_from_first_ws_event,
|
|
)
|
|
|
|
event = {"type": "response.create", "model": "gpt-4o", "input": "hello"}
|
|
assert _extract_model_from_first_ws_event(event) == "gpt-4o"
|
|
|
|
def test_nested_format_extracts_model(self):
|
|
from litellm.proxy.response_api_endpoints.endpoints import (
|
|
_extract_model_from_first_ws_event,
|
|
)
|
|
|
|
event = {"type": "response.create", "response": {"model": "gpt-4o", "input": "hello"}}
|
|
assert _extract_model_from_first_ws_event(event) == "gpt-4o"
|
|
|
|
def test_nested_format_takes_precedence_over_flat(self):
|
|
from litellm.proxy.response_api_endpoints.endpoints import (
|
|
_extract_model_from_first_ws_event,
|
|
)
|
|
|
|
event = {
|
|
"type": "response.create",
|
|
"model": "flat-model",
|
|
"response": {"model": "nested-model"},
|
|
}
|
|
assert _extract_model_from_first_ws_event(event) == "nested-model"
|
|
|
|
def test_no_model_returns_none(self):
|
|
from litellm.proxy.response_api_endpoints.endpoints import (
|
|
_extract_model_from_first_ws_event,
|
|
)
|
|
|
|
event = {"type": "response.create", "input": "hello"}
|
|
assert _extract_model_from_first_ws_event(event) is None
|
|
|
|
def test_non_object_returns_none(self):
|
|
from litellm.proxy.response_api_endpoints.endpoints import (
|
|
_extract_model_from_first_ws_event,
|
|
)
|
|
|
|
assert _extract_model_from_first_ws_event([]) is None
|
|
|
|
|
|
class TestResponsesWSFirstFrameValidation:
|
|
@pytest.mark.asyncio
|
|
async def test_rejects_non_response_create_first_frame(self):
|
|
from litellm.proxy.response_api_endpoints.endpoints import (
|
|
_read_ws_model_from_first_frame,
|
|
)
|
|
|
|
ws = MagicMock()
|
|
ws.receive_text = AsyncMock(return_value=json.dumps({"type": "session.update", "model": "gpt-4o"}))
|
|
ws.send_text = AsyncMock()
|
|
ws.close = AsyncMock()
|
|
|
|
result = await _read_ws_model_from_first_frame(ws)
|
|
|
|
assert result is None
|
|
ws.send_text.assert_awaited_once()
|
|
ws.close.assert_awaited_once_with(code=1008, reason="Invalid first message")
|
|
error_payload = json.loads(ws.send_text.await_args.args[0])
|
|
assert error_payload["error"]["message"] == "First message must be a response.create JSON object."
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rejects_non_object_json_first_frame(self):
|
|
from litellm.proxy.response_api_endpoints.endpoints import (
|
|
_read_ws_model_from_first_frame,
|
|
)
|
|
|
|
ws = MagicMock()
|
|
ws.receive_text = AsyncMock(return_value=json.dumps(["gpt-4o"]))
|
|
ws.send_text = AsyncMock()
|
|
ws.close = AsyncMock()
|
|
|
|
result = await _read_ws_model_from_first_frame(ws)
|
|
|
|
assert result is None
|
|
ws.send_text.assert_awaited_once()
|
|
ws.close.assert_awaited_once_with(code=1008, reason="Invalid first message")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_client_disconnect_first_frame_does_not_close(self):
|
|
from fastapi import WebSocketDisconnect
|
|
|
|
from litellm.proxy.response_api_endpoints.endpoints import (
|
|
_read_ws_model_from_first_frame,
|
|
)
|
|
|
|
ws = MagicMock()
|
|
ws.receive_text = AsyncMock(side_effect=WebSocketDisconnect(code=1006))
|
|
ws.send_text = AsyncMock()
|
|
ws.close = AsyncMock()
|
|
|
|
result = await _read_ws_model_from_first_frame(ws)
|
|
|
|
assert result is None
|
|
ws.close.assert_not_awaited()
|
|
ws.send_text.assert_not_awaited()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_server_error_first_frame_closes_with_internal_error(self):
|
|
from litellm.proxy.response_api_endpoints.endpoints import (
|
|
_read_ws_model_from_first_frame,
|
|
)
|
|
|
|
ws = MagicMock()
|
|
ws.receive_text = AsyncMock(side_effect=RuntimeError("boom"))
|
|
ws.send_text = AsyncMock()
|
|
ws.close = AsyncMock()
|
|
|
|
result = await _read_ws_model_from_first_frame(ws)
|
|
|
|
assert result is None
|
|
ws.close.assert_awaited_once_with(code=1011, reason="Internal server error")
|
|
|
|
|
|
class TestResponsesWSFirstFrameModelAuth:
|
|
@pytest.mark.asyncio
|
|
async def test_endpoint_enforces_auth_after_model_from_first_frame(self):
|
|
from litellm.proxy.response_api_endpoints.endpoints import (
|
|
responses_websocket_endpoint,
|
|
)
|
|
|
|
ws = MagicMock()
|
|
ws.headers = {}
|
|
ws.query_params = {}
|
|
ws.scope = {"headers": []}
|
|
ws.url = "ws://testserver/v1/responses"
|
|
ws.accept = AsyncMock()
|
|
ws.receive_text = AsyncMock(
|
|
return_value=json.dumps({"type": "response.create", "model": "gpt-4o-mini", "input": []})
|
|
)
|
|
ws.close = AsyncMock()
|
|
|
|
processor = MagicMock()
|
|
processor.common_processing_pre_call_logic = AsyncMock(return_value=({"model": "gpt-4o-mini"}, MagicMock()))
|
|
|
|
async def fake_llm_call():
|
|
return None
|
|
|
|
authenticated_models: Final[list[str]] = []
|
|
|
|
async def record_model_auth(*, model: str, **_kwargs: object) -> None:
|
|
authenticated_models.append(model)
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.response_api_endpoints.endpoints._enforce_responses_ws_first_frame_model_auth",
|
|
new_callable=AsyncMock,
|
|
side_effect=record_model_auth,
|
|
),
|
|
patch(
|
|
"litellm.proxy.response_api_endpoints.endpoints.ProxyBaseLLMRequestProcessing",
|
|
return_value=processor,
|
|
),
|
|
patch(
|
|
"litellm.proxy.route_llm_request.route_request",
|
|
new_callable=AsyncMock,
|
|
return_value=fake_llm_call(),
|
|
),
|
|
):
|
|
await responses_websocket_endpoint(
|
|
websocket=ws,
|
|
model=None,
|
|
user_api_key_dict=MagicMock(),
|
|
)
|
|
|
|
assert authenticated_models == ["gpt-4o-mini"]
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("nested", [False, True])
|
|
@pytest.mark.parametrize("query_model", [None, "gpt-4o-mini"])
|
|
async def test_endpoint_routes_on_first_frame_input_and_previous_response_id(
|
|
self, nested: bool, query_model: str | None
|
|
):
|
|
from litellm.proxy.response_api_endpoints.endpoints import (
|
|
responses_websocket_endpoint,
|
|
)
|
|
|
|
replayed_input = [{"type": "reasoning", "id": "encitem_abc", "encrypted_content": "litellm_enc:abc;blob"}]
|
|
payload = {"model": "gpt-4o-mini", "input": replayed_input, "previous_response_id": "resp_prev"}
|
|
first_frame = (
|
|
{"type": "response.create", "response": payload} if nested else {"type": "response.create", **payload}
|
|
)
|
|
raw_first_frame = json.dumps(first_frame)
|
|
|
|
ws = MagicMock()
|
|
ws.headers = {}
|
|
ws.query_params = {}
|
|
ws.scope = {"headers": []}
|
|
ws.url = "ws://testserver/v1/responses"
|
|
ws.accept = AsyncMock()
|
|
ws.receive_text = AsyncMock(return_value=raw_first_frame)
|
|
ws.close = AsyncMock()
|
|
|
|
processor = MagicMock()
|
|
processor.common_processing_pre_call_logic = AsyncMock(
|
|
return_value=({"model": "gpt-4o-mini", "litellm_metadata": {}}, MagicMock())
|
|
)
|
|
|
|
async def fake_llm_call():
|
|
return None
|
|
|
|
with (
|
|
patch( # test-quality-ok: first-frame model auth needs a live router and key table and has its own tests below
|
|
"litellm.proxy.response_api_endpoints.endpoints._enforce_responses_ws_first_frame_model_auth",
|
|
new_callable=AsyncMock,
|
|
),
|
|
patch( # test-quality-ok: the pre-call processor needs a live proxy; the payload it hands to routing is what is under test
|
|
"litellm.proxy.response_api_endpoints.endpoints.ProxyBaseLLMRequestProcessing",
|
|
return_value=processor,
|
|
),
|
|
patch( # test-quality-ok: routing is the seam where the first frame's input and previous_response_id become observable
|
|
"litellm.proxy.route_llm_request.route_request",
|
|
new_callable=AsyncMock,
|
|
return_value=fake_llm_call(),
|
|
) as mock_route_request,
|
|
):
|
|
await responses_websocket_endpoint(
|
|
websocket=ws,
|
|
model=query_model,
|
|
user_api_key_dict=MagicMock(),
|
|
)
|
|
|
|
ws.receive_text.assert_awaited_once()
|
|
routed = mock_route_request.await_args.kwargs["data"]
|
|
assert routed["model"] == "gpt-4o-mini"
|
|
assert routed["input"] == replayed_input
|
|
assert routed["previous_response_id"] == "resp_prev"
|
|
assert processor.common_processing_pre_call_logic.await_args.kwargs["model"] == "gpt-4o-mini"
|
|
assert mock_route_request.await_args.kwargs["route_type"] == "_aresponses_websocket"
|
|
ws.close.assert_not_awaited()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("provider_rejected", [True, False])
|
|
async def test_endpoint_books_a_provider_rejected_connection_as_a_failed_request(self, provider_rejected: bool):
|
|
from litellm.proxy.response_api_endpoints.endpoints import (
|
|
responses_websocket_endpoint,
|
|
)
|
|
|
|
ws = MagicMock()
|
|
ws.headers = {}
|
|
ws.query_params = {}
|
|
ws.scope = {"headers": []}
|
|
ws.url = "ws://testserver/v1/responses"
|
|
ws.accept = AsyncMock()
|
|
ws.receive_text = AsyncMock(
|
|
return_value=json.dumps({"type": "response.create", "model": "gpt-4o-mini", "input": []})
|
|
)
|
|
ws.close = AsyncMock()
|
|
|
|
processor = MagicMock()
|
|
processor.common_processing_pre_call_logic = AsyncMock(
|
|
return_value=({"model": "gpt-4o-mini", "litellm_metadata": {}}, MagicMock())
|
|
)
|
|
failure = litellm.BadRequestError(
|
|
message="invalid_encrypted_content", model="gpt-4o-mini", llm_provider="openai"
|
|
)
|
|
|
|
async def fake_llm_call():
|
|
return failure if provider_rejected else None
|
|
|
|
proxy_logging_obj = MagicMock()
|
|
proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None)
|
|
proxy_logging_obj.arelease_responses_websocket_slot = AsyncMock(return_value=None)
|
|
user_api_key_dict = MagicMock()
|
|
|
|
with (
|
|
patch( # test-quality-ok: first-frame model auth needs a live router and key table and has its own tests above
|
|
"litellm.proxy.response_api_endpoints.endpoints._enforce_responses_ws_first_frame_model_auth",
|
|
new_callable=AsyncMock,
|
|
),
|
|
patch( # test-quality-ok: the pre-call processor needs a live proxy; what the endpoint does with the relay's outcome is under test
|
|
"litellm.proxy.response_api_endpoints.endpoints.ProxyBaseLLMRequestProcessing",
|
|
return_value=processor,
|
|
),
|
|
patch( # test-quality-ok: routing is the seam that hands back the relay's outcome
|
|
"litellm.proxy.route_llm_request.route_request",
|
|
new_callable=AsyncMock,
|
|
return_value=fake_llm_call(),
|
|
),
|
|
patch( # test-quality-ok: the failure hook is the proxy's only path to a failed spend log row
|
|
"litellm.proxy.proxy_server.proxy_logging_obj",
|
|
proxy_logging_obj,
|
|
),
|
|
):
|
|
await responses_websocket_endpoint(
|
|
websocket=ws,
|
|
model=None,
|
|
user_api_key_dict=user_api_key_dict,
|
|
)
|
|
|
|
ws.close.assert_not_awaited()
|
|
if not provider_rejected:
|
|
proxy_logging_obj.post_call_failure_hook.assert_not_awaited()
|
|
return
|
|
proxy_logging_obj.post_call_failure_hook.assert_awaited_once()
|
|
booked = proxy_logging_obj.post_call_failure_hook.await_args.kwargs
|
|
assert booked["original_exception"] is failure
|
|
assert booked["user_api_key_dict"] is user_api_key_dict
|
|
assert booked["request_data"]["model"] == "gpt-4o-mini"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_endpoint_sends_an_error_frame_when_routing_rejects_the_connection(self):
|
|
from litellm.proxy.response_api_endpoints.endpoints import (
|
|
responses_websocket_endpoint,
|
|
)
|
|
|
|
ws = MagicMock()
|
|
ws.headers = {}
|
|
ws.query_params = {}
|
|
ws.scope = {"headers": []}
|
|
ws.url = "ws://testserver/v1/responses"
|
|
ws.accept = AsyncMock()
|
|
ws.receive_text = AsyncMock(
|
|
return_value=json.dumps({"type": "response.create", "model": "gpt-4o-mini", "input": []})
|
|
)
|
|
ws.send_text = AsyncMock()
|
|
ws.close = AsyncMock()
|
|
|
|
processor = MagicMock()
|
|
processor.common_processing_pre_call_logic = AsyncMock(
|
|
return_value=({"model": "gpt-4o-mini", "litellm_metadata": {}}, MagicMock())
|
|
)
|
|
rejection = litellm.RateLimitError(
|
|
message="origin deployment is cooling down", model="gpt-4o-mini", llm_provider="openai"
|
|
)
|
|
proxy_logging_obj = MagicMock()
|
|
proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None)
|
|
proxy_logging_obj.arelease_responses_websocket_slot = AsyncMock(return_value=None)
|
|
user_api_key_dict = MagicMock()
|
|
|
|
with (
|
|
patch( # test-quality-ok: first-frame model auth needs a live router and key table and has its own tests above
|
|
"litellm.proxy.response_api_endpoints.endpoints._enforce_responses_ws_first_frame_model_auth",
|
|
new_callable=AsyncMock,
|
|
),
|
|
patch( # test-quality-ok: the pre-call processor needs a live proxy; what the endpoint tells the client is under test
|
|
"litellm.proxy.response_api_endpoints.endpoints.ProxyBaseLLMRequestProcessing",
|
|
return_value=processor,
|
|
),
|
|
patch( # test-quality-ok: routing is the seam that raises the affinity rejection
|
|
"litellm.proxy.route_llm_request.route_request",
|
|
new_callable=AsyncMock,
|
|
side_effect=rejection,
|
|
),
|
|
patch( # test-quality-ok: the failure hook is the proxy's only path to a failed spend log row
|
|
"litellm.proxy.proxy_server.proxy_logging_obj",
|
|
proxy_logging_obj,
|
|
),
|
|
):
|
|
await responses_websocket_endpoint(
|
|
websocket=ws,
|
|
model=None,
|
|
user_api_key_dict=user_api_key_dict,
|
|
)
|
|
|
|
frame = json.loads(ws.send_text.await_args.args[0])
|
|
assert frame["type"] == "error"
|
|
assert frame["status"] == 429
|
|
assert frame["error"]["type"] == "rate_limit_exceeded"
|
|
assert "cooling down" in frame["error"]["message"]
|
|
ws.close.assert_awaited_once_with(code=1011, reason="Internal server error")
|
|
booked = proxy_logging_obj.post_call_failure_hook.await_args.kwargs
|
|
assert booked["original_exception"] is rejection
|
|
assert booked["user_api_key_dict"] is user_api_key_dict
|
|
assert booked["request_data"]["model"] == "gpt-4o-mini"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reruns_model_auth_for_first_frame_model(self):
|
|
from starlette.requests import Request
|
|
|
|
from litellm.proxy.response_api_endpoints.endpoints import (
|
|
_enforce_responses_ws_first_frame_model_auth,
|
|
)
|
|
|
|
request = Request({"type": "http", "method": "POST", "path": "/v1/responses", "headers": []})
|
|
user_api_key_dict = MagicMock()
|
|
llm_router = MagicMock()
|
|
empty_settings: Final[dict[str, object]] = {}
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.auth.user_api_key_auth.enforce_key_and_fallback_model_access",
|
|
new_callable=AsyncMock,
|
|
) as mock_key_check,
|
|
patch(
|
|
"litellm.proxy.auth.user_api_key_auth.run_centralized_common_checks",
|
|
new_callable=AsyncMock,
|
|
) as mock_common_checks,
|
|
patch(
|
|
"litellm.proxy.proxy_server.llm_model_list",
|
|
[],
|
|
),
|
|
patch("litellm.proxy.proxy_server.master_key", "sk-test"),
|
|
patch("litellm.proxy.proxy_server.user_custom_auth", None),
|
|
patch("litellm.proxy.proxy_server.general_settings", empty_settings),
|
|
):
|
|
await _enforce_responses_ws_first_frame_model_auth(
|
|
request=request,
|
|
model="gpt-4o-mini",
|
|
user_api_key_dict=user_api_key_dict,
|
|
llm_router=llm_router,
|
|
)
|
|
|
|
mock_key_check.assert_awaited_once_with(
|
|
valid_token=user_api_key_dict,
|
|
request_data={"model": "gpt-4o-mini"},
|
|
route="/v1/responses",
|
|
request=request,
|
|
llm_model_list=[],
|
|
llm_router=llm_router,
|
|
)
|
|
mock_common_checks.assert_awaited_once_with(
|
|
user_api_key_auth_obj=user_api_key_dict,
|
|
request=request,
|
|
request_data={"model": "gpt-4o-mini"},
|
|
route="/v1/responses",
|
|
)
|
|
|
|
|
|
class TestReadWSModelFromFirstFrameErrors:
|
|
@pytest.mark.asyncio
|
|
async def test_transport_error_first_frame_closes_with_internal_error(self):
|
|
import asyncio
|
|
|
|
from litellm.proxy.response_api_endpoints.endpoints import (
|
|
_read_ws_model_from_first_frame,
|
|
)
|
|
|
|
ws = MagicMock()
|
|
ws.receive_text = AsyncMock(side_effect=asyncio.TimeoutError())
|
|
ws.send_text = AsyncMock()
|
|
ws.close = AsyncMock()
|
|
|
|
result = await _read_ws_model_from_first_frame(ws)
|
|
|
|
assert result is None
|
|
ws.send_text.assert_not_awaited()
|
|
ws.close.assert_awaited_once_with(code=1011, reason="Internal server error")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_json_sends_error_and_closes(self):
|
|
from litellm.proxy.response_api_endpoints.endpoints import (
|
|
_read_ws_model_from_first_frame,
|
|
)
|
|
|
|
ws = MagicMock()
|
|
ws.receive_text = AsyncMock(return_value="this is not json")
|
|
ws.send_text = AsyncMock()
|
|
ws.close = AsyncMock()
|
|
|
|
result = await _read_ws_model_from_first_frame(ws)
|
|
|
|
assert result is None
|
|
payload = json.loads(ws.send_text.await_args.args[0])
|
|
assert payload["error"]["message"] == "First message is not valid JSON."
|
|
ws.close.assert_awaited_once_with(code=1008, reason="Invalid JSON in first message")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_model_sends_error_and_closes(self):
|
|
from litellm.proxy.response_api_endpoints.endpoints import (
|
|
_read_ws_model_from_first_frame,
|
|
)
|
|
|
|
ws = MagicMock()
|
|
ws.receive_text = AsyncMock(return_value=json.dumps({"type": "response.create", "input": []}))
|
|
ws.send_text = AsyncMock()
|
|
ws.close = AsyncMock()
|
|
|
|
result = await _read_ws_model_from_first_frame(ws)
|
|
|
|
assert result is None
|
|
payload = json.loads(ws.send_text.await_args.args[0])
|
|
assert "No model provided" in payload["error"]["message"]
|
|
ws.close.assert_awaited_once_with(code=1008, reason="No model provided")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_valid_first_frame_returns_model_and_raw(self):
|
|
from litellm.proxy.response_api_endpoints.endpoints import (
|
|
_read_ws_model_from_first_frame,
|
|
)
|
|
|
|
raw = json.dumps({"type": "response.create", "model": "gpt-4o", "input": []})
|
|
ws = MagicMock()
|
|
ws.receive_text = AsyncMock(return_value=raw)
|
|
ws.send_text = AsyncMock()
|
|
ws.close = AsyncMock()
|
|
|
|
result = await _read_ws_model_from_first_frame(ws)
|
|
|
|
assert result == ("gpt-4o", raw)
|
|
ws.send_text.assert_not_awaited()
|
|
ws.close.assert_not_awaited()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_query_model_wins_over_first_frame_model(self):
|
|
from litellm.proxy.response_api_endpoints.endpoints import (
|
|
_read_ws_model_from_first_frame,
|
|
)
|
|
|
|
raw = json.dumps({"type": "response.create", "model": "gpt-4o", "input": []})
|
|
ws = MagicMock()
|
|
ws.receive_text = AsyncMock(return_value=raw)
|
|
ws.send_text = AsyncMock()
|
|
ws.close = AsyncMock()
|
|
|
|
result = await _read_ws_model_from_first_frame(ws, query_model="reasoning-group")
|
|
|
|
assert result == ("reasoning-group", raw)
|
|
ws.close.assert_not_awaited()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_query_model_satisfies_a_first_frame_without_model(self):
|
|
from litellm.proxy.response_api_endpoints.endpoints import (
|
|
_read_ws_model_from_first_frame,
|
|
)
|
|
|
|
raw = json.dumps({"type": "response.create", "input": []})
|
|
ws = MagicMock()
|
|
ws.receive_text = AsyncMock(return_value=raw)
|
|
ws.send_text = AsyncMock()
|
|
ws.close = AsyncMock()
|
|
|
|
result = await _read_ws_model_from_first_frame(ws, query_model="reasoning-group")
|
|
|
|
assert result == ("reasoning-group", raw)
|
|
ws.send_text.assert_not_awaited()
|
|
ws.close.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"configured,expected",
|
|
[
|
|
(None, 3600.0),
|
|
(60, 60.0),
|
|
(1200, 1200.0),
|
|
(7200, 7200.0),
|
|
(59, 3600.0),
|
|
(0, 3600.0),
|
|
(9000, 3600.0),
|
|
("not-a-number", 3600.0),
|
|
],
|
|
)
|
|
def test_responses_ws_session_limit_resolution(monkeypatch, configured, expected):
|
|
from litellm.proxy.proxy_server import general_settings
|
|
from litellm.proxy.response_api_endpoints.endpoints import (
|
|
_resolve_responses_ws_session_limit_seconds,
|
|
)
|
|
|
|
if configured is None:
|
|
monkeypatch.delitem(general_settings, "responses_websocket_session_limit_seconds", raising=False)
|
|
else:
|
|
monkeypatch.setitem(general_settings, "responses_websocket_session_limit_seconds", configured)
|
|
|
|
assert _resolve_responses_ws_session_limit_seconds() == expected
|
|
|
|
|
|
class TestManagedResponsesSameProvider:
|
|
def _handler(self, model, custom_llm_provider=None):
|
|
from litellm.responses.streaming_iterator import (
|
|
ManagedResponsesWebSocketHandler,
|
|
)
|
|
|
|
return ManagedResponsesWebSocketHandler(
|
|
websocket=MagicMock(),
|
|
model=model,
|
|
logging_obj=MagicMock(),
|
|
custom_llm_provider=custom_llm_provider,
|
|
)
|
|
|
|
def test_none_model_treated_as_same_provider(self):
|
|
assert self._handler("openai/gpt-4o")._same_provider(None) is True
|
|
|
|
def test_identical_model_is_same_provider(self):
|
|
assert self._handler("openai/gpt-4o")._same_provider("openai/gpt-4o") is True
|
|
|
|
def test_same_provider_different_model(self):
|
|
assert self._handler("gpt-4o")._same_provider("gpt-4o-mini") is True
|
|
|
|
def test_different_provider_is_not_same(self):
|
|
assert self._handler("gpt-4o")._same_provider("vertex_ai/gemini-2.0-flash") is False
|
|
|
|
def test_inject_credentials_keeps_provider_for_same_provider_model(self):
|
|
handler = self._handler("gpt-4o", custom_llm_provider="openai")
|
|
call_kwargs: dict = {}
|
|
handler._inject_credentials(call_kwargs, model="gpt-4o-mini")
|
|
assert call_kwargs["custom_llm_provider"] == "openai"
|
|
|
|
def test_inject_credentials_drops_provider_for_cross_provider_model(self):
|
|
handler = self._handler("gpt-4o", custom_llm_provider="openai")
|
|
call_kwargs: dict = {}
|
|
handler._inject_credentials(call_kwargs, model="vertex_ai/gemini-2.0-flash")
|
|
assert "custom_llm_provider" not in call_kwargs
|
|
|
|
def test_unresolvable_connection_model_falls_back_to_custom_provider(self):
|
|
handler = self._handler("my-custom-deployment", custom_llm_provider="openai")
|
|
assert handler._same_provider("gpt-4o-mini") is True
|
|
call_kwargs: dict = {}
|
|
handler._inject_credentials(call_kwargs, model="gpt-4o-mini")
|
|
assert call_kwargs["custom_llm_provider"] == "openai"
|
|
|
|
def test_unresolvable_connection_model_still_drops_cross_provider(self):
|
|
handler = self._handler("my-custom-deployment", custom_llm_provider="openai")
|
|
call_kwargs: dict = {}
|
|
handler._inject_credentials(call_kwargs, model="vertex_ai/gemini-2.0-flash")
|
|
assert "custom_llm_provider" not in call_kwargs
|
|
|
|
|
|
def _auth_override():
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
|
|
return UserAPIKeyAuth(api_key="sk-test-cursor", user_id="cursor-user")
|
|
|
|
|
|
def test_cursor_chat_completions_messages_body_uses_chat_pipeline():
|
|
"""A genuine chat-completions body (``messages`` present; what Cursor sends for
|
|
models whose BYOK it already fixed) must run through the standard chat pipeline
|
|
untouched: multi-turn tool history (assistant tool_calls + role="tool" results)
|
|
and nested chat-format tool defs are valid there, while blindly renaming
|
|
``messages`` to ``input`` (the pre-fix behavior) produced items the Responses API
|
|
rejects. Asserts acompletion is called with the exact messages and aresponses is
|
|
never touched."""
|
|
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
|
|
|
import litellm.proxy.proxy_server as ps
|
|
|
|
messages = [
|
|
{"role": "user", "content": "read a file"},
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_hist1",
|
|
"type": "function",
|
|
"function": {"name": "read_file", "arguments": '{"path": "a.py"}'},
|
|
}
|
|
],
|
|
},
|
|
{"role": "tool", "tool_call_id": "call_hist1", "content": "file contents"},
|
|
{"role": "user", "content": "now summarize"},
|
|
]
|
|
|
|
mock_router = MagicMock()
|
|
mock_router.acompletion = AsyncMock(
|
|
return_value=litellm.ModelResponse(
|
|
id="chatcmpl-cursor-1",
|
|
choices=[
|
|
{
|
|
"index": 0,
|
|
"message": {"role": "assistant", "content": "summary"},
|
|
"finish_reason": "stop",
|
|
}
|
|
],
|
|
model="gpt-4o",
|
|
)
|
|
)
|
|
mock_router.aresponses = AsyncMock()
|
|
mock_router.get_available_deployment = MagicMock(return_value=None)
|
|
|
|
app.dependency_overrides[user_api_key_auth] = _auth_override
|
|
try:
|
|
with patch.object(ps, "llm_router", mock_router):
|
|
client = TestClient(app)
|
|
response = client.post(
|
|
"/cursor/chat/completions",
|
|
json={
|
|
"model": "gpt-4o",
|
|
"messages": messages,
|
|
"tools": [
|
|
{
|
|
"type": "function",
|
|
"function": {"name": "read_file", "parameters": {"type": "object"}},
|
|
}
|
|
],
|
|
},
|
|
headers={"Authorization": "Bearer sk-test-cursor"},
|
|
)
|
|
finally:
|
|
app.dependency_overrides.pop(user_api_key_auth, None)
|
|
|
|
assert response.status_code == 200, response.text
|
|
body = response.json()
|
|
assert body["choices"][0]["message"]["content"] == "summary"
|
|
assert "output" not in body
|
|
|
|
mock_router.acompletion.assert_called_once()
|
|
called_kwargs = mock_router.acompletion.call_args.kwargs
|
|
assert called_kwargs["messages"] == messages
|
|
assert "input" not in called_kwargs
|
|
mock_router.aresponses.assert_not_called()
|
|
|
|
|
|
def test_cursor_chat_completions_input_body_uses_responses_pipeline_and_strips_stream_options():
|
|
"""A Responses-shaped body (``input``, no ``messages``; what Cursor agent mode
|
|
sends) must run through the Responses pipeline with chat-completions output, and
|
|
``stream_options`` (chat-completions-only; Cursor sends include_usage) must be
|
|
stripped before the Responses call since OpenAI's Responses API rejects it.
|
|
Stripping must not mutate the dict _read_request_body returned: that can be the
|
|
request-scope cached parsed body itself, and removing a key from it corrupts the
|
|
cache's key snapshot so any later _read_request_body caller (spend tracking,
|
|
logging hooks) silently gets an empty body; a follow-up read must still see the
|
|
full original body."""
|
|
import asyncio
|
|
|
|
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
|
|
|
|
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
|
from litellm.proxy.common_utils.http_parsing_utils import (
|
|
read_request_body as real_read_request_body,
|
|
)
|
|
from litellm.types.llms.openai import ResponsesAPIResponse
|
|
|
|
import litellm.proxy.proxy_server as ps
|
|
|
|
captured_requests = []
|
|
|
|
async def capturing_read_request_body(request):
|
|
captured_requests.append(request)
|
|
return await real_read_request_body(request=request)
|
|
|
|
mock_router = MagicMock()
|
|
mock_router.aresponses = AsyncMock(
|
|
return_value=ResponsesAPIResponse(
|
|
id="resp_cursor_agent1",
|
|
created_at=1234567890,
|
|
model="gpt-4o",
|
|
object="response",
|
|
output=[
|
|
ResponseOutputMessage(
|
|
id="msg_agent1",
|
|
type="message",
|
|
role="assistant",
|
|
status="completed",
|
|
content=[ResponseOutputText(type="output_text", text="agent reply", annotations=[])],
|
|
)
|
|
],
|
|
)
|
|
)
|
|
mock_router.acompletion = AsyncMock()
|
|
|
|
app.dependency_overrides[user_api_key_auth] = _auth_override
|
|
try:
|
|
with (
|
|
patch.object(ps, "llm_router", mock_router),
|
|
patch(
|
|
"litellm.proxy.response_api_endpoints.endpoints.read_request_body",
|
|
side_effect=capturing_read_request_body,
|
|
),
|
|
):
|
|
client = TestClient(app)
|
|
response = client.post(
|
|
"/cursor/chat/completions",
|
|
json={
|
|
"model": "gpt-4o",
|
|
"input": [{"role": "user", "content": "hello"}],
|
|
"stream_options": {"include_usage": True},
|
|
},
|
|
headers={"Authorization": "Bearer sk-test-cursor"},
|
|
)
|
|
finally:
|
|
app.dependency_overrides.pop(user_api_key_auth, None)
|
|
|
|
assert response.status_code == 200, response.text
|
|
body = response.json()
|
|
assert body["choices"][0]["message"]["content"] == "agent reply"
|
|
assert "output" not in body
|
|
|
|
mock_router.aresponses.assert_called_once()
|
|
called_kwargs = mock_router.aresponses.call_args.kwargs
|
|
assert "stream_options" not in called_kwargs
|
|
mock_router.acompletion.assert_not_called()
|
|
|
|
assert captured_requests
|
|
followup_body = asyncio.run(real_read_request_body(request=captured_requests[0]))
|
|
assert followup_body.get("stream_options") == {"include_usage": True}
|
|
assert followup_body.get("input") == [{"role": "user", "content": "hello"}]
|
|
|
|
|
|
def test_cursor_models_route_delegates_to_model_list():
|
|
"""Clients pointed at <proxy>/cursor as an OpenAI-compatible base URL resolve and
|
|
verify keys via GET {base}/models (the OpenAI SDK contract). Without a dedicated
|
|
route those requests fall through to the Cursor Cloud Agents passthrough and 401
|
|
for lack of a Cursor API key, so BYOK verification fails before any chat request
|
|
is sent. Both /cursor/models and /cursor/v1/models must serve the standard model
|
|
list instead."""
|
|
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
|
|
|
import litellm.proxy.proxy_server as ps
|
|
|
|
model_payload = {"data": [{"id": "gpt-5.6", "object": "model"}], "object": "list"}
|
|
|
|
app.dependency_overrides[user_api_key_auth] = _auth_override
|
|
try:
|
|
with patch.object(ps, "model_list", AsyncMock(return_value=model_payload)) as mock_model_list:
|
|
client = TestClient(app)
|
|
for path in ("/cursor/models", "/cursor/v1/models"):
|
|
response = client.get(path, headers={"Authorization": "Bearer sk-test-cursor"})
|
|
assert response.status_code == 200, f"{path}: {response.text}"
|
|
assert response.json() == model_payload
|
|
assert mock_model_list.call_count == 2
|
|
finally:
|
|
app.dependency_overrides.pop(user_api_key_auth, None)
|
|
|
|
|
|
class TestNestFlatChatTools:
|
|
def test_flat_custom_tool_is_nested(self):
|
|
from litellm.proxy.response_api_endpoints.endpoints import _convert_tool_envelope
|
|
|
|
result = _convert_tool_envelope(
|
|
{"type": "custom", "name": "ApplyPatch", "description": "V4A patch", "format": {"type": "text"}},
|
|
to_chat=True,
|
|
)
|
|
assert result == {
|
|
"type": "custom",
|
|
"custom": {"name": "ApplyPatch", "description": "V4A patch", "format": {"type": "text"}},
|
|
}
|
|
|
|
def test_flat_function_tool_is_nested(self):
|
|
from litellm.proxy.response_api_endpoints.endpoints import _convert_tool_envelope
|
|
|
|
result = _convert_tool_envelope(
|
|
{"type": "function", "name": "read_file", "description": "d", "parameters": {"type": "object"}},
|
|
to_chat=True,
|
|
)
|
|
assert result == {
|
|
"type": "function",
|
|
"function": {"name": "read_file", "description": "d", "parameters": {"type": "object"}},
|
|
}
|
|
|
|
def test_already_nested_and_unrecognized_tools_pass_through_unchanged(self):
|
|
from litellm.proxy.response_api_endpoints.endpoints import _convert_tool_envelope
|
|
|
|
tools = [
|
|
{"type": "custom", "custom": {"name": "already_nested"}},
|
|
{"type": "function", "function": {"name": "f", "parameters": {}}},
|
|
{"type": "web_search"},
|
|
{"type": "custom"},
|
|
{"name": "typeless"},
|
|
{},
|
|
"junk",
|
|
None,
|
|
42,
|
|
]
|
|
assert [_convert_tool_envelope(tool, to_chat=True) for tool in tools] == tools
|
|
|
|
|
|
class TestCursorMessagesArmToolNormalization:
|
|
@pytest.mark.asyncio
|
|
async def test_flat_custom_tool_nested_before_chat_completion_delegation(self):
|
|
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
|
|
seen = {}
|
|
|
|
async def fake_chat_completion(request, fastapi_response, model, user_api_key_dict):
|
|
from litellm.proxy.common_utils.http_parsing_utils import read_request_body
|
|
|
|
seen["body"] = await read_request_body(request=request)
|
|
return {"id": "chatcmpl-fake", "object": "chat.completion", "choices": []}
|
|
|
|
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(api_key=MASTER_KEY)
|
|
try:
|
|
with patch("litellm.proxy.proxy_server.chat_completion", new=fake_chat_completion):
|
|
client = TestClient(app)
|
|
response = client.post(
|
|
"/cursor/chat/completions",
|
|
json={
|
|
"model": "gpt-5.6",
|
|
"messages": [{"role": "user", "content": "use ApplyPatch"}],
|
|
"tools": [
|
|
{
|
|
"type": "function",
|
|
"function": {"name": "read_file", "parameters": {"type": "object"}},
|
|
},
|
|
{
|
|
"type": "custom",
|
|
"name": "ApplyPatch",
|
|
"description": "V4A patch",
|
|
"format": {
|
|
"type": "grammar",
|
|
"definition": "start: patch",
|
|
"syntax": "lark",
|
|
},
|
|
},
|
|
],
|
|
"tool_choice": {"type": "custom", "name": "ApplyPatch"},
|
|
},
|
|
headers={"Authorization": "Bearer sk-9876"},
|
|
)
|
|
finally:
|
|
app.dependency_overrides.pop(user_api_key_auth, None)
|
|
|
|
assert response.status_code == 200
|
|
assert seen["body"]["tools"] == [
|
|
{"type": "function", "function": {"name": "read_file", "parameters": {"type": "object"}}},
|
|
{
|
|
"type": "custom",
|
|
"custom": {
|
|
"name": "ApplyPatch",
|
|
"description": "V4A patch",
|
|
"format": {
|
|
"type": "grammar",
|
|
"grammar": {"definition": "start: patch", "syntax": "lark"},
|
|
},
|
|
},
|
|
},
|
|
]
|
|
assert seen["body"]["tool_choice"] == {"type": "custom", "custom": {"name": "ApplyPatch"}}
|
|
assert seen["body"]["messages"] == [{"role": "user", "content": "use ApplyPatch"}]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_messages_body_without_flat_tools_leaves_parsed_body_cache_untouched(self):
|
|
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
|
|
seen = {}
|
|
|
|
async def fake_chat_completion(request, fastapi_response, model, user_api_key_dict):
|
|
from litellm.proxy.common_utils.http_parsing_utils import read_request_body
|
|
|
|
seen["body"] = await read_request_body(request=request)
|
|
return {"id": "chatcmpl-fake", "object": "chat.completion", "choices": []}
|
|
|
|
body = {
|
|
"model": "gpt-5.6",
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
"tools": [{"type": "function", "function": {"name": "f", "parameters": {}}}],
|
|
}
|
|
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(api_key=MASTER_KEY)
|
|
try:
|
|
with patch("litellm.proxy.proxy_server.chat_completion", new=fake_chat_completion):
|
|
client = TestClient(app)
|
|
response = client.post(
|
|
"/cursor/chat/completions",
|
|
json=body,
|
|
headers={"Authorization": "Bearer sk-9876"},
|
|
)
|
|
finally:
|
|
app.dependency_overrides.pop(user_api_key_auth, None)
|
|
|
|
assert response.status_code == 200
|
|
assert seen["body"]["tools"] == body["tools"]
|
|
assert seen["body"]["messages"] == body["messages"]
|
|
|
|
|
|
class TestToolEnvelopeConversionMatrix:
|
|
"""
|
|
Cursor mixes Responses API shapes into chat bodies PER LEVEL, independently
|
|
(live-captured: a pre-nested custom envelope carrying a flat grammar format).
|
|
Tool definitions and tool_choice share one envelope rule, so every cell of
|
|
direction x envelope x format must land on that direction's canonical shape.
|
|
"""
|
|
|
|
FLAT_GRAMMAR = {"type": "grammar", "definition": "start: patch", "syntax": "lark"}
|
|
NESTED_GRAMMAR = {"type": "grammar", "grammar": {"definition": "start: patch", "syntax": "lark"}}
|
|
TEXT = {"type": "text"}
|
|
|
|
@pytest.mark.parametrize("to_chat", [True, False])
|
|
@pytest.mark.parametrize("envelope", ["flat", "nested"])
|
|
@pytest.mark.parametrize("format_shape", ["absent", "text", "flat_grammar", "nested_grammar"])
|
|
def test_every_direction_envelope_and_format_lands_canonical(self, to_chat, envelope, format_shape):
|
|
from litellm.proxy.response_api_endpoints.endpoints import _convert_tool_envelope
|
|
|
|
format_value = {
|
|
"absent": None,
|
|
"text": self.TEXT,
|
|
"flat_grammar": self.FLAT_GRAMMAR,
|
|
"nested_grammar": self.NESTED_GRAMMAR,
|
|
}[format_shape]
|
|
payload = {"name": "ApplyPatch", "description": "V4A patch"}
|
|
if format_value is not None:
|
|
payload["format"] = format_value
|
|
tool = {"type": "custom", "custom": payload} if envelope == "nested" else {"type": "custom", **payload}
|
|
|
|
canonical_payload = {"name": "ApplyPatch", "description": "V4A patch"}
|
|
if format_shape in ("flat_grammar", "nested_grammar"):
|
|
canonical_payload["format"] = self.NESTED_GRAMMAR if to_chat else self.FLAT_GRAMMAR
|
|
elif format_shape == "text":
|
|
canonical_payload["format"] = self.TEXT
|
|
expected = (
|
|
{"type": "custom", "custom": canonical_payload} if to_chat else {"type": "custom", **canonical_payload}
|
|
)
|
|
|
|
assert _convert_tool_envelope(tool, to_chat=to_chat) == expected
|
|
|
|
def test_nested_envelope_with_flat_grammar_matches_live_cursor_capture(self):
|
|
from litellm.proxy.response_api_endpoints.endpoints import _convert_tool_envelope
|
|
|
|
cursor_tool = {"type": "custom", "custom": {"name": "ApplyPatch", "format": self.FLAT_GRAMMAR}}
|
|
assert _convert_tool_envelope(cursor_tool, to_chat=True) == {
|
|
"type": "custom",
|
|
"custom": {"name": "ApplyPatch", "format": self.NESTED_GRAMMAR},
|
|
}
|
|
|
|
@pytest.mark.parametrize("to_chat", [True, False])
|
|
def test_conversion_is_idempotent(self, to_chat):
|
|
from litellm.proxy.response_api_endpoints.endpoints import _convert_tool_envelope
|
|
|
|
once = _convert_tool_envelope({"type": "custom", "name": "A", "format": self.FLAT_GRAMMAR}, to_chat=to_chat)
|
|
assert _convert_tool_envelope(once, to_chat=to_chat) == once
|
|
|
|
def test_nested_function_tool_flattens_and_flat_passes_through(self):
|
|
from litellm.proxy.response_api_endpoints.endpoints import _convert_tool_envelope
|
|
|
|
nested = {"type": "function", "function": {"name": "read_file", "parameters": {"type": "object"}}}
|
|
flat = {"type": "function", "name": "read_file", "parameters": {"type": "object"}}
|
|
assert _convert_tool_envelope(nested, to_chat=False) == flat
|
|
assert _convert_tool_envelope(flat, to_chat=False) == flat
|
|
|
|
@pytest.mark.parametrize("to_chat", [True, False])
|
|
def test_unrecognized_entries_pass_through(self, to_chat):
|
|
from litellm.proxy.response_api_endpoints.endpoints import _convert_tool_envelope
|
|
|
|
entries = [{"type": "web_search"}, {"type": "custom"}, "junk", None, {}, 42, {"type": "auto"}]
|
|
assert [_convert_tool_envelope(entry, to_chat=to_chat) for entry in entries] == entries
|
|
|
|
@pytest.mark.parametrize("to_chat", [True, False])
|
|
def test_empty_nested_envelope_falls_back_to_top_level_payload(self, to_chat):
|
|
"""An empty nested envelope must not shadow payload fields that sit at the top
|
|
level; treating the empty dict as the sole payload source dropped the name."""
|
|
from litellm.proxy.response_api_endpoints.endpoints import _convert_tool_envelope
|
|
|
|
hybrid = {"type": "custom", "custom": {}, "name": "ApplyPatch", "format": self.TEXT}
|
|
expected_payload = {"name": "ApplyPatch", "format": self.TEXT}
|
|
expected = {"type": "custom", "custom": expected_payload} if to_chat else {"type": "custom", **expected_payload}
|
|
assert _convert_tool_envelope(hybrid, to_chat=to_chat) == expected
|
|
|
|
def test_nested_payload_wins_over_stray_top_level_fields(self):
|
|
from litellm.proxy.response_api_endpoints.endpoints import _convert_tool_envelope
|
|
|
|
tool = {"type": "custom", "custom": {"name": "NestedName"}, "name": "TopName"}
|
|
assert _convert_tool_envelope(tool, to_chat=False) == {"type": "custom", "name": "NestedName"}
|
|
|
|
@pytest.mark.parametrize("to_chat", [True, False])
|
|
def test_nameless_envelope_passes_through_unchanged(self, to_chat):
|
|
from litellm.proxy.response_api_endpoints.endpoints import _convert_tool_envelope
|
|
|
|
nameless = {"type": "custom", "custom": {}, "description": "no name anywhere"}
|
|
assert _convert_tool_envelope(nameless, to_chat=to_chat) == nameless
|
|
|
|
|
|
class TestToolChoiceSharesTheToolEnvelopeRule:
|
|
"""
|
|
tool_choice carries the same {"type": T, T: {...}} chat envelope as a tool
|
|
definition, so it converts through the same function in both directions.
|
|
OpenAI requires the nested key on chat (SDK ChatCompletionNamedToolChoiceParam
|
|
and ChatCompletionNamedToolChoiceCustomParam both mark it Required).
|
|
"""
|
|
|
|
@pytest.mark.parametrize("choice_type", ["custom", "function"])
|
|
def test_flat_tool_choice_is_nested_for_chat(self, choice_type):
|
|
from litellm.proxy.response_api_endpoints.endpoints import _convert_tool_envelope
|
|
|
|
assert _convert_tool_envelope({"type": choice_type, "name": "ApplyPatch"}, to_chat=True) == {
|
|
"type": choice_type,
|
|
choice_type: {"name": "ApplyPatch"},
|
|
}
|
|
|
|
@pytest.mark.parametrize("choice_type", ["custom", "function"])
|
|
def test_nested_tool_choice_is_flattened_for_responses(self, choice_type):
|
|
from litellm.proxy.response_api_endpoints.endpoints import _convert_tool_envelope
|
|
|
|
assert _convert_tool_envelope({"type": choice_type, choice_type: {"name": "ApplyPatch"}}, to_chat=False) == {
|
|
"type": choice_type,
|
|
"name": "ApplyPatch",
|
|
}
|
|
|
|
@pytest.mark.parametrize("to_chat", [True, False])
|
|
def test_sentinel_and_malformed_tool_choice_pass_through(self, to_chat):
|
|
from litellm.proxy.response_api_endpoints.endpoints import _convert_tool_envelope
|
|
|
|
for unchanged in ("auto", "required", "none", None, {"type": "auto"}, 42):
|
|
assert _convert_tool_envelope(unchanged, to_chat=to_chat) == unchanged
|
|
|
|
|
|
class TestNormalizeToolDialectCoversBothFields:
|
|
"""
|
|
The regression that motivated one normalizer: tools were converted while
|
|
tool_choice was left flat, so OpenAI rejected the request. Both fields move
|
|
together in a single call, on both arms.
|
|
"""
|
|
|
|
@pytest.mark.parametrize("to_chat", [True, False])
|
|
def test_tools_and_tool_choice_convert_together(self, to_chat):
|
|
from litellm.proxy.response_api_endpoints.endpoints import _normalize_tool_dialect
|
|
|
|
flat = {"type": "custom", "name": "ApplyPatch"}
|
|
nested = {"type": "custom", "custom": {"name": "ApplyPatch"}}
|
|
source = flat if to_chat else nested
|
|
expected = nested if to_chat else flat
|
|
|
|
out = _normalize_tool_dialect({"messages": [], "tools": [source], "tool_choice": source}, to_chat=to_chat)
|
|
assert out["tools"] == [expected]
|
|
assert out["tool_choice"] == expected
|
|
|
|
def test_body_needing_no_conversion_is_returned_by_identity(self):
|
|
from litellm.proxy.response_api_endpoints.endpoints import _normalize_tool_dialect
|
|
|
|
data = {"messages": [], "tools": [{"type": "function", "function": {"name": "f"}}], "tool_choice": "auto"}
|
|
assert _normalize_tool_dialect(data, to_chat=True) is data
|
|
|
|
def test_absent_tool_fields_are_not_invented(self):
|
|
from litellm.proxy.response_api_endpoints.endpoints import _normalize_tool_dialect
|
|
|
|
data = {"messages": [{"role": "user", "content": "hi"}]}
|
|
result = _normalize_tool_dialect(data, to_chat=True)
|
|
assert result == data
|
|
assert "tools" not in result and "tool_choice" not in result
|
|
|
|
|
|
class TestCursorInputArmFlattening:
|
|
@pytest.mark.asyncio
|
|
async def test_nested_chat_shapes_in_input_body_reach_aresponses_flattened(self):
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
|
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
|
|
|
|
from litellm.types.llms.openai import ResponsesAPIResponse
|
|
|
|
mock_response = ResponsesAPIResponse(
|
|
id="resp_flat123",
|
|
created_at=1234567890,
|
|
model="gpt-5.6",
|
|
object="response",
|
|
output=[
|
|
ResponseOutputMessage(
|
|
id="msg_flat123",
|
|
type="message",
|
|
role="assistant",
|
|
status="completed",
|
|
content=[ResponseOutputText(type="output_text", text="ok", annotations=[])],
|
|
)
|
|
],
|
|
)
|
|
|
|
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(api_key=MASTER_KEY)
|
|
try:
|
|
with patch("litellm.proxy.proxy_server.llm_router") as mock_router:
|
|
mock_router.aresponses = AsyncMock(return_value=mock_response)
|
|
client = TestClient(app)
|
|
response = client.post(
|
|
"/cursor/chat/completions",
|
|
json={
|
|
"model": "gpt-5.6",
|
|
"input": [{"role": "user", "content": "use ApplyPatch"}],
|
|
"tools": [
|
|
{
|
|
"type": "custom",
|
|
"custom": {
|
|
"name": "ApplyPatch",
|
|
"format": {
|
|
"type": "grammar",
|
|
"grammar": {"definition": "start: patch", "syntax": "lark"},
|
|
},
|
|
},
|
|
},
|
|
{"type": "function", "name": "read_file", "parameters": {"type": "object"}},
|
|
],
|
|
"tool_choice": {"type": "custom", "custom": {"name": "ApplyPatch"}},
|
|
},
|
|
headers={"Authorization": "Bearer sk-9876"},
|
|
)
|
|
finally:
|
|
app.dependency_overrides.pop(user_api_key_auth, None)
|
|
|
|
assert response.status_code == 200
|
|
call_kwargs = mock_router.aresponses.call_args.kwargs
|
|
assert call_kwargs["tools"] == [
|
|
{
|
|
"type": "custom",
|
|
"name": "ApplyPatch",
|
|
"format": {"type": "grammar", "definition": "start: patch", "syntax": "lark"},
|
|
},
|
|
{"type": "function", "name": "read_file", "parameters": {"type": "object"}},
|
|
]
|
|
assert call_kwargs["tool_choice"] == {"type": "custom", "name": "ApplyPatch"}
|
|
|
|
|
|
class TestChatCompletionsBodyDetection:
|
|
def test_routing_matrix(self):
|
|
from litellm.proxy.response_api_endpoints.endpoints import _is_chat_completions_body
|
|
|
|
assert _is_chat_completions_body({"messages": [{"role": "user", "content": "hi"}]}) is True
|
|
assert _is_chat_completions_body({"messages": [{"role": "user", "content": "hi"}], "input": []}) is True
|
|
assert _is_chat_completions_body({"messages": None, "input": [{"role": "user", "content": "hi"}]}) is False
|
|
assert _is_chat_completions_body({"messages": [], "input": [{"role": "user", "content": "hi"}]}) is False
|
|
assert _is_chat_completions_body({"messages": None}) is True
|
|
assert _is_chat_completions_body({"messages": []}) is True
|
|
assert _is_chat_completions_body({"input": [{"role": "user", "content": "hi"}]}) is False
|
|
assert _is_chat_completions_body({}) is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_null_messages_stub_with_input_reaches_responses_arm(self):
|
|
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
|
|
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
|
from litellm.types.llms.openai import ResponsesAPIResponse
|
|
|
|
mock_response = ResponsesAPIResponse(
|
|
id="resp_stub1",
|
|
created_at=1234567890,
|
|
model="gpt-5.6",
|
|
object="response",
|
|
output=[
|
|
ResponseOutputMessage(
|
|
id="msg_stub1",
|
|
type="message",
|
|
role="assistant",
|
|
status="completed",
|
|
content=[ResponseOutputText(type="output_text", text="ok", annotations=[])],
|
|
)
|
|
],
|
|
)
|
|
|
|
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(api_key=MASTER_KEY)
|
|
try:
|
|
with patch("litellm.proxy.proxy_server.llm_router") as mock_router:
|
|
mock_router.aresponses = AsyncMock(return_value=mock_response)
|
|
client = TestClient(app)
|
|
response = client.post(
|
|
"/cursor/chat/completions",
|
|
json={
|
|
"model": "gpt-5.6",
|
|
"messages": None,
|
|
"input": [{"role": "user", "content": "hello"}],
|
|
},
|
|
headers={"Authorization": "Bearer sk-9876"},
|
|
)
|
|
finally:
|
|
app.dependency_overrides.pop(user_api_key_auth, None)
|
|
|
|
assert response.status_code == 200
|
|
assert mock_router.aresponses.call_args is not None
|
|
assert mock_router.aresponses.call_args.kwargs["input"] == [{"role": "user", "content": "hello"}]
|
|
|
|
|
|
class TestParseCursorModelVariant:
|
|
@pytest.mark.parametrize(
|
|
"model,expected_base,expected_effort",
|
|
[
|
|
("claude-opus-5-thinking-high", "claude-opus-5", "high"),
|
|
("claude-opus-5-thinking-xhigh-fast", "claude-opus-5", "xhigh"),
|
|
("gemini-3.0-pro-thinking-low", "gemini-3.0-pro", "low"),
|
|
("claude-opus-5-fast", "claude-opus-5", None),
|
|
("gpt-5.6-sol", "gpt-5.6-sol", None),
|
|
("foo-thinking-ultra-fast", "foo-thinking-ultra", None),
|
|
("gpt-5.6-thinking-max", "gpt-5.6", "max"),
|
|
("foo-thinking-mega-fast", "foo-thinking-mega", None),
|
|
("-thinking-high", "-thinking-high", None),
|
|
],
|
|
)
|
|
def test_parse_matrix(self, model, expected_base, expected_effort):
|
|
from litellm.proxy.response_api_endpoints.endpoints import _parse_cursor_model_variant
|
|
|
|
variant = _parse_cursor_model_variant(model)
|
|
assert variant.base_model == expected_base
|
|
assert variant.reasoning_effort == expected_effort
|
|
|
|
|
|
class TestResolveCursorModelVariant:
|
|
@pytest.fixture(scope="class")
|
|
def wildcard_router(self):
|
|
from litellm import Router
|
|
|
|
return Router(
|
|
model_list=[
|
|
{"model_name": "anthropic/*", "litellm_params": {"model": "anthropic/*", "api_key": "fake"}},
|
|
{"model_name": "openai/*", "litellm_params": {"model": "openai/*", "api_key": "fake"}},
|
|
{
|
|
"model_name": "explicit-alias-thinking-high",
|
|
"litellm_params": {"model": "anthropic/claude-opus-5", "api_key": "fake"},
|
|
},
|
|
]
|
|
)
|
|
|
|
def test_chat_body_suffix_stripped_into_reasoning_effort(self, wildcard_router):
|
|
from litellm.proxy.response_api_endpoints.endpoints import _resolve_cursor_model_variant
|
|
|
|
body = {
|
|
"model": "claude-opus-5-thinking-xhigh-fast",
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
}
|
|
resolved = _resolve_cursor_model_variant(body, wildcard_router)
|
|
assert resolved["model"] == "claude-opus-5"
|
|
assert resolved["reasoning_effort"] == "xhigh"
|
|
assert resolved["messages"] == body["messages"]
|
|
assert body["model"] == "claude-opus-5-thinking-xhigh-fast"
|
|
|
|
def test_responses_body_suffix_stripped_into_reasoning_dict(self, wildcard_router):
|
|
from litellm.proxy.response_api_endpoints.endpoints import _resolve_cursor_model_variant
|
|
|
|
body = {"model": "claude-opus-5-thinking-high", "input": [{"role": "user", "content": "hi"}]}
|
|
resolved = _resolve_cursor_model_variant(body, wildcard_router)
|
|
assert resolved["model"] == "claude-opus-5"
|
|
assert resolved["reasoning"] == {"effort": "high"}
|
|
|
|
def test_responses_body_merges_effort_into_existing_reasoning(self, wildcard_router):
|
|
from litellm.proxy.response_api_endpoints.endpoints import _resolve_cursor_model_variant
|
|
|
|
body = {
|
|
"model": "claude-opus-5-thinking-high",
|
|
"input": [{"role": "user", "content": "hi"}],
|
|
"reasoning": {"summary": "auto"},
|
|
}
|
|
resolved = _resolve_cursor_model_variant(body, wildcard_router)
|
|
assert resolved["model"] == "claude-opus-5"
|
|
assert resolved["reasoning"] == {"summary": "auto", "effort": "high"}
|
|
|
|
def test_existing_reasoning_effort_wins_but_model_still_rewritten(self, wildcard_router):
|
|
from litellm.proxy.response_api_endpoints.endpoints import _resolve_cursor_model_variant
|
|
|
|
chat_body = {
|
|
"model": "claude-opus-5-thinking-high",
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
"reasoning_effort": "low",
|
|
}
|
|
resolved_chat = _resolve_cursor_model_variant(chat_body, wildcard_router)
|
|
assert resolved_chat["model"] == "claude-opus-5"
|
|
assert resolved_chat["reasoning_effort"] == "low"
|
|
|
|
responses_body = {
|
|
"model": "claude-opus-5-thinking-high",
|
|
"input": [{"role": "user", "content": "hi"}],
|
|
"reasoning": {"effort": "low"},
|
|
}
|
|
resolved_responses = _resolve_cursor_model_variant(responses_body, wildcard_router)
|
|
assert resolved_responses["model"] == "claude-opus-5"
|
|
assert resolved_responses["reasoning"] == {"effort": "low"}
|
|
|
|
def test_fast_only_suffix_strips_without_reasoning(self, wildcard_router):
|
|
from litellm.proxy.response_api_endpoints.endpoints import _resolve_cursor_model_variant
|
|
|
|
body = {"model": "claude-opus-5-fast", "messages": [{"role": "user", "content": "hi"}]}
|
|
resolved = _resolve_cursor_model_variant(body, wildcard_router)
|
|
assert resolved["model"] == "claude-opus-5"
|
|
assert "reasoning_effort" not in resolved
|
|
|
|
def test_explicitly_configured_suffixed_name_untouched(self, wildcard_router):
|
|
from litellm.proxy.response_api_endpoints.endpoints import _resolve_cursor_model_variant
|
|
|
|
body = {"model": "explicit-alias-thinking-high", "messages": [{"role": "user", "content": "hi"}]}
|
|
assert _resolve_cursor_model_variant(body, wildcard_router) is body
|
|
|
|
def test_provider_inferable_bare_name_untouched(self, wildcard_router):
|
|
from litellm.proxy.response_api_endpoints.endpoints import _resolve_cursor_model_variant
|
|
|
|
body = {"model": "gpt-4o-mini", "messages": [{"role": "user", "content": "hi"}]}
|
|
assert _resolve_cursor_model_variant(body, wildcard_router) is body
|
|
|
|
def test_unservable_base_untouched(self, wildcard_router):
|
|
from litellm.proxy.response_api_endpoints.endpoints import _resolve_cursor_model_variant
|
|
|
|
body = {"model": "totally-unknown-thinking-high", "messages": [{"role": "user", "content": "hi"}]}
|
|
assert _resolve_cursor_model_variant(body, wildcard_router) is body
|
|
|
|
def test_no_router_untouched(self):
|
|
from litellm.proxy.response_api_endpoints.endpoints import _resolve_cursor_model_variant
|
|
|
|
body = {"model": "claude-opus-5-thinking-high", "messages": [{"role": "user", "content": "hi"}]}
|
|
assert _resolve_cursor_model_variant(body, None) is body
|
|
|
|
def test_missing_or_non_string_model_untouched(self, wildcard_router):
|
|
from litellm.proxy.response_api_endpoints.endpoints import _resolve_cursor_model_variant
|
|
|
|
no_model = {"messages": [{"role": "user", "content": "hi"}]}
|
|
assert _resolve_cursor_model_variant(no_model, wildcard_router) is no_model
|
|
null_model = {"model": None, "messages": [{"role": "user", "content": "hi"}]}
|
|
assert _resolve_cursor_model_variant(null_model, wildcard_router) is null_model
|
|
|
|
|
|
def _router_serving_only(base_model: str) -> MagicMock:
|
|
mock_router = MagicMock()
|
|
mock_router.model_names = set()
|
|
mock_router.model_group_alias = {}
|
|
mock_router.team_public_model_names = frozenset()
|
|
mock_router.is_recognized_model.side_effect = lambda model: (
|
|
model in mock_router.model_names or model in mock_router.model_group_alias
|
|
)
|
|
mock_router.router_general_settings.pass_through_all_models = False
|
|
mock_router.default_deployment = None
|
|
mock_router.pattern_router.patterns = {base_model: ["anthropic/*"]}
|
|
mock_router.pattern_router.get_pattern.side_effect = lambda model: (
|
|
[{"model_name": "anthropic/*"}] if model == base_model else None
|
|
)
|
|
return mock_router
|
|
|
|
|
|
class TestCursorModelSuffixResolutionEndToEnd:
|
|
@pytest.mark.asyncio
|
|
async def test_chat_arm_rewrites_suffixed_model_before_delegation(self):
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
|
|
|
seen = {}
|
|
|
|
async def fake_chat_completion(request, fastapi_response, model, user_api_key_dict):
|
|
from litellm.proxy.common_utils.http_parsing_utils import read_request_body
|
|
|
|
seen["body"] = await read_request_body(request=request)
|
|
return {"id": "chatcmpl-fake", "object": "chat.completion", "choices": []}
|
|
|
|
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(api_key=MASTER_KEY)
|
|
try:
|
|
with (
|
|
patch("litellm.proxy.proxy_server.llm_router", new=_router_serving_only("claude-opus-5")),
|
|
patch("litellm.proxy.proxy_server.chat_completion", new=fake_chat_completion),
|
|
):
|
|
client = TestClient(app)
|
|
response = client.post(
|
|
"/cursor/chat/completions",
|
|
json={
|
|
"model": "claude-opus-5-thinking-xhigh-fast",
|
|
"messages": [{"role": "user", "content": "hi"}],
|
|
},
|
|
headers={"Authorization": "Bearer sk-9876"},
|
|
)
|
|
finally:
|
|
app.dependency_overrides.pop(user_api_key_auth, None)
|
|
|
|
assert response.status_code == 200
|
|
assert seen["body"]["model"] == "claude-opus-5"
|
|
assert seen["body"]["reasoning_effort"] == "xhigh"
|
|
assert seen["body"]["messages"] == [{"role": "user", "content": "hi"}]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_responses_arm_rewrites_suffixed_model_before_routing(self):
|
|
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
|
|
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
|
from litellm.types.llms.openai import ResponsesAPIResponse
|
|
|
|
mock_response = ResponsesAPIResponse(
|
|
id="resp_suffix1",
|
|
created_at=1234567890,
|
|
model="claude-opus-5",
|
|
object="response",
|
|
output=[
|
|
ResponseOutputMessage(
|
|
id="msg_suffix1",
|
|
type="message",
|
|
role="assistant",
|
|
status="completed",
|
|
content=[ResponseOutputText(type="output_text", text="ok", annotations=[])],
|
|
)
|
|
],
|
|
)
|
|
|
|
mock_router = _router_serving_only("claude-opus-5")
|
|
mock_router.aresponses = AsyncMock(return_value=mock_response)
|
|
|
|
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(api_key=MASTER_KEY)
|
|
try:
|
|
with patch("litellm.proxy.proxy_server.llm_router", new=mock_router):
|
|
client = TestClient(app)
|
|
response = client.post(
|
|
"/cursor/chat/completions",
|
|
json={
|
|
"model": "claude-opus-5-thinking-high",
|
|
"input": [{"role": "user", "content": "hello"}],
|
|
},
|
|
headers={"Authorization": "Bearer sk-9876"},
|
|
)
|
|
finally:
|
|
app.dependency_overrides.pop(user_api_key_auth, None)
|
|
|
|
assert response.status_code == 200
|
|
assert mock_router.aresponses.call_args is not None
|
|
assert mock_router.aresponses.call_args.kwargs["model"] == "claude-opus-5"
|
|
assert mock_router.aresponses.call_args.kwargs["reasoning"] == {"effort": "high"}
|
|
|
|
|
|
def _cursor_budget_auth_env(base_model: str, spend: float):
|
|
from litellm import Router
|
|
from litellm.caching.dual_cache import DualCache
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.hooks.model_max_budget_limiter import (
|
|
VIRTUAL_KEY_SPEND_CACHE_KEY_PREFIX,
|
|
PROXY_VirtualKeyModelMaxBudgetLimiter,
|
|
)
|
|
|
|
valid_token = UserAPIKeyAuth(
|
|
api_key="sk-cursor-budget-test",
|
|
token="hashed-cursor-budget-token",
|
|
model_max_budget={base_model: {"budget_limit": 0.00001, "time_period": "1d"}},
|
|
)
|
|
limiter = PROXY_VirtualKeyModelMaxBudgetLimiter(dual_cache=DualCache())
|
|
limiter.dual_cache.in_memory_cache.set_cache(
|
|
key=f"{VIRTUAL_KEY_SPEND_CACHE_KEY_PREFIX}:{valid_token.token}:{base_model}:1d",
|
|
value=spend,
|
|
)
|
|
router = Router(
|
|
model_list=[{"model_name": "anthropic/*", "litellm_params": {"model": "anthropic/*", "api_key": "fake"}}]
|
|
)
|
|
|
|
mock_proxy_logging_obj = MagicMock()
|
|
mock_proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None)
|
|
|
|
proxy_server_attrs = {
|
|
"prisma_client": MagicMock(),
|
|
"user_api_key_cache": DualCache(),
|
|
"proxy_logging_obj": mock_proxy_logging_obj,
|
|
"master_key": "sk-master-key",
|
|
"general_settings": {},
|
|
"llm_model_list": [],
|
|
"llm_router": router,
|
|
"open_telemetry_logger": None,
|
|
"model_max_budget_limiter": limiter,
|
|
"user_custom_auth": None,
|
|
"jwt_handler": None,
|
|
"litellm_proxy_admin_name": "admin",
|
|
}
|
|
return valid_token, proxy_server_attrs
|
|
|
|
|
|
def _post_cursor_with_real_auth(valid_token, proxy_server_attrs, request_model: str):
|
|
with (
|
|
patch.multiple("litellm.proxy.proxy_server", **proxy_server_attrs),
|
|
patch(
|
|
"litellm.proxy.auth.resolvers.store.IdentityStore._resolve_key",
|
|
new_callable=AsyncMock,
|
|
return_value=valid_token,
|
|
),
|
|
):
|
|
client = TestClient(app)
|
|
return client.post(
|
|
"/cursor/chat/completions",
|
|
json={"model": request_model, "input": [{"role": "user", "content": "hi"}]},
|
|
headers={"Authorization": "Bearer sk-cursor-budget-test"},
|
|
)
|
|
|
|
|
|
class TestCursorVariantPerModelBudgetEnforcement:
|
|
"""Regression tests for the per-model budget bypass on /cursor/chat/completions.
|
|
|
|
user_api_key_auth enforced key model_max_budget against the raw request model,
|
|
but _resolve_cursor_model_variant only rewrote minted aliases like
|
|
<base>-thinking-<level> to <base> inside the handler, after auth had already
|
|
run. A key whose budget for <base> was exhausted could keep calling <base>
|
|
through any unconfigured alias. The variant must now be resolved in a
|
|
route-level dependency that runs before user_api_key_auth, so these tests
|
|
exercise the real dependency chain (real auth, real budget limiter) through
|
|
TestClient and fail if that ordering ever breaks."""
|
|
|
|
def test_minted_alias_rejected_when_base_model_budget_exhausted(self):
|
|
valid_token, attrs = _cursor_budget_auth_env(base_model="claude-opus-5", spend=1.0)
|
|
|
|
response = _post_cursor_with_real_auth(valid_token, attrs, request_model="claude-opus-5-thinking-high")
|
|
|
|
assert response.status_code == 422, response.text
|
|
error = response.json()["error"]
|
|
assert error["type"] == "budget_exceeded"
|
|
assert "exceeded budget for model=claude-opus-5" in error["message"]
|
|
|
|
def test_alias_rejection_matches_base_model_rejection(self):
|
|
valid_token, attrs = _cursor_budget_auth_env(base_model="claude-opus-5", spend=1.0)
|
|
|
|
base_response = _post_cursor_with_real_auth(valid_token, attrs, request_model="claude-opus-5")
|
|
alias_response = _post_cursor_with_real_auth(valid_token, attrs, request_model="claude-opus-5-fast")
|
|
|
|
assert base_response.status_code == 422, base_response.text
|
|
assert alias_response.status_code == 422, alias_response.text
|
|
assert alias_response.json() == base_response.json()
|
|
|
|
|
|
class TestCursorVariantResolvedBeforeAuth:
|
|
"""The route-level resolver dependency must rewrite the parsed body before
|
|
user_api_key_auth reads it, so every auth check (model access, key and
|
|
end-user model budgets, rate limits) sees the base model, and names the
|
|
router already serves must reach auth untouched."""
|
|
|
|
def _run_with_recording_auth(self, mock_router, request_model: str):
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
|
from litellm.proxy.common_utils.http_parsing_utils import read_request_body
|
|
|
|
from fastapi import Request
|
|
|
|
bodies_seen_by_auth = []
|
|
|
|
async def recording_auth(request: Request) -> UserAPIKeyAuth:
|
|
bodies_seen_by_auth.append(await read_request_body(request=request))
|
|
return UserAPIKeyAuth(api_key="sk-test-cursor")
|
|
|
|
async def fake_chat_completion(request, fastapi_response, model, user_api_key_dict):
|
|
return {"id": "chatcmpl-fake", "object": "chat.completion", "choices": []}
|
|
|
|
app.dependency_overrides[user_api_key_auth] = recording_auth
|
|
try:
|
|
with (
|
|
patch("litellm.proxy.proxy_server.llm_router", new=mock_router),
|
|
patch("litellm.proxy.proxy_server.chat_completion", new=fake_chat_completion),
|
|
):
|
|
client = TestClient(app)
|
|
response = client.post(
|
|
"/cursor/chat/completions",
|
|
json={"model": request_model, "messages": [{"role": "user", "content": "hi"}]},
|
|
headers={"Authorization": "Bearer sk-test-cursor"},
|
|
)
|
|
finally:
|
|
app.dependency_overrides.pop(user_api_key_auth, None)
|
|
|
|
assert response.status_code == 200, response.text
|
|
assert len(bodies_seen_by_auth) == 1
|
|
return bodies_seen_by_auth[0]
|
|
|
|
def test_auth_sees_base_model_for_minted_alias(self):
|
|
auth_body = self._run_with_recording_auth(
|
|
mock_router=_router_serving_only("claude-opus-5"),
|
|
request_model="claude-opus-5-thinking-xhigh-fast",
|
|
)
|
|
assert auth_body["model"] == "claude-opus-5"
|
|
assert auth_body["reasoning_effort"] == "xhigh"
|
|
|
|
def test_auth_sees_servable_model_name_untouched(self):
|
|
mock_router = _router_serving_only("claude-opus-5")
|
|
mock_router.model_names = {"claude-opus-5-thinking-high"}
|
|
|
|
auth_body = self._run_with_recording_auth(
|
|
mock_router=mock_router,
|
|
request_model="claude-opus-5-thinking-high",
|
|
)
|
|
assert auth_body["model"] == "claude-opus-5-thinking-high"
|
|
assert "reasoning_effort" not in auth_body
|
|
|
|
|
|
class TestCursorGateRecognizesRoutingGroups:
|
|
def test_group_name_variant_is_not_mangled(self):
|
|
from litellm import Router
|
|
from litellm.proxy.response_api_endpoints.endpoints import _resolve_cursor_model_variant
|
|
|
|
router = Router(
|
|
model_list=[{"model_name": "member-fast", "litellm_params": {"model": "openai/gpt-4o", "api_key": "fake"}}],
|
|
routing_groups=[
|
|
{"group_name": "grouped-thinking-high", "models": ["member-fast"], "routing_strategy": "simple-shuffle"}
|
|
],
|
|
)
|
|
body = {"model": "grouped-thinking-high", "messages": [{"role": "user", "content": "hi"}]}
|
|
resolved = _resolve_cursor_model_variant(body, router)
|
|
assert resolved["model"] == "grouped-thinking-high"
|
|
assert "reasoning_effort" not in resolved
|
|
|
|
|
|
BLOCK_MESSAGE = "Content flagged by policy, response withheld"
|
|
|
|
|
|
def _post_blocked_responses(
|
|
original_response: ResponsesAPIResponse | litellm.ModelResponse | None,
|
|
payload: Mapping[str, object] | None = None,
|
|
) -> httpx.Response:
|
|
from litellm.integrations.custom_guardrail import ModifyResponseException
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
|
|
|
exc = ModifyResponseException(
|
|
message=BLOCK_MESSAGE,
|
|
model="gpt-4o-mini",
|
|
request_data={"model": "gpt-4o-mini", "input": "hi"},
|
|
guardrail_name="zero-usage-regression",
|
|
original_response=original_response,
|
|
)
|
|
mock_proxy_logging = MagicMock()
|
|
mock_proxy_logging.post_call_failure_hook = AsyncMock()
|
|
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
|
|
api_key="sk-test", request_route="/v1/responses"
|
|
)
|
|
body = {"model": "gpt-4o-mini", "input": "Write a haiku about token accounting"}
|
|
if payload:
|
|
body.update(payload)
|
|
try:
|
|
with (
|
|
patch(
|
|
"litellm.proxy.response_api_endpoints.endpoints.ProxyBaseLLMRequestProcessing.base_process_llm_request",
|
|
new=AsyncMock(side_effect=exc),
|
|
),
|
|
patch("litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging),
|
|
):
|
|
client = TestClient(app)
|
|
return client.post("/v1/responses", json=body, headers={"Authorization": "Bearer sk-9876"})
|
|
finally:
|
|
app.dependency_overrides.pop(user_api_key_auth, None)
|
|
|
|
|
|
def _assert_blocked_output_item(item: Mapping[str, object], text: str) -> None:
|
|
assert item["type"] == "message"
|
|
assert item["id"].startswith("msg_")
|
|
assert item["role"] == "assistant"
|
|
assert item["status"] == "completed"
|
|
assert item["content"][0]["type"] == "output_text"
|
|
assert item["content"][0]["text"] == text
|
|
|
|
|
|
def _sse_data_frames(text: str) -> list[str]:
|
|
return [line.removeprefix("data: ").strip() for line in text.splitlines() if line.startswith("data: ")]
|
|
|
|
|
|
class TestGuardrailBlockedResponsesUsage:
|
|
"""Regression tests for https://github.com/BerriAI/litellm/issues/36880.
|
|
|
|
The ModifyResponseException handler in responses_api hardcoded the synthetic
|
|
blocked reply's usage to zeros, discarding the real token counts the blocked
|
|
upstream call consumed. The blocked reply must carry the usage from
|
|
e.original_response, exactly like /v1/chat/completions already does."""
|
|
|
|
def _post_blocked_responses(self, original_response):
|
|
return _post_blocked_responses(original_response)
|
|
|
|
def test_post_call_block_reports_real_upstream_usage(self):
|
|
from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse
|
|
|
|
original = ResponsesAPIResponse(
|
|
id="resp_upstream",
|
|
created_at=1,
|
|
model="gpt-4o-mini",
|
|
object="response",
|
|
output=[],
|
|
status="completed",
|
|
usage=ResponseAPIUsage(input_tokens=14, output_tokens=20, total_tokens=34),
|
|
)
|
|
|
|
response = self._post_blocked_responses(original)
|
|
|
|
assert response.status_code == 200, response.text
|
|
body = response.json()
|
|
assert body["output"][0]["content"][0]["text"] == "Content flagged by policy, response withheld"
|
|
assert body["usage"]["input_tokens"] == 14
|
|
assert body["usage"]["output_tokens"] == 20
|
|
assert body["usage"]["total_tokens"] == 34
|
|
|
|
def test_post_call_block_maps_bridged_chat_usage(self):
|
|
original = litellm.ModelResponse()
|
|
original.usage = litellm.Usage(prompt_tokens=14, completion_tokens=18, total_tokens=32)
|
|
|
|
response = self._post_blocked_responses(original)
|
|
|
|
assert response.status_code == 200, response.text
|
|
usage = response.json()["usage"]
|
|
assert usage["input_tokens"] == 14
|
|
assert usage["output_tokens"] == 18
|
|
assert usage["total_tokens"] == 32
|
|
|
|
def test_pre_call_block_reports_zero_usage(self):
|
|
response = self._post_blocked_responses(None)
|
|
|
|
assert response.status_code == 200, response.text
|
|
usage = response.json()["usage"]
|
|
assert usage["input_tokens"] == 0
|
|
assert usage["output_tokens"] == 0
|
|
assert usage["total_tokens"] == 0
|
|
|
|
|
|
class TestResponsesInputTokens:
|
|
"""Regression tests for POST /v1/responses/input_tokens.
|
|
|
|
The docs promise OpenAI-format token counting on the proxy, but the route was
|
|
never registered, so the POST fell through to the GET/DELETE-only
|
|
/v1/responses/{response_id} route and returned 405."""
|
|
|
|
def _post_input_tokens(
|
|
self,
|
|
body: dict[str, Any],
|
|
path: str = "/v1/responses/input_tokens",
|
|
counter: AsyncMock | None = None,
|
|
llm_router: litellm.Router | None = None,
|
|
) -> tuple[Response, AsyncMock]:
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
|
from litellm.proxy.response_api_endpoints.endpoints import _proxy_llm_router, _proxy_token_counter
|
|
from litellm.types.utils import TokenCountResponse
|
|
|
|
token_counter_mock = (
|
|
counter
|
|
if counter is not None
|
|
else AsyncMock(
|
|
return_value=TokenCountResponse(
|
|
total_tokens=13,
|
|
request_model=body.get("model", ""),
|
|
model_used=body.get("model", ""),
|
|
tokenizer_type="openai_api",
|
|
)
|
|
)
|
|
)
|
|
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(api_key="sk-test", request_route=path)
|
|
app.dependency_overrides[_proxy_token_counter] = lambda: token_counter_mock
|
|
app.dependency_overrides[_proxy_llm_router] = lambda: llm_router
|
|
try:
|
|
client = TestClient(app)
|
|
response = client.post(path, json=body, headers={"Authorization": "Bearer sk-9876"})
|
|
return response, token_counter_mock
|
|
finally:
|
|
app.dependency_overrides.pop(user_api_key_auth, None)
|
|
app.dependency_overrides.pop(_proxy_token_counter, None)
|
|
app.dependency_overrides.pop(_proxy_llm_router, None)
|
|
|
|
def test_string_input_returns_openai_input_tokens_shape(self):
|
|
response, counter = self._post_input_tokens({"model": "gpt-4o", "input": "Hello, how are you?"})
|
|
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {"object": "response.input_tokens", "input_tokens": 13}
|
|
counter.assert_awaited_once()
|
|
assert counter.call_args.kwargs["call_endpoint"] is True
|
|
token_request = counter.call_args.kwargs["request"]
|
|
assert token_request.model == "gpt-4o"
|
|
assert token_request.messages == [{"role": "user", "content": "Hello, how are you?"}]
|
|
|
|
def test_every_route_alias_is_registered(self):
|
|
for path in ("/v1/responses/input_tokens", "/responses/input_tokens", "/openai/v1/responses/input_tokens"):
|
|
response, _ = self._post_input_tokens({"model": "gpt-4o", "input": "hi"}, path=path)
|
|
assert response.status_code == 200, f"{path}: {response.status_code} {response.text}"
|
|
|
|
def test_input_items_instructions_and_tools_are_forwarded(self):
|
|
tools = [
|
|
{
|
|
"type": "function",
|
|
"name": "get_weather",
|
|
"description": "Get weather for a city",
|
|
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}},
|
|
}
|
|
]
|
|
response, counter = self._post_input_tokens(
|
|
{
|
|
"model": "gpt-4o",
|
|
"input": [{"role": "user", "content": "What is the weather in Paris?"}],
|
|
"instructions": "You are terse.",
|
|
"tools": tools,
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
token_request = counter.call_args.kwargs["request"]
|
|
assert token_request.messages == [
|
|
{"role": "system", "content": "You are terse."},
|
|
{"role": "user", "content": "What is the weather in Paris?"},
|
|
]
|
|
assert token_request.tools == tools
|
|
|
|
def test_item_cache_control_is_left_out_of_the_counted_messages(self):
|
|
response, counter = self._post_input_tokens(
|
|
{
|
|
"model": "anthropic/claude-sonnet-4-6",
|
|
"input": [
|
|
{"role": "system", "content": "Long handbook", "cache_control": {"type": "ephemeral"}},
|
|
{
|
|
"role": "user",
|
|
"content": "What does a reviewer do first?",
|
|
"cache_control": {"type": "ephemeral"},
|
|
},
|
|
],
|
|
}
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
assert counter.call_args.kwargs["request"].messages == [
|
|
{"role": "system", "content": "Long handbook"},
|
|
{"role": "user", "content": "What does a reviewer do first?"},
|
|
]
|
|
|
|
def test_missing_model_returns_openai_400(self):
|
|
response, counter = self._post_input_tokens({"input": "Hello"})
|
|
|
|
assert response.status_code == 400, response.text
|
|
assert response.json() == {
|
|
"error": {
|
|
"message": "Missing required parameter: 'model'.",
|
|
"type": "invalid_request_error",
|
|
"param": "model",
|
|
"code": "missing_required_parameter",
|
|
}
|
|
}
|
|
counter.assert_not_awaited()
|
|
|
|
def test_missing_input_returns_openai_400(self):
|
|
response, counter = self._post_input_tokens({"model": "gpt-4o"})
|
|
|
|
assert response.status_code == 400, response.text
|
|
assert response.json() == {
|
|
"error": {
|
|
"message": "Missing required parameter: 'input'.",
|
|
"type": "invalid_request_error",
|
|
"param": "input",
|
|
"code": "missing_required_parameter",
|
|
}
|
|
}
|
|
counter.assert_not_awaited()
|
|
|
|
@pytest.mark.parametrize("empty_input", ["", []])
|
|
def test_empty_input_returns_openai_400(self, empty_input):
|
|
response, counter = self._post_input_tokens({"model": "gpt-4o", "input": empty_input})
|
|
|
|
assert response.status_code == 400, response.text
|
|
assert response.json() == {
|
|
"error": {
|
|
"message": """One of "input" or "previous_response_id" or 'prompt' or 'conversation' must be provided.""",
|
|
"type": "invalid_request_error",
|
|
"param": None,
|
|
"code": "missing_required_parameter",
|
|
}
|
|
}
|
|
counter.assert_not_awaited()
|
|
|
|
def test_invalid_tools_returns_openai_400(self):
|
|
response, counter = self._post_input_tokens({"model": "gpt-4o", "input": "hi", "tools": "not-a-list"})
|
|
|
|
assert response.status_code == 400, response.text
|
|
error = response.json()["error"]
|
|
assert error["type"] == "invalid_request_error"
|
|
assert error["param"] == "tools"
|
|
assert error["message"].startswith("tools: ")
|
|
counter.assert_not_awaited()
|
|
|
|
def test_invalid_tools_are_dropped_under_drop_params(self) -> None:
|
|
response, counter = self._post_input_tokens(
|
|
{"model": "gpt-4o", "input": "hi", "tools": [5, {"type": "web_search"}], "drop_params": True}
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
assert counter.await_args.kwargs["request"].tools == [{"type": "web_search"}]
|
|
|
|
def test_malformed_tools_follow_the_counted_deployment_even_when_fallbacks_are_configured(self) -> None:
|
|
router: Final = litellm.Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "gpt-5.2",
|
|
"litellm_params": {"model": "openai/gpt-5.2", "api_key": "sk-test", "additional_drop_params": ["tools"]},
|
|
},
|
|
{"model_name": "backup", "litellm_params": {"model": "openai/gpt-5.4-mini", "api_key": "sk-test"}},
|
|
],
|
|
fallbacks=[{"gpt-5.2": ["backup"]}],
|
|
)
|
|
|
|
response, counter = self._post_input_tokens(
|
|
{"model": "gpt-5.2", "input": "hi", "tools": 5}, llm_router=router
|
|
)
|
|
|
|
assert response.status_code == 200, response.text
|
|
assert counter.await_args.kwargs["request"].tools is None
|
|
|
|
def test_malformed_tools_for_a_model_outside_the_router_get_a_clean_400(self) -> None:
|
|
router: Final = litellm.Router(
|
|
model_list=[{"model_name": "gpt-5.2", "litellm_params": {"model": "openai/gpt-5.2", "api_key": "sk-test"}}]
|
|
)
|
|
|
|
response, counter = self._post_input_tokens(
|
|
{"model": "openai/gpt-5.4-mini", "input": "hi", "tools": 5}, llm_router=router
|
|
)
|
|
|
|
assert response.status_code == 400, response.text
|
|
assert response.json()["error"]["param"] == "tools"
|
|
counter.assert_not_awaited()
|
|
|
|
def test_provider_error_maps_status_code(self):
|
|
from litellm.proxy._types import ProxyException
|
|
|
|
failing_counter = AsyncMock(
|
|
side_effect=ProxyException(
|
|
message="rate limited",
|
|
type="token_counting_error",
|
|
param="model",
|
|
code="429",
|
|
)
|
|
)
|
|
response, _ = self._post_input_tokens({"model": "gpt-4o", "input": "hi"}, counter=failing_counter)
|
|
|
|
assert response.status_code == 429, response.text
|
|
assert response.json()["error"]["message"] == "rate limited"
|
|
|
|
@pytest.mark.parametrize(
|
|
"provider_body, status_code, expected_error",
|
|
[
|
|
pytest.param(
|
|
{
|
|
"error": {
|
|
"message": "Missing required parameter: 'tools[0].name'.",
|
|
"type": "invalid_request_error",
|
|
"param": "tools[0].name",
|
|
"code": "missing_required_parameter",
|
|
}
|
|
},
|
|
400,
|
|
{
|
|
"message": "Missing required parameter: 'tools[0].name'.",
|
|
"type": "invalid_request_error",
|
|
"param": "tools[0].name",
|
|
"code": "missing_required_parameter",
|
|
},
|
|
id="openai_error_passes_through",
|
|
),
|
|
pytest.param(
|
|
{
|
|
"error": {
|
|
"message": "Missing required parameter: 'tools[0].name'.",
|
|
"type": "invalid_request_error",
|
|
"param": "tools[0].name",
|
|
}
|
|
},
|
|
400,
|
|
{
|
|
"message": "Missing required parameter: 'tools[0].name'.",
|
|
"type": "invalid_request_error",
|
|
"param": "tools[0].name",
|
|
"code": None,
|
|
},
|
|
id="openai_error_without_code_keeps_type_and_param",
|
|
),
|
|
pytest.param(
|
|
{
|
|
"type": "error",
|
|
"error": {"type": "invalid_request_error", "message": "tools.0: Input tag 'function' is invalid"},
|
|
"request_id": "req_vrtx_1",
|
|
},
|
|
400,
|
|
{
|
|
"message": "tools.0: Input tag 'function' is invalid",
|
|
"type": "invalid_request_error",
|
|
"param": None,
|
|
"code": None,
|
|
},
|
|
id="anthropic_error_message_unwrapped",
|
|
),
|
|
pytest.param(
|
|
{
|
|
"type": "error",
|
|
"error": {"type": "not_found_error", "message": "model: claude-typo"},
|
|
"request_id": "req_vrtx_2",
|
|
},
|
|
404,
|
|
{"message": "model: claude-typo", "type": "invalid_request_error", "param": None, "code": None},
|
|
id="anthropic_error_type_follows_the_status",
|
|
),
|
|
pytest.param(
|
|
{"error": {"code": 404, "message": "Publisher model not found.", "status": "NOT_FOUND"}},
|
|
404,
|
|
{"message": "Publisher model not found.", "type": "invalid_request_error", "param": None, "code": None},
|
|
id="google_error_message_unwrapped",
|
|
),
|
|
pytest.param(
|
|
"litellm.APIError: Google Gen AI Studio API error: 404 - "
|
|
+ json.dumps(
|
|
{"error": {"code": 404, "message": "models/gemini-typo is not found.", "status": "NOT_FOUND"}}
|
|
),
|
|
404,
|
|
{
|
|
"message": "models/gemini-typo is not found.",
|
|
"type": "invalid_request_error",
|
|
"param": None,
|
|
"code": None,
|
|
},
|
|
id="prefixed_gemini_error_message_unwrapped",
|
|
),
|
|
pytest.param(
|
|
"litellm.BadRequestError: AzureException - "
|
|
+ json.dumps(
|
|
{
|
|
"error": {
|
|
"message": "Unrecognized request argument supplied: foo",
|
|
"type": "invalid_request_error",
|
|
"param": "foo",
|
|
"code": "unknown_parameter",
|
|
}
|
|
}
|
|
),
|
|
400,
|
|
{
|
|
"message": "Unrecognized request argument supplied: foo",
|
|
"type": "invalid_request_error",
|
|
"param": "foo",
|
|
"code": "unknown_parameter",
|
|
},
|
|
id="prefixed_openai_error_passes_through",
|
|
),
|
|
pytest.param(
|
|
{"message": "The provided model doesn't support counting tokens."},
|
|
400,
|
|
{
|
|
"message": "The provided model doesn't support counting tokens.",
|
|
"type": "invalid_request_error",
|
|
"param": None,
|
|
"code": None,
|
|
},
|
|
id="bedrock_error_message_unwrapped",
|
|
),
|
|
pytest.param(
|
|
"BedrockException - " + json.dumps({"message": "The provided model doesn't support counting tokens."}),
|
|
400,
|
|
{
|
|
"message": "The provided model doesn't support counting tokens.",
|
|
"type": "invalid_request_error",
|
|
"param": None,
|
|
"code": None,
|
|
},
|
|
id="prefixed_bedrock_error_message_unwrapped",
|
|
),
|
|
pytest.param(
|
|
"tool schema {unclosed is invalid",
|
|
400,
|
|
{
|
|
"message": "tool schema {unclosed is invalid",
|
|
"type": "invalid_request_error",
|
|
"param": None,
|
|
"code": None,
|
|
},
|
|
id="plain_text_with_a_brace_kept_whole",
|
|
),
|
|
pytest.param(
|
|
"upstream count service unavailable",
|
|
503,
|
|
{
|
|
"message": "upstream count service unavailable",
|
|
"type": "internal_server_error",
|
|
"param": None,
|
|
"code": None,
|
|
},
|
|
id="plain_text_error",
|
|
),
|
|
],
|
|
)
|
|
def test_provider_count_error_answers_in_openai_error_shape(
|
|
self,
|
|
provider_body: str | Mapping[str, object],
|
|
status_code: int,
|
|
expected_error: Mapping[str, str | None],
|
|
) -> None:
|
|
from litellm.proxy._types import ProxyException
|
|
|
|
failing_counter: Final = AsyncMock(
|
|
side_effect=ProxyException(
|
|
message=provider_body if isinstance(provider_body, str) else json.dumps(provider_body),
|
|
type="token_counting_error",
|
|
param="model",
|
|
code=str(status_code),
|
|
)
|
|
)
|
|
response, _ = self._post_input_tokens({"model": "gpt-4o", "input": "hi"}, counter=failing_counter)
|
|
|
|
assert response.status_code == status_code, response.text
|
|
assert response.json() == {"error": expected_error}
|
|
|
|
def test_non_provider_count_error_keeps_its_own_type(self) -> None:
|
|
from litellm.proxy._types import ProxyException
|
|
|
|
failing_counter: Final = AsyncMock(
|
|
side_effect=ProxyException(
|
|
message="Token counting is disabled and no provider API result available",
|
|
type="token_counting_disabled",
|
|
param="model",
|
|
code=503,
|
|
)
|
|
)
|
|
response, _ = self._post_input_tokens({"model": "gpt-4o", "input": "hi"}, counter=failing_counter)
|
|
|
|
assert response.status_code == 503, response.text
|
|
assert response.json()["error"]["type"] == "token_counting_disabled"
|
|
|
|
def test_provider_count_error_names_the_call_id_when_opted_in(self) -> None:
|
|
from litellm.proxy._types import ProxyException
|
|
|
|
failing_counter: Final = AsyncMock(
|
|
side_effect=ProxyException(
|
|
message="LiteLLM does not support counting tokens for Vertex AI model zai-org/glm-5.2-maas.",
|
|
type="token_counting_error",
|
|
param="model",
|
|
code="400",
|
|
headers={"x-litellm-call-id": "call-9564"},
|
|
)
|
|
)
|
|
with patch("litellm.proxy.proxy_server.general_settings", {"include_call_id_in_error_body": True}):
|
|
response, _ = self._post_input_tokens({"model": "gpt-4o", "input": "hi"}, counter=failing_counter)
|
|
|
|
assert response.status_code == 400, response.text
|
|
assert response.json() == {
|
|
"error": {
|
|
"message": "LiteLLM does not support counting tokens for Vertex AI model zai-org/glm-5.2-maas.",
|
|
"type": "invalid_request_error",
|
|
"param": None,
|
|
"code": None,
|
|
"litellm_call_id": "call-9564",
|
|
}
|
|
}
|
|
|
|
|
|
class TestGuardrailBlockedResponsesShape:
|
|
"""A pre_call block raises ModifyResponseException before any provider call.
|
|
|
|
The reply must satisfy the Responses API contract the request selected:
|
|
stream=true answers SSE ending in one response.completed whose output[0] is
|
|
a completed assistant message item with output_text content, and a plain
|
|
POST answers JSON with the same item, both with the usage the blocked call
|
|
consumed (zero for pre_call)."""
|
|
|
|
def test_non_stream_block_is_a_completed_assistant_message(self):
|
|
response = _post_blocked_responses(None)
|
|
|
|
assert response.status_code == 200, response.text
|
|
assert response.headers["content-type"].startswith("application/json")
|
|
body = response.json()
|
|
_assert_blocked_output_item(body["output"][0], BLOCK_MESSAGE)
|
|
assert body["usage"]["total_tokens"] == 0
|
|
|
|
def test_stream_block_answers_sse_with_completed_event(self):
|
|
response = _post_blocked_responses(None, payload={"stream": True})
|
|
|
|
assert response.status_code == 200, response.text
|
|
assert response.headers["content-type"].startswith("text/event-stream")
|
|
frames = _sse_data_frames(response.text)
|
|
assert frames[-1] == "[DONE]"
|
|
events = [json.loads(frame) for frame in frames[:-1]]
|
|
types = [event["type"] for event in events]
|
|
assert "response.created" in types
|
|
completed = [event for event in events if event["type"] == "response.completed"]
|
|
assert len(completed) == 1
|
|
completed_response = completed[0]["response"]
|
|
_assert_blocked_output_item(completed_response["output"][0], BLOCK_MESSAGE)
|
|
assert completed_response["usage"]["total_tokens"] == 0
|
|
delta_text = "".join(event["delta"] for event in events if event["type"] == "response.output_text.delta")
|
|
assert delta_text == BLOCK_MESSAGE
|
|
|
|
def test_stream_block_keeps_upstream_usage(self):
|
|
from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse
|
|
|
|
original = ResponsesAPIResponse(
|
|
id="resp_upstream",
|
|
created_at=1,
|
|
model="gpt-4o-mini",
|
|
object="response",
|
|
output=[],
|
|
status="completed",
|
|
usage=ResponseAPIUsage(input_tokens=14, output_tokens=20, total_tokens=34),
|
|
)
|
|
|
|
response = _post_blocked_responses(original, payload={"stream": True})
|
|
|
|
assert response.status_code == 200, response.text
|
|
frames = _sse_data_frames(response.text)
|
|
completed = [json.loads(frame) for frame in frames[:-1] if json.loads(frame)["type"] == "response.completed"]
|
|
usage = completed[0]["response"]["usage"]
|
|
assert usage["input_tokens"] == 14
|
|
assert usage["output_tokens"] == 20
|
|
assert usage["total_tokens"] == 34
|
|
|
|
|
|
def test_responses_routes_document_response_models_in_openapi_schema():
|
|
from typing import cast
|
|
|
|
from fastapi import FastAPI
|
|
|
|
from litellm.proxy.response_api_endpoints.endpoints import router
|
|
|
|
def as_object(value: object) -> dict[str, object]:
|
|
assert isinstance(value, dict)
|
|
return cast(dict[str, object], value)
|
|
|
|
openapi_app = FastAPI()
|
|
openapi_app.include_router(router)
|
|
openapi: Final = cast(dict[str, object], openapi_app.openapi())
|
|
|
|
def ok_200_properties(path: str, method: str) -> dict[str, object]:
|
|
operation: Final = as_object(as_object(as_object(openapi)["paths"])[path])[method]
|
|
schema: Final = as_object(
|
|
as_object(
|
|
as_object(as_object(as_object(as_object(operation)["responses"])["200"])["content"])["application/json"]
|
|
)["schema"]
|
|
)
|
|
ref: Final = schema["$ref"]
|
|
assert isinstance(ref, str)
|
|
component: Final = ref.rsplit("/", 1)[-1]
|
|
return as_object(
|
|
as_object(as_object(as_object(as_object(openapi)["components"])["schemas"])[component])["properties"]
|
|
)
|
|
|
|
assert "output" in ok_200_properties("/v1/responses", "post")
|
|
assert "output" in ok_200_properties("/v1/responses/{response_id}", "get")
|
|
assert "deleted" in ok_200_properties("/v1/responses/{response_id}", "delete")
|
|
assert "data" in ok_200_properties("/v1/responses/{response_id}/input_items", "get")
|