diff --git a/ai/learnings/ERRORS.md b/ai/learnings/ERRORS.md index f5967063..2e5a508a 100644 --- a/ai/learnings/ERRORS.md +++ b/ai/learnings/ERRORS.md @@ -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. diff --git a/packages/service/src/routes/openai.test.ts b/packages/service/src/routes/openai.test.ts index 0fce79e2..7c5b87a2 100644 --- a/packages/service/src/routes/openai.test.ts +++ b/packages/service/src/routes/openai.test.ts @@ -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 ─────────────────────────────────────────────────────── diff --git a/packages/service/src/routes/openai.ts b/packages/service/src/routes/openai.ts index 12b7566f..3e7386ce 100644 --- a/packages/service/src/routes/openai.ts +++ b/packages/service/src/routes/openai.ts @@ -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, ); @@ -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 }>;