Files
olp/lib/ir/ir-to-openai.mjs
T
taodengandClaude Opus 4.7 4b1a9c8808 fix(ir): D12 — remove invented top-level error field on OpenAI response shapes
cold-audit catch from 2026-05-23

Cold-audit Finding 3 (P2 ALIGNMENT.md Rule 2(b) violation). The IR→OpenAI
translator was inventing a top-level `error: {message, type}` field on
chat.completion / chat.completion.chunk objects. OpenAI's spec defines
no such field — errors surface via HTTP 4xx/5xx with `{error: {...}}`
body, not as in-band SSE annotations. The pre-D12 code comments
acknowledged Rule 2(b) for `finish_reason` enum values then proceeded
to invent the top-level error field anyway.

Changes (3 files, +25 / -50 net):

1. lib/ir/ir-to-openai.mjs — both invention sites removed:
   - irChunkToOpenAISSE: removed `if (irChunk.type === 'error')` branch
     that emitted `{...spec fields, error: {message, type: 'provider_error'}}`
   - irResponseToOpenAINonStream: removed `else if (chunk.type === 'error')`
     branch + dead `errorChunk` local var + the `if (errorChunk)` block
     that appended top-level `response.error = {...}`
   - Added explanatory comments at both sites citing ALIGNMENT.md Rule 2(b)
     + OpenAI spec URLs so future readers see the rationale

2. server.mjs:581 — burst-replay loop break condition simplified:
   - Pre: `if (irChunk.type === 'stop' || irChunk.type === 'error') break;`
   - Post: `if (irChunk.type === 'stop') break;`
   - Cache writes (server.mjs:456, :470) only append to streamedChunks
     after the error-chunk guard fires, so cached chunks structurally
     cannot contain type === 'error'. The removed clause was dead.

3. test-features.mjs — one test rewritten (test count unchanged 288 → 288):
   - Old test asserted `payload.error.type === 'provider_error'` exists
     (i.e., it tested the violation as if it were correct)
   - New test asserts `payload.error === undefined` (the correct Rule 2(b)
     invariant) and that object stays `chat.completion.chunk` with
     finish_reason in the OpenAI enum

IR contract decision (verified by both implementer and reviewer):
- IR keeps `'error'` in IRResponseChunk typedef — providers legitimately
  emit error chunks (e.g., anthropic.mjs)
- server.mjs intercepts error chunks BEFORE translation:
  · collectAllChunks() throws ProviderError on type === 'error'
  · Real-streaming branch throws ProviderError (no first chunk) or
    truncates res.end() (after first chunk per ADR 0004 first-chunk rule)
- The translator is downstream of both guards; error chunks structurally
  cannot reach it in normal operation
- Therefore the invention sites were dead-code paths handling an
  impossible case — safe to remove
- Pass-through behavior on the impossible path: produces an empty SSE
  delta `{choices: [{index: 0, delta: {}, finish_reason: null}]}`
  (verified manually by reviewer); the error message text does NOT
  leak to the wire

288/288 tests pass on Node 20.20.2.

Authority:
- ALIGNMENT.md Rule 2(b) — no invented OpenAI-spec fields
- OpenAI Chat Completions object spec
  https://platform.openai.com/docs/api-reference/chat/object
  https://platform.openai.com/docs/api-reference/chat/streaming
- ADR 0004 § Fallback safety — first-chunk rule (preserves the
  truncation semantics for post-first-chunk errors)

Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent
of drafter): APPROVE. Verified error chunks never reach translator via
three independent grep passes (collectAllChunks guard, real-streaming
guard, no third caller exists), verified cache cannot contain error
chunks (streamedChunks.push is post-guard), verified Rule 2(b) compliance
by direct read of ALIGNMENT.md line 29 + OpenAI spec citations.

Three non-blocking suggestions (defensive log on impossible path;
defensive guard at server.mjs:587; comment placement nit) explicitly
"Not for D12" per the reviewer — preserved as separate cleanup
opportunities if ever needed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 10:28:38 +10:00

172 lines
5.5 KiB
JavaScript

/**
* lib/ir/ir-to-openai.mjs — IR v1.0 → OpenAI Chat Completions response translation
*
* Authority: ADR 0003 § "Translation direction model" (symmetric)
* Entry-surface authority: OpenAI Chat Completions API response shape
* https://platform.openai.com/docs/api-reference/chat/object
* https://platform.openai.com/docs/api-reference/chat/streaming
*
* Produces OpenAI-shaped responses from IR response chunks so that the
* entry surface (server.mjs) can emit them to clients without knowing
* which provider generated them.
*/
import { randomBytes } from 'node:crypto';
// ── ID generation ─────────────────────────────────────────────────────────
/**
* Generates a random chat-completion request ID.
* OpenAI format: chatcmpl-<alphanumeric>
* @returns {string}
*/
export function generateRequestId() {
return `chatcmpl-${randomBytes(12).toString('base64url')}`;
}
// ── Streaming translation ─────────────────────────────────────────────────
/**
* Converts a single IRResponseChunk to an OpenAI SSE event string.
*
* Per OpenAI streaming spec, each chunk is a `chat.completion.chunk` object
* with a `choices[0].delta` field.
*
* @param {import('./types.mjs').IRResponseChunk} irChunk
* @param {string} requestId - from generateRequestId()
* @param {string} model - the model string from the IR request
* @returns {string} SSE line in the form `data: {...}\n\n`
*/
export function irChunkToOpenAISSE(irChunk, requestId, model) {
const created = Math.floor(Date.now() / 1000);
if (irChunk.type === 'stop') {
const chunk = {
id: requestId,
object: 'chat.completion.chunk',
created,
model,
choices: [{
index: 0,
delta: {},
finish_reason: irChunk.finish_reason ?? 'stop',
}],
};
// Include usage if the provider surfaced token counts on the final chunk
if (irChunk.usage) {
chunk.usage = irChunk.usage;
}
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 = {};
if (irChunk.role !== undefined) {
delta.role = irChunk.role;
}
if (typeof irChunk.content === 'string' && irChunk.content !== '') {
delta.content = irChunk.content;
} else if (irChunk.content === '') {
// Empty string delta is valid — pass through (first chunk often role-only + empty content)
delta.content = '';
}
if (Array.isArray(irChunk.tool_calls) && irChunk.tool_calls.length > 0) {
delta.tool_calls = irChunk.tool_calls;
}
const chunk = {
id: requestId,
object: 'chat.completion.chunk',
created,
model,
choices: [{
index: 0,
delta,
finish_reason: null,
}],
};
return `data: ${JSON.stringify(chunk)}\n\n`;
}
/** SSE stream terminator per OpenAI spec */
export const SSE_DONE = 'data: [DONE]\n\n';
// ── Non-streaming translation ─────────────────────────────────────────────
/**
* Assembles a non-streaming OpenAI chat.completion object from an array of
* IR response chunks (all chunks already collected from the provider).
*
* @param {import('./types.mjs').IRResponseChunk[]} irChunks
* @param {string} requestId
* @param {string} model
* @returns {object} OpenAI chat.completion object
*/
export function irResponseToOpenAINonStream(irChunks, requestId, model) {
let content = '';
let finish_reason = 'stop';
let usage = null;
const tool_calls = [];
for (const chunk of irChunks) {
if (chunk.type === 'delta') {
if (typeof chunk.content === 'string') {
content += chunk.content;
}
if (Array.isArray(chunk.tool_calls)) {
tool_calls.push(...chunk.tool_calls);
}
} else if (chunk.type === 'stop') {
if (chunk.finish_reason) {
finish_reason = chunk.finish_reason;
}
if (chunk.usage) {
usage = chunk.usage;
}
}
// 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 = {
role: 'assistant',
content: content || null,
};
if (tool_calls.length > 0) {
message.tool_calls = tool_calls;
if (!content) message.content = null;
}
const response = {
id: requestId,
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model,
choices: [{
index: 0,
message,
finish_reason,
}],
};
if (usage) {
response.usage = usage;
}
return response;
}