P1.5.4 — OpenAI Adapter (GPT-4o family) — Behavioral Contract (Step 2)

This contract states the behavioral invariants the P1.5.4 implementation satisfies. The contract is signed at audit time and verified at Step 5.

1. Public surface

The adapter module src/domains/router/adapters/openai.ts exports:

1.1 Functions

/**
 * Standard text completion via OpenAI Chat Completions.
 * Reads COLIBRI_OPENAI_API_KEY at call-time; raises OpenAiConfigError if absent.
 */
export async function createOpenAiCompletion(
  prompt: string,
  options?: OpenAiCompletionOptions,
): Promise<CompletionResult>;

/**
 * Tool-use completion. `tools` is the AnthropicTool[] shape; the adapter
 * wraps each into OpenAI's { type: 'function', function: {...} } envelope
 * before sending, and maps tool_calls responses back to AnthropicTool-shape
 * content blocks (JSON-stringified) on the way out.
 */
export async function createOpenAiCompletionWithTools(
  prompt: string,
  tools: ReadonlyArray<OpenAiTool>,
  options?: OpenAiCompletionOptions,
): Promise<CompletionResult>;

1.2 Error classes

/**
 * Raised when COLIBRI_OPENAI_API_KEY is absent at call-time.
 * Distinct from OpenAiApiError so callers handle config vs. API failures
 * separately. Mirrors AnthropicConfigError.
 */
export class OpenAiConfigError extends Error {
  readonly code: 'OPENAI_CONFIG_ERROR';
}

/**
 * Raised on terminal API error or retries exhausted.
 * `status` is the HTTP status code (or undefined for network-level failures).
 * Mirrors AnthropicApiError.
 */
export class OpenAiApiError extends Error {
  readonly code: 'OPENAI_API_ERROR' | 'OPENAI_RETRIES_EXHAUSTED';
  readonly status: number | undefined;
}

1.3 Types

/**
 * Tool descriptor — Anthropic-shaped (passed in by the router which
 * normalizes around AnthropicTool). The adapter wraps each item into
 * OpenAI's function-call envelope at request time.
 *
 * Aliased as OpenAiTool for export discoverability and self-documentation;
 * structurally identical to the AnthropicTool shape from
 * src/domains/integrations/claude.ts:95-99.
 */
export interface OpenAiTool {
  readonly name: string;
  readonly description: string;
  readonly input_schema: Record<string, unknown>;
}

/**
 * Options bag. Every field is optional. `baseUrl` is the addition vs.
 * AnthropicCompletionOptions — necessary for Azure OpenAI redirection.
 */
export interface OpenAiCompletionOptions {
  readonly model?: string;       // default: 'gpt-4o'
  readonly maxTokens?: number;   // default: 1024
  readonly systemPrompt?: string;
  readonly apiKey?: string;      // overrides process.env.COLIBRI_OPENAI_API_KEY
  readonly baseUrl?: string;     // overrides COLIBRI_OPENAI_BASE_URL
  readonly fetchFn?: typeof fetch;
  readonly logger?: (...args: unknown[]) => void;
  readonly delayFn?: (ms: number) => Promise<void>;
}

1.4 Result

CompletionResult from src/domains/integrations/claude.ts is re-exported by reference (type-only import + export) — not redefined. This guarantees the two adapters’ results are structurally identical and assignable as the same CompletionFn signature.

export type { CompletionResult } from '../../integrations/claude.js';

2. Parity invariants

ID Statement Verification
P1 Adapter call shape matches CompletionFn from src/domains/router/fallback.ts. Compile-time check: const fn: CompletionFn = createOpenAiCompletion; assigns.
P2 OpenAiTool is structurally identical to AnthropicTool. TypeScript structural-typing: assigning one to the other compiles.
P3 Behavior under 429 + 5xx is identical to Phase 0 Claude adapter (3 retries, base 100 ms, doubling). Tests AC12, AC13, AC14 mirror Anthropic adapter tests.
P4 Network errors are not retried (matches Phase 0). Test AC15.
P5 Terminal 4xx (non-429) → no retry. Test AC20.
P6 All log lines on success: [openai] model=<m> prompt_tokens=<n> completion_tokens=<n> latency_ms=<n>. Test AC16.
P7 CompletionResult.content for tool-use responses is a JSON-stringified array of AnthropicTool-shaped content blocks. Test AC7.
P8 CompletionResult.stopReason is the OpenAI finish_reason value verbatim (no normalization). Test AC7 + AC10 inspect this.
P9 CompletionResult.model is the OpenAI model echoed from the response (which may include a date suffix the request did not include). Test AC2.
P10 The adapter NEVER writes to process.stdout — only to the injected logger or console.error. Inspection at Step 5.

3. Environment contract

Variable Required? Default Validation
COLIBRI_OPENAI_API_KEY call-time required none options.apiKey ?? process.env['COLIBRI_OPENAI_API_KEY']; missing → OpenAiConfigError.
COLIBRI_OPENAI_BASE_URL call-time optional https://api.openai.com/v1 options.baseUrl ?? process.env['COLIBRI_OPENAI_BASE_URL'] ?? DEFAULT.

The adapter does NOT add either variable to src/config.ts schema. The schema floor remains untouched; the adapter operates as a pure call-time consumer of process.env. This matches ANTHROPIC_API_KEY’s call-time validation pattern, modulo the namespace difference (the Anthropic key IS in the schema but only because P0.9.2 schema-declared it before P0.9.2’s adapter actually used it).

3.1 Schema posture rationale

Phase 1.5 lays down five vendor adapters (Anthropic / Kimi / Codex / OpenAI / plus more in later waves). Adding each COLIBRI_<VENDOR>_* set to the boot schema would bloat src/config.ts and require schema bumps for every adapter slice. The adapter-local + call-time pattern keeps boot-time validation focused on the Phase 0 floor (DB path, log level, webhook URL, the one schema-declared Anthropic key + model + timeout).

4. Tool-use mapping table

4.1 Request side — OpenAiTool[] → OpenAI tools[]

Per AnthropicTool item:

{
  name: "get_weather",
  description: "Get the current weather",
  input_schema: { type: "object", properties: { location: { type: "string" } }, required: ["location"] }
}

Becomes:

{
  type: "function",
  function: {
    name: "get_weather",
    description: "Get the current weather",
    parameters: { type: "object", properties: { location: { type: "string" } }, required: ["location"] }
  }
}

Field-by-field mapping:

Source (OpenAiTool) Target (OpenAI request tools[i]) Operation
(literal) type constant 'function'
name function.name passthrough
description function.description passthrough
input_schema function.parameters passthrough (key rename)

Empty tools[] array → no tools key in the request body (matches Anthropic adapter degrade-to-plain behavior).

4.2 Response side — OpenAI choices[0].message.tool_calls → Anthropic-shape content

Per tool_calls[i]:

{ id: "call_abc123", type: "function", function: { name: "get_weather", arguments: "{\"location\":\"London\"}" } }

Becomes (in the JSON-stringified CompletionResult.content array):

{ type: "tool_use", id: "call_abc123", name: "get_weather", input: { location: "London" } }

Field-by-field mapping:

Source (OpenAI response tool_calls[i]) Target (content[i]) Operation
id id passthrough
(literal 'function') (dropped) redundant with synthesized type
(synthesized) type constant 'tool_use'
function.name name passthrough
function.arguments (JSON string) input (object) JSON.parse(arguments)

4.3 Legacy function_call accepted on response side

OpenAI’s older shape:

{
  message: {
    role: "assistant",
    content: null,
    function_call: { name: "get_weather", arguments: "{...}" }
  }
}

Adapter normalizes this to a single-element tool_calls-shaped array before the §4.2 mapping fires. Synthesized id for the legacy shape: 'legacy-fcall-0' (no real ID is available — the model contract did not provide one).

4.4 Text content path

When tool_calls and function_call are both absent and choices[0].message.content is a non-empty string, the adapter sets CompletionResult.content to that string directly (no JSON.stringify wrapping). This matches the Anthropic text-block extraction at src/domains/integrations/claude.ts:204-217.

5. Error hierarchy

Condition Error Code HTTP status (if applicable)
COLIBRI_OPENAI_API_KEY absent at call-time OpenAiConfigError OPENAI_CONFIG_ERROR
HTTP 4xx (non-429) returned OpenAiApiError OPENAI_API_ERROR the status
HTTP 429 retries exhausted OpenAiApiError OPENAI_RETRIES_EXHAUSTED 429
HTTP 5xx retries exhausted OpenAiApiError OPENAI_RETRIES_EXHAUSTED last 5xx status
Network error (fetch threw) OpenAiApiError OPENAI_API_ERROR undefined
JSON.parse(arguments) failed for a tool call OpenAiApiError OPENAI_API_ERROR undefined (and indicates the model emitted invalid JSON)

All OpenAiApiError instances carry a human-readable message that includes the HTTP status (when applicable) and a short context fragment indicating which API call failed.

6. Retry policy (parity with Phase 0 Claude adapter)

  • 429 + any 5xx → retry up to MAX_RETRIES = 3 times.
  • Backoff: exponential, base delay BASE_DELAY_MS = 100, doubling each attempt (100, 200, 400 ms).
  • Delays use options.delayFn if provided, otherwise a setTimeout-based sleep.
  • Total attempts on success: 1 to 4 (1 initial + up to 3 retries).
  • All retries log via the injected logger — pattern: [openai] retry attempt=<n> after_ms=<delay> last_status=<status>.

7. Determinism contract

  • The adapter does NOT read Date.now() for any purpose other than computing latencyMs. Two calls with identical inputs + a recorded fetch sequence + injected delayFn produce identical outputs except for latencyMs.
  • The adapter does NOT use Math.random().
  • The adapter does NOT mutate any module-scope state.
  • The adapter does NOT read from a database or filesystem.

8. Result shape

interface CompletionResult {
  readonly content: string;          // text OR JSON-stringified tool-use blocks
  readonly model: string;            // OpenAI's echoed model (may include date)
  readonly promptTokens: number;     // from usage.prompt_tokens
  readonly completionTokens: number; // from usage.completion_tokens
  readonly latencyMs: number;        // ≥ 0; measured around the fetch call
  readonly stopReason: string;       // OpenAI's finish_reason verbatim
}

9. Logging contract

Event Log line
Success [openai] model=<m> prompt_tokens=<n> completion_tokens=<n> latency_ms=<n>
Retry [openai] retry attempt=<n> after_ms=<delay> last_status=<status>
Terminal error (no log; the thrown error carries the message)

All log lines go through the injected logger (default: console.error). NEVER process.stdout — the MCP StdioServerTransport owns stdout.

10. Forbiddens

The adapter MUST NOT:

  • Touch src/domains/router/index.ts (Wave 3 file-disjoint constraint).
  • Touch src/config.ts (call-time env reads only).
  • Touch src/server.ts (no MCP tool registration).
  • Touch src/domains/integrations/claude.ts (Phase 0 reference; not Phase 1.5 scope).
  • Touch src/db/migrations/* (P1.5.9 already handled).
  • Touch src/domains/router/scoring.ts (P1.5.1 scope; sealed at 89adef66).
  • Touch src/domains/router/fallback.ts (P1.5.5 scope).
  • Read AMS_* env vars (rejected by assertNoDonorNamespace).
  • Hardcode a model version (gpt-4o is the default, NOT a literal in flow).
  • Implement fallback / circuit-breaker logic (P1.5.5 scope).
  • Stream responses (stream: true is Phase 1.5+ optional).
  • Add image / vision content blocks.

11. Compile-time + runtime assignment to CompletionFn

// Compile-time:
import type { CompletionFn } from '../fallback.js';
const _typeCheck: CompletionFn = createOpenAiCompletion;
const _typeCheck2: CompletionFn = (prompt, opts) =>
  createOpenAiCompletionWithTools(prompt, [], opts);

The contract requires that both public functions are structurally assignable to CompletionFn. Step 5 verifies this via the test file including the above assignment (commented as a // @ts-expect-no-error sentinel).

12. Migration / rollback

Adding the adapter is additive — no migration. Rolling it back means deleting src/domains/router/adapters/openai.ts + its test file. Since index.ts is NOT mutated, removal does not cascade into any other module. The OpenAI candidate row in mcp_model_candidates continues to ship enabled = 0 until the Wave 4 fold-in commit re-exports + the candidate flip lands.

End of contract.


Back to top

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

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