mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-26 06:03:19 +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.
737 lines
29 KiB
Python
737 lines
29 KiB
Python
import itertools
|
|
import json
|
|
import re
|
|
import threading
|
|
import uuid
|
|
from collections import deque
|
|
from collections.abc import Callable, Iterator, Mapping, Sequence
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from types import MappingProxyType
|
|
from typing import Final
|
|
from urllib.parse import parse_qs, urlsplit
|
|
|
|
import anthropic
|
|
import httpx
|
|
import openai
|
|
import pytest
|
|
import yaml
|
|
from integration._support.client import Gateway, eventually, gateway_from_environment, list_value, object_value
|
|
from integration._support.database import read_rows
|
|
from integration._support.process import OwnedProxy, graceful_stop_seconds, owned_proxy_process
|
|
from integration._support.wire import Reply, Request, Wire, wire_server
|
|
from pydantic import JsonValue, TypeAdapter
|
|
|
|
pytestmark: Final = pytest.mark.shares_worker_state
|
|
|
|
MARKER: Final = re.compile(rb"traceresp-[0-9a-f]{32}")
|
|
TRACERESPONSE: Final = re.compile(r"00-([0-9a-f]{32})-([0-9a-f]{16})-01")
|
|
SERVER: Final = 2
|
|
CALL_ID: Final = "litellm.call_id"
|
|
ANSWER: Final = "trace ok"
|
|
JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
|
UPSTREAM_CALL_ID: Final = "upstream-chosen-call-id"
|
|
HOSTILE_UPSTREAM_VALUES: Final = MappingProxyType(
|
|
{
|
|
"empty": "",
|
|
"numeric": "12345",
|
|
"big": "00-" + "a" * 5000,
|
|
"garbage": "not-a-traceparent",
|
|
"joined": f"00-{'1' * 32}-{'2' * 16}-01, 00-{'3' * 32}-{'4' * 16}-01",
|
|
}
|
|
)
|
|
RELAY_CASES: Final = ("none", "continued", "restarted", "callid", *HOSTILE_UPSTREAM_VALUES)
|
|
|
|
|
|
def _marker() -> str:
|
|
return "traceresp-" + uuid.uuid4().hex
|
|
|
|
|
|
def _trace_id() -> str:
|
|
return uuid.uuid4().hex
|
|
|
|
|
|
def _span_id() -> str:
|
|
return uuid.uuid4().hex[:16]
|
|
|
|
|
|
def _chat_reply(identity: str, stream: bool) -> Reply:
|
|
message: Final = {"role": "assistant", "content": ANSWER}
|
|
usage: Final = {"prompt_tokens": 7, "completion_tokens": 2, "total_tokens": 9}
|
|
if not stream:
|
|
return Reply(
|
|
body=json.dumps(
|
|
{
|
|
"id": identity,
|
|
"object": "chat.completion",
|
|
"created": 1,
|
|
"model": "gpt-4o-mini",
|
|
"choices": [{"index": 0, "message": message, "finish_reason": "stop"}],
|
|
"usage": usage,
|
|
}
|
|
).encode()
|
|
)
|
|
chunk: Final = {"id": identity, "object": "chat.completion.chunk", "created": 1, "model": "gpt-4o-mini"}
|
|
frames: Final = (
|
|
{**chunk, "choices": [{"index": 0, "delta": message}]},
|
|
{**chunk, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], "usage": usage},
|
|
)
|
|
return Reply(
|
|
content_type="text/event-stream",
|
|
chunks=(*(b"data: " + json.dumps(frame).encode() + b"\n\n" for frame in frames), b"data: [DONE]\n\n"),
|
|
)
|
|
|
|
|
|
def _responses_reply(identity: str, stream: bool) -> Reply:
|
|
response: Final = {
|
|
"id": identity,
|
|
"object": "response",
|
|
"created_at": 1,
|
|
"status": "completed",
|
|
"model": "gpt-4o-mini",
|
|
"output": [
|
|
{
|
|
"id": "msg_" + identity,
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"status": "completed",
|
|
"content": [{"type": "output_text", "text": ANSWER, "annotations": []}],
|
|
}
|
|
],
|
|
"usage": {"input_tokens": 7, "output_tokens": 2, "total_tokens": 9},
|
|
}
|
|
if not stream:
|
|
return Reply(body=json.dumps(response).encode())
|
|
events: Final = (
|
|
{"type": "response.created", "sequence_number": 0, "response": {**response, "status": "in_progress"}},
|
|
{
|
|
"type": "response.output_text.delta",
|
|
"sequence_number": 1,
|
|
"item_id": "msg_" + identity,
|
|
"output_index": 0,
|
|
"content_index": 0,
|
|
"delta": ANSWER,
|
|
},
|
|
{"type": "response.completed", "sequence_number": 2, "response": response},
|
|
)
|
|
return Reply(
|
|
content_type="text/event-stream",
|
|
chunks=tuple(f"event: {event['type']}\ndata: {json.dumps(event)}\n\n".encode() for event in events),
|
|
)
|
|
|
|
|
|
def _provider(request: Request) -> Reply:
|
|
found: Final = MARKER.search(request.body)
|
|
if found is None:
|
|
return Reply(status=404, body=b'{"error":"no marker"}')
|
|
marker: Final = found.group(0).decode()
|
|
stream: Final = JSON_OBJECT.validate_json(request.body).get("stream") is True
|
|
if request.target.endswith("/responses"):
|
|
return _responses_reply(f"resp_{marker}", stream)
|
|
return _chat_reply(f"chatcmpl-{marker}", stream)
|
|
|
|
|
|
def _query(request: Request) -> Mapping[str, str]:
|
|
return MappingProxyType({name: values[0] for name, values in parse_qs(urlsplit(request.target).query).items()})
|
|
|
|
|
|
def _continued_traceresponse(request: Request) -> Reply:
|
|
incoming: Final = TRACERESPONSE.fullmatch(request.headers.get("traceparent", ""))
|
|
if incoming is None:
|
|
return Reply(status=400, body=b'{"error":"no sampled traceparent reached the upstream"}')
|
|
return Reply(body=b'{"case":"continued"}', headers={"traceresponse": f"00-{incoming.group(1)}-{_span_id()}-01"})
|
|
|
|
|
|
def _relay(request: Request) -> Reply:
|
|
case: Final = urlsplit(request.target).path.rsplit("/", 1)[-1]
|
|
query: Final = _query(request)
|
|
body: Final = json.dumps({"case": case}).encode()
|
|
if case == "continued":
|
|
return _continued_traceresponse(request)
|
|
if case == "restarted":
|
|
return Reply(body=body, headers={"traceresponse": f"00-{query['trace']}-{query['span']}-01"})
|
|
if case == "callid":
|
|
return Reply(status=int(query.get("status", "200")), body=body, headers={"x-litellm-call-id": UPSTREAM_CALL_ID})
|
|
if case in HOSTILE_UPSTREAM_VALUES:
|
|
return Reply(body=body, headers={"traceresponse": HOSTILE_UPSTREAM_VALUES[case]})
|
|
return Reply(body=body)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ExportedSpan:
|
|
trace_id: str
|
|
span_id: str
|
|
parent_span_id: str
|
|
kind: int
|
|
attributes: Mapping[str, JsonValue]
|
|
links: tuple[tuple[str, str], ...]
|
|
|
|
|
|
def _attribute(value: JsonValue) -> JsonValue:
|
|
typed: Final = object_value(value)
|
|
return next((typed[key] for key in ("stringValue", "intValue", "boolValue", "doubleValue") if key in typed), None)
|
|
|
|
|
|
def _link(value: JsonValue) -> tuple[str, str]:
|
|
link: Final = object_value(value)
|
|
return str(link["traceId"]), str(link["spanId"])
|
|
|
|
|
|
def _exported(value: JsonValue) -> ExportedSpan:
|
|
span: Final = object_value(value)
|
|
attributes: Final = tuple(object_value(attribute) for attribute in list_value(span.get("attributes", [])))
|
|
return ExportedSpan(
|
|
trace_id=str(span["traceId"]),
|
|
span_id=str(span["spanId"]),
|
|
parent_span_id=str(span.get("parentSpanId", "")),
|
|
kind=int(str(span.get("kind", 0))),
|
|
attributes=MappingProxyType({str(item["key"]): _attribute(item["value"]) for item in attributes}),
|
|
links=tuple(_link(link) for link in list_value(span.get("links", []))),
|
|
)
|
|
|
|
|
|
def _scope_spans(resource: JsonValue) -> Iterator[JsonValue]:
|
|
return itertools.chain.from_iterable(
|
|
list_value(object_value(scope).get("spans", [])) for scope in list_value(object_value(resource)["scopeSpans"])
|
|
)
|
|
|
|
|
|
def _batch_spans(body: bytes) -> tuple[ExportedSpan, ...]:
|
|
resources: Final = list_value(JSON_OBJECT.validate_json(body).get("resourceSpans", []))
|
|
return tuple(_exported(span) for span in itertools.chain.from_iterable(map(_scope_spans, resources)))
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Collector:
|
|
wire: Wire
|
|
outage: threading.Event
|
|
accepted: Sequence[Request]
|
|
guard: threading.Lock
|
|
|
|
def spans(self) -> tuple[ExportedSpan, ...]:
|
|
with self.guard:
|
|
batches: Final = tuple(self.accepted)
|
|
return tuple(itertools.chain.from_iterable(_batch_spans(batch.body) for batch in batches))
|
|
|
|
def with_span_id(self, span_id: str) -> tuple[ExportedSpan, ...]:
|
|
return tuple(span for span in self.spans() if span.span_id == span_id)
|
|
|
|
def span(self, span_id: str) -> ExportedSpan:
|
|
eventually(lambda: self.with_span_id(span_id), lambda found: len(found) >= 1, seconds=30)
|
|
found: Final = self.with_span_id(span_id)
|
|
assert len(found) == 1, found
|
|
return found[0]
|
|
|
|
def server_spans_for_call(self, call_id: str) -> tuple[ExportedSpan, ...]:
|
|
return tuple(span for span in self.spans() if span.kind == SERVER and span.attributes.get(CALL_ID) == call_id)
|
|
|
|
def server_span_for_call(self, call_id: str) -> ExportedSpan:
|
|
eventually(lambda: self.server_spans_for_call(call_id), lambda found: len(found) >= 1, seconds=30)
|
|
found: Final = self.server_spans_for_call(call_id)
|
|
assert len(found) == 1, found
|
|
return found[0]
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def collector() -> Iterator[Collector]:
|
|
outage: Final = threading.Event()
|
|
accepted: Final[deque[Request]] = deque() # mutable-ok: the sink thread appends each accepted export batch
|
|
guard: Final = threading.Lock()
|
|
|
|
def sink(request: Request) -> Reply:
|
|
if outage.is_set():
|
|
return Reply(status=503, body=b'{"error":"sink down"}')
|
|
with guard:
|
|
accepted.append(request)
|
|
return Reply()
|
|
|
|
with wire_server(sink) as wire:
|
|
yield Collector(wire, outage, accepted, guard)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def provider() -> Iterator[Wire]:
|
|
with wire_server(_provider) as wire:
|
|
yield wire
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def relay() -> Iterator[Wire]:
|
|
with wire_server(_relay) as wire:
|
|
yield wire
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Answer:
|
|
headers: httpx.Headers
|
|
text: str
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Rig:
|
|
proxy: Gateway
|
|
process: OwnedProxy
|
|
model: str
|
|
upstream: Wire
|
|
sink: Collector
|
|
|
|
def base_url(self) -> str:
|
|
return str(self.proxy.client.base_url)
|
|
|
|
def openai_client(self) -> openai.OpenAI:
|
|
return openai.OpenAI(base_url=self.base_url() + "/v1", api_key=self.proxy.key, max_retries=0)
|
|
|
|
def anthropic_client(self) -> anthropic.Anthropic:
|
|
return anthropic.Anthropic(base_url=self.base_url(), api_key=self.proxy.key, max_retries=0)
|
|
|
|
def post(self, path: str, body: Mapping[str, JsonValue], headers: Sequence[tuple[str, str]] = ()) -> httpx.Response:
|
|
return self.proxy.client.post(
|
|
path, json=body, headers=[("Authorization", f"Bearer {self.proxy.key}"), *headers]
|
|
)
|
|
|
|
def chat(self, marker: str, *, headers: Sequence[tuple[str, str]] = ()) -> httpx.Response:
|
|
return self.post(
|
|
"/v1/chat/completions",
|
|
{"model": self.model, "messages": [{"role": "user", "content": marker}], "cache": {"no-cache": True}},
|
|
headers,
|
|
)
|
|
|
|
def upstream_count(self, marker: str) -> int:
|
|
return sum(1 for request in self.upstream.drain() if marker.encode() in request.body)
|
|
|
|
|
|
def _config(provider: Wire, relay: Wire, sink: Collector, model: str) -> dict[str, JsonValue]:
|
|
config: Final = object_value(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()))
|
|
return {
|
|
**config,
|
|
"model_list": [
|
|
*list_value(config.get("model_list") or []),
|
|
{
|
|
"model_name": model,
|
|
"litellm_params": {
|
|
"model": "openai/gpt-4o-mini",
|
|
"api_key": "integration-provider-key",
|
|
"api_base": provider.url + "/v1",
|
|
},
|
|
},
|
|
],
|
|
"litellm_settings": {**object_value(config["litellm_settings"]), "callbacks": ["otel"]},
|
|
"callback_settings": {"otel": {"exporter": "http/json", "endpoint": sink.wire.url}},
|
|
"general_settings": {
|
|
**object_value(config["general_settings"]),
|
|
"pass_through_endpoints": [
|
|
{"path": f"/relay/{case}", "target": f"{relay.url}/relay/{case}"} for case in RELAY_CASES
|
|
],
|
|
},
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class RigFactory:
|
|
provider: Wire
|
|
relay: Wire
|
|
sink: Collector
|
|
directory: Path
|
|
otel_v2: bool
|
|
|
|
def start(self) -> Iterator[Rig]:
|
|
model: Final = "traceresp-model-" + uuid.uuid4().hex[:12]
|
|
path: Final = self.directory / f"traceresponse-{uuid.uuid4().hex}.yaml"
|
|
path.write_text(yaml.safe_dump(_config(self.provider, self.relay, self.sink, model)))
|
|
gate: Final = {"LITELLM_OTEL_V2": "1"} if self.otel_v2 else {}
|
|
with (
|
|
gateway_from_environment() as gateway,
|
|
owned_proxy_process(
|
|
gateway,
|
|
self.directory,
|
|
{**gate, "OTEL_BSP_SCHEDULE_DELAY": "300"},
|
|
config=path,
|
|
remove_environment=() if self.otel_v2 else ("LITELLM_OTEL_V2",),
|
|
workers=2,
|
|
) as owned,
|
|
):
|
|
yield Rig(owned.gateway, owned, model, self.provider, self.sink)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def factory(
|
|
provider: Wire, relay: Wire, collector: Collector, tmp_path_factory: pytest.TempPathFactory
|
|
) -> Callable[[bool], RigFactory]:
|
|
return lambda otel_v2: RigFactory(provider, relay, collector, tmp_path_factory.mktemp("traceresponse"), otel_v2)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def rig(factory: Callable[[bool], RigFactory]) -> Iterator[Rig]:
|
|
yield from factory(True).start()
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def control(factory: Callable[[bool], RigFactory]) -> Iterator[Rig]:
|
|
yield from factory(False).start()
|
|
|
|
|
|
def _named(headers: httpx.Headers) -> tuple[str, str]:
|
|
values: Final = headers.get_list("traceresponse")
|
|
assert len(values) == 1, values
|
|
match: Final = TRACERESPONSE.fullmatch(values[0])
|
|
assert match is not None, values
|
|
return match.group(1), match.group(2)
|
|
|
|
|
|
def _assert_names_its_server_span(rig: Rig, headers: httpx.Headers) -> ExportedSpan:
|
|
trace_id, span_id = _named(headers)
|
|
span: Final = rig.sink.span(span_id)
|
|
assert (span.kind, span.trace_id) == (SERVER, trace_id), span
|
|
call_ids: Final = headers.get_list("x-litellm-call-id")
|
|
assert len(call_ids) == 1 and span.attributes.get(CALL_ID) == call_ids[0], (call_ids, span)
|
|
return span
|
|
|
|
|
|
def _chat_sdk(rig: Rig, marker: str) -> Answer:
|
|
completion: Final = rig.openai_client().chat.completions.with_raw_response.create(
|
|
model=rig.model, messages=[{"role": "user", "content": marker}], extra_body={"cache": {"no-cache": True}}
|
|
)
|
|
return Answer(completion.headers, completion.parse().choices[0].message.content or "")
|
|
|
|
|
|
def _chat_stream_sdk(rig: Rig, marker: str) -> Answer:
|
|
with rig.openai_client().chat.completions.with_streaming_response.create(
|
|
model=rig.model,
|
|
messages=[{"role": "user", "content": marker}],
|
|
stream=True,
|
|
extra_body={"cache": {"no-cache": True}},
|
|
) as chunks:
|
|
text: Final = "".join(chunk.choices[0].delta.content or "" for chunk in chunks.parse() if chunk.choices)
|
|
return Answer(chunks.headers, text)
|
|
|
|
|
|
def _messages_sdk(rig: Rig, marker: str) -> Answer:
|
|
reply: Final = rig.anthropic_client().messages.with_raw_response.create(
|
|
model=rig.model, max_tokens=16, messages=[{"role": "user", "content": marker}]
|
|
)
|
|
block: Final = reply.parse().content[0]
|
|
assert isinstance(block, anthropic.types.TextBlock), block
|
|
return Answer(reply.headers, block.text)
|
|
|
|
|
|
def _messages_stream_sdk(rig: Rig, marker: str) -> Answer:
|
|
with rig.anthropic_client().messages.with_streaming_response.create(
|
|
model=rig.model, max_tokens=16, messages=[{"role": "user", "content": marker}], stream=True
|
|
) as streamed:
|
|
text: Final = "".join(
|
|
event.delta.text
|
|
for event in streamed.parse()
|
|
if event.type == "content_block_delta" and event.delta.type == "text_delta"
|
|
)
|
|
return Answer(streamed.headers, text)
|
|
|
|
|
|
def _responses_sdk(rig: Rig, marker: str) -> Answer:
|
|
created: Final = rig.openai_client().responses.with_raw_response.create(model=rig.model, input=marker)
|
|
return Answer(created.headers, created.parse().output_text)
|
|
|
|
|
|
def _responses_stream_sdk(rig: Rig, marker: str) -> Answer:
|
|
with rig.openai_client().responses.with_streaming_response.create(
|
|
model=rig.model, input=marker, stream=True
|
|
) as events:
|
|
text: Final = "".join(event.delta for event in events.parse() if event.type == "response.output_text.delta")
|
|
return Answer(events.headers, text)
|
|
|
|
|
|
SDK_ROUTES: Final[Mapping[str, Callable[[Rig, str], Answer]]] = MappingProxyType(
|
|
{
|
|
"chat": _chat_sdk,
|
|
"chat_stream": _chat_stream_sdk,
|
|
"messages": _messages_sdk,
|
|
"messages_stream": _messages_stream_sdk,
|
|
"responses": _responses_sdk,
|
|
"responses_stream": _responses_stream_sdk,
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("route", tuple(SDK_ROUTES))
|
|
def test_sdk_response_names_the_server_span_that_carries_its_call_id(rig: Rig, route: str) -> None:
|
|
marker: Final = _marker()
|
|
answer: Final = SDK_ROUTES[route](rig, marker)
|
|
assert answer.text == ANSWER, answer
|
|
_assert_names_its_server_span(rig, answer.headers)
|
|
assert rig.upstream_count(marker) == 1
|
|
|
|
|
|
def _spend_rows(response_id: str) -> int:
|
|
return len(read_rows('SELECT request_id FROM "LiteLLM_SpendLogs" WHERE request_id=%s', (response_id,)))
|
|
|
|
|
|
@pytest.mark.parametrize("stream", (False, True), ids=("plain", "stream"))
|
|
def test_a_response_cache_hit_names_its_own_server_span(rig: Rig, stream: bool) -> None:
|
|
marker: Final = _marker()
|
|
body: Final[dict[str, JsonValue]] = {
|
|
"model": rig.model,
|
|
"messages": [{"role": "user", "content": marker}],
|
|
"stream": stream,
|
|
}
|
|
first: Final = rig.post("/v1/chat/completions", body)
|
|
assert first.status_code == 200, first.text
|
|
first_span: Final = _assert_names_its_server_span(rig, first.headers)
|
|
eventually(lambda: _spend_rows(f"chatcmpl-{marker}"), lambda rows: rows == 1, seconds=70)
|
|
second: Final = rig.post("/v1/chat/completions", body)
|
|
assert second.status_code == 200, second.text
|
|
assert f"chatcmpl-{marker}" in second.text, second.text
|
|
second_span: Final = _assert_names_its_server_span(rig, second.headers)
|
|
assert second_span.span_id != first_span.span_id
|
|
assert second.headers["x-litellm-call-id"] != first.headers["x-litellm-call-id"]
|
|
assert rig.upstream_count(marker) == 1
|
|
|
|
|
|
def test_a_sampled_caller_traceparent_is_continued_in_the_header(rig: Rig) -> None:
|
|
trace_id: Final = _trace_id()
|
|
parent: Final = _span_id()
|
|
response: Final = rig.chat(_marker(), headers=(("traceparent", f"00-{trace_id}-{parent}-01"),))
|
|
assert response.status_code == 200, response.text
|
|
span: Final = _assert_names_its_server_span(rig, response.headers)
|
|
assert (span.trace_id, span.parent_span_id) == (trace_id, parent), span
|
|
|
|
|
|
def test_an_unsampled_caller_traceparent_gets_no_header(rig: Rig) -> None:
|
|
response: Final = rig.chat(_marker(), headers=(("traceparent", f"00-{_trace_id()}-{_span_id()}-00"),))
|
|
assert response.status_code == 200, response.text
|
|
assert response.headers.get_list("traceresponse") == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"traceparent",
|
|
("", "12345", "00-" + "f" * 5000, "not-a-traceparent", f"00-{'5' * 32}-{'6' * 16}-01, 00-{'7' * 32}-{'8' * 16}-01"),
|
|
ids=("empty", "numeric", "five_kilobytes", "garbage", "list"),
|
|
)
|
|
def test_a_malformed_caller_traceparent_starts_a_fresh_trace_in_the_header(rig: Rig, traceparent: str) -> None:
|
|
response: Final = rig.chat(_marker(), headers=(("traceparent", traceparent),))
|
|
assert response.status_code == 200, response.text
|
|
span: Final = _assert_names_its_server_span(rig, response.headers)
|
|
assert span.parent_span_id == "", span
|
|
assert span.trace_id not in traceparent, span
|
|
|
|
|
|
def test_a_traceparent_sent_twice_is_continued_once(rig: Rig) -> None:
|
|
trace_id: Final = _trace_id()
|
|
parent: Final = _span_id()
|
|
value: Final = f"00-{trace_id}-{parent}-01"
|
|
response: Final = rig.chat(_marker(), headers=(("traceparent", value), ("traceparent", value)))
|
|
assert response.status_code == 200, response.text
|
|
span: Final = _assert_names_its_server_span(rig, response.headers)
|
|
assert (span.trace_id, span.parent_span_id) == (trace_id, parent), span
|
|
|
|
|
|
def _error_bad_key(rig: Rig) -> httpx.Response:
|
|
return rig.proxy.client.post(
|
|
"/v1/chat/completions",
|
|
json={"model": rig.model, "messages": [{"role": "user", "content": _marker()}]},
|
|
headers={"Authorization": "Bearer sk-not-a-real-key"},
|
|
)
|
|
|
|
|
|
def _error_unknown_model(rig: Rig) -> httpx.Response:
|
|
return rig.post(
|
|
"/v1/chat/completions",
|
|
{"model": "no-such-model-" + uuid.uuid4().hex, "messages": [{"role": "user", "content": "x"}]},
|
|
)
|
|
|
|
|
|
def _error_invalid_body(rig: Rig) -> httpx.Response:
|
|
return rig.post("/key/generate", {"models": "not-a-list"})
|
|
|
|
|
|
def _error_unhandled(rig: Rig) -> httpx.Response:
|
|
return rig.proxy.client.post(
|
|
"/v1/chat/completions",
|
|
content=b"[1]",
|
|
headers={"Authorization": f"Bearer {rig.proxy.key}", "content-type": "application/json"},
|
|
)
|
|
|
|
|
|
ERRORS: Final[Mapping[str, tuple[int, Callable[[Rig], httpx.Response]]]] = MappingProxyType(
|
|
{
|
|
"bad_key": (401, _error_bad_key),
|
|
"unknown_model": (400, _error_unknown_model),
|
|
"invalid_body": (422, _error_invalid_body),
|
|
"unhandled": (500, _error_unhandled),
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("error", tuple(ERRORS))
|
|
def test_an_error_response_names_its_own_server_span(rig: Rig, error: str) -> None:
|
|
status, send = ERRORS[error]
|
|
response: Final = send(rig)
|
|
assert response.status_code == status, response.text
|
|
trace_id, span_id = _named(response.headers)
|
|
span: Final = rig.sink.span(span_id)
|
|
assert (span.kind, span.trace_id) == (SERVER, trace_id), span
|
|
|
|
|
|
def test_an_excluded_health_route_gets_no_header(rig: Rig) -> None:
|
|
response: Final = rig.proxy.client.get("/health/liveliness")
|
|
assert response.status_code == 200, response.text
|
|
assert response.headers.get_list("traceresponse") == []
|
|
|
|
|
|
def test_a_pass_through_without_an_upstream_header_gets_the_gateway_header(rig: Rig) -> None:
|
|
response: Final = rig.post("/relay/none", {"marker": _marker()})
|
|
assert response.status_code == 200, response.text
|
|
assert response.json() == {"case": "none"}
|
|
span: Final = _assert_names_its_server_span(rig, response.headers)
|
|
assert span.links == (), span
|
|
|
|
|
|
def test_a_pass_through_upstream_header_in_the_same_trace_is_relayed_unchanged_without_a_link(rig: Rig) -> None:
|
|
trace_id: Final = _trace_id()
|
|
response: Final = rig.post(
|
|
"/relay/continued", {"marker": _marker()}, (("traceparent", f"00-{trace_id}-{_span_id()}-01"),)
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
relayed: Final = response.headers.get_list("traceresponse")
|
|
assert len(relayed) == 1 and relayed[0].startswith(f"00-{trace_id}-"), relayed
|
|
span: Final = rig.sink.server_span_for_call(response.headers["x-litellm-call-id"])
|
|
assert span.trace_id == trace_id, span
|
|
assert span.span_id not in relayed[0], (span, relayed)
|
|
assert span.links == (), span
|
|
|
|
|
|
def test_a_pass_through_upstream_that_restarted_the_trace_is_relayed_and_linked(rig: Rig) -> None:
|
|
upstream_trace: Final = _trace_id()
|
|
upstream_span: Final = _span_id()
|
|
response: Final = rig.proxy.client.post(
|
|
"/relay/restarted",
|
|
params={"trace": upstream_trace, "span": upstream_span},
|
|
json={"marker": _marker()},
|
|
headers={"Authorization": f"Bearer {rig.proxy.key}"},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
assert response.headers.get_list("traceresponse") == [f"00-{upstream_trace}-{upstream_span}-01"]
|
|
span: Final = rig.sink.server_span_for_call(response.headers["x-litellm-call-id"])
|
|
assert span.trace_id != upstream_trace, span
|
|
assert span.links == ((upstream_trace, upstream_span),), span
|
|
|
|
|
|
@pytest.mark.parametrize("case", tuple(HOSTILE_UPSTREAM_VALUES))
|
|
def test_a_hostile_pass_through_upstream_header_is_relayed_once_unchanged_and_never_linked(rig: Rig, case: str) -> None:
|
|
response: Final = rig.post(f"/relay/{case}", {"marker": _marker()})
|
|
assert response.status_code == 200, response.text
|
|
assert response.headers.get_list("traceresponse") == [HOSTILE_UPSTREAM_VALUES[case]]
|
|
span: Final = rig.sink.server_span_for_call(response.headers["x-litellm-call-id"])
|
|
assert span.links == (), span
|
|
|
|
|
|
@pytest.mark.parametrize("status", (200, 404, 500))
|
|
def test_a_pass_through_upstream_call_id_never_replaces_the_gateway_call_id(rig: Rig, status: int) -> None:
|
|
response: Final = rig.proxy.client.post(
|
|
"/relay/callid",
|
|
params={"status": str(status)},
|
|
json={"marker": _marker()},
|
|
headers={"Authorization": f"Bearer {rig.proxy.key}"},
|
|
)
|
|
assert response.status_code == status, response.text
|
|
call_ids: Final = response.headers.get_list("x-litellm-call-id")
|
|
assert len(call_ids) == 1 and call_ids[0] != UPSTREAM_CALL_ID, call_ids
|
|
_assert_names_its_server_span(rig, response.headers)
|
|
assert rig.sink.server_spans_for_call(UPSTREAM_CALL_ID) == ()
|
|
|
|
|
|
def _exposed(response: httpx.Response) -> frozenset[str]:
|
|
listed: Final = response.headers.get("access-control-expose-headers", "")
|
|
return frozenset(name.strip().lower() for name in listed.split(",") if name.strip())
|
|
|
|
|
|
def test_a_browser_origin_can_read_the_header(rig: Rig) -> None:
|
|
response: Final = rig.proxy.request("GET", "/v1/models", headers={"Origin": "http://dashboard.example"})
|
|
assert response.status_code == 200, response.text
|
|
assert "traceresponse" in _exposed(response), response.headers
|
|
_named(response.headers)
|
|
|
|
|
|
def test_with_the_v2_gate_off_no_response_carries_or_exposes_the_header(control: Rig) -> None:
|
|
marker: Final = _marker()
|
|
chat: Final = control.chat(marker)
|
|
assert chat.status_code == 200, chat.text
|
|
assert chat.headers.get_list("traceresponse") == []
|
|
assert control.upstream_count(marker) == 1
|
|
models: Final = control.proxy.request("GET", "/v1/models", headers={"Origin": "http://dashboard.example"})
|
|
assert models.status_code == 200, models.text
|
|
assert _exposed(models), models.headers
|
|
assert "traceresponse" not in _exposed(models), models.headers
|
|
assert models.headers.get_list("traceresponse") == []
|
|
|
|
|
|
def _raw_send(rig: Rig, index: int) -> httpx.Response:
|
|
marker: Final = _marker()
|
|
stream: Final = index % 2 == 0
|
|
route: Final = index % 3
|
|
if route == 0:
|
|
return rig.post(
|
|
"/v1/chat/completions",
|
|
{"model": rig.model, "messages": [{"role": "user", "content": marker}], "stream": stream},
|
|
)
|
|
if route == 1:
|
|
return rig.post("/v1/responses", {"model": rig.model, "input": marker, "stream": stream})
|
|
return rig.post(
|
|
"/v1/messages",
|
|
{"model": rig.model, "max_tokens": 16, "messages": [{"role": "user", "content": marker}], "stream": stream},
|
|
)
|
|
|
|
|
|
def _burst(rig: Rig, count: int) -> tuple[httpx.Response, ...]:
|
|
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
return tuple(pool.map(lambda index: _raw_send(rig, index), range(count)))
|
|
|
|
|
|
def _assert_each_names_its_own_span(rig: Rig, responses: Sequence[httpx.Response]) -> None:
|
|
assert [response.status_code for response in responses] == [200] * len(responses), [
|
|
response.text for response in responses
|
|
]
|
|
spans: Final = tuple(_assert_names_its_server_span(rig, response.headers) for response in responses)
|
|
assert len({span.span_id for span in spans}) == len(responses), spans
|
|
assert len({span.trace_id for span in spans}) == len(responses), spans
|
|
|
|
|
|
def test_concurrent_mixed_requests_each_name_their_own_server_span(rig: Rig) -> None:
|
|
_assert_each_names_its_own_span(rig, _burst(rig, 24))
|
|
|
|
|
|
def test_a_sink_outage_keeps_every_header_and_the_spans_land_after_recovery(rig: Rig) -> None:
|
|
rig.sink.outage.set()
|
|
try:
|
|
responses: Final = _burst(rig, 20)
|
|
assert all(len(TRACERESPONSE.findall(response.headers.get("traceresponse", ""))) == 1 for response in responses)
|
|
eventually(lambda: any(True for _ in rig.sink.wire.drain()), lambda seen: seen, seconds=30)
|
|
finally:
|
|
rig.sink.outage.clear()
|
|
_assert_each_names_its_own_span(rig, responses)
|
|
|
|
|
|
def _fresh_chat(rig: Rig) -> httpx.Response:
|
|
with httpx.Client(base_url=rig.base_url(), timeout=30, trust_env=False) as client:
|
|
return client.post(
|
|
"/v1/chat/completions",
|
|
json={
|
|
"model": rig.model,
|
|
"messages": [{"role": "user", "content": _marker()}],
|
|
"cache": {"no-cache": True},
|
|
},
|
|
headers={"Authorization": f"Bearer {rig.proxy.key}"},
|
|
)
|
|
|
|
|
|
@pytest.mark.timeout(2 * graceful_stop_seconds() + 120)
|
|
def test_replacement_workers_still_name_their_server_spans(factory: Callable[[bool], RigFactory]) -> None:
|
|
started: Final = factory(True).start()
|
|
fresh: Final = next(started)
|
|
try:
|
|
originals: Final = eventually(fresh.process.worker_pids, lambda found: len(found) == 2, seconds=30)
|
|
replacements: Final = frozenset(fresh.process.kill_worker(pid) for pid in sorted(originals))
|
|
assert not replacements & originals, (originals, replacements)
|
|
assert fresh.process.process.poll() is None, "Proxy root exited after its workers were replaced"
|
|
_assert_each_names_its_own_span(fresh, tuple(_fresh_chat(fresh) for _ in range(8)))
|
|
finally:
|
|
started.close()
|