P1.5.4 — OpenAI Adapter (GPT-4o family) — Audit (Step 1)
This audit inventories the existing δ Router adapter surface, the Phase 0 ν Claude adapter as structural reference, and the OpenAI Chat Completions API divergences that P1.5.4 must accommodate.
1. Scope statement
Add an OpenAI adapter at src/domains/router/adapters/openai.ts that:
- Mirrors the surface parity of the Phase 0 ν Claude adapter at
src/domains/integrations/claude.ts(P0.9.2 shipped). - Covers GPT-4o and GPT-4o mini under a single module —
options.modelselects the specific variant (no separate file per model). - Reads
COLIBRI_OPENAI_API_KEY+COLIBRI_OPENAI_BASE_URLat call-time (no schema-level requirement; missing key throwsOpenAiConfigError). - Maps OpenAI’s
tool_callsresponse shape intoAnthropicTool-compatible blocks so the router’s existing tool-use plumbing accepts the output without a type-coercion adapter at the call site. - Provides injection seams (
fetchFn,logger,delayFn,apiKey,baseUrl) so tests never hit the real OpenAI API. - Implements the same exponential-backoff retry semantics as the Claude adapter (429 + 5xx → up to 3 retries, base delay 100 ms doubling).
- Adds no MCP tool registration — router_call wiring is P1.5.7+ scope.
- Adds no
src/domains/router/index.tsre-export — Wave 3 has 3 parallel adapter slices and re-export coordination lands in a Wave 4 fold-in commit. This is the single hard divergence from the staging file’s literal instructions.
2. Files in scope
2.1 Create
| Path | Reason |
|---|---|
src/domains/router/adapters/openai.ts |
The adapter module itself. |
src/__tests__/domains/router/adapters/openai.test.ts |
5–10 parity tests with all seams injected. |
2.2 Modify
| Path | Reason |
|---|---|
| (none) | Wave 3 ships file-disjoint; index.ts is touched in the Wave 4 fold-in commit by the wave coordinator. |
2.3 Touch-not (forbidden by dispatch)
| Path | Why |
|---|---|
src/domains/router/index.ts |
CRITICAL OVERRIDE. Sibling parallel T3 executors (P1.5.2 Kimi, P1.5.3 Codex) would race here. Re-export lands in a coordinated fold-in. |
src/domains/router/scoring.ts |
P1.5.1 scope; shipped at 89adef66. |
src/domains/router/fallback.ts |
P1.5.5 scope. |
src/domains/integrations/claude.ts |
Phase 0 ν surface; reference only, not mutated. |
src/server.ts |
No MCP tool registration in P1.5.4. |
src/config.ts |
COLIBRI_OPENAI_* are call-time-validated only; no schema entry. |
src/db/migrations/* |
P1.5.9 already shipped the candidate table. |
3. Reference surface — Phase 0 ν Claude adapter
3.1 src/domains/integrations/claude.ts — 392 LOC
Public exports (the parity target):
| Export | Kind | Purpose |
|---|---|---|
createCompletion(prompt, options?) |
function | Standard text completion. |
createCompletionWithTools(prompt, tools, options?) |
function | Tool-use completion. |
AnthropicConfigError |
class | Raised when key absent at call-time. code: 'ANTHROPIC_CONFIG_ERROR'. |
AnthropicApiError |
class | Raised on terminal API error or retries exhausted. code: 'ANTHROPIC_API_ERROR' | 'ANTHROPIC_RETRIES_EXHAUSTED'. |
AnthropicTool |
interface | Tool descriptor { name, description, input_schema }. |
CompletionOptions |
interface | Options bag with all injection seams. |
CompletionResult |
interface | { content, model, promptTokens, completionTokens, latencyMs, stopReason }. |
3.2 Internal constants (the parity floor)
| Constant | Value | Adapter parity |
|---|---|---|
ANTHROPIC_API_BASE |
'https://api.anthropic.com/v1' |
OpenAI: 'https://api.openai.com/v1' |
ANTHROPIC_API_VERSION |
'2023-06-01' |
OpenAI: no version header (uses path-based versioning) |
DEFAULT_MAX_TOKENS |
1024 |
Preserved |
MAX_RETRIES |
3 |
Preserved |
BASE_DELAY_MS |
100 |
Preserved |
3.3 Behavioral invariants (preserved verbatim across adapters)
- Library-only — no MCP tool registration in the adapter module.
- No new runtime dependency — uses global
fetch(Node ≥ 20). - Stderr-only logging —
console.error/ injected logger; neverprocess.stdout. - Injection seams —
fetchFn,logger,delayFnall overridable. - Call-time API key validation — schema marks optional; raise typed config error if absent.
- Retry policy — 429 + 5xx → exponential backoff, max 3 retries, base 100 ms doubling.
- Frozen / pure — adapter never mutates
process.env, never reads clock outside latency measurement, never reads files.
4. Router CompletionFn shape (parity contract)
From src/domains/router/fallback.ts (P0.5.2 shipped):
export type CompletionFn = (
prompt: string,
options: CompletionFnOptions,
) => Promise<CompletionResult>;
export interface CompletionFnOptions {
readonly model?: string;
readonly maxTokens?: number;
readonly systemPrompt?: string;
readonly apiKey?: string;
readonly fetchFn?: typeof fetch;
readonly logger?: (...args: unknown[]) => void;
readonly delayFn?: (ms: number) => Promise<void>;
}
CompletionResult (from src/domains/integrations/claude.ts):
export interface CompletionResult {
readonly content: string;
readonly model: string;
readonly promptTokens: number;
readonly completionTokens: number;
readonly latencyMs: number;
readonly stopReason: string;
}
createOpenAiCompletion and createOpenAiCompletionWithTools MUST satisfy
this CompletionFn shape so the Wave 5 fallback cascade (P1.5.5) can pass
them in interchangeably with the Claude / Kimi / Codex adapters.
5. OpenAI Chat Completions API — divergences from Anthropic Messages API
5.1 Endpoint + auth
| Aspect | Anthropic | OpenAI |
|---|---|---|
| URL | POST https://api.anthropic.com/v1/messages |
POST https://api.openai.com/v1/chat/completions |
| Auth header | x-api-key: <key> |
Authorization: Bearer <key> |
| Version header | anthropic-version: 2023-06-01 |
(no version header; path-versioned) |
| Content-Type | application/json |
application/json |
| Base URL override | not exposed in P0.9.2 | MUST be exposed via COLIBRI_OPENAI_BASE_URL (Azure OpenAI compat) |
5.2 Request body shape
OpenAI Chat Completions:
{
"model": "gpt-4o",
"max_tokens": 1024,
"messages": [
{"role": "system", "content": "..."}, // optional system role
{"role": "user", "content": "..."} // user prompt
],
"tools": [ // optional, omit if empty
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather",
"parameters": { /* JSON schema */ }
}
}
]
}
Anthropic Messages:
{
"model": "claude-sonnet-4-5",
"max_tokens": 1024,
"system": "...", // optional system as top-level field
"messages": [
{"role": "user", "content": "..."}
],
"tools": [
{ "name": "get_weather", "description": "...", "input_schema": {...} }
]
}
Divergence map for tool descriptors (input side — what AnthropicTool items
must be transformed INTO when sent to OpenAI):
| AnthropicTool field | OpenAI Chat Completions field | Notes |
|---|---|---|
name |
function.name |
Wrapped in { type: 'function', function: {...} } envelope. |
description |
function.description |
Same semantics. |
input_schema |
function.parameters |
Same JSON Schema body, different key name. |
5.3 Response body shape
OpenAI Chat Completions (text completion):
{
"id": "chatcmpl-...",
"object": "chat.completion",
"created": 1234567890,
"model": "gpt-4o",
"choices": [
{
"index": 0,
"message": { "role": "assistant", "content": "Hello!" },
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15
}
}
OpenAI Chat Completions (tool-use):
{
"id": "chatcmpl-...",
"model": "gpt-4o",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"location\":\"London\"}" // JSON string, NOT object
}
}
]
},
"finish_reason": "tool_calls"
}
],
"usage": { "prompt_tokens": 20, "completion_tokens": 12, "total_tokens": 32 }
}
Key divergences from Anthropic on the response side:
- Content location. Anthropic puts everything in
content: [{type, ...}]; OpenAI puts text inchoices[0].message.contentand tool calls inchoices[0].message.tool_calls. - Tool-call arguments encoding. OpenAI delivers
function.argumentsas a JSON-encoded string (not an object). Anthropic deliversinputas a parsed object. - Finish-reason naming. OpenAI uses
stop/tool_calls/length/content_filter; Anthropic usesend_turn/tool_use/max_tokens/stop_sequence. The adapter records the OpenAI value verbatim instopReason(no normalization — the router treats it as opaque). - Token usage key names. OpenAI:
usage.prompt_tokens+usage.completion_tokens. Anthropic:usage.input_tokens+usage.output_tokens. - Legacy
function_callshape. Older models used a singlefunction_callonmessageinstead of an arraytool_calls. The adapter accepts the legacy shape on the response side too, but emits only the new shape upstream into thecontentfield.
5.4 Tool-use mapping table — OpenAI response → AnthropicTool-shaped block
The adapter normalizes the OpenAI tool-call response into an array of blocks
matching Anthropic’s tool_use content-block shape, then JSON-stringifies
the array into CompletionResult.content (mirroring P0.9.2’s behavior in
parseResult at src/domains/integrations/claude.ts:208-214).
| OpenAI response field | Mapped to (Anthropic-shape) | Encoding |
|---|---|---|
choices[0].message.tool_calls[i].id |
id |
passthrough |
(literal 'tool_use') |
type |
synthesized |
choices[0].message.tool_calls[i].function.name |
name |
passthrough |
choices[0].message.tool_calls[i].function.arguments (string) |
input (object) |
JSON.parse(arguments) |
choices[0].message.tool_calls[i].type |
(dropped) | OpenAI always sends 'function'; redundant with synthesized 'tool_use'. |
Legacy message.function_call is accepted on input and mapped as a
single-element tool_calls-shaped array before the above transform fires.
5.5 Error semantics
OpenAI returns the same HTTP status families:
- 400 / 401 / 403 / 404 — terminal, no retry,
OpenAiApiErrorraised. - 429 — retryable, exponential backoff, max 3 retries,
OpenAiApiErrorwith codeOPENAI_RETRIES_EXHAUSTEDafter exhaustion. - 5xx — retryable like 429.
- Network error —
OpenAiApiErrorraised, no retry (matches Phase 0).
6. Environment contract
| Variable | Required? | Default | Notes |
|---|---|---|---|
COLIBRI_OPENAI_API_KEY |
call-time required | none | Adapter throws OpenAiConfigError on first call if absent. NOT in src/config.ts schema. |
COLIBRI_OPENAI_BASE_URL |
call-time optional | https://api.openai.com/v1 |
Allows Azure OpenAI / self-hosted overrides. Validated at use site only. |
Namespace rationale:
- The vendor name
OPENAI_API_KEYwould clash with theOPENAI_API_KEYmany shells already have for an unrelated client. The adapter uses theCOLIBRI_*prefix to match the project’s namespace floor (config.ts §1). - The Anthropic adapter reuses the vendor name
ANTHROPIC_API_KEYbecause it lives insrc/config.tsschema. The OpenAI adapter lives insrc/domains/router/adapters/, reads viaoptions.apiKey ?? process.env['COLIBRI_OPENAI_API_KEY']at call-time only, and never touches the boot-time schema.
7. Test-corpus inventory
Test file to create: src/__tests__/domains/router/adapters/openai.test.ts.
Mandatory coverage (5–10 parity tests per spec; will ship ~25 to give adequate divergence + parity coverage):
| AC | Test description |
|---|---|
| AC1 | createOpenAiCompletion POSTs to /chat/completions with Authorization: Bearer header. |
| AC2 | Request body shape — messages + max_tokens + model. |
| AC3 | System prompt prepended as a messages[0] with role: 'system'. |
| AC4 | createOpenAiCompletionWithTools wraps tools into {type:'function', function:{name, description, parameters}}. |
| AC5 | Empty tools array → no tools key in body (Anthropic parity). |
| AC6 | options.model switches between gpt-4o and gpt-4o-mini (the singleton-model AC). |
| AC7 | Tool-call response — tool_calls mapped to tool_use-shape array, content is JSON-stringified. |
| AC8 | Tool-call arguments — arguments string JSON-parsed into input object. |
| AC9 | Legacy function_call response normalized identically to tool_calls. |
| AC10 | Token mapping — prompt_tokens → promptTokens, completion_tokens → completionTokens. |
| AC11 | OpenAiConfigError raised when COLIBRI_OPENAI_API_KEY absent. |
| AC12 | 429 → exponential backoff retry. |
| AC13 | 5xx → exponential backoff retry. |
| AC14 | Retries exhausted → OpenAiApiError with code OPENAI_RETRIES_EXHAUSTED. |
| AC15 | Network error → OpenAiApiError immediate (no retry). |
| AC16 | [openai] log line on success — includes model, prompt_tokens, completion_tokens, latency_ms. |
| AC17 | COLIBRI_OPENAI_BASE_URL override changes the URL. |
| AC18 | options.baseUrl overrides process.env['COLIBRI_OPENAI_BASE_URL']. |
| AC19 | Adapter satisfies CompletionFn shape (compile-time + runtime type-assignment). |
| AC20 | Terminal 4xx (e.g., 400, 401) → no retry. |
8. Risks + mitigations
| Risk | Mitigation |
|---|---|
Sibling worktrees (P1.5.2 Kimi, P1.5.3 Codex) race on index.ts. |
Hard override: do not touch index.ts. Re-export coordination is a Wave 4 fold-in commit. |
OpenAI’s arguments JSON-string can fail JSON.parse. |
Adapter raises OpenAiApiError with a descriptive message if JSON.parse(arguments) throws (defensive — the model is supposed to emit valid JSON, but some failures leak). |
| Legacy vs. new tool-shape detection ambiguity. | Adapter checks tool_calls first (the modern shape); falls back to function_call only if tool_calls is absent. Documented at the dispatch site. |
| Azure OpenAI deployment URLs use a different path structure. | Operator-provided base URL is used verbatim with /chat/completions appended; documented as the responsibility of the operator. |
COLIBRI_OPENAI_BASE_URL could be malformed. |
Validated at call time only — adapter does not enforce URL parseability (matches Phase 0 COLIBRI_WEBHOOK_URL’s call-site validation pattern). |
9. Out of scope
- Streaming responses (
stream: true). Phase 1.5 ships non-streaming first. - Function-calling with parallel tools. The adapter accepts multiple
tool_callsin a response (the response shape is an array), but the router’s fallback module is single-call-per-attempt. - Image / vision inputs. The candidate table marks
gpt-4oas multimodal but Phase 1.5 ships text-only prompts. - Cost-tracking annotation (
costUsd). Phase 1.5.6 fold-in. - Embedding endpoints (
/v1/embeddings). Out of scope per ADR-005 §Decision. - Adding
COLIBRI_OPENAI_*tosrc/config.tsschema. Call-time validation is the explicit pattern for adapter-local secrets (matches Phase 0ANTHROPIC_API_KEY’s schema-optional + call-time-required posture).
10. Base SHA + worktree
- Base:
origin/mainat89adef66(post-P1.5.1 #252 merge). - Worktree:
E:\AMS\.worktrees\claude\p1-5-4-openai-adapter. - Branch:
feature/p1-5-4-openai-adapter.
11. Acceptance summary
After Step 4 (Implement), the following will be true:
src/domains/router/adapters/openai.tsexportscreateOpenAiCompletion,createOpenAiCompletionWithTools,OpenAiConfigError,OpenAiApiError,OpenAiTool,OpenAiCompletionOptions, all assignable to the router’sCompletionFnshape.src/__tests__/domains/router/adapters/openai.test.tshas 20+ tests, all parity-aligned withsrc/__tests__/domains/integrations/claude.test.ts.src/domains/router/index.tsis byte-identical to base SHA89adef66.npm run build && npm run lint && npm testgreen; zero regression vs. main89adef66.
End of audit.