P1.5.4 — OpenAI Adapter (GPT-4o family) — Execution Packet (Step 3)
This packet defines the concrete execution plan for Step 4 (Implement) and Step 5 (Verify) per the contract signed in Step 2.
1. Implementation sequence
Step 4 sub-sequence
- Create
src/domains/router/adapters/directory. - Create
src/__tests__/domains/router/adapters/directory. - Implement
src/domains/router/adapters/openai.ts(~360 LOC). - Implement
src/__tests__/domains/router/adapters/openai.test.ts(~700 LOC, ~25 tests). - Run
npm run build— fails iff TypeScript types are wrong. - Run
npm run lint— fails iff stylistic rules violated. - Run
npm test -- --testPathPattern="openai"— green. - Run
npm test(full suite) — green; expected delta 3153 → 3153 + N (N ≈ 25). - Commit
feat(p1-5-4-openai-adapter): OpenAI GPT-4o adapter with surface parity (no stubs).
Step 5 sub-sequence
- Verify file enumeration:
src/domains/router/adapters/openai.ts— createdsrc/__tests__/domains/router/adapters/openai.test.ts— createdsrc/domains/router/index.ts— byte-identical to89adef66(CRITICAL)
- Run final test gate trifecta:
npm run build && npm run lint && npm test. - Inventory parity-test pass count against the AC table (§7 of audit).
- Write
docs/verification/p1-5-4-openai-adapter-verification.md. - Commit
verify(p1-5-4-openai-adapter): parity tests + mapping evidence.
2. Module shape — src/domains/router/adapters/openai.ts
2.1 Imports
import type {
CompletionResult,
} from '../../integrations/claude.js';
The adapter imports CompletionResult as a type-only re-export from the
Phase 0 ν Claude adapter. This is the single load-bearing import —
it guarantees CompletionResult from openai.ts and claude.ts are
literally the same type, which is what makes both adapters assignable to
the router’s CompletionFn shape.
No runtime dependencies from claude.ts are imported (no error classes
shared, no helpers shared). The adapter is fully self-contained at the
runtime level.
2.2 Constants
const OPENAI_API_BASE_DEFAULT = 'https://api.openai.com/v1';
const OPENAI_PATH_CHAT = '/chat/completions';
const DEFAULT_MODEL = 'gpt-4o';
const DEFAULT_MAX_TOKENS = 1024;
const MAX_RETRIES = 3;
const BASE_DELAY_MS = 100;
The DEFAULT_MODEL is the only model id literal in the entire module — and
it is the default value injected when options.model is undefined. The
caller is responsible for supplying 'gpt-4o-mini' (or any other variant)
via options.model. This is the singleton-module-handles-the-family AC.
2.3 Error classes
export class OpenAiConfigError extends Error {
readonly code = 'OPENAI_CONFIG_ERROR' as const;
constructor(message: string) { super(message); this.name = 'OpenAiConfigError'; }
}
export class OpenAiApiError extends Error {
readonly code: 'OPENAI_API_ERROR' | 'OPENAI_RETRIES_EXHAUSTED';
readonly status: number | undefined;
constructor(message: string, status?: number, exhausted = false) {
super(message);
this.name = 'OpenAiApiError';
this.status = status;
this.code = exhausted ? 'OPENAI_RETRIES_EXHAUSTED' : 'OPENAI_API_ERROR';
}
}
2.4 Interfaces
export interface OpenAiTool {
readonly name: string;
readonly description: string;
readonly input_schema: Record<string, unknown>;
}
export interface OpenAiCompletionOptions {
readonly model?: string;
readonly maxTokens?: number;
readonly systemPrompt?: string;
readonly apiKey?: string;
readonly baseUrl?: string;
readonly fetchFn?: typeof fetch;
readonly logger?: (...args: unknown[]) => void;
readonly delayFn?: (ms: number) => Promise<void>;
}
CompletionResult is re-exported from claude.js:
export type { CompletionResult } from '../../integrations/claude.js';
2.5 Helpers (file-internal)
function isRetryable(status: number): boolean { /* same as claude.ts */ }
function sleep(ms: number): Promise<void> { /* same as claude.ts */ }
/** Build the OpenAI Chat Completions request body. */
function buildRequestBody(
prompt: string,
model: string,
maxTokens: number,
systemPrompt?: string,
tools?: ReadonlyArray<OpenAiTool>,
): Record<string, unknown>;
/** Wrap AnthropicTool[] into OpenAI function-call envelopes. */
function mapToolsForRequest(tools: ReadonlyArray<OpenAiTool>): Array<Record<string, unknown>>;
/** Normalize legacy function_call into a tool_calls-shaped array. */
function normalizeToolCalls(message: Record<string, unknown>): Array<Record<string, unknown>>;
/** Map an OpenAI tool_calls response into an Anthropic-shape content array. */
function mapToolUseResponse(toolCalls: Array<Record<string, unknown>>): Array<Record<string, unknown>>;
/** Parse the OpenAI Chat Completions response body into CompletionResult. */
function parseResult(json: any, startMs: number): CompletionResult;
2.6 Retry loop
async function attemptWithRetry(
apiKey: string,
baseUrl: string,
requestBody: Record<string, unknown>,
options: OpenAiCompletionOptions,
): Promise<CompletionResult> {
// structurally identical to claude.ts attemptWithRetry, with:
// - URL: `${baseUrl}${OPENAI_PATH_CHAT}`
// - Authorization: `Bearer ${apiKey}`
// - Content-Type: 'application/json'
// - log prefix: '[openai]'
// - error class: OpenAiApiError
}
2.7 Public API
export async function createOpenAiCompletion(
prompt: string,
options: OpenAiCompletionOptions = {},
): Promise<CompletionResult>;
export async function createOpenAiCompletionWithTools(
prompt: string,
tools: ReadonlyArray<OpenAiTool>,
options: OpenAiCompletionOptions = {},
): Promise<CompletionResult>;
Both:
- Resolve
apiKeyviaoptions.apiKey ?? process.env['COLIBRI_OPENAI_API_KEY']. - Throw
OpenAiConfigErrorif missing. - Resolve
baseUrlviaoptions.baseUrl ?? process.env['COLIBRI_OPENAI_BASE_URL'] ?? OPENAI_API_BASE_DEFAULT. - Resolve
modelviaoptions.model ?? DEFAULT_MODEL. - Resolve
maxTokensviaoptions.maxTokens ?? DEFAULT_MAX_TOKENS. - Build body via
buildRequestBody(...). - Delegate to
attemptWithRetry(...).
3. Test file shape — src/__tests__/domains/router/adapters/openai.test.ts
3.1 Fixtures
const FAKE_API_KEY = 'sk-test-openai-fake-key';
const FAKE_MODEL = 'gpt-4o-test';
const FAKE_PROMPT = 'Say hello.';
const SUCCESS_RESPONSE = {
id: 'chatcmpl-test-001',
object: 'chat.completion',
created: 1234567890,
model: FAKE_MODEL,
choices: [
{ index: 0, message: { role: 'assistant', content: 'Hello!' }, finish_reason: 'stop' },
],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
};
const TOOL_USE_RESPONSE = {
id: 'chatcmpl-test-002',
model: FAKE_MODEL,
choices: [
{
index: 0,
message: {
role: 'assistant',
content: null,
tool_calls: [
{
id: 'call_abc',
type: 'function',
function: {
name: 'get_weather',
arguments: '{"location":"London"}',
},
},
],
},
finish_reason: 'tool_calls',
},
],
usage: { prompt_tokens: 20, completion_tokens: 12, total_tokens: 32 },
};
const LEGACY_FUNCTION_CALL_RESPONSE = {
id: 'chatcmpl-test-003',
model: FAKE_MODEL,
choices: [
{
index: 0,
message: {
role: 'assistant',
content: null,
function_call: {
name: 'get_weather',
arguments: '{"location":"Paris"}',
},
},
finish_reason: 'function_call',
},
],
usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 },
};
const SAMPLE_TOOL: OpenAiTool = {
name: 'get_weather',
description: 'Get the current weather',
input_schema: {
type: 'object',
properties: { location: { type: 'string' } },
required: ['location'],
},
};
3.2 Mock helpers (mirror Phase 0 claude.test.ts)
makeMockFetch, makeSilentLogger, makeInstantDelay, baseOptions — all
structurally identical to src/__tests__/domains/integrations/claude.test.ts
helpers, with the response body fixtures swapped.
3.3 Test grouping
describe('createOpenAiCompletion — success path', ...) // AC1, AC2, AC3
describe('createOpenAiCompletionWithTools — request shape', ...) // AC4, AC5
describe('model selection', ...) // AC6
describe('tool-use response mapping', ...) // AC7, AC8, AC9
describe('token + finish-reason mapping', ...) // AC10
describe('API key validation', ...) // AC11
describe('retry logic — 429', ...) // AC12, AC14
describe('retry logic — 5xx', ...) // AC13
describe('terminal errors — no retry', ...) // AC15, AC20
describe('logging', ...) // AC16
describe('base URL configuration', ...) // AC17, AC18
describe('CompletionFn shape parity', ...) // AC19
4. File-disjointness guarantee
P1.5.2 (Kimi), P1.5.3 (Codex), P1.5.4 (OpenAI — this slice) all run in parallel in Wave 3. Their file footprints:
| Slice | Files written |
|---|---|
| P1.5.2 Kimi | src/domains/router/adapters/kimi.ts + tests + 5 chain docs |
| P1.5.3 Codex | src/domains/router/adapters/codex.ts + tests + 5 chain docs |
| P1.5.4 OpenAI (this) | src/domains/router/adapters/openai.ts + tests + 5 chain docs |
| Wave 4 fold-in | src/domains/router/index.ts (re-exports for all three) |
There are zero file overlaps between the three parallel slices. The
index.ts re-export is intentionally deferred to a coordinator fold-in.
5. Test count expectation
Base: 3153 tests across NN suites at 89adef66.
Delta: + ~25 from new openai.test.ts.
Expected post-PR: 3178 (3153 + 25).
6. Failure modes + recovery
| Failure | Likely cause | Recovery |
|---|---|---|
npm run build red on adapter |
TypeScript misalignment with CompletionResult |
Re-import CompletionResult from claude.js instead of redefining; ensure OpenAiTool is readonly on all fields. |
npm run lint red |
unused import / any lint rule |
Mirror claude.ts’s // eslint-disable-next-line @typescript-eslint/no-explicit-any annotations on the response-body parser. |
npm test red on full suite |
accidental mutation of shared state | The adapter is pure (no module-scope state). Check that test fixtures use Object.freeze if shared between tests. |
Pre-existing server-startup-smoke flake |
unrelated to this slice | Re-run; documented as a known flake at 09d462f8 per memory. |
| Test mistakenly hits real OpenAI | injected fetchFn not threaded through helper |
Use baseOptions({ fetchFn }) pattern from claude.test.ts; never call createOpenAiCompletion without fetchFn in tests. |
7. Compile-time CompletionFn assignment proof
The test file will include:
import type { CompletionFn } from '../../../../domains/router/fallback.js';
import {
createOpenAiCompletion,
createOpenAiCompletionWithTools,
type OpenAiTool,
} from '../../../../domains/router/adapters/openai.js';
// Compile-time parity assertion: if these assignments fail to typecheck,
// the adapter has drifted from CompletionFn and the build will fail.
const _completionFnParity: CompletionFn = createOpenAiCompletion;
const _completionFnWithToolsParity: CompletionFn = (prompt, opts) =>
createOpenAiCompletionWithTools(prompt, [] as ReadonlyArray<OpenAiTool>, opts);
// Reference both to silence "unused variable" lint:
void _completionFnParity;
void _completionFnWithToolsParity;
8. Step 5 verification checklist (preview)
src/domains/router/adapters/openai.tsexists.src/__tests__/domains/router/adapters/openai.test.tsexists.src/domains/router/index.tsbyte-identical to89adef66.- All exports listed in §1.1 + §1.2 + §1.3 present.
- AC1–AC20 each have a matching test.
npm run buildgreen.npm run lintgreen.npm testgreen; test count rises by ≥ 5.- No
process.stdout.writecall anywhere inopenai.ts. - No reference to
AMS_*env var. - No MCP tool registration.
- No literal
gpt-4o-minioutside test fixtures (the adapter uses the caller-suppliedoptions.model). - No
index.tsmutation.
9. Writeback preview
task_update:
task_id: P1.5.4
status: done
progress: 100
thought_record:
task_id: P1.5.4
branch: feature/p1-5-4-openai-adapter
commits: [<sha1>, <sha2>, <sha3>, <sha4>, <sha5>]
worktree: .worktrees/claude/p1-5-4-openai-adapter
tests_run: ['npm run build', 'npm run lint', 'npm test']
summary: "OpenAI adapter ships with GPT-4o + GPT-4o mini under a single
module. Env COLIBRI_OPENAI_API_KEY + COLIBRI_OPENAI_BASE_URL.
tool_calls response mapped into AnthropicTool shape (with
JSON-parsing of arguments). options.model selects the
specific GPT-4o variant. Legacy function_call accepted on
response side. 25 parity tests green. index.ts untouched per
Wave 3 file-disjoint constraint — re-export deferred to
Wave 4 fold-in."
blockers: []
End of packet.