mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-14 04:09:09 +00:00
* fix(proxy): return provider-matching 4xx for the LIT-8062 status code follow-ups
- OCR multipart errors are 400 invalid_request_error on the file param
- a missing model is a 400 on chat, responses and messages even with a wildcard deployment
- responses max_output_tokens <= 0 reaches the provider instead of being raised to 16
- daily activity rejects end_date before start_date with a 400
- vector store file attributes are sent in full so the provider enforces its key limit
- router-wide defaults no longer satisfy a param the Router takes positionally, or a model the router does not serve
- a JSON null user_config is treated as absent
* chore: keep the type gates at their merge-base counts
* fix(proxy): keep fixed-target wildcards serving a missing model and close router-default gaps
- a missing model is rejected only when the wildcard would forward it into the target
- moderation only counts router defaults for models get_model_list lists
- user_config defaults and deployment params never satisfy a positionally bound param
* test(integration): pin the fixed-target wildcard path and no provider call on positional-param 400s
* chore: validate pattern-matched deployments instead of reading untyped dicts
* fix(proxy): keep router defaults counting for a2a agent models
* test: drop the signature-walk test and type the new test helpers
* test(integration): audit matrix for the status-code follow-ups
Drives every mode the fix branches on through a real proxy, Postgres, Redis and
the scripted upstream: positional params against router, deployment and
user_config defaults; moderation listed vs unlisted; unknown and a2a models;
every user_config shape on chat, responses and messages, streaming and SDK
clients; forwarding vs fixed-target wildcards for missing, null and empty
models; Responses max_output_tokens edges; OCR parse errors; vector store file
attributes; every daily activity route; and a provider outage mid burst plus a
killed worker on a two-worker proxy. Product gaps found are skipped as BUG.
* style: ruff format the audit test files
* test: name the auth pre-check as the source of the JSON array 500
* test: build the audit helpers without mutation and share one sentinel
* test: build every audit request body as one literal
* fix(proxy): keep router defaults and model-less dispatch for requests a fallback chain serves
* fix(proxy): count request, key and team fallbacks when an unlisted model inherits router defaults
* fix(proxy): skip key and team fallbacks on the client-credential dispatch path
* fix(proxy): keep router defaults when the router's own * fallback serves an unlisted model
* fix(proxy): count client-style fallbacks, forward an empty model, report invalid usage chat dates
- The router-default pre-check now treats client-style request, key and team fallbacks
([{"model": ...}]) as serving an unlisted model, as the router does
- The forwarding-wildcard gate only rejects a missing or null model; an empty model
reaches the provider as before (vLLM serves its loaded model for it)
- The /usage/ai/chat tools hand the model the date-range error instead of an empty
dataset or a generic fetch error
* test(usage): type the invalid-date tool test
* fix(proxy): keep rejecting an empty model behind a forwarding wildcard
Key and team model checks skip an empty model, so forwarding it let a key
restricted to another model reach a server's default model (vLLM serves
its loaded model for ""). Pin that with a restricted-key integration cell.
* fix(usage): report the query layer's date 400 to the usage chat model
Checking dates before the fetch refused formats the global usage tool's query
accepts (2026/10/01 returned spend on main). Catch the daily activity query's
own 400 instead, so each tool rejects exactly what it rejected before and only
the message changes.
* fix(proxy): restore three model-less and unlisted-model paths main served
An empty model is looked up as sent, so next to a forwarding */* wildcard it
still reaches the fixed * deployment it matches. A null model with a prompt_id
is left to the prompt manager, which may pick the model. A Claude Code
subagent turn on a session-bound auto/complexity router counts as served, so
the router default max_tokens still applies.
* fix(proxy): only skip the model check for a prompt_id when a prompt manager is registered
Without a callback prompt manager nothing can pick the model, so a null model
with a prompt_id behind a forwarding wildcard gets the same 400 as any other
null model.
* fix(proxy): leave a model-less Claude Code subagent turn to its session router
A subagent turn on a session bound to an auto, complexity, adaptive or
quality router is answered by that router whatever model it names, so a
null or missing model behind a forwarding wildcard is no longer rejected.
An empty model stays rejected.
* fix(proxy): skip the wildcard model check only for a bound Claude Code session
Read the session's router binding, as the router does, instead of only
checking that the request could resume one, so a subagent turn without a
model on an unbound session is still rejected before a forwarding
wildcard sends it upstream.
* refactor(proxy): stop predicting routing in the proxy pre-checks
- drop the wildcard missing-model gate: a model-less request behind a forwarding wildcard goes to the router and gets the provider's own status, as on main
- drop the "model the router serves" check: router defaults count for any named model again, except moderation, where the Router merges them only for listed models
- keep the positional-param, moderation and null user_config fixes
* test(ocr): cover the form, size, empty body, invalid JSON and file-document 400s
---------
Co-authored-by: yucheng-berri <293926326+yucheng-berri@users.noreply.github.com>
280 lines
11 KiB
Python
280 lines
11 KiB
Python
"""Shared payloads and wire helpers for the status-code follow-up audit cells.
|
|
|
|
Every cell drives a real proxy against the scripted upstream and reads the outbound provider body back from the
|
|
upstream's observation feed, so a cell asserts both what the caller got and what the provider received."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import uuid
|
|
from collections.abc import Iterator, Mapping, Sequence
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
from typing import Final
|
|
|
|
import httpx
|
|
from integration._support.client import JSON_OBJECT, Gateway, Scenario, gateway_from_environment, object_value
|
|
from integration._support.process import owned_proxy_process
|
|
from integration._support.upstream import ScenarioHandle, delete_scenario, register_scenario
|
|
from pydantic import JsonValue
|
|
|
|
from tests.integration.cost_calculation.cost_tracking_case import (
|
|
JsonResponse,
|
|
SseResponse,
|
|
StoredResponse,
|
|
)
|
|
|
|
ROUTER_DEFAULTS: Final[dict[str, JsonValue]] = {
|
|
"prompt": "router default prompt",
|
|
"input": "router default input",
|
|
"max_tokens": 32,
|
|
}
|
|
CHAT: Final[dict[str, JsonValue]] = {
|
|
"id": "chatcmpl-$UNIQUE_ID",
|
|
"object": "chat.completion",
|
|
"created": 1,
|
|
"model": "gpt-4o-mini",
|
|
"choices": [{"index": 0, "message": {"role": "assistant", "content": "scripted"}, "finish_reason": "stop"}],
|
|
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
|
}
|
|
RESPONSE: Final[dict[str, JsonValue]] = {
|
|
"id": "resp_$UNIQUE_ID",
|
|
"object": "response",
|
|
"created_at": 1,
|
|
"status": "completed",
|
|
"model": "gpt-4o-mini",
|
|
"output": [
|
|
{
|
|
"type": "message",
|
|
"id": "msg_$UNIQUE_ID",
|
|
"status": "completed",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "scripted", "annotations": []}],
|
|
}
|
|
],
|
|
"usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
|
|
}
|
|
MESSAGE: Final[dict[str, JsonValue]] = {
|
|
"id": "msg_$UNIQUE_ID",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"model": "claude-haiku-4-5",
|
|
"content": [{"type": "text", "text": "scripted"}],
|
|
"stop_reason": "end_turn",
|
|
"stop_sequence": None,
|
|
"usage": {"input_tokens": 1, "output_tokens": 1},
|
|
}
|
|
MODERATION: Final[dict[str, JsonValue]] = {
|
|
"id": "modr-$UNIQUE_ID",
|
|
"model": "omni-moderation-latest",
|
|
"results": [{"flagged": False, "categories": {}, "category_scores": {}}],
|
|
}
|
|
CHAT_FRAMES: Final = (
|
|
'data: {"id":"chatcmpl-$UNIQUE_ID","object":"chat.completion.chunk","created":1,"model":"gpt-4o-mini",'
|
|
'"choices":[{"index":0,"delta":{"role":"assistant","content":"streamed "},"finish_reason":null}]}',
|
|
'data: {"id":"chatcmpl-$UNIQUE_ID","object":"chat.completion.chunk","created":1,"model":"gpt-4o-mini",'
|
|
'"choices":[{"index":0,"delta":{"content":"response"},"finish_reason":null}]}',
|
|
'data: {"id":"chatcmpl-$UNIQUE_ID","object":"chat.completion.chunk","created":1,"model":"gpt-4o-mini",'
|
|
'"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
|
|
"data: [DONE]",
|
|
)
|
|
RESPONSES_FRAMES: Final = (
|
|
"event: response.created\n"
|
|
'data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_$UNIQUE_ID","object":"response",'
|
|
'"created_at":1,"status":"in_progress","model":"gpt-4o-mini","output":[],"usage":null}}',
|
|
"event: response.output_text.delta\n"
|
|
'data: {"type":"response.output_text.delta","sequence_number":1,"item_id":"msg_$UNIQUE_ID","output_index":0,'
|
|
'"content_index":0,"delta":"streamed response"}',
|
|
"event: response.completed\n"
|
|
'data: {"type":"response.completed","sequence_number":2,"response":{"id":"resp_$UNIQUE_ID","object":"response",'
|
|
'"created_at":1,"status":"completed","model":"gpt-4o-mini","output":[{"type":"message","id":"msg_$UNIQUE_ID",'
|
|
'"status":"completed","role":"assistant","content":[{"type":"output_text","text":"streamed response",'
|
|
'"annotations":[]}]}],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}}',
|
|
)
|
|
MESSAGE_FRAMES: Final = (
|
|
"event: message_start\n"
|
|
'data: {"type":"message_start","message":{"id":"msg_$UNIQUE_ID","type":"message","role":"assistant",'
|
|
'"model":"claude-haiku-4-5","content":[],"stop_reason":null,"stop_sequence":null,'
|
|
'"usage":{"input_tokens":1,"output_tokens":0}}}',
|
|
"event: content_block_start\n"
|
|
'data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}',
|
|
"event: content_block_delta\n"
|
|
'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"streamed response"}}',
|
|
'event: content_block_stop\ndata: {"type":"content_block_stop","index":0}',
|
|
"event: message_delta\n"
|
|
'data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":2}}',
|
|
'event: message_stop\ndata: {"type":"message_stop"}',
|
|
)
|
|
UNSET: Final = object()
|
|
USER_MESSAGES: Final[list[JsonValue]] = [{"role": "user", "content": "Hello"}]
|
|
STREAM_TERMINALS: Final[Mapping[str, str]] = {
|
|
"chat": "data: [DONE]",
|
|
"responses": '"type":"response.completed"',
|
|
"messages": "event: message_stop",
|
|
}
|
|
ENDPOINT_PATHS: Final[Mapping[str, str]] = {
|
|
"chat": "/v1/chat/completions",
|
|
"responses": "/v1/responses",
|
|
"messages": "/v1/messages",
|
|
}
|
|
ENDPOINT_BODIES: Final[Mapping[str, dict[str, JsonValue]]] = {
|
|
"chat": {"messages": USER_MESSAGES},
|
|
"responses": {"input": "Hello", "store": False},
|
|
"messages": {"messages": USER_MESSAGES, "max_tokens": 16},
|
|
}
|
|
|
|
|
|
def json_response(body: dict[str, JsonValue], status: int = 200) -> JsonResponse:
|
|
return JsonResponse(content_type="application/json", body=body, status=status)
|
|
|
|
|
|
def sse_response(frames: tuple[str, ...]) -> SseResponse:
|
|
return SseResponse(content_type="text/event-stream", frames=frames)
|
|
|
|
|
|
def openai_error(message: str, param: str) -> JsonResponse:
|
|
return json_response(
|
|
{"error": {"message": message, "type": "invalid_request_error", "param": param, "code": None}}, 400
|
|
)
|
|
|
|
|
|
class Upstream:
|
|
"""Reads the scripted upstream's destructive observation feed and keeps every record it drained."""
|
|
|
|
def __init__(self, url: str) -> None:
|
|
self.url = url.rstrip("/")
|
|
self.items: tuple[dict[str, JsonValue], ...] = ()
|
|
|
|
def drain(self) -> None:
|
|
with httpx.Client(timeout=10, trust_env=False) as client:
|
|
payload: Final = JSON_OBJECT.validate_python(
|
|
client.get(f"{self.url}/__observations?include_method=true").json()
|
|
)
|
|
requests: Final = payload.get("requests")
|
|
assert isinstance(requests, list)
|
|
self.items = (*self.items, *(object_value(item) for item in requests if isinstance(item, dict)))
|
|
|
|
def calls(self, identity: str) -> tuple[dict[str, JsonValue], ...]:
|
|
self.drain()
|
|
return tuple(
|
|
item
|
|
for item in self.items
|
|
if f"/{identity}/" in str(item.get("path")) and not str(item.get("path", "")).endswith("/models")
|
|
)
|
|
|
|
|
|
def drain_rig_upstream() -> None:
|
|
"""Drops every observation earlier cells left on the shared upstream, so a cell reads only its own calls."""
|
|
Upstream(os.environ["INTEGRATION_UPSTREAM_URL"]).drain()
|
|
|
|
|
|
def assert_no_provider_call(gateway: Gateway, *identities: str) -> None:
|
|
upstream: Final = Upstream(gateway.upstream_url)
|
|
assert {identity: upstream.calls(identity) for identity in identities} == dict.fromkeys(identities, ())
|
|
|
|
|
|
def one_outbound(gateway: Gateway, identity: str) -> dict[str, JsonValue]:
|
|
calls: Final = Upstream(gateway.upstream_url).calls(identity)
|
|
assert len(calls) == 1, calls
|
|
return object_value(calls[0]["body"])
|
|
|
|
|
|
def post(
|
|
gateway: Gateway,
|
|
path: str,
|
|
body: Mapping[str, JsonValue],
|
|
*,
|
|
key: str | None = None,
|
|
headers: Mapping[str, str] | None = None,
|
|
) -> httpx.Response:
|
|
return gateway.client.post(
|
|
path,
|
|
json=dict(body),
|
|
headers={"Authorization": f"Bearer {gateway.key if key is None else key}", **(headers or {})},
|
|
timeout=30,
|
|
)
|
|
|
|
|
|
def stream_lines(gateway: Gateway, path: str, body: Mapping[str, JsonValue]) -> tuple[int, tuple[str, ...]]:
|
|
with gateway.client.stream(
|
|
"POST", path, json=dict(body), headers={"Authorization": f"Bearer {gateway.key}"}, timeout=30
|
|
) as response:
|
|
return response.status_code, tuple(response.iter_lines())
|
|
|
|
|
|
def error_body(response: httpx.Response) -> dict[str, JsonValue]:
|
|
"""The error object of an OpenAI-shaped or Anthropic-shaped error response."""
|
|
return object_value(JSON_OBJECT.validate_python(response.json())["error"])
|
|
|
|
|
|
def invalid_request(response: httpx.Response) -> dict[str, JsonValue]:
|
|
assert response.status_code == 400, response.text
|
|
error: Final = error_body(response)
|
|
assert error.get("type") == "invalid_request_error", response.text
|
|
return error
|
|
|
|
|
|
def register(scenario: Scenario, prefix: str, response: StoredResponse) -> tuple[str, ScenarioHandle]:
|
|
identity: Final = f"{prefix}-{uuid.uuid4().hex}"
|
|
handle: Final = register_scenario(identity, response)
|
|
scenario.cleanups.callback(delete_scenario, handle)
|
|
return identity, handle
|
|
|
|
|
|
def deployment(
|
|
scenario: Scenario, name: str, model: str, response: StoredResponse, **extra: JsonValue
|
|
) -> tuple[str, dict[str, JsonValue]]:
|
|
identity, handle = register(scenario, name, response)
|
|
return identity, {
|
|
"model_name": name,
|
|
"litellm_params": {"model": model, "api_base": handle.api_base(), "api_key": identity, **extra},
|
|
}
|
|
|
|
|
|
@contextmanager
|
|
def owned_gateway(
|
|
directory: Path,
|
|
config: Mapping[str, JsonValue],
|
|
*,
|
|
workers: int = 1,
|
|
environment: Mapping[str, str] | None = None,
|
|
) -> Iterator[Gateway]:
|
|
"""A proxy booted from this checkout with `config` and extra `environment`, sharing the rig's database, Redis
|
|
and upstream."""
|
|
path: Final = directory / "config.yaml"
|
|
path.write_text(json.dumps(dict(config)), encoding="utf-8")
|
|
with (
|
|
gateway_from_environment() as gateway,
|
|
owned_proxy_process(gateway, directory, environment or {}, config=path, workers=workers) as owned,
|
|
):
|
|
yield Gateway(owned.gateway.client, owned.gateway.key, gateway.upstream_url)
|
|
|
|
|
|
def stream_finished(endpoint: str, lines: Sequence[str]) -> bool:
|
|
"""Whether the stream reached the endpoint's terminal frame."""
|
|
return any(STREAM_TERMINALS[endpoint] in line for line in lines)
|
|
|
|
|
|
def _frame_text(frame: Mapping[str, JsonValue]) -> str:
|
|
"""The text one chat, responses or messages delta frame carries, or "" for any other frame."""
|
|
choices: Final = frame.get("choices")
|
|
first: Final = choices[0] if isinstance(choices, list) and choices else None
|
|
chat_delta: Final = first.get("delta") if isinstance(first, dict) else None
|
|
if isinstance(chat_delta, dict) and isinstance(chat_delta.get("content"), str):
|
|
return str(chat_delta["content"])
|
|
delta: Final = frame.get("delta")
|
|
if frame.get("type") == "response.output_text.delta" and isinstance(delta, str):
|
|
return delta
|
|
if frame.get("type") == "content_block_delta" and isinstance(delta, dict) and delta.get("type") == "text_delta":
|
|
return str(delta.get("text"))
|
|
return ""
|
|
|
|
|
|
def assembled_text(lines: Sequence[str]) -> str:
|
|
"""The text a chat, responses or messages stream carried, joined across its delta frames."""
|
|
return "".join(
|
|
_frame_text(JSON_OBJECT.validate_json(line.removeprefix("data: ")))
|
|
for line in lines
|
|
if line.startswith("data: ") and line != "data: [DONE]"
|
|
)
|