Add ChatGPT subscription provider via OAuth 2.0 PKCE#53166
Add ChatGPT subscription provider via OAuth 2.0 PKCE#53166morgankrey wants to merge 30 commits intomainfrom
Conversation
Adds a new language model provider that authenticates users with their ChatGPT Plus/Pro subscription using OpenAI's Codex CLI OAuth client, then routes requests to chatgpt.com/backend-api/codex/responses. - New openai_subscribed provider with OAuth PKCE sign-in flow - Stores credentials in the system keychain (access + refresh tokens) - Auto-refreshes tokens within 5 minutes of expiry - Exposes codex-mini-latest, o4-mini, and o3 models - Adds `store` field and `extra_headers` param to Responses API client
- Propagate refreshed credentials to in-memory State immediately after persisting, so subsequent requests don't redundantly refresh again - Clear sign_in_task when keychain write fails during OAuth, so the UI doesn't get stuck in a permanent "Signing in..." state
…vider - Replace the hardcoded CodexModel enum (3 models) with open_ai::Model, so all standard OpenAI models appear in the dropdown after signing in. - Add o4-mini as a proper variant in open_ai::Model. - Keep codex-mini-latest as a Custom model entry. - Add instructions field to responses::Request and extract system messages into it for the Codex backend (fixes 'Instructions are required' error). - Delegate to open_ai::Model for supports_images, max_output_tokens, count_tokens, and supports_parallel_tool_calls instead of hardcoding.
The Codex backend (chatgpt.com/backend-api/codex) only supports a subset of OpenAI models. Replace the open_ai::Model::iter() approach with a dedicated ChatGptModel enum listing only the models that work through the Codex backend, based on Roo Code's Codex model catalog: - gpt-5.4, gpt-5.4-mini - gpt-5.3-codex, gpt-5.3-codex-spark - gpt-5.2-codex, gpt-5.2 - gpt-5.1-codex-max, gpt-5.1-codex, gpt-5.1-codex-mini, gpt-5.1 - gpt-5-codex, gpt-5-codex-mini, gpt-5 Older models (gpt-3.5-turbo, gpt-4, o1, o3, etc.) and the deprecated codex-mini-latest are removed since they aren't available through this backend.
Multiple concurrent stream_completion calls seeing expired credentials would all independently call refresh_token(). With OAuth rotating refresh tokens, the first refresh invalidates the old token, causing all subsequent concurrent refreshes to fail. Add a Shared<Task> to State so the first caller to notice expiry spawns the refresh task and subsequent callers join the same future.
If the user starts the sign-in flow but never completes it in the browser, the TCP listener on port 1455 would block forever. Race the accept against a 2-minute timer so the port is released and the UI gets a meaningful error.
Replace hand-rolled format! string interpolation with url::form_urlencoded::Serializer in exchange_code and refresh_token. The previous code didn't percent-encode the code, verifier, or refresh_token values, which would break if they contained &, =, or +.
Early-return if a sign-in task is already in progress. Without this, a second call would drop the existing task (cancelling it while port 1455 may still be bound) and the new task could fail to bind.
Dropping sign_in_task prevents a completing OAuth flow from writing credentials back into state after the user has signed out.
The ConfigurationView was duplicating sign-out logic (missing the sign_in_task cancellation) and constructing a throwaway provider for sign-in. Extract both into free functions so the provider and the view share the same implementation.
The ConfigurationView shows 'Signed in as {email}' but the email
was never populated. OpenAI's token endpoint doesn't include email
at the top level — it's in the JWT id_token claims. Refactor
extract_account_id into extract_jwt_claims that parses the JWT once
and returns both account_id and email.
The hand-rolled percent_decode was incorrect for multi-byte UTF-8 (decoded each byte as an independent char). Use url::Url for building the auth URL and form_urlencoded::parse for decoding the callback query string. Remove both hand-rolled functions.
Previously a schema change or corruption would silently show the user as signed out. Now a log::warn helps diagnose the issue.
If the system clock is before the UNIX epoch, the silent unwrap_or(0) would cause broken credential expiry behavior. Now the error is visible in logs.
A single read() could miss query parameters if the browser's HTTP request is split across TCP segments. Read in a loop until the header terminator is found.
Add http_client::oauth_callback_page() that generates a nicely styled HTML page with Zed branding for OAuth callback responses. Use it in both the ChatGPT subscription provider and the MCP context server OAuth flow, replacing the unstyled inline HTML in both places. The template is parameterized on title and message so both callers get consistent styling that updates in one place.
smol::Timer::after is banned by a project-wide clippy lint. Pass the AsyncApp through to await_oauth_callback so it can use cx.background_executor().timer() instead.
7d1af89 to
64069d5
Compare
The into_open_ai_response call was hardcoding supports_parallel_tool_calls=true and supports_prompt_cache_key=false instead of asking the model. This meant reasoning models like GPT-5 Codex variants would incorrectly send parallel_tool_calls=true, which could cause API errors. Add the missing methods to ChatGptModel and delegate like the standard OpenAI provider does.
Three tests covering get_fresh_credentials: - test_concurrent_refresh_deduplicates: two concurrent callers with expired credentials only trigger one HTTP refresh call - test_fresh_credentials_skip_refresh: fresh credentials return immediately with no HTTP call - test_no_credentials_returns_no_api_key: missing credentials return the correct error variant
The default ConfiguredApiCard button label is "Reset Key" which makes sense for API key providers but not for an OAuth session.
…es, token counting - Extract OAuth callback server into http_client crate for reuse - HTML-escape oauth_callback_page parameters, add error styling - Update context_server to delegate to shared OAuth callback server - Rewrite openai_subscribed OAuth flow: ephemeral port, browser opens after listener - Add auth_generation counter to prevent stale refresh writes after sign-out - Make do_sign_out awaitable, cancel in-flight work immediately - Distinguish fatal (400/401/403) vs transient (5xx) refresh errors - Clear credentials on fatal refresh, keep on transient - Make authenticate() await initial credential load - Surface auth errors in ConfigurationView UI - Implement real token counting via tiktoken - Add tests for fatal/transient refresh, sign-out during refresh, authenticate-awaits-load
| // --------------------------------------------------------------------------- | ||
|
|
||
| #[cfg(not(target_family = "wasm"))] | ||
| mod oauth_callback_server { |
There was a problem hiding this comment.
This should probably live in its own crate, not http_client. Same for the hardcoded html above.
| let _ = tx.send(result); | ||
| }) | ||
| .detach(); | ||
| Ok((redirect_uri, mapped_rx)) |
There was a problem hiding this comment.
Instead of detaching and returning this mapped_rx. I think we should just return the Task with the desired result type. Also, is there a reason we use smol::spawn? Can we use our own executor?
| // The ChatGPT Subscription provider routes requests to chatgpt.com/backend-api/codex, | ||
| // which only supports a subset of OpenAI models. This list is maintained separately | ||
| // from the standard OpenAI API model list (open_ai::Model). | ||
|
|
||
| #[derive(Clone, Debug, PartialEq)] | ||
| enum ChatGptModel { | ||
| Gpt5, | ||
| Gpt5Codex, | ||
| Gpt5CodexMini, | ||
| Gpt51, | ||
| Gpt51Codex, | ||
| Gpt51CodexMax, | ||
| Gpt51CodexMini, | ||
| Gpt52, | ||
| Gpt52Codex, | ||
| Gpt53Codex, | ||
| Gpt53CodexSpark, | ||
| Gpt54, | ||
| Gpt54Mini, | ||
| } |
There was a problem hiding this comment.
It looks like codex uses /backend-api/codex/models to get the final list of models. Could we use that too so that we don't have to release a new version when they add a model?
https://github.com/openai/codex/blob/main/codex-rs/codex-api/src/endpoint/models.rs
| s.credentials = None; | ||
| s.sign_in_task = None; | ||
| s.refresh_task = None; | ||
| s.last_auth_error = None; |
There was a problem hiding this comment.
Should these be combined under one AuthState enum? There may be a reason this isn't practical, I haven't dug into it yet
Adds a new language model provider that lets users authenticate with their ChatGPT Plus/Pro subscription and use OpenAI models (codex-mini-latest, o4-mini, o3) directly in the Zed agent — without needing a separate API key.
How it works
OAuth 2.0 + PKCE sign-in: Uses OpenAI's official Codex CLI client ID to run an authorization code flow. A local HTTP server on
127.0.0.1:1455captures the callback, exchanges the code for tokens, and stores them in the system keychain.Token refresh: Access tokens are automatically refreshed when they're within 5 minutes of expiry, using the stored refresh token.
Responses API: Requests go to
https://chatgpt.com/backend-api/codex/responsesusing the existingopen_ai::responsesclient (Responses API format, not Chat Completions which was deprecated for this endpoint in Feb 2026).Required headers:
originator: zed,OpenAI-Beta: responses=experimental,ChatGPT-Account-Id(extracted from JWT),store: falsein the body.Files changed
crates/open_ai/src/responses.rs: Addstore: Option<bool>field toRequest; addextra_headersparam tostream_responsefor per-provider header injectioncrates/language_models/src/provider/openai_subscribed.rs: New provider (sign-in UI, OAuth flow, token storage/refresh, model list)crates/language_models/src/provider/open_ai.rs,open_ai_compatible.rs,opencode.rs: Passvec![]for newextra_headersparamcrates/language_models/src/language_models.rs: Register the new providercrates/language_models/Cargo.toml: Addrandandsha2deps for PKCEOpen questions / known gaps
http://localhost:1455/auth/callback— may need to match exactly what OpenAI's Codex CLI useso3availability: o3 may require a higher subscription tier; consider gating itTesting
Sign-in flow was designed to match the Copilot Chat provider pattern. Manual testing against the live OAuth endpoint is needed.
Release Notes: