mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Every write to the per-sign-in Codex memory checked the account digest, but the reads did not. A credential reconnected to another ChatGPT account therefore tried the old account's working model first, sorted by its refusals and trusted its model list, until the first write rebound the entry, and the vision gate kept answering from the old account in the meantime. The client reads (working model, refusals, model list) now take the credential and treat an entry learned under another account as empty. The vision gate holds no account id, so the connect routes rebind the entry and the disconnect routes drop it, and the gate reads the entry as its process last bound it.
908 lines
33 KiB
TypeScript
908 lines
33 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 { levelOfWireEffort, reasoningTitleOf } from "./reasoning/support";
|
|
import {
|
|
CODEX_ENDPOINT,
|
|
asksEncryptedReasoning,
|
|
learnFromReasoningRejection,
|
|
planCodexReasoning,
|
|
type CodexReasoningWire,
|
|
} from "./codex-reasoning-wire";
|
|
import {
|
|
getCachedCodexSlug,
|
|
rememberRejectedCodexSlug,
|
|
setCachedCodexSlug,
|
|
type CodexCredential,
|
|
} from "./codex-slug-cache";
|
|
import { loadCodexAttemptChain } 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).
|
|
*
|
|
* Slug-drift defence (`docs/codex-protocol-spec.md` §7b): the client tries
|
|
* its sign-in's working slug first, then walks an order built from the
|
|
* models the account is offered (`codex-slug-chain.ts`), so an upstream
|
|
* retirement does not take the integration down.
|
|
*/
|
|
|
|
/**
|
|
* 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",
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 }>;
|
|
/**
|
|
* The stored credential (`userCodexCredentialKey` or
|
|
* `CENTRAL_CODEX_CREDENTIAL_KEY`) its working slug, refused slugs and model
|
|
* list are kept under; and two test seams (see `loadCodexAttemptChain`).
|
|
*/
|
|
credentialKey: string;
|
|
slugChain?: string[];
|
|
readModels?: Parameters<typeof loadCodexAttemptChain>[0]["readModels"];
|
|
}
|
|
|
|
/** 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 selection: Pick<
|
|
CodexClientConfig,
|
|
"credentialKey" | "slugChain" | "readModels"
|
|
>;
|
|
private lastDiagnostics: CodexAttemptDiagnostics | null = null;
|
|
|
|
constructor(config: CodexClientConfig) {
|
|
this.accessToken = config.accessToken;
|
|
this.accountId = config.accountId;
|
|
this.onTokenRefresh = config.onTokenRefresh;
|
|
this.selection = config;
|
|
}
|
|
|
|
private get credential(): CodexCredential {
|
|
return { key: this.selection.credentialKey, accountId: this.accountId };
|
|
}
|
|
|
|
/**
|
|
* The last generateCompletion(): slugs attempted in order, whether the
|
|
* working slug was cached, and which slug answered (null when none did).
|
|
*/
|
|
getLastDiagnostics(): CodexAttemptDiagnostics | null {
|
|
return this.lastDiagnostics;
|
|
}
|
|
|
|
async generateCompletion(
|
|
params: CompletionParams,
|
|
): Promise<CompletionResult> {
|
|
// The working slug alone first: the account's list is read only when
|
|
// there is none, or when it stops working.
|
|
const cached = getCachedCodexSlug(this.credential);
|
|
const cacheState = cached === null ? "miss" : "hit";
|
|
const loadChain = () =>
|
|
loadCodexAttemptChain({
|
|
...this.selection,
|
|
credential: this.credential,
|
|
accessToken: this.accessToken,
|
|
});
|
|
const queue = cached ? [cached] : await loadChain();
|
|
let chainLoaded = cached === null;
|
|
const attempted: string[] = [];
|
|
let lastSlugRejectionError: Error | null = null;
|
|
|
|
for (;;) {
|
|
if (queue.length === 0) {
|
|
if (chainLoaded) break;
|
|
chainLoaded = true;
|
|
queue.push(
|
|
...(await loadChain()).filter((s) => !attempted.includes(s)),
|
|
);
|
|
continue;
|
|
}
|
|
const slug = queue.shift() as string;
|
|
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(this.credential, 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(this.credential, 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)) {
|
|
// Refused for this sign-in: out of the working slot and to the back
|
|
// of its line, even while its list still offers it; walk on.
|
|
rememberRejectedCodexSlug(this.credential, slug);
|
|
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: the rejection matcher.
|
|
export const __test = {
|
|
isSlugRejection,
|
|
};
|