Files
olp/lib/ir/ir-to-openai.mjs
T
taodengandClaude Opus 4.7 ed82e65859 chore: D19 — cleanup batch (Findings 8 + 14 + 15 + D17 dead import)
cold-audit catch from 2026-05-23

Batched 4 small P3 mechanical cleanups per Iron Rule 11 IDR cleanup-batch
convention (all P3, all small, no semantic feature changes beyond defensive
validation).

Changes (7 files):

1. lib/ir/ir-to-openai.mjs (+30 / -3) — Finding 8 defensive validator:
   - Added OPENAI_FINISH_REASON_ENUM Set with the 6 spec-allowed values
     (stop / length / tool_calls / content_filter / function_call / null)
   - Added normalizeFinishReason(value) helper that returns value unchanged
     if in enum, else 'stop'
   - Routed both irChunkToOpenAISSE (streaming path) and
     irResponseToOpenAINonStream (non-stream path) through the helper
   - Bonus tightening (in-scope, same finish_reason concept):
     irResponseToOpenAINonStream's gate changed from `if (chunk.finish_reason)`
     (truthy check) to `if (chunk.finish_reason !== undefined)` so an
     explicit null (valid spec value meaning "still in progress") is no
     longer silently dropped by the truthy guard
   - Note: undefined → null collapse via `?? null` is unreachable in the
     current codebase (all provider plugins explicitly set finish_reason
     on stop chunks); defensive only against a future plugin that omits
     the field — documented inline

2. .github/workflows/alignment.yml (-16) — Finding 14 dead CI cleanup:
   - Removed `setup.mjs` from path triggers (push + pull_request) — the
     file does not exist in the repo
   - Removed the dead `KNOWN_PROVIDERS=(...)` bash array from job 1 and
     its comment block — no later step iterated over it, so the array
     was abandoned
   - LEFT untouched: the Node.js inline KNOWN_PROVIDERS array in the
     models-registry validation job — that one is actively consumed by
     the schema validation script

3. lib/providers/anthropic.mjs / codex.mjs / mistral.mjs (3 × 1 line) —
   Finding 15: removed unused `PROVIDER_ERROR_CODES` from import lines.
   Each line went from `import { ProviderError, PROVIDER_ERROR_CODES } from
   './base.mjs';` to `import { ProviderError } from './base.mjs';`. The
   constant remains exported from base.mjs (its declaration site, where
   it IS used for validation).

4. server.mjs (1 line) — D17 reviewer's observation: removed unused
   `getProviderForModel` from the import line. The function is only
   called by lib/fallback/engine.mjs which imports it directly from
   lib/providers/index.mjs. server.mjs's import was dead (the routing
   SPOT lives in engine.mjs after D17 — server.mjs uses buildDefaultChain
   exclusively).

5. test-features.mjs (+44) — Suite 3 (irChunkToOpenAISSE format) extended
   with 4 new finish_reason normalization tests:
   - Test 1: non-spec streaming finish_reason ('timeout', 'overloaded',
     'cancelled') → mapped to 'stop'
   - Test 2: spec-enum streaming finish_reason (all 6 incl. null) preserved
   - Test 3: non-spec non-stream finish_reason → mapped to 'stop'
   - Test 4: spec-enum non-stream finish_reason preserved (null
     intentionally omitted — documented inline)

Tests: 324 → 328 (+4). All pass on Node 20.

Pre-commit fold-ins (per evidence-first checkpoint #4):

- **D19 reviewer suggestion #1**: added inline comment to
  normalizeFinishReason explaining the unreachable `undefined → null`
  branch (defensive only, no current plugin omits the field). Cheap
  future-reader clarity.
- **D19 reviewer suggestion #2**: added inline comment to Test 4
  explaining why null is intentionally omitted from the spec-enum list
  (non-stream `!== undefined` gate enters with null and overwrites
  default 'stop' to null — semantically odd but spec-valid).

Reviewer suggestion #3 (consider stricter `undefined → 'stop'` on
streaming-stop path vs `null → null` on delta path) explicitly marked
out of scope by reviewer — would require call-site context awareness;
filed mentally as potential future work, not tracked as an issue
since no current path triggers it.

Authority:
- ALIGNMENT.md Rule 2(b) — only spec-defined fields in OpenAI responses
- OpenAI Chat Completions spec finish_reason enum
  https://platform.openai.com/docs/api-reference/chat/object#finish_reason
- CC 开发铁律 v1.6 § 10.x — Cold Audit Findings 8 / 14 / 15

Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent
of drafter): APPROVE. Verified the unreachable `undefined → null` branch
claim by grep-checking all 3 provider plugins (none emit undefined);
verified the two KNOWN_PROVIDERS arrays were correctly distinguished
(only the dead bash one removed); ran npm test independently to confirm
328/328.

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

202 lines
6.8 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';
// ── finish_reason enum guard ───────────────────────────────────────────────
/**
* OpenAI-spec finish_reason enum (including null).
* Ref: https://platform.openai.com/docs/api-reference/chat/object#finish_reason
*/
const OPENAI_FINISH_REASON_ENUM = new Set([
'stop', 'length', 'tool_calls', 'content_filter', 'function_call', null,
]);
/**
* Maps a raw finish_reason value to an OpenAI-spec enum member.
* Non-spec values (e.g. 'timeout', 'overloaded') are silently normalized to
* 'stop' so that OLP never emits a spec-violating finish_reason to clients.
*
* @param {string|null|undefined} value
* @returns {string|null}
*/
function normalizeFinishReason(value) {
// Note: `value ?? null` collapses undefined → null (which IS a valid spec
// value meaning "still in progress"). All current provider plugins explicitly
// set finish_reason on stop chunks (anthropic defaults to 'stop'; codex /
// mistral always set a value), so the undefined branch is unreachable in the
// current codebase — defensive only against a future plugin that omits the
// field.
const v = value ?? null;
if (OPENAI_FINISH_REASON_ENUM.has(v)) return v;
return 'stop';
}
// ── 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: normalizeFinishReason(irChunk.finish_reason),
}],
};
// 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 !== undefined) {
finish_reason = normalizeFinishReason(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;
}