mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
* feat(otel): excluded_services opt-out for datastore spans on tenant destinations Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): keep upstream support unchanged Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(otel): excluded_services resolves from the otel callback config only Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(otel): name the otel callback logger so excluded_services owner lookup matches Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): assert no aux datastore traces reach the tenant sink Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): read bogus-start proxy log from the results dir Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): read only this invocation's bogus-start proxy log Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): assert operator kept db spans over the whole recorded window Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): split operator db-span asserts by trace scope Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(otel): build the otel logger after preset callbacks and validate the exclusion env at boot Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(otel): tolerate a bogus exclusion env when callback config wins Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(otel): keep bogus exclusion env fatal when a preset parses it Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(otel): hoist the preset check out of the callback loop Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(otel): use a rule-scoped pyright suppression Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(otel): log and drop unknown excluded_services instead of failing boot Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): wait for the operator spend-writer span before checking the tenant for postgres Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(otel v2): leave callback init and boot untouched when excluded_services is unset Read callback_settings.otel.excluded_services directly instead of making the otel callback build its own logger, and drop the new boot-time parse of callback_settings.otel, so a proxy without the setting behaves exactly as on main Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(otel v2): normalize callback_settings excluded_services without rereading OTel env vars Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(otel v2): log and ignore malformed excluded_services instead of failing startup Lowercase and trim names, drop non-string items, and add an integration matrix over endpoints, clients, cache hits, destination outages and setting shapes Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(otel v2): cover failed upstream calls in the excluded_services matrix Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(otel v2): pin operator Langfuse credentials in preset-only excluded_services tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yucheng <yucheng@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: mrinal <mrinal@berri.ai>
713 lines
29 KiB
Python
713 lines
29 KiB
Python
import asyncio
|
|
import json
|
|
import os
|
|
import re
|
|
import signal
|
|
import uuid
|
|
from collections.abc import Callable, Generator, Iterator, Mapping
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Final, Literal
|
|
|
|
import anthropic
|
|
import httpx
|
|
import openai
|
|
import psutil
|
|
import pytest
|
|
import yaml
|
|
from integration._support.client import Gateway, Scenario, eventually, gateway_from_environment, object_value
|
|
from integration._support.otlp_sink import Span, SpanSinks, configure_sink, recorded_spans, spans_for_trace
|
|
from integration._support.process import OwnedProxy, owned_proxy_process
|
|
from integration._support.wire import Reply, Request, Wire, wire_server
|
|
from pydantic import JsonValue, TypeAdapter
|
|
|
|
MARKER: Final = re.compile(rb"excl-[0-9a-f]{32}")
|
|
FAILING: Final = re.compile(rb"excl-fail-[0-9a-f]{32}")
|
|
JSON: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
|
|
REPLY_TEXT: Final = "excluded ok"
|
|
SERVER: Final = 2
|
|
INVALID_NAME_LOG: Final = "is not a datastore service"
|
|
INVALID_VALUE_LOG: Final = "excluded_services must be"
|
|
Endpoint = Literal["chat", "responses", "messages"]
|
|
Client = Literal["raw", "sdk", "async_sdk"]
|
|
ENDPOINTS: Final[tuple[Endpoint, ...]] = ("chat", "responses", "messages")
|
|
CLIENTS: Final[tuple[Client, ...]] = ("raw", "sdk", "async_sdk")
|
|
AuditConfigWriter = Callable[[Path, Mapping[str, JsonValue]], Path]
|
|
|
|
|
|
def _marker() -> str:
|
|
return "excl-" + uuid.uuid4().hex
|
|
|
|
|
|
def _chat_reply(identity: str, stream: bool) -> Reply:
|
|
if not stream:
|
|
return Reply(
|
|
body=json.dumps(
|
|
{
|
|
"id": identity,
|
|
"object": "chat.completion",
|
|
"created": 1,
|
|
"model": "gpt-4o-mini",
|
|
"choices": [
|
|
{"index": 0, "message": {"role": "assistant", "content": REPLY_TEXT}, "finish_reason": "stop"}
|
|
],
|
|
"usage": {"prompt_tokens": 7, "completion_tokens": 2, "total_tokens": 9},
|
|
}
|
|
).encode()
|
|
)
|
|
chunk: Final = {"id": identity, "object": "chat.completion.chunk", "created": 1, "model": "gpt-4o-mini"}
|
|
first, _, rest = REPLY_TEXT.partition(" ")
|
|
deltas: Final[tuple[dict[str, JsonValue], ...]] = (
|
|
{**chunk, "choices": [{"index": 0, "delta": {"role": "assistant", "content": first}}]},
|
|
{**chunk, "choices": [{"index": 0, "delta": {"content": " " + rest}, "finish_reason": "stop"}]},
|
|
{**chunk, "choices": [], "usage": {"prompt_tokens": 7, "completion_tokens": 2, "total_tokens": 9}},
|
|
)
|
|
return Reply(
|
|
content_type="text/event-stream",
|
|
chunks=(*(b"data: " + json.dumps(delta).encode() + b"\n\n" for delta in deltas), b"data: [DONE]\n\n"),
|
|
)
|
|
|
|
|
|
def _responses_reply(identity: str, stream: bool) -> Reply:
|
|
response: Final[dict[str, JsonValue]] = {
|
|
"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": REPLY_TEXT, "annotations": []}],
|
|
}
|
|
],
|
|
"usage": {"input_tokens": 7, "output_tokens": 2, "total_tokens": 9},
|
|
}
|
|
if not stream:
|
|
return Reply(body=json.dumps(response).encode())
|
|
events: Final[tuple[dict[str, JsonValue], ...]] = (
|
|
{
|
|
"type": "response.created",
|
|
"sequence_number": 0,
|
|
"response": {**response, "status": "in_progress", "output": []},
|
|
},
|
|
{
|
|
"type": "response.output_text.delta",
|
|
"sequence_number": 1,
|
|
"item_id": "msg_" + identity,
|
|
"output_index": 0,
|
|
"content_index": 0,
|
|
"delta": REPLY_TEXT,
|
|
},
|
|
{"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 _upstream(request: Request) -> Reply:
|
|
if FAILING.search(request.body) is not None:
|
|
return Reply(status=500, body=b'{"error":{"message":"scripted upstream failure","type":"server_error"}}')
|
|
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 = object_value(JSON.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 _at(payload: JsonValue, *path: str | int) -> JsonValue:
|
|
if not path:
|
|
return payload
|
|
step: Final = path[0]
|
|
if isinstance(step, int):
|
|
assert isinstance(payload, list), payload
|
|
return _at(payload[step], *path[1:])
|
|
return _at(object_value(payload)[step], *path[1:])
|
|
|
|
|
|
def _sse(body: str) -> tuple[JsonValue, ...]:
|
|
return tuple(
|
|
JSON.validate_json(line[6:])
|
|
for line in body.splitlines()
|
|
if line.startswith("data: ") and line != "data: [DONE]"
|
|
)
|
|
|
|
|
|
def _raw_text(endpoint: Endpoint, stream: bool, body: str) -> str:
|
|
if not stream:
|
|
path: Final[tuple[str | int, ...]] = {
|
|
"chat": ("choices", 0, "message", "content"),
|
|
"responses": ("output", 0, "content", 0, "text"),
|
|
"messages": ("content", 0, "text"),
|
|
}[endpoint]
|
|
return str(_at(JSON.validate_json(body), *path))
|
|
events: Final = _sse(body)
|
|
if endpoint == "chat":
|
|
return "".join(
|
|
str(object_value(_at(event, "choices", 0, "delta")).get("content") or "")
|
|
for event in events
|
|
if _at(event, "choices")
|
|
)
|
|
if endpoint == "responses":
|
|
return "".join(
|
|
str(_at(event, "delta")) for event in events if _at(event, "type") == "response.output_text.delta"
|
|
)
|
|
return "".join(
|
|
str(_at(event, "delta", "text"))
|
|
for event in events
|
|
if _at(event, "type") == "content_block_delta" and _at(event, "delta", "type") == "text_delta"
|
|
)
|
|
|
|
|
|
def _body(model: str, endpoint: Endpoint, marker: str, stream: bool) -> tuple[str, dict[str, JsonValue]]:
|
|
if endpoint == "chat":
|
|
return "/v1/chat/completions", {
|
|
"model": model,
|
|
"messages": [{"role": "user", "content": marker}],
|
|
"stream": stream,
|
|
}
|
|
if endpoint == "responses":
|
|
return "/v1/responses", {"model": model, "input": marker, "stream": stream}
|
|
return "/v1/messages", {
|
|
"model": model,
|
|
"max_tokens": 16,
|
|
"messages": [{"role": "user", "content": marker}],
|
|
"stream": stream,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Sent:
|
|
call_id: str
|
|
text: str
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Cursors:
|
|
operator: int
|
|
tenant: int
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Rig:
|
|
proxy: Gateway
|
|
owned: OwnedProxy
|
|
scenario: Scenario
|
|
model: str
|
|
key: str
|
|
upstream: Wire
|
|
sinks: SpanSinks
|
|
|
|
def cursors(self) -> Cursors:
|
|
self.upstream.drain()
|
|
return Cursors(recorded_spans(self.sinks.operator)[0], recorded_spans(self.sinks.tenant)[0])
|
|
|
|
def upstream_hits(self, marker: str) -> int:
|
|
return sum(1 for request in self.upstream.drain() if marker.encode() in request.body)
|
|
|
|
def base_url(self) -> str:
|
|
return str(self.proxy.client.base_url)
|
|
|
|
def raw(
|
|
self, endpoint: Endpoint, marker: str, stream: bool, key: str | None = None, trace_id: str | None = None
|
|
) -> Sent:
|
|
path, body = _body(self.model, endpoint, marker, stream)
|
|
auth: Final = {"Authorization": f"Bearer {key or self.key}"}
|
|
parent: Final = {} if trace_id is None else {"traceparent": f"00-{trace_id}-{uuid.uuid4().hex[:16]}-01"}
|
|
with self.proxy.client.stream("POST", path, json=body, headers={**auth, **parent}) as response:
|
|
text: Final = response.read().decode()
|
|
assert response.status_code == 200, text
|
|
return Sent(response.headers["x-litellm-call-id"], _raw_text(endpoint, stream, text))
|
|
|
|
def sdk(self, endpoint: Endpoint, marker: str, stream: bool) -> Sent:
|
|
if endpoint == "messages":
|
|
messages: Final = anthropic.Anthropic(base_url=self.base_url(), api_key=self.key, max_retries=0).messages
|
|
if not stream:
|
|
reply: Final = messages.with_raw_response.create(
|
|
model=self.model, max_tokens=16, messages=[{"role": "user", "content": marker}]
|
|
)
|
|
block: Final = reply.parse().content[0]
|
|
assert isinstance(block, anthropic.types.TextBlock), block
|
|
return Sent(reply.headers["x-litellm-call-id"], block.text)
|
|
with messages.with_streaming_response.create(
|
|
model=self.model, max_tokens=16, messages=[{"role": "user", "content": marker}], stream=True
|
|
) as streamed:
|
|
return Sent(
|
|
streamed.headers["x-litellm-call-id"],
|
|
"".join(
|
|
event.delta.text
|
|
for event in streamed.parse()
|
|
if event.type == "content_block_delta" and event.delta.type == "text_delta"
|
|
),
|
|
)
|
|
client: Final = openai.OpenAI(base_url=self.base_url() + "/v1", api_key=self.key, max_retries=0)
|
|
if endpoint == "chat":
|
|
if not stream:
|
|
completion: Final = client.chat.completions.with_raw_response.create(
|
|
model=self.model, messages=[{"role": "user", "content": marker}]
|
|
)
|
|
return Sent(
|
|
completion.headers["x-litellm-call-id"], completion.parse().choices[0].message.content or ""
|
|
)
|
|
with client.chat.completions.with_streaming_response.create(
|
|
model=self.model, messages=[{"role": "user", "content": marker}], stream=True
|
|
) as chunks:
|
|
return Sent(
|
|
chunks.headers["x-litellm-call-id"],
|
|
"".join(chunk.choices[0].delta.content or "" for chunk in chunks.parse() if chunk.choices),
|
|
)
|
|
if not stream:
|
|
created: Final = client.responses.with_raw_response.create(model=self.model, input=marker)
|
|
return Sent(created.headers["x-litellm-call-id"], created.parse().output_text)
|
|
with client.responses.with_streaming_response.create(model=self.model, input=marker, stream=True) as events:
|
|
return Sent(
|
|
events.headers["x-litellm-call-id"],
|
|
"".join(event.delta for event in events.parse() if event.type == "response.output_text.delta"),
|
|
)
|
|
|
|
async def async_sdk(self, endpoint: Endpoint, marker: str, stream: bool) -> Sent:
|
|
if endpoint == "messages":
|
|
messages: Final = anthropic.AsyncAnthropic(
|
|
base_url=self.base_url(), api_key=self.key, max_retries=0
|
|
).messages
|
|
if not stream:
|
|
reply: Final = await messages.with_raw_response.create(
|
|
model=self.model, max_tokens=16, messages=[{"role": "user", "content": marker}]
|
|
)
|
|
block: Final = reply.parse().content[0]
|
|
assert isinstance(block, anthropic.types.TextBlock), block
|
|
return Sent(reply.headers["x-litellm-call-id"], block.text)
|
|
async with messages.with_streaming_response.create(
|
|
model=self.model, max_tokens=16, messages=[{"role": "user", "content": marker}], stream=True
|
|
) as streamed:
|
|
pieces: Final = [
|
|
event.delta.text
|
|
async for event in await streamed.parse()
|
|
if event.type == "content_block_delta" and event.delta.type == "text_delta"
|
|
]
|
|
return Sent(streamed.headers["x-litellm-call-id"], "".join(pieces))
|
|
client: Final = openai.AsyncOpenAI(base_url=self.base_url() + "/v1", api_key=self.key, max_retries=0)
|
|
if endpoint == "chat":
|
|
if not stream:
|
|
completion: Final = await client.chat.completions.with_raw_response.create(
|
|
model=self.model, messages=[{"role": "user", "content": marker}]
|
|
)
|
|
return Sent(
|
|
completion.headers["x-litellm-call-id"], completion.parse().choices[0].message.content or ""
|
|
)
|
|
async with client.chat.completions.with_streaming_response.create(
|
|
model=self.model, messages=[{"role": "user", "content": marker}], stream=True
|
|
) as chunks:
|
|
deltas: Final = [
|
|
chunk.choices[0].delta.content or "" async for chunk in await chunks.parse() if chunk.choices
|
|
]
|
|
return Sent(chunks.headers["x-litellm-call-id"], "".join(deltas))
|
|
if not stream:
|
|
created: Final = await client.responses.with_raw_response.create(model=self.model, input=marker)
|
|
return Sent(created.headers["x-litellm-call-id"], created.parse().output_text)
|
|
async with client.responses.with_streaming_response.create(
|
|
model=self.model, input=marker, stream=True
|
|
) as events:
|
|
texts: Final = [
|
|
event.delta async for event in await events.parse() if event.type == "response.output_text.delta"
|
|
]
|
|
return Sent(events.headers["x-litellm-call-id"], "".join(texts))
|
|
|
|
def send(self, endpoint: Endpoint, client: Client, marker: str, stream: bool) -> Sent:
|
|
if client == "raw":
|
|
return self.raw(endpoint, marker, stream)
|
|
if client == "sdk":
|
|
return self.sdk(endpoint, marker, stream)
|
|
return asyncio.run(self.async_sdk(endpoint, marker, stream))
|
|
|
|
|
|
def _db_systems(spans: tuple[Span, ...]) -> set[str]:
|
|
return {
|
|
str(system)
|
|
for span in spans
|
|
if (system := span["attributes"].get("db.system.name") or span["attributes"].get("db.system")) is not None
|
|
}
|
|
|
|
|
|
def _names(spans: tuple[Span, ...]) -> list[str]:
|
|
return sorted(span["name"] for span in spans)
|
|
|
|
|
|
def _has_root(spans: tuple[Span, ...]) -> bool:
|
|
return any(span["kind"] == SERVER for span in spans)
|
|
|
|
|
|
def _trace_of_call(sink: str, call_id: str, since: int) -> tuple[Span, ...]:
|
|
_, spans = recorded_spans(sink, since)
|
|
traces: Final = {span["trace_id"] for span in spans if span["attributes"].get("litellm.call_id") == call_id}
|
|
return tuple(span for span in spans if span["trace_id"] in traces)
|
|
|
|
|
|
def _operator_trace(rig: Rig, sent: Sent, cursors: Cursors) -> tuple[Span, ...]:
|
|
trace: Final = eventually(
|
|
lambda: _trace_of_call(rig.sinks.operator, sent.call_id, cursors.operator),
|
|
lambda spans: _has_root(spans) and "redis" in _db_systems(spans),
|
|
seconds=40,
|
|
)
|
|
assert len({span["trace_id"] for span in trace}) == 1, _names(trace)
|
|
return trace
|
|
|
|
|
|
def _traced_raw(rig: Rig, endpoint: Endpoint, marker: str) -> tuple[str, Sent]:
|
|
trace_id: Final = uuid.uuid4().hex
|
|
return trace_id, rig.raw(endpoint, marker, stream=False, trace_id=trace_id)
|
|
|
|
|
|
def _operator_trace_by_id(rig: Rig, trace_id: str, cursors: Cursors) -> tuple[Span, ...]:
|
|
return eventually(
|
|
lambda: spans_for_trace(recorded_spans(rig.sinks.operator, cursors.operator)[1], trace_id),
|
|
_has_root,
|
|
seconds=40,
|
|
)
|
|
|
|
|
|
def _tenant_mirror(rig: Rig, operator: tuple[Span, ...], cursors: Cursors) -> tuple[Span, ...]:
|
|
kept: Final = frozenset(span["name"] for span in operator if not _db_systems((span,)))
|
|
return eventually(
|
|
lambda: spans_for_trace(recorded_spans(rig.sinks.tenant, cursors.tenant)[1], operator[0]["trace_id"]),
|
|
lambda spans: kept <= {span["name"] for span in spans},
|
|
seconds=40,
|
|
)
|
|
|
|
|
|
def _assert_tenant_mirrors(rig: Rig, operator: tuple[Span, ...], cursors: Cursors) -> tuple[Span, ...]:
|
|
tenant: Final = _tenant_mirror(rig, operator, cursors)
|
|
assert _db_systems(tenant) == set(), f"datastore spans reached the tenant: {_names(tenant)}"
|
|
assert sum(1 for span in tenant if span["kind"] == SERVER) == 1, _names(tenant)
|
|
return tenant
|
|
|
|
|
|
def _assert_withheld(rig: Rig, sent: Sent, cursors: Cursors) -> tuple[Span, ...]:
|
|
tenant: Final = _assert_tenant_mirrors(rig, _operator_trace(rig, sent, cursors), cursors)
|
|
assert any("gen_ai.operation.name" in span["attributes"] for span in tenant), _names(tenant)
|
|
return tenant
|
|
|
|
|
|
def _config(directory: Path, otel_audit_config: AuditConfigWriter, otel: Mapping[str, JsonValue], name: str) -> Path:
|
|
written: Final = otel_audit_config(directory, {})
|
|
loaded: Final = object_value(JSON.validate_python(yaml.safe_load(written.read_text())))
|
|
settings: Final = object_value(loaded["callback_settings"])
|
|
config: Final = {**loaded, "callback_settings": {**settings, "otel": {**object_value(settings["otel"]), **otel}}}
|
|
path: Final = directory / f"{name}.yaml"
|
|
path.write_text(yaml.safe_dump(config))
|
|
return path
|
|
|
|
|
|
@contextmanager
|
|
def _started(
|
|
provider: Wire,
|
|
sinks: SpanSinks,
|
|
config: Path,
|
|
directory: Path,
|
|
langfuse_vars: Mapping[str, JsonValue],
|
|
workers: int,
|
|
) -> Generator[Rig]:
|
|
with (
|
|
gateway_from_environment() as gateway,
|
|
owned_proxy_process(
|
|
gateway,
|
|
directory,
|
|
{"LITELLM_OTEL_V2": "1", "OTEL_BSP_SCHEDULE_DELAY": "300"},
|
|
config=config,
|
|
remove_environment=("LITELLM_OTEL_EXCLUDED_SERVICES",),
|
|
workers=workers,
|
|
) as owned,
|
|
owned.gateway.scenario() as scenario,
|
|
):
|
|
model: Final = scenario.model(api_base=provider.url + "/v1")
|
|
team: Final = scenario.team()
|
|
attached: Final = owned.gateway.request(
|
|
"POST", f"/team/{team}/callback", {"callback_name": "langfuse_otel", "callback_vars": dict(langfuse_vars)}
|
|
)
|
|
assert attached.status_code == 200, attached.text
|
|
yield Rig(owned.gateway, owned, scenario, model, scenario.key(team_id=team), provider, sinks)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def provider() -> Iterator[Wire]:
|
|
with wire_server(_upstream) as wire:
|
|
yield wire
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def rig(
|
|
provider: Wire,
|
|
audit_sinks: SpanSinks,
|
|
otel_audit_config: AuditConfigWriter,
|
|
langfuse_vars: dict[str, JsonValue],
|
|
tmp_path_factory: pytest.TempPathFactory,
|
|
) -> Iterator[Rig]:
|
|
directory: Final = tmp_path_factory.mktemp("excluded-matrix")
|
|
config: Final = _config(directory, otel_audit_config, {"excluded_services": ["redis", "postgres"]}, "matrix")
|
|
with _started(provider, audit_sinks, config, directory, langfuse_vars, workers=2) as started:
|
|
yield started
|
|
|
|
|
|
@pytest.mark.timeout(120)
|
|
@pytest.mark.parametrize("stream", [False, True], ids=["unary", "stream"])
|
|
@pytest.mark.parametrize("client", CLIENTS)
|
|
@pytest.mark.parametrize("endpoint", ENDPOINTS)
|
|
def test_tenant_trace_keeps_request_spans_without_datastore_spans(
|
|
rig: Rig, endpoint: Endpoint, client: Client, stream: bool
|
|
) -> None:
|
|
cursors: Final = rig.cursors()
|
|
marker: Final = _marker()
|
|
sent: Final = rig.send(endpoint, client, marker, stream)
|
|
assert sent.text == REPLY_TEXT, sent
|
|
assert rig.upstream_hits(marker) == 1
|
|
_assert_withheld(rig, sent, cursors)
|
|
|
|
|
|
@pytest.mark.timeout(120)
|
|
@pytest.mark.parametrize("endpoint", ["chat", "messages"])
|
|
def test_cache_hit_twin_keeps_datastore_spans_off_the_tenant(rig: Rig, endpoint: Endpoint) -> None:
|
|
marker: Final = _marker()
|
|
first: Final = rig.raw(endpoint, marker, stream=False)
|
|
assert first.text == REPLY_TEXT, first
|
|
assert rig.upstream_hits(marker) == 1
|
|
cursors: Final = rig.cursors()
|
|
trace_id, hit = eventually(
|
|
lambda: _traced_raw(rig, endpoint, marker), lambda sent: rig.upstream_hits(marker) == 0, seconds=20
|
|
)
|
|
assert hit.text == REPLY_TEXT, hit
|
|
_assert_tenant_mirrors(rig, _operator_trace_by_id(rig, trace_id, cursors), cursors)
|
|
|
|
|
|
@pytest.mark.timeout(120)
|
|
@pytest.mark.parametrize("endpoint", ENDPOINTS)
|
|
def test_failed_upstream_call_keeps_datastore_spans_off_the_tenant(rig: Rig, endpoint: Endpoint) -> None:
|
|
cursors: Final = rig.cursors()
|
|
marker: Final = "excl-fail-" + uuid.uuid4().hex
|
|
trace_id: Final = uuid.uuid4().hex
|
|
path, body = _body(rig.model, endpoint, marker, stream=False)
|
|
failed: Final = rig.proxy.client.post(
|
|
path,
|
|
json=body,
|
|
headers={"Authorization": f"Bearer {rig.key}", "traceparent": f"00-{trace_id}-{uuid.uuid4().hex[:16]}-01"},
|
|
)
|
|
assert failed.status_code == 500, failed.text
|
|
assert rig.upstream_hits(marker) >= 1
|
|
operator: Final = eventually(
|
|
lambda: spans_for_trace(recorded_spans(rig.sinks.operator, cursors.operator)[1], trace_id),
|
|
lambda spans: _has_root(spans) and "redis" in _db_systems(spans),
|
|
seconds=40,
|
|
)
|
|
_assert_tenant_mirrors(rig, operator, cursors)
|
|
|
|
|
|
@pytest.mark.timeout(120)
|
|
def test_key_level_callback_vars_destination_is_filtered_too(rig: Rig, langfuse_vars: dict[str, JsonValue]) -> None:
|
|
key: Final = rig.scenario.key(
|
|
metadata={
|
|
"logging": [
|
|
{"callback_name": "langfuse_otel", "callback_type": "success", "callback_vars": dict(langfuse_vars)}
|
|
]
|
|
}
|
|
)
|
|
cursors: Final = rig.cursors()
|
|
marker: Final = _marker()
|
|
sent: Final = rig.raw("chat", marker, stream=False, key=key)
|
|
assert sent.text == REPLY_TEXT, sent
|
|
assert rig.upstream_hits(marker) == 1
|
|
_assert_withheld(rig, sent, cursors)
|
|
|
|
|
|
@pytest.mark.timeout(120)
|
|
@pytest.mark.parametrize("status", [403, 404])
|
|
def test_rejecting_tenant_destination_leaves_serving_and_the_operator_trace_intact(rig: Rig, status: int) -> None:
|
|
configure_sink(rig.sinks.tenant, status=status)
|
|
try:
|
|
cursors: Final = rig.cursors()
|
|
marker: Final = _marker()
|
|
sent: Final = rig.raw("chat", marker, stream=True)
|
|
assert sent.text == REPLY_TEXT, sent
|
|
assert rig.upstream_hits(marker) == 1
|
|
_assert_withheld(rig, sent, cursors)
|
|
finally:
|
|
configure_sink(rig.sinks.tenant, status=200)
|
|
after: Final = rig.cursors()
|
|
_assert_withheld(rig, rig.raw("responses", _marker(), stream=False), after)
|
|
|
|
|
|
def _burst(rig: Rig, count: int) -> tuple[Sent | str, ...]:
|
|
def one(index: int) -> Sent | str:
|
|
try:
|
|
return rig.raw(ENDPOINTS[index % 3], _marker(), stream=index % 2 == 0)
|
|
except (httpx.HTTPError, AssertionError) as error:
|
|
return repr(error)
|
|
|
|
with ThreadPoolExecutor(max_workers=10) as pool:
|
|
return tuple(pool.map(one, range(count)))
|
|
|
|
|
|
def _served(results: tuple[Sent | str, ...]) -> tuple[Sent, ...]:
|
|
return tuple(result for result in results if isinstance(result, Sent))
|
|
|
|
|
|
def _assert_operator_exactly_once(rig: Rig, served: tuple[Sent, ...], cursors: Cursors) -> set[str]:
|
|
wanted: Final = {sent.call_id for sent in served}
|
|
|
|
def roots() -> dict[str, int]:
|
|
_, spans = recorded_spans(rig.sinks.operator, cursors.operator)
|
|
traced: Final = {
|
|
span["trace_id"]: str(span["attributes"]["litellm.call_id"])
|
|
for span in spans
|
|
if span["attributes"].get("litellm.call_id") in wanted
|
|
}
|
|
counts: Final = {call: 0 for call in wanted}
|
|
for span in spans:
|
|
if span["kind"] == SERVER and span["trace_id"] in traced:
|
|
counts[traced[span["trace_id"]]] += 1
|
|
return counts
|
|
|
|
landed: Final = eventually(roots, lambda counts: all(count >= 1 for count in counts.values()), seconds=90)
|
|
assert landed == {call: 1 for call in wanted}, landed
|
|
_, spans = recorded_spans(rig.sinks.operator, cursors.operator)
|
|
return {span["trace_id"] for span in spans if span["attributes"].get("litellm.call_id") in wanted}
|
|
|
|
|
|
def _assert_tenant_never_saw_datastore_spans(rig: Rig, cursors: Cursors, traces: set[str]) -> None:
|
|
tenant: Final = eventually(
|
|
lambda: recorded_spans(rig.sinks.tenant, cursors.tenant)[1],
|
|
lambda spans: traces <= {span["trace_id"] for span in spans if span["kind"] == SERVER},
|
|
seconds=90,
|
|
)
|
|
assert _db_systems(tenant) == set(), _names(tenant)
|
|
|
|
|
|
@pytest.mark.timeout(300)
|
|
def test_tenant_outage_during_a_mixed_burst_keeps_serving_and_never_leaks_datastore_spans(rig: Rig) -> None:
|
|
cursors: Final = rig.cursors()
|
|
configure_sink(rig.sinks.tenant, status=503)
|
|
try:
|
|
results: Final = _burst(rig, 30)
|
|
finally:
|
|
configure_sink(rig.sinks.tenant, status=200)
|
|
served: Final = _served(results)
|
|
assert len(served) == 30, [result for result in results if isinstance(result, str)]
|
|
assert all(sent.text == REPLY_TEXT for sent in served), served
|
|
traces: Final = _assert_operator_exactly_once(rig, served, cursors)
|
|
_assert_tenant_never_saw_datastore_spans(rig, cursors, traces)
|
|
after: Final = rig.cursors()
|
|
_assert_withheld(rig, rig.raw("messages", _marker(), stream=True), after)
|
|
|
|
|
|
@pytest.mark.timeout(300)
|
|
def test_stalled_tenant_destination_during_a_burst_does_not_block_responses(rig: Rig) -> None:
|
|
cursors: Final = rig.cursors()
|
|
configure_sink(rig.sinks.tenant, paused=True)
|
|
try:
|
|
results: Final = _burst(rig, 20)
|
|
finally:
|
|
configure_sink(rig.sinks.tenant, paused=False)
|
|
served: Final = _served(results)
|
|
assert len(served) == 20, [result for result in results if isinstance(result, str)]
|
|
traces: Final = _assert_operator_exactly_once(rig, served, cursors)
|
|
_assert_tenant_never_saw_datastore_spans(rig, cursors, traces)
|
|
|
|
|
|
@pytest.mark.timeout(300)
|
|
def test_killing_one_of_two_workers_mid_burst_keeps_the_filter_on_the_survivor(rig: Rig) -> None:
|
|
root: Final = psutil.Process(rig.owned.process.pid)
|
|
workers: Final = eventually(
|
|
lambda: tuple(child for child in root.children() if "resource_tracker" not in " ".join(child.cmdline())),
|
|
lambda found: len(found) == 2,
|
|
seconds=30,
|
|
)
|
|
cursors: Final = rig.cursors()
|
|
|
|
def one(index: int) -> Sent | str:
|
|
if index == 6:
|
|
os.kill(workers[0].pid, signal.SIGKILL)
|
|
try:
|
|
return rig.raw("chat", _marker(), stream=index % 2 == 0)
|
|
except (httpx.HTTPError, AssertionError) as error:
|
|
return repr(error)
|
|
|
|
with ThreadPoolExecutor(max_workers=6) as pool:
|
|
results: Final = tuple(pool.map(one, range(18)))
|
|
assert rig.owned.process.poll() is None, "Proxy root exited after a worker was killed"
|
|
failures: Final = tuple(result for result in results if isinstance(result, str))
|
|
assert all(failure.startswith(("ReadError(", "RemoteProtocolError(", "ConnectError(")) for failure in failures), (
|
|
failures
|
|
)
|
|
assert len(failures) <= 6, failures
|
|
settled: Final = tuple(result for index, result in enumerate(results) if index > 12 and isinstance(result, Sent))
|
|
traces: Final = _assert_operator_exactly_once(rig, settled, cursors)
|
|
_assert_tenant_never_saw_datastore_spans(rig, cursors, traces)
|
|
after: Final = rig.cursors()
|
|
_assert_withheld(rig, rig.raw("chat", _marker(), stream=False), after)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Setting:
|
|
otel: Mapping[str, JsonValue]
|
|
withholds_redis: bool
|
|
logs: str | None
|
|
|
|
|
|
SETTINGS: Final[dict[str, Setting]] = {
|
|
"missing": Setting({}, False, None),
|
|
"null": Setting({"excluded_services": None}, False, None),
|
|
"empty_list": Setting({"excluded_services": []}, False, None),
|
|
"empty_string": Setting({"excluded_services": ""}, False, None),
|
|
"yaml_string": Setting({"excluded_services": "redis"}, True, None),
|
|
"duplicates": Setting({"excluded_services": ["redis", "redis"]}, True, None),
|
|
"case_and_space": Setting({"excluded_services": ["REDIS", " Postgres "]}, True, None),
|
|
"integer": Setting({"excluded_services": 7}, False, INVALID_VALUE_LOG),
|
|
"mapping": Setting({"excluded_services": {"redis": True}}, False, INVALID_VALUE_LOG),
|
|
"non_string_item": Setting({"excluded_services": [7, "redis"]}, True, INVALID_VALUE_LOG),
|
|
"oversized_name": Setting({"excluded_services": "x" * 5000}, False, INVALID_NAME_LOG),
|
|
}
|
|
|
|
|
|
@pytest.mark.timeout(180)
|
|
@pytest.mark.parametrize("name", SETTINGS)
|
|
def test_excluded_services_setting_shapes_boot_and_resolve(
|
|
name: str,
|
|
provider: Wire,
|
|
audit_sinks: SpanSinks,
|
|
otel_audit_config: AuditConfigWriter,
|
|
langfuse_vars: dict[str, JsonValue],
|
|
tmp_path: Path,
|
|
) -> None:
|
|
setting: Final = SETTINGS[name]
|
|
config: Final = _config(tmp_path, otel_audit_config, setting.otel, name)
|
|
with _started(provider, audit_sinks, config, tmp_path, langfuse_vars, workers=1) as started:
|
|
cursors: Final = started.cursors()
|
|
marker: Final = _marker()
|
|
sent: Final = started.raw("chat", marker, stream=False)
|
|
assert sent.text == REPLY_TEXT, sent
|
|
assert started.upstream_hits(marker) == 1
|
|
operator: Final = _operator_trace(started, sent, cursors)
|
|
tenant: Final = _tenant_mirror(started, operator, cursors)
|
|
if setting.withholds_redis:
|
|
assert "redis" not in _db_systems(tenant), _names(tenant)
|
|
else:
|
|
eventually(
|
|
lambda: _db_systems(
|
|
spans_for_trace(recorded_spans(started.sinks.tenant, cursors.tenant)[1], tenant[0]["trace_id"])
|
|
),
|
|
lambda systems: "redis" in systems,
|
|
seconds=30,
|
|
)
|
|
log: Final = started.owned.log.read_text()
|
|
if setting.logs is None:
|
|
assert INVALID_NAME_LOG not in log and INVALID_VALUE_LOG not in log, log[-2000:]
|
|
else:
|
|
assert setting.logs in log, log[-4000:]
|