Files
HealthLog/docs/codex-protocol-spec.md
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

48 KiB

Codex Backend Protocol Spec — chatgpt.com/backend-api/codex/responses

This is a TypeScript-implementer's reference for the Codex CLI's "Responses-over-HTTP" call to the ChatGPT-backed Codex endpoint. Every claim cites a specific Rust source file in openai/codex (path is relative to the repo root).

The endpoint is reverse-engineered from the official client, so this document is the authoritative shape — there is no public OpenAPI for this URL.


0. Endpoint

  • Method: POST
  • URL: https://chatgpt.com/backend-api/codex/responses
  • Content-Type: application/json
  • Accept: text/event-stream (the client always sets this)
  • Stream: SSE; non-streaming is not supported on this URL (server returns 400 "Stream must be set to true").

The base URL https://chatgpt.com/backend-api/codex is the constant CHATGPT_CODEX_BASE_URL in codex-rs/model-provider-info/src/lib.rs:37. The path /responses is joined onto it by ResponsesClient::path() in codex-rs/codex-api/src/endpoint/responses.rs:102-104 (returns "responses") and the provider's url_for_path joins them with a /.

The ACCEPT header is forced to text/event-stream in codex-rs/codex-api/src/endpoint/responses.rs:137-140.


1. Request body — ResponsesApiRequest

Source: codex-rs/codex-api/src/common.rs:169-190.

interface ResponsesApiRequest {
  // Required
  model: string; // e.g. "gpt-5-codex"
  input: ResponseItem[]; // MUST be a list, not a string
  tools: unknown[]; // [] is valid
  tool_choice: string; // "auto" is what core uses
  parallel_tool_calls: boolean; // bool, no default — must serialize
  reasoning: Reasoning | null; // null is allowed; field is required
  store: boolean; // false on chatgpt backend (only true on Azure)
  stream: true; // MUST be true on this URL
  include: string[]; // ["reasoning.encrypted_content"] when reasoning is on, else []

  // Optional (skipped when empty/None)
  instructions?: string; // skipped if "" (serde "skip_serializing_if = String::is_empty")
  service_tier?: string; // omit unless model supports it
  prompt_cache_key?: string; // core sets this to thread_id
  text?: TextControls; // verbosity + JSON-schema output
  client_metadata?: Record<string, string>; // free-form, see below
}

Serde rules from codex-rs/codex-api/src/common.rs:169-190:

Field Skip-rule
instructions skipped when empty string
reasoning always emitted (Option, but no skip attribute → null when None)
service_tier skipped when None
prompt_cache_key skipped when None
text skipped when None
client_metadata skipped when None

All other fields are always emitted.

There are NO serde renames — wire keys equal Rust field names verbatim.

1a. The Reasoning block

Source: codex-rs/codex-api/src/common.rs:113-119.

interface Reasoning {
  effort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
  summary?: "auto" | "concise" | "detailed" | "none";
}
  • effort enum: codex-rs/protocol/src/openai_models.rs:42-52 (lowercase serde). Medium is the default if you ask for one.
  • summary enum: codex-rs/protocol/src/config_types.rs:24-36 (lowercase serde). The Codex CLI sets summary to Some(...) only when the user asked for summaries; ReasoningSummary::None is mapped to None (omitted) in codex-rs/core/src/client.rs:697-701.
  • When the model does not support reasoning summaries, the entire reasoning field is set to None (i.e. emitted as null): codex-rs/core/src/client.rs:689-705.
  • When reasoning is non-null, the client also sets include: ["reasoning.encrypted_content"]. When null, include: []. See codex-rs/core/src/client.rs:721-725.

1b. TextControls

Source: codex-rs/codex-api/src/common.rs:142-148.

interface TextControls {
  verbosity?: "low" | "medium" | "high"; // codex-rs/codex-api/src/common.rs:150-157
  format?: TextFormat;
}

interface TextFormat {
  type: "json_schema"; // only variant; codex-rs/codex-api/src/common.rs:121-126
  strict: boolean;
  schema: unknown; // raw JSON-Schema
  name: string; // "codex_output_schema" in CLI; arbitrary
}

text is omitted entirely when no verbosity AND no output schema is requested (codex-rs/codex-api/src/common.rs:279-297).

1c. Minimal valid body

For a plain text turn with no tools and no reasoning, the smallest body the Codex client itself ever sends is:

{
  "model": "gpt-5-codex",
  "instructions": "You are a helpful coding agent.",
  "input": [
    {
      "type": "message",
      "role": "user",
      "content": [{ "type": "input_text", "text": "Hello" }]
    }
  ],
  "tools": [],
  "tool_choice": "auto",
  "parallel_tool_calls": false,
  "reasoning": null,
  "store": false,
  "stream": true,
  "include": []
}

tools: [] is acceptable — the field is required but may be the empty array. tool_choice: "auto" is the literal string the CLI always sends (codex-rs/core/src/client.rs:750).

1d. client_metadata

Free-form Record<string,string>. The CLI uses it to forward an installation id and (over WebSocket) trace-context headers — none of these are mandatory for the HTTP path. Safe to omit (None → field skipped).


2. ResponseItem (the input array)

Source: codex-rs/protocol/src/models.rs:741-891.

#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponseItem { Message {...}, Reasoning {...}, ... }

So every entry in input is an object with a type discriminator (snake_case) plus variant-specific fields.

The variants you'll need for outbound traffic are Message, Reasoning, FunctionCall, FunctionCallOutput. The server-only variants (LocalShellCall, WebSearchCall, ImageGenerationCall, ToolSearchCall, ToolSearchOutput, CustomToolCall*, Compaction, ContextCompaction) appear in stream output and only need to round-trip for multi-turn continuations.

2a. Message

codex-rs/protocol/src/models.rs:744-756.

interface MessageItem {
  type: "message";
  role: "user" | "assistant" | "system" | "developer"; // free string
  content: ContentItem[];
  // The `id` field exists in Rust but is `skip_serializing` — never emit it for inbound input
  // The `phase` field is for SERVER output; do not send it.
}

Note: id has #[serde(skip_serializing)] (line 745-747) — set client-side but never put into the request.

2b. ContentItem

codex-rs/protocol/src/models.rs:697-712.

type ContentItem =
  | { type: "input_text"; text: string }
  | {
      type: "input_image";
      image_url: string;
      detail?: "auto" | "low" | "high" | "original";
    }
  | { type: "output_text"; text: string };
  • Use input_text and input_image for user turns.
  • Use output_text for assistant-message replays in multi-turn continuations.
  • image_url accepts data:image/png;base64,... (see codex-rs/protocol/src/models.rs:2369) or an HTTPS URL.
  • detail enum: codex-rs/protocol/src/models.rs:715-723 (default High when serializing for the CLI).

2c. Reasoning

codex-rs/protocol/src/models.rs:757-767.

interface ReasoningItem {
  type: "reasoning";
  // `id` is skip_serializing — do not send
  summary: ReasoningSummary[]; // Vec<ReasoningItemReasoningSummary>
  content?: ReasoningContent[]; // omitted unless server gave you one
  encrypted_content: string | null;
}

type ReasoningSummary = { type: "summary_text"; text: string };
type ReasoningContent = { type: "reasoning_text" | "text"; text: string };

Sources: codex-rs/protocol/src/models.rs:1192-1203.

For the simplest "fresh chat" use, you don't construct Reasoning items yourself — they only re-appear in input when continuing a turn that the server previously emitted them on. When you do replay them, keep encrypted_content verbatim.

2d. FunctionCall (outbound continuation)

codex-rs/protocol/src/models.rs:778-791.

interface FunctionCallItem {
  type: "function_call";
  // `id` skip_serializing
  name: string;
  namespace?: string | null; // omitted when None
  arguments: string; // JSON-as-string, NOT a parsed object
  call_id: string; // required
}

Important: arguments is a string containing JSON, not an object — the Responses API mandates this on the wire. See the comment at lines 786-789.

2e. FunctionCallOutput

codex-rs/protocol/src/models.rs:809-814.

interface FunctionCallOutputItem {
  type: "function_call_output";
  call_id: string;
  output: string | { content?: string; content_items?: ContentItem[] };
}

The output payload uses FunctionCallOutputPayload, which is a string OR a structured { content | content_items } object — see lines 804-808 for the wire-format note.

2f. Other variants you might see in stream output

type Notes
local_shell_call local-shell tool action (server emits)
web_search_call server emits when web_search ran
image_generation_call server emits
tool_search_call / tool_search_output dynamic-tool registry
custom_tool_call / custom_tool_call_output user-defined tools
compaction (alias compaction_summary) / context_compaction turn compaction artifacts

If you don't intend to round-trip these, keep them out of input. The unknown-variant catch-all (Other) is #[serde(other)] so unrecognized types are deserialized but lossy — don't echo them.


3. HTTP headers

The Codex CLI sends these on every Responses POST. Required ones are starred.

3a. From BearerAuthProvider::add_auth_headers — codex-rs/model-provider/src/bearer_auth_provider.rs:31-46

Header Required Value
Authorization * Bearer <access_token> (the OAuth access token, see §6)
ChatGPT-Account-ID * (for ChatGPT auth) the chatgpt_account_id claim from the id_token; obtained via auth.get_account_id() (codex-rs/login/src/auth/manager.rs:339-344)
X-OpenAI-Fedramp optional "true" only for FedRAMP accounts

Without ChatGPT-Account-ID, chatgpt_get_request errors out with "ChatGPT account ID not available, please re-run codex login" (codex-rs/chatgpt/src/chatgpt_client.rs:33-36).

3b. From the default reqwest client — codex-rs/login/src/auth/default_client.rs:232-248

Header Required Value
originator * default "codex_cli_rs" (constant DEFAULT_ORIGINATOR at line 36); identifies the client family
User-Agent * format: "<originator>/<version> (<os_type> <os_version>; <arch>) <terminal_user_agent>" — see get_codex_user_agent() lines 133-157. Anything sane works (e.g. "healthlog/1.0 (Node)" is fine for a third-party client).
x-openai-internal-codex-residency optional "us" only when residency requirement is set

3c. From build_session_headers — codex-rs/codex-api/src/requests/headers.rs:5-16

Sent on every Responses request when the CLI has a session/thread:

Header Value
session_id and session-id UUID-shaped session id (both spellings sent)
thread_id and thread-id UUID-shaped thread id (both spellings sent)

Optional but cheap; the server tolerates them as routing hints. Generate one UUID and use it for both names — duplicates are intentional, see lines 7-13.

3d. From endpoint/responses.rs:91-97

Header Required Value
x-client-request-id optional mirror the thread_id so it appears as the request-id in OpenAI logs
x-openai-subagent optional only set for sub-agent flows (review/compact/memory_consolidation/collab_spawn). Omit for first-party chat.

3e. From core/src/client.rs:135-148, 1648-1670

These are Codex-specific extras the CLI adds; the server tolerates them all.

Header Value
OpenAI-Beta only set on the WebSocket transport: "responses_websockets=2026-02-06" (line 145). NOT sent on HTTP — see line 911 (only the WS handshake adds it).
x-codex-beta-features optional, comma-separated feature flags
x-codex-installation-id UUID-ish, identifies the install — also embedded in client_metadata (line 759-762)
x-codex-turn-state sticky-routing token captured from a prior response's x-codex-turn-state response header — replay it on subsequent requests in the same "turn"
x-codex-turn-metadata optional, observability-only
x-codex-window-id / x-codex-parent-thread-id sub-agent only
x-openai-memgen-request "true" for memory-consolidation sub-agent only
x-responsesapi-include-timing-metrics "true" if the client wants timing info in the response

3f. Minimum third-party header set

For HealthLog's purposes (no sub-agent, no session continuity, no Azure, no sticky routing):

POST /backend-api/codex/responses HTTP/1.1
Host: chatgpt.com
Authorization: Bearer <oauth_access_token>
ChatGPT-Account-ID: <account_id>
Content-Type: application/json
Accept: text/event-stream
originator: <something_distinctive>
User-Agent: <descriptive_string>
session_id: <uuid>
thread_id: <uuid>

That's enough for the call to succeed.

3g. What the CLI does NOT send on HTTP

  • OpenAI-Beta header on plain HTTP — only on WebSocket. Don't send it, it's not needed.
  • OpenAI-Organization / OpenAI-Project — those are for api.openai.com only (codex-rs/model-provider-info/src/lib.rs:333-343), NOT for the ChatGPT backend.

4. SSE response

4a. Wire format

Standard SSE: eventsource_stream::Eventsource is used (codex-rs/codex-api/src/sse/responses.rs:439), so the format is:

event: <type>\n
data: <single-line JSON>\n
\n

Each event is one frame, separated by a blank line. The implementation tolerates event:-only lines without data: (used for keep-alives in tests, codex-rs/codex-api/src/sse/responses.rs:638-642). There is no data: [DONE] terminator — the stream ends with a response.completed event (or response.failed/response.incomplete); after that the connection is closed.

If the connection closes without a terminal event, the client surfaces "stream closed before response.completed" (line 458).

The data payload is a JSON object with at minimum { "type": "<type>", ...}. The Rust deserializer is forgiving — unknown event kinds are logged at trace level and ignored (codex-rs/codex-api/src/sse/responses.rs:425-427).

4b. Event types — the complete list

Source: codex-rs/codex-api/src/sse/responses.rs:300-428.

The data: payload follows the ResponsesStreamEvent shape (lines 179-192):

interface ResponsesStreamEvent {
  type: string;
  headers?: unknown; // map; only present on metadata frames
  metadata?: unknown; // present on response.metadata
  response?: unknown; // present on created/completed/failed/incomplete
  item?: unknown; // present on output_item.* — a ResponseItem
  item_id?: string;
  call_id?: string;
  delta?: string; // text deltas
  summary_index?: number;
  content_index?: number;
  // ... other fields are tolerated and ignored
}
Event type Carries Use it for
response.created response: {} (server may add id, headers.OpenAI-Model, etc.) Signals the turn started. The CLI emits ResponseEvent::Created.
response.metadata metadata.openai_verification_recommendation: string[] Optional; CLI uses to surface trust prompts. Ignore for chat.
response.output_item.added item: ResponseItem A new top-level item is starting (e.g. assistant message stub, function call).
response.output_item.done item: ResponseItem The item is complete — this is where the final assistant Message arrives with its full content[].text.
response.output_text.delta delta: string Token-by-token text streaming for the assistant message currently in flight. Concatenate to assemble live text.
response.reasoning_summary_text.delta delta: string, summary_index: number Reasoning summary stream (when reasoning.summary requested).
response.reasoning_summary_part.added summary_index: number Marker that a new summary section started.
response.reasoning_text.delta delta: string, content_index: number Reasoning text stream.
response.custom_tool_call_input.delta item_id: string, call_id?: string, delta: string Custom tool input streaming (also used for response.function_call_arguments.delta per line 806-813 of the test fixture).
response.completed response: { id, usage?, end_turn? } Terminal event — emit your final assembled text + usage.
response.failed response: { error: { code, message, ... } } Terminal event — error path; see §4c.
response.incomplete response: { incomplete_details: { reason: string } } Terminal event — partial result, reason may be max_output_tokens etc.

Anything else (response.in_progress, response.content_part.added, etc.) is currently ignored by the official client (line 425-427); a TS implementation should follow suit and silently drop unknown type values.

4c. Error event — response.failed

Schema of response.error (codex-rs/codex-api/src/sse/responses.rs:122-130):

interface FailedError {
  type?: string;
  code?: string; // primary discriminator
  message?: string;
  plan_type?: string;
  resets_at?: number; // unix seconds
}

Code-to-meaning mapping (codex-rs/codex-api/src/sse/responses.rs:547-570):

error.code Meaning Treat as
context_length_exceeded input too big fatal, surface to user
insufficient_quota account quota fatal
usage_not_included feature not in plan fatal
invalid_prompt malformed/safety-rejected prompt fatal, surface message
cyber_policy flagged as security risk fatal, message + fallback "This request has been flagged for possible cybersecurity risk." (line 142-143)
server_is_overloaded / slow_down backpressure retry with backoff
rate_limit_exceeded TPM/RPM limit retry; parse delay from message via regex (?i)try again in\s*(\d+(?:\.\d+)?)\s*(s|ms|seconds?) (line 586)
anything else unknown server error retry

4d. Token usage — response.completed

Source: codex-rs/codex-api/src/sse/responses.rs:132-167, 392-407.

interface ResponseCompletedData {
  id: string;
  end_turn?: boolean; // some providers don't emit; treat None as "unknown"
  usage?: {
    input_tokens: number;
    input_tokens_details?: { cached_tokens: number };
    output_tokens: number;
    output_tokens_details?: { reasoning_tokens: number };
    total_tokens: number;
  };
}

Path: event.data.response.usage.{input_tokens, output_tokens, total_tokens}. Cached and reasoning-token sub-totals live one level deeper in *_details.

4e. Assembling the final text

Two valid strategies:

  1. Live streaming: concatenate every response.output_text.delta's delta field. Stop at response.completed. (Reasoning deltas come on a separate channel — response.reasoning_*.delta — and should NOT be concatenated into the visible answer.)
  2. End-of-turn snapshot: ignore deltas, take the response.output_item.done whose item.type == "message" and item.role == "assistant". Its item.content[] will contain output_text entries with the full text. This is more robust if you only care about the final answer.

The CLI uses both (deltas drive the live UI; done gives the canonical ResponseItem for the conversation log).

4f. Response headers worth reading

These are on the HTTP response (not in the SSE body). Source: codex-rs/codex-api/src/sse/responses.rs:69-95.

Header Use
x-request-id Pass to the user when reporting errors
OpenAI-Model The actual model the server routed to (may differ from your request when safety routing kicks in)
x-reasoning-included If present, server already accounted for prior reasoning tokens — don't re-estimate
x-codex-turn-state Sticky-routing token — replay in next request's x-codex-turn-state header
X-Models-Etag Cache key for the /models endpoint
cf-ray Cloudflare ray, useful for support

4g. Idle timeout

The CLI uses an idle timeout per stream (default in codex-rs/model-provider-info/src/lib.rs constants); if no SSE event arrives within the window, it emits "idle timeout waiting for SSE" and closes. A reasonable TS default is 120 seconds.


5. HTTP error responses (non-SSE)

Source: codex-rs/codex-api/src/api_bridge.rs:36-130.

If the server fails before the SSE stream begins, it returns a JSON body with shape:

{ "error": { "type": "...", "code": "...", "message": "..." } }
Status Special handling
400 with error.code == "cyber_policy" treat as CyberPolicy (message or fallback)
400 body containing "The image data you provided does not represent a valid image" invalid image
400 other InvalidRequest — e.g. our two seen failures ("Input must be a list", "Stream must be set to true")
429 with error.type == "usage_limit_reached" parse plan_type, resets_at, plus rate-limit headers (§5a)
429 with error.type == "usage_not_included" feature/plan rejection
429 other retryable rate-limit
500 retry
503 with error.code in {"server_is_overloaded","slow_down"} retry with backoff
anything else propagate verbatim

5a. Rate-limit headers

Source: codex-rs/codex-api/src/rate_limits.rs (lines 22-345). The Codex backend uses bespoke headers (NOT standard X-RateLimit-*). The keys you'll see:

x-codex-active-limit
x-codex-primary-used-percent
x-codex-primary-window-minutes
x-codex-primary-reset-at
x-codex-secondary-primary-used-percent
x-codex-secondary-primary-window-minutes
x-codex-secondary-primary-reset-at
x-codex-bengalfox-primary-used-percent
x-codex-bengalfox-limit-name
x-codex-credits-has-credits
x-codex-credits-unlimited
x-codex-credits-balance
x-codex-promo-message

Surface them to the user when relevant. Never block on them — they're informational.

5b. Other error-relevant headers

Header Source line Use
x-request-id / x-oai-request-id api_bridge.rs:136-137 request id for support
cf-ray api_bridge.rs:138 Cloudflare ray
x-openai-authorization-error api_bridge.rs:139 identity-error sub-code
x-error-json api_bridge.rs:140 base64-JSON-encoded error envelope; decode with standard b64 then JSON

6. Authentication

6a. What goes in Authorization

The OAuth access token issued by auth.openai.com. For ChatGPT-mode auth, CodexAuth::get_token() returns tokens.access_token directly — no swap, no extra exchange (codex-rs/login/src/auth/manager.rs:325-336). That's what BearerAuthProvider puts in Bearer <token>.

The token format is a JWT. The Rust client extracts the expiry from the JWT itself (parse_jwt_expiration in manager.rs:1797), so there is no expires_in to track separately.

6b. Where the token comes from (device-code flow)

Source: codex-rs/login/src/server.rs:711-782 and codex-rs/login/src/device_code_auth.rs.

  1. Issuer: https://auth.openai.com (constant DEFAULT_ISSUER, server.rs:51).
  2. The device-code flow ends with a PKCE code exchange at POST {issuer}/oauth/token with body:
    grant_type=authorization_code
    code=<authorization_code>
    redirect_uri=<redirect>
    client_id=app_EMoamEEZ73f0CkXaXp7hrann
    code_verifier=<pkce_verifier>
    
    Content-Type: application/x-www-form-urlencoded.
  3. The response is JSON { id_token, access_token, refresh_token } (server.rs:719-723). All three are required.

The client_id value app_EMoamEEZ73f0CkXaXp7hrann is in codex-rs/login/src/auth/manager.rs:921.

The OAuth scope is openid profile email offline_access api.connectors.read api.connectors.invoke (codex-rs/login/src/server.rs:493-495). Plus the non-standard query params id_token_add_organizations=true, codex_cli_simplified_flow=true, and originator=<originator> on the authorize URL (lines 502-506). The token endpoint itself only needs the form fields above.

6c. ChatGPT-Account-ID

Read from the id_token JWT — specifically the chatgpt_account_id claim. The CLI reads it at parse time and exposes it via CodexAuth::get_account_id() (manager.rs:339-344). For a TS client: decode the JWT (no signature verification needed for our purposes; the server validates), then look at payload.chatgpt_account_id (typically nested under https://api.openai.com/auth.chatgpt_account_id — see manager.rs:891 for the namespace).

If you can't find the account id in the id_token, the server returns 401 on the responses POST.

6d. Refresh procedure

Source: codex-rs/login/src/auth/manager.rs:806-848, 906-926.

POST https://auth.openai.com/oauth/token
Content-Type: application/json    ← note: refresh uses JSON, not form-encoded
{
  "client_id": "app_EMoamEEZ73f0CkXaXp7hrann",
  "grant_type": "refresh_token",
  "refresh_token": "<refresh_token>"
}

Override the URL with env CODEX_REFRESH_TOKEN_URL_OVERRIDE if you need a non-default issuer.

Response shape (manager.rs:913-918):

{
  "id_token": "...", // optional
  "access_token": "...", // optional but expected
  "refresh_token": "..." // optional; rotated when present
}

Persist whichever fields the server returned. The refresh token is rotated — the next refresh must use the newly returned refresh_token if any; reusing the old one yields refresh_token_reused and 401 (lines 856-857).

Failure handling (HTTP 401 body, lines 851-878):

error.code in body Treat as
refresh_token_expired permanent — user must re-login
refresh_token_reused permanent — concurrent refresh; re-login
refresh_token_invalidated permanent — token revoked; re-login
anything else log and re-login

6e. When to refresh

The CLI considers a token "stale" when (manager.rs:1796-1805):

  1. The JWT's exp is <= now, OR
  2. last_refresh < now - 8 days (constant TOKEN_REFRESH_INTERVAL = 8 at line 85; last_refresh is the wall-clock time of the previous successful refresh).

For a TS client, refresh proactively when the access token's exp claim is within ~5 minutes of expiry, and force-refresh on any 401 response from the responses endpoint.

6f. Revocation (optional)

POST https://auth.openai.com/oauth/revoke revokes a refresh token. Constant: REVOKE_TOKEN_URL at manager.rs:95. Override env: CODEX_REVOKE_TOKEN_URL_OVERRIDE (manager.rs:97).


7. Model strings

The CLI sends the model slug verbatim in the model field. There is no enum on the wire — anything the backend recognises works. Slugs the official client knows about (sample, not exhaustive):

Slug Source
gpt-5-codex codex-rs/tui/src/model_migration.rs; codex-rs/tui/src/bottom_pane/status_line_setup.rs
gpt-5-codex-mini codex-rs/tui/src/model_migration.rs
gpt-5.3-codex used as a "cyber-restricted" placeholder in tests (codex-rs/codex-api/src/sse/responses.rs:1374) — confirms the slug exists
gpt-5.1 shows up in rate-limit error messages in tests (codex-rs/codex-api/src/sse/responses.rs:875)

The ChatGPT backend ignores model strings the user's account doesn't have entitlements for — the server may safety-route to a different model and report the actual model used in the OpenAI-Model response header (§4f). Trust that header, not the request value, when logging.

7a. ChatGPT-account-auth allow-list (verified 2026-05-09)

When authenticating via the ChatGPT-OAuth path (Bearer token + ChatGPT-Account-ID header, §3a), the backend rejects model slugs that are NOT in the active codex-rs/models-manager/models.json bundle. The rejection is HTTP 400 with body:

{
  "detail": "The '<slug>' model is not supported when using Codex with a ChatGPT account."
}

Confirmed-rejected slugs (despite the source-tree references in the table above):

  • gpt-5 — rejected
  • gpt-5-codex — rejected (the API-key-auth Codex slug; not the ChatGPT-auth one)

Confirmed-accepted slugs on a ChatGPT Plus subscription (per available_in_plans in models.json):

  • gpt-5.6-terra — HealthLog's default (first chain entry) as of 2026-10-24; the backend rejected gpt-5.5 and everything below it that day. gpt-5.6-luna and gpt-5.6-sol are its siblings on the same line
  • gpt-6-sol, gpt-6.1-sol, gpt-6-luna, gpt-6-astra — the gpt-6 line, listed in models.json since 2026-09-22 (gpt-6.1-sol the catalog default since 2026-09-29). The catalog names gpt-6-sol as the upgrade of gpt-5.6-terra and gpt-5.6-sol, and gpt-6-luna as that of gpt-5.6-luna. Every gpt-6 slug lists input_modalities: ["text", "image"] and the same reasoning presets as the gpt-5.6 line (low to max, sol and astra also ultra). The presets omit none, as they do for gpt-5.5, which a live probe on 2026-10-06 showed accepts it; HealthLog sends none for "off" on the gpt-6 line too and learns a refusal per slug. The gpt-6 slugs were not probed against a live backend when they joined the chain
  • gpt-5.5 — the default from 2026-06 until 2026-10-24
  • gpt-5.4 / gpt-5.4-mini / gpt-5.2 — accepted per plan
  • gpt-5.3-codex — was the default (verified 2026-05-09); the backend rotated off the -codex line on 2026-06-02 and this slug is now only accepted intermittently, so it sits on a late chain rung

The codex-rs/tui/src/model_migration.rs references to gpt-5-codex etc. are migration prompts shown to existing users — they are NOT the slugs sent on the wire after migration. The wire slugs come from models.json. Always cross-check models.json (gh api repos/openai/codex/contents/codex-rs/models-manager/models.json) before picking a default.

7b. Slug-drift defence — fallback chain (HealthLog v1.4.15+)

The ChatGPT-Codex allow-list is rotated by OpenAI without prior notice (gpt-5-codex → gpt-5.3-codex happened over 48 h in May 2026 and bricked HealthLog twice while the v1.4.7..v1.4.13 saga unrolled). The cause: a single hard-coded slug in CODEX_MODEL flips invalid the moment the upstream rotates it. Operator brief, verbatim 2026-05-09: "Die Integration des Slug Drift Risiko darf halt immer überhaupt nicht sein."

To eliminate that risk class, HealthLog's CodexClient walks an ordered chain on every fresh request series.

Chain (top = first try). gpt-5.6-terra leads because it is the slug verified against the backend. The gpt-6 rungs follow before terra's own siblings: the catalog names gpt-6-sol as terra's upgrade, and the 2026-10-24 rotation retired a whole line at once, so a rotation that takes terra most likely takes gpt-5.6-luna and gpt-5.6-sol with it. gpt-6.1-sol (the catalog default, also on plans gpt-6-sol is not offered on) and gpt-6-luna (the lighter one) come next. gpt-6-astra, the frontier tier, stays out of the default:

const DEFAULT_SLUG_FALLBACK_CHAIN = [
  "gpt-5.6-terra", // verified against the backend 2026-10-24
  "gpt-6-sol", // terra's upgrade in the upstream catalog
  "gpt-6.1-sol", // the catalog default, on every plan
  "gpt-6-luna", // lighter gpt-6 sibling
  "gpt-5.6-luna", // lighter sibling on terra's line
  "gpt-5.6-sol", // heavier sibling on terra's line
  "gpt-5.5", // previous default, kept as the last rung while plans differ
];

Every default rung must be a model family the reasoning, sampling and vision gates recognise (src/lib/ai/openai-model-family.ts); codex-slug-fallback.test.ts holds the chain to that.

The shipped constant in src/lib/ai/codex-slug-chain.ts is authoritative; when the upstream rotates again, that file moves first and this listing follows.

The chain is overridable via the env var CODEX_MODEL_FALLBACK_CHAIN (comma-separated). The first env-var-named slug supersedes CODEX_MODEL; if both are set, CODEX_MODEL is folded into position 0 and de-duplicated.

Trigger condition (when to walk to the next slug):

Status Action
400 body matches /not supported when using Codex with a ChatGPT account/i walk
400 body contains model_not_found walk
400 body contains does not exist AND mentions a model name walk
404 (any body) walk
401 first time refresh and re-try same slug; do NOT walk
401 after refresh, 403, 429, 5xx propagate (don't walk)
200 with SSE response.failed.error.code === "invalid_prompt" propagate (request shape is wrong, slug is fine)

Positive cache — when a slug is observed working (HTTP 200 with at least one response.completed event), record (slug, timestamp) in a process-local Map. On the next request, jump straight to the cached slug. TTL: 1 hour. Cache scope: per-process (no DB write).

Cache invalidation: on first 400/404 walk, the cached entry is dropped. The next-best slug is promoted into the cache only after it succeeds.

All-failed case: if every slug in the chain rejects with the walk-trigger, the client throws a structured error with httpStatus: 503 and message "AI provider unreachable — all configured Codex slugs were rejected". The route layer surfaces this to the user with "AI provider unreachable — check Settings".

Observability: each attempted slug is recorded in the Wide-Event annotation codex.slug.attempts: ["gpt-5.3-codex","gpt-5-codex"] so Loki can answer "which slug carried this request" and "how often did the cache hit". Cache hits emit codex.slug.cache: "hit" | "miss" | "expired".

The implementation lives in src/lib/ai/codex-client.ts; the contract is unit-tested in src/lib/ai/__tests__/codex-client.test.ts.


8. Worked example — minimal end-to-end

Outbound:

POST /backend-api/codex/responses HTTP/1.1
Host: chatgpt.com
Authorization: Bearer eyJhbGciOi...<oauth-access-token>
ChatGPT-Account-ID: a1b2c3d4-...
Content-Type: application/json
Accept: text/event-stream
originator: healthlog
User-Agent: healthlog/1.0
session_id: 11111111-1111-1111-1111-111111111111
thread_id: 22222222-2222-2222-2222-222222222222
{
  "model": "gpt-5-codex",
  "instructions": "",
  "input": [
    {
      "type": "message",
      "role": "user",
      "content": [{ "type": "input_text", "text": "Say hi in one word." }]
    }
  ],
  "tools": [],
  "tool_choice": "auto",
  "parallel_tool_calls": false,
  "reasoning": null,
  "store": false,
  "stream": true,
  "include": []
}

Inbound (illustrative — based on codex-rs/codex-api/src/sse/responses.rs:666-730 test fixtures):

event: response.created
data: {"type":"response.created","response":{"id":"resp_abc"}}

event: response.output_item.added
data: {"type":"response.output_item.added","item":{"type":"message","role":"assistant","content":[]}}

event: response.output_text.delta
data: {"type":"response.output_text.delta","delta":"Hi"}

event: response.output_text.delta
data: {"type":"response.output_text.delta","delta":"!"}

event: response.output_item.done
data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Hi!"}]}}

event: response.completed
data: {"type":"response.completed","response":{"id":"resp_abc","usage":{"input_tokens":12,"output_tokens":2,"total_tokens":14}}}

A TS client should:

  1. Stream-parse SSE.
  2. Concatenate delta from each response.output_text.delta for live UI.
  3. On response.output_item.done with item.type=="message" and item.role=="assistant", take item.content as the canonical reply.
  4. On response.completed, store response.id (for follow-up turns) and response.usage (for token accounting).
  5. On response.failed / response.incomplete, surface error per §4c.

9. Quick reference — the original errors we hit

  • "Input must be a list" → input was a string. Wrap as [{ type: "message", role: "user", content: [{ type: "input_text", text: "..." }] }].
  • "Stream must be set to true" → must set stream: true. The chatgpt.com endpoint has no non-streaming mode.

Both come back as HTTP 400 with error.code == "invalid_prompt" (no cyber_policy flag) → mapped to ApiError::InvalidRequest per api_bridge.rs:75-77.


10. Source-file index (for re-verification)

All paths relative to openai/codex repo:

  • codex-rs/codex-api/src/common.rs — ResponsesApiRequest, Reasoning, TextControls, TextFormat
  • codex-rs/codex-api/src/endpoint/responses.rs — request construction, headers, ACCEPT
  • codex-rs/codex-api/src/requests/headers.rs — session/thread/sub-agent headers
  • codex-rs/codex-api/src/sse/responses.rs — SSE parsing, all event types, error mapping
  • codex-rs/codex-api/src/api_bridge.rs — HTTP error mapping
  • codex-rs/codex-api/src/rate_limits.rs — rate-limit headers
  • codex-rs/codex-api/src/auth.rs — AuthProvider trait
  • codex-rs/protocol/src/models.rs — ResponseItem, ContentItem, MessagePhase, ImageDetail, etc.
  • codex-rs/protocol/src/openai_models.rs — ReasoningEffort enum
  • codex-rs/protocol/src/config_types.rs — ReasoningSummary, Verbosity enums
  • codex-rs/model-provider/src/bearer_auth_provider.rs — Authorization + ChatGPT-Account-ID headers
  • codex-rs/model-provider/src/auth.rs — auth-provider selection
  • codex-rs/model-provider-info/src/lib.rs — CHATGPT_CODEX_BASE_URL, OpenAI provider creation
  • codex-rs/login/src/auth/manager.rs — token storage, refresh, expiry, CLIENT_ID
  • codex-rs/login/src/server.rs — code exchange (exchange_code_for_tokens), authorize URL (scope)
  • codex-rs/login/src/device_code_auth.rs — device-code flow
  • codex-rs/login/src/auth/default_client.rs — originator, User-Agent, residency
  • codex-rs/chatgpt/src/chatgpt_client.rs — confirms chatgpt_base_url + path pattern and ChatGPT-Account-ID requirement
  • codex-rs/core/src/client.rs — how the CLI builds requests (build_responses_request, build_responses_headers); the source of tool_choice="auto", store=false for chatgpt, stream=true, include=["reasoning.encrypted_content"] when reasoning is on, prompt_cache_key=thread_id