feat(ai): add a uniform usage telemetry channel (Phase 1: the generic seam) - #680
Merged
Merged
Conversation
Providers surface no token usage today, so nothing downstream can do cost math. This adds the generic, provider-agnostic seam for it — no provider run-fn is touched, so every provider still reports no usage and behavior is unchanged. - `Usage` in StreamTypes: normalized input/output/cached/cacheWrite/ reasoning/total counters plus an `extra` bag. Every counter is `number | undefined`, where `undefined` means "not reported" and is deliberately distinct from `0` — collapsing the two understates spend. - Transport: an optional `usage` SIBLING of `data` on `StreamFinish`. A sibling keeps both streaming-convention exceptions intact (one-shot `finish.data` is the whole Output; json-mode `finish.data.object` is the parsed object) and survives the worker boundary with no protocol change. - Surfacing mirrors the existing `refusal` precedent: a reserved output key that is never declared on an outputSchema and never rides a dataflow edge. - `mergeUsage` composes multi-turn cost consumer-side (agent-loop turns, structured-generation validation retries), so providers stay stateless. - CacheCoordinator strips `usage` on save: a cache hit genuinely costs zero tokens, so replaying stored counts would invent spend that never happened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PwyJuFrJnibKvrrk8Fa4Fn
Coverage Report
File CoverageNo changed files found. |
recordUsageTelemetry was wired only into AiTask.execute, but StreamingAiTask overrides executeStream and never routes through it — so every streaming AI task (structured generation, KB chat, streaming text generation) reported no usage at all. StreamingAiTask now sums the finish events' usage and records it once the stream drains. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PwyJuFrJnibKvrrk8Fa4Fn
StructuredGenerationTask closes the generator as soon as it sees finish, so the trailing telemetry call never ran for the most cost-sensitive task. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PwyJuFrJnibKvrrk8Fa4Fn
This was referenced Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #624 — Phase 1 of 2 (the generic seam only; no provider run-fn is touched).
Providers surface no token usage today, so nothing downstream can compute cost. This lands the provider-agnostic channel end to end and leaves the per-provider fill-in to Phase 2. Every provider currently emits
usage: undefined(none sets the new field), so this is purely additive with no behavior change: absent usage leaves no key on any output, and all existing streaming/caching semantics are untouched.The four design decisions
1. Shape — a normalized
Usage, whereundefined ≠ 0Usage(inpackages/task-graph/src/task/StreamTypes.ts) carriesinput,output,cached,cacheWrite,reasoning,total, plus anextrabag for provider-specific counters with no normalized slot.Every counter is
number | undefined, andundefinedmeans "the provider did not report this", never "zero". This is the load-bearing detail for cost math: a model that billed 0 cached tokens and a model that never tells you about caching are different facts, and collapsing them to0silently understates spend.mergeUsagepreserves the distinction field-wise (undefined + undefined === undefined,undefined + 5 === 5).It lives in
task-graphbeside the other canonical stream types (StreamFinishis defined there, not inpackages/ai), and is re-exported from@workglow/aiso a provider package canimport type { Usage } from "@workglow/ai"without depending ontask-graphdirectly.2. Transport — a sibling field on
finishStreamFinishgains an optionalusage, as a sibling ofdata, never inside it.data?datais governed by the streaming conventions:{}for delta streams, the entire Output for one-shot run-fns, andfinish.data.objectfor json-mode. Folding token counts intodatawould corrupt all three at once.usagestream-event arm? Everyswitch (event.type)overStreamEventin the codebase and in downstream consumers would need a new case, and a stray usage event arriving mid-stream would have ambiguous ordering againstfinish. A sibling on the event that already means "this request is over" has exactly the right lifetime.AiProviderRunFnreturnsPromise<void>by explicit contract — there is no return value to put metadata on.WorkerManager.callWorkerRunFunction) forwards the whole event object, so a sibling field survives structured clone with no protocol change. Covered by a test.3. Surfacing — a reserved output key, mirroring
refusalThere is already a proven precedent for a non-port output field: refusal.
USAGE_OUTPUT_KEYfollows it exactly —refusalusageStreamEventAccumulatorapplyRefusal()applyUsage(), chained into all threematerialize()exitsStreamProcessorstream_endoutputSchema()The cache difference is deliberate. A refusal must not be memoized because it would replay as "the answer". Usage is different: the value is still worth caching, but token counts are a fact about one execution — serving that entry later costs zero tokens, so replaying the original counts would invent spend that never happened. So
CacheCoordinator.savestripsUSAGE_OUTPUT_KEYrather than bailing. This lands in the same commit as the accumulator/processor change, so there is no window in which cache hits report phantom tokens.Telemetry is recorded in one place —
AiTask.execute(), via a newpackages/ai/src/capability/UsageTelemetry.ts— so provider packages never touch telemetry directly (they also run inside workers, which have a separate registry and no main-thread telemetry provider). It records OTel gen-ai semantic-convention attributes (gen_ai.usage.input_tokens,…output_tokens,…cached_input_tokens,…cache_write_input_tokens,…reasoning_tokens,…total_tokens) and onegetLogger().debug(...). Guarded onisEnabledfollowing thetraced.tspattern, so attribute flattening is never paid for when nothing is collecting.ITelemetryProvideris unchanged —SpanAttributesalready accepts flat numbers.4. Multi-turn — aggregated consumer-side, keeping providers stateless
The no-accumulation rule means a provider must never buffer. So summing happens at the two places that already own multi-request control flow, both of which drop the sibling today:
AiChatWithKbTaskdeliberately swallows inner-turnfinishevents (the agent loop). It now merges each turn's usage into an accumulator and attaches the total to the outer finish, which already carries{ iterations }.StructuredGenerationTasksynthesizes a fresh finish across validation-retry attempts. It now sums across attempts — a rejected attempt is still a billed request, and without this a schema-flaky model would look exactly as cheap as a clean one.mergeUsagesums numericextrakeys and takes last-wins on string keys (a string is a label — service tier, cache key — not a counter).On output schemas
usageis not added to any task'soutputSchema()— reserved keys are undeclared by design, exactly like refusal. Checked theadditionalProperties: falseexposure explicitly: task output is never validated againstoutputSchemaat runtime (only input and config are), andTask.addInputcopies only keys the target declares as ports, sousagecannot reach a strict downstream input over a normal edge. Both are pinned by tests — one runsStructuredGenerationTask(whose output schema isadditionalProperties: false, required: ["object"]) with usage attached, and one asserts usage does not leak onto a downstream task's input.Phase 2 is deliberately deferred
Per-provider fill-in must land after #641 (
ai-provider-cache-checkpoints, 84 files) merges — that PR edits ~15 of the provider run-fns Phase 2 would touch. Phase 1 changes no provider file at all (all 16 files here are inpackages/ai,packages/task-graph,packages/test).Checked against #641's actual file list rather than assuming: it shares exactly 2 of my 16 files, and in both the change is a single line inside
getJobInput(sessionId: x→session: { sessionId: x }) —packages/ai/src/task/base/AiTask.tsgetJobInput, ~L314packages/ai/src/task/AiChatWithKbTask.tsgetJobInput, ~L368So the overlap is file-level only, with no overlapping hunks — these auto-merge. The deferral is about the ~15 provider run-fns Phase 2 needs, which this PR leaves untouched.
Because the seam is generic, the follow-up is mechanical: each run-fn maps its own finish payload into
Usageand attaches it to thefinishit already emits. Checklist:Usage already flows past the loop and is discarded — no request change needed
@workglow/anthropic—usage.input_tokens/output_tokens/cache_read_input_tokens→cached/cache_creation_input_tokens→cacheWrite@workglow/openai(Responses) —usage.input_tokens/output_tokens/input_tokens_details.cached_tokens/cache_write_tokens/output_tokens_details.reasoning_tokens@workglow/google-gemini—usageMetadata.promptTokenCount/candidatesTokenCount/cachedContentTokenCount@workglow/ollama— the final chunk's prompt/eval counts (exact field names to confirm against the SDK response at implementation time)Needs
stream_options: { include_usage: true }on the request@workglow/xai@workglow/deepseek@workglow/openrouter@workglow/llamacpp-serverNo usage available — leave unreported (
undefined, not0)@workglow/node-llama-cpp@workglow/huggingface-transformers@workglow/chrome-ai@workglow/tf-mediapipeTests
Driven entirely from synthetic streams (no provider emits usage yet), extending the existing suites rather than adding parallel ones —
collectStream.test.ts,StreamEventAccumulator.test.ts,StreamingAccumulation.test.ts,WorkerRunFn_roundtrip.test.ts,AiChatWithKbTask.test.ts,StructuredGenerationTask.test.ts.Covered: usage on finish surfaces as
result.usage; absent usage leaves no key at all; one-shot finish + usage keepsdataas the payload; delta-mode finish + usage keeps deltas winning; json-mode keepsdata.objectintact; usage and refusal coexist (a refused turn still reports its billed tokens);mergeUsageunit tests (undefined preservation both directions, extras merged with numeric summed / string last-wins, no input mutation); parity invariant —run()and the streaming path produce identical usage; usage is not resurrected from the output cache (and the stored entry has nousagekey); usage survives the worker boundary throughstructuredClone; a 2-turn agent loop and a 2-attempt structured-generation retry each aggregate correctly, with single-turn/single-attempt cases asserting no double-counting.Verification
All commands run on this branch at
5fff2be.bun run formatthen reordered imports in two files, so both affected sections were re-run afterwards to confirm the formatted tree is still green:🤖 Generated with Claude Code
https://claude.ai/code/session_01PwyJuFrJnibKvrrk8Fa4Fn
Generated by Claude Code