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:

  1. Mirrors the surface parity of the Phase 0 ν Claude adapter at src/domains/integrations/claude.ts (P0.9.2 shipped).
  2. Covers GPT-4o and GPT-4o mini under a single module — options.model selects the specific variant (no separate file per model).
  3. Reads COLIBRI_OPENAI_API_KEY + COLIBRI_OPENAI_BASE_URL at call-time (no schema-level requirement; missing key throws OpenAiConfigError).
  4. Maps OpenAI’s tool_calls response shape into AnthropicTool-compatible blocks so the router’s existing tool-use plumbing accepts the output without a type-coercion adapter at the call site.
  5. Provides injection seams (fetchFn, logger, delayFn, apiKey, baseUrl) so tests never hit the real OpenAI API.
  6. Implements the same exponential-backoff retry semantics as the Claude adapter (429 + 5xx → up to 3 retries, base delay 100 ms doubling).
  7. Adds no MCP tool registration — router_call wiring is P1.5.7+ scope.
  8. Adds no src/domains/router/index.ts re-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; never process.stdout.
  • Injection seams — fetchFn, logger, delayFn all 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:

  1. Content location. Anthropic puts everything in content: [{type, ...}]; OpenAI puts text in choices[0].message.content and tool calls in choices[0].message.tool_calls.
  2. Tool-call arguments encoding. OpenAI delivers function.arguments as a JSON-encoded string (not an object). Anthropic delivers input as a parsed object.
  3. Finish-reason naming. OpenAI uses stop / tool_calls / length / content_filter; Anthropic uses end_turn / tool_use / max_tokens / stop_sequence. The adapter records the OpenAI value verbatim in stopReason (no normalization — the router treats it as opaque).
  4. Token usage key names. OpenAI: usage.prompt_tokens + usage.completion_tokens. Anthropic: usage.input_tokens + usage.output_tokens.
  5. Legacy function_call shape. Older models used a single function_call on message instead of an array tool_calls. The adapter accepts the legacy shape on the response side too, but emits only the new shape upstream into the content field.

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, OpenAiApiError raised.
  • 429 — retryable, exponential backoff, max 3 retries, OpenAiApiError with code OPENAI_RETRIES_EXHAUSTED after exhaustion.
  • 5xx — retryable like 429.
  • Network errorOpenAiApiError raised, 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_KEY would clash with the OPENAI_API_KEY many shells already have for an unrelated client. The adapter uses the COLIBRI_* prefix to match the project’s namespace floor (config.ts §1).
  • The Anthropic adapter reuses the vendor name ANTHROPIC_API_KEY because it lives in src/config.ts schema. The OpenAI adapter lives in src/domains/router/adapters/, reads via options.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_tokenspromptTokens, completion_tokenscompletionTokens.
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_calls in 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-4o as 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_* to src/config.ts schema. Call-time validation is the explicit pattern for adapter-local secrets (matches Phase 0 ANTHROPIC_API_KEY’s schema-optional + call-time-required posture).

10. Base SHA + worktree

  • Base: origin/main at 89adef66 (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:

  1. src/domains/router/adapters/openai.ts exports createOpenAiCompletion, createOpenAiCompletionWithTools, OpenAiConfigError, OpenAiApiError, OpenAiTool, OpenAiCompletionOptions, all assignable to the router’s CompletionFn shape.
  2. src/__tests__/domains/router/adapters/openai.test.ts has 20+ tests, all parity-aligned with src/__tests__/domains/integrations/claude.test.ts.
  3. src/domains/router/index.ts is byte-identical to base SHA 89adef66.
  4. npm run build && npm run lint && npm test green; zero regression vs. main 89adef66.

End of audit.


Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.