Skip to content

feat(zarvis): codex-oauth provider — ChatGPT subscription via OAuth - #46

Merged
edwin-zvs merged 4 commits into
mainfrom
feat/codex-oauth-provider
May 19, 2026
Merged

edwin-zvs merged 4 commits into
mainfrom
feat/codex-oauth-provider

Conversation

@edwin-zvs

Copy link
Copy Markdown
Contributor

Summary

Adds a new codex-oauth: provider so zarvis can run against the user's ChatGPT subscription, billed via the OAuth-backed Codex backend (the same endpoint Codex CLI uses). Distinct from openai: which stays on platform pay-as-you-go.

zarvis  codex-oauth:gpt-5-codex
zarvis  codex-oauth:gpt-5

Reads ~/.codex/auth.json (the file codex login writes). Refresh tokens rotate; this code persists rotations atomically with the same tmp+fsync+rename dance Codex CLI uses, and surfaces refresh_token_reused / refresh_token_invalidated as actionable errors instead of silent retries.

Why a separate provider?

Different host + path (chatgpt.com/backend-api/codex/responses, not api.openai.com/v1/responses), different auth (Bearer + ChatGPT-Account-ID, not x-api-key), different request body (Responses-API shape, instructions + input items, not Chat Completions), different model strings (gpt-5, gpt-5-codex, gpt-5-codex-mini), different cookie/UA fingerprint expectations (Cloudflare blocks default reqwest/* UA). Forcing all that through openai: would silently swap billing paths; explicit prefix keeps the choice visible.

Three commits

Commit Scope
22c0ac8 Scaffolding: codex-oauth: prefix in routing, stub provider that returns "not yet implemented", match-arm coverage in provider_name, resolve_model_from_spec, title_mode.
b166d6a Auth layer: auth.json loader, refresh against auth.openai.com/oauth/token, atomic rotation persistence, needs_refresh heuristic.
363b4f9 Wire layer: build_responses_body for the Responses-API JSON, SSE event streaming through the TextSink, tool-call accumulation by item_id, terminal-event usage + stop reason, context_window_tokens entry.

Operator-supplied instructions

The Codex backend rejects empty instructions. We don't bundle the upstream gpt_5_codex_prompt.md by default (would silently mirror Codex CLI's behavior into zarvis sessions). Operator must export AGENTD_ZARVIS_CODEX_INSTRUCTIONS with either:

  • A copy of the upstream codex prompt (mirror Codex CLI exactly), OR
  • A zarvis-tailored prompt (sacrifice prompt-cache hit rate, behavior may differ subtly).

Unset/empty → hard error at first complete() call, with the env-var name + a pointer to where to copy a canonical prompt from.

Known limitations / follow-ups

  • Live integration smoke test pending. Unit tests pin the request-body JSON and SSE-event semantics in isolation; the full path (~/.codex/auth.json → refresh → streaming response → ProviderTurn) hasn't been exercised against a real subscription account yet. Operator-tested in this PR.
  • Cross-process refresh race still possible (multiple zarvis adapters refreshing at the same instant). Documented; refresh window is narrow. Adding a file lock has its own concurrency edges.
  • Reasoning summary events observed but not surfaced.
  • Cookie store not wired (workspace reqwest lacks the cookies feature). If Cloudflare challenges start firing we'll need to enable it.
  • JWT-parsing fallback for account_id: only the tokens.account_id field is read today. Newer codex builds populate it directly; older builds left it blank and required parsing it from the id_token JWT. Add when we see it bite.
  • Anthropic OAuth (the credit-pool Agent SDK path) is a separate follow-up PR, deferred until after the June 15, 2026 credit-pool cutover settles.

Test plan

  • cargo test --workspace --test-threads=1 — all green (zarvis: 81 → 93 tests).
  • Routing: codex-oauth: parses; bare gpt-5-codex stays on openai:.
  • Auth: load, missing-file error, no-tokens hint, blank tokens, atomic save round-trip, needs_refresh decisioning.
  • Body builder: plain message, tool spec, full tool-call/result round trip, empty-prose edge case.
  • Live smoke against a real ~/.codex/auth.json — operator.

🤖 Generated with Claude Code

edwin-zvs and others added 4 commits May 18, 2026 16:01
Adds the `codex-oauth:` model-spec prefix so users can opt into the
subscription-billed Codex backend (`chatgpt.com/backend-api/codex/responses`)
distinct from the platform `openai:` provider. No bare-name fallback —
opting into a different billing path requires the explicit prefix,
even for codex-specific model strings like `gpt-5-codex`.

This commit is scaffolding only:

  * `provider::routing::Provider::CodexOauth` + `codex-oauth:` parser
  * New `provider::codex_oauth` module exposing a `CodexOauth` struct
    that satisfies the `LlmProvider` trait and returns a clear
    `not yet implemented` error from `complete()`
  * Match-arm coverage in `ResolvedModel::provider_name`,
    `resolve_model_from_spec`, and `title_mode::provider_for`
    (title-gen explicitly does not support codex-oauth — it picks
    from openai/anthropic/ollama defaults)
  * Routing tests for both directions: explicit `codex-oauth:` works,
    bare `gpt-5-codex` does NOT silently route here

Subsequent commits will fill in:
  - `~/.codex/auth.json` loader with refresh-token rotation
    persistence (refresh tokens rotate per call; failing to persist
    triggers `refresh_token_reused` and logs the user out)
  - Request builder for the Responses-API-shaped body
    (`instructions` + `input`, distinct from chat completions)
  - SSE event parser for `response.output_text.delta` etc.
  - Required-headers helper: `Authorization`, `ChatGPT-Account-ID`,
    `OpenAI-Beta`, `originator: codex_cli_rs`, non-default User-Agent
    (the default `reqwest/*` UA gets blocked by Cloudflare; verified
    against `openai/codex` CLI hitting the same wall on Linux)
  - Tool-use mapping (Responses tool shape differs from Chat Completions)
  - Context window for codex-oauth models

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires up the credential half of the provider. The `complete()` call
still returns "implementation pending" but now does so AFTER:

  - locating `auth.json` (`$CODEX_HOME` → `~/.codex/auth.json`),
  - parsing the OAuth `tokens` block with actionable errors when the
    user is in API-key mode or hasn't run `codex login`,
  - refreshing against `https://auth.openai.com/oauth/token` when
    `last_refresh` is missing or >24h old (modulo a 5-min leeway),
  - persisting the rotated `refresh_token` atomically (tmp file
    + fsync + rename, matching codex CLI's
    `codex-rs/login/src/auth/manager.rs` dance) so a crash during
    rotation can't strand the user with `refresh_token_reused` on
    the next start,
  - and surfacing a clear "re-run `codex login`" diagnostic when the
    server says `refresh_token_reused` / `refresh_token_invalidated`
    instead of letting it look like a transient network failure.

reqwest client uses a non-default `codex_cli_rs/<ver>` User-Agent.
The default `reqwest/*` UA gets blocked at Cloudflare on the
`chatgpt.com/backend-api/...` path (verified against openai/codex
sources — affects even their own CLI on Linux). The cookie store
isn't wired yet because workspace reqwest doesn't have the `cookies`
feature enabled; documented in the constructor for if Cloudflare
challenges start firing.

In-process refresh is serialized via a tokio Mutex on the auth
state. Cross-process (multiple zarvis adapters refreshing at the
same instant) is still a possible race; documented as a known
limitation since the refresh window is narrow and file-locking has
its own concurrency edges.

Six new unit tests, all on the pure path-logic / file I/O surface:
load round-trip, missing-file error, missing-tokens hint, blank
tokens, atomic save, and needs_refresh decisioning.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ming

Implements the live half of the `codex-oauth` provider: builds the
`POST https://chatgpt.com/backend-api/codex/responses` request body
in Responses-API shape, sends it with the required Cloudflare-/
CLI-mimicking headers (`OpenAI-Beta: responses=experimental`,
`originator: codex_cli_rs`, `ChatGPT-Account-ID`, custom UA), and
streams the SSE response back into `ProviderTurn` (text deltas
through the sink, accumulated tool calls in stream order, usage +
stop reason from the terminal event).

Notable design choices:

  * `instructions` is NOT auto-populated from the agent's `system`
    arg or from a bundled prompt. Operator MUST set
    `AGENTD_ZARVIS_CODEX_INSTRUCTIONS`; unset/empty produces a
    clear error that names the env var and points to where to
    copy a canonical prompt from (the upstream codex CLI's
    `gpt_5_codex_prompt.md`). Per the design decision: avoid
    shipping zarvis's prompt into the codex prompt-cache key and
    avoid silently mirroring CLI behavior the operator didn't
    opt into.

  * `Message` → Responses `input` items mapping is the long pole.
    AssistantToolCalls fans out into one `message` item (only when
    the pre-tool prose is non-empty) plus one `function_call` item
    per call. ToolResult becomes a `function_call_output` item.
    Tool spec uses the flatter Responses shape (no `function`
    wrapper, top-level `name`/`description`/`parameters`).

  * SSE event handling covers `response.output_text.delta` for
    streamed prose, `response.output_item.added` +
    `response.function_call_arguments.{delta,done}` for tool
    calls accumulated by `item_id`, and the
    `response.{completed,incomplete,failed}` terminal events for
    usage + stop reason. Other event kinds (reasoning summary
    deltas, raw items) are observed but not surfaced — follow-up.

  * Auth: the existing in-process Mutex around `AuthState` is held
    only across the optional refresh + token snapshot; the HTTP
    call streams without holding the lock so concurrent turns
    don't serialize.

  * Error mapping: 400 with overflow-shaped body becomes the typed
    `ContextOverflow` sentinel (the existing `parse_overflow`
    matcher already covers the wording variants we'd expect from
    this backend). 401 surfaces as "re-run `codex login`" so the
    agent loop doesn't retry forever.

  * `context_window_tokens` gets a `codex-oauth` arm at 400K
    (matches the gpt-5 family); the runtime overflow-learn path
    in `model_limits.rs` tightens it if the subscription tier
    enforces something lower.

Four new unit tests on the request-body shape (pure JSON, no
network): plain message turn, tool-spec emission, full
tool-call/result round trip, and the no-empty-prose edge case.

Total tests in adapter-zarvis: 81 → 93. Live integration smoke
test is still pending — needs a real `~/.codex/auth.json` to
exercise the full path; the test surface today pins the
request/response JSON shape independently.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…prompt by default

Two follow-ups from live testing against a real ChatGPT-account
subscription:

1) The ChatGPT-account backend rejects requests with `store: true`
   (the public Responses API default) with
   `400 "Store must be set to false"`. Codex CLI sets it explicitly
   in `codex-rs/core/src/client.rs`; we now do too. Locked in by an
   assertion in `build_body_emits_responses_message_shape`.

2) The `AGENTD_ZARVIS_CODEX_INSTRUCTIONS` env var was overcautious.
   When live-testing, the obvious move was to set it to `"hi"` just
   to satisfy the "non-empty" server check — friction without value.
   Resolution order is now:

     a) `AGENTD_ZARVIS_CODEX_INSTRUCTIONS` (operator explicit
        override — useful for mirroring Codex CLI exactly by pasting
        the upstream `gpt_5_codex_prompt.md`),
     b) the agent's `system` arg (zarvis system prompt — same slot
        every other provider already uses; this is the default
        out-of-box experience),
     c) error only when both are empty.

   This makes `codex-oauth:` symmetric with `openai:` /
   `anthropic:` / `ollama:` from the operator's perspective — no
   special env-var setup unless they want CLI-exact behavior.

Also fixes a minor doc lie: the `_system` argument is no longer
intentionally ignored — it's the default `instructions` value.

New test `resolve_instructions_prefers_env_then_system` covers all
four branches (env wins, env-blank-falls-back, env-unset-uses-system,
both-empty-errors).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@edwin-zvs
edwin-zvs merged commit 8b17d97 into main May 19, 2026
1 check passed
@edwin-zvs
edwin-zvs deleted the feat/codex-oauth-provider branch May 19, 2026 14:11
edwin-zvs added a commit that referenced this pull request May 19, 2026
Adds an optional `env` table to `[adapters.<name>]` so operators can
set per-harness env defaults from `~/.config/agentd/config.toml`
without needing to export them in the shell that launches the
daemon. Closes the gap users hit after #46 — wanting to pin the
zarvis default model without `export AGENTD_ZARVIS_MODEL=…` in
their shell config.

    [adapters.zarvis]
    env = { AGENTD_ZARVIS_MODEL = "codex-oauth:gpt-5.5" }

Precedence (lowest → highest):

  1. `[adapters.<name>].env` — operator default in config.toml
  2. `params.env` — per-session env from `agent new --env KEY=VAL`
  3. daemon meta — AGENTD_SESSION_DATA_DIR / AGENTD_SESSION_KIND
     (always wins so adapters can rely on them being right)

The merge is applied in both `SessionManager::create` and
`SessionManager::respawn`. Persisted `start_params.env` still wins
on respawn because it gets layered on top of `adapter_cfg.env` —
the per-session CLI flag stays authoritative across daemon restarts.

Two new tests pin the TOML parse + the absent-env-is-empty default.

The inline TOML example comment in `config.rs` also got a `zarvis.env`
snippet so users discover the field when they look at the bundled
default config.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
edwin-zvs added a commit that referenced this pull request May 19, 2026
Resolved conflicts:
  - crates/cli/src/ui.rs: took upstream's reveal-pin-on-real-drops algorithm
    (#47, #49) and reapplied the SessionEvent::ContextCompacted arms in
    format_event_body + short_event_label. Conflicts were only there
    because cargo fmt churn from this branch touched lines near the
    upstream rewrite.
  - crates/adapter-zarvis/src/provider/codex_oauth.rs: new provider
    introduced by #46 needed a Content::Summary arm matching the other
    providers — emits the SUMMARY_WIRE_PREFIX'd text as a user input
    message in the Responses-API shape.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant