Skip to content

server: generate asks the runner for pass-one metrics too - #271

Merged
glennneuber merged 1 commit into
mainfrom
feat/generate-intermediate-metrics
Sep 4, 2026
Merged

server: generate asks the runner for pass-one metrics too#271
glennneuber merged 1 commit into
mainfrom
feat/generate-intermediate-metrics

Conversation

@glennneuber

Copy link
Copy Markdown

Stacked on the v0.33.3 fold branch (#264) because the plumbing it uses arrives with the fold; GitHub retargets this to main when #264 merges. Not to be merged before #264.

Upstream v0.33.3 added llm.CompletionRequest.IncludeIntermediateMetrics and the fold wired it into ChatHandler (reportedPassMetrics() preferred over ADR 0010's textual reconstruction at the thinking→content transition). GenerateHandler has the same structured-outputs double request and the same cancellation problem but neither upstream nor the fold wired it there, so /api/generate still reconstructed pass-one metrics by tokenizing text — blind to image-embedding tokens, the exact gap ADR 0010 documents.

Change (server/routes.go, +50/−21), mirroring ChatHandler line for line:

  • includeIntermediateMetrics := req.Format != nil && currentFormat == nil, computed after the deferring block (so it rides PR server: chat transition restarts require a deferred pass one #238's gate exactly as chat's does), set on the llm.CompletionRequest.
  • Metrics hoisted out of the response literal; the latest report is kept as firstPassMetrics and non-terminal chunks are blanked, so no mid-stream counter leaks into streamed /api/generate chunks.
  • Transition site: pass1 = reportedPassMetrics(firstPassMetrics), falling back to transitionPassMetrics(...); transitionPromptDelta is still computed only from a reconstructed pass (it needs the textual count on both sides). The done-time arithmetic (ADR 0004 summing, ADR 0010 derivation) is byte-identical.

Tests (server/routes_generate_test.go, +175): TestGenerateThinkFormatTransitionMetrics unchanged and green (its mock reports nothing, so it still exercises the reconstruction fallback). New TestGenerateThinkFormatTransitionMetricsReportedPassOne — streamed, one nemotron3 image (2042 tokens): pass one emits a non-terminal chunk carrying its cache-inclusive prefill (textual + 2042), then blocks until the transition cancels it; asserts pass one requested per-token metrics and pass two did not, every pre-done chunk carries no metrics, the folded prompt_eval_count equals pass one's own prefill and strictly exceeds the textual count, and eval_count/durations sum per ADR 0004. Negative controls run and reverted: removing the blanking fails on a leaked chunk; removing the reportedPassMetrics preference fails on eval_count/durations (the prompt count alone cannot discriminate, by design — same as the chat twin).

ADR 0010's status bullet gains a dated line saying both handlers now prefer the runner's reported pass-one metrics, reconstruction as fallback.

Verified (golang:1.26 container, -u 1000:1000): go build ./... && go test -count=1 ./server/ ./llm/ ./api/ all ok; go test -race -run ThinkFormat ./server/ 9/9; gofumpt clean; golangci-lint at repo root 0 issues.

Follow-up for a decision, deliberately not made here: Chat's fold re-attributes PromptEvalCachedCount from pass one (res.Metrics.PromptEvalCachedCount = pass1.PromptEvalCachedCount); Generate's fold never did, so /api/generate still reports the continuation's cached count. Pre-existing, one line to close, but it is a fold-arithmetic change and out of this PR's scope.

🤖 Generated with Claude Code

Upstream's v0.33.3 `IncludeIntermediateMetrics` was folded in for
ChatHandler only. `/api/generate`'s structured-outputs double request
therefore still reconstructed pass-one metrics by tokenizing text at the
transition site, and a textual count cannot see image-embedding tokens --
the exact gap ADR 0010 documents (~2042 tokens per nemotron3 image, 256+
per gemma4 image). On a vision request the two endpoints disagreed on
`prompt_eval_count` for the same work, and `eval_count` came from
re-tokenized chunk text rather than the runner's own count.

GenerateHandler now mirrors ChatHandler exactly: `includeIntermediateMetrics
:= req.Format != nil && currentFormat == nil` is computed after the
deferring block, set on the `llm.CompletionRequest`, and the callback keeps
the latest report in `firstPassMetrics` while blanking the non-terminal
copies, so no mid-stream counter leaks into a streamed `/api/generate`
chunk. At the transition site `reportedPassMetrics(firstPassMetrics)` is
preferred and `transitionPassMetrics()` stays as the fallback for runners
and paths that report nothing.

What stays: the done-time fold is untouched -- ADR 0004's summing (each
pass's prefill counts as prefill, each pass's decode as decode) and ADR
0010's `transitionPromptDelta` derivation, still computed only from a
reconstructed pass because it needs a textual count on both sides. The
runner side is untouched; both engines already honour the flag.
`TestGenerateThinkFormatTransitionMetrics` keeps pinning the reconstruction
fallback (its mock reports nothing on pass one);
`TestGenerateThinkFormatTransitionMetricsReportedPassOne` is the new
vision-shaped, streamed twin of the chat test -- pass one reports a
cache-inclusive prefill carrying an image surplus, the folded counts must
carry it, and the chunks before done must carry no metrics at all.

ADR 0010 gains a dated status line; its decision is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@glennneuber
glennneuber changed the base branch from task/upstream-sync-0.33.3 to main September 4, 2026 11:17
@glennneuber
glennneuber merged commit fa29a87 into main Sep 4, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant