diff --git a/lib/ir/ir-to-openai.mjs b/lib/ir/ir-to-openai.mjs index 856cc87..3b3bd3a 100644 --- a/lib/ir/ir-to-openai.mjs +++ b/lib/ir/ir-to-openai.mjs @@ -59,29 +59,14 @@ export function irChunkToOpenAISSE(irChunk, requestId, model) { return `data: ${JSON.stringify(chunk)}\n\n`; } - if (irChunk.type === 'error') { - // SSE error chunk. ALIGNMENT.md Rule 2 (b) forbids inventing - // `finish_reason` values not in OpenAI's enum - // (https://platform.openai.com/docs/api-reference/chat/streaming - // enumerates: stop, length, tool_calls, content_filter, function_call, - // null). Surface the error via the top-level `error` object and use - // finish_reason: 'stop' on the choice — clients that respect the - // enum see a valid terminator; clients that read the `error` field - // see the failure detail. - const chunk = { - id: requestId, - object: 'chat.completion.chunk', - created, - model, - choices: [{ - index: 0, - delta: { content: '' }, - finish_reason: 'stop', - }], - error: { message: irChunk.error ?? 'Unknown provider error', type: 'provider_error' }, - }; - return `data: ${JSON.stringify(chunk)}\n\n`; - } + // Note: type === 'error' is intentionally not handled here. + // Error chunks are converted to thrown ProviderError in server.mjs before + // they reach this translator (see collectAllChunks and the real-streaming + // error path). Per ALIGNMENT.md Rule 2 (b), adding a top-level `error` + // field on chat.completion.chunk objects is a spec violation — OpenAI errors + // surface via HTTP 4xx/5xx with {error: {...}} body, not in-band SSE fields. + // If an error chunk somehow reaches here it falls through to the delta path + // which is a no-op (no content/role/tool_calls), producing an empty delta. // type === 'delta' const delta = {}; @@ -131,7 +116,6 @@ export function irResponseToOpenAINonStream(irChunks, requestId, model) { let content = ''; let finish_reason = 'stop'; let usage = null; - let errorChunk = null; const tool_calls = []; for (const chunk of irChunks) { @@ -149,17 +133,13 @@ export function irResponseToOpenAINonStream(irChunks, requestId, model) { if (chunk.usage) { usage = chunk.usage; } - } else if (chunk.type === 'error') { - // Surface provider errors via the top-level `error` annotation on the - // response object below + an inline content marker. `finish_reason` - // stays 'stop' because ALIGNMENT.md Rule 2 (b) forbids inventing - // enum values OpenAI's spec does not define - // (https://platform.openai.com/docs/api-reference/chat/object — - // finish_reason ∈ {stop, length, tool_calls, content_filter, - // function_call, null}). - content += chunk.error ? `[provider error: ${chunk.error}]` : '[provider error]'; - errorChunk = chunk; } + // Note: type === 'error' is intentionally not handled here. + // Error chunks are converted to thrown ProviderError in server.mjs + // (collectAllChunks) before this function is ever called. Adding a + // top-level `error` field on chat.completion objects violates + // ALIGNMENT.md Rule 2 (b) — OpenAI errors surface via HTTP 4xx/5xx + // with {error: {...}} body, not as invented response fields. } const message = { @@ -187,12 +167,5 @@ export function irResponseToOpenAINonStream(irChunks, requestId, model) { response.usage = usage; } - if (errorChunk) { - response.error = { - message: errorChunk.error ?? 'Unknown provider error', - type: 'provider_error', - }; - } - return response; } diff --git a/server.mjs b/server.mjs index 8f764c0..d983816 100644 --- a/server.mjs +++ b/server.mjs @@ -578,7 +578,7 @@ async function handleChatCompletions(req, res) { for (const irChunk of chunks) { res.write(irChunkToOpenAISSE(irChunk, requestId, ir.model)); - if (irChunk.type === 'stop' || irChunk.type === 'error') break; + if (irChunk.type === 'stop') break; } res.write(SSE_DONE); res.end(); diff --git a/test-features.mjs b/test-features.mjs index f3a8b3f..ccd02ca 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -357,17 +357,19 @@ describe('irChunkToOpenAISSE format', () => { assert.deepEqual(payload.choices[0].delta, {}); }); - it('formats an error chunk with finish_reason within the OpenAI enum', () => { - // ALIGNMENT.md Rule 2 (b): finish_reason must stay within the OpenAI spec - // enum (stop|length|tool_calls|content_filter|function_call|null). - // Provider errors surface via the top-level `error` object, not via an - // invented finish_reason value. + it('does not invent a top-level error field on error chunks (ALIGNMENT.md Rule 2(b))', () => { + // ALIGNMENT.md Rule 2 (b): OLP must not introduce OpenAI-spec fields that + // OpenAI's /v1/chat/completions specification does not document. + // OpenAI chat.completion.chunk objects have no top-level `error` field. + // Provider errors surface via HTTP 4xx/5xx, not as in-band SSE fields. + // Error chunks should not reach the translator in normal operation — + // server.mjs converts them to thrown ProviderError before translation. + // If one somehow does reach here, no `error` field must be invented. const sse = irChunkToOpenAISSE({ type: 'error', error: 'spawn failed' }, ID, MODEL); const payload = JSON.parse(sse.slice(6).trim()); - assert.ok(payload.error); - assert.equal(payload.error.type, 'provider_error'); + assert.equal(payload.error, undefined, 'translator must not invent top-level error field'); + assert.equal(payload.object, 'chat.completion.chunk'); assert.ok(['stop', 'length', 'tool_calls', 'content_filter', 'function_call', null].includes(payload.choices[0].finish_reason)); - assert.equal(payload.choices[0].finish_reason, 'stop'); }); it('SSE_DONE is the [DONE] terminator', () => {