Usage metering (gateway sidecar)
Decision (operating model): model gateways (e.g. LiteLLM) are BYO — a workspace/harness points at its own
endpoint; Everdict does not mandate one. Budget stays Everdict-owned (BudgetTracker). To still learn what a
black-box harness spent (aider with trace:none reports nothing), Everdict can put a tiny usage proxy in
front of the BYO endpoint and recover token usage per run.
Why a proxy (evidence)
We probed the workclaw LiteLLM directly:
chatgpt/gpt-5.4-miniis a ChatGPT-subscription model → LiteLLM has no price → per-keyspend = 0, and/spend/logsis empty. So "read cost from the gateway" yields 0 for these models.- But every call's response carries
usage(prompt/completion/total tokens) — e.g. 1644 tokens — in the body, and the per-call cost in a response header (x-litellm-response-cost/x-litellm-response-cost-original;0.0for subscription models, a real$for metered ones). The only place tokens/cost exist for a black-box harness is the response, which the harness discards. A forwarding proxy that Everdict owns can read both. (This is the "uniform cost/token capture via an LLM-proxy" from the original architecture.) Tokens always;$when the gateway prices the model (subscription →$0).
Mechanism (@everdict/trace)
createUsageProxy({ upstreamBaseUrl, runHeader?, defaultRunId?, tally? }) → { server, tally } (and
startUsageProxy(...) → listens on 127.0.0.1:0 → { url, tally, close }):
- A reverse proxy: forwards
/v1/*to the BYO upstream verbatim (request + response bodies unchanged). - On each JSON response it parses
usage(extractUsage, body) and the cost header (costFromHeaders) and tallies by run — run id from thex-everdict-runheader (stripped before forwarding, never leaks upstream) ordefaultRunId(per-run proxy instance).inMemoryUsageTally()keeps{promptTokens, completionTokens, totalTokens, usd, calls}per run.
Wired into the run lifecycle (per-workspace / per-run)
The proxy lives in the agent's sandbox, on localhost — so it works on every backend (Local/Nomad/K8s)
without any cross-network reconfiguration (the agent→upstream path is the one that already works).
Exception — containerized image-cases on a self-hosted runner (DockerDriver): there the agent process runs on
the runner HOST while the child runs in a container, so the loopback proxy is unreachable from the child; leaving
metering on would rewrite the child's model base URL to a dead endpoint and kill every model call. runCaseJob
disables metering fail-safe for containerize jobs (warn logged) — meter those runs via trace instrumentation
(trace: otel/mlflow) instead:
- Control plane decides whether to meter a run and sets
CaseJob.meterUsage(authoritative). Resolution inRunService(async): per-run override (POST /runsbodymeterUsage) → per-workspace policy (meterUsageFor(tenant)) →false.main.tswires the policy as durable per-workspace settings → env fallback:(await settingsStore.get(tenant))?.meterUsage ?? envPolicy(tenant), where theWorkspaceSettingsStore(@everdict/db, InMemory/Pg, tableeverdict_workspace_settings) is managed by admins viaPUT/GET /workspace/settings(settings:write/settings:read, admin-only), andenvPolicyis the default fromEVERDICT_METER_TENANTS(comma list) orEVERDICT_METER_USAGE=1(all). runCaseJobusesjob.meterUsage(falls back to theEVERDICT_METER_USAGEenv only for directLocalBackend.dispatchwith no control plane) → passesmeterUsagetomakeHarness.CommandHarness.run(only whentrace:none+ the model-base env var is present — avoids double-counting a harness that already reports its own cost) starts a per-runstartUsageProxy(upstream = OPENAI_API_BASE), rewritesOPENAI_API_BASEto the proxy, runs the command (aider/any CLI — zero harness code), then emits the captured tokens and cost as a syntheticllm_calltrace event (cost: { inputTokens, outputTokens, usd }—usdfrom the gateway cost header,0for subscription models).- That event rides
runCase→result.trace, so the existing path settles it:RunService.trackalready doesbudget.settle(tenant, costOf(result))and persistsresultin theRunStore. No RunService change. - Surfaced on the run record:
RunStoreget/list/update returnRunRecord.usage({promptTokens, completionTokens, totalTokens, usd, calls}), derived fromresult.traceviausageFromTrace(@everdict/domain) on read — no column, no migration, always consistent. Clients (API/MCP/web) readrecord.usagewithout parsing the trace.
Verified
- Deterministic (
packages/trace/src/usage-proxy.test.ts):extractUsage(incl.totalfallback, null on no-usage/non-JSON);costFromHeaders(both header names, non-numeric → 0); proxy passthrough (body unchanged), per-run token and$accumulation (cost header →usd), run header not leaked upstream, header-less →default. - Deterministic (
packages/harnesses/src/command.test.ts):meterUsagerewrites the base to the proxy, emits the syntheticllm_callwith the captured tokens andusd, and closes the proxy; not metered whentrace≠none. - Deterministic (
packages/application-control/src/run/run-service.test.ts): resolution order — per-run override > per-workspace policyoff — and the decided value is carried on
CaseJob.meterUsage. - Live proxy (
scripts/live/usage-proxy.mjs) vs real workclaw LiteLLMgpt-5.4-mini:run-A= 2 calls / 3276 tokens,run-B= 1 call / 1642 tokens — captured while responses pass through intact. - Live lifecycle (
scripts/live/usage-proxy-run.mjs): acommandharness dispatched viaLocalBackendwithEVERDICT_METER_USAGE=1→result.tracecarriesllm_call{inputTokens: 1637, outputTokens: 6, usd: 0}→sumCost = { usd: 0, tokens: 1643 }(the exact valuebudget.settlereceives). Subscription model =$0, yet tokens are metered.
Management surfaces (admin)
- HTTP:
PUT/GET /workspace/settings(settings:write/settings:read). - Web:
/dashboard/settingstogglesmeterUsage(@/features/workspace-settings,can()-gated). - MCP:
get_workspace_settings/set_workspace_settingstools (apps/api/src/mcp.ts, admin-gated, workspace-scoped) — full BFF↔MCP parity.
Not yet (next)
- Note:
$capture is live-ready but reads0on workclaw's LiteLLM because its models are subscription (unpriced); it yields real$for any metered model the gateway prices.