mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-30 01:52:18 +00:00
* test(integration): run the Langfuse DB-callback test on its own scratch database The test from #43282 wrote success_callback=langfuse and the LANGFUSE_* env into the shared integration LiteLLM_Config. The suite's long-running gateway reloads that table and only ever adds callbacks, so it kept exporting to the test's closed Langfuse fake for the rest of the shard even after the rows were restored. The owned proxy now gets a scratch database, which also removes the snapshot/restore code. scratch_database moves into _support/database.py so test_cache_and_quota and this test share one copy, and the stock-config guard now checks the callback settings instead of the raw YAML text. * test(integration): include failure_callback in the stock-config Langfuse guard
369 lines
16 KiB
Python
369 lines
16 KiB
Python
import base64
|
|
import json
|
|
import time
|
|
import uuid
|
|
from collections.abc import Sequence
|
|
from pathlib import Path
|
|
from typing import Final
|
|
|
|
import yaml
|
|
from integration._support.client import Gateway, eventually, object_value, string_value
|
|
from integration._support.database import read_rows, scratch_database
|
|
from integration._support.process import owned_proxy
|
|
from integration._support.wire import Reply, Request, Wire, wire_server
|
|
from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import ExportTraceServiceRequest
|
|
from opentelemetry.proto.common.v1.common_pb2 import KeyValue
|
|
from opentelemetry.proto.trace.v1.trace_pb2 import Span
|
|
from pydantic import BaseModel, JsonValue, TypeAdapter
|
|
|
|
PUBLIC_KEY: Final = "pk-lf-integration"
|
|
SECRET_KEY: Final = "sk-lf-integration"
|
|
PROJECTS_PATH: Final = "/api/public/projects"
|
|
TRACES_PATH: Final = "/api/public/otel/v1/traces"
|
|
PROMPTS_PATH: Final = "/api/public/v2/prompts/"
|
|
STOCK_CONFIG: Final = Path("tests/integration/proxy_config.yaml")
|
|
CONFIG_SECTIONS: Final = ("litellm_settings", "environment_variables")
|
|
LANGFUSE_ENVIRONMENT: Final = ("LANGFUSE_HOST", "LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY")
|
|
INHERITED_ENVIRONMENT: Final = (*LANGFUSE_ENVIRONMENT, "DATABASE_URL_READ_REPLICA")
|
|
_PROXY_CONFIG: Final = TypeAdapter(dict[str, object])
|
|
_SETTINGS: Final = TypeAdapter(dict[str, object])
|
|
|
|
|
|
class _ProviderBody(BaseModel):
|
|
messages: list[object]
|
|
|
|
|
|
def _completion(text: str) -> Reply:
|
|
return Reply(
|
|
body=json.dumps(
|
|
{
|
|
"id": "chatcmpl-" + text,
|
|
"object": "chat.completion",
|
|
"created": 1,
|
|
"model": "gpt-4o-mini",
|
|
"choices": [{"index": 0, "message": {"role": "assistant", "content": text}, "finish_reason": "stop"}],
|
|
"usage": {"prompt_tokens": 11, "completion_tokens": 4, "total_tokens": 15},
|
|
}
|
|
).encode()
|
|
)
|
|
|
|
|
|
def _projects() -> Reply:
|
|
return Reply(body=json.dumps({"data": [{"id": "integration-project", "name": "integration"}]}).encode())
|
|
|
|
|
|
def _text_prompt(name: str) -> Reply:
|
|
return Reply(
|
|
body=json.dumps(
|
|
{
|
|
"type": "text",
|
|
"name": name,
|
|
"version": 1,
|
|
"prompt": "Say {{word}}",
|
|
"config": {},
|
|
"labels": ["production"],
|
|
"tags": [],
|
|
}
|
|
).encode()
|
|
)
|
|
|
|
|
|
def _langfuse_config(tmp_path: Path) -> Path:
|
|
config: Final = _PROXY_CONFIG.validate_python(yaml.safe_load(STOCK_CONFIG.read_text()))
|
|
settings: Final = {
|
|
**_SETTINGS.validate_python(config["litellm_settings"]),
|
|
"success_callback": ["langfuse"],
|
|
"failure_callback": ["langfuse"],
|
|
}
|
|
path: Final = tmp_path / "langfuse.yaml"
|
|
path.write_text(yaml.safe_dump({**config, "litellm_settings": settings}))
|
|
return path
|
|
|
|
|
|
def _langfuse_environment(langfuse: Wire) -> dict[str, str]:
|
|
return {
|
|
"LANGFUSE_HOST": langfuse.url,
|
|
"LANGFUSE_PUBLIC_KEY": PUBLIC_KEY,
|
|
"LANGFUSE_SECRET_KEY": SECRET_KEY,
|
|
"LANGFUSE_FLUSH_INTERVAL": "1",
|
|
}
|
|
|
|
|
|
def _config_rows(database_url: str) -> list[dict[str, JsonValue]]:
|
|
return read_rows(
|
|
'SELECT param_name, param_value FROM "LiteLLM_Config" WHERE param_name IN (%s, %s) ORDER BY param_name',
|
|
CONFIG_SECTIONS,
|
|
database_url=database_url,
|
|
)
|
|
|
|
|
|
def _attribute(entries: Sequence[KeyValue], key: str) -> str | list[str] | None:
|
|
for entry in entries:
|
|
if entry.key != key:
|
|
continue
|
|
if entry.value.HasField("array_value"):
|
|
return [item.string_value for item in entry.value.array_value.values]
|
|
return entry.value.string_value
|
|
return None
|
|
|
|
|
|
def _spans(batches: Sequence[Request]) -> tuple[Span, ...]:
|
|
return tuple(
|
|
span
|
|
for batch in batches
|
|
if batch.target == TRACES_PATH and batch.headers.get("content-type") == "application/x-protobuf"
|
|
for resource_spans in ExportTraceServiceRequest.FromString(batch.body).resource_spans
|
|
for scope_spans in resource_spans.scope_spans
|
|
for span in scope_spans.spans
|
|
)
|
|
|
|
|
|
def test_langfuse_callback_delivers_the_generation_over_otlp_v4_with_the_caller_trace_fields(
|
|
gateway: Gateway, tmp_path: Path
|
|
) -> None:
|
|
marker: Final = "langfuse" + uuid.uuid4().hex
|
|
trace_id: Final = uuid.uuid4().hex
|
|
provider_secret: Final = "synthetic-provider-secret-" + marker
|
|
|
|
def upstream(request: Request) -> Reply:
|
|
assert request.headers["authorization"] == f"Bearer {provider_secret}"
|
|
return _completion(marker + "-answer")
|
|
|
|
def langfuse(request: Request) -> Reply:
|
|
if request.method == "GET" and request.target.startswith(PROJECTS_PATH):
|
|
return _projects()
|
|
return Reply(body=b"", content_type="application/x-protobuf")
|
|
|
|
with (
|
|
wire_server(upstream) as provider,
|
|
wire_server(langfuse) as destination,
|
|
owned_proxy(
|
|
gateway, tmp_path, _langfuse_environment(destination), config=_langfuse_config(tmp_path)
|
|
) as candidate,
|
|
candidate.scenario() as scenario,
|
|
):
|
|
model: Final = scenario.model(api_base=provider.url + "/v1", api_key=provider_secret)
|
|
response: Final = candidate.request(
|
|
"POST",
|
|
"/v1/chat/completions",
|
|
{
|
|
"model": model,
|
|
"messages": [{"role": "user", "content": marker + "-question"}],
|
|
"metadata": {
|
|
"trace_id": trace_id,
|
|
"trace_name": marker + "-trace",
|
|
"generation_name": marker,
|
|
"trace_user_id": marker + "-user",
|
|
"session_id": marker + "-session",
|
|
"tags": [marker],
|
|
},
|
|
"cache": {"no-cache": True},
|
|
},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
received: Final[list[Request]] = [] # mutable-ok: drain() consumes the queue, later polls keep earlier ones
|
|
|
|
def exported() -> tuple[Span, ...]:
|
|
received.extend(destination.drain())
|
|
return tuple(span for span in _spans(received) if span.name == marker)
|
|
|
|
spans: Final = eventually(exported, lambda values: len(values) == 1, seconds=20)
|
|
span: Final = spans[0]
|
|
posts: Final = tuple(request for request in received if request.method == "POST")
|
|
assert {request.target for request in posts} == {TRACES_PATH}, [request.target for request in received]
|
|
basic: Final = "Basic " + base64.b64encode(f"{PUBLIC_KEY}:{SECRET_KEY}".encode()).decode()
|
|
for request in posts:
|
|
assert request.headers["authorization"] == basic
|
|
assert request.headers["content-type"] == "application/x-protobuf"
|
|
assert request.headers["x-langfuse-ingestion-version"] == "4"
|
|
assert provider_secret.encode() not in request.body
|
|
assert candidate.key.encode() not in request.body
|
|
|
|
assert span.trace_id.hex() == trace_id
|
|
assert span.parent_span_id == b""
|
|
attributes: Final = span.attributes
|
|
assert _attribute(attributes, "langfuse.observation.type") == "generation"
|
|
assert _attribute(attributes, "langfuse.trace.name") == marker + "-trace"
|
|
assert _attribute(attributes, "user.id") == marker + "-user"
|
|
assert _attribute(attributes, "session.id") == marker + "-session"
|
|
assert marker in (_attribute(attributes, "langfuse.trace.tags") or ())
|
|
assert _attribute(attributes, "langfuse.observation.model.name") == "openai/gpt-4o-mini"
|
|
assert json.loads(str(_attribute(attributes, "langfuse.observation.usage_details"))) == {
|
|
"input": 11,
|
|
"output": 4,
|
|
"total": 15,
|
|
"cache_creation_input_tokens": 0,
|
|
"cache_read_input_tokens": 0,
|
|
}
|
|
assert marker + "-question" in str(_attribute(attributes, "langfuse.observation.input"))
|
|
assert marker + "-answer" in str(_attribute(attributes, "langfuse.observation.output"))
|
|
assert (
|
|
_attribute(attributes, "langfuse.observation.metadata.litellm_call_id")
|
|
== response.headers["x-litellm-call-id"]
|
|
)
|
|
|
|
|
|
def test_langfuse_callback_stored_in_the_db_through_config_update_delivers_the_generation_over_otlp_v4(
|
|
gateway: Gateway, tmp_path: Path
|
|
) -> None:
|
|
marker: Final = "langfusedb" + uuid.uuid4().hex
|
|
provider_secret: Final = "synthetic-provider-secret-" + marker
|
|
public_key: Final = "pk-lf-db-" + marker
|
|
secret_key: Final = "sk-lf-db-" + marker
|
|
stock_settings: Final = _SETTINGS.validate_python(
|
|
_PROXY_CONFIG.validate_python(yaml.safe_load(STOCK_CONFIG.read_text()))["litellm_settings"]
|
|
)
|
|
assert "langfuse" not in json.dumps(
|
|
[stock_settings.get(key) for key in ("callbacks", "success_callback", "failure_callback")]
|
|
)
|
|
|
|
def upstream(request: Request) -> Reply:
|
|
assert request.headers["authorization"] == f"Bearer {provider_secret}"
|
|
return _completion(marker + "-answer")
|
|
|
|
def langfuse(request: Request) -> Reply:
|
|
if request.method == "GET" and request.target.startswith(PROJECTS_PATH):
|
|
return _projects()
|
|
return Reply(body=b"", content_type="application/x-protobuf")
|
|
|
|
with (
|
|
scratch_database() as scratch_url,
|
|
wire_server(upstream) as provider,
|
|
wire_server(langfuse) as destination,
|
|
owned_proxy(
|
|
gateway,
|
|
tmp_path,
|
|
{"DATABASE_URL": scratch_url, "LANGFUSE_FLUSH_INTERVAL": "1"},
|
|
remove_environment=INHERITED_ENVIRONMENT,
|
|
) as candidate,
|
|
candidate.scenario() as scenario,
|
|
):
|
|
candidate.post(
|
|
"/config/update",
|
|
{
|
|
"litellm_settings": {"success_callback": ["langfuse"]},
|
|
"environment_variables": {
|
|
"LANGFUSE_HOST": destination.url,
|
|
"LANGFUSE_PUBLIC_KEY": public_key,
|
|
"LANGFUSE_SECRET_KEY": secret_key,
|
|
},
|
|
},
|
|
)
|
|
model: Final = scenario.model(api_base=provider.url + "/v1", api_key=provider_secret)
|
|
body: Final = candidate.post(
|
|
"/v1/chat/completions",
|
|
{
|
|
"model": model,
|
|
"messages": [{"role": "user", "content": marker + "-question"}],
|
|
"metadata": {"generation_name": marker},
|
|
"cache": {"no-cache": True},
|
|
},
|
|
)
|
|
received: Final[list[Request]] = [] # mutable-ok: drain() consumes the queue, later polls keep earlier ones
|
|
|
|
def exported() -> tuple[Span, ...]:
|
|
received.extend(destination.drain())
|
|
return tuple(span for span in _spans(received) if span.name == marker)
|
|
|
|
spans: Final = eventually(exported, lambda values: len(values) == 1, seconds=20)
|
|
posts: Final = tuple(request for request in received if request.method == "POST")
|
|
assert {request.target for request in posts} == {TRACES_PATH}, [request.target for request in received]
|
|
basic: Final = "Basic " + base64.b64encode(f"{public_key}:{secret_key}".encode()).decode()
|
|
for request in posts:
|
|
assert request.headers["authorization"] == basic
|
|
assert request.headers["content-type"] == "application/x-protobuf"
|
|
assert request.headers["x-langfuse-ingestion-version"] == "4"
|
|
assert provider_secret.encode() not in request.body
|
|
assert candidate.key.encode() not in request.body
|
|
|
|
attributes: Final = spans[0].attributes
|
|
assert _attribute(attributes, "langfuse.observation.type") == "generation"
|
|
assert _attribute(attributes, "langfuse.observation.metadata.response_id") == string_value(body["id"])
|
|
assert marker + "-question" in str(_attribute(attributes, "langfuse.observation.input"))
|
|
assert marker + "-answer" in str(_attribute(attributes, "langfuse.observation.output"))
|
|
|
|
stored: Final = {string_value(row["param_name"]): row["param_value"] for row in _config_rows(scratch_url)}
|
|
callbacks: Final = TypeAdapter(list[str]).validate_python(
|
|
object_value(stored["litellm_settings"]).get("success_callback") or []
|
|
)
|
|
assert "langfuse" in callbacks, stored
|
|
assert set(object_value(stored["environment_variables"])) >= set(LANGFUSE_ENVIRONMENT), stored
|
|
assert secret_key not in json.dumps(stored["environment_variables"]), stored
|
|
|
|
|
|
def test_prompt_fetch_encodes_the_name_retries_a_5xx_once_and_keeps_langfuse_headers_off_the_client(
|
|
gateway: Gateway, tmp_path: Path
|
|
) -> None:
|
|
marker: Final = "prompt" + uuid.uuid4().hex
|
|
leak: Final = "leak-" + marker
|
|
flaky_prompt: Final = f"{marker}/what?"
|
|
encoded_flaky_prompt: Final = f"{marker}%2Fwhat%3F"
|
|
missing_prompt: Final = marker + "-missing"
|
|
|
|
seen_prompt_gets: Final[list[str]] = [] # mutable-ok: the double counts attempts across requests
|
|
|
|
def upstream(request: Request) -> Reply:
|
|
return _completion(marker + "-answer")
|
|
|
|
def langfuse(request: Request) -> Reply:
|
|
if request.method == "GET" and request.target.startswith(PROJECTS_PATH):
|
|
return _projects()
|
|
if request.method == "POST":
|
|
return Reply(body=b"", content_type="application/x-protobuf")
|
|
assert request.target.startswith(PROMPTS_PATH), request.target
|
|
assert request.headers["authorization"].startswith("Basic ")
|
|
if request.target.startswith(PROMPTS_PATH + encoded_flaky_prompt):
|
|
prior: Final = sum(1 for seen in seen_prompt_gets if seen.startswith(PROMPTS_PATH + encoded_flaky_prompt))
|
|
seen_prompt_gets.append(request.target)
|
|
if prior == 0:
|
|
return Reply(status=503, body=b'{"message":"try later"}', headers={"retry-after": "30"})
|
|
return _text_prompt(flaky_prompt)
|
|
seen_prompt_gets.append(request.target)
|
|
return Reply(
|
|
status=404,
|
|
body=b'{"message":"Prompt not found","error":"LangfuseNotFoundError"}',
|
|
headers={"set-cookie": f"session={leak}; Path=/", "x-upstream-internal": leak, "server": leak},
|
|
)
|
|
|
|
with (
|
|
wire_server(upstream) as provider,
|
|
wire_server(langfuse) as destination,
|
|
owned_proxy(
|
|
gateway, tmp_path, _langfuse_environment(destination), config=_langfuse_config(tmp_path)
|
|
) as candidate,
|
|
candidate.scenario() as scenario,
|
|
):
|
|
flaky: Final = scenario.model(
|
|
model="langfuse/gpt-4o-mini", prompt_id=flaky_prompt, api_base=provider.url + "/v1", api_key="synthetic"
|
|
)
|
|
missing: Final = scenario.model(
|
|
model="langfuse/gpt-4o-mini", prompt_id=missing_prompt, api_base=provider.url + "/v1", api_key="synthetic"
|
|
)
|
|
started: Final = time.monotonic()
|
|
response: Final = candidate.request(
|
|
"POST",
|
|
"/v1/chat/completions",
|
|
{"model": flaky, "messages": [{"role": "user", "content": marker}], "prompt_variables": {"word": marker}},
|
|
)
|
|
elapsed: Final = time.monotonic() - started
|
|
assert response.status_code == 200, response.text
|
|
assert elapsed < 5, f"a retried cold prompt miss took {elapsed:.1f}s"
|
|
attempts: Final = tuple(
|
|
target for target in seen_prompt_gets if target.startswith(PROMPTS_PATH + encoded_flaky_prompt)
|
|
)
|
|
assert len(attempts) == 2, seen_prompt_gets
|
|
assert all(target.split("?", 1)[0] == PROMPTS_PATH + encoded_flaky_prompt for target in attempts), attempts
|
|
sent: Final = _ProviderBody.model_validate_json(provider.drain()[-1].body).messages
|
|
assert any("Say " + marker in json.dumps(message) for message in sent), sent
|
|
|
|
failure: Final = candidate.request(
|
|
"POST",
|
|
"/v1/chat/completions",
|
|
{"model": missing, "messages": [{"role": "user", "content": marker}], "prompt_variables": {"word": marker}},
|
|
)
|
|
assert failure.status_code == 404, failure.text
|
|
assert "Prompt not found" in failure.text
|
|
assert leak not in failure.text
|
|
assert leak not in json.dumps(dict(failure.headers))
|
|
assert "set-cookie" not in failure.headers and "x-upstream-internal" not in failure.headers
|
|
assert sum(1 for target in seen_prompt_gets if target.startswith(PROMPTS_PATH + missing_prompt)) == 1
|