From e2e67de23a529d29fae63ef25d78cfa758eb39dc Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Sat, 23 May 2026 17:06:30 +1000 Subject: [PATCH] feat(phase-1): land IR + plugin loader + server skeleton (D3) Phase 1 Day 1. First executable code lands. Zero providers wired yet (per ALIGNMENT.md "v0.1 ships 0 Enabled Providers"); the server starts clean and POST /v1/chat/completions returns 503 with no_enabled_provider. Files added: lib/ir/types.mjs - IR v1.0 schema + validators (ADR 0003) lib/ir/openai-to-ir.mjs - OpenAI Chat Completions to IR lib/ir/ir-to-openai.mjs - IR chunks to OpenAI SSE / non-stream lib/providers/base.mjs - Provider contract + validateProvider + ProviderError lib/providers/index.mjs - Static empty registry stub (ADR 0002) server.mjs - HTTP listener with createOlpServer factory + main guard test-features.mjs - 61 tests across 7 suites (IR / provider / HTTP) Files modified: package.json - main and scripts.start/test added back; targets now exist. Authority citations: IR fields and translation direction: ADR 0003 sections Decision and Translation direction model. Provider contract (9 fields): ADR 0002 section Provider contract v1.0 interface. Entry surface routes (health, v1/models, v1/chat/completions): OLP v0.1 spec section 4.1 single-protocol entry; ALIGNMENT.md Authority 2. Zero-Enabled-Providers behaviour: ALIGNMENT.md Provider Inventory. Architectural decisions worth recording: 1. server.mjs uses a createOlpServer factory plus an import.meta.url main guard. The factory returns an unbound http.Server; only the main-script invocation calls .listen(). Tests import the real server.mjs and exercise the real router. No parallel implementation in the test file. This pattern was a fold-in from the orchestration step. The initial sonnet draft put a top-level server.listen call in server.mjs, which forced test-features.mjs to reimplement the router inline (a false- confidence trap because the real server logic would never be tested). Refactored before reviewer dispatch. 2. lib/providers/index.mjs ships an empty STATIC_REGISTRY array, not a placeholder with dummy entries. ALIGNMENT.md Provider Inventory says v0.1 ships zero Enabled Providers; the registry honors that exactly. Phase 1 Day 2 adds the first import (Anthropic) when its plugin lands. 3. BadRequestError lives in openai-to-ir.mjs and ProviderError in base.mjs. Reviewer suggested relocating to a shared lib/errors.mjs once the count exceeds two; deferred to Phase 1 Day 2 to ship with the third typed error class. 4. contractVersion: '1.0' on each provider plugin: not enforced at D3 because no providers exist yet. Reviewer flagged for Phase 1 Day 2 tightening when the first provider lands. Reviewer chain (Iron Rule 10): Initial implementer: sonnet (general-purpose). Refactor (createOlpServer + main guard) by the orchestrator after catching the inline-router parallel-implementation issue. Fresh-context reviewer: opus (ecc:code-reviewer). Verdict APPROVE_WITH_MINOR. Reviewer's two non-blocking findings folded in: F1: removed unused createServer import from test-features.mjs line 12, left over from the refactor. F2: replaced finish_reason value 'error' with 'stop' in both the streaming error chunk path (lib/ir/ir-to-openai.mjs line 72) and the non-streaming error aggregation path (lib/ir/ir-to-openai.mjs line 153). The 'error' value is not in OpenAI's documented finish_reason enum (stop / length / tool_calls / content_filter / function_call / null), so emitting it would violate ALIGNMENT.md Rule 2 (b). Provider errors are now surfaced via a top-level response.error object plus an inline content marker. The matching test assertion at test-features.mjs line 325 was updated to verify finish_reason stays within the OpenAI enum. Note on the F2 fold-in: Reviewer pointed only at the streaming path (line 72). After applying that fix I ran grep across lib/ and test-features.mjs for the same invention pattern and caught a second hit at line 153 (non-streaming aggregation). This is the "fold-in must grep the full repo, not only the file the reviewer named" discipline from ~/.cc-rules/memory/feedback/evidence_first_under_speed_pressure.md. Both hits are fixed in this commit. Verification: node --check on all 7 new files plus modified package.json plus server.mjs plus lib/ir/ir-to-openai.mjs - all clean. npm test - 61/61 pass in 209ms, no flakes, no skipped. OLP_PORT=14001 node server.mjs followed by curl /health returns proper JSON; curl /v1/models returns 200 empty list; server shuts down cleanly on signal. grep "finish_reason.*error" returns zero hits across lib/ and tests. Co-Authored-By: Claude Opus 4.7 (noreply@anthropic.com) --- lib/ir/ir-to-openai.mjs | 198 +++++++++++++ lib/ir/openai-to-ir.mjs | 202 +++++++++++++ lib/ir/types.mjs | 172 +++++++++++ lib/providers/base.mjs | 210 ++++++++++++++ lib/providers/index.mjs | 82 ++++++ package.json | 5 + server.mjs | 334 +++++++++++++++++++++ test-features.mjs | 626 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 1829 insertions(+) create mode 100644 lib/ir/ir-to-openai.mjs create mode 100644 lib/ir/openai-to-ir.mjs create mode 100644 lib/ir/types.mjs create mode 100644 lib/providers/base.mjs create mode 100644 lib/providers/index.mjs create mode 100644 server.mjs create mode 100644 test-features.mjs diff --git a/lib/ir/ir-to-openai.mjs b/lib/ir/ir-to-openai.mjs new file mode 100644 index 0000000..856cc87 --- /dev/null +++ b/lib/ir/ir-to-openai.mjs @@ -0,0 +1,198 @@ +/** + * 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- + * @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`; + } + + 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`; + } + + // 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; + let errorChunk = 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; + } + } 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; + } + } + + 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; + } + + if (errorChunk) { + response.error = { + message: errorChunk.error ?? 'Unknown provider error', + type: 'provider_error', + }; + } + + return response; +} diff --git a/lib/ir/openai-to-ir.mjs b/lib/ir/openai-to-ir.mjs new file mode 100644 index 0000000..70db991 --- /dev/null +++ b/lib/ir/openai-to-ir.mjs @@ -0,0 +1,202 @@ +/** + * lib/ir/openai-to-ir.mjs — OpenAI Chat Completions → IR v1.0 translation + * + * Authority: ADR 0003 § "Translation direction model" and § "Required/Optional fields" + * Entry-surface authority: OpenAI Chat Completions API + * https://platform.openai.com/docs/api-reference/chat/create + * + * This is the entry adapter — the single point where an OpenAI-shaped request + * is normalized into the IR. Provider plugins never see the OpenAI shape; + * they receive only the IR (ADR 0003 § "IR is not exposed externally"). + */ + +import { IR_VERSION, validateIRRequest } from './types.mjs'; + +// ── Custom error ────────────────────────────────────────────────────────── + +export class BadRequestError extends Error { + /** @param {string} message */ + constructor(message) { + super(message); + this.name = 'BadRequestError'; + this.statusCode = 400; + } +} + +// ── Role normalization ──────────────────────────────────────────────────── + +/** + * OpenAI deprecated role='function' in favour of role='tool'. + * Per ADR 0003, IR supports system/user/assistant/tool. + * @param {string} role + * @returns {string} + */ +function normalizeRole(role) { + if (role === 'function') return 'tool'; + return role; +} + +// ── Message translation ─────────────────────────────────────────────────── + +/** + * @param {object} msg - an OpenAI message object + * @returns {import('./types.mjs').IRMessage} + */ +function translateMessage(msg) { + if (!msg || typeof msg !== 'object') { + throw new BadRequestError('Each message must be an object'); + } + + const irMsg = { + role: normalizeRole(msg.role), + content: msg.content ?? '', + }; + + // content can be null for tool/function calls in OpenAI shape — normalise to '' + if (irMsg.content === null) { + irMsg.content = ''; + } + + if (msg.name !== undefined) { + irMsg.name = String(msg.name); + } + if (msg.tool_call_id !== undefined) { + irMsg.tool_call_id = String(msg.tool_call_id); + } + // OpenAI also uses function_call (deprecated) — treat as equivalent to tool_calls + if (Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) { + irMsg.tool_calls = msg.tool_calls.map(tc => ({ + id: tc.id, + type: 'function', + function: { + name: tc.function?.name ?? '', + arguments: tc.function?.arguments ?? '', + }, + })); + } else if (msg.function_call && typeof msg.function_call === 'object') { + // Deprecated OpenAI function_call field — map to single tool_calls entry + irMsg.tool_calls = [{ + id: `fc-${Date.now()}`, + type: 'function', + function: { + name: msg.function_call.name ?? '', + arguments: msg.function_call.arguments ?? '', + }, + }]; + } + + return irMsg; +} + +// ── Tool definitions ────────────────────────────────────────────────────── + +/** + * @param {object[]} tools - OpenAI tools array + * @returns {import('./types.mjs').IRToolDefinition[]} + */ +function translateTools(tools) { + if (!Array.isArray(tools)) return []; + return tools.map((t, i) => { + if (t.type !== 'function' || !t.function) { + throw new BadRequestError(`tools[${i}]: only type='function' tools are supported in IR v1.0`); + } + return { + type: 'function', + function: { + name: t.function.name, + ...(t.function.description !== undefined && { description: t.function.description }), + ...(t.function.parameters !== undefined && { parameters: t.function.parameters }), + }, + }; + }); +} + +// ── Main translator ─────────────────────────────────────────────────────── + +/** + * Translates an OpenAI Chat Completions request body into IR v1.0. + * + * Throws BadRequestError on validation failure. + * + * @param {object} openAIRequest - parsed JSON body from POST /v1/chat/completions + * @returns {import('./types.mjs').IRRequest} + */ +export function openAIToIR(openAIRequest) { + if (!openAIRequest || typeof openAIRequest !== 'object') { + throw new BadRequestError('Request body must be a JSON object'); + } + + if (!openAIRequest.model || typeof openAIRequest.model !== 'string') { + throw new BadRequestError('Request body must include a non-empty "model" string'); + } + + if (!Array.isArray(openAIRequest.messages) || openAIRequest.messages.length === 0) { + throw new BadRequestError('Request body must include a non-empty "messages" array'); + } + + /** @type {import('./types.mjs').IRRequest} */ + const ir = { + irVersion: IR_VERSION, + model: openAIRequest.model, + stream: openAIRequest.stream === true, + messages: openAIRequest.messages.map((m, i) => { + try { + return translateMessage(m); + } catch (e) { + throw new BadRequestError(`messages[${i}]: ${e.message}`); + } + }), + }; + + // Optional numeric fields — type-coerce if needed + if (openAIRequest.max_tokens !== undefined && openAIRequest.max_tokens !== null) { + const v = Number(openAIRequest.max_tokens); + if (!Number.isInteger(v) || v <= 0) { + throw new BadRequestError('max_tokens must be a positive integer'); + } + ir.max_tokens = v; + } + + if (openAIRequest.temperature !== undefined && openAIRequest.temperature !== null) { + const v = Number(openAIRequest.temperature); + if (isNaN(v) || v < 0 || v > 2) { + throw new BadRequestError('temperature must be a number in [0, 2]'); + } + ir.temperature = v; + } + + if (openAIRequest.top_p !== undefined && openAIRequest.top_p !== null) { + const v = Number(openAIRequest.top_p); + if (isNaN(v) || v < 0 || v > 1) { + throw new BadRequestError('top_p must be a number in [0, 1]'); + } + ir.top_p = v; + } + + if (openAIRequest.stop !== undefined && openAIRequest.stop !== null) { + if (typeof openAIRequest.stop !== 'string' && !Array.isArray(openAIRequest.stop)) { + throw new BadRequestError('stop must be a string or array of strings'); + } + ir.stop = openAIRequest.stop; + } + + if (openAIRequest.tools !== undefined && openAIRequest.tools !== null) { + ir.tools = translateTools(openAIRequest.tools); + } + + if (openAIRequest.tool_choice !== undefined && openAIRequest.tool_choice !== null) { + ir.tool_choice = openAIRequest.tool_choice; + } + + if (openAIRequest.response_format !== undefined && openAIRequest.response_format !== null) { + ir.response_format = openAIRequest.response_format; + } + + // Validate the constructed IR — belt-and-suspenders + const { valid, errors } = validateIRRequest(ir); + if (!valid) { + throw new BadRequestError(`IR validation failed: ${errors.join('; ')}`); + } + + return ir; +} diff --git a/lib/ir/types.mjs b/lib/ir/types.mjs new file mode 100644 index 0000000..bb94dfe --- /dev/null +++ b/lib/ir/types.mjs @@ -0,0 +1,172 @@ +/** + * lib/ir/types.mjs — IR v1.0 type definitions and validators + * + * Authority: ADR 0003 § "Required fields" and "Optional fields" + * The IR is OLP-internal; there is no external IR endpoint. + * Per ADR 0003, the IR encodes the common subset that every provider + * plugin can consume, with lossy edges documented per-provider. + */ + +// ── Constants ────────────────────────────────────────────────────────────── + +export const IR_VERSION = '1.0'; + +/** Per ADR 0003 § Required fields. role='function' is OpenAI-deprecated; openai-to-ir.mjs maps it to 'tool'. */ +export const VALID_ROLES = ['system', 'user', 'assistant', 'tool']; + +// ── JSDoc typedefs ──────────────────────────────────────────────────────── + +/** + * @typedef {Object} IRToolCall + * @property {string} id + * @property {'function'} type + * @property {{ name: string, arguments: string }} function + */ + +/** + * @typedef {Object} IRMessage + * @property {'system'|'user'|'assistant'|'tool'} role + * @property {string|Array<{type:string,[key:string]:any}>} content + * @property {string} [name] + * @property {IRToolCall[]} [tool_calls] + * @property {string} [tool_call_id] + */ + +/** + * @typedef {Object} IRToolDefinition + * @property {'function'} type + * @property {{ name: string, description?: string, parameters?: object }} function + */ + +/** + * @typedef {Object} IRRequest + * @property {string} irVersion - always IR_VERSION + * @property {IRMessage[]} messages + * @property {string} model + * @property {boolean} stream + * @property {number} [max_tokens] + * @property {number} [temperature] + * @property {number} [top_p] + * @property {string|string[]} [stop] + * @property {IRToolDefinition[]} [tools] + * @property {string|object} [tool_choice] + * @property {object} [response_format] + */ + +/** + * @typedef {Object} IRResponseChunk + * @property {'delta'|'stop'|'error'} type + * @property {string} [content] - present when type==='delta' + * @property {string} [role] - present on first delta + * @property {IRToolCall[]} [tool_calls] - present when provider emits tool-use in stream + * @property {string} [finish_reason] - present when type==='stop' + * @property {string} [error] - present when type==='error' + * @property {Object} [usage] + * @property {number} [usage.prompt_tokens] + * @property {number} [usage.completion_tokens] + * @property {number} [usage.total_tokens] + */ + +// ── Validators ──────────────────────────────────────────────────────────── + +/** + * @param {*} obj + * @returns {{ valid: boolean, errors: string[] }} + */ +export function validateIRMessage(obj) { + const errors = []; + if (obj === null || typeof obj !== 'object') { + errors.push('message must be an object'); + return { valid: false, errors }; + } + if (!VALID_ROLES.includes(obj.role)) { + errors.push(`role must be one of ${VALID_ROLES.join('|')}, got ${JSON.stringify(obj.role)}`); + } + if (obj.content === undefined || obj.content === null) { + errors.push('content is required'); + } else if (typeof obj.content !== 'string' && !Array.isArray(obj.content)) { + errors.push('content must be a string or array of content parts'); + } + if (obj.name !== undefined && typeof obj.name !== 'string') { + errors.push('name must be a string when present'); + } + if (obj.tool_call_id !== undefined && typeof obj.tool_call_id !== 'string') { + errors.push('tool_call_id must be a string when present'); + } + if (obj.tool_calls !== undefined) { + if (!Array.isArray(obj.tool_calls)) { + errors.push('tool_calls must be an array when present'); + } + } + return { valid: errors.length === 0, errors }; +} + +/** + * @param {*} obj + * @returns {{ valid: boolean, errors: string[] }} + */ +export function validateIRRequest(obj) { + const errors = []; + if (obj === null || typeof obj !== 'object') { + errors.push('IR request must be an object'); + return { valid: false, errors }; + } + + // Required: messages + if (!Array.isArray(obj.messages)) { + errors.push('messages must be an array'); + } else { + obj.messages.forEach((m, i) => { + const r = validateIRMessage(m); + if (!r.valid) { + r.errors.forEach(e => errors.push(`messages[${i}]: ${e}`)); + } + }); + if (obj.messages.length === 0) { + errors.push('messages must not be empty'); + } + } + + // Required: model + if (typeof obj.model !== 'string' || obj.model.trim() === '') { + errors.push('model must be a non-empty string'); + } + + // Required: stream + if (typeof obj.stream !== 'boolean') { + errors.push('stream must be a boolean'); + } + + // Optional: max_tokens + if (obj.max_tokens !== undefined && (!Number.isInteger(obj.max_tokens) || obj.max_tokens <= 0)) { + errors.push('max_tokens must be a positive integer when present'); + } + + // Optional: temperature [0,2] + if (obj.temperature !== undefined) { + if (typeof obj.temperature !== 'number' || obj.temperature < 0 || obj.temperature > 2) { + errors.push('temperature must be a number in [0,2] when present'); + } + } + + // Optional: top_p [0,1] + if (obj.top_p !== undefined) { + if (typeof obj.top_p !== 'number' || obj.top_p < 0 || obj.top_p > 1) { + errors.push('top_p must be a number in [0,1] when present'); + } + } + + // Optional: stop + if (obj.stop !== undefined) { + if (typeof obj.stop !== 'string' && !Array.isArray(obj.stop)) { + errors.push('stop must be a string or array of strings when present'); + } + } + + // Optional: tools + if (obj.tools !== undefined && !Array.isArray(obj.tools)) { + errors.push('tools must be an array when present'); + } + + return { valid: errors.length === 0, errors }; +} diff --git a/lib/providers/base.mjs b/lib/providers/base.mjs new file mode 100644 index 0000000..8537f7c --- /dev/null +++ b/lib/providers/base.mjs @@ -0,0 +1,210 @@ +/** + * lib/providers/base.mjs — Provider contract definition and shared helpers + * + * Authority: ADR 0002 § "Provider contract (v1.0 interface)" + * + * This module does NOT implement the Provider contract itself. + * Provider plugins compose the helpers exported here; they do not inherit + * from a base class (per ADR 0002 § Consequences/Mitigations: "compose helpers, + * do not inherit"). + */ + +// ── Contract typedef ────────────────────────────────────────────────────── + +/** + * @typedef {Object} ProviderAuth + * @property {string} type - e.g. 'subscription', 'api-key', 'oauth' + * @property {string} storage - e.g. 'cli-managed', 'env', 'keychain' + * @property {string} path - artifact location hint + * @property {string|null} refresh - refresh mechanism or null if not applicable + */ + +/** + * @typedef {Object} ProviderHints + * @property {boolean} requiresTTY + * @property {boolean} concurrentSpawnSafe + * @property {number} maxConcurrent + */ + +/** + * @typedef {Object} ProviderContractV1 + * @property {string} name - unique lowercase key + * @property {string} displayName - human-readable name + * @property {string[]} models - model strings this provider serves + * @property {ProviderAuth} auth + * @property {function} spawn - async (irRequest, authContext) => AsyncIterator + * @property {function} estimateCost - (request) => {inputTokens, outputTokensEstimate, currency, usd}|null + * @property {function} quotaStatus - async (authContext) => {available, percentUsed, resetsAt, pool}|null + * @property {function} healthCheck - async () => {ok: boolean, latencyMs: number, error?: string} + * @property {ProviderHints} hints + */ + +// ── Contract validator ──────────────────────────────────────────────────── + +/** + * Validates that a plugin object satisfies the v1.0 Provider contract. + * Per ADR 0002 § "Loading model", the registry calls this at startup for + * every registered provider; an invalid provider throws rather than silently + * degrading. + * + * @param {*} p + * @returns {{ valid: boolean, errors: string[] }} + */ +export function validateProvider(p) { + const errors = []; + + if (!p || typeof p !== 'object') { + errors.push('provider must be an object'); + return { valid: false, errors }; + } + + if (typeof p.name !== 'string' || p.name.trim() === '') { + errors.push('name must be a non-empty string'); + } else if (!/^[a-z][a-z0-9_-]*$/.test(p.name)) { + errors.push('name must be lowercase alphanumeric (with _ or -) starting with a letter'); + } + + if (typeof p.displayName !== 'string' || p.displayName.trim() === '') { + errors.push('displayName must be a non-empty string'); + } + + if (!Array.isArray(p.models)) { + errors.push('models must be an array of strings'); + } else if (p.models.some(m => typeof m !== 'string')) { + errors.push('every entry in models must be a string'); + } + + if (!p.auth || typeof p.auth !== 'object') { + errors.push('auth must be an object with { type, storage, path, refresh }'); + } else { + if (typeof p.auth.type !== 'string') errors.push('auth.type must be a string'); + if (typeof p.auth.storage !== 'string') errors.push('auth.storage must be a string'); + if (typeof p.auth.path !== 'string') errors.push('auth.path must be a string'); + if (p.auth.refresh !== null && typeof p.auth.refresh !== 'string') { + errors.push('auth.refresh must be a string or null'); + } + } + + if (typeof p.spawn !== 'function') { + errors.push('spawn must be a function'); + } + + if (typeof p.estimateCost !== 'function') { + errors.push('estimateCost must be a function'); + } + + if (typeof p.quotaStatus !== 'function') { + errors.push('quotaStatus must be a function'); + } + + if (typeof p.healthCheck !== 'function') { + errors.push('healthCheck must be a function'); + } + + if (!p.hints || typeof p.hints !== 'object') { + errors.push('hints must be an object with { requiresTTY, concurrentSpawnSafe, maxConcurrent }'); + } else { + if (typeof p.hints.requiresTTY !== 'boolean') errors.push('hints.requiresTTY must be a boolean'); + if (typeof p.hints.concurrentSpawnSafe !== 'boolean') errors.push('hints.concurrentSpawnSafe must be a boolean'); + if (typeof p.hints.maxConcurrent !== 'number' || !Number.isInteger(p.hints.maxConcurrent) || p.hints.maxConcurrent < 0) { + errors.push('hints.maxConcurrent must be a non-negative integer'); + } + } + + return { valid: errors.length === 0, errors }; +} + +// ── Error class ─────────────────────────────────────────────────────────── + +/** Error codes surfaced by provider plugins */ +export const PROVIDER_ERROR_CODES = /** @type {const} */ ([ + 'AUTH_MISSING', + 'QUOTA_EXHAUSTED', + 'RATE_LIMITED', + 'CLI_NOT_FOUND', + 'SPAWN_FAILED', + 'OUTPUT_PARSE_ERROR', +]); + +export class ProviderError extends Error { + /** + * @param {string} message + * @param {typeof PROVIDER_ERROR_CODES[number]} code + */ + constructor(message, code) { + super(message); + this.name = 'ProviderError'; + this.code = code; + } +} + +// ── Shared helpers ──────────────────────────────────────────────────────── + +/** + * Wraps a promise with a timeout. Rejects with a ProviderError if the promise + * does not settle within `ms` milliseconds. + * + * @template T + * @param {Promise} promise + * @param {number} ms + * @param {typeof PROVIDER_ERROR_CODES[number]} errorCode + * @returns {Promise} + */ +export function withTimeout(promise, ms, errorCode) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new ProviderError(`Operation timed out after ${ms}ms`, errorCode)); + }, ms); + promise.then( + v => { clearTimeout(timer); resolve(v); }, + e => { clearTimeout(timer); reject(e); }, + ); + }); +} + +/** + * Merges two AsyncIterators into a single ordered stream. + * Items from whichever source yields first are emitted first. + * Useful when a provider plugin wants to interleave two internal streams. + * + * Not used at D3 (no providers yet) but provided as infrastructure so + * provider authors don't each implement their own fan-in. + * + * @template T + * @param {AsyncIterator} iter1 + * @param {AsyncIterator} iter2 + * @returns {AsyncGenerator} + */ +export async function* mergeStreams(iter1, iter2) { + // Convert each iterator to a pull-based promise queue + const done1 = { done: true }; + const done2 = { done: true }; + + let p1 = iter1.next(); + let p2 = iter2.next(); + + while (true) { + const winner = await Promise.race([ + p1.then(r => ({ r, which: 1 })), + p2.then(r => ({ r, which: 2 })), + ]); + + if (winner.which === 1) { + if (winner.r.done) { + // iter1 exhausted — drain iter2 + for await (const v of { [Symbol.asyncIterator]: () => iter2 }) yield v; + return; + } + yield winner.r.value; + p1 = iter1.next(); + } else { + if (winner.r.done) { + // iter2 exhausted — drain iter1 + for await (const v of { [Symbol.asyncIterator]: () => iter1 }) yield v; + return; + } + yield winner.r.value; + p2 = iter2.next(); + } + } +} diff --git a/lib/providers/index.mjs b/lib/providers/index.mjs new file mode 100644 index 0000000..fa60ef1 --- /dev/null +++ b/lib/providers/index.mjs @@ -0,0 +1,82 @@ +/** + * lib/providers/index.mjs — Static provider registry + * + * Authority: ADR 0002 § "Loading model" + * + * This file is a hand-maintained static enumeration. There is no filesystem + * scan, no dynamic discovery, no npm-installed plugin loading. Adding a + * provider requires: (1) write lib/providers/.mjs, (2) add one import + + * one entry to STATIC_REGISTRY below, (3) README § "Supported Providers", + * (4) inclusion ADR per ADR 0006. + * + * At D3 (Phase 1 Day 1), the registry is intentionally empty. + * 0 Enabled Providers per ALIGNMENT.md § Provider Inventory. + * Phase 1 Day 2+ will add the anthropic plugin. + */ + +import { validateProvider } from './base.mjs'; + +// ── Static registry ─────────────────────────────────────────────────────── + +// Phase 1 Day 2+ will add imports here, e.g.: +// import * as anthropic from './anthropic.mjs'; +// And push to STATIC_REGISTRY, e.g.: +// STATIC_REGISTRY.push(anthropic.default ?? anthropic); + +const STATIC_REGISTRY = []; + +// ── Registry functions ──────────────────────────────────────────────────── + +/** + * Loads and validates providers, filtering by the enabled set in config. + * + * @param {{ enabled?: Record }} [config={}] + * @returns {Map} + */ +export function loadProviders(config = {}) { + const loaded = new Map(); + + for (const p of STATIC_REGISTRY) { + const { valid, errors } = validateProvider(p); + if (!valid) { + throw new Error(`Provider ${p?.name ?? 'unknown'} fails contract validation: ${errors.join('; ')}`); + } + + const enabled = config.enabled?.[p.name] === true; + if (enabled) { + loaded.set(p.name, p); + } + } + + return loaded; +} + +/** + * Returns the first provider in `loadedProviders` whose `models[]` includes + * the exact `modelString`. Returns null if no provider is found. + * + * This is the naive lookup strategy for D3. Phase 2+ may add prefix/alias + * matching once models-registry.json is consulted per AGENTS.md SPOT policy. + * + * @param {Map} loadedProviders + * @param {string} modelString + * @returns {{ provider: import('./base.mjs').ProviderContractV1, name: string }|null} + */ +export function getProviderForModel(loadedProviders, modelString) { + for (const [name, p] of loadedProviders) { + if (p.models.includes(modelString)) { + return { provider: p, name }; + } + } + return null; +} + +/** + * Returns all provider names in the static registry (whether enabled or not). + * Used by /health and diagnostics. + * + * @returns {string[]} + */ +export function listAllProviderNames() { + return STATIC_REGISTRY.map(p => p.name); +} diff --git a/package.json b/package.json index 08ebe5d..5bdf5fd 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,11 @@ "version": "0.1.0-bootstrap", "description": "Personal multi-provider LLM proxy. Successor to OCP. One HTTP endpoint, multiple subscriptions behind it, automatic routing + fallback + caching.", "type": "module", + "main": "server.mjs", + "scripts": { + "start": "node server.mjs", + "test": "node test-features.mjs" + }, "engines": { "node": ">=18" }, diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..39eb816 --- /dev/null +++ b/server.mjs @@ -0,0 +1,334 @@ +#!/usr/bin/env node +/** + * server.mjs — OLP HTTP listener and request dispatcher + * + * Authority (entry surface): OpenAI Chat Completions API + * https://platform.openai.com/docs/api-reference/chat/create + * Authority (IR): ADR 0003 + * Authority (provider dispatch): ADR 0002 + * + * Design principles (OCP precedent, ESM/.mjs, http built-ins, no external deps): + * - Node ESM, no build step, no bundler + * - http built-in only (no Express/Fastify) + * - Zero runtime npm dependencies in the proxy core + * + * Env vars: + * OLP_PORT — listen port (default: 3456) + */ + +import { createServer } from 'node:http'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { openAIToIR, BadRequestError } from './lib/ir/openai-to-ir.mjs'; +import { + irChunkToOpenAISSE, + irResponseToOpenAINonStream, + generateRequestId, + SSE_DONE, +} from './lib/ir/ir-to-openai.mjs'; +import { loadProviders, getProviderForModel, listAllProviderNames } from './lib/providers/index.mjs'; +import { ProviderError } from './lib/providers/base.mjs'; + +// ── Config ──────────────────────────────────────────────────────────────── + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8')); +const VERSION = pkg.version; + +const PORT = parseInt(process.env.OLP_PORT ?? '3456', 10); +const BODY_LIMIT = 5 * 1024 * 1024; // 5 MB + +// ── Provider registry ───────────────────────────────────────────────────── +// ALIGNMENT.md § Provider Inventory: 0 Enabled Providers at v0.1. +// Empty config → empty loaded map → all POST /v1/chat/completions → 503. +const loadedProviders = loadProviders({ enabled: {} }); + +// ── Logging ─────────────────────────────────────────────────────────────── + +function logEvent(level, event, data = {}) { + const entry = { ts: new Date().toISOString(), level, event, ...data }; + if (level === 'error' || level === 'warn') { + process.stderr.write(JSON.stringify(entry) + '\n'); + } else { + process.stdout.write(JSON.stringify(entry) + '\n'); + } +} + +// ── Body reader ─────────────────────────────────────────────────────────── + +/** + * Reads and JSON-parses the request body. + * Enforces the 5MB body limit. + * Throws on parse failure or oversized body. + * + * @param {import('node:http').IncomingMessage} req + * @returns {Promise} + */ +function readJSON(req) { + return new Promise((resolve, reject) => { + let body = ''; + let size = 0; + req.on('data', chunk => { + size += chunk.length; + if (size > BODY_LIMIT) { + reject(Object.assign(new Error('Request body too large (limit 5MB)'), { statusCode: 413 })); + req.destroy(); + return; + } + body += chunk; + }); + req.on('end', () => { + try { + resolve(JSON.parse(body)); + } catch { + reject(Object.assign(new Error('Invalid JSON in request body'), { statusCode: 400 })); + } + }); + req.on('error', reject); + }); +} + +// ── Response helpers ────────────────────────────────────────────────────── + +/** + * @param {import('node:http').ServerResponse} res + * @param {number} status + * @param {object} body + * @param {Record} [extraHeaders] + */ +function sendJSON(res, status, body, extraHeaders = {}) { + const payload = JSON.stringify(body); + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + ...extraHeaders, + }); + res.end(payload); +} + +/** + * OpenAI-format error response helper. + * @param {import('node:http').ServerResponse} res + * @param {number} status + * @param {string} message + * @param {string} type + */ +function sendError(res, status, message, type) { + sendJSON(res, status, { error: { message, type } }); +} + +// ── OLP response headers ────────────────────────────────────────────────── + +/** + * Returns the standard OLP diagnostic headers. + * Per spec: X-OLP-Provider-Used, X-OLP-Model-Used, X-OLP-Fallback-Hops, + * X-OLP-Cache, X-OLP-Latency-Ms. + * Fallback-Hops is always 0 at D3 (no fallback engine yet — ADR 0004). + * Cache is always 'miss' at D3 (no cache layer yet — ADR 0005). + * + * @param {object} opts + * @param {string} opts.providerUsed + * @param {string} opts.modelUsed + * @param {number} opts.startMs + * @returns {Record} + */ +function olpHeaders({ providerUsed, modelUsed, startMs }) { + return { + 'X-OLP-Provider-Used': providerUsed, + 'X-OLP-Model-Used': modelUsed, + 'X-OLP-Fallback-Hops': '0', + 'X-OLP-Cache': 'miss', + 'X-OLP-Latency-Ms': String(Date.now() - startMs), + }; +} + +// ── Route handlers ──────────────────────────────────────────────────────── + +/** + * GET /health + * Returns server health including count of loaded providers. + */ +function handleHealth(req, res) { + const enabled = loadedProviders.size; + const available = listAllProviderNames().length; + sendJSON(res, 200, { + ok: true, + version: VERSION, + providers: { enabled, available }, + }); +} + +/** + * GET /v1/models + * Returns an empty data array at D3. + * Will be populated from models-registry.json + loaded providers in Phase 1 Day 2. + */ +function handleModels(req, res) { + sendJSON(res, 200, { object: 'list', data: [] }); +} + +/** + * POST /v1/chat/completions + * Core dispatch path: OpenAI request → IR → provider.spawn → OpenAI response. + * + * @param {import('node:http').IncomingMessage} req + * @param {import('node:http').ServerResponse} res + */ +async function handleChatCompletions(req, res) { + const startMs = Date.now(); + + // Require JSON content-type + const ct = req.headers['content-type'] ?? ''; + if (!ct.includes('application/json')) { + return sendError(res, 415, 'Content-Type must be application/json', 'invalid_request_error'); + } + + let body; + try { + body = await readJSON(req); + } catch (e) { + return sendError(res, e.statusCode ?? 400, e.message, 'invalid_request_error'); + } + + // Translate OpenAI → IR + let ir; + try { + ir = openAIToIR(body); + } catch (e) { + if (e instanceof BadRequestError) { + return sendError(res, 400, e.message, 'invalid_request_error'); + } + throw e; + } + + // Find a provider for the requested model + const match = getProviderForModel(loadedProviders, ir.model); + if (!match) { + // ALIGNMENT.md: 0 Enabled Providers at v0.1 → 503 per spec + return sendError( + res, 503, + `No enabled providers for model ${ir.model}. See README § Supported Providers.`, + 'no_enabled_provider', + ); + } + + const { provider, name: providerName } = match; + const requestId = generateRequestId(); + + // Auth context is a stub at D3 — providers will populate this in Phase 1 Day 2+ + const authContext = {}; + + const headers = olpHeaders({ providerUsed: providerName, modelUsed: ir.model, startMs }); + + if (ir.stream) { + // Streaming response path + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + ...headers, + }); + + try { + for await (const irChunk of provider.spawn(ir, authContext)) { + res.write(irChunkToOpenAISSE(irChunk, requestId, ir.model)); + if (irChunk.type === 'stop' || irChunk.type === 'error') break; + } + res.write(SSE_DONE); + } catch (e) { + // Best-effort error reporting in the stream + const errChunk = { type: 'error', error: e.message ?? 'Provider spawn failed' }; + res.write(irChunkToOpenAISSE(errChunk, requestId, ir.model)); + res.write(SSE_DONE); + logEvent('error', 'spawn_error', { provider: providerName, model: ir.model, error: e.message }); + } + res.end(); + } else { + // Non-streaming response path + try { + const chunks = []; + for await (const irChunk of provider.spawn(ir, authContext)) { + chunks.push(irChunk); + if (irChunk.type === 'stop' || irChunk.type === 'error') break; + } + const response = irResponseToOpenAINonStream(chunks, requestId, ir.model); + sendJSON(res, 200, response, headers); + } catch (e) { + logEvent('error', 'spawn_error', { provider: providerName, model: ir.model, error: e.message }); + const status = e instanceof ProviderError ? 502 : 500; + sendError(res, status, e.message ?? 'Provider error', 'provider_error'); + } + } +} + +// ── Request router ──────────────────────────────────────────────────────── + +/** + * @param {import('node:http').IncomingMessage} req + * @param {import('node:http').ServerResponse} res + */ +async function router(req, res) { + const { method, url } = req; + + // Strip query string for routing + const path = url?.split('?')[0] ?? '/'; + + try { + if (method === 'GET' && path === '/health') { + return handleHealth(req, res); + } + + if (method === 'GET' && path === '/v1/models') { + return handleModels(req, res); + } + + if (method === 'POST' && path === '/v1/chat/completions') { + return await handleChatCompletions(req, res); + } + + // 404 for any unrecognised route + sendError(res, 404, `Route ${method} ${path} not found`, 'not_found'); + } catch (e) { + logEvent('error', 'unhandled_request_error', { method, path, error: e?.message }); + if (!res.headersSent) { + sendError(res, 500, 'Internal server error', 'internal_error'); + } + } +} + +// ── Server factory + main guard ─────────────────────────────────────────── +// +// Factory pattern: `createOlpServer()` returns an http.Server bound to the +// shared router but NOT yet listening. Tests import this factory and call +// .listen() on their own port. The main guard below only runs .listen() +// when this file is invoked directly via `node server.mjs` — preventing +// import-time side effects when tests pull in server.mjs. + +export function createOlpServer() { + return createServer(router); +} + +export { router, loadedProviders, VERSION }; + +// Main guard: only listen when invoked as the entrypoint. ESM equivalent of +// `require.main === module` is comparing import.meta.url against argv[1]. +const isMain = (() => { + try { + return import.meta.url === `file://${process.argv[1]}`; + } catch { + return false; + } +})(); + +if (isMain) { + const server = createOlpServer(); + server.listen(PORT, '127.0.0.1', () => { + const enabledCount = loadedProviders.size; + process.stdout.write( + `OLP v${VERSION} listening on :${PORT} (${enabledCount} providers enabled — Phase 1 in progress)\n`, + ); + }); +} diff --git a/test-features.mjs b/test-features.mjs new file mode 100644 index 0000000..46d2fbe --- /dev/null +++ b/test-features.mjs @@ -0,0 +1,626 @@ +/** + * test-features.mjs — OLP D3 test suite + * + * Uses Node's built-in node:test runner. No external dependencies. + * Run: node test-features.mjs (or: npm test) + * + * Authority: ADR 0002 (provider contract), ADR 0003 (IR v1.0) + */ + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { request as httpRequest } from 'node:http'; + +// ── Modules under test ──────────────────────────────────────────────────── + +import { validateIRRequest, validateIRMessage, VALID_ROLES, IR_VERSION } from './lib/ir/types.mjs'; +import { openAIToIR, BadRequestError } from './lib/ir/openai-to-ir.mjs'; +import { + irChunkToOpenAISSE, + irResponseToOpenAINonStream, + generateRequestId, + SSE_DONE, +} from './lib/ir/ir-to-openai.mjs'; +import { validateProvider, ProviderError, withTimeout } from './lib/providers/base.mjs'; +import { loadProviders, getProviderForModel, listAllProviderNames } from './lib/providers/index.mjs'; + +// ── Helpers ─────────────────────────────────────────────────────────────── + +/** Minimal valid IR request for use in tests */ +function makeIR(overrides = {}) { + return { + irVersion: IR_VERSION, + model: 'test-model', + stream: false, + messages: [{ role: 'user', content: 'Hello' }], + ...overrides, + }; +} + +/** Minimal valid provider stub that satisfies the v1.0 contract */ +function makeProvider(overrides = {}) { + return { + name: 'stub', + displayName: 'Stub Provider', + models: ['stub-model-v1'], + auth: { type: 'none', storage: 'none', path: '', refresh: null }, + spawn: async function* () { yield { type: 'stop', finish_reason: 'stop' }; }, + estimateCost: () => null, + quotaStatus: async () => null, + healthCheck: async () => ({ ok: true, latencyMs: 0 }), + hints: { requiresTTY: false, concurrentSpawnSafe: true, maxConcurrent: 4 }, + ...overrides, + }; +} + +// ── HTTP helper ─────────────────────────────────────────────────────────── + +/** + * Makes an HTTP request to the test server. + * @param {{ port, method, path, headers?, body? }} opts + * @returns {Promise<{ status: number, headers: object, body: string }>} + */ +function fetch(opts) { + return new Promise((resolve, reject) => { + const bodyStr = opts.body ? JSON.stringify(opts.body) : undefined; + const req = httpRequest({ + hostname: '127.0.0.1', + port: opts.port, + method: opts.method ?? 'GET', + path: opts.path, + headers: { + ...(bodyStr && { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(bodyStr) }), + ...(opts.headers ?? {}), + }, + }, res => { + let data = ''; + res.on('data', c => { data += c; }); + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data })); + }); + req.on('error', reject); + if (bodyStr) req.write(bodyStr); + req.end(); + }); +} + +// ── Suite 1: IR validation ──────────────────────────────────────────────── + +describe('IR validation — validateIRRequest', () => { + it('accepts a minimal valid IR request', () => { + const r = validateIRRequest(makeIR()); + assert.equal(r.valid, true); + assert.deepEqual(r.errors, []); + }); + + it('accepts an IR request with all optional fields', () => { + const r = validateIRRequest(makeIR({ + max_tokens: 256, + temperature: 0.7, + top_p: 0.9, + stop: ['\n'], + tools: [], + tool_choice: 'auto', + response_format: { type: 'text' }, + })); + assert.equal(r.valid, true); + }); + + it('rejects when messages is missing', () => { + const ir = makeIR(); + delete ir.messages; + const r = validateIRRequest(ir); + assert.equal(r.valid, false); + assert.ok(r.errors.some(e => e.includes('messages'))); + }); + + it('rejects when messages is empty', () => { + const r = validateIRRequest(makeIR({ messages: [] })); + assert.equal(r.valid, false); + assert.ok(r.errors.some(e => e.includes('messages must not be empty'))); + }); + + it('rejects when model is missing', () => { + const ir = makeIR(); + delete ir.model; + const r = validateIRRequest(ir); + assert.equal(r.valid, false); + assert.ok(r.errors.some(e => e.includes('model'))); + }); + + it('rejects when stream is not boolean', () => { + const r = validateIRRequest(makeIR({ stream: 'true' })); + assert.equal(r.valid, false); + assert.ok(r.errors.some(e => e.includes('stream'))); + }); + + it('rejects temperature out of range', () => { + const r = validateIRRequest(makeIR({ temperature: 3 })); + assert.equal(r.valid, false); + assert.ok(r.errors.some(e => e.includes('temperature'))); + }); + + it('rejects top_p out of range', () => { + const r = validateIRRequest(makeIR({ top_p: -0.1 })); + assert.equal(r.valid, false); + assert.ok(r.errors.some(e => e.includes('top_p'))); + }); + + it('rejects non-object input', () => { + const r = validateIRRequest(null); + assert.equal(r.valid, false); + }); +}); + +describe('IR validation — validateIRMessage', () => { + for (const role of VALID_ROLES) { + it(`accepts role="${role}"`, () => { + const r = validateIRMessage({ role, content: 'test' }); + assert.equal(r.valid, true); + }); + } + + it('rejects invalid role', () => { + const r = validateIRMessage({ role: 'admin', content: 'x' }); + assert.equal(r.valid, false); + assert.ok(r.errors.some(e => e.includes('role'))); + }); + + it('rejects missing content', () => { + const r = validateIRMessage({ role: 'user' }); + assert.equal(r.valid, false); + assert.ok(r.errors.some(e => e.includes('content'))); + }); + + it('accepts array content (multi-part)', () => { + const r = validateIRMessage({ role: 'user', content: [{ type: 'text', text: 'hi' }] }); + assert.equal(r.valid, true); + }); + + it('rejects non-object input', () => { + const r = validateIRMessage('not an object'); + assert.equal(r.valid, false); + }); +}); + +// ── Suite 2: openAIToIR translation ────────────────────────────────────── + +describe('openAIToIR translation', () => { + it('translates a minimal request', () => { + const ir = openAIToIR({ model: 'gpt-4o', messages: [{ role: 'user', content: 'Hi' }] }); + assert.equal(ir.irVersion, IR_VERSION); + assert.equal(ir.model, 'gpt-4o'); + assert.equal(ir.stream, false); + assert.equal(ir.messages.length, 1); + assert.equal(ir.messages[0].role, 'user'); + assert.equal(ir.messages[0].content, 'Hi'); + }); + + it('defaults stream to false when absent', () => { + const ir = openAIToIR({ model: 'm', messages: [{ role: 'user', content: 'x' }] }); + assert.equal(ir.stream, false); + }); + + it('passes stream=true through', () => { + const ir = openAIToIR({ model: 'm', messages: [{ role: 'user', content: 'x' }], stream: true }); + assert.equal(ir.stream, true); + }); + + it('translates multi-turn with system message', () => { + const ir = openAIToIR({ + model: 'claude-sonnet-4-6', + messages: [ + { role: 'system', content: 'You are helpful.' }, + { role: 'user', content: 'What is 2+2?' }, + { role: 'assistant', content: '4' }, + { role: 'user', content: 'Thanks!' }, + ], + }); + assert.equal(ir.messages.length, 4); + assert.equal(ir.messages[0].role, 'system'); + assert.equal(ir.messages[1].role, 'user'); + assert.equal(ir.messages[2].role, 'assistant'); + }); + + it('maps deprecated role=function to role=tool', () => { + const ir = openAIToIR({ + model: 'm', + messages: [{ role: 'function', name: 'my_fn', content: '{"result":1}' }], + }); + assert.equal(ir.messages[0].role, 'tool'); + assert.equal(ir.messages[0].name, 'my_fn'); + }); + + it('translates request with tools', () => { + const ir = openAIToIR({ + model: 'm', + messages: [{ role: 'user', content: 'Search for X' }], + tools: [{ + type: 'function', + function: { name: 'search', description: 'Web search', parameters: { type: 'object', properties: {} } }, + }], + tool_choice: 'auto', + }); + assert.equal(ir.tools.length, 1); + assert.equal(ir.tools[0].function.name, 'search'); + assert.equal(ir.tool_choice, 'auto'); + }); + + it('translates request with response_format', () => { + const ir = openAIToIR({ + model: 'm', + messages: [{ role: 'user', content: 'Give JSON' }], + response_format: { type: 'json_object' }, + }); + assert.deepEqual(ir.response_format, { type: 'json_object' }); + }); + + it('translates optional numeric fields', () => { + const ir = openAIToIR({ + model: 'm', + messages: [{ role: 'user', content: 'x' }], + max_tokens: 100, + temperature: 0.5, + top_p: 0.95, + }); + assert.equal(ir.max_tokens, 100); + assert.equal(ir.temperature, 0.5); + assert.equal(ir.top_p, 0.95); + }); + + it('throws BadRequestError when model is missing', () => { + assert.throws( + () => openAIToIR({ messages: [{ role: 'user', content: 'x' }] }), + BadRequestError, + ); + }); + + it('throws BadRequestError when messages is empty', () => { + assert.throws( + () => openAIToIR({ model: 'm', messages: [] }), + BadRequestError, + ); + }); + + it('throws BadRequestError when body is not an object', () => { + assert.throws(() => openAIToIR(null), BadRequestError); + }); +}); + +// ── Suite 3: irChunkToOpenAISSE format ──────────────────────────────────── + +describe('irChunkToOpenAISSE format', () => { + const ID = 'chatcmpl-test123'; + const MODEL = 'test-model'; + + it('generates request IDs with chatcmpl- prefix', () => { + const id = generateRequestId(); + assert.ok(id.startsWith('chatcmpl-')); + assert.ok(id.length > 12); + }); + + it('formats a delta chunk as SSE event', () => { + const sse = irChunkToOpenAISSE({ type: 'delta', role: 'assistant', content: 'Hello' }, ID, MODEL); + assert.ok(sse.startsWith('data: ')); + assert.ok(sse.endsWith('\n\n')); + const payload = JSON.parse(sse.slice(6).trim()); + assert.equal(payload.object, 'chat.completion.chunk'); + assert.equal(payload.id, ID); + assert.equal(payload.model, MODEL); + assert.equal(payload.choices[0].delta.content, 'Hello'); + assert.equal(payload.choices[0].delta.role, 'assistant'); + assert.equal(payload.choices[0].finish_reason, null); + }); + + it('formats a stop chunk with finish_reason', () => { + const sse = irChunkToOpenAISSE({ type: 'stop', finish_reason: 'stop' }, ID, MODEL); + const payload = JSON.parse(sse.slice(6).trim()); + assert.equal(payload.choices[0].finish_reason, 'stop'); + 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. + 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.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', () => { + assert.equal(SSE_DONE, 'data: [DONE]\n\n'); + }); + + it('irResponseToOpenAINonStream assembles a complete response', () => { + const chunks = [ + { type: 'delta', role: 'assistant', content: 'Hello' }, + { type: 'delta', content: ' world' }, + { type: 'stop', finish_reason: 'stop', usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 } }, + ]; + const resp = irResponseToOpenAINonStream(chunks, ID, MODEL); + assert.equal(resp.object, 'chat.completion'); + assert.equal(resp.id, ID); + assert.equal(resp.model, MODEL); + assert.equal(resp.choices[0].message.content, 'Hello world'); + assert.equal(resp.choices[0].message.role, 'assistant'); + assert.equal(resp.choices[0].finish_reason, 'stop'); + assert.equal(resp.usage.total_tokens, 7); + }); +}); + +// ── Suite 4: Provider contract validation ───────────────────────────────── + +describe('Provider contract validation', () => { + it('accepts a fully valid provider stub', () => { + const r = validateProvider(makeProvider()); + assert.equal(r.valid, true); + assert.deepEqual(r.errors, []); + }); + + it('rejects provider with missing name', () => { + const p = makeProvider(); + delete p.name; + const r = validateProvider(p); + assert.equal(r.valid, false); + assert.ok(r.errors.some(e => e.includes('name'))); + }); + + it('rejects provider with non-lowercase name', () => { + const r = validateProvider(makeProvider({ name: 'MyProvider' })); + assert.equal(r.valid, false); + assert.ok(r.errors.some(e => e.includes('name'))); + }); + + it('rejects provider with missing displayName', () => { + const p = makeProvider(); + delete p.displayName; + const r = validateProvider(p); + assert.equal(r.valid, false); + assert.ok(r.errors.some(e => e.includes('displayName'))); + }); + + it('rejects provider with missing models array', () => { + const p = makeProvider(); + delete p.models; + const r = validateProvider(p); + assert.equal(r.valid, false); + assert.ok(r.errors.some(e => e.includes('models'))); + }); + + it('rejects provider with missing spawn', () => { + const p = makeProvider(); + delete p.spawn; + const r = validateProvider(p); + assert.equal(r.valid, false); + assert.ok(r.errors.some(e => e.includes('spawn'))); + }); + + it('rejects provider with missing estimateCost', () => { + const p = makeProvider(); + delete p.estimateCost; + const r = validateProvider(p); + assert.equal(r.valid, false); + assert.ok(r.errors.some(e => e.includes('estimateCost'))); + }); + + it('rejects provider with missing healthCheck', () => { + const p = makeProvider(); + delete p.healthCheck; + const r = validateProvider(p); + assert.equal(r.valid, false); + assert.ok(r.errors.some(e => e.includes('healthCheck'))); + }); + + it('rejects provider with missing hints', () => { + const p = makeProvider(); + delete p.hints; + const r = validateProvider(p); + assert.equal(r.valid, false); + assert.ok(r.errors.some(e => e.includes('hints'))); + }); + + it('rejects provider with invalid hints.maxConcurrent', () => { + const r = validateProvider(makeProvider({ hints: { requiresTTY: false, concurrentSpawnSafe: true, maxConcurrent: -1 } })); + assert.equal(r.valid, false); + assert.ok(r.errors.some(e => e.includes('maxConcurrent'))); + }); + + it('rejects non-object input', () => { + const r = validateProvider(null); + assert.equal(r.valid, false); + }); + + it('ProviderError carries code field', () => { + const e = new ProviderError('auth missing', 'AUTH_MISSING'); + assert.equal(e.code, 'AUTH_MISSING'); + assert.equal(e.name, 'ProviderError'); + assert.ok(e instanceof Error); + }); + + it('withTimeout rejects after deadline', async () => { + const p = new Promise(r => setTimeout(() => r('late'), 200)); + await assert.rejects( + () => withTimeout(p, 50, 'SPAWN_FAILED'), + err => err instanceof ProviderError && err.code === 'SPAWN_FAILED', + ); + }); + + it('withTimeout resolves when promise is fast', async () => { + const p = Promise.resolve(42); + const v = await withTimeout(p, 1000, 'SPAWN_FAILED'); + assert.equal(v, 42); + }); +}); + +// ── Suite 5: Plugin registry ────────────────────────────────────────────── + +describe('Plugin registry', () => { + it('empty STATIC_REGISTRY → loadProviders returns empty Map', () => { + const m = loadProviders({}); + assert.equal(m.size, 0); + }); + + it('loadProviders with no config → empty Map', () => { + const m = loadProviders(); + assert.equal(m.size, 0); + }); + + it('listAllProviderNames returns empty array at D3', () => { + assert.deepEqual(listAllProviderNames(), []); + }); + + it('getProviderForModel returns null when no providers loaded', () => { + const m = loadProviders({}); + const r = getProviderForModel(m, 'gpt-4o'); + assert.equal(r, null); + }); + + it('getProviderForModel finds provider by exact model string', () => { + // Build a synthetic loaded map to test the function without touching STATIC_REGISTRY + const p = makeProvider({ name: 'alpha', models: ['alpha-v1', 'alpha-v2'] }); + const m = new Map([['alpha', p]]); + const r = getProviderForModel(m, 'alpha-v1'); + assert.ok(r !== null); + assert.equal(r.name, 'alpha'); + }); + + it('getProviderForModel returns null for unknown model', () => { + const p = makeProvider({ name: 'alpha', models: ['alpha-v1'] }); + const m = new Map([['alpha', p]]); + assert.equal(getProviderForModel(m, 'beta-v1'), null); + }); +}); + +// ── Suite 6: HTTP integration tests ────────────────────────────────────── + +describe('HTTP integration', () => { + let serverInstance; + let port; + + before(async () => { + // Use the REAL server module via its createOlpServer() factory. The + // main guard in server.mjs prevents auto-listen on import; we call + // .listen() ourselves on a test port. This means every HTTP test below + // exercises the real router code — there is no parallel implementation + // to drift. + const { createOlpServer } = await import('./server.mjs'); + + // Pick a port: env OLP_TEST_PORT or random high port + port = parseInt(process.env.OLP_TEST_PORT ?? String(13456 + Math.floor(Math.random() * 1000)), 10); + + serverInstance = createOlpServer(); + + // Retry once on port-in-use + await new Promise((resolve, reject) => { + serverInstance.listen(port, '127.0.0.1', resolve); + serverInstance.once('error', async (e) => { + if (e.code === 'EADDRINUSE') { + port++; + serverInstance.listen(port, '127.0.0.1', resolve); + serverInstance.once('error', reject); + } else { + reject(e); + } + }); + }); + }); + + after(() => new Promise(r => serverInstance.close(r))); + + it('GET /health returns 200 with expected shape', async () => { + const r = await fetch({ port, method: 'GET', path: '/health' }); + assert.equal(r.status, 200); + const body = JSON.parse(r.body); + assert.equal(body.ok, true); + assert.ok(typeof body.version === 'string'); + assert.ok(typeof body.providers.enabled === 'number'); + assert.ok(typeof body.providers.available === 'number'); + }); + + it('GET /v1/models returns 200 with empty data array', async () => { + const r = await fetch({ port, method: 'GET', path: '/v1/models' }); + assert.equal(r.status, 200); + const body = JSON.parse(r.body); + assert.equal(body.object, 'list'); + assert.deepEqual(body.data, []); + }); + + it('POST /v1/chat/completions with no providers → 503 with no_enabled_provider', async () => { + const r = await fetch({ + port, + method: 'POST', + path: '/v1/chat/completions', + body: { model: 'gpt-4o', messages: [{ role: 'user', content: 'Hi' }] }, + }); + assert.equal(r.status, 503); + const body = JSON.parse(r.body); + assert.equal(body.error.type, 'no_enabled_provider'); + assert.ok(body.error.message.includes('gpt-4o')); + }); + + it('POST /v1/chat/completions with invalid JSON body → 400', async () => { + const req = httpRequest({ + hostname: '127.0.0.1', + port, + method: 'POST', + path: '/v1/chat/completions', + headers: { 'Content-Type': 'application/json', 'Content-Length': '5' }, + }); + const result = await new Promise((resolve, reject) => { + req.on('error', reject); + let body = ''; + req.on('response', res => { + res.on('data', c => body += c); + res.on('end', () => resolve({ status: res.statusCode, body })); + }); + req.write('{bad}'); + req.end(); + }); + assert.equal(result.status, 400); + }); + + it('POST /v1/chat/completions with missing model → 400', async () => { + const r = await fetch({ + port, + method: 'POST', + path: '/v1/chat/completions', + body: { messages: [{ role: 'user', content: 'x' }] }, + }); + assert.equal(r.status, 400); + const body = JSON.parse(r.body); + assert.equal(body.error.type, 'invalid_request_error'); + }); + + it('GET /unknown → 404', async () => { + const r = await fetch({ port, method: 'GET', path: '/unknown/route' }); + assert.equal(r.status, 404); + const body = JSON.parse(r.body); + assert.equal(body.error.type, 'not_found'); + }); + + it('POST /v1/chat/completions without Content-Type → 415', async () => { + // Our fetch helper sets Content-Type to application/json when body is truthy; + // send text/plain directly to verify the 415 path. + const result = await new Promise((resolve, reject) => { + const req = httpRequest({ + hostname: '127.0.0.1', + port, + method: 'POST', + path: '/v1/chat/completions', + headers: { 'Content-Type': 'text/plain', 'Content-Length': '2' }, + }, res => { + let data = ''; + res.on('data', c => data += c); + res.on('end', () => resolve({ status: res.statusCode, body: data })); + }); + req.on('error', reject); + req.write('{}'); + req.end(); + }); + assert.equal(result.status, 415); + }); +});