mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-12 03:48:56 +00:00
* feat(lens): consume the independent Lens service and shared UI * fix(lens): keep embedded setup stable and vendor UI before builds * chore(lens): pin embedded UI provenance to published Lens source * fix(lens): complete embedded UI extraction across CI builds * docs(lens): record latest main and removal-boundary validation * fix(lens): wire external chart credentials and ingestion modes * test(lens): cover adapter failures and fix extraction CI * docs(lens): refresh bundled chart setup instructions * test(lens): restore gateway adapter test package marker * build(lens): pin clean-cut UI and supported storage chart * test(ui): await guardrail scope menu before checking options * perf(lens): omit unused trace-team lookup from product requests * docs(lens): record final-image adapter permission checks * test(lens): assert delegated investigation authorization * refactor(lens): remove copied trace runtime and preserve spend logging * test(lens): verify independent gateway image upgrades and outages * test(lens): pin the chart qualification database image * fix(lens): update embedded UI metadata filters * test: wait for Lens chart pod identity convergence * fix(e2e): select Lens chart pods by deployment ownership * fix(e2e): select Helm migration ownership for Lens upgrades * test(lens): retain migration Jobs through rollout assertions * docs(helm): require existing database for migration hooks * test(lens): qualify release boundaries with embedded navigation update * docs(lens): record browser qualification for embedded tabs * fix(lens): remove unrelated CI and guardrail test changes * test(lens): give qualification tenants unique key aliases * feat(lens): embed the latest canonical Lens interface * fix(lens): qualify split inference through the gateway * fix(lens): preserve scoped auth and isolate delegated credentials * chore(lens): remove unrelated documentation and lint changes * fix(lens): bound concurrent forwarding buffers through response delivery * fix(lens): preserve OAuth2 dispatch without inference policies * chore(lens): adopt current shared onboarding UI * fix(lens): refresh shared setup UI and review context * fix(lens): bound uploads before gateway authentication * docs(lens): remove extraction evidence from the gateway repo * docs(lens): refresh shared setup prompt for paired releases
650 lines
26 KiB
Python
650 lines
26 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import base64
|
|
import binascii
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import sys
|
|
import time
|
|
from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
|
|
from contextlib import asynccontextmanager
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from functools import cache
|
|
from itertools import chain
|
|
from pathlib import Path
|
|
from types import MappingProxyType
|
|
from typing import TYPE_CHECKING, Final, Literal
|
|
from uuid import uuid4
|
|
|
|
import httpx
|
|
from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter
|
|
|
|
from litellm.rust_bridge.clickhouse import spend_storage_config
|
|
from litellm.tracing.generated.types import AllQueryScope, Trace
|
|
from litellm.tracing.remote import LensConnection, RemoteTraceStore
|
|
from litellm.tracing.storage import LensTraceStorage
|
|
from litellm.tracing.types import SpendLogRecord
|
|
|
|
if TYPE_CHECKING:
|
|
from prisma import Prisma
|
|
from prisma.types import LiteLLM_SpendLogsCreateWithoutRelationsInput
|
|
|
|
REPO_ROOT: Final = Path(__file__).resolve().parents[1]
|
|
TRACE_FIXTURES: Final = REPO_ROOT / "scripts/lens_assets/fixtures/traces"
|
|
SPEND_FIXTURES: Final = REPO_ROOT / "scripts/lens_assets/fixtures/spend"
|
|
JSON: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
|
|
JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
|
SPEND_ROWS: Final = TypeAdapter(tuple[SpendLogRecord, ...])
|
|
TRACE: Final = TypeAdapter(Trace)
|
|
NANOSECOND_FIELDS: Final = frozenset({"startTimeUnixNano", "endTimeUnixNano", "timeUnixNano"})
|
|
TRACE_ID_FIELDS: Final = frozenset({"traceId", "trace_id", "session_id"})
|
|
TRACE_ID_ATTRIBUTES: Final = frozenset({"session.id"})
|
|
SPAN_ID_FIELDS: Final = frozenset({"spanId", "parentSpanId", "span_id"})
|
|
COPY_WINDOW_MS: Final = 24 * 60 * 60 * 1000
|
|
LONG_SESSION_SOURCE: Final = "openai_agents_swarm"
|
|
LONG_SESSION_REPEATS: Final = (50, 400, 4000)
|
|
|
|
|
|
class TenantIdentity(BaseModel):
|
|
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
team_id: str
|
|
api_key: str
|
|
user: str
|
|
|
|
|
|
class IngestionKeyRecord(BaseModel):
|
|
model_config = ConfigDict(frozen=True)
|
|
id: str
|
|
|
|
|
|
class IngestionKeyCreated(BaseModel):
|
|
model_config = ConfigDict(frozen=True)
|
|
key: str
|
|
active: bool
|
|
record: IngestionKeyRecord
|
|
|
|
|
|
class FixtureCapture(BaseModel):
|
|
model_config = ConfigDict(frozen=True)
|
|
name: str
|
|
trace_id: str
|
|
spend_linked: bool
|
|
spend_complete: bool = True
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class FixtureReplay:
|
|
name: str
|
|
export: JsonValue
|
|
offset_ms: int
|
|
namespace: str
|
|
|
|
|
|
def spend_fixtures(directory: Path = SPEND_FIXTURES) -> tuple[tuple[str, tuple[SpendLogRecord, ...]], ...]:
|
|
return tuple(
|
|
(
|
|
path.stem.removesuffix("_spend_logs"),
|
|
SPEND_ROWS.validate_python(
|
|
tuple(
|
|
{"litellm_call_id": "", **JSON_OBJECT.validate_json(line)} for line in path.read_text().splitlines()
|
|
)
|
|
),
|
|
)
|
|
for path in sorted(directory.glob("*_spend_logs.jsonl"))
|
|
)
|
|
|
|
|
|
def managed_response(value: str) -> str | None:
|
|
if not value.startswith("resp_"):
|
|
return None
|
|
try:
|
|
decoded: Final = base64.b64decode(value[5:], validate=True).decode()
|
|
except (binascii.Error, UnicodeDecodeError):
|
|
return None
|
|
return decoded if "response_id:" in decoded else None
|
|
|
|
|
|
def response_ids(rows: tuple[SpendLogRecord, ...]) -> Iterator[str]:
|
|
for row in rows:
|
|
yield row["request_id"]
|
|
yield row["response_id"]
|
|
if (decoded := managed_response(row["response_id"])) is not None:
|
|
if (upstream := re.search(r"response_id:([^;]+)", decoded)) is not None:
|
|
yield upstream.group(1)
|
|
|
|
|
|
def response_pattern(rows: tuple[SpendLogRecord, ...]) -> re.Pattern[str]:
|
|
identities: Final = sorted(
|
|
frozenset(
|
|
identity
|
|
for identity in chain(
|
|
response_ids(rows),
|
|
(row["litellm_call_id"] for row in rows),
|
|
(row.get("provider_request_id", "") for row in rows),
|
|
)
|
|
if identity
|
|
),
|
|
key=len,
|
|
reverse=True,
|
|
)
|
|
return re.compile("|".join(re.escape(identity) for identity in identities) or r"(?!)")
|
|
|
|
|
|
def rebased_response(value: str, namespace: str, pattern: re.Pattern[str]) -> str:
|
|
decoded: Final = managed_response(value)
|
|
if decoded is None:
|
|
if value.startswith(("msg_", "req_")):
|
|
prefix, suffix = value.split("_", 1)
|
|
return f"{prefix}_seed-{namespace}-{suffix}"
|
|
return f"seed-{namespace}-{value}"
|
|
payload: Final = pattern.sub(lambda match: rebased_response(match.group(), namespace, pattern), decoded)
|
|
return "resp_" + base64.b64encode(payload.encode()).decode()
|
|
|
|
|
|
@cache
|
|
def fixture_exports(directory: Path) -> tuple[tuple[str, JsonValue], ...]:
|
|
return tuple((path.stem, JSON.validate_json(path.read_bytes())) for path in sorted(directory.glob("*.json")))
|
|
|
|
|
|
def fixture_replays(
|
|
directory: Path, now_ms: int, namespace: str, response_pattern: re.Pattern[str]
|
|
) -> tuple[FixtureReplay, ...]:
|
|
exports: Final = fixture_exports(directory)
|
|
query_latest: Final = max(
|
|
(max(timestamps(export)) for name, export in exports if name.startswith("query_")), default=0
|
|
)
|
|
|
|
def replay(name: str, export: JsonValue) -> FixtureReplay:
|
|
group: Final = "query" if name.startswith("query_") else name
|
|
latest_ns: Final = query_latest if group == "query" else max(timestamps(export))
|
|
offset_ms: Final = now_ms - latest_ns // 1_000_000 - 1000
|
|
capture_namespace: Final = f"{namespace}-{group}"
|
|
return FixtureReplay(
|
|
name=name,
|
|
export=rebase(export, offset_ms * 1_000_000, capture_namespace, response_pattern),
|
|
offset_ms=offset_ms,
|
|
namespace=capture_namespace,
|
|
)
|
|
|
|
return tuple(replay(name, export) for name, export in exports)
|
|
|
|
|
|
def timestamps(value: JsonValue) -> Iterator[int]:
|
|
if isinstance(value, list):
|
|
for item in value:
|
|
yield from timestamps(item)
|
|
elif isinstance(value, dict):
|
|
for key, item in value.items():
|
|
if key in NANOSECOND_FIELDS and isinstance(item, (str, int)) and int(item) > 0:
|
|
yield int(item)
|
|
else:
|
|
yield from timestamps(item)
|
|
|
|
|
|
def seed_id(value: str, namespace: str, length: int) -> str:
|
|
return hashlib.sha256(f"{namespace}:{value}".encode()).hexdigest()[:length] if value else ""
|
|
|
|
|
|
def rebase(
|
|
value: JsonValue, offset_ns: int, namespace: str, response_pattern: re.Pattern[str], field: str = ""
|
|
) -> JsonValue:
|
|
if field in NANOSECOND_FIELDS and isinstance(value, (str, int)):
|
|
return str(int(value) + offset_ns) if int(value) else value
|
|
if isinstance(value, str):
|
|
if field == "metadata":
|
|
return json.dumps(rebase(JSON.validate_json(value), offset_ns, namespace, response_pattern))
|
|
if field == "bytesValue":
|
|
return base64.b64encode(
|
|
re.sub(
|
|
response_pattern.pattern.encode(),
|
|
lambda match: rebased_response(match.group().decode(), namespace, response_pattern).encode(),
|
|
base64.b64decode(value),
|
|
)
|
|
).decode()
|
|
if field in TRACE_ID_FIELDS:
|
|
return seed_id(value, namespace, 32)
|
|
if field in SPAN_ID_FIELDS:
|
|
return seed_id(value, namespace, 16)
|
|
return response_pattern.sub(lambda match: rebased_response(match.group(), namespace, response_pattern), value)
|
|
if isinstance(value, list):
|
|
return [rebase(item, offset_ns, namespace, response_pattern) for item in value]
|
|
if isinstance(value, dict):
|
|
attribute_key: Final = value.get("key")
|
|
attribute_value: Final = value.get("value")
|
|
session_id: Final = attribute_value.get("stringValue") if isinstance(attribute_value, dict) else None
|
|
if (
|
|
isinstance(attribute_key, str)
|
|
and attribute_key in TRACE_ID_ATTRIBUTES
|
|
and isinstance(attribute_value, dict)
|
|
and isinstance(session_id, str)
|
|
):
|
|
return {
|
|
**{
|
|
key: rebase(item, offset_ns, namespace, response_pattern, key)
|
|
for key, item in value.items()
|
|
if key != "value"
|
|
},
|
|
"value": {
|
|
**{
|
|
key: rebase(item, offset_ns, namespace, response_pattern, key)
|
|
for key, item in attribute_value.items()
|
|
if key != "stringValue"
|
|
},
|
|
"stringValue": seed_id(session_id, namespace, 32),
|
|
},
|
|
}
|
|
return {key: rebase(item, offset_ns, namespace, response_pattern, key) for key, item in value.items()}
|
|
return value
|
|
|
|
|
|
def rebase_spend(
|
|
rows: tuple[SpendLogRecord, ...], offset_ms: int, namespace: str, response_pattern: re.Pattern[str]
|
|
) -> tuple[SpendLogRecord, ...]:
|
|
return SPEND_ROWS.validate_python(
|
|
tuple(
|
|
{
|
|
**JSON_OBJECT.validate_python(rebase(JSON.validate_python(row), 0, namespace, response_pattern)),
|
|
"start_time": row["start_time"] + offset_ms,
|
|
"end_time": row["end_time"] + offset_ms,
|
|
"completion_start_time": (
|
|
row["completion_start_time"] + offset_ms if row["completion_start_time"] is not None else None
|
|
),
|
|
}
|
|
for row in rows
|
|
)
|
|
)
|
|
|
|
|
|
def postgres_row(row: SpendLogRecord) -> LiteLLM_SpendLogsCreateWithoutRelationsInput:
|
|
from prisma import Json
|
|
from prisma.types import LiteLLM_SpendLogsCreateWithoutRelationsInput
|
|
|
|
return LiteLLM_SpendLogsCreateWithoutRelationsInput(
|
|
request_id=row["request_id"],
|
|
call_type=row["call_type"],
|
|
api_key=row["api_key"],
|
|
user=row["user"],
|
|
team_id=row["team_id"],
|
|
spend=row["spend"],
|
|
model=row["model"],
|
|
model_group=row["model_group"],
|
|
custom_llm_provider=row["custom_llm_provider"],
|
|
prompt_tokens=row["prompt_tokens"],
|
|
completion_tokens=row["completion_tokens"],
|
|
total_tokens=row["total_tokens"],
|
|
startTime=datetime.fromtimestamp(row["start_time"] / 1000, tz=timezone.utc),
|
|
endTime=datetime.fromtimestamp(row["end_time"] / 1000, tz=timezone.utc),
|
|
request_duration_ms=row["end_time"] - row["start_time"],
|
|
session_id=row["session_id"],
|
|
status=row["status"],
|
|
cache_hit=str(row["cache_hit"]),
|
|
request_tags=Json(list(row["request_tags"])),
|
|
metadata=Json(JSON.validate_json(row["metadata"])),
|
|
messages=Json(JSON.validate_json(row["messages"])),
|
|
response=Json(JSON.validate_json(row["response"])),
|
|
proxy_server_request=Json(None),
|
|
)
|
|
|
|
|
|
class SeedOptions(BaseModel):
|
|
model_config = ConfigDict(frozen=True)
|
|
profile: Literal["default", "large"]
|
|
copies: int | None
|
|
timeout_seconds: float = 120
|
|
|
|
|
|
def seed_arguments(argv: Sequence[str] | None = None) -> SeedOptions:
|
|
parser: Final = argparse.ArgumentParser(description="Replay tracing fixtures into a running local Lens stack")
|
|
parser.add_argument("--profile", choices=("default", "large"), default="default")
|
|
parser.add_argument(
|
|
"--copies",
|
|
type=int,
|
|
default=os.environ.get("LENS_DEV_SEED_COPIES"),
|
|
help="Override fixture copies (default: 1, large: 2000; env: LENS_DEV_SEED_COPIES)",
|
|
)
|
|
parser.add_argument("--timeout-seconds", type=float, default=os.environ.get("LENS_DEV_SEED_TIMEOUT_SECONDS", "120"))
|
|
arguments: Final = SeedOptions.model_validate(vars(parser.parse_args(argv)))
|
|
if arguments.copies is not None and arguments.copies < 1:
|
|
parser.error("--copies must be positive")
|
|
if not math.isfinite(arguments.timeout_seconds) or arguments.timeout_seconds <= 0:
|
|
parser.error("--timeout-seconds must be finite and positive")
|
|
return arguments
|
|
|
|
|
|
async def seed_copy(
|
|
client: httpx.AsyncClient,
|
|
storage: RemoteTraceStore,
|
|
database: Prisma,
|
|
replays: tuple[FixtureReplay, ...],
|
|
fixtures: tuple[tuple[str, tuple[SpendLogRecord, ...]], ...],
|
|
pattern: re.Pattern[str],
|
|
) -> tuple[tuple[str, tuple[SpendLogRecord, ...]], ...]:
|
|
by_name: Final = MappingProxyType(dict(fixtures))
|
|
paired: Final = tuple(
|
|
(
|
|
replay.name,
|
|
rebase_spend(by_name[replay.name], replay.offset_ms, replay.namespace, pattern),
|
|
)
|
|
for replay in replays
|
|
if replay.name in by_name
|
|
)
|
|
tenant: Final = await ingest_replays(
|
|
client, storage, replays, fixture_capture(*next((name, rows[0]) for name, rows in paired)).trace_id
|
|
)
|
|
stamped: Final = tuple((name, tuple(stamp(row, tenant) for row in rows)) for name, rows in paired)
|
|
stamped_spends: Final = tuple(chain.from_iterable(rows for _, rows in stamped))
|
|
await storage.insert_rows("spend_logs", stamped_spends)
|
|
await database.litellm_spendlogs.create_many(data=[postgres_row(row) for row in stamped_spends])
|
|
return stamped
|
|
|
|
|
|
def stamp(row: SpendLogRecord, tenant: TenantIdentity) -> SpendLogRecord:
|
|
return {**row, "team_id": tenant.team_id, "api_key": tenant.api_key, "user": tenant.user}
|
|
|
|
|
|
async def verify(
|
|
client: httpx.AsyncClient, captures: tuple[tuple[str, tuple[SpendLogRecord, ...]], ...], trace_salt: str
|
|
) -> None:
|
|
verified: Final = tuple(
|
|
await asyncio.gather(*(verify_capture(client, name, rows, trace_salt) for name, rows in captures))
|
|
)
|
|
sys.stdout.write(
|
|
json.dumps({"spend_rows": sum(len(rows) for _, rows in captures), "captures": verified}, indent=2) + "\n"
|
|
)
|
|
if not all(capture["verified"] for capture in verified):
|
|
raise RuntimeError("Seed spend verification failed")
|
|
|
|
|
|
async def ingest_replays(
|
|
client: httpx.AsyncClient, storage: RemoteTraceStore, replays: tuple[FixtureReplay, ...], trace_id: str
|
|
) -> TenantIdentity:
|
|
for replay in replays:
|
|
(
|
|
await client.post(
|
|
"/v1/traces", content=json.dumps(replay.export), headers={"Content-Type": "application/json"}
|
|
)
|
|
).raise_for_status()
|
|
identity: Final = await LensTraceStorage(storage).query_sql(
|
|
"SELECT DISTINCT TeamId AS team_id, ApiKeyHash AS api_key, UserId AS user "
|
|
f"FROM otel_traces WHERE TraceId = '{trace_id}'",
|
|
AllQueryScope(kind="all"),
|
|
os.environ["LITELLM_MASTER_KEY"],
|
|
)
|
|
return TenantIdentity.model_validate(identity.data[0])
|
|
|
|
|
|
async def seeded_trace_ids(storage: RemoteTraceStore, api_key_hash: str) -> tuple[str, ...]:
|
|
literal: Final = api_key_hash.replace("\\", "\\\\").replace("'", "\\'")
|
|
result: Final = await LensTraceStorage(storage).query_sql(
|
|
f"SELECT DISTINCT TraceId AS trace_id FROM otel_traces WHERE ApiKeyHash = '{literal}' ORDER BY trace_id",
|
|
AllQueryScope(kind="all"),
|
|
os.environ["LITELLM_MASTER_KEY"],
|
|
)
|
|
return TypeAdapter(tuple[str, ...]).validate_python(tuple(row["trace_id"] for row in result.data))
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Copies:
|
|
"""Server-side copies of seeded traces and their spend.
|
|
|
|
Each copy `n` hashes trace ids with `session` (or `n` when empty), keeps root span ids, hashes the
|
|
other span ids with `n`, rewrites seeded call ids from `source` to `{target}{n}-` and moves `n * step_ms`
|
|
earlier. A `session` folds every copy into one trace under a single root that spans all of them.
|
|
"""
|
|
|
|
trace_ids: tuple[str, ...]
|
|
request_ids: tuple[str, ...]
|
|
numbers: range
|
|
step_ms: int
|
|
source: str
|
|
target: str
|
|
session: str = ""
|
|
|
|
|
|
def copied_trace_id(trace_id: str, salt: str) -> str:
|
|
return hashlib.sha256(f"{trace_id}:{salt}".encode()).hexdigest()[:32]
|
|
|
|
|
|
def clickhouse_call_id(column: str) -> str:
|
|
target: Final = "concat({target:String}, toString(c.n), '-')"
|
|
return (
|
|
f"if(startsWith({column}, 'resp_'), concat('resp_', base64Encode(replaceAll("
|
|
f"tryBase64Decode(substring({column}, 6)), {{source:String}}, {target}))), "
|
|
f"replaceAll({column}, {{source:String}}, {target}))"
|
|
)
|
|
|
|
|
|
def clickhouse_hash(column: str, salt: str, length: int) -> str:
|
|
return f"if({column} = '', '', substring(lower(hex(SHA256(concat({column}, ':', {salt})))), 1, {length}))"
|
|
|
|
|
|
def clickhouse_copy_sql(database: str) -> tuple[str, str]:
|
|
trace_salt: Final = "if({session:String} = '', toString(c.n), {session:String})"
|
|
roots: Final = (
|
|
f"(SELECT SpanId FROM {database}.otel_traces "
|
|
"WHERE TraceId IN {trace_ids:Array(String)} AND ParentSpanId = '')"
|
|
)
|
|
folded_root: Final = "{session:String} != '' AND t.ParentSpanId = ''"
|
|
shift: Final = f"toIntervalMillisecond(if({folded_root}, {{last:UInt64}}, c.n) * {{step_ms:UInt64}})"
|
|
numbers: Final = "CROSS JOIN (SELECT number AS n FROM numbers({first:UInt64}, {count:UInt64})) AS c"
|
|
spans: Final = f"""INSERT INTO {database}.otel_traces
|
|
SELECT t.* REPLACE (
|
|
t.Timestamp - {shift} AS Timestamp,
|
|
{clickhouse_hash("t.TraceId", trace_salt, 32)} AS TraceId,
|
|
if(t.ParentSpanId = '', t.SpanId, {clickhouse_hash("t.SpanId", "toString(c.n)", 16)}) AS SpanId,
|
|
if(t.ParentSpanId IN {roots}, t.ParentSpanId, {clickhouse_hash("t.ParentSpanId", "toString(c.n)", 16)})
|
|
AS ParentSpanId,
|
|
t.Duration + if({folded_root}, {{last:UInt64}} * {{step_ms:UInt64}} * 1000000, 0) AS Duration,
|
|
arrayMap(at -> at - {shift}, t.`Events.Timestamp`) AS `Events.Timestamp`,
|
|
mapApply((name, value) -> (name, {clickhouse_call_id("value")}), t.SpanAttributes) AS SpanAttributes,
|
|
{clickhouse_call_id("t.LiteLLMRequestId")} AS LiteLLMRequestId,
|
|
arrayMap(key -> concat(extract(key, '^[^:]*:'), {clickhouse_call_id("replaceRegexpOne(key, '^[^:]*:', '')")}),
|
|
t.CallKeys) AS CallKeys
|
|
)
|
|
FROM {database}.otel_traces AS t {numbers}
|
|
WHERE t.TraceId IN {{trace_ids:Array(String)}}
|
|
AND ({{session:String}} = '' OR t.ParentSpanId != '' OR c.n = {{first:UInt64}})"""
|
|
spend: Final = f"""INSERT INTO {database}.spend_logs
|
|
SELECT s.* REPLACE (
|
|
{clickhouse_call_id("s.request_id")} AS request_id,
|
|
{clickhouse_call_id("s.response_id")} AS response_id,
|
|
{clickhouse_call_id("s.provider_request_id")} AS provider_request_id,
|
|
{clickhouse_call_id("s.litellm_call_id")} AS litellm_call_id,
|
|
{clickhouse_hash("s.trace_id", trace_salt, 32)} AS trace_id,
|
|
{clickhouse_hash("s.session_id", trace_salt, 32)} AS session_id,
|
|
if(s.span_id IN {roots}, s.span_id, {clickhouse_hash("s.span_id", "toString(c.n)", 16)}) AS span_id,
|
|
s.start_time - toIntervalMillisecond(c.n * {{step_ms:UInt64}}) AS start_time,
|
|
s.end_time - toIntervalMillisecond(c.n * {{step_ms:UInt64}}) AS end_time,
|
|
s.completion_start_time - toIntervalMillisecond(c.n * {{step_ms:UInt64}}) AS completion_start_time
|
|
)
|
|
FROM {database}.spend_logs AS s {numbers}
|
|
WHERE s.request_id IN {{request_ids:Array(String)}}"""
|
|
return spans, spend
|
|
|
|
|
|
def clickhouse_array(values: tuple[str, ...]) -> str:
|
|
return "[" + ",".join("'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'" for value in values) + "]"
|
|
|
|
|
|
async def copy_clickhouse(client: httpx.AsyncClient, database: str, copies: Copies) -> None:
|
|
parameters: Final = {
|
|
"param_trace_ids": clickhouse_array(copies.trace_ids),
|
|
"param_request_ids": clickhouse_array(copies.request_ids),
|
|
"param_first": str(copies.numbers.start),
|
|
"param_count": str(len(copies.numbers)),
|
|
"param_last": str(copies.numbers.stop - 1),
|
|
"param_step_ms": str(copies.step_ms),
|
|
"param_source": copies.source,
|
|
"param_target": copies.target,
|
|
"param_session": copies.session,
|
|
}
|
|
for sql in clickhouse_copy_sql(database):
|
|
(await client.post("/", params=parameters, content=sql)).raise_for_status()
|
|
|
|
|
|
POSTGRES_COPY_TARGET: Final = "($2 || c.n || '-')"
|
|
POSTGRES_COPY_SHIFT: Final = "make_interval(secs => c.n * $6::bigint / 1000.0)"
|
|
POSTGRES_COPY_SQL: Final = f"""INSERT INTO "LiteLLM_SpendLogs"
|
|
SELECT (jsonb_populate_record(s, jsonb_build_object(
|
|
'request_id', CASE WHEN left(s.request_id, 5) = 'resp_'
|
|
THEN 'resp_' || translate(encode(convert_to(replace(convert_from(decode(substr(s.request_id, 6), 'base64'),
|
|
'UTF8'), $1, {POSTGRES_COPY_TARGET}), 'UTF8'), 'base64'), E'\\n', '')
|
|
ELSE replace(s.request_id, $1, {POSTGRES_COPY_TARGET}) END,
|
|
'session_id', CASE WHEN coalesce(s.session_id, '') = '' THEN s.session_id
|
|
ELSE substr(encode(sha256(convert_to(
|
|
s.session_id || ':' || CASE WHEN $3 = '' THEN c.n::text ELSE $3 END, 'UTF8')), 'hex'), 1, 32) END,
|
|
'startTime', s."startTime" - {POSTGRES_COPY_SHIFT},
|
|
'endTime', s."endTime" - {POSTGRES_COPY_SHIFT},
|
|
'completionStartTime', s."completionStartTime" - {POSTGRES_COPY_SHIFT}
|
|
))).*
|
|
FROM "LiteLLM_SpendLogs" AS s CROSS JOIN generate_series($4::int, $5::int) AS c(n)
|
|
WHERE s.request_id = ANY(string_to_array($7, E'\\n'))"""
|
|
|
|
|
|
async def copy_postgres(database: Prisma, copies: Copies) -> None:
|
|
await database.execute_raw(
|
|
POSTGRES_COPY_SQL,
|
|
copies.source,
|
|
copies.target,
|
|
copies.session,
|
|
copies.numbers.start,
|
|
copies.numbers.stop - 1,
|
|
copies.step_ms,
|
|
"\n".join(copies.request_ids),
|
|
)
|
|
|
|
|
|
def long_sessions(
|
|
replays: tuple[FixtureReplay, ...],
|
|
captures: tuple[tuple[str, tuple[SpendLogRecord, ...]], ...],
|
|
source: str,
|
|
target: str,
|
|
repeats: tuple[int, ...] = LONG_SESSION_REPEATS,
|
|
) -> tuple[Copies, ...]:
|
|
replay: Final = next(replay for replay in replays if replay.name == LONG_SESSION_SOURCE)
|
|
rows: Final = dict(captures)[LONG_SESSION_SOURCE]
|
|
span_ns: Final = tuple(timestamps(replay.export))
|
|
return tuple(
|
|
Copies(
|
|
trace_ids=(fixture_capture(LONG_SESSION_SOURCE, rows[0]).trace_id,),
|
|
request_ids=tuple(row["request_id"] for row in rows),
|
|
numbers=range(count),
|
|
step_ms=(max(span_ns) - min(span_ns)) // 1_000_000 + 1000,
|
|
source=source,
|
|
target=f"{target}s{count}x",
|
|
session=f"session{count}",
|
|
)
|
|
for count in repeats
|
|
)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def ingestion_client(client: httpx.AsyncClient, timeout_seconds: float) -> AsyncIterator[httpx.AsyncClient]:
|
|
response: Final = await client.post("/lens/tracing/keys", json={"name": "Local fixture seed"})
|
|
response.raise_for_status()
|
|
created: Final = IngestionKeyCreated.model_validate_json(response.content)
|
|
try:
|
|
if not created.active:
|
|
raise RuntimeError("Lens ingestion is not ready; start the Lens service before seeding")
|
|
async with httpx.AsyncClient(
|
|
base_url=os.environ["LITELLM_LENS_URL"],
|
|
headers={"Authorization": f"Bearer {created.key}"},
|
|
timeout=timeout_seconds,
|
|
) as uploader:
|
|
yield uploader
|
|
finally:
|
|
(await client.delete(f"/lens/tracing/keys/{created.record.id}")).raise_for_status()
|
|
|
|
|
|
async def seed(profile: str = "default", copies: int | None = None, timeout_seconds: float = 120) -> int:
|
|
from prisma import Prisma
|
|
|
|
fixtures: Final = spend_fixtures()
|
|
pattern: Final = response_pattern(tuple(chain.from_iterable(rows for _, rows in fixtures)))
|
|
count: Final = copies if copies is not None else (2000 if profile == "large" else 1)
|
|
namespace: Final = uuid4().hex
|
|
now_ms: Final = time.time_ns() // 1_000_000
|
|
config: Final = spend_storage_config()
|
|
connection: Final = LensConnection.from_env()
|
|
replays: Final = fixture_replays(TRACE_FIXTURES, now_ms, namespace + "-0", pattern)
|
|
source: Final = f"seed-{namespace}-0-"
|
|
target: Final = f"seed-{namespace}-"
|
|
async with (
|
|
httpx.AsyncClient(
|
|
base_url=os.environ.get("PROXY_BASE_URL", "http://127.0.0.1:4002"),
|
|
headers={"Authorization": f"Bearer {os.environ['LITELLM_MASTER_KEY']}"},
|
|
timeout=timeout_seconds,
|
|
) as client,
|
|
httpx.AsyncClient(base_url=config.url, params={"database": config.database}, timeout=600) as clickhouse,
|
|
Prisma(http={"timeout": httpx.Timeout(600)}) as database,
|
|
connection.lifespan_client() as lens,
|
|
):
|
|
storage: Final = RemoteTraceStore(lens)
|
|
async with ingestion_client(client, timeout_seconds) as uploader:
|
|
captures: Final = await seed_copy(uploader, storage, database, replays, fixtures, pattern)
|
|
await verify(client, captures, "")
|
|
trace_ids: Final = await seeded_trace_ids(storage, captures[0][1][0]["api_key"])
|
|
repeated: Final = Copies(
|
|
trace_ids=trace_ids,
|
|
request_ids=tuple(row["request_id"] for _, rows in captures for row in rows),
|
|
numbers=range(1, count),
|
|
step_ms=COPY_WINDOW_MS // count,
|
|
source=source,
|
|
target=target,
|
|
)
|
|
sessions: Final = long_sessions(replays, captures, source, target) if profile == "large" else ()
|
|
for plan in (repeated, *sessions) if count > 1 else sessions:
|
|
await copy_clickhouse(clickhouse, config.database, plan)
|
|
await copy_postgres(database, plan)
|
|
if count > 1:
|
|
await verify(client, captures, str(count - 1))
|
|
for plan in sessions:
|
|
sys.stdout.write(
|
|
f"Long session: {len(plan.numbers)} repeats, "
|
|
f"trace_id={copied_trace_id(plan.trace_ids[0], plan.session)}\n"
|
|
)
|
|
sys.stdout.write(f"Seed complete: profile={profile}, copies={count}, namespace={namespace}\n")
|
|
return 0
|
|
|
|
|
|
def fixture_capture(name: str, row: SpendLogRecord) -> FixtureCapture:
|
|
metadata: Final = JSON_OBJECT.validate_json(row["metadata"])
|
|
capture: Final = metadata.get("fixture_capture")
|
|
return (
|
|
FixtureCapture.model_validate(capture)
|
|
if capture is not None
|
|
else FixtureCapture(name=name, trace_id=row["trace_id"], spend_linked=True)
|
|
)
|
|
|
|
|
|
async def verify_capture(
|
|
client: httpx.AsyncClient, name: str, rows: tuple[SpendLogRecord, ...], trace_salt: str = ""
|
|
) -> Mapping[str, JsonValue]:
|
|
capture: Final = fixture_capture(name, rows[0])
|
|
trace_id: Final = copied_trace_id(capture.trace_id, trace_salt) if trace_salt else capture.trace_id
|
|
detail: Final = await client.get(f"/v1/traces/{trace_id}")
|
|
detail.raise_for_status()
|
|
trace: Final = TRACE.validate_json(detail.content)
|
|
expected: Final = sum(row["spend"] or 0 for row in rows)
|
|
actual: Final = trace["summary"]["spend"]
|
|
return {
|
|
"fixture": name,
|
|
"trace_id": trace_id,
|
|
"spend_rows": len(rows),
|
|
"recorded_spend": expected,
|
|
"trace_spend": actual,
|
|
"verified": math.isclose(actual, expected)
|
|
if actual is not None
|
|
else not (capture.spend_linked and capture.spend_complete),
|
|
}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
arguments: Final = seed_arguments()
|
|
raise SystemExit(asyncio.run(seed(arguments.profile, arguments.copies, arguments.timeout_seconds)))
|