P1.5.3 — Codex Adapter — Step 2 Contract
Round: R92, Wave 3 (parallel slice 2/3) — p1-5-3-codex-adapter
Base SHA: 89adef66
Step: 2 of 5 (contract)
Author tier: T3 executor
§1. Scope
This contract governs src/domains/router/adapters/codex.ts and its
test suite at src/__tests__/domains/router/adapters/codex.test.ts. It
binds the adapter’s public surface, error semantics, env contract,
tool-use mapping, retry policy, logging, and forbidden mutations.
The audit at docs/audits/p1-5-3-codex-adapter-audit.md is the
informational sister document; this contract is the prescriptive one.
§2. Public exports
The module MUST export exactly these symbols (and only these — no unintended exports):
| Symbol | Kind | Required? |
|---|---|---|
createCodexCompletion |
function | yes |
createCodexCompletionWithTools |
function | yes |
CodexApiError |
class extends Error |
yes |
CodexConfigError |
class extends Error |
yes |
CodexCompletionOptions |
interface | yes |
CodexCompletionResult |
type alias for the shared CompletionResult |
yes |
CompletionResult itself is re-exported from
../integrations/claude.js to keep the shape canonical and avoid
divergence. The Codex adapter does NOT define its own
CompletionResult shape.
AnthropicTool is imported and re-exported so callers of the Codex
adapter never need to import from integrations/claude.js directly —
the adapter is the router’s view of the tool surface, and the type is
the router contract.
§3. Signatures
3.1 createCodexCompletion
function createCodexCompletion(
prompt: string,
options?: CodexCompletionOptions,
): Promise<CompletionResult>;
Semantics:
- Resolves
apiKeyfromoptions.apiKey ?? process.env.COLIBRI_CODEX_API_KEY. If absent, throwsCodexConfigErrorsynchronously (rejected promise). - Resolves
baseUrlfromoptions.baseUrl ?? process.env.COLIBRI_CODEX_BASE_URL ?? CODEX_API_BASE. - Resolves
modelfromoptions.model ?? DEFAULT_CODEX_MODEL('gpt-4o-mini'). - Resolves
maxTokensfromoptions.maxTokens ?? DEFAULT_MAX_TOKENS(1024). - Builds a Codex chat-completions request body (see §4).
- POSTs to
${baseUrl}/chat/completionsviaoptions.fetchFn ?? globalThis.fetch. - On 2xx: parses, logs (stderr), returns
CompletionResult. - On 429/5xx: retries up to
MAX_RETRIES = 3withBASE_DELAY_MS = 100exponential backoff (delays passed throughoptions.delayFn ?? sleep). - On other 4xx: throws
CodexApiErrorwith status + codeCODEX_API_ERROR. - On exhausted retries: throws
CodexApiErrorwith status + codeCODEX_RETRIES_EXHAUSTED. - On network error (fetch reject): throws
CodexApiErrorwithstatus: undefinedand codeCODEX_API_ERROR.
3.2 createCodexCompletionWithTools
function createCodexCompletionWithTools(
prompt: string,
tools: AnthropicTool[],
options?: CodexCompletionOptions,
): Promise<CompletionResult>;
Semantics:
- All of §3.1 applies.
- Translates
tools[](Anthropic shape) → OpenAI tool shape (§5.1) before building the request body. - When
tools.length === 0: degrades tocreateCodexCompletionshape (notoolskey in request body — OpenAI rejects an emptytoolsarray with HTTP 400, matching Anthropic’s behaviour). - On a
tool_callsresponse: synthesises an Anthropic-shape content array (§5.2) and JSON-stringifies it intoCompletionResult.contentfor byte-shape parity with the Claude adapter’s tool-use path.
§4. Codex request body shape
{
"model": "<resolved model id>",
"max_tokens": 1024,
"messages": [
// System message OPTIONAL — only present when options.systemPrompt is non-empty
{"role": "system", "content": "..."},
{"role": "user", "content": "<prompt>"}
],
// Tools array OPTIONAL — only present when tools.length > 0
"tools": [
{
"type": "function",
"function": {
"name": "<tool name>",
"description": "<tool description>",
"parameters": <JSON Schema>
}
}
]
}
Field rules:
model: required. The resolved model id.max_tokens: required. The resolved max tokens.messages: required. Array of length 1 (no system prompt) or 2 (with).tools: omitted unless non-empty.tool_choice: omitted in P1.5.3 (model decides).stream: omitted (Codex defaults to non-streaming).temperature,top_p,n,stop,presence_penalty,frequency_penalty: omitted (use Codex defaults — same posture as Claude adapter).
The body is built by an internal helper buildCodexRequestBody(...)
exactly analogous to buildRequestBody in integrations/claude.ts.
§5. Tool-use translation contract
5.1 Input: AnthropicTool → OpenAI tool
For each element t of the tools[] input array, the request body
contains the corresponding element:
{
type: 'function',
function: {
name: t.name,
description: t.description,
parameters: t.input_schema,
},
}
Properties are inserted in this exact order so request bodies are JSON.stringify-stable (matters for log readability and request-cache testing).
5.2 Output: OpenAI tool_calls → Anthropic content shape
When the Codex response contains a non-empty
choices[0].message.tool_calls[] array, the adapter synthesises an
Anthropic-shape content array and JSON-stringifies it into
CompletionResult.content:
const synthesised = tool_calls.map((c) => ({
type: 'tool_use',
id: c.id,
name: c.function.name,
input: tryParseJson(c.function.arguments),
}));
return JSON.stringify(synthesised);
tryParseJson:
- Returns the parsed object on success.
- Returns the raw string on
JSON.parsefailure (Codex MAY emit truncated argument strings under streaming; we are non-streaming, so this is defensive). The logger emits a WARN line in this case.
When tool_calls is empty/absent AND content is a non-empty string,
the adapter returns content directly (text path — matches Claude
adapter’s content[0].text extraction).
§6. Response parsing
The internal helper parseCodexResult(json, startMs):
const latencyMs = Date.now() - startMs;- Extract
choices[0](defensive —choicesis an array of length 1 for non-streaming non-n>1requests). - Extract
message.contentandmessage.tool_calls:- If
tool_callsis non-empty array → synthesise Anthropic content shape (§5.2) and JSON-stringify intocontent. - Else if
contentis a non-empty string → use it directly. - Else → empty string.
- If
- Extract
finish_reasonand normalise via the §7 table. - Extract
usage.prompt_tokens→promptTokens(default 0). - Extract
usage.completion_tokens→completionTokens(default 0). - Echo
json.model→result.model(default'unknown'). - Return a frozen-able plain object matching
CompletionResult.
§7. Finish-reason normalisation table
Codex finish_reason |
Normalised stopReason |
|---|---|
'stop' |
'end_turn' |
'tool_calls' |
'tool_use' |
'length' |
'max_tokens' |
'content_filter' |
'content_filter' (pass-through) |
'function_call' (deprecated) |
'tool_use' |
absent / null / unknown string |
'unknown' |
A pure helper normalizeFinishReason(raw) implements the table. Tests
cover every row (table-driven test in codex.test.ts).
§8. Error classes
8.1 CodexConfigError
class CodexConfigError extends Error {
readonly code = 'CODEX_CONFIG_ERROR' as const;
constructor(message: string);
}
Thrown only when apiKey cannot be resolved at call time.
8.2 CodexApiError
class CodexApiError extends Error {
readonly code: 'CODEX_API_ERROR' | 'CODEX_RETRIES_EXHAUSTED';
readonly status: number | undefined;
constructor(message: string, status?: number, exhausted?: boolean);
}
Thrown on:
- Terminal HTTP error (4xx except 429) —
code: 'CODEX_API_ERROR',status: <code>. - Exhausted retries —
code: 'CODEX_RETRIES_EXHAUSTED',status: <last code>,exhausted: true. - Network-level error (fetch rejected) —
code: 'CODEX_API_ERROR',status: undefined.
Both classes match the shape parity of AnthropicConfigError /
AnthropicApiError byte-for-byte modulo the Anthropic → Codex prefix
and the literal code strings.
§9. Options interface
interface CodexCompletionOptions {
readonly model?: string;
readonly maxTokens?: number;
readonly systemPrompt?: string;
readonly fetchFn?: typeof fetch;
readonly logger?: (...args: unknown[]) => void;
readonly delayFn?: (ms: number) => Promise<void>;
readonly apiKey?: string;
readonly baseUrl?: string;
}
All fields optional. Order in the source file is:
model, maxTokens, systemPrompt, fetchFn, logger, delayFn, apiKey, baseUrl
— mirrors the Claude adapter’s CompletionOptions plus the new
baseUrl seam appended at the end.
§10. Env contract
| Variable | Required | Default | Read at |
|---|---|---|---|
COLIBRI_CODEX_API_KEY |
call-time (else CodexConfigError) |
none | Each call to create* |
COLIBRI_CODEX_BASE_URL |
optional | constant 'https://api.openai.com/v1' |
Each call to create* |
Neither variable is added to src/config.ts. The adapter reads
process.env[...] directly (mirroring how claude.ts reads
process.env['ANTHROPIC_API_KEY']).
Forbidden:
- Any read of
AMS_*namespace — Phase 0 floor refuses to start with these present. - Any import from
src/config.jsfor the Codex env vars.
§11. Logging contract
Per call (after a successful 2xx response):
[codex] model=<resolved model> prompt_tokens=N completion_tokens=N latency_ms=N
- Channel: stderr only (via
options.logger ?? console.error). - Format string-identical to the Claude adapter, with
[claude]→[codex]. - Never writes to
process.stdout(donor bug mitigation —StdioServerTransportowns stdout per CLAUDE.md).
Optional WARN line when tryParseJson fails on tool-call arguments:
[codex] WARN tool_call_id=<id> failed to JSON.parse function.arguments — passing through as string
§12. Retry policy
Identical to Claude adapter:
| Constant | Value |
|---|---|
MAX_RETRIES |
3 |
BASE_DELAY_MS |
100 |
| Retryable statuses | 429 + 5xx |
| Backoff | exponential ×2 (100 → 200 → 400) |
Retry exhaustion ⇒ CodexApiError(code: 'CODEX_RETRIES_EXHAUSTED', status: <last>).
§13. Invariants
| I# | Invariant |
|---|---|
| I1 | Module import does NOT throw on missing COLIBRI_CODEX_API_KEY. |
| I2 | createCodexCompletion returns the shared CompletionResult shape (not a Codex-flavoured variant). |
| I3 | createCodexCompletionWithTools accepts AnthropicTool[] (router contract type), NOT OpenAI-shaped tool objects. |
| I4 | The adapter does NOT import from src/domains/router/index.ts, scoring.ts, fallback.ts, or scoring-weights.ts. |
| I5 | The adapter does NOT modify src/domains/router/index.ts. The export * for codex is deferred to the fold-in commit. |
| I6 | The adapter does NOT register any MCP tool. |
| I7 | The adapter does NOT read AMS_* env vars. |
| I8 | All HTTP traffic is via options.fetchFn (default globalThis.fetch). Tests inject a mock; no real network calls occur in npm test. |
| I9 | All sleeps use options.delayFn (default real setTimeout-backed). Tests inject makeInstantDelay(). |
| I10 | Stderr-only logging — process.stdout.write is never called. |
| I11 | Tool translation is bi-directional and lossless for the supported subset (no streaming, no parallel tool calls beyond an array). |
| I12 | The model id is NEVER hardcoded as a top-level const CODEX_VERSION; it is options.model ?? DEFAULT_CODEX_MODEL. |
| I13 | The base URL has a documented default constant. Override via options.baseUrl ⇒ process.env.COLIBRI_CODEX_BASE_URL ⇒ constant. |
| I14 | CodexConfigError and CodexApiError are unrelated by inheritance (parallel sibling classes — same pattern as the Anthropic pair). |
| I15 | The exported types include CompletionResult (re-exported from claude.js) and AnthropicTool (re-exported from claude.js). |
§14. Tool-use mapping table (canonical — repeats from audit §13)
| Direction | Anthropic side | Codex (OpenAI) side | Conversion |
|---|---|---|---|
| Req: tool def | tools[i] flat |
tools[i].function nested |
Wrap inside {type:'function', function:{...}} |
| Req: tool def | tools[i].name |
tools[i].function.name |
Move nested |
| Req: tool def | tools[i].description |
tools[i].function.description |
Move nested |
| Req: tool def | tools[i].input_schema |
tools[i].function.parameters |
Move + rename |
| Req: system prompt | top-level system key |
first message {role:'system'} |
Restructure |
| Resp: text content | content[i].text (where type:'text') |
choices[0].message.content |
Direct extract |
| Resp: tool call | content[i] (where type:'tool_use') |
choices[0].message.tool_calls[j] |
Different array location |
| Resp: tool call id | content[i].id |
tool_calls[j].id |
Pass-through (Codex prefix preserved) |
| Resp: tool call name | content[i].name |
tool_calls[j].function.name |
Un-nest |
| Resp: tool call args | content[i].input (object) |
tool_calls[j].function.arguments (JSON string) |
JSON.parse (defensive) |
| Resp: finish reason | stop_reason |
choices[0].finish_reason |
Table §7 |
| Resp: prompt tokens | usage.input_tokens |
usage.prompt_tokens |
Rename |
| Resp: completion tokens | usage.output_tokens |
usage.completion_tokens |
Rename |
§15. Forbiddens (CRITICAL section)
The dispatch packet enumerates these; the contract restates them at contract strength:
- Editing main checkout.
git statusfromE:\AMSMUST show clean. - Pushing to
main. Branch isfeature/p1-5-3-codex-adapter. - Force-pushing any branch.
- Modifying
src/domains/router/index.ts. Even a one-lineexport *is forbidden. The fold-in commit owns this between Wave 3 and Wave 4. - Touching any file outside the slice’s 6 files (1 adapter + 1 test
- 4 chain docs).
- AMS_* env vars in any form.
- MCP tool registration (
router_*tools are Phase 1.5.7+). - Hardcoding model version —
options.model ?? DEFAULT_CODEX_MODELonly. - Fallback logic — single-call adapter only; router-level cascade
is
fallback.ts, not this file.
§16. Step 2 exit gate
Contract is complete. The packet (Step 3) may now lay out the implementation sequence + test ordering + verification evidence layout.
Commit message:
contract(p1-5-3-codex-adapter): behavioral contract + tool-use mapping