Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions ai/learnings/ERRORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,37 @@ needs an import attribute of "type: json"
- Resolution: 2026-06-10 — both fixes applied

---

## [ERR-20260628-001] SSE trace events pollute OpenAI-compatible streaming output

**Logged**: 2026-06-28T00:31:00Z
**Priority**: high
**Status**: resolved (PR pending — issue #110)

### Summary
Routerly's `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. The `x-routerly-no-trace` request header was documented but not honoured.

### Root cause
`emit()` (line ~230) called `reply.raw.write(...)` directly with no header check.
The `x-routerly-no-trace` header existed in the docs but was never read from
`request.headers`.

### Fix
Read the header near the other header reads (line ~120) and gate the `reply.raw.write`
call. Trace entries are still recorded in the in-memory trace store via
`appendTrace(traceId, [entry])` so the dashboard / debug surface is unaffected.

### Regression coverage
- `it('suppresses trace events from SSE stream when x-routerly-no-trace: 1 is sent')`
- `it('emits trace events on SSE stream when x-routerly-no-trace header is absent')`
Both tests use the all-candidates-exhausted path which calls `emit()` directly
from the route handler, guaranteeing a trace event hits the wire.

### Lesson
When a header is documented as a feature, the implementation must read it. Add a
failing test that exercises the documented behaviour BEFORE adding the
implementation, so the missing wire-up is caught at the same time as the bug.
39 changes: 39 additions & 0 deletions packages/service/src/routes/openai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,45 @@ describe('POST /v1/chat/completions — streaming', () => {
expect(res.body).toContain('[DONE]')
expect(mockLlmStream).toHaveBeenCalledTimes(2)
})

it('suppresses trace events from SSE stream when x-routerly-no-trace: 1 is sent', async () => {
// All-candidates-exhausted path triggers emit() directly via the route handler
// (line ~320), guaranteeing the SSE wire contains a {"type":"trace"} event
// unless the header gates it.
mockRouteRequest.mockResolvedValue({ models: [{ model: 'openai/gpt-4o', weight: 1 }], trace: [] })
mockReadConfig.mockResolvedValue([testModel])
mockLlmStream.mockRejectedValue(new Error('upstream failed'))

const app = await buildApp()
const res = await app.inject({
method: 'POST', url: '/v1/chat/completions',
headers: { 'content-type': 'application/json', 'x-routerly-no-trace': '1' },
payload: JSON.stringify({ model: 'gpt-4o', stream: true, messages: [{ role: 'user', content: 'Hi' }] }),
})
await app.close()

expect(res.body).not.toContain('"type":"trace"')
expect(res.body).toContain('[DONE]')
})

it('emits trace events on SSE stream when x-routerly-no-trace header is absent', async () => {
// Companion test: confirms traces ARE written to the wire by default
// (sanity-check that suppressTrace is the only thing keeping them out).
mockRouteRequest.mockResolvedValue({ models: [{ model: 'openai/gpt-4o', weight: 1 }], trace: [] })
mockReadConfig.mockResolvedValue([testModel])
mockLlmStream.mockRejectedValue(new Error('upstream failed'))

const app = await buildApp()
const res = await app.inject({
method: 'POST', url: '/v1/chat/completions',
headers: { 'content-type': 'application/json' },
payload: JSON.stringify({ model: 'gpt-4o', stream: true, messages: [{ role: 'user', content: 'Hi' }] }),
})
await app.close()

expect(res.body).toContain('"type":"trace"')
expect(res.body).toContain('[DONE]')
})
})

// ─── POST /v1/responses ───────────────────────────────────────────────────────
Expand Down
5 changes: 4 additions & 1 deletion packages/service/src/routes/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export const openaiRoutes: FastifyPluginAsync = async (fastify) => {
const traceId = randomUUID();
setTrace(traceId, []);
const conversationId = (request.headers['x-routerly-conversation-id'] as string | undefined) || undefined;
const suppressTrace = (request.headers['x-routerly-no-trace'] as string | undefined) === '1';
const isMemoryEnabled = (project.policies ?? []).some(
(p: any) => p.type === 'llm' && p.enabled && p.config?.memory === true,
);
Expand Down Expand Up @@ -227,7 +228,9 @@ export const openaiRoutes: FastifyPluginAsync = async (fastify) => {

const emit = (entry: TraceEntry) => {
appendTrace(traceId, [entry]);
reply.raw.write(`data: ${JSON.stringify({ type: 'trace', entry })}\n\n`);
if (!suppressTrace) {
reply.raw.write(`data: ${JSON.stringify({ type: 'trace', entry })}\n\n`);
}
};

let sortedCandidates: Array<{ model: string; weight: number }>;
Expand Down