Files
HealthLog/src/lib/ai/codex-client.ts
T
Marc-André Bombeck 8787d71d1c fix(ai): recognise the gpt-6 model family in every model gate
The ChatGPT Codex backend and the OpenAI API serve gpt-6-sol, gpt-6-luna,
gpt-6-astra and gpt-6.1-sol, but the reasoning, Chat Completions and vision
gates only knew the gpt-5 line. A gpt-6 model got no reasoning control and
no real "off", went out on the legacy wire with max_tokens and a
temperature that reasoning models refuse, and was denied image input.

The three gates now read one classifier, openai-model-family.ts, so a new
generation is taught in one place. Every GPT from 5.1 on, the gpt-6 line
included, is the family that takes effort "none"; every GPT from 5 on reads
images. The upstream catalog (codex-rs/models-manager/models.json) lists
image input for every gpt-6 slug and the same reasoning presets as the
gpt-5.6 line; the presets omit "none" for gpt-5.5 too, which accepts it.

The Codex slug ladder moves into codex-slug-chain.ts and gains gpt-6-sol,
gpt-6.1-sol and gpt-6-luna. gpt-5.6-terra stays first as the slug verified
against the backend; the catalog names gpt-6-sol as its upgrade, and a
rotation retires a whole line at once, so the gpt-6 rungs follow before
terra's own siblings. The ladder test now holds every rung to a recognised
reasoning family with a real off and image input. The OpenAI presets offer
gpt-6.1-sol and gpt-6-luna.
2026-10-24 10:11:16 +02:00

1046 lines
38 KiB
TypeScript

import { createHash, randomUUID } from "node:crypto";
import { callTimeoutMs } from "./effective-timeout";
import { safeFetch } from "@/lib/safe-fetch";
import type {
AIProvider,
AiContentPart,
AiMessage,
AiProviderState,
AiToolCall,
AiToolDef,
CompletionParams,
CompletionResult,
} from "./types";
import {
annotateReasoningDowngrade,
isReasoningParameterRejection,
learnedReasoningDialect,
rememberReasoningDialect,
} from "./reasoning/dialect-cache";
import type { ReasoningLevel } from "./reasoning/levels";
import { levelOfWireEffort, reasoningTitleOf } from "./reasoning/support";
import {
getCachedCodexSlug,
invalidateCachedCodexSlug,
setCachedCodexSlug,
} from "./codex-slug-cache";
import {
DEFAULT_SLUG_FALLBACK_CHAIN,
loadFallbackChain,
} from "./codex-slug-chain";
export { resolveCodexVisionSlug } from "./codex-slug-chain";
/**
* Codex backend client — talks to `chatgpt.com/backend-api/codex/responses`
* via the OpenAI Responses API (SSE streaming, ChatGPT-Account-ID
* header, OAuth Bearer auth). Implemented against the spec at
* `docs/codex-protocol-spec.md`, which mirrors the official `openai/codex`
* CLI.
*
* Required headers:
* - `Authorization: Bearer <oauth-access-token>`
* - `ChatGPT-Account-ID: <chatgpt_account_id JWT claim>`
* - `Content-Type: application/json`
* - `Accept: text/event-stream`
* - `originator`, `User-Agent`, `session_id`, `thread_id`
*
* Response is always SSE (server rejects `stream: false` with 400).
*
* v1.4.15 (Phase C1) adds a slug-drift defence — see
* `docs/codex-protocol-spec.md` §7b. The client walks an ordered
* fallback chain on every fresh request series (short-circuiting on
* a cached working slug) so a single upstream allow-list rotation no
* longer bricks the integration.
*/
const CODEX_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses";
/**
* Triggers per spec §7b — body / status that mean "this slug is no
* longer accepted, walk to the next one in the chain".
*/
function isSlugRejection(status: number, bodyExcerpt: string): boolean {
if (status === 404) return true;
if (status !== 400) return false;
const lower = bodyExcerpt.toLowerCase();
if (lower.includes("not supported when using codex with a chatgpt account")) {
return true;
}
if (lower.includes("model_not_found")) return true;
if (lower.includes("does not exist") && lower.includes("model")) {
return true;
}
return false;
}
const ORIGINATOR = "healthlog";
const USER_AGENT = "HealthLog/1.0 (+https://github.com/MBombeck/HealthLog)";
/** A Responses-API user/assistant content block. */
type CodexContentBlock =
| { type: "input_text"; text: string }
| { type: "input_image"; image_url: string; detail: "high" }
| { type: "output_text"; text: string };
/** A Responses-API input item. */
type CodexInputItem =
| {
type: "message";
role: "user" | "assistant";
content: CodexContentBlock[];
}
| {
type: "function_call";
call_id: string;
name: string;
arguments: string;
}
| {
type: "function_call_output";
call_id: string;
output: string;
}
// v1.41 — a reasoning item from an earlier round of the same turn, handed
// back exactly as the stream delivered it (encrypted content included).
| Record<string, unknown>;
/**
* Map an `AiContentPart[]` body into Responses input content blocks. Text parts
* on an assistant turn use `output_text` (Responses API requires it for
* assistant replays); user turns use `input_text`. Image parts are user-only.
*/
function mapCodexParts(
parts: AiContentPart[],
role: "user" | "assistant",
): CodexContentBlock[] {
const textType: "input_text" | "output_text" =
role === "assistant" ? "output_text" : "input_text";
const out: CodexContentBlock[] = [];
for (const part of parts) {
if (part.type === "text") {
out.push({ type: textType, text: part.text });
} else if (part.type === "image") {
out.push({
type: "input_image",
image_url: `data:${part.mediaType};base64,${part.dataBase64}`,
detail: "high",
});
}
// `document` (PDF) parts are not representable here (image-only wire).
}
return out;
}
/**
* Build the Responses-API `input` array from the message turns. Tool-call /
* tool-result turns map to the `function_call` / `function_call_output`
* top-level items the Responses API expects; everything else is a `message`.
* A text-only single user turn is byte-identical to the pre-refactor wire.
*/
function buildCodexInput(
messages: AiMessage[],
slug: string,
): CodexInputItem[] {
const items: CodexInputItem[] = [];
for (const m of messages) {
if (m.role === "tool") {
items.push({
type: "function_call_output",
call_id: m.toolCallId ?? "",
output: typeof m.content === "string" ? m.content : "",
});
continue;
}
// v1.21.3 — assistant message replays MUST use `output_text`, not
// `input_text` (Responses API; docs/codex-protocol-spec.md §2b). The
// single-user-turn case never hit this — only the multi-round tool loop
// replays a prior assistant turn — so the spec violation surfaced as a 400
// exactly on the Coach tool-call path that broke in production. User turns
// keep `input_text` / `input_image`.
const textType: "input_text" | "output_text" =
m.role === "assistant" ? "output_text" : "input_text";
const content: CodexContentBlock[] =
typeof m.content === "string"
? [{ type: textType, text: m.content }]
: mapCodexParts(m.content, m.role);
// v1.41 — the reasoning items this round produced go back before its
// message and function calls, unchanged: with `store: false` the server
// keeps nothing, and a tool round that loses its reasoning items reasons
// from scratch (https://developers.openai.com/cookbook/examples/responses_api/reasoning_items).
// Only state this client produced for this slug is re-inserted; anything
// else (another provider's state, a slug the chain walked away from) is
// dropped, because an encrypted item is only readable where it was made.
if (m.role === "assistant") {
for (const item of codexStateItems(m.providerState, slug)) {
items.push(item);
}
}
items.push({ type: "message", role: m.role, content });
if (m.role === "assistant" && m.toolCalls) {
for (const tc of m.toolCalls) {
items.push({
type: "function_call",
call_id: tc.id,
name: tc.name,
arguments: tc.arguments,
});
}
}
}
return items;
}
/** The reasoning items of `state` when this client made it for `slug`. */
function codexStateItems(
state: AiProviderState | undefined,
slug: string,
): Record<string, unknown>[] {
if (!state || state.providerType !== "codex" || state.model !== slug) {
return [];
}
return state.items.filter(
(item): item is Record<string, unknown> =>
item !== null &&
typeof item === "object" &&
(item as { type?: unknown }).type === "reasoning",
);
}
// ── v1.41 reasoning wire ────────────────────────────────────────────────────
/**
* The Responses-API efforts in ascending order. The Codex backend names the
* values a slug accepts in its 400 body ("Supported values are: 'none',
* 'low', …"), which is how a slug's ladder is learned.
*/
const CODEX_EFFORT_ORDER = [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh",
] as const;
/** What one request sends: `null` is today's `reasoning: null`. */
type CodexReasoningWire = { effort: string; summary: boolean } | null;
/** What a slug was learned to refuse (see `reasoning/dialect-cache.ts`). */
interface CodexReasoningDialect {
/** The slug refused `reasoning` altogether. */
disabled?: true;
/** The slug refused `reasoning.summary`. */
noSummary?: true;
/** The efforts the slug named as supported when it refused one. */
efforts?: string[];
}
function dialectKey(slug: string) {
return { provider: "codex", endpoint: CODEX_ENDPOINT, model: slug };
}
function readCodexDialect(slug: string): CodexReasoningDialect {
const raw = learnedReasoningDialect(dialectKey(slug));
if (!raw) return {};
try {
return JSON.parse(raw) as CodexReasoningDialect;
} catch {
return {};
}
}
function wantedEffort(level: ReasoningLevel): string {
// "Off" is a real off on the current slugs: a 2026-10-06 probe answered
// `effort: "none"` on gpt-5.5 with a non-reasoning reply. A slug that
// refuses it learns its lowest supported value instead.
return level === "off" ? "none" : level;
}
/** The supported effort closest to `wanted` from below, else the lowest. */
function nearestEffort(wanted: string, supported: string[]): string | null {
const rank = (e: string) =>
(CODEX_EFFORT_ORDER as readonly string[]).indexOf(e);
const known = supported
.filter((e) => rank(e) >= 0)
.sort((a, b) => rank(a) - rank(b));
if (known.length === 0) return null;
const below = known.filter((e) => rank(e) <= rank(wanted));
return below.length > 0 ? below[below.length - 1] : known[0];
}
function planCodexReasoning(
reasoning: CompletionParams["reasoning"],
slug: string,
): CodexReasoningWire {
if (!reasoning) return null;
const dialect = readCodexDialect(slug);
if (dialect.disabled) return null;
let effort: string | null = wantedEffort(reasoning.effort);
if (dialect.efforts && !dialect.efforts.includes(effort)) {
effort = nearestEffort(effort, dialect.efforts);
}
if (!effort) return null;
return {
effort,
summary: reasoning.summaries && effort !== "none" && !dialect.noSummary,
};
}
/**
* True when the request asks for the encrypted reasoning items, the only
* reasoning state a `store: false` request can hand to the next round.
*/
function asksEncryptedReasoning(wire: CodexReasoningWire): boolean {
return wire !== null && wire.effort !== "none";
}
/**
* Read a 400 that refuses a reasoning parameter and learn what the slug
* accepts instead. Returns the lesser wire to retry with, or `undefined`
* when the body is not about reasoning (the caller's error path takes it).
*/
function learnFromReasoningRejection(
slug: string,
wire: Exclude<CodexReasoningWire, null>,
status: number,
body: string,
): CodexReasoningWire | undefined {
// Only a 400 that refuses the reasoning parameter counts; a 400 about the
// prompt or a tool, and a replay error about a reasoning item handed back
// from an earlier round, must not teach the slug anything.
if (!isReasoningParameterRejection(status, body, /reasoning/i, slug)) {
return undefined;
}
const dialect = readCodexDialect(slug);
let next: CodexReasoningWire;
if (wire.summary && /reasoning\.summary|summary/i.test(body)) {
dialect.noSummary = true;
next = { effort: wire.effort, summary: false };
} else {
const listed = /supported values are:([^"\n]*)/i.exec(body);
const efforts = listed
? Array.from(listed[1].matchAll(/'([a-z]+)'/g), (m) => m[1])
: [];
const nearest =
efforts.length > 0 && !efforts.includes(wire.effort)
? nearestEffort(wire.effort, efforts)
: null;
if (nearest) {
dialect.efforts = efforts;
next = { effort: nearest, summary: wire.summary && nearest !== "none" };
} else {
dialect.disabled = true;
next = null;
}
}
rememberReasoningDialect(dialectKey(slug), JSON.stringify(dialect));
annotateReasoningDowngrade(
"codex",
wire.effort,
next ? next.effort : "default",
);
return next;
}
/**
* Map tool defs into the Responses `function` tool shape.
*
* v1.21.3 — the Responses-API function tool requires an explicit `strict`
* field; omitting it is one documented cause of a 400 on a client-supplied
* `tools` array. We send `strict: false` because the Coach tool schemas are
* not authored to the strict-mode contract (which additionally demands
* `additionalProperties: false` and every property in `required`). This is
* codex-only — the OpenAI Chat-Completions client nests tools differently and
* is untouched.
*/
function buildCodexTools(tools: AiToolDef[]) {
return tools.map((t) => ({
type: "function" as const,
name: t.name,
description: t.description,
strict: false,
parameters: t.parameters,
}));
}
interface CodexClientConfig {
accessToken: string;
accountId: string;
onTokenRefresh: () => Promise<{ accessToken: string; accountId: string }>;
/**
* Override the slug fallback chain (test-only). When unset, the
* chain is loaded from env + DEFAULT_SLUG_FALLBACK_CHAIN.
*/
slugChain?: string[];
}
/** Diagnostic shape returned to the route layer for Wide-Event logging. */
export interface CodexAttemptDiagnostics {
attempted: string[];
cacheState: "hit" | "miss" | "expired";
workingSlug: string | null;
}
/**
* A UUID-shaped id derived from a cache key, for the session headers: the
* same key gives the same id, and the key itself never rides a header.
*/
function uuidFromKey(key: string): string {
const hex = createHash("sha256").update(`codex-session:${key}`).digest("hex");
return [
hex.slice(0, 8),
hex.slice(8, 12),
`4${hex.slice(13, 16)}`,
`${((parseInt(hex[16], 16) & 0x3) | 0x8).toString(16)}${hex.slice(17, 20)}`,
hex.slice(20, 32),
].join("-");
}
export class CodexClient implements AIProvider {
readonly type = "codex" as const;
/** See `AIProvider.responseTimeoutSeconds`; stamped by the resolver. */
responseTimeoutSeconds: number | null = null;
private accessToken: string;
private accountId: string;
private onTokenRefresh: () => Promise<{
accessToken: string;
accountId: string;
}>;
private readonly slugChain: string[];
private lastDiagnostics: CodexAttemptDiagnostics | null = null;
constructor(config: CodexClientConfig) {
this.accessToken = config.accessToken;
this.accountId = config.accountId;
this.onTokenRefresh = config.onTokenRefresh;
this.slugChain = config.slugChain ?? loadFallbackChain();
if (this.slugChain.length === 0) {
// Hardcoded floor — even with a misconfigured env var we always
// try at least one slug.
this.slugChain = [...DEFAULT_SLUG_FALLBACK_CHAIN];
}
}
/**
* Diagnostics for the most recent generateCompletion() call, used by
* the route layer for Wide-Event annotations. Captures the order of
* attempted slugs, whether the cache was hit, and which slug
* eventually worked (or null on all-failed).
*/
getLastDiagnostics(): CodexAttemptDiagnostics | null {
return this.lastDiagnostics;
}
/**
* Build the slug-attempt order: cached slug first (if any and not
* expired), then the rest of the fallback chain in order, deduped
* against the cached slug.
*/
private buildAttemptOrder(): { order: string[]; cacheState: "hit" | "miss" } {
const cached = getCachedCodexSlug();
if (cached === null) {
return { order: [...this.slugChain], cacheState: "miss" };
}
const order = [cached, ...this.slugChain.filter((s) => s !== cached)];
return { order, cacheState: "hit" };
}
async generateCompletion(
params: CompletionParams,
): Promise<CompletionResult> {
const { order, cacheState } = this.buildAttemptOrder();
const attempted: string[] = [];
let lastSlugRejectionError: Error | null = null;
for (const slug of order) {
attempted.push(slug);
let wire = planCodexReasoning(params.reasoning, slug);
const firstAttempt = await this.doRequest(params, slug, wire);
if (firstAttempt.status === 401) {
// Token-refresh path — don't walk the chain, this is auth-state
// not slug-state. Refresh once and re-try the SAME slug.
const fresh = await this.onTokenRefresh();
this.accessToken = fresh.accessToken;
this.accountId = fresh.accountId;
const retryAfterRefresh = await this.doRequest(params, slug, wire);
if (retryAfterRefresh.ok) {
this.lastDiagnostics = {
attempted,
cacheState,
workingSlug: slug,
};
setCachedCodexSlug(slug);
return this.parseResponse(retryAfterRefresh, slug, params, wire);
}
// Even after refresh — surface the auth failure verbatim, do
// NOT walk the chain (auth issues don't get fixed by changing
// the slug).
throw await this.buildUpstreamError(
retryAfterRefresh,
"Codex request failed after token refresh",
slug,
);
}
let attempt = firstAttempt;
let rawBody = "";
// v1.41 — a slug that refuses a reasoning parameter is asked again with
// the lesser request it names (no summary, a lower effort, no reasoning
// at all), and the slug remembers it. The answer is never lost to a
// reasoning refusal. Bounded: each step strictly reduces the request.
for (let step = 0; !attempt.ok && step < 3; step += 1) {
rawBody = await attempt.text().catch(() => "");
if (!wire) break;
const next = learnFromReasoningRejection(
slug,
wire,
attempt.status,
redactBody(rawBody),
);
if (next === undefined) break;
wire = next;
rawBody = "";
attempt = await this.doRequest(params, slug, wire);
}
if (attempt.ok) {
this.lastDiagnostics = {
attempted,
cacheState,
workingSlug: slug,
};
setCachedCodexSlug(slug);
return this.parseResponse(attempt, slug, params, wire);
}
// Capture the body for the slug-rejection check.
if (!rawBody) rawBody = await attempt.text().catch(() => "");
const bodyExcerpt = redactBody(rawBody);
if (isSlugRejection(attempt.status, bodyExcerpt)) {
// Slug rejected — drop the cache (in case it pointed here),
// record the failure for diagnostics, and walk to the next
// chain slot.
invalidateCachedCodexSlug();
const slugErr = new Error(
`Codex slug "${slug}" rejected (${attempt.status})`,
);
Object.assign(slugErr, {
httpStatus: attempt.status,
upstream: "codex",
model: slug,
bodyExcerpt,
});
lastSlugRejectionError = slugErr;
continue;
}
// Non-slug error (5xx, 429, invalid_prompt, etc.) — propagate
// immediately. Walking the chain wouldn't help.
//
// v1.21.3 — fold the redacted upstream body into the message itself, not
// just a side property. Codex's 400 body names the exact field/param it
// rejected; without it in the message the chain runner's `summariseError`
// (which reads `err.message`) logged a bare "Codex request failed (400)"
// with no actionable reason — which is what made the live tool-call 400
// un-diagnosable. The body is already redacted of bearer/sk- secrets.
const err = new Error(
bodyExcerpt
? `Codex request failed (${attempt.status}): ${bodyExcerpt}`
: `Codex request failed (${attempt.status})`,
);
Object.assign(err, {
httpStatus: attempt.status,
upstream: "codex",
model: slug,
bodyExcerpt,
});
this.lastDiagnostics = {
attempted,
cacheState,
workingSlug: null,
};
throw err;
}
// All slugs exhausted — every one rejected as not-supported.
this.lastDiagnostics = {
attempted,
cacheState,
workingSlug: null,
};
const message =
"AI provider unreachable — all configured Codex slugs were rejected";
const err = new Error(message);
Object.assign(err, {
httpStatus: 503,
upstream: "codex",
model: attempted[attempted.length - 1] ?? null,
bodyExcerpt: lastSlugRejectionError
? (lastSlugRejectionError as Error & { bodyExcerpt?: string })
.bodyExcerpt
: null,
attempted,
});
throw err;
}
private async buildUpstreamError(
res: Response,
message: string,
slug: string,
): Promise<Error> {
const rawBody = await res.text().catch(() => "");
const bodyExcerpt = redactBody(rawBody);
// v1.21.3 — fold the redacted body into the message (see the non-slug error
// path); the chain runner's `summariseError` reads `err.message`.
const err = new Error(
bodyExcerpt
? `${message} (${res.status}): ${bodyExcerpt}`
: `${message} (${res.status})`,
);
Object.assign(err, {
httpStatus: res.status,
upstream: "codex",
model: slug,
bodyExcerpt,
});
return err;
}
private async doRequest(
params: CompletionParams,
slug: string,
wire: CodexReasoningWire,
): Promise<Response> {
// v1.41.2 — calls that share a cache key share one session and thread
// id and send the key as `prompt_cache_key`, the way the official client
// sends its thread id (docs/codex-protocol-spec.md §3b/§3c). Before, every
// round of a Coach turn arrived as a new session with no key, so the
// backend had no reason to route it to the cache the previous round
// filled, and each round was charged its whole prefix again.
const cacheKey = params.cacheKey?.trim() || null;
const sessionId = cacheKey ? uuidFromKey(cacheKey) : randomUUID();
const threadId = cacheKey ? sessionId : randomUUID();
// v1.18.11 — fold vision inputs (Lab-OCR) into the user turn when present.
// The Codex/ChatGPT-OAuth backend accepts the `input_image` content block
// on this same `codex/responses` endpoint (docs/codex-protocol-spec.md
// §2b: `image_url` accepts a `data:<mime>;base64,...` URL, `detail`
// defaults to "high"). Image INPUT consumes the ChatGPT plan allocation —
// no API key is required (an API key only matters for image GENERATION).
// When no images are present the content is byte-identical to the prior
// text-only wire, so every text caller (Coach, insights, status cards) is
// untouched. The image is UNTRUSTED data to transcribe, framed as such by
// the system prompt. Documents (PDF) are NOT folded in — `input_image` is
// image-only; the OCR route gates PDFs to Anthropic.
//
// v1.20.0 — `input` is now built from the full message array (multi-turn +
// tool-result turns), with `tools` mapped to the Responses `function` shape
// (the wire already declared `tools`/`tool_choice` — now they carry defs).
const input = buildCodexInput(params.messages, slug);
const tools =
params.tools && params.tools.length > 0
? buildCodexTools(params.tools)
: [];
const toolChoice = params.toolChoice ?? "auto";
return safeFetch(
CODEX_ENDPOINT,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
Authorization: `Bearer ${this.accessToken}`,
"ChatGPT-Account-ID": this.accountId,
originator: ORIGINATOR,
"User-Agent": USER_AGENT,
session_id: sessionId,
"session-id": sessionId,
thread_id: threadId,
"thread-id": threadId,
},
body: JSON.stringify({
model: slug,
instructions: params.system,
input,
tools,
tool_choice: toolChoice,
parallel_tool_calls: false,
// Reasoning is required-but-nullable on the wire. Without a
// requested level (every caller before v1.41, and every caller
// that does not opt in) it stays `null`, the field present so the
// server stops complaining about a missing key (it returned 400 in
// earlier iterations). With one, the official client's shape:
// `{ effort, summary }`, `summary` only when summaries are wanted.
// https://developers.openai.com/api/docs/guides/reasoning
reasoning: wire
? {
effort: wire.effort,
...(wire.summary ? { summary: "auto" } : {}),
}
: null,
store: false,
stream: true,
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
// Required field; empty array when not asking for reasoning. With
// reasoning on, the encrypted items come back so the next tool
// round can hand them over statelessly (`store` must stay false).
include: asksEncryptedReasoning(wire)
? ["reasoning.encrypted_content"]
: [],
}),
// SSE streaming completion — match the 60 s budget the other AI
// clients use so a long generation is not clipped by the 15 s
// default while still bounding a tar-pit upstream.
},
// v1.20.1 — compose the caller's cancel signal (Coach SSE disconnect) so
// a mid-generation abort tears the upstream call down early.
// The record owner's response-timeout setting, else the surface value
// (the comprehensive briefing needs >60 s), else 60 s.
{
timeoutMs: callTimeoutMs(params, this.responseTimeoutSeconds),
signal: params.signal,
},
);
}
/**
* Consume the Codex SSE stream and assemble the final assistant
* message. Event types per the spec:
*
* - `response.output_item.done` — final assembled item; an
* assistant `Message` carries the canonical reply in
* `item.content[].text`.
* - `response.output_text.delta` — incremental chunks; fallback
* when no done event arrives.
* - `response.completed` — terminal; carries `usage.total_tokens`.
* - `response.failed` / `response.incomplete` — terminal errors.
*
* Reasoning deltas (`response.reasoning_*.delta`) are deliberately
* NOT folded into the visible text — they belong to a separate
* channel.
*/
private async parseResponse(
res: Response,
requestedSlug: string,
params: CompletionParams,
wire: CodexReasoningWire,
): Promise<CompletionResult> {
if (!res.body) {
// v1.20.1 — sentinel httpStatus + kind so the chain classifier can tell
// a stream-level failure apart from a connect-time transport error.
const err = new Error("Codex returned no response body");
Object.assign(err, {
httpStatus: 0,
kind: "stream_failed",
upstream: "codex",
});
throw err;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let assembledText = "";
let deltaText = "";
let tokensUsed: number | null = null;
let cachedInputTokens: number | null = null;
let serverModel: string | null = null;
const toolCalls: AiToolCall[] = [];
// v1.41 — reasoning. Events only reach `onReasoning` when the caller asked
// for reasoning (the contract in `types.ts`); summaries are model text and
// the receiver screens them.
const emit = params.reasoning ? params.onReasoning : undefined;
const reasoningItems: Record<string, unknown>[] = [];
const keepsReasoningItems = asksEncryptedReasoning(wire);
const summaries: string[] = [];
let reasoningTokens: number | null = null;
const summaryBuffers = new Map<string, string>();
const titled = new Set<string>();
const texted = new Set<string>();
const emitTitle = (key: string, text: string) => {
if (!emit || titled.has(key)) return;
const title = reasoningTitleOf(text);
if (title) {
titled.add(key);
emit({ kind: "title", text: title });
}
};
const emitText = (key: string, text: string) => {
if (!emit || texted.has(key) || !text.trim()) return;
emitTitle(key, text);
texted.add(key);
emit({ kind: "text", text });
};
// The server reports the actual routed model in the OpenAI-Model
// response header — useful when safety-routing kicks in.
const headerModel = res.headers.get("OpenAI-Model");
if (headerModel) serverModel = headerModel;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let separator: number;
while ((separator = buffer.indexOf("\n\n")) !== -1) {
const rawEvent = buffer.slice(0, separator);
buffer = buffer.slice(separator + 2);
const dataLine = rawEvent
.split("\n")
.find((l) => l.startsWith("data:"));
if (!dataLine) continue;
const payload = dataLine.slice(5).trim();
if (!payload) continue;
let parsed: {
type?: string;
delta?: string;
text?: string;
item_id?: string;
summary_index?: number;
item?: {
type?: string;
role?: string;
content?: Array<{ type?: string; text?: string }>;
summary?: Array<{ type?: string; text?: string }>;
id?: string;
call_id?: string;
name?: string;
arguments?: string;
};
response?: {
usage?: {
total_tokens?: number;
input_tokens_details?: { cached_tokens?: number };
output_tokens_details?: { reasoning_tokens?: number };
};
error?: { code?: string; message?: string };
incomplete_details?: { reason?: string };
};
error?: { code?: string; message?: string };
};
try {
parsed = JSON.parse(payload);
} catch {
continue;
}
switch (parsed.type) {
case "response.output_text.delta": {
if (parsed.delta) deltaText += parsed.delta;
break;
}
// v1.41 — live reasoning summaries. A summary's first bold line
// is its title; it goes out as soon as it has closed, the full
// text when the part is done.
case "response.reasoning_summary_text.delta": {
const key = `${parsed.item_id ?? ""}:${parsed.summary_index ?? 0}`;
const buffered =
(summaryBuffers.get(key) ?? "") + (parsed.delta ?? "");
summaryBuffers.set(key, buffered);
emitTitle(key, buffered);
break;
}
case "response.reasoning_summary_text.done": {
const key = `${parsed.item_id ?? ""}:${parsed.summary_index ?? 0}`;
emitText(key, parsed.text ?? summaryBuffers.get(key) ?? "");
break;
}
case "response.output_item.done": {
const item = parsed.item;
if (item?.type === "message" && item.role === "assistant") {
const text = item.content
?.filter((c) => c.type === "output_text" && c.text)
.map((c) => c.text!)
.join("");
if (text) assembledText += text;
} else if (item?.type === "reasoning" && params.reasoning) {
// The whole item, untouched: the next round hands it back.
// Only an item that carries its encrypted content, and only
// when this request asked for it: under `store: false` the
// server keeps nothing, so a replayed item without it points
// at nothing and the next round fails with a 400.
if (
keepsReasoningItems &&
typeof (item as { encrypted_content?: unknown })
.encrypted_content === "string"
) {
reasoningItems.push(item as Record<string, unknown>);
}
(item.summary ?? []).forEach((part, index) => {
if (typeof part.text !== "string" || !part.text) return;
summaries.push(part.text);
// A part whose `.done` never arrived still reaches the
// receiver, once.
emitText(`${item.id ?? ""}:${index}`, part.text);
});
} else if (item?.type === "function_call") {
// v1.20.0 — the model asked to call a tool. Surface it for
// F1's loop; the id is the call_id the function_call_output
// turn must echo back.
toolCalls.push({
id: item.call_id ?? item.id ?? "",
name: item.name ?? "",
arguments: item.arguments ?? "",
});
}
break;
}
case "response.completed": {
tokensUsed = parsed.response?.usage?.total_tokens ?? null;
cachedInputTokens =
parsed.response?.usage?.input_tokens_details?.cached_tokens ??
null;
// Reasoning tokens are billed as output and already inside
// `total_tokens`; reported separately for the trail and budget.
reasoningTokens =
parsed.response?.usage?.output_tokens_details
?.reasoning_tokens ?? null;
break;
}
case "response.failed": {
const err = parsed.response?.error;
const message =
err?.message ?? "Codex stream returned response.failed";
const e = new Error(message);
Object.assign(e, {
upstream: "codex",
// v1.20.1 — sentinel httpStatus + kind so the chain classifier
// distinguishes a stream-level failure from a transport error.
httpStatus: 0,
kind: "stream_failed",
errorCode: err?.code ?? null,
model: serverModel ?? requestedSlug,
});
throw e;
}
case "response.incomplete": {
const reason =
parsed.response?.incomplete_details?.reason ??
"incomplete response";
const e = new Error(`Codex stream incomplete: ${reason}`);
Object.assign(e, {
httpStatus: 0,
kind: "stream_failed",
upstream: "codex",
// The documented reason (`max_output_tokens`,
// `content_filter`), so a caller can tell a reply cut at the
// output limit from any other stream failure.
...(parsed.response?.incomplete_details?.reason
? {
incompleteReason:
parsed.response.incomplete_details.reason,
}
: {}),
});
// The text streamed before the cut, for a caller that can use
// a partial reply (the lab scan keeps the rows that closed).
// Not enumerable: it is model output about the person's data,
// and an error is serialised into logs and events.
Object.defineProperty(e, "partialContent", {
value: assembledText || deltaText,
enumerable: false,
});
throw e;
}
case "error":
case "response.error": {
const message =
parsed.error?.message ?? "Codex stream returned an error event";
const e = new Error(message);
Object.assign(e, {
httpStatus: 0,
kind: "stream_failed",
upstream: "codex",
});
throw e;
}
default:
// Unknown event types are tolerated and ignored, same
// as the official client.
break;
}
}
}
} finally {
reader.releaseLock();
}
const content = assembledText || deltaText;
// A function-call-only reply carries no text — valid (F1). Only an empty
// reply with neither text NOR a tool call is an error.
if (!content && toolCalls.length === 0) {
// v1.20.1 — sentinel httpStatus + kind so the chain classifier can tell
// an empty reply apart from a transport failure. Cascade unchanged
// (`status <= 0` is already a hard failure).
const err = new Error("Codex returned empty content");
Object.assign(err, {
httpStatus: 0,
kind: "empty_response",
upstream: "codex",
});
throw err;
}
if (emit) emit({ kind: "done", text: "" });
const sentLevel = wire ? levelOfWireEffort(wire.effort) : null;
const reasoning: CompletionResult["reasoning"] = params.reasoning
? {
summary: summaries,
tokens: reasoningTokens,
...(sentLevel !== params.reasoning.effort
? { downgradedTo: sentLevel ?? "off" }
: {}),
}
: undefined;
return {
content,
tokensUsed,
cachedInputTokens,
model: serverModel ?? requestedSlug,
providerType: "codex",
...(toolCalls.length > 0 ? { toolCalls } : {}),
finishReason: toolCalls.length > 0 ? "tool_calls" : "stop",
...(reasoning ? { reasoning } : {}),
// Keyed on the slug that was asked, not the routed model header: the
// next round re-inserts the items only when it asks the same slug.
...(reasoningItems.length > 0
? {
providerState: {
providerType: "codex" as const,
model: requestedSlug,
items: reasoningItems,
},
}
: {}),
};
}
}
function redactBody(rawBody: string): string {
return rawBody
.slice(0, 500)
.replace(/sk-(?:ant-)?[A-Za-z0-9_-]{8,}/g, "sk-***redacted***")
.replace(/Bearer\s+[A-Za-z0-9_.-]+/gi, "Bearer ***redacted***");
}
// Test-only re-exports — keeps the spec-driven defaults visible to unit
// tests without exporting a settable mutable.
export const __test = {
DEFAULT_SLUG_FALLBACK_CHAIN,
isSlugRejection,
loadFallbackChain,
};