-
Notifications
You must be signed in to change notification settings - Fork 0
llm
devctl brings LLM traffic into the same inspector stack as logs and traces: an in-memory store, then MCP, web, TUI, and CLI. There is no new service kind. A source is a typed driver. Two are built in: litellm pulls from a LiteLLM management hop (/spend/logs), and proxy captures completion bodies straight off a devctl proxy route — no management API needed (see Proxy-capture source).
The inspector is off by default. It does not sit on telemetry — this is a pull source, not OTLP ingest. Full prompts never go on the status snapshot.
flowchart LR
subgraph apps [App traffic]
App --> CustomProxy
CustomProxy --> LiteLLM
end
subgraph inspect [Inspector hop]
Coord[LlmCoordinator]
Coord -->|"preferred: service port"| LiteLLM
Coord -->|"or via proxy URL plus prefix"| CustomProxy
end
LiteDriver[type litellm]
Store[LlmCallStore]
Coord --> LiteDriver
LiteDriver --> Store
Store --> MCP
Store --> Web
Store --> TUI
Store --> CLI
The demo's minimal profile (examples/demo-platform) includes a stdlib OpenAI-compatible stub (services.llm) captured with type: proxy — no LiteLLM and no cloud key. See the demo README.
Top-level llm. Unknown fields are rejected. Bearer tokens must come from the environment (token_env); never inline keys.
llm:
enabled: true
sources:
- name: platform
type: litellm
service: litellm
port: http
auth:
type: bearer
token_env: LITELLM_MASTER_KEY
capture:
prompts: true
poll_seconds: 5type must be a builtin (litellm, proxy) or a plugin llmSources name. When llm.enabled is true, sources must be non-empty and each source needs a unique name. A litellm source needs exactly one management hop: management_endpoint / management_service, or else exactly one of service, endpoint, or via.route. via.route may exist alongside management_* so apps can keep using a traffic proxy while the inspector talks to LiteLLM directly. A proxy source instead tags the route(s) to capture with via.routes (via.route is singular sugar) and has no management hop — see Proxy-capture source.
path_prefix is stripped of slashes; the LiteLLM driver always appends /spend/logs. Do not put that leaf in config.
capture.prompts defaults to true. Set false to drop request/response bodies at ingest. Bodies still need LiteLLM store_prompts_in_spend_logs; empty "{}" bodies are treated as missing. Redaction uses the same secrets detector as logs, at upsert, before any surface reads the store.
poll_seconds defaults to 5. auth.header defaults to Authorization. Set it to x-api-key or x-litellm-api-key when a gateway already owns Authorization (IAP, custom proxy). The coordinator applies route-minted identity headers first, then the LiteLLM key on the configured header.
A custom proxy in front of LiteLLM does not change type. The driver still speaks LiteLLM management APIs (GET {prefix}/spend/logs?summarize=false). What changes is how the daemon reaches that API.
1. Apps use the custom proxy; inspector uses the LiteLLM process (preferred). Typical when LiteLLM is a local services.litellm and nginx / IAP / the devctl proxy only sits on the app path. Use service + port as in the default above. No via.
2. LiteLLM is only reachable through the custom proxy (remote gateway, IAP, path mount):
- name: via-gateway
type: litellm
endpoint: https://gateway.internal.example
path_prefix: /llm
headers:
X-Tenant: local
auth:
type: bearer
token_env: LITELLM_MASTER_KEYIf that hop is already a named proxy route (IAP / service-account inject), reuse it so the inspector gets the same minted headers:
- name: via-devctl-proxy
type: litellm
via:
route: litellm
path_prefix: /llm
auth:
type: bearer
token_env: LITELLM_MASTER_KEY
header: x-litellm-api-keyvia.route must match proxy.routes[].name. It resolves to that route’s upstream and applies the route’s identity middleware.
3. Custom proxy only forwards OpenAI traffic (/v1/chat/completions) and does not expose /spend/logs. Keep type: litellm only if you can still name a LiteLLM management hop:
via:
route: llm-apps
management_endpoint: http://127.0.0.1:4000A 404/401/403 from /spend/logs is a source error in the UI (“this URL is not LiteLLM management; set path_prefix or management_endpoint”), not an empty list. If there is no management hop at all, this is not a LiteLLM source.
LiteLLM needs a DB plus a master key (or a key with get_spend_routes).
Experimental.
capture.field_mapandcost_per_tokenmay change without a deprecation period. See Experimental features.
When LiteLLM sits behind a gateway that only exposes /v1/chat/completions and blocks /spend/logs (common with Apigee, IAP, or API Management), there is no management hop to poll. Instead, route the completion traffic through the devctl proxy and let devctl capture the bodies as they pass:
proxy:
enabled: true
listen: { host: 127.0.0.1, port: 17400 }
routes:
- name: apigee-llm
match: { path: /llm }
upstream: { url: https://gateway.example/llm }
auth: { type: none } # workers inject their own gateway token
llm:
enabled: true
sources:
- name: apigee-llm
type: proxy
via: { route: apigee-llm } # singular sugar; or via.routes: [a, b]
capture:
prompts: true # false → keep metadata, drop bodies
max_bytes: 1048576 # per-direction cap (default llm.capture_max_bytes, then 1 MiB)
paths: # optional; extra POST JSON paths to capture raw
- /generations/v1alpha2
field_map: # optional; proprietary JSON → inspector fields
model: "$.request.model_name"
prompt_tokens: "$.response.metadata.input_tokens"
completion_tokens: "$.response.metadata.output_tokens"
cost: "$.response.metadata.price"
finish_reason: "$.response.choices[0].finish_reason"
cost_per_token: # optional; proxy only
input: 0.000001 # per prompt token
output: 0.000002 # per completion tokenPoint workers at the route (e.g. http://127.0.0.1:17400/llm/v1/chat/completions) and every OpenAI-compatible completion, chat, embedding, or streamed (text/event-stream) call is parsed and fed into the same store as any other source. All surfaces below then work unchanged.
-
Only tagged routes are buffered.
via.routes: [a, b]tags multiple proxy routes on one source;via.routeis singular sugar for one name (unioned with the list, first-seen order). All other proxy traffic still streams untouched. The request is buffered only when itscontent-lengthis withinmax_bytes; otherwise it is streamed and its stored body marked omitted. The response is always streamed to the caller — never buffered-then-forwarded — so SSE keeps flowing.capture.pathson that source apply to every tagged route. -
OpenAI-compatible completions are captured by default. Capture engages on a
POSTwith a JSON request content-type on a completion-shaped path (/chat/completions,/completions,/embeddings);GET /models,/model/info, health checks, and CORS preflights are ignored. Anthropic-native/messagesand the OpenAI Responses API (/responses) use different request/stream shapes and are not captured unless listed incapture.paths. -
capture.pathsadds proprietary endpoints. Each entry is a path substring (must start with/, not/alone) matched case-insensitively against the inbound request pathname, so a route mount prefix does not need repeating —/generations/v1alpha2matches/llm/generations/v1alpha2. Matching POST JSON is stored as a raw HTTP pair: parsed JSON bodies, or raw SSE text (not reassembled into achat.completion). Model, token usage, and finish reason are copied when those standard JSON fields are present (model,usage.prompt_tokens/input_tokens,choices[0].finish_reason); otherwise they are omitted andmodelshowsunknown. Built-in OpenAI paths on the same source still use the OpenAI mapper. -
capture.field_mapfills inspector summary fields from non-OpenAI JSON. Optional,type: proxyonly. Each value is a JSONPath subset evaluated against{ request, response }—$/$.prefix, dotted keys, and[n]indexes (no$..or filters). Allowed keys:model,prompt_tokens,completion_tokens,cost,finish_reason. Paths must start with$.request.or$.response.. A present mapped value wins over the automatic parser; a missing or null path leaves the default, so a source can mix OpenAI completions with proprietarycapture.paths. Raw SSE stored as text cannot be walked. Mappedcostwins overcost_per_token; ifcostis unset or misses,cost_per_tokenstill estimates from the final token counts. -
proxyhas no management hop. It captures fromvia.route/via.routesand must not setservice,endpoint, ormanagement_*; config validation rejects those.via.routesis only valid ontype: proxy. -
cost_per_tokenestimates spend. Optional,type: proxyonly (litellmand plugin sources already report spend and must not set it). Bothinputandoutputare required>= 0numbers when the block is present. A captured call getscost = promptTokens * input + completionTokens * outputwhen those usage fields exist; missing prompt or completion tokens leavecostunset (total-only usage is not enough). The inspector chip already showscostwhen it is set. -
Redaction uses the same
secretsdetector at upsert, and full prompts never go on the status snapshot. Usage keys (prompt_tokens,completion_tokens,total_tokens,max_tokens),token_type, and logprobtokenpieces stay visible. Credential-shaped values and keys such asapi_key/access_tokenare still masked.secrets.redact: falsestores new payloads unmasked. A streamed response carries token usage only when the caller setsstream_options.include_usage. TUI/revealunmasks service env only; it cannot restore a payload that was already redacted at ingest.
Each stored call has an optional caller — the service that issued the request, not the LLM source (platform, apigee-llm, …). On a type: proxy source the stored source name is the tagged route (often the Apigee/gateway route). Detail views label that via, so the gateway is not read as the caller. Attribution, in order:
-
X-Devctl-Service(orX-Devctl-Service-Name) on the inbound proxy request. Host processes already getDEVCTL_SERVICE_NAMEin their environment; send it as this header from the OpenAI / LiteLLM client. The proxy strips the header before forwarding so it never reaches the vendor. -
x-litellm-metadataJSON withservice/service_name/devctl_service. Not stripped — LiteLLM already uses this header. -
Loopback TCP peer. For traffic that hits a captured proxy route from
127.0.0.1/::1, devctl maps the client port to a managed process (pid, parent, or process group) when the request starts, while the socket is still up. Looking up after the response races the client close and leavescallerempty. On Linux this reads/procdirectly (/proc/net/tcp[6]for the socket owner,/proc/<pid>/statfor parent/group) so it works in a minimal container that ships neitherlsofnorps; macOS falls back tolsof/ps, Windows tonetstat. Remote peers are not looked up, so a coincidental local pid cannot be blamed. A client that runs in a different container or network namespace than the daemon (its socket owner is the runtime, not a managed process), or any non-loopback client, is best-effort — send the header ormetadata.service. -
Completion body
metadata.service/metadata.service_name/metadata.devctl_service(LiteLLM extra body), else a non-email OpenAIuser. -
LiteLLM spend logs:
metadata.service/metadata.service_name/metadata.devctl_service, elseuser/end_userwhen it is not an email.
TUI list shows caller next to status; detail has caller then via (proxy) or source (LiteLLM). Filter by caller everywhere: CLI devctl llm --caller worker, the TUI /caller worker command (LLM screen filters calls; proxy screen filters traffic hops), the web console caller dropdown, and MCP get_llm_calls's caller. Pass - (CLI also accepts none) to show only calls with no known caller.
Query stays on the store (RPC llm_calls_page / get_llm_call). Secrets are redacted again on MCP/web output.
| Surface | Entry |
|---|---|
| MCP |
get_llm_calls (filter + cursor) and get_llm_call in inspect. List pages omit bodies; detail includes redacted payloads. |
| Web |
#/llm and #/llm/:id — list plus live inspector (conversation transcript or request/response JSON, copy/find/wrap). Search matches stored bodies. Jump to a trace when trace_id is present. |
| TUI |
llm nav tab, /llm, enter for the full overlay; enter again jumps to a trace when traceId is present. The list shows caller, model, latency, and tokens; the inspector shows the selected call's conversation (or syntax-colored JSON) without leaving the screen. r toggles conversation vs JSON; the overlay JSON is a collapsible tree. |
| CLI |
devctl llm (filters including --caller, --json, --follow) and devctl llm show <id>. |
The proxy source buffers completion bodies only on the routes it is told to capture; all other proxy traffic still streams without buffering. Additional pull-style source types can plug in through LlmSourceFactory / plugin llmSources; a push source (like proxy) feeds the store directly rather than being polled.
Start
- How it fits together
- Installation
- Quick start
- Onboard your repository
- Examples & recipes
- Developer setup
- Agent skills
Use
Configure
Identity
Reference