Skip to content

fix(service): honour x-routerly-no-trace header in streaming sse output#111

Closed
AmSach wants to merge 1 commit into
Inebrio:developfrom
AmSach:fix/110-x-routerly-no-trace-suppression
Closed

fix(service): honour x-routerly-no-trace header in streaming sse output#111
AmSach wants to merge 1 commit into
Inebrio:developfrom
AmSach:fix/110-x-routerly-no-trace-suppression

Conversation

@AmSach

@AmSach AmSach commented Jun 28, 2026

Copy link
Copy Markdown

Summary

Fixes #110 — the documented x-routerly-no-trace request header was never read, so emit() in packages/service/src/routes/openai.ts unconditionally wrote {"type":"trace",...} SSE frames into the wire stream. Strict OpenAI-compatible clients (@ai-sdk/openai-compatible used by opencode, openai npm package in strict mode, etc.) validate the first data: line as a ChatCompletionChunk with a choices[] array and abort the stream as soon as they hit a trace event.

Root cause

// before — packages/service/src/routes/openai.ts:228-230
const emit = (entry: TraceEntry) => {
  appendTrace(traceId, [entry]);
  reply.raw.write(`data: ${JSON.stringify({ type: 'trace', entry })}\n\n`);
};

The header check was completely missing, despite the source code having been documented to support it.

Fix

// after
const suppressTrace = (request.headers['x-routerly-no-trace'] as string | undefined) === '1';

const emit = (entry: TraceEntry) => {
  appendTrace(traceId, [entry]);
  if (!suppressTrace) {
    reply.raw.write(`data: ${JSON.stringify({ type: 'trace', entry })}\n\n`);
  }
};
  • The trace entry is still recorded in the in-memory traceStore via appendTrace(...) — dashboard and debug surface unaffected.
  • The header value is read once per request (next to the existing x-routerly-conversation-id read at line 119), matching the existing header-parsing pattern in the file.
  • traceId and the in-memory trace history remain accessible via the x-routerly-trace-id response header.

Tests

Two new tests added in packages/service/src/routes/openai.test.ts:

  1. suppresses trace events from sse stream when x-routerly-no-trace: 1 is sent — sends the header, triggers the all-candidates-exhausted path (which calls emit() directly via the route handler), and asserts the SSE body does NOT contain "type":"trace".
  2. emits trace events on sse stream when x-routerly-no-trace header is absent — companion test that confirms trace events ARE still written to the wire by default, so the suppression is header-gated (not unconditional).

Both tests were verified to:

  • Pass with the fix applied.
  • The suppression test fails with expected 'data: {"type":"trace",... not to contain '"type":"trace"' when the fix is reverted — confirming it actually catches the bug.

Verification

  • npm run typecheck --workspace=packages/service — clean
  • npm test --workspace=packages/service50/50 test files, 1079/1079 tests pass (was 1077, +2 new tests, no regressions)

Files changed

  • packages/service/src/routes/openai.ts (+4 / −1): one header read, one if (!suppressTrace) guard around the SSE write.
  • packages/service/src/routes/openai.test.ts (+39): two new regression tests.
  • ai/learnings/ERRORS.md: ERR-20260628-001 entry.

Diff summary

 packages/service/src/routes/openai.test.ts | 39 ++++++++++++++++++++++++++++++
 packages/service/src/routes/openai.ts      |  5 +++-
 2 files changed, 43 insertions(+), 1 deletion(-)

Fixes #110

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

the x-routerly-no-trace request header was documented but never read.
emit() in packages/service/src/routes/openai.ts unconditionally wrote
{"type":"trace",...} sse frames into the wire stream, breaking strict
openai-compatible clients (@ai-sdk/openai-compatible, openai strict mode, etc.)
that validate the first data: line as a ChatCompletionChunk with a choices[] array.

this affected every stream request through opencode, vercel ai sdk, and any
sdk that does schema validation on sse frames — see issue Inebrio#110.

fix: read the header near the other header reads and gate the
reply.raw.write call. trace entries are still recorded in the in-memory
traceStore via appendTrace(traceId, [entry]) so the dashboard / debug
surface is unaffected.

closes Inebrio#110

Signed-off-by: Aman Sachan <amansachan92905@gmail.com>
@AmSach
AmSach force-pushed the fix/110-x-routerly-no-trace-suppression branch from 2fd05cf to 441a591 Compare June 28, 2026 00:52

@carlosatta carlosatta left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good catch, and thanks for the thorough PR description.

Fix is correct and well-scoped.

  • The suppressTrace flag is read once from the header, placed correctly next to the existing x-routerly-conversation-id read — consistent with the pattern already in the file.
  • appendTrace(traceId, [entry]) is still called unconditionally, so the in-memory trace store is unaffected. Dashboard and debug surfaces work as before.
  • Both streaming endpoints (/v1/chat/completions and /v1/responses) go through the same handleOpenAICompletion function and share the single emit closure, so the fix covers both.
  • The non-streaming emit at the bottom of the function does not call reply.raw.write — not a concern.

Tests are well-designed. The all-candidates-exhausted path (via mockLlmStream.mockRejectedValue) is a reliable way to guarantee emit() fires on the wire path. The companion test confirming traces ARE emitted by default is the right pairing — it keeps the fix from silently becoming unconditional suppression in a future refactor.

Minor notes (non-blocking):

  1. The PR only adds the test against the error path. A test against a successful stream would give complete coverage of the guard, but it would require a more complex mock setup and is not strictly necessary since the guard is inside the closure and applies to all callers.
  2. The PR description mentions the Anthropic route potentially being affected — it is not. The emit in anthropic.ts only calls appendTrace, never reply.raw.write, so no SSE frames leak there.

Looks good to merge. I am targeting this for the 0.3.0 release.

@carlosatta

Copy link
Copy Markdown
Contributor

Thank you for this contribution, Aman — you correctly identified and fixed the issue.

After reviewing the diff, we found that the same fix had already been applied to release/0.3.0 in commit 70f5f18 before this PR was opened, so merging would create a conflict. We're closing this to keep the history clean.

Your root cause analysis and the learning document in ai/learnings/ERRORS.md are spot on and appreciated.

@carlosatta carlosatta closed this Jul 3, 2026
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.

[Bug] SSE Trace Events Break OpenAI-Compatible Streaming Clients (e.g. opencode agent)

3 participants