fix(aibridge): record token usage without an MCP proxier - #27886
Conversation
Streaming Responses interceptions called recordTokenUsage from inside the `i.mcpProxy != nil` branch, so a bridge built with a nil proxier served requests normally but recorded no token usage. Upstream reports usage on the response.completed event independently of tool injection. coderd/aibridged treats proxier construction failure as non-fatal and caches the resulting bridge, so a transient config-retrieval error suppressed usage recording for every streaming Responses request served by that bridge until its cache TTL expired. Record usage for every completed response, guarded only on completedResponse, matching responses/blocking.go and both chatcompletions implementations. Per-iteration semantics are preserved for the inner agentic loop. The integration harness substituted a non-nil noop manager whenever no proxier was supplied, so the nil path was never exercised. Add withoutMCP() to cover it. Fixes coder#27885
|
All contributors have signed the CLA ✍️ ✅ |
|
I have read the CLA Document and I hereby sign the CLA |
|
Hi @blockgroot, thank you for your contribution! Could you please double-check https://coder.com/docs/about/contributing/AI_CONTRIBUTING |
|
Thanks @johnstcn, and apologies for not including this up front. I've added a disclosure to the PR description per AI_CONTRIBUTING: this was investigated and drafted with Claude Opus 5, and I reviewed the change and ran the tests locally. Summarising the verification that's in the description:
One gap I should flag: I couldn't run Happy to make any changes that would help review, or to drop the harness option if you'd prefer the nil case covered a different way. |
johnstcn
left a comment
There was a problem hiding this comment.
Thanks for the update @blockgroot! I have some comments below.
| // withoutMCP runs the bridge with a nil MCP server proxier, matching a | ||
| // deployment where proxier construction failed and injection degraded. This | ||
| // differs from NoopMCPManager, which is non-nil and reports zero tools. | ||
| func withoutMCP() bridgeOption { | ||
| return func(c *bridgeConfig) { c.noMCPProxy = true } | ||
| } | ||
|
|
There was a problem hiding this comment.
Suggestion: since this is just used by one test, we could just inline it where it's used.
There was a problem hiding this comment.
Done in 0e81277. The option is now inlined at its only call site as func(c *bridgeConfig) { c.noMCPProxy = true }.
I kept the noMCPProxy field itself, since newBridgeTestServer still needs to distinguish "no proxier specified" from "explicitly nil" at setupbridge.go:156, and moved the explanation onto the field.
| require.Positive(t, bridgeServer.Recorder.TotalInputTokens()) | ||
| require.Positive(t, bridgeServer.Recorder.TotalOutputTokens()) |
There was a problem hiding this comment.
We should be comparing this with the token counts before the request.
There was a problem hiding this comment.
Done in 0e81277. The test now captures TotalInputTokens() and TotalOutputTokens() before the request and asserts the delta.
I also switched from require.Positive to the exact fixture totals, matching the style in TestOpenAIChatCompletions (bridge_internal_test.go:820), since that catches miscounting rather than just absence.
| // TestResponsesStreamingRecordsTokenUsagePerAgenticIteration asserts that | ||
| // decoupling token recording from the MCP proxier does not double-count usage | ||
| // when the inner agentic loop iterates. The injected-tool fixture drives two | ||
| // upstream calls, so two records are expected. | ||
| func TestResponsesStreamingRecordsTokenUsagePerAgenticIteration(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| bridgeServer, _, resp := setupInjectedToolTest( | ||
| t, | ||
| fixtures.OaiResponsesStreamingSingleInjectedTool, | ||
| true, | ||
| defaultTracer, | ||
| pathOpenAIResponses, | ||
| nil, | ||
| ) | ||
| defer resp.Body.Close() | ||
|
|
||
| usages := bridgeServer.Recorder.RecordedTokenUsages() | ||
| require.Len(t, usages, 2, "one token usage record per agentic iteration") | ||
| } |
There was a problem hiding this comment.
I'd suggest moving this test into TestOpenAIChatCompletions. Also, do we need an equivalent test for the Anthropic provider?
There was a problem hiding this comment.
Both addressed in 0e81277, though not quite as suggested. Two things:
On the agentic-iteration test: I removed it entirely rather than moving it. TestResponsesInjectedTool/streaming_success (responses_internal_test.go:872) already drives the same OaiResponsesStreamingSingleInjectedTool fixture and asserts an exact record count via require.Len(t, tokenUsages, len(tc.expectTokenUsages)) at :987, so the double-counting case was already covered. Mine was redundant.
On placement: I moved the remaining test into bridge_internal_test.go and deleted the standalone file. I didn't put it inside TestOpenAIChatCompletions because it exercises /openai/v1/responses and /anthropic/v1/messages rather than /openai/v1/chat/completions. Happy to move it if you'd still prefer it grouped there.
On Anthropic: added as a second case in the same table. Worth noting it passes with and without the fix, so it's a parity guard rather than a regression test. The Messages implementation records usage inside the SSE event switch at messages/streaming.go:269 (message_start) and :294 (message_delta), neither gated on the proxier. The mcpProxy checks in that function only decide whether events are relayed downstream, and they run after recording. So Anthropic never had this bug, but the case now pins that behaviour.
Inline the nil-proxier bridge option at its only call site and document the noMCPProxy field, which newBridgeTestServer still needs to tell "unset" apart from "explicitly nil". Compare token counts against the values recorded before the request, and assert the exact fixture totals rather than only that they are positive, matching TestOpenAIChatCompletions. Fold the standalone test file into bridge_internal_test.go and cover the Anthropic Messages route alongside OpenAI Responses. Drop the per-agentic-iteration test: TestResponsesInjectedTool/streaming_success already drives the same fixture and asserts an exact record count, so it covers the double-counting case.
|
@blockgroot Thanks for addressing the feedback! You may need to merge latest main to address the Storybook CI job. |
|
Updated, now at 07d29f2. Storybook still fails with the same PIXEL_KEY error. Its job spec, pixel.jsonc, and the pixel-storybook version are unchanged on main, and fork PRs don't get repo secrets, so this isn't a staleness issue. May need a fork exception in that job. Also, ci is gated on action_required, likely pending maintainer approval. |
Disclosure: investigated and drafted with Claude Opus 5. I reviewed the change and ran the tests locally.
Streaming Responses interceptions recorded no token usage when the bridge was built with a nil
mcp.ServerProxier, becauserecordTokenUsagewas called from inside thei.mcpProxy != nilbranch inaibridge/intercept/responses/streaming.go. Requests completed normally and returned200, so traffic was served but metered as zero, with no error surfaced. Upstream reports usage on theresponse.completedevent independently of tool injection, so the proxier is not a valid precondition for recording it.That state is reachable in production:
coderd/aibridged/pool.go:255-265treats proxier construction failure as non-fatal ("MCP server injection can gracefully degrade") and caches the resulting bridge viaSetWithTTL, so one transient config-retrieval error suppressed usage recording for every streaming Responses request served by that bridge until its TTL expired.This records usage for every completed response, guarded only on
completedResponse, matchingresponses/blocking.goand bothchatcompletionsimplementations. Per-iteration semantics are preserved for the inner agentic loop.The integration harness substituted a non-nil noop manager whenever no proxier was supplied (
setupbridge.go:153-155), so the nil path was never exercised.withoutMCP()covers it.Verification, with the fix reverted:
and with it applied:
The per-iteration case asserts exactly two records for the injected-tool fixture, so decoupling the call from the proxier does not double-count when the agentic loop iterates.
go test -race ./aibridge/...passes across all 15 packages.Fixes #27885
One caveat on verification: I was unable to run
make gen/make pre-commitlocally, as I do not have the full mise toolchain installed. The change touches no codegen inputs (no SQL, protos, mocks, or TypeScript), so I do not expect generated-file drift, but flagging it rather than leaving it implied.