* feat(oauth): add Hugging Face + ChatGPT Codex as first-class built-in OAuth providers
Adds two new built-in OAuth presets that round out the existing OpenRouter and
xAI surfaces, so the TUI `/provider` OAuth chooser and `zero auth login` work
out of the box for these providers.
Hugging Face
- Preset scopes, endpoints, and OIDC issuer are pre-filled; the operator
supplies a "public" OAuth client_id (no secret) via
`ZERO_OAUTH_HUGGINGFACE_CLIENT_ID` after registering an app at
huggingface.co/settings/applications/new.
- Default flow is RFC 8628 device code, so headless/SSH is the first-class
path (matches the existing xAI posture).
- The bearer works against the OpenAI-compatible Inference Providers router
at `https://router.huggingface.co/v1` for hundreds of OSS models.
ChatGPT (Codex backend)
- Uses the publicly-shipped Codex CLI client identity
(`app_EMoamEEZ73f0CkXaXp7hrann`), opt-in via `ZERO_OAUTH_ALLOW_PRESETS=1`,
overridable via `ZERO_OAUTH_CHATGPT_*`.
- The bespoke `provideroauth.ChatGPTLogin` extracts the `chatgpt_account_id`
claim from the OIDC ID token (added as `IDToken` on the `oauth.Token`
struct) and stores it on `Token.Account` so the Codex provider can inject
it as a header on every request.
- A new `internal/providers/openai/codex.go` wraps the openai provider with
the Codex-flavored request shape: `originator: codex_cli_rs`,
`chatgpt-account-id: <claim>`, and a branded User-Agent. The factory
branches off the catalog id (not the provider kind) so other
openai-compatible providers keep the standard openai path unchanged.
- The openai provider gained a `SetRequestExtra` callback so the Codex
wrapper can inject per-request headers without coupling the openai
provider to Codex.
CLI / TUI
- `zero auth chatgpt` is a one-shot login command (analog to
`zero auth openrouter`); the TUI's OAuth chooser now lists both new
providers automatically (the list is built from
`providercatalog.OAuthProviders()`).
- The provider-wizard visible-row cap was bumped from 8 to 10 so the
common providers (OpenAI, Anthropic, Google, Ollama, OpenRouter, the
new Hugging Face, the new ChatGPT, Groq, …) all fit without scrolling
the test fixtures.
Tests
- `internal/oauth/presets_test.go`: presets resolve correctly with the
expected endpoints, scopes, and flows; env overrides win; Hugging Face
preset stays inert without a client_id (the safety posture the existing
xAI tests assert).
- `internal/providercatalog/oauth_test.go` + `catalog_test.go`: catalog
now exposes 4 OAuth providers (`openrouter`, `huggingface`, `chatgpt`,
`xai`); the `chatgpt` descriptor is `OAuth=true` with
`OAuthDeviceFlow=false` (Codex is loopback-only) and `RequiresAuth=true`
via an inline IIFE that sets it after the openAICompat helper.
- `internal/provideroauth/chatgpt_test.go`: ID-token claim extraction
round-trips (happy path, missing claim, missing token, tampered JWS,
non-string claim); the full login flow runs against a stub auth server
and verifies the preset's client_id reaches the token endpoint.
- `internal/providers/openai/codex_test.go`: every request gets the three
Codex headers; the 401-refresh retry path keeps them on the second
attempt; configured baseURL wins; default originator is `codex_cli_rs`;
branded User-Agent wins over the openai default; AccountResolver is
consulted when AccountID is empty; the header is omitted when the
resolver says no.
Out of scope (separate PRs): multi-account / profiles, generic
template-driven OAuth wizard, MCP / provider OAuth store unification,
subscription-proxy autoconfig.
* fix(ci): gofmt chatgpt_test.go and provider_wizard.go
The ubuntu smoke job's "Check formatting" step lists these two files as
unformatted (struct field alignment). The local gofmt run on Windows
also lists ~170 other files, but those are CRLF-vs-LF noise from
checkout and not real gofmt issues on Linux; the CI only flags the
two struct-alignment changes below.
- internal/provideroauth/chatgpt_test.go: align chatgptTestServer fields
- internal/tui/provider_wizard.go: align ChatGPTOptions literal fields
* fix(codex): hit the /responses path and resolve account id per request
Addresses the two findings in anandh8x's CHANGES_REQUESTED review on #245.
The Codex provider previously routed the chat-completions body to
{baseURL}/chat/completions, which is not the path the Codex backend serves
(chatgpt.com/backend-api/codex/responses, the Responses API). Add an
Endpoint field to openai.Options so a flavor can target a sibling path on
the same host; the Codex constructor sets it to {baseURL}/responses and
falls back to the openai transport unchanged for everything else.
The factory was also passing the OAuth token's Account field as a static
AccountID at construction time. A refresh that rotates the token (and
its account claim) would leave a stale value on the wire. Drop the
static AccountID from the factory wiring; the per-request
AccountResolver (which reads the live store) is now the sole source, so
a refresh takes effect on the next outgoing request.
Tests:
- codex_test.go: handler mounted on /responses; expected path updated;
TestCodexProviderResolverIsConsultedOnEveryRequest pins the per-request
resolver behavior (a refresh-style sequence of calls picks up the new
account id on the second attempt).
- factory_test.go: expected request path updated to /responses.
* Addresses the Codex path shape on /responses.
The previous fix (2381197) corrected the URL and the per-request account
resolver, but the Codex provider still spoke chat-completions: it sent
{model, messages, tools, stream, stream_options, max_completion_tokens}
to /responses and parsed data: [DONE]-terminated SSE frames
(choices[].delta). The Codex backend at chatgpt.com/backend-api/codex
serves the Responses API — input items, function_call /
function_call_output, response.output_text.delta /
response.function_call_arguments.delta events — and rejects a
chat-completions body.
The Codex provider now has its own request/stream path that matches the
Responses schema:
- internal/providers/openai/codex_responses.go (new) holds the wire
types (responsesRequest, inputItem, contentItem, responsesTool,
responsesEvent, itemPayload, responsePayload, usagePayload), the
request builder (system → message/role=system, user/assistant →
message with text or content parts, prior tool calls → function_call,
tool results → function_call_output, tools → responsesTool), the
Responses SSE dispatcher, the tool-call accumulator
(response.output_item.added registers the call id + name, the
argument deltas accumulate, response.output_item.done finalizes),
and the terminal-event handler (response.completed emits usage +
done, response.failed surfaces the error after the usage chunk so
token accounting is preserved).
- internal/providers/openai/codex.go: StreamCompletion now marshals a
Responses request and dispatches via the new streamResponses
function. The wrapped openai Provider is still constructed for its
validated endpoint / auth / retry / idle-timeout config and its
SetRequestExtra callback, but the openai chat-completions stream
parser is no longer used for Codex. injectCodexHeaders still runs
on every attempt (including the 401-refresh retry) so the Codex
headers stay attached to the rotated bearer.
The shared providerio plumbing (auth + 401-retry, SSE scanner with
the idle watchdog, error humanization, secret redaction) is reused
verbatim so the OAuth 401-refresh and bearer-rotation paths stay
identical to the rest of the openai-compatible surface. The
stream-end fallback surfaces a StreamEventDone with
FinishReasonLength when the Codex backend closes the stream without
a response.completed (e.g. an internal truncation), so the runtime
can react instead of hanging on a phantom completion.
Tests:
- internal/providers/openai/codex_test.go:
- newCodexTestServer now returns a minimal Responses sequence
(response.created + response.completed); newCodexResponsesServer
takes a custom payload sequence for richer cases.
- The two tests with inline handlers (TestCodexProviderResolverIs-
ConsultedOnEveryRequest, TestCodexProviderRetriesHeadersAfter401)
use a response.completed payload on the second attempt instead of
data: [DONE].
- TestCodexProviderSendsResponsesRequestShape replaces the old
TestCodexProviderSendsRequestBody and pins the Responses shape:
the body MUST carry an `input` array of message / function_call
/ function_call_output items and `tools` of function type, and
MUST NOT carry the chat-completions `messages` field.
- TestCodexProviderParsesResponsesTextDeltas: response.output_text.
delta events surface as StreamEventText in order, the
response.completed usage chunk surfaces as StreamEventUsage
(with input_tokens, output_tokens, cached_input_tokens, and
reasoning_tokens from the optional details blocks), and
StreamEventDone follows.
- TestCodexProviderParsesResponsesToolCalls:
response.output_item.added emits StreamEventToolCallStart with
the right name, response.function_call_arguments.delta events
emit StreamEventToolCallDelta in order, response.output_item.done
emits StreamEventToolCallEnd.
- TestCodexProviderSendsAssistantToolCallsAsInputItems: a runtime
message with content + tool_calls serializes as BOTH the
assistant message and a function_call item; a tool result
serializes as a function_call_output. The expected count is 5
items (user + assistant text + function_call + function_call_
output + user) — earlier this test had 4, which would have
missed the assistant message before the function_call.
- TestCodexProviderEmitsErrorOnResponseErrorEvent: a response.error
event surfaces as a single StreamEventError with the server's
message and stops the scan.
- TestCodexProviderEmitsErrorOnMalformedStream: a payload that
parses as JSON but lacks the `type` field is reported as a
stream error rather than silently dropped.
- TestCodexProviderEmitsLengthFinishWhenStreamEndsWithout-
Completion: a stream that closes without a response.completed
surfaces a StreamEventDone with FinishReasonLength.
Verification:
- `go build ./...` clean (with the user-owned untracked files moved
aside per the project's working-tree convention).
- `go vet ./internal/providers/...` clean.
- `go test ./internal/providers/... ./internal/providercatalog/...
./internal/provideroauth/... ./internal/oauth/...` PASS.
- `gofmt -l` on the three changed files clean.
11 KiB
OAuth logins & using ChatGPT / Claude subscriptions
Zero supports two distinct things people mean by "log in with OAuth":
- OAuth login for a provider/gateway that issues a standard bearer token —
fully built in (
zero auth login …). Zero attaches the token to model calls automatically. - Using a ChatGPT or Claude subscription (Plus / Pro / Max) instead of a pay-per-token API key — only possible through a local proxy, for the reasons documented below. Zero ships a convenience preset and this recipe.
1. OAuth login for a provider or gateway (built in)
For any OAuth 2.0 / OIDC provider that returns a normal access token usable as
Authorization: Bearer … on its API, configure it with ZERO_OAUTH_<NAME>_*
env vars and log in:
export ZERO_OAUTH_ACME_CLIENT_ID=…
export ZERO_OAUTH_ACME_AUTHORIZE_URL=https://acme.example/oauth/authorize
export ZERO_OAUTH_ACME_TOKEN_URL=https://acme.example/oauth/token
export ZERO_OAUTH_ACME_SCOPES="openid profile"
zero auth login acme # browser (loopback); --device for headless
zero auth status
When a login exists for a provider, the OpenAI and Anthropic providers send
Authorization: Bearer <fresh-token> (auto-refreshed; one refresh-and-retry on a
401) instead of the API key. With no login they use the API key exactly as
before. Tokens are stored 0600 (or the OS keyring with
ZERO_OAUTH_STORAGE=keyring) and never logged. See zero auth --help.
In the setup wizard (/provider)
Running /provider opens a "How do you want to connect?" chooser:
❯ Sign in with OAuth One-click browser login (OpenRouter, xAI, ChatGPT, Hugging Face)
Paste an API key / browse providers Any of 20+ providers, local, or a proxy
Pick Sign in with OAuth → the list of providers that do real OAuth → choose one:
❯ OpenRouter browser sign-in · creates a key
xAI (Grok) browser or device code
ChatGPT browser (Codex backend, ChatGPT Plus/Pro)
Hugging Face browser or device code
- OpenRouter / xAI / ChatGPT / Hugging Face are real OAuth: your browser
opens to approve → done (no key to paste). OpenRouter mints a key; xAI /
ChatGPT / Hugging Face store a refreshable bearer. Hugging Face requires a
one-time OAuth-app registration (no secret needed for "public" apps); the
preset pre-fills scopes, endpoints, and the OIDC issuer. The same chooser
appears in first-run onboarding. (xAI uses an opt-in preset — set
ZERO_OAUTH_ALLOW_PRESETS=1or your ownZERO_OAUTH_XAI_*; see below.) - Device code (headless / SSH): for a provider that supports it (xAI,
Hugging Face), press d on the list to get a code to enter on another
device instead of opening a browser. On an SSH session or headless Linux box
(no
DISPLAY) device code is used automatically; setZERO_OAUTH_DEVICE=1to force it anywhere. The CLI equivalent iszero auth login <name> --device. - ChatGPT / Claude are intentionally not in this list for the proxy path —
use the dedicated
chatgpt-proxy/custom-anthropic-compatiblepreset (see §2) for subscription-via-proxy. ChatGPT is a first-class OAuth provider in this version (routes to the Codex backend) — see "Built-in OAuth providers" below.
Built-in OAuth providers
- OpenRouter (no env needed) —
zero auth openrouteropens a browser, you approve, and it mints an OpenRouter API key (public PKCE flow, no client_id). In the interactive setup wizard, pick OpenRouter and press ctrl+o at the key step to do the same inline ("Log in with OAuth"). The minted key is saved to the provider profile and used normally. - xAI (Grok) — opt-in preset — xAI's flow needs an OAuth
client_id. Zero ships a built-in preset for the public Grok-CLI client, but to keep third-party client identities out of the default credential path it is off by default. Enable it withexport ZERO_OAUTH_ALLOW_PRESETS=1, thenzero auth login xai(browser, or--devicefor headless) works one-click; the token is used directly onapi.x.ai/v1. Without the opt-in, setZERO_OAUTH_XAI_CLIENT_ID(and endpoints, or an issuer) yourself viaZERO_OAUTH_XAI_*. Either way the preset is fully overridable byZERO_OAUTH_XAI_*(env wins), and it requires a SuperGrok / X Premium+ subscription; the client_id is an undocumented public Grok-CLI client that may change without notice. - ChatGPT (Codex) — opt-in preset —
zero auth chatgptopens a browser, you approve with your ChatGPT Plus/Pro/Business/Enterprise account, and the bearer is stored. The bearer routes tohttps://chatgpt.com/backend-api/codex/responses(the same endpoint the openai/codex CLI uses), withoriginator: codex_cli_rsand thechatgpt-account-idclaim injected as headers on every request. Thechatgpt-account-idis extracted from the OIDC ID token and stored alongside the bearer; if the claim is missing (older ChatGPT accounts, or a rotated authorization server), the Codex backend will 401 andzero auth status chatgptwill show the warning. Like xAI, the preset uses the publicly-shipped Codex CLI client identity (app_EMoamEEZ73f0CkXaXp7hrann) and is opt-in viaZERO_OAUTH_ALLOW_PRESETS=1. As of mid-2026 the Codex backend is Cloudflare-gated: requests from a non-Codex client can still be challenged, and thechatgpt-proxyroute in §2 is the conservative fallback. - Hugging Face — opt-in preset, BYO client_id —
zero auth login huggingface(or--devicefor headless) opens a Hugging Face OAuth flow. The bearer works on the OpenAI-compatible router athttps://router.huggingface.co/v1for hundreds of OSS models (Llama, Qwen, DeepSeek, Mistral, etc.). HF does not ship a globally-known client_id, so the preset ships endpoints + scopes + the OIDC issuer pre-filled; you must register a "public" OAuth app (no secret) at https://huggingface.co/settings/applications/new and set the resultingclient_idviaZERO_OAUTH_HUGGINGFACE_CLIENT_ID. Enable the preset withZERO_OAUTH_ALLOW_PRESETS=1(or omit it — the BYO client_id path usesclient_credentials = noneand doesn't need the opt-in). Free tier has strict rate limits; Pro removes them.
Any field of a preset is overridable via ZERO_OAUTH_<NAME>_*. For a fully custom
OAuth/OIDC provider, set those env vars (see zero auth --help) and
zero auth login <name>.
2. ChatGPT / Claude subscriptions — why a proxy is required
We researched this carefully. As of mid-2026, a subscription OAuth token does not work as a drop-in bearer against the standard APIs:
- OpenAI (ChatGPT): a "Sign in with ChatGPT" token only works against
ChatGPT's own backend (
chatgpt.com/backend-api/codex/responses, the Responses API), notapi.openai.com. That backend is Cloudflare bot-protected — non-browser / headless clients getcf-mitigated: challenge→403. It also requires mimicking the official Codex client (originator + account-id header). First-class path (this version):zero auth chatgptdoes exactly that mimicking (originator: codex_cli_rs,chatgpt-account-id: <claim>) and routes requests to the Codex backend, no proxy required — see §1. Thechatgpt-proxyroute below is the conservative fallback when Cloudflare challenges become an issue, and is the only path that works without a browser-based ChatGPT OAuth login. - Anthropic (Claude): the Messages API rejects subscription OAuth tokens
for third-party use unless the request spoofs the Claude Code identity
(
anthropic-beta: oauth-2025-04-20,claude-cliUA, and a verbatim "You are Claude Code…" system prompt) — and even then tool-using requests on Max plans are routed to a disabled billing lane and400. Anthropic's policy prohibits subscription-token use outside Claude Code / claude.ai, and the timeline hardened through 2026: a Feb 19 2026 docs update spelled out that Free/Pro/Max OAuth tokens may not be used in third-party tools or the Agent SDK, then on April 4 2026 enforcement landed and subscription OAuth tokens stopped working in third-party harnesses (starting with OpenClaw, then the rest). As of mid-2026 the only supported ways to drive Claude from a third-party tool are a standard API key or pay-as-you-go "Extra Usage" billing — both per-token, not the flat subscription. The request to allow subscription use (claude-code #37205) was closed "not planned."
So Zero does not call those backends directly or spoof those clients — that
would be fragile, account-risky, and (for Anthropic) against the vendor's terms.
The robust, supported pattern is a local proxy that holds your subscription
session and exposes a clean OpenAI- or Anthropic-compatible endpoint on
127.0.0.1. The proxy absorbs the Cloudflare / client-spoofing surface; Zero
just points at it.
ChatGPT via a local proxy
Run a local ChatGPT OAuth proxy that exposes an OpenAI-compatible endpoint (these
typically listen on 127.0.0.1:10531/v1). Then use the built-in chatgpt-proxy
preset (no API key — the proxy authenticates):
// ~/.config/zero/config.json (or ./.zero/config.json)
{
"activeProvider": "chatgpt",
"providers": [
{
"name": "chatgpt",
"catalogID": "chatgpt-proxy", // OpenAI-compatible, local, no key
"baseURL": "http://localhost:10531/v1", // override for your proxy's port
"model": "gpt-5" // whatever model your proxy serves
}
]
}
zero exec --prompt "say hi" # routes through the proxy → your ChatGPT plan
Claude via a local proxy
There is no single canonical Claude OAuth-proxy port, so use the generic
custom-anthropic-compatible entry pointed at your proxy's Anthropic-compatible
endpoint:
{
"activeProvider": "claude",
"providers": [
{
"name": "claude",
"catalogID": "custom-anthropic-compatible",
"baseURL": "http://localhost:<port>", // your Claude proxy
"apiKey": "unused-by-proxy",
"model": "claude-sonnet-4.5"
}
]
}
3. Supported alternatives (no proxy)
- API key (recommended, simplest): set
OPENAI_API_KEY/ANTHROPIC_API_KEY(or per-profileapiKey) and use theopenai/anthropiccatalog providers. Bills as API usage. - Anthropic subscription automation, sanctioned: spawn the real
claudeCLI (e.g.claude -p …) as a subprocess — the only path Anthropic recognizes as a first-class subscription session.
Notes
- The
chatgpt-proxybase URL / port and model are defaults you override for your setup; they are not an endorsement of any specific proxy implementation. - Subscription-via-proxy depends on third-party tools and undocumented vendor backends; it can break without notice. The API-key path is the stable one.