feat(lens): wire service setup, scoped delivery receipts and lease attempts

This commit is contained in:
moe-berri 2026-10-07 11:50:14 -07:00
parent 3eef8cae96
commit ac39101ba7
34 changed files with 1117 additions and 140 deletions

View file

@ -3,8 +3,15 @@ services:
image: ${LENS_WORKER_IMAGE:-${LITELLM_VERSION:+ghcr.io/berriai/litellm-lens-worker:v}${LITELLM_VERSION:-}}
environment:
LITELLM_URL: ${LITELLM_URL:?Set the URL reachable from this container}
LENS_WORKER_TOKEN: ${LENS_WORKER_TOKEN:?Create a worker credential in the Lens UI}
LENS_PYTHON_CONCURRENCY: ${LENS_PYTHON_CONCURRENCY:-2}
LENS_WORKER_TOKEN: ${LENS_WORKER_TOKEN:-}
LITELLM_LENS_SERVICE_TOKEN: ${LITELLM_LENS_SERVICE_TOKEN:?Set the same secret on LiteLLM and Lens}
CLICKHOUSE_URL: ${CLICKHOUSE_URL:?Set the ClickHouse URL reachable from Lens}
CLICKHOUSE_DATABASE: ${CLICKHOUSE_DATABASE:-litellm}
AGENT_TRACING_RETENTION_DAYS: ${AGENT_TRACING_RETENTION_DAYS:-14}
ports:
- "127.0.0.1:${LENS_PORT:-4318}:4318"
mem_limit: 2g
cpus: 2
restart: unless-stopped
read_only: true
tmpfs:

View file

@ -2,6 +2,4 @@ general_settings:
master_key: os.environ/LITELLM_MASTER_KEY
tracing:
store:
type: clickhouse
url: os.environ/CLICKHOUSE_URL
retention_days: 14
type: lens

View file

@ -10,9 +10,7 @@ services:
import os, sys
from urllib.parse import quote
postgres_password = quote(os.environ["POSTGRES_PASSWORD"], safe="")
clickhouse_password = quote(os.environ["CLICKHOUSE_PASSWORD"], safe="")
os.environ["DATABASE_URL"] = f"postgresql://litellm:{postgres_password}@db:5432/litellm"
os.environ["CLICKHOUSE_URL"] = f"http://default:{clickhouse_password}@clickhouse:8123"
os.execv("docker/prod_entrypoint.sh", ["docker/prod_entrypoint.sh", *sys.argv[1:]])
command: ["--config", "/app/lens-config.yaml", "--port", "4000"]
environment:
@ -20,7 +18,9 @@ services:
LITELLM_SALT_KEY: ${LITELLM_SALT_KEY:?Set a permanent encryption key and keep it across upgrades}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set a permanent database password}
STORE_MODEL_IN_DB: "True"
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:?Set a permanent ClickHouse password}
LITELLM_LENS_URL: http://lens-worker:4318
LITELLM_LENS_PUBLIC_URL: ${LITELLM_LENS_PUBLIC_URL:-http://localhost:4318}
LITELLM_LENS_SERVICE_TOKEN: ${LITELLM_LENS_SERVICE_TOKEN:?Set the shared Lens service secret}
LENS_WORKER_IMAGE: ghcr.io/berriai/litellm-lens-worker:v${LITELLM_VERSION}
volumes:
- ./config.yaml:/app/lens-config.yaml:ro
@ -30,19 +30,24 @@ services:
depends_on:
db:
condition: service_healthy
clickhouse:
condition: service_healthy
restart: unless-stopped
lens-worker:
profiles: [lens]
image: ghcr.io/berriai/litellm-lens-worker:v${LITELLM_VERSION}
environment:
LITELLM_URL: http://litellm:4000
LENS_WORKER_TOKEN: ${LENS_WORKER_TOKEN:-}
LENS_PYTHON_CONCURRENCY: ${LENS_PYTHON_CONCURRENCY:-2}
LITELLM_LENS_SERVICE_TOKEN: ${LITELLM_LENS_SERVICE_TOKEN}
CLICKHOUSE_HOST: clickhouse
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:?Set a permanent ClickHouse password}
CLICKHOUSE_DATABASE: ${CLICKHOUSE_DATABASE:-litellm}
AGENT_TRACING_RETENTION_DAYS: ${AGENT_TRACING_RETENTION_DAYS:-14}
depends_on: [litellm]
networks: [proxy]
networks: [proxy, storage]
ports:
- "127.0.0.1:${LENS_PORT:-4318}:4318"
mem_limit: 2g
cpus: 2
restart: unless-stopped
read_only: true
tmpfs:

View file

@ -321,3 +321,47 @@ through an emptyDir. Empty when the sidecar is off or uses 127.0.0.1 TCP.
- name: LITELLM_COLLECTOR_DRAIN_TIMEOUT_SECONDS
value: {{ .Values.collector.drainTimeoutSeconds | quote }}
{{- end -}}
{{- define "litellm.lensWorker.image" -}}
{{- if .Values.lensWorker.image.digest -}}
{{- if not (regexMatch "^sha256:[0-9a-f]{64}$" .Values.lensWorker.image.digest) -}}
{{- fail "lensWorker.image.digest must be sha256 followed by 64 lowercase hex characters" -}}
{{- end -}}
{{- printf "%s@%s" .Values.lensWorker.image.repository .Values.lensWorker.image.digest -}}
{{- else -}}
{{- $backendTag := .Values.image.tag | default .Chart.AppVersion -}}
{{- $releaseTag := ternary (printf "v%s" $backendTag) $backendTag (regexMatch "^[0-9]" $backendTag) -}}
{{- $tag := .Values.lensWorker.image.tag | default $releaseTag -}}
{{- $repository := .Values.lensWorker.image.repository -}}
{{- if and (hasPrefix "sha-" $tag) (eq $repository "ghcr.io/berriai/litellm-lens-worker") -}}
{{- $repository = "ghcr.io/berriai/litellm-lens-worker-dev" -}}
{{- end -}}
{{- printf "%s:%s" $repository $tag -}}
{{- end -}}
{{- end -}}
{{- define "litellm.gateway.collectorSocketDir" -}}
{{- if and .Values.gateway.collector.enabled (hasPrefix "unix://" .Values.gateway.collector.address) -}}
{{- dir (trimPrefix "unix://" .Values.gateway.collector.address) -}}
{{- end -}}
{{- end -}}
{{/*
LITELLM_COLLECTOR_* env shared by the producer (gateway container) and the
consumer (collector container), so both agree on the transport and the
shutdown drain window.
*/}}
{{- define "litellm.gateway.collectorEnv" -}}
{{- with .Values.gateway.collector }}
- name: LITELLM_COLLECTOR_ENABLED
value: "true"
- name: LITELLM_COLLECTOR_ADDRESS
value: {{ .address | quote }}
- name: LITELLM_COLLECTOR_BUFFER_SIZE
value: {{ .bufferSize | quote }}
- name: LITELLM_COLLECTOR_ON_UNAVAILABLE
value: {{ .onUnavailable | quote }}
- name: LITELLM_COLLECTOR_DRAIN_TIMEOUT_SECONDS
value: {{ .drainTimeoutSeconds | quote }}
{{- end }}
{{- end -}}

View file

@ -56,6 +56,17 @@ spec:
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
{{- if .Values.lensWorker.enabled }}
- name: LITELLM_LENS_URL
value: {{ printf "http://%s-lens-worker:%v" (include "litellm.fullname" .) .Values.lensWorker.service.port | quote }}
- name: LITELLM_LENS_PUBLIC_URL
value: {{ required "lensWorker.publicUrl is required" .Values.lensWorker.publicUrl | quote }}
- name: LITELLM_LENS_SERVICE_TOKEN
valueFrom:
secretKeyRef:
name: {{ required "lensWorker.serviceTokenSecret.name is required" .Values.lensWorker.serviceTokenSecret.name | quote }}
key: {{ .Values.lensWorker.serviceTokenSecret.key | quote }}
{{- end }}
{{- include "litellm.proxyEnv" . | nindent 12 }}
{{- if .Values.liteadmin.enabled }}
- name: LITELLM_ADMIN_AGENT_URL

View file

@ -0,0 +1,95 @@
{{- if .Values.lensWorker.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "litellm.fullname" . }}-lens-worker
labels:
{{- include "litellm.labels" . | nindent 4 }}
app.kubernetes.io/component: lens-worker
spec:
replicas: {{ .Values.lensWorker.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: lens-worker
template:
metadata:
labels:
{{- include "litellm.labels" . | nindent 8 }}
app.kubernetes.io/component: lens-worker
spec:
automountServiceAccountToken: false
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: lens-worker
image: {{ include "litellm.lensWorker.image" . | quote }}
imagePullPolicy: {{ .Values.lensWorker.image.pullPolicy }}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: [ALL]
env:
- name: LITELLM_URL
value: {{ .Values.lensWorker.url | default (printf "http://%s:%v" (include "litellm.fullname" .) .Values.service.port) | quote }}
- name: LITELLM_LENS_SERVICE_TOKEN
valueFrom:
secretKeyRef:
name: {{ required "lensWorker.serviceTokenSecret.name is required" .Values.lensWorker.serviceTokenSecret.name | quote }}
key: {{ .Values.lensWorker.serviceTokenSecret.key | quote }}
- name: CLICKHOUSE_URL
valueFrom:
secretKeyRef:
name: {{ required "lensWorker.clickhouseSecret.name is required" .Values.lensWorker.clickhouseSecret.name | quote }}
key: {{ .Values.lensWorker.clickhouseSecret.key | quote }}
{{- if .Values.lensWorker.tokenSecret.name }}
- name: LENS_WORKER_TOKEN
valueFrom:
secretKeyRef:
name: {{ .Values.lensWorker.tokenSecret.name | quote }}
key: {{ .Values.lensWorker.tokenSecret.key | quote }}
{{- end }}
ports:
- name: otlp
containerPort: 4318
livenessProbe:
httpGet:
path: /health/live
port: otlp
readinessProbe:
httpGet:
path: /health/ready
port: otlp
resources:
{{- toYaml .Values.lensWorker.resources | nindent 12 }}
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir:
medium: Memory
sizeLimit: {{ .Values.lensWorker.tmpSizeLimit }}
{{- with .Values.lensWorker.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.lensWorker.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.lensWorker.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,29 @@
{{- if and .Values.lensWorker.enabled .Values.lensWorker.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "litellm.fullname" . }}-lens-worker
{{- with .Values.lensWorker.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with .Values.lensWorker.ingress.className }}
ingressClassName: {{ . | quote }}
{{- end }}
{{- with .Values.lensWorker.ingress.tls }}
tls:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
- host: {{ required "lensWorker.ingress.host is required" .Values.lensWorker.ingress.host | quote }}
http:
paths:
- path: /v1/
pathType: Prefix
backend:
service:
name: {{ include "litellm.fullname" . }}-lens-worker
port:
name: otlp
{{- end }}

View file

@ -0,0 +1,14 @@
{{- if .Values.lensWorker.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "litellm.fullname" . }}-lens-worker
spec:
selector:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: lens-worker
ports:
- name: otlp
port: {{ .Values.lensWorker.service.port }}
targetPort: otlp
{{- end }}

View file

@ -647,3 +647,41 @@ serviceMonitor:
namespaceSelector:
matchNames: []
# - test-namespace
lensWorker:
enabled: false
replicaCount: 1
image:
repository: ghcr.io/berriai/litellm-lens-worker
tag: ""
digest: ""
pullPolicy: IfNotPresent
tokenSecret:
name: ""
key: token
serviceTokenSecret:
name: ""
key: service-token
clickhouseSecret:
name: ""
key: url
publicUrl: ""
service:
port: 4318
ingress:
enabled: false
className: ""
host: ""
annotations: {}
tls: []
url: ""
tmpSizeLimit: 1Gi
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 2Gi
nodeSelector: {}
tolerations: []
affinity: {}

View file

@ -57,6 +57,17 @@ spec:
containerPort: 4001
protocol: TCP
env:
{{- if .Values.lensWorker.enabled }}
- name: LITELLM_LENS_URL
value: {{ printf "http://%s-lens-worker:%v" (include "litellm.fullname" .) .Values.lensWorker.service.port | quote }}
- name: LITELLM_LENS_PUBLIC_URL
value: {{ required "lensWorker.publicUrl is required" .Values.lensWorker.publicUrl | quote }}
- name: LITELLM_LENS_SERVICE_TOKEN
valueFrom:
secretKeyRef:
name: {{ required "lensWorker.serviceTokenSecret.name is required" .Values.lensWorker.serviceTokenSecret.name | quote }}
key: {{ .Values.lensWorker.serviceTokenSecret.key | quote }}
{{- end }}
- name: LENS_WORKER_IMAGE
value: {{ include "litellm.lensWorker.image" . | quote }}
{{- include "litellm.serverEnv" (dict "root" $ "component" .Values.backend) | nindent 12 }}

View file

@ -42,11 +42,34 @@ spec:
env:
- name: LITELLM_URL
value: {{ .Values.lensWorker.url | default (printf "http://%s:%v" (include "litellm.backend.fullname" .) .Values.backend.service.port) | quote }}
- name: LITELLM_LENS_SERVICE_TOKEN
valueFrom:
secretKeyRef:
name: {{ required "lensWorker.serviceTokenSecret.name is required" .Values.lensWorker.serviceTokenSecret.name | quote }}
key: {{ .Values.lensWorker.serviceTokenSecret.key | quote }}
- name: CLICKHOUSE_URL
valueFrom:
secretKeyRef:
name: {{ required "lensWorker.clickhouseSecret.name is required" .Values.lensWorker.clickhouseSecret.name | quote }}
key: {{ .Values.lensWorker.clickhouseSecret.key | quote }}
{{- if .Values.lensWorker.tokenSecret.name }}
- name: LENS_WORKER_TOKEN
valueFrom:
secretKeyRef:
name: {{ required "lensWorker.tokenSecret.name must reference a Lens worker token" .Values.lensWorker.tokenSecret.name | quote }}
name: {{ .Values.lensWorker.tokenSecret.name | quote }}
key: {{ .Values.lensWorker.tokenSecret.key | quote }}
{{- end }}
ports:
- name: otlp
containerPort: 4318
livenessProbe:
httpGet:
path: /health/live
port: otlp
readinessProbe:
httpGet:
path: /health/ready
port: otlp
resources:
{{- toYaml .Values.lensWorker.resources | nindent 12 }}
volumeMounts:

View file

@ -0,0 +1,29 @@
{{- if and .Values.lensWorker.enabled .Values.lensWorker.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "litellm.fullname" . }}-lens-worker
{{- with .Values.lensWorker.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with .Values.lensWorker.ingress.className }}
ingressClassName: {{ . | quote }}
{{- end }}
{{- with .Values.lensWorker.ingress.tls }}
tls:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
- host: {{ required "lensWorker.ingress.host is required" .Values.lensWorker.ingress.host | quote }}
http:
paths:
- path: /v1/
pathType: Prefix
backend:
service:
name: {{ include "litellm.fullname" . }}-lens-worker
port:
name: otlp
{{- end }}

View file

@ -0,0 +1,14 @@
{{- if .Values.lensWorker.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "litellm.fullname" . }}-lens-worker
spec:
selector:
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: lens-worker
ports:
- name: otlp
port: {{ .Values.lensWorker.service.port }}
targetPort: otlp
{{- end }}

View file

@ -641,6 +641,21 @@ lensWorker:
tokenSecret:
name: ""
key: token
serviceTokenSecret:
name: ""
key: service-token
clickhouseSecret:
name: ""
key: url
publicUrl: ""
service:
port: 4318
ingress:
enabled: false
className: ""
host: ""
annotations: {}
tls: []
url: ""
tmpSizeLimit: 1Gi
resources:

View file

@ -4201,6 +4201,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.19",
"tokio",
"tower-http",
"tracing",
"typify",
"unicode-casefold",

View file

@ -30,6 +30,7 @@ tempfile.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["signal", "sync", "process", "io-util"] }
tracing.workspace = true
tower-http = { version = "0.6.11", features = ["cors"] }
url.workspace = true
unicode-casefold = "0.2"

View file

@ -33,8 +33,11 @@ impl Config {
{
return Err(Error::Configuration("LITELLM_URL"));
}
let worker_token = required("LENS_WORKER_TOKEN")?;
let service_token = required("LITELLM_LENS_SERVICE_TOKEN")?;
let worker_token = std::env::var("LENS_WORKER_TOKEN")
.ok()
.filter(|value| !value.is_empty())
.unwrap_or_else(|| service_token.clone());
if service_token.len() < 32 {
return Err(Error::Configuration(
"LITELLM_LENS_SERVICE_TOKEN must contain at least 32 characters",
@ -51,7 +54,7 @@ impl Config {
release: required("LITELLM_RELEASE_TAG")?,
storage: StorageConfig::new(
std::env::var("CLICKHOUSE_DATABASE").unwrap_or_else(|_| "litellm".into()),
&required("CLICKHOUSE_URL")?,
&clickhouse_url()?,
std::env::var("AGENT_TRACING_RETENTION_DAYS")
.unwrap_or_else(|_| "14".into())
.parse()
@ -62,6 +65,21 @@ impl Config {
}
}
fn clickhouse_url() -> Result<String, Error> {
if let Ok(url) = required("CLICKHOUSE_URL") {
return Ok(url);
}
let mut url = url::Url::parse("http://localhost:8123")
.map_err(|_| Error::Configuration("CLICKHOUSE_HOST"))?;
url.set_host(Some(&required("CLICKHOUSE_HOST")?))
.map_err(|_| Error::Configuration("CLICKHOUSE_HOST"))?;
url.set_username(&std::env::var("CLICKHOUSE_USER").unwrap_or_else(|_| "default".into()))
.map_err(|_| Error::Configuration("CLICKHOUSE_USER"))?;
url.set_password(Some(&required("CLICKHOUSE_PASSWORD")?))
.map_err(|_| Error::Configuration("CLICKHOUSE_PASSWORD"))?;
Ok(url.into())
}
pub fn http_client() -> Result<Client, Error> {
let settings = HttpSettings {
connect_timeout: Duration::from_secs(5),

View file

@ -14,6 +14,7 @@ pub struct Control {
base: Url,
token: Arc<str>,
model_slots: Arc<Semaphore>,
attempt: Option<u64>,
}
impl Control {
@ -26,6 +27,7 @@ impl Control {
base,
token: token.into(),
model_slots: Arc::new(Semaphore::new(16)),
attempt: None,
}
}
@ -52,6 +54,10 @@ impl Control {
Some(body) => request.json(body),
None => request,
};
let request = match self.attempt {
Some(attempt) => request.header("x-litellm-lens-attempt", attempt),
None => request,
};
let mut response = request.send().await?;
let status = response.status();
if !status.is_success() {
@ -141,6 +147,11 @@ pub struct JobClient {
}
impl JobClient {
pub fn with_attempt(mut self, attempt: u64) -> Self {
self.control.attempt = Some(attempt);
self
}
pub fn new(
control: Control,
lens_id: &str,

View file

@ -81,16 +81,98 @@ impl State {
}
pub fn router(state: Arc<State>) -> Router {
Router::new()
let public = Router::new()
.route("/health/live", get(|| async { StatusCode::OK }))
.route("/health/ready", get(ready))
.route("/v1/traces", post(traces))
.route("/v1/logs", post(logs))
.route("/internal/read", post(read))
.route("/internal/spend", post(spend))
.route("/v1/traces/receipt", post(receipt))
.layer(
tower_http::cors::CorsLayer::new()
.allow_origin(tower_http::cors::Any)
.allow_methods([http::Method::POST, http::Method::GET])
.allow_headers([
http::header::AUTHORIZATION,
http::header::CONTENT_TYPE,
http::header::CONTENT_ENCODING,
]),
);
public
.merge(
Router::new()
.route("/internal/read", post(read))
.route("/internal/spend", post(spend))
.route("/internal/credentials", post(credentials))
.route("/internal/status", get(status)),
)
.with_state(state)
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct ReceiptRequest {
trace_id: String,
#[serde(default)]
span_ids: Vec<String>,
}
async fn receipt(
AppState(state): AppState<Arc<State>>,
headers: HeaderMap,
body: Body,
) -> Result<Json<Value>, Error> {
let tenant = state.credentials.tenant(&headers)?;
state.require_storage()?;
let _permit = state
.read_slots
.try_acquire()
.map_err(|_| Error::Unavailable)?;
let body = tokio::time::timeout(Duration::from_secs(5), to_bytes(body, 64 * 1024))
.await
.map_err(|_| Error::Unavailable)?
.map_err(|_| Error::TooLarge)?;
let request: ReceiptRequest =
serde_json::from_slice(&body).map_err(|_| Error::InvalidRequest)?;
let received = litellm_traces_clickhouse::trace_received(
&state.storage.client,
state.storage.config.storage().reader(),
&tenant,
&request.trace_id,
&request.span_ids,
)
.await?;
Ok(Json(serde_json::json!({"received": received})))
}
async fn status(
AppState(state): AppState<Arc<State>>,
headers: HeaderMap,
) -> Result<Json<Value>, Error> {
auth::authorize_service(&headers, &state.service_token)?;
Ok(Json(serde_json::json!({
"storage_ready": state.schema_ready.load(Ordering::Acquire),
"credentials_ready": state.credentials.ready(),
"release": std::env::var("LITELLM_RELEASE_TAG").unwrap_or_default(),
"protocol_version": wire::PROTOCOL_VERSION,
})))
}
async fn credentials(
AppState(state): AppState<Arc<State>>,
headers: HeaderMap,
body: Body,
) -> Result<StatusCode, Error> {
auth::authorize_service(&headers, &state.service_token)?;
let body = tokio::time::timeout(Duration::from_secs(5), to_bytes(body, 8 * 1024 * 1024))
.await
.map_err(|_| Error::Unavailable)?
.map_err(|_| Error::TooLarge)?;
state
.credentials
.replace(serde_json::from_slice(&body).map_err(|_| Error::InvalidRequest)?)?;
Ok(StatusCode::NO_CONTENT)
}
async fn ready(AppState(state): AppState<Arc<State>>) -> StatusCode {
if state.schema_ready.load(Ordering::Acquire) && state.credentials.ready() {
StatusCode::OK

View file

@ -51,13 +51,13 @@ async fn run() -> Result<(), litellm_lens::Error> {
config.worker_token.clone(),
);
let storage = Storage::new(config.storage, client.clone(), config.service_token.clone());
let state = Arc::new(State::new(storage, config.service_token));
let state = Arc::new(State::new(storage, config.service_token.clone()));
let listener = tokio::net::TcpListener::bind(config.address).await?;
let auth_task = tokio::spawn(auth::refresh_loop(
state.credentials.clone(),
client,
control.url("lens/worker/ingestion-credentials")?,
config.worker_token,
control.url("lens/internal/ingestion-credentials")?,
config.service_token,
));
let provision_task = tokio::spawn(provision(state.clone()));
let mut worker = tokio::spawn(Worker::new(control, config.release).serve());

View file

@ -23,6 +23,7 @@ struct Identity {
#[derive(Deserialize)]
struct JobIdentity {
id: String,
attempts: u64,
}
impl Worker {
@ -48,7 +49,8 @@ impl Worker {
if claim.is_err() || !validator.is_valid(&payload) {
let identity: Identity = serde_json::from_value(payload)?;
let client =
JobClient::new(self.control.clone(), &identity.lens_id, &identity.job.id, 1)?;
JobClient::new(self.control.clone(), &identity.lens_id, &identity.job.id, 1)?
.with_attempt(identity.job.attempts);
self.failure(&client, "The worker could not read this investigation. Update the worker to match the gateway, then retry.").await?;
return Ok(true);
}
@ -58,7 +60,8 @@ impl Worker {
&claim.lens_id,
&claim.job.id,
claim.job.settings.concurrency.get() as usize,
)?;
)?
.with_attempt(u64::try_from(claim.job.attempts).map_err(|_| Error::InvalidRequest)?);
let work = async {
let sample: wire::Sample = client.get("sample").await?;
claim.reviews = Some(client.get("reviews").await?);

View file

@ -29,7 +29,7 @@ fn workspace() -> Workspace {
}
fn request(code: &str) -> wire::PythonRequest {
serde_json::from_value(json!({"code": code})).unwrap()
serde_json::from_value(json!({"action": "python", "code": code})).unwrap()
}
fn succeeded(reply: &Value) {

View file

@ -16,6 +16,7 @@ mod insert;
pub mod query;
mod query_access;
mod reads;
mod receipt;
mod schema;
mod span_batches;
mod span_row;
@ -32,6 +33,7 @@ pub use litellm_traces::{QueryScope, ReadQuery};
pub use query::{QueryHelp, execute_read, query_help, query_sql};
pub use query_access::QueryReaders;
pub use reads::ClickHouseTraces;
pub use receipt::trace_received;
pub use schema::{
NORMALIZED_FIELD_DEFINITIONS, NormalizedFieldDefinition, apply_migrations, ensure_schema,
reconcile_retention, schema_statements,

View file

@ -0,0 +1,57 @@
use crate::{Connection, Error, Parameter};
use litellm_http::Client;
use litellm_traces::Tenant;
use serde::Deserialize;
use std::collections::{BTreeMap, BTreeSet};
#[derive(Deserialize)]
struct Receipt {
received: u32,
}
#[derive(Deserialize)]
struct Rows {
data: Vec<Receipt>,
}
pub async fn trace_received(
client: &Client,
connection: &Connection,
tenant: &Tenant,
trace_id: &str,
span_ids: &[String],
) -> Result<bool, Error> {
let valid_id =
|value: &str, length| value.len() == length && value.bytes().all(|b| b.is_ascii_hexdigit());
if !valid_id(trace_id, 32)
|| span_ids.len() > 1000
|| span_ids.iter().any(|id| !valid_id(id, 16))
{
return Err(Error::InvalidParameters);
}
let spans: BTreeSet<_> = span_ids.iter().map(|id| id.to_ascii_lowercase()).collect();
let expected = spans.len();
let parameters = BTreeMap::from([
(
"trace_id".into(),
Parameter::Text(trace_id.to_ascii_lowercase()),
),
(
"api_key_hash".into(),
Parameter::Text(tenant.api_key_hash.clone()),
),
(
"span_ids".into(),
Parameter::Strings(spans.into_iter().collect()),
),
]);
let response = litellm_storage_clickhouse::execute_read(client, connection,
"SELECT toUInt32(uniqExact(SpanId)) AS received FROM otel_traces WHERE TraceId={trace_id:String} AND ApiKeyHash={api_key_hash:String} AND (empty({span_ids:Array(String)}) OR has({span_ids:Array(String)}, SpanId))", &parameters).await?;
let rows: Rows = serde_json::from_str(&response).map_err(|_| Error::InvalidResponse)?;
let row = rows.data.first().ok_or(Error::InvalidResponse)?;
Ok(if expected == 0 {
row.received > 0
} else {
row.received as usize == expected
})
}

View file

@ -7,7 +7,7 @@ from types import MappingProxyType
from typing import Annotated, Final, TypeAlias
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import AwareDatetime, Field
@ -18,15 +18,17 @@ from litellm.proxy.auth.resolvers.exceptions import KeyNotFoundError
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.db.routing_prisma_wrapper import writer_wrapper
from litellm.proxy.lens.billing import validate_key
from litellm.proxy.lens.inference import Deployment, deployment_prices
from litellm.proxy.lens.ingestion import (
IngestionCredential,
IngestionKey,
IngestionKeyCreated,
IngestionKeyRequest,
IngestionSnapshot,
ServiceConnection,
ServiceStatus,
new_key,
)
from litellm.proxy.lens.inference import Deployment, deployment_prices
from litellm.proxy.lens.models import (
ActivitySelection,
Claim,
@ -76,6 +78,7 @@ from litellm.proxy.lens.state import (
summarized,
)
from litellm.proxy.tracing_runtime import provide_storage
from litellm.tracing.remote import LensConnection
from litellm.types.llms.base import LiteLLMBaseModel
router: Final = APIRouter(prefix="/lens", tags=["Lens"])
@ -124,37 +127,42 @@ async def worker_auth(credentials: Annotated[HTTPAuthorizationCredentials, Depen
WorkerAuth: TypeAlias = Annotated[Worker, Depends(worker_auth)]
Attempt: TypeAlias = Annotated[int, Header(alias="X-LiteLLM-Lens-Attempt", ge=1)]
@router.post("/tracing/keys", response_model=IngestionKeyCreated)
async def create_ingestion_key(body: IngestionKeyRequest, auth: Auth) -> IngestionKeyCreated:
user_scope(auth, write=True)
async def service_auth(credentials: Annotated[HTTPAuthorizationCredentials, Depends(_bearer)]) -> None:
try:
created: Final = new_key(body, auth.user_id or "")
connection: Final = LensConnection.from_env()
except ValueError as error:
raise HTTPException(422, str(error)) from error
await repository().save_ingestion_key(created.record)
return created
raise HTTPException(503, "Configure the Lens service connection") from error
if not secrets.compare_digest(credentials.credentials, connection.token):
raise HTTPException(401, "Invalid Lens service credential")
@router.get("/tracing/keys", response_model=tuple[IngestionKey, ...])
async def list_ingestion_keys(auth: Auth) -> tuple[IngestionKey, ...]:
ServiceAuth: TypeAlias = Annotated[None, Depends(service_auth)]
@router.get("/service", response_model=ServiceConnection)
async def service_connection(auth: Auth) -> ServiceConnection:
import os
import httpx
user_scope(auth)
return await repository().ingestion_keys()
public_url: Final = os.environ.get("LITELLM_LENS_PUBLIC_URL", "").rstrip("/")
try:
connection: Final = LensConnection.from_env()
async with connection.client() as client:
response: Final = await client.get("/internal/status", timeout=2)
if response.status_code == 200:
status: Final = ServiceStatus.model_validate_json(response.content)
return ServiceConnection(url=public_url, connected=True, status=status)
except (ValueError, httpx.HTTPError):
pass
return ServiceConnection(url=public_url, connected=False, status=ServiceStatus())
@router.delete("/tracing/keys/{key_id}")
async def revoke_ingestion_key(key_id: str, auth: Auth) -> bool:
user_scope(auth, write=True)
await repository().revoke_ingestion_key(key_id)
return True
@router.get("/worker/ingestion-credentials", response_model=IngestionSnapshot)
async def ingestion_credentials(worker: WorkerAuth, response: Response) -> IngestionSnapshot:
if not worker.scope.all_teams:
raise HTTPException(403, "Ingestion requires an administrator-managed Lens service")
response.headers["Cache-Control"] = "no-store"
async def credential_snapshot() -> IngestionSnapshot:
keys: Final = await repository().ingestion_keys()
now: Final = int(datetime.now(timezone.utc).timestamp())
return IngestionSnapshot(
@ -167,7 +175,51 @@ async def ingestion_credentials(worker: WorkerAuth, response: Response) -> Inges
)
async def assigned(lens_id: str, job_id: str, worker: Worker) -> tuple[Lens, Job]:
async def publish_credentials() -> bool:
import httpx
try:
connection: Final = LensConnection.from_env()
snapshot: Final = await credential_snapshot()
async with connection.client() as client:
response: Final = await client.post("/internal/credentials", json=snapshot.model_dump(mode="json"), timeout=2)
return response.status_code == 204
except (ValueError, httpx.HTTPError):
return False
@router.post("/tracing/keys", response_model=IngestionKeyCreated)
async def create_ingestion_key(body: IngestionKeyRequest, auth: Auth) -> IngestionKeyCreated:
user_scope(auth, write=True)
try:
created: Final = new_key(body, auth.user_id or "")
except ValueError as error:
raise HTTPException(422, str(error)) from error
await repository().save_ingestion_key(created.record)
return created.model_copy(update={"active": await publish_credentials()})
@router.get("/tracing/keys", response_model=tuple[IngestionKey, ...])
async def list_ingestion_keys(auth: Auth) -> tuple[IngestionKey, ...]:
user_scope(auth)
return await repository().ingestion_keys()
@router.delete("/tracing/keys/{key_id}")
async def revoke_ingestion_key(key_id: str, auth: Auth) -> bool:
user_scope(auth, write=True)
await repository().revoke_ingestion_key(key_id)
await publish_credentials()
return True
@router.get("/internal/ingestion-credentials", response_model=IngestionSnapshot)
async def ingestion_credentials(service: ServiceAuth, response: Response) -> IngestionSnapshot:
response.headers["Cache-Control"] = "no-store"
return await credential_snapshot()
async def assigned(lens_id: str, job_id: str, worker: Worker, attempt: int = 1) -> tuple[Lens, Job]:
lens: Final = await get_lens(lens_id, worker.scope)
job: Final = current_job(lens)
if (
@ -175,6 +227,7 @@ async def assigned(lens_id: str, job_id: str, worker: Worker) -> tuple[Lens, Job
or job.id != job_id
or job.status != "running"
or job.worker_id != worker.id
or job.attempts != attempt
or job.lease_until is None
or job.lease_until <= datetime.now(timezone.utc)
):
@ -484,6 +537,7 @@ class WorkerBilling(LiteLLMBaseModel):
class WorkerName(WorkerBilling):
name: str = Field(default="Lens worker", min_length=1)
managed: bool = False
def configured_worker_image() -> str:
@ -501,7 +555,11 @@ async def register_worker(body: WorkerName, auth: Auth) -> WorkerCreated:
scope: Final = user_scope(auth, write=True)
image: Final = configured_worker_image()
await validate_key(body.analysis_key_id)
token: Final = "lens-" + secrets.token_urlsafe(40)
try:
token: Final = LensConnection.from_env().token if body.managed else "lens-" + secrets.token_urlsafe(40)
except ValueError as error:
raise HTTPException(503, "Configure the Lens service before enabling investigations") from error
token_hash: Final = hashlib.sha256(token.encode()).hexdigest()
worker: Final = Worker(
id=str(uuid4()),
name=body.name,
@ -509,7 +567,10 @@ async def register_worker(body: WorkerName, auth: Auth) -> WorkerCreated:
analysis_key_id=body.analysis_key_id,
last_seen=datetime(1970, 1, 1, tzinfo=timezone.utc),
)
await repository().save_worker(worker, hashlib.sha256(token.encode()).hexdigest())
if body.managed:
managed: Final = await repository().configure_service_worker(worker, token_hash)
return WorkerCreated(worker=managed, token="", image=image, managed=True)
await repository().save_worker(worker, token_hash)
return WorkerCreated(worker=worker, token=token, image=image)
@ -560,8 +621,8 @@ async def claim(worker: WorkerAuth, protocol_version: int = 1, worker_release: s
@router.post("/worker/{lens_id}/{job_id}/progress", response_model=bool)
async def progress(lens_id: str, job_id: str, body: Progress, worker: WorkerAuth) -> bool:
_, assigned_job = await assigned(lens_id, job_id, worker)
async def progress(lens_id: str, job_id: str, body: Progress, worker: WorkerAuth, attempt: Attempt = 1) -> bool:
_, assigned_job = await assigned(lens_id, job_id, worker, attempt)
if body.review is not None:
if assigned_job.sample is None or body.review.execution_id not in frozenset(
execution.id for execution in assigned_job.sample.executions
@ -579,14 +640,14 @@ async def progress(lens_id: str, job_id: str, body: Progress, worker: WorkerAuth
@router.get("/worker/{lens_id}/{job_id}/reviews", response_model=tuple[Review, ...])
async def cached_reviews(lens_id: str, job_id: str, worker: WorkerAuth) -> tuple[Review, ...]:
_, job = await assigned(lens_id, job_id, worker)
async def cached_reviews(lens_id: str, job_id: str, worker: WorkerAuth, attempt: Attempt = 1) -> tuple[Review, ...]:
_, job = await assigned(lens_id, job_id, worker, attempt)
return await repository().reviews(lens_id, job)
@router.get("/worker/{lens_id}/{job_id}/sample", response_model=Sample)
async def sample(lens_id: str, job_id: str, worker: WorkerAuth, storage: StorageDep) -> Sample:
lens, job = await assigned(lens_id, job_id, worker)
async def sample(lens_id: str, job_id: str, worker: WorkerAuth, storage: StorageDep, attempt: Attempt = 1) -> Sample:
lens, job = await assigned(lens_id, job_id, worker, attempt)
if job.sample is not None:
return job.sample
pages: list[Sample] = [] # mutable-ok: freeze selection after stable cursor traversal
@ -610,7 +671,7 @@ async def sample(lens_id: str, job_id: str, worker: WorkerAuth, storage: Storage
def freeze(e: Lens) -> Lens:
active: Final = current_job(e)
if active is None or active.id != job_id or active.worker_id != worker.id:
if active is None or active.id != job_id or active.worker_id != worker.id or active.attempts != attempt:
raise HTTPException(409, "Job was cancelled or reassigned")
return (
replace_job(e, active.model_copy(update=MappingProxyType({"sample": selected})))
@ -634,8 +695,9 @@ async def content(
storage: StorageDep,
cursor: str = "",
offset: int = Query(default=0, ge=0),
attempt: Attempt = 1,
) -> ExecutionContent:
lens, job = await assigned(lens_id, job_id, worker)
lens, job = await assigned(lens_id, job_id, worker, attempt)
selected: Final = job.sample or Sample(executions=(), eligible=0)
execution: Final = next((e for e in selected.executions if e.id == execution_id), None)
if execution is None:
@ -656,11 +718,11 @@ def model_failure(error: HTTPException | ProxyException) -> HTTPException:
@router.post("/worker/{lens_id}/{job_id}/model", response_model=ModelResult)
async def model(
lens_id: str, job_id: str, body: ModelRequest, worker: WorkerAuth, request: Request, response: Response
lens_id: str, job_id: str, body: ModelRequest, worker: WorkerAuth, request: Request, response: Response, attempt: Attempt = 1
) -> ModelResult:
from litellm.proxy.lens.inference import analyze
lens, job = await assigned(lens_id, job_id, worker)
lens, job = await assigned(lens_id, job_id, worker, attempt)
try:
completion: Final = await analyze(repository(), lens, job, worker, body, request)
except (ProxyException, HTTPException) as error:
@ -671,14 +733,14 @@ async def model(
@router.post("/worker/{lens_id}/{job_id}/result", response_model=Lens)
async def result(lens_id: str, job_id: str, body: Result, worker: WorkerAuth, storage: StorageDep) -> Lens:
async def result(lens_id: str, job_id: str, body: Result, worker: WorkerAuth, storage: StorageDep, attempt: Attempt = 1) -> Lens:
lens: Final = await get_lens(lens_id, worker.scope)
old: Final = next((j for j in lens.jobs if j.id == job_id), None)
if old and old.status in ("completed", "failed") and old.worker_id == worker.id:
if old and old.status in ("completed", "failed") and old.worker_id == worker.id and old.attempts == attempt:
if old.review_versions and old.status == "completed":
await repository().complete_reviews(lens_id, old, old.review_versions)
return lens
_, job = await assigned(lens_id, job_id, worker)
_, job = await assigned(lens_id, job_id, worker, attempt)
now: Final = datetime.now(timezone.utc)
selected: Final = job.sample or Sample(executions=(), eligible=0)
allowed: Final = frozenset(e.id for e in selected.executions)
@ -707,7 +769,7 @@ async def result(lens_id: str, job_id: str, body: Result, worker: WorkerAuth, st
def finish(e: Lens) -> Lens:
active: Final = current_job(e)
if active is None or active.id != job_id or active.worker_id != worker.id:
if active is None or active.id != job_id or active.worker_id != worker.id or active.attempts != attempt:
return e
restored: Final = e.model_copy(
update=MappingProxyType(
@ -797,8 +859,8 @@ def merge_results(lens: Lens, result: Result, revision: int, now: datetime, job_
@router.post("/worker/{lens_id}/{job_id}/heartbeat", response_model=bool)
async def heartbeat(lens_id: str, job_id: str, worker: WorkerAuth) -> bool:
return await progress(lens_id, job_id, Progress(), worker)
async def heartbeat(lens_id: str, job_id: str, worker: WorkerAuth, attempt: Attempt = 1) -> bool:
return await progress(lens_id, job_id, Progress(), worker, attempt)
async def claim_candidate(candidate: Lens, worker: Worker, now: datetime) -> Claim | None:

View file

@ -44,6 +44,20 @@ class IngestionSnapshot(Record):
class IngestionKeyCreated(Record):
key: str
record: IngestionKey
active: bool = False
class ServiceStatus(Record):
storage_ready: bool = False
credentials_ready: bool = False
release: str = ""
protocol_version: int = 0
class ServiceConnection(Record):
url: str
connected: bool
status: ServiceStatus
def new_key(request: IngestionKeyRequest, user_id: str) -> IngestionKeyCreated:

View file

@ -402,6 +402,7 @@ class WorkerCreated(Record):
image: str
worker: Worker
token: str
managed: bool = False
class LensList(Record):

View file

@ -391,6 +391,19 @@ class LensRepository:
'UPDATE "LiteLLM_LensWorker" SET data=$1::jsonb WHERE id=$2', worker.model_dump_json(), worker.id
)
async def configure_service_worker(self, worker: Worker, token_hash: str) -> Worker:
rows: Final = _ROWS.validate_python(
await self.db.query_raw(
'INSERT INTO "LiteLLM_LensWorker" AS existing (id,token_hash,data) VALUES ($1,$2,$3::jsonb) '
'ON CONFLICT (token_hash) DO UPDATE '
"SET data=jsonb_set(EXCLUDED.data, '{id}', to_jsonb(existing.id)) RETURNING data",
worker.id,
token_hash,
worker.model_dump_json(),
)
)
return Worker.model_validate(rows[0].data)
async def set_worker_billing(self, worker_id: str, key_id: str) -> Worker | None:
rows: Final = _ROWS.validate_python(
await self.db.query_raw(

View file

@ -181,7 +181,7 @@ export function liveLensApi(client: LensClient, apiClient: ApiClient, accessToke
required(
client.POST("/lens/workers/register", {
headers,
body: { name: "Lens worker", analysis_key_id: analysisKeyId },
body: { name: "Lens worker", analysis_key_id: analysisKeyId, managed: true },
}),
),
setWorkerBillingKey: (workerId, analysisKeyId) =>

View file

@ -2,6 +2,8 @@
import { ArrowRight, ArrowUpRight, Check, Copy, KeyRound, Loader2, Send } from "lucide-react";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { components } from "@/lib/http/schema";
import { useTimeout } from "usehooks-ts";
import { cn } from "@/lib/cva.config";
@ -12,7 +14,7 @@ import { copyToClipboard } from "@/utils/dataUtils";
import anthropicLogo from "../../../../../public/assets/logos/anthropic.svg";
import openaiLogo from "../../../../../public/assets/logos/openai_small.svg";
import otelLogo from "../../../../../public/assets/logos/opentelemetry.svg";
import { agentTraceCall, apiClient, getProxyBaseUrl, sendOtlpTraceCall } from "../../../networking";
import { agentTraceCall, apiClient, getProxyBaseUrl } from "../../../networking";
import { ActiveDot } from "../../traces/ui/ActiveDot";
import { sampleTraceExport } from "./sampleTrace";
import { FRAMEWORKS, frameworkSnippet, type FrameworkGuide } from "./tracingSetupGuides";
@ -20,13 +22,9 @@ import type { TraceSummary } from "../../traces/types";
const COPIED_RESET_MS = 1500;
const DOCS_URL = "https://docs.litellm.ai/docs/proxy/lens";
const EXAMPLE_MODEL = "openai/gpt-6-sol";
const EXAMPLE_MODEL = "openai/gpt-6.1-sol";
const SAMPLE_TRACE_POLL_MS = 1000;
export const TRACING_KEY_REQUEST = {
key_alias: "Agent tracing",
allowed_routes: ["/v1/traces"],
metadata: { purpose: "agent_tracing" },
} as const;
export const TRACING_KEY_REQUEST = { name: "Agent tracing" } as const;
const SAMPLE_TRACE_POLL_ATTEMPTS = 15;
type Installer = "pip" | "uv";
@ -39,25 +37,25 @@ const PY_INSTALL: Record<Installer, (packages: string) => string> = {
export const tracingEnvSnippet = (proxyUrl: string, tracingKey: string | null = null): string =>
[
...(tracingKey ? [`export LITELLM_TRACING_KEY=${tracingKey}`] : []),
`export LITELLM_TRACING_KEY="${tracingKey ?? "<your tracing key>"}"`,
`export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="${proxyUrl}/v1/traces"`,
`export OTEL_EXPORTER_OTLP_TRACES_HEADERS="Authorization=Bearer $${tracingKey ? "LITELLM_TRACING_KEY" : "LITELLM_API_KEY"}"`,
`export OTEL_EXPORTER_OTLP_TRACES_HEADERS="Authorization=Bearer $LITELLM_TRACING_KEY"`,
'export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"',
'export OTEL_METRICS_EXPORTER="none"',
'export OTEL_LOGS_EXPORTER="none"',
].join("\n");
export const codingAgentPrompt = (proxyUrl: string, guide: FrameworkGuide, model: string): string =>
export const codingAgentPrompt = (proxyUrl: string, traceUrl: string, guide: FrameworkGuide, model: string): string =>
[
`Send this ${guide.label} project's OpenTelemetry traces to LiteLLM.`,
"Keep the existing model configuration, authentication, and application behavior. Never hardcode a key; read it from LITELLM_API_KEY.",
"Keep the existing model configuration, authentication, and application behavior. Never hardcode keys. Read the model key from LITELLM_API_KEY and the dedicated tracing key from LITELLM_TRACING_KEY.",
"Set the trace destination wherever this project loads environment variables:",
tracingEnvSnippet(proxyUrl),
tracingEnvSnippet(traceUrl),
guide.install ?? `Install and enable the ${guide.plugin?.label}: ${guide.plugin?.url}`,
guide.plugin?.instruction ??
"Initialize OpenTelemetry before creating the agent. If the app already configures a tracer provider, keep it and point its exporter at the destination above instead.",
"Adapt this example to the existing application, replacing research_agent with the agent's name:",
frameworkSnippet(guide, proxyUrl, model),
frameworkSnippet(guide, proxyUrl, model, traceUrl),
guide.note ?? "",
"Run the agent once and confirm its named run appears in Lens > Traces.",
]
@ -74,17 +72,14 @@ export const maskSecret = (secret: string): string =>
export const otlpEndpoints = (proxyUrl: string): readonly (readonly [string, string, boolean])[] => [
["Traces endpoint", `${proxyUrl}/v1/traces`, true],
["Auth header", "Authorization: Bearer <LiteLLM virtual key>", true],
["Auth header", "Authorization: Bearer <Lens tracing key>", true],
["Protocol", "OTLP/HTTP (protobuf or JSON)", false],
];
export const PROXY_CONFIG_SNIPPET = [
"general_settings:",
" tracing:",
" store:",
" type: clickhouse",
" url: os.environ/CLICKHOUSE_URL",
" retention_days: 14",
'export LITELLM_LENS_URL="http://lens-worker:4318"',
'export LITELLM_LENS_PUBLIC_URL="https://traces.example.com"',
'export LITELLM_LENS_SERVICE_TOKEN="<shared service secret>"',
].join("\n");
function CodeBlock({
@ -203,9 +198,13 @@ async function waitForTrace(accessToken: string, traceId: string): Promise<Trace
function SendTestTrace({
accessToken,
traceUrl,
tracingKey,
onOpenTrace,
}: {
accessToken: string;
traceUrl: string;
tracingKey: string | null;
onOpenTrace: (trace: TraceSummary) => void;
}) {
const [state, setState] = useState<SendState>({ kind: "idle" });
@ -213,7 +212,16 @@ function SendTestTrace({
setState({ kind: "sending" });
const sample = sampleTraceExport(Date.now());
try {
await sendOtlpTraceCall(accessToken, sample.body);
if (!tracingKey) throw new Error("Generate a tracing key first");
const response = await fetch(`${traceUrl}/v1/traces`, {
method: "POST",
credentials: "omit",
redirect: "error",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${tracingKey}` },
body: JSON.stringify(sample.body),
signal: AbortSignal.timeout(15000),
});
if (!response.ok) throw new Error(`Trace upload failed (HTTP ${response.status})`);
} catch {
setState({ kind: "failed", message: "Could not send the test trace." });
return;
@ -241,7 +249,7 @@ function SendTestTrace({
const busy = state.kind === "sending" || state.kind === "waiting";
return (
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" onClick={() => void send()} disabled={busy}>
<Button variant="outline" onClick={() => void send()} disabled={busy || !tracingKey || !traceUrl}>
{busy ? (
<Loader2 aria-hidden="true" className="size-4 animate-spin" />
) : (
@ -282,8 +290,8 @@ function TraceReceipt({
</div>
{missingAfterCheck && (
<p className="mt-3 text-sm leading-6 text-muted-foreground">
No traces received yet. Check the exporter URL and LiteLLM key in your agent’s environment, then check its
logs for export errors.
No traces received yet. Check the Lens URL and tracing key in your agent’s environment, then check its logs
for export errors.
</p>
)}
</section>
@ -305,7 +313,7 @@ function TracingKey({
setCreating(true);
setError("");
try {
const result = await apiClient.post<{ key?: string }>("/key/generate", {
const result = await apiClient.post<components["schemas"]["IngestionKeyCreated"]>("/lens/tracing/keys", {
accessToken,
body: TRACING_KEY_REQUEST,
});
@ -323,8 +331,8 @@ function TracingKey({
<CodeBlock code={tracingKey} display={maskSecret(tracingKey)} tabs={<FileLabel>Your tracing key</FileLabel>} />
<p className="text-sm text-muted-foreground">
Hidden for safety. Copy copies the full key, and the environment step below includes it. This key can only
send traces, so your agent still needs its own key for model calls. Manage it under Virtual Keys as
&quot;Agent tracing&quot;.
send traces and check delivery. Your agent still needs its own key for model calls. Save this key before
leaving the page.
</p>
</div>
);
@ -339,7 +347,7 @@ function TracingKey({
)}
Generate tracing key
</Button>
<span className="text-sm text-muted-foreground">Or use any existing LiteLLM virtual key.</span>
<span className="text-sm text-muted-foreground">Use a dedicated Lens key for tracing.</span>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
);
@ -416,17 +424,17 @@ function EnableTracing({ checked, checking, onCheck }: { checked: boolean; check
<>
<Step title="Proxy configuration">
<p className="mb-3 text-sm leading-6 text-muted-foreground">
Set your ClickHouse URL, add this to config.yaml, then restart the proxy. Ask your proxy administrator if you
don’t manage this deployment.
Run the Lens service with ClickHouse access, then set these variables on LiteLLM and restart it. Use the same
service secret on both services.
</p>
<CodeBlock code={PROXY_CONFIG_SNIPPET} tabs={<FileLabel>config.yaml</FileLabel>} />
<CodeBlock code={PROXY_CONFIG_SNIPPET} tabs={<FileLabel>LiteLLM environment</FileLabel>} />
<a
className="mt-3 inline-flex items-center gap-1 text-sm underline underline-offset-4"
href={`${DOCS_URL}#configure-an-existing-proxy`}
target="_blank"
rel="noreferrer"
>
ClickHouse and proxy setup <ArrowUpRight aria-hidden="true" className="size-3.5" />
Lens service setup <ArrowUpRight aria-hidden="true" className="size-3.5" />
</a>
</Step>
{checked && !checking && (
@ -444,10 +452,20 @@ function EnableTracing({ checked, checking, onCheck }: { checked: boolean; check
);
}
function CodingAgentSetup({ proxyUrl, guide, model }: { proxyUrl: string; guide: FrameworkGuide; model: string }) {
function CodingAgentSetup({
proxyUrl,
traceUrl,
guide,
model,
}: {
proxyUrl: string;
traceUrl: string;
guide: FrameworkGuide;
model: string;
}) {
const [codingAgent, setCodingAgent] = useState<CodingAgent>("Claude Code");
const [copied, setCopied] = useState<string | null>(null);
const command = codingAgentCommand(codingAgent, codingAgentPrompt(proxyUrl, guide, model));
const command = codingAgentCommand(codingAgent, codingAgentPrompt(proxyUrl, traceUrl, guide, model));
useTimeout(() => setCopied(null), copied === null ? null : COPIED_RESET_MS);
const copy = async () => {
if (await copyToClipboard(command)) setCopied(command);
@ -471,8 +489,8 @@ function CodingAgentSetup({ proxyUrl, guide, model }: { proxyUrl: string; guide:
</div>
<div className="p-4">
<p className="text-sm leading-6 text-muted-foreground">
Run the setup command in your agent’s project. It uses your <code className="text-xs">LITELLM_API_KEY</code>
.
Run the setup command in your agent’s project. It uses <code className="text-xs">LITELLM_TRACING_KEY</code>{" "}
for traces and keeps your model key separate .
</p>
<Button className="mt-3" onClick={() => void copy()}>
{copied === command ? (
@ -505,6 +523,12 @@ function ConnectAgent({
canMintTracingKey,
}: ConnectAgentProps) {
const proxyUrl = getProxyBaseUrl().replace(/\/$/, "");
const connection = useQuery({
queryKey: ["lens-service", accessToken],
queryFn: () => apiClient.get<components["schemas"]["ServiceConnection"]>("/lens/service", { accessToken }),
refetchInterval: 15000,
});
const traceUrl = connection.data?.url ?? "";
const [framework, setFramework] = useState(FRAMEWORKS[0].id);
const [installer, setInstaller] = useState<Installer>("pip");
const [tracingKey, setTracingKey] = useState<string | null>(null);
@ -513,11 +537,36 @@ function ConnectAgent({
? PY_INSTALL[installer](guide.install.slice("pip install ".length))
: guide.install;
const model = EXAMPLE_MODEL;
const quickstart = frameworkSnippet(guide, proxyUrl, model, !!tracingKey);
const quickstart = frameworkSnippet(guide, proxyUrl, model, traceUrl);
if (!traceUrl)
return (
<p role="status" className="text-sm text-muted-foreground">
{connection.isPending
? "Checking Lens connection…"
: "Set LITELLM_LENS_PUBLIC_URL on LiteLLM to the Lens address your agents can reach, then restart LiteLLM."}
</p>
);
return (
<>
<Endpoints proxyUrl={proxyUrl}>
{!readOnly && <SendTestTrace accessToken={accessToken} onOpenTrace={onOpenTrace} />}
{!connection.data?.connected && (
<p role="alert" className="text-sm text-destructive">
The Lens service is unavailable. Check the service URL and shared secret.
</p>
)}
{connection.data?.connected && !connection.data.status.storage_ready && (
<p role="alert" className="text-sm text-destructive">
Lens is connected, but ClickHouse is unavailable.
</p>
)}
<Endpoints proxyUrl={traceUrl}>
{!readOnly && (
<SendTestTrace
accessToken={accessToken}
traceUrl={traceUrl}
tracingKey={tracingKey}
onOpenTrace={onOpenTrace}
/>
)}
</Endpoints>
<div className="mt-6 space-y-2">
<label id="tracing-framework" className="block text-sm font-medium">
@ -546,18 +595,16 @@ function ConnectAgent({
</Select>
</div>
<CodingAgentSetup proxyUrl={proxyUrl} guide={guide} model={model} />
<Step title="Create a tracing key">
{canMintTracingKey && !readOnly ? (
<TracingKey accessToken={accessToken} tracingKey={tracingKey} onCreated={setTracingKey} />
) : (
<p className="text-sm text-muted-foreground">Ask your proxy admin for a dedicated Lens tracing key.</p>
)}
</Step>
<CodingAgentSetup proxyUrl={proxyUrl} traceUrl={traceUrl} guide={guide} model={model} />
<details className="mt-6 border-t pt-6">
<summary className="w-fit cursor-pointer text-sm font-medium">Set up manually</summary>
<Step title="Get a LiteLLM key">
{canMintTracingKey && !readOnly ? (
<TracingKey accessToken={accessToken} tracingKey={tracingKey} onCreated={setTracingKey} />
) : (
<p className="text-sm text-muted-foreground">
Use any LiteLLM virtual key you already have, or ask a proxy admin for one.
</p>
)}
</Step>
{install && (
<Step title="Install dependencies">
<CodeBlock
@ -592,13 +639,14 @@ function ConnectAgent({
</>
) : (
<>
Set <code>LITELLM_API_KEY</code> to your LiteLLM key.
Set <code>LITELLM_TRACING_KEY</code> to a Lens tracing key and keep <code>LITELLM_API_KEY</code> for
model calls.
</>
)}
</p>
<CodeBlock
code={tracingEnvSnippet(proxyUrl, tracingKey)}
display={tracingEnvSnippet(proxyUrl, tracingKey && maskSecret(tracingKey))}
code={tracingEnvSnippet(traceUrl, tracingKey)}
display={tracingEnvSnippet(traceUrl, tracingKey && maskSecret(tracingKey))}
tabs={<FileLabel>Shell</FileLabel>}
wrap
/>

View file

@ -419,7 +419,7 @@ try {
}`,
existingModel: true,
fileName: "openclaw.json",
note: 'Set LITELLM_API_KEY to your LiteLLM key, then run openclaw agent --local --session-id first-trace --message "What is an agent trace?". Select research_agent in Lens. Restart an existing gateway after changing the config.',
note: 'Set LITELLM_TRACING_KEY to your Lens tracing key, then run openclaw agent --local --session-id first-trace --message "What is an agent trace?". Select research_agent in Lens. Restart an existing gateway after changing the config.',
plugin: {
label: "diagnostics-otel plugin",
url: "https://docs.openclaw.ai/plugins/reference/diagnostics-otel",
@ -448,7 +448,7 @@ backends:
plugin: {
label: "community hermes-otel plugin",
url: "https://github.com/briancaffey/hermes-otel#install",
instruction: "Set LITELLM_API_KEY to your LiteLLM key, then add this to ~/.hermes/hermes_otel.yaml.",
instruction: "Set LITELLM_TRACING_KEY to your Lens tracing key, then add this to ~/.hermes/hermes_otel.yaml.",
},
},
{
@ -485,17 +485,17 @@ with trace.get_tracer(__name__).start_as_current_span(AGENT_NAME) as span:
},
];
export function frameworkSnippet(guide: FrameworkGuide, proxyUrl: string, model: string, tracingKey = false): string {
export function frameworkSnippet(guide: FrameworkGuide, proxyUrl: string, model: string, traceUrl: string): string {
const values: Record<string, string> = {
MODEL: JSON.stringify(model),
OPENAI_MODEL: JSON.stringify(`openai/${model}`),
BASE_URL: JSON.stringify(`${proxyUrl}/v1`),
PROXY_URL: JSON.stringify(proxyUrl),
TRACE_URL: `${proxyUrl}/v1/traces`,
TRACE_URL: `${traceUrl}/v1/traces`,
};
const code = guide.quickstart.replace(
/\{(MODEL|OPENAI_MODEL|BASE_URL|PROXY_URL|TRACE_URL)\}/g,
(_, name: string) => values[name],
);
return tracingKey && guide.existingModel ? code.replaceAll("${LITELLM_API_KEY}", "${LITELLM_TRACING_KEY}") : code;
return guide.existingModel ? code.replaceAll("${LITELLM_API_KEY}", "${LITELLM_TRACING_KEY}") : code;
}

View file

@ -19,6 +19,12 @@ function InstallSteps({ address, created }: { address: string; created: WorkerCr
const command = workerSetupCommand(address, created.token, created.image);
const copyCommand = useCopy();
const copyToken = useCopy();
if (created.managed)
return (
<p role="status" className="text-sm text-muted-foreground">
Connecting your Lens service… This page updates automatically. Check the service logs if it does not connect.
</p>
);
return (
<>
<Button variant="default" className="w-full gap-2" onClick={() => copyCommand.mutate(command)}>
@ -80,7 +86,9 @@ export function WorkerInstall({ address, created, connected, children, className
<SettingsCard {...props} data-slot="worker-install" className={cn("flex flex-col gap-5", className)}>
<header className="space-y-1">
<h3 className="text-base font-semibold">Run the worker</h3>
<p className="text-sm text-muted-foreground">Run this command on a server with Docker.</p>
<p className="text-sm text-muted-foreground">
Your Lens service connects automatically. A separately hosted worker can use the Docker command.
</p>
</header>
<InstallSteps address={address} created={created} />
</SettingsCard>

View file

@ -15,6 +15,8 @@ export function workerSetupCommand(address: string, token: string, image: string
" --security-opt no-new-privileges --add-host host.docker.internal:host-gateway",
` -e ${quote("LITELLM_URL=" + address)}`,
` -e ${quote("LENS_WORKER_TOKEN=" + token)}`,
" -p 127.0.0.1:4318:4318 --memory 2g --cpus 2",
" -e LITELLM_LENS_SERVICE_TOKEN -e CLICKHOUSE_URL",
` ${quote(image)}`,
].join(" \\\n");
}

View file

@ -9086,6 +9086,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/lens/internal/ingestion-credentials": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Ingestion Credentials */
get: operations["ingestion_credentials_lens_internal_ingestion_credentials_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/lens/preview/sample": {
parameters: {
query?: never;
@ -9103,6 +9120,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/lens/service": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Service Connection */
get: operations["service_connection_lens_service_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/lens/traces/findings": {
parameters: {
query?: never;
@ -9120,6 +9154,41 @@ export interface paths {
patch?: never;
trace?: never;
};
"/lens/tracing/keys": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Ingestion Keys */
get: operations["list_ingestion_keys_lens_tracing_keys_get"];
put?: never;
/** Create Ingestion Key */
post: operations["create_ingestion_key_lens_tracing_keys_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/lens/tracing/keys/{key_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Revoke Ingestion Key */
delete: operations["revoke_ingestion_key_lens_tracing_keys__key_id__delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/lens/watch-all": {
parameters: {
query?: never;
@ -13010,7 +13079,7 @@ export interface paths {
* Example:
* ```bash
* curl --location --request DELETE 'http://0.0.0.0:4000/project/delete' \
* --header "Authorization: Bearer $LITELLM_MASTER_KEY" \
* --header 'Authorization: Bearer sk-1234' \
* --header 'Content-Type: application/json' \
* --data '{
* "project_ids": ["project-123", "project-456"]
@ -13040,7 +13109,7 @@ export interface paths {
* Example:
* ```bash
* curl --location 'http://0.0.0.0:4000/project/info?project_id=project-123' \
* --header "Authorization: Bearer $LITELLM_MASTER_KEY"
* --header 'Authorization: Bearer sk-1234'
* ```
*/
get: operations["project_info_project_info_get"];
@ -13066,7 +13135,7 @@ export interface paths {
* Example:
* ```bash
* curl --location 'http://0.0.0.0:4000/project/list' \
* --header "Authorization: Bearer $LITELLM_MASTER_KEY"
* --header 'Authorization: Bearer sk-1234'
* ```
*/
get: operations["list_projects_project_list_get"];
@ -13119,7 +13188,7 @@ export interface paths {
*
* ```bash
* curl --location 'http://0.0.0.0:4000/project/new' \
* --header "Authorization: Bearer $LITELLM_MASTER_KEY" \
* --header 'Authorization: Bearer sk-1234' \
* --header 'Content-Type: application/json' \
* --data '{
* "project_alias": "flight-search-assistant",
@ -13146,7 +13215,7 @@ export interface paths {
*
* ```bash
* curl --location 'http://0.0.0.0:4000/project/new' \
* --header "Authorization: Bearer $LITELLM_MASTER_KEY" \
* --header 'Authorization: Bearer sk-1234' \
* --header 'Content-Type: application/json' \
* --data '{
* "project_alias": "hotel-recommendations",
@ -13200,7 +13269,7 @@ export interface paths {
* Example:
* ```bash
* curl --location 'http://0.0.0.0:4000/project/update' \
* --header "Authorization: Bearer $LITELLM_MASTER_KEY" \
* --header 'Authorization: Bearer sk-1234' \
* --header 'Content-Type: application/json' \
* --data '{
* "project_id": "project-123",
@ -33554,6 +33623,79 @@ export interface components {
*/
object: "list";
};
/** IngestionCredential */
IngestionCredential: {
/** Expires At */
expires_at: number | null;
tenant: components["schemas"]["IngestionTenant"];
/** Token Hash */
token_hash: string;
};
/** IngestionKey */
IngestionKey: {
/**
* Created At
* Format: date-time
*/
created_at: string;
/** Expires At */
expires_at: number | null;
/** Id */
id: string;
/** Name */
name: string;
tenant: components["schemas"]["IngestionTenant"];
};
/** IngestionKeyCreated */
IngestionKeyCreated: {
/**
* Active
* @default false
*/
active: boolean;
/** Key */
key: string;
record: components["schemas"]["IngestionKey"];
};
/** IngestionKeyRequest */
IngestionKeyRequest: {
/** Expires At */
expires_at?: string | null;
/**
* Name
* @default Agent tracing
*/
name: string;
/**
* Team Id
* @default
*/
team_id: string;
};
/** IngestionSnapshot */
IngestionSnapshot: {
/** Issued At */
issued_at: number;
/** Keys */
keys: components["schemas"]["IngestionCredential"][];
};
/** IngestionTenant */
IngestionTenant: {
/** Api Key Hash */
api_key_hash: string;
/**
* Org Id
* @default
*/
org_id: string;
/**
* Team Id
* @default
*/
team_id: string;
/** User Id */
user_id: string;
};
/** InlineSkill */
InlineSkill: {
/** Description */
@ -45161,6 +45303,37 @@ export interface components {
/** Timeout */
timeout?: number | null;
};
/** ServiceConnection */
ServiceConnection: {
/** Connected */
connected: boolean;
status: components["schemas"]["ServiceStatus"];
/** Url */
url: string;
};
/** ServiceStatus */
ServiceStatus: {
/**
* Credentials Ready
* @default false
*/
credentials_ready: boolean;
/**
* Protocol Version
* @default 0
*/
protocol_version: number;
/**
* Release
* @default
*/
release: string;
/**
* Storage Ready
* @default false
*/
storage_ready: boolean;
};
/** SessionLogoutResponse */
SessionLogoutResponse: {
/** Message */
@ -50001,6 +50174,11 @@ export interface components {
WorkerCreated: {
/** Image */
image: string;
/**
* Managed
* @default false
*/
managed: boolean;
/** Token */
token: string;
worker: components["schemas"]["Worker"];
@ -50009,6 +50187,11 @@ export interface components {
WorkerName: {
/** Analysis Key Id */
analysis_key_id: string;
/**
* Managed
* @default false
*/
managed: boolean;
/**
* Name
* @default Lens worker
@ -63048,6 +63231,26 @@ export interface operations {
};
};
};
ingestion_credentials_lens_internal_ingestion_credentials_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["IngestionSnapshot"];
};
};
};
};
preview_sample_lens_preview_sample_post: {
parameters: {
query?: never;
@ -63081,6 +63284,26 @@ export interface operations {
};
};
};
service_connection_lens_service_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ServiceConnection"];
};
};
};
};
trace_findings_lens_traces_findings_post: {
parameters: {
query?: never;
@ -63114,6 +63337,90 @@ export interface operations {
};
};
};
list_ingestion_keys_lens_tracing_keys_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["IngestionKey"][];
};
};
};
};
create_ingestion_key_lens_tracing_keys_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["IngestionKeyRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["IngestionKeyCreated"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
revoke_ingestion_key_lens_tracing_keys__key_id__delete: {
parameters: {
query?: never;
header?: never;
path: {
key_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": boolean;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
watch_all_lens_watch_all_post: {
parameters: {
query?: never;
@ -63173,7 +63480,9 @@ export interface operations {
cursor?: string;
offset?: number;
};
header?: never;
header?: {
"X-LiteLLM-Lens-Attempt"?: number;
};
path: {
lens_id: string;
job_id: string;
@ -63205,7 +63514,9 @@ export interface operations {
heartbeat_lens_worker__lens_id___job_id__heartbeat_post: {
parameters: {
query?: never;
header?: never;
header?: {
"X-LiteLLM-Lens-Attempt"?: number;
};
path: {
lens_id: string;
job_id: string;
@ -63237,7 +63548,9 @@ export interface operations {
model_lens_worker__lens_id___job_id__model_post: {
parameters: {
query?: never;
header?: never;
header?: {
"X-LiteLLM-Lens-Attempt"?: number;
};
path: {
lens_id: string;
job_id: string;
@ -63273,7 +63586,9 @@ export interface operations {
progress_lens_worker__lens_id___job_id__progress_post: {
parameters: {
query?: never;
header?: never;
header?: {
"X-LiteLLM-Lens-Attempt"?: number;
};
path: {
lens_id: string;
job_id: string;
@ -63309,7 +63624,9 @@ export interface operations {
result_lens_worker__lens_id___job_id__result_post: {
parameters: {
query?: never;
header?: never;
header?: {
"X-LiteLLM-Lens-Attempt"?: number;
};
path: {
lens_id: string;
job_id: string;
@ -63345,7 +63662,9 @@ export interface operations {
cached_reviews_lens_worker__lens_id___job_id__reviews_get: {
parameters: {
query?: never;
header?: never;
header?: {
"X-LiteLLM-Lens-Attempt"?: number;
};
path: {
lens_id: string;
job_id: string;
@ -63377,7 +63696,9 @@ export interface operations {
sample_lens_worker__lens_id___job_id__sample_get: {
parameters: {
query?: never;
header?: never;
header?: {
"X-LiteLLM-Lens-Attempt"?: number;
};
path: {
lens_id: string;
job_id: string;