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 apiKey from options.apiKey ?? process.env.COLIBRI_CODEX_API_KEY. If absent, throws CodexConfigError synchronously (rejected promise).
  • Resolves baseUrl from options.baseUrl ?? process.env.COLIBRI_CODEX_BASE_URL ?? CODEX_API_BASE.
  • Resolves model from options.model ?? DEFAULT_CODEX_MODEL ('gpt-4o-mini').
  • Resolves maxTokens from options.maxTokens ?? DEFAULT_MAX_TOKENS (1024).
  • Builds a Codex chat-completions request body (see §4).
  • POSTs to ${baseUrl}/chat/completions via options.fetchFn ?? globalThis.fetch.
  • On 2xx: parses, logs (stderr), returns CompletionResult.
  • On 429/5xx: retries up to MAX_RETRIES = 3 with BASE_DELAY_MS = 100 exponential backoff (delays passed through options.delayFn ?? sleep).
  • On other 4xx: throws CodexApiError with status + code CODEX_API_ERROR.
  • On exhausted retries: throws CodexApiError with status + code CODEX_RETRIES_EXHAUSTED.
  • On network error (fetch reject): throws CodexApiError with status: undefined and code CODEX_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 to createCodexCompletion shape (no tools key in request body — OpenAI rejects an empty tools array with HTTP 400, matching Anthropic’s behaviour).
  • On a tool_calls response: synthesises an Anthropic-shape content array (§5.2) and JSON-stringifies it into CompletionResult.content for 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.parse failure (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):

  1. const latencyMs = Date.now() - startMs;
  2. Extract choices[0] (defensive — choices is an array of length 1 for non-streaming non-n>1 requests).
  3. Extract message.content and message.tool_calls:
    • If tool_calls is non-empty array → synthesise Anthropic content shape (§5.2) and JSON-stringify into content.
    • Else if content is a non-empty string → use it directly.
    • Else → empty string.
  4. Extract finish_reason and normalise via the §7 table.
  5. Extract usage.prompt_tokenspromptTokens (default 0).
  6. Extract usage.completion_tokenscompletionTokens (default 0).
  7. Echo json.modelresult.model (default 'unknown').
  8. 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 AnthropicCodex 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.js for 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 — StdioServerTransport owns 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.baseUrlprocess.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:

  1. Editing main checkout. git status from E:\AMS MUST show clean.
  2. Pushing to main. Branch is feature/p1-5-3-codex-adapter.
  3. Force-pushing any branch.
  4. Modifying src/domains/router/index.ts. Even a one-line export * is forbidden. The fold-in commit owns this between Wave 3 and Wave 4.
  5. Touching any file outside the slice’s 6 files (1 adapter + 1 test
    • 4 chain docs).
  6. AMS_* env vars in any form.
  7. MCP tool registration (router_* tools are Phase 1.5.7+).
  8. Hardcoding model versionoptions.model ?? DEFAULT_CODEX_MODEL only.
  9. 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


Back to top

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

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