mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-25 05:54:53 +00:00
* test(integration): give each owned proxy a private database and Redis by default owned_proxy and owned_proxy_process now boot on a scratch database and a per-proxy redis-server unless the caller opts into the worker's state with state=OwnedState.SHARED_WITH_WORKER, passes its own DATABASE_URL, or strips DATABASE_URL. Under the replica profile the private database gets the writer and reader roles so the reader path stays covered. Gateway carries the database_url its proxy writes to, and the Scenario cleanup checks and delete_key_if_present read from it, so they keep checking something on a private database. The 189 test files with owned proxy sites that depend on the worker's database or Redis opt in with a shares_worker_state module marker, which a module-scoped conftest fixture turns into the shared state. The 7 helper modules with such sites opt in per call, since conftest fixtures and other test files reach them. * test(integration): reach session fixtures with the shared marker and keep the worker retry default The module-scoped fixture that applied shares_worker_state ran after session-scoped fixtures, so proxies those booted went private in marked files. The marker is now applied around each marked item's setup, call and teardown, which covers fixtures of every scope. Owned proxies on the worker database inherited the worker gateway's router_settings.num_retries of 0 from it. A private proxy now gets the same default in its config copy unless the config sets num_retries itself. Five providers files read the owned proxy's spend rows or response cache through the worker's database or Redis and opt into sharing.
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 owned.gateway
|
|
|
|
|
|
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]"
|
|
)
|