Thanks to visit codestin.com
Credit goes to github.com

Skip to content

fix(aibridge): record token usage without an MCP proxier - #27886

Merged
johnstcn merged 3 commits into
coder:mainfrom
blockgroot:fix/aibridge-responses-token-usage-without-mcp
Aug 12, 2026
Merged

fix(aibridge): record token usage without an MCP proxier#27886
johnstcn merged 3 commits into
coder:mainfrom
blockgroot:fix/aibridge-responses-token-usage-without-mcp

Conversation

@blockgroot

@blockgroot blockgroot commented Aug 5, 2026

Copy link
Copy Markdown

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, because recordTokenUsage was called from inside the i.mcpProxy != nil branch in aibridge/intercept/responses/streaming.go. Requests completed normally and returned 200, so traffic was served but metered as zero, with no error surfaced. Upstream reports usage on the response.completed event 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-265 treats proxier construction failure as non-fatal ("MCP server injection can gracefully degrade") and caches the resulting bridge via SetWithTTL, 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, 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 (setupbridge.go:153-155), so the nil path was never exercised. withoutMCP() covers it.

Verification, with the fix reverted:

--- FAIL: TestResponsesStreamingRecordsTokenUsageWithoutMCP/without_mcp_proxy
        Error: "[]" should have 1 item(s), but has 0
--- PASS: TestResponsesStreamingRecordsTokenUsageWithoutMCP/with_noop_mcp_proxy

and with it applied:

--- PASS: TestResponsesStreamingRecordsTokenUsageWithoutMCP/without_mcp_proxy
--- PASS: TestResponsesStreamingRecordsTokenUsageWithoutMCP/with_noop_mcp_proxy
--- PASS: TestResponsesStreamingRecordsTokenUsagePerAgenticIteration

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-commit locally, 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.

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
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@github-actions github-actions Bot added the community Pull Requests and issues created by the community. label Aug 5, 2026
@blockgroot

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

cdrci2 added a commit to coder/cla that referenced this pull request Aug 5, 2026
@blockgroot
blockgroot marked this pull request as ready for review August 5, 2026 12:11
@johnstcn

Copy link
Copy Markdown
Member

Hi @blockgroot, thank you for your contribution! Could you please double-check https://coder.com/docs/about/contributing/AI_CONTRIBUTING

@blockgroot

Copy link
Copy Markdown
Author

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:

  • TestResponsesStreamingRecordsTokenUsageWithoutMCP/without_mcp_proxy fails without the fix ("[]" should have 1 item(s), but has 0) and passes with it, while the with_noop_mcp_proxy control passes either way. The only difference between the two cases is whether the proxier is nil.
  • TestResponsesStreamingRecordsTokenUsagePerAgenticIteration asserts exactly two records for the injected-tool fixture, so moving the call out of the MCP branch does not double-count when the inner loop iterates.
  • go test -race ./aibridge/... passes across all 15 packages, and golangci-lint (v1.64.8, the version pinned in mise.toml), paralleltestctx, intxcheck and make lint/emdash are clean.

One gap I should flag: I couldn't run make gen / make pre-commit, as I don't have the full mise toolchain installed locally. The change touches no codegen inputs, so I don't expect drift, but I didn't want to imply I'd run checks that I hadn't.

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
johnstcn self-requested a review August 12, 2026 07:40

@johnstcn johnstcn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update @blockgroot! I have some comments below.

Comment on lines +124 to +130
// 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 }
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: since this is just used by one test, we could just inline it where it's used.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +49 to +50
require.Positive(t, bridgeServer.Recorder.TotalInputTokens())
require.Positive(t, bridgeServer.Recorder.TotalOutputTokens())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be comparing this with the token counts before the request.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +55 to +74
// 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")
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd suggest moving this test into TestOpenAIChatCompletions. Also, do we need an equivalent test for the Anthropic provider?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@johnstcn

Copy link
Copy Markdown
Member

@blockgroot Thanks for addressing the feedback! You may need to merge latest main to address the Storybook CI job.

@blockgroot

Copy link
Copy Markdown
Author

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.

@johnstcn
johnstcn merged commit 7a545b3 into coder:main Aug 12, 2026
27 of 28 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 12, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

community Pull Requests and issues created by the community.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: streaming Responses API records no token usage when no MCP proxier is configured

2 participants