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 = 3times. - Backoff: exponential, base delay
BASE_DELAY_MS = 100, doubling each attempt (100, 200, 400 ms). - Delays use
options.delayFnif provided, otherwise asetTimeout-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 computinglatencyMs. Two calls with identical inputs + a recorded fetch sequence + injecteddelayFnproduce identical outputs except forlatencyMs. - 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 at89adef66). - Touch
src/domains/router/fallback.ts(P1.5.5 scope). - Read
AMS_*env vars (rejected byassertNoDonorNamespace). - Hardcode a model version (
gpt-4ois the default, NOT a literal in flow). - Implement fallback / circuit-breaker logic (P1.5.5 scope).
- Stream responses (
stream: trueis 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.