Skip to content

Commit 37f6d7a

Browse files
morgankreyzed-zippy[bot]rtfeldmanagu-z
authored
Add ChatGPT subscription provider via OAuth 2.0 PKCE (#53166)
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 1. **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:1455` captures the callback, exchanges the code for tokens, and stores them in the system keychain. 2. **Token refresh**: Access tokens are automatically refreshed when they're within 5 minutes of expiry, using the stored refresh token. 3. **Responses API**: Requests go to `https://chatgpt.com/backend-api/codex/responses` using the existing `open_ai::responses` client (Responses API format, not Chat Completions which was deprecated for this endpoint in Feb 2026). 4. **Required headers**: `originator: zed`, `OpenAI-Beta: responses=experimental`, `ChatGPT-Account-Id` (extracted from JWT), `store: false` in the body. ## Files changed - `crates/open_ai/src/responses.rs`: Add `store: Option<bool>` field to `Request`; add `extra_headers` param to `stream_response` for per-provider header injection - `crates/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`: Pass `vec![]` for new `extra_headers` param - `crates/language_models/src/language_models.rs`: Register the new provider - `crates/language_models/Cargo.toml`: Add `rand` and `sha2` deps for PKCE ## Open questions / known gaps - [ ] **Terms of service**: Usage appears to be within OpenAI's ToS (interactive use via their official CLI client ID), but needs legal sign-off before shipping - [ ] **Redirect URI**: Currently `http://localhost:1455/auth/callback` — may need to match exactly what OpenAI's Codex CLI uses - [ ] **UI polish**: The sign-in card is functional but minimal; needs design review - [ ] **Error messages**: OAuth error responses from the callback URL aren't surfaced to the user yet - [ ] **`o3` availability**: o3 may require a higher subscription tier; consider gating it ## Testing Sign-in flow was designed to match the Copilot Chat provider pattern. Manual testing against the live OAuth endpoint is needed. Release Notes: - Added ChatGPT subscription provider, allowing users to use their ChatGPT Plus/Pro subscription with the Zed agent --------- Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com> Co-authored-by: Richard Feldman <richard@zed.dev> Co-authored-by: Richard Feldman <oss@rtfeldman.com> Co-authored-by: Agus Zubiaga <agus@zed.dev>
1 parent 003e821 commit 37f6d7a

17 files changed

Lines changed: 2058 additions & 159 deletions

File tree

Cargo.lock

Lines changed: 18 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ members = [
140140
"crates/net",
141141
"crates/node_runtime",
142142
"crates/notifications",
143+
"crates/oauth_callback_server",
143144
"crates/ollama",
144145
"crates/onboarding",
145146
"crates/opencode",
@@ -399,6 +400,7 @@ nc = { path = "crates/nc" }
399400
net = { path = "crates/net" }
400401
node_runtime = { path = "crates/node_runtime" }
401402
notifications = { path = "crates/notifications" }
403+
oauth_callback_server = { path = "crates/oauth_callback_server" }
402404
ollama = { path = "crates/ollama" }
403405
onboarding = { path = "crates/onboarding" }
404406
opencode = { path = "crates/opencode" }

crates/context_server/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ gpui.workspace = true
2626
http_client = { workspace = true, features = ["test-support"] }
2727
log.workspace = true
2828
net.workspace = true
29+
oauth_callback_server.workspace = true
2930
parking_lot.workspace = true
3031
rand.workspace = true
3132
postage.workspace = true
@@ -36,7 +37,6 @@ settings.workspace = true
3637
sha2.workspace = true
3738
slotmap.workspace = true
3839
tempfile.workspace = true
39-
tiny_http.workspace = true
4040
url = { workspace = true, features = ["serde"] }
4141
util.workspace = true
4242

crates/context_server/src/oauth.rs

Lines changed: 23 additions & 147 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,18 @@ use anyhow::{Context as _, Result, anyhow, bail};
2020
use async_trait::async_trait;
2121
use base64::Engine as _;
2222
use futures::AsyncReadExt as _;
23+
use futures::FutureExt as _;
2324
use futures::channel::mpsc;
25+
use futures::future::BoxFuture;
2426
use http_client::{AsyncBody, HttpClient, Request};
2527
use parking_lot::Mutex as SyncMutex;
2628
use rand::Rng as _;
2729
use serde::{Deserialize, Serialize};
2830
use sha2::{Digest, Sha256};
2931

30-
use std::str::FromStr;
3132
use std::sync::Arc;
3233
use std::time::{Duration, SystemTime};
3334
use url::Url;
34-
use util::ResultExt as _;
3535

3636
/// The CIMD URL where Zed's OAuth client metadata document is hosted.
3737
pub const CIMD_URL: &str = "https://zed.dev/oauth/client-metadata.json";
@@ -992,58 +992,14 @@ impl OAuthCallback {
992992
/// Parse the query string from a callback URL like
993993
/// `http://127.0.0.1:<port>/callback?code=...&state=...`.
994994
pub fn parse_query(query: &str) -> Result<Self> {
995-
let mut code: Option<String> = None;
996-
let mut state: Option<String> = None;
997-
let mut error: Option<String> = None;
998-
let mut error_description: Option<String> = None;
999-
1000-
for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
1001-
match key.as_ref() {
1002-
"code" => {
1003-
if !value.is_empty() {
1004-
code = Some(value.into_owned());
1005-
}
1006-
}
1007-
"state" => {
1008-
if !value.is_empty() {
1009-
state = Some(value.into_owned());
1010-
}
1011-
}
1012-
"error" => {
1013-
if !value.is_empty() {
1014-
error = Some(value.into_owned());
1015-
}
1016-
}
1017-
"error_description" => {
1018-
if !value.is_empty() {
1019-
error_description = Some(value.into_owned());
1020-
}
1021-
}
1022-
_ => {}
1023-
}
1024-
}
1025-
1026-
// Check for OAuth error response (RFC 6749 Section 4.1.2.1) before
1027-
// checking for missing code/state.
1028-
if let Some(error_code) = error {
1029-
bail!(
1030-
"OAuth authorization failed: {} ({})",
1031-
error_code,
1032-
error_description.as_deref().unwrap_or("no description")
1033-
);
1034-
}
1035-
1036-
let code = code.ok_or_else(|| anyhow!("missing 'code' parameter in OAuth callback"))?;
1037-
let state = state.ok_or_else(|| anyhow!("missing 'state' parameter in OAuth callback"))?;
1038-
1039-
Ok(Self { code, state })
995+
let params = oauth_callback_server::OAuthCallbackParams::parse_query(query)?;
996+
Ok(Self {
997+
code: params.code,
998+
state: params.state,
999+
})
10401000
}
10411001
}
10421002

1043-
/// How long to wait for the browser to complete the OAuth flow before giving
1044-
/// up and releasing the loopback port.
1045-
const CALLBACK_TIMEOUT: Duration = Duration::from_secs(2 * 60);
1046-
10471003
/// Start a loopback HTTP server to receive the OAuth authorization callback.
10481004
///
10491005
/// Binds to an ephemeral loopback port for each flow.
@@ -1056,104 +1012,24 @@ const CALLBACK_TIMEOUT: Duration = Duration::from_secs(2 * 60);
10561012
/// contains `code` and `state` query parameters, responds with a minimal
10571013
/// HTML page telling the user they can close the tab, and shuts down.
10581014
///
1059-
/// The callback server shuts down when the returned oneshot receiver is dropped
1060-
/// (e.g. because the authentication task was cancelled), or after a timeout
1061-
/// ([CALLBACK_TIMEOUT]).
1062-
pub async fn start_callback_server() -> Result<(
1063-
String,
1064-
futures::channel::oneshot::Receiver<Result<OAuthCallback>>,
1065-
)> {
1066-
let server = tiny_http::Server::http("127.0.0.1:0")
1067-
.map_err(|e| anyhow!(e).context("Failed to bind loopback listener for OAuth callback"))?;
1068-
let port = server
1069-
.server_addr()
1070-
.to_ip()
1071-
.context("server not bound to a TCP address")?
1072-
.port();
1073-
1074-
let redirect_uri = format!("http://127.0.0.1:{}/callback", port);
1075-
1076-
let (tx, rx) = futures::channel::oneshot::channel();
1077-
1078-
// `tiny_http` is blocking, so we run it on a background thread.
1079-
// The `recv_timeout` loop lets us check for cancellation (the receiver
1080-
// being dropped) and enforce an overall timeout.
1081-
std::thread::spawn(move || {
1082-
let deadline = std::time::Instant::now() + CALLBACK_TIMEOUT;
1083-
1084-
loop {
1085-
if tx.is_canceled() {
1086-
return;
1087-
}
1088-
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
1089-
if remaining.is_zero() {
1090-
return;
1091-
}
1092-
1093-
let timeout = remaining.min(Duration::from_millis(500));
1094-
let Some(request) = (match server.recv_timeout(timeout) {
1095-
Ok(req) => req,
1096-
Err(_) => {
1097-
let _ = tx.send(Err(anyhow!("OAuth callback server I/O error")));
1098-
return;
1099-
}
1100-
}) else {
1101-
// Timeout with no request — loop back and check cancellation.
1102-
continue;
1103-
};
1104-
1105-
let result = handle_callback_request(&request);
1106-
1107-
let (status_code, body) = match &result {
1108-
Ok(_) => (
1109-
200,
1110-
"<html><body><h1>Authorization successful</h1>\
1111-
<p>You can close this tab and return to Zed.</p></body></html>",
1112-
),
1113-
Err(err) => {
1114-
log::error!("OAuth callback error: {}", err);
1115-
(
1116-
400,
1117-
"<html><body><h1>Authorization failed</h1>\
1118-
<p>Something went wrong. Please try again from Zed.</p></body></html>",
1119-
)
1120-
}
1121-
};
1122-
1123-
let response = tiny_http::Response::from_string(body)
1124-
.with_status_code(status_code)
1125-
.with_header(
1126-
tiny_http::Header::from_str("Content-Type: text/html")
1127-
.expect("failed to construct response header"),
1128-
)
1129-
.with_header(
1130-
tiny_http::Header::from_str("Keep-Alive: timeout=0,max=0")
1131-
.expect("failed to construct response header"),
1132-
);
1133-
request.respond(response).log_err();
1134-
1135-
let _ = tx.send(result);
1136-
return;
1015+
/// The callback server shuts down when the returned future is dropped (e.g.
1016+
/// because the authentication task was cancelled), or after a timeout.
1017+
pub fn start_callback_server() -> Result<(String, BoxFuture<'static, Result<OAuthCallback>>)> {
1018+
let (redirect_uri, rx) = oauth_callback_server::start_oauth_callback_server()?;
1019+
let future = async move {
1020+
match rx.await {
1021+
Ok(Ok(params)) => Ok(OAuthCallback {
1022+
code: params.code,
1023+
state: params.state,
1024+
}),
1025+
Ok(Err(e)) => Err(e),
1026+
Err(_) => Err(anyhow!(
1027+
"OAuth callback server was shut down before receiving a response"
1028+
)),
11371029
}
1138-
});
1139-
1140-
Ok((redirect_uri, rx))
1141-
}
1142-
1143-
/// Extract the `code` and `state` query parameters from an OAuth callback
1144-
/// request to `/callback`.
1145-
fn handle_callback_request(request: &tiny_http::Request) -> Result<OAuthCallback> {
1146-
let url = Url::parse(&format!("http://localhost{}", request.url()))
1147-
.context("malformed callback request URL")?;
1148-
1149-
if url.path() != "/callback" {
1150-
bail!("unexpected path in OAuth callback: {}", url.path());
11511030
}
1152-
1153-
let query = url
1154-
.query()
1155-
.ok_or_else(|| anyhow!("OAuth callback has no query string"))?;
1156-
OAuthCallback::parse_query(query)
1031+
.boxed();
1032+
Ok((redirect_uri, future))
11571033
}
11581034

11591035
// -- JSON fetch helper -------------------------------------------------------

crates/language_models/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,19 +47,24 @@ lmstudio = { workspace = true, features = ["schemars"] }
4747
log.workspace = true
4848
menu.workspace = true
4949
mistral = { workspace = true, features = ["schemars"] }
50+
oauth_callback_server.workspace = true
5051
ollama = { workspace = true, features = ["schemars"] }
5152
open_ai = { workspace = true, features = ["schemars"] }
5253
opencode = { workspace = true, features = ["schemars"] }
5354
open_router = { workspace = true, features = ["schemars"] }
55+
rand.workspace = true
5456
release_channel.workspace = true
5557
schemars.workspace = true
58+
sha2.workspace = true
5659
serde.workspace = true
5760
serde_json.workspace = true
5861
settings.workspace = true
62+
smol.workspace = true
5963
strum.workspace = true
6064
tokio = { workspace = true, features = ["rt", "rt-multi-thread"] }
6165
ui.workspace = true
6266
ui_input.workspace = true
67+
url.workspace = true
6368
util.workspace = true
6469
x_ai = { workspace = true, features = ["schemars"] }
6570

@@ -71,5 +76,6 @@ feature_flags.workspace = true
7176
gpui = { workspace = true, features = ["test-support"] }
7277
http_client = { workspace = true, features = ["test-support"] }
7378
language_model = { workspace = true, features = ["test-support"] }
79+
parking_lot.workspace = true
7480
pretty_assertions.workspace = true
7581
settings = { workspace = true, features = ["test-support"] }

crates/language_models/src/language_models.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ use crate::provider::ollama::OllamaLanguageModelProvider;
2727
use crate::provider::open_ai::OpenAiLanguageModelProvider;
2828
use crate::provider::open_ai_compatible::OpenAiCompatibleLanguageModelProvider;
2929
use crate::provider::open_router::OpenRouterLanguageModelProvider;
30+
use crate::provider::openai_subscribed::OpenAiSubscribedProvider;
3031
use crate::provider::opencode::OpenCodeLanguageModelProvider;
3132
use crate::provider::vercel_ai_gateway::VercelAiGatewayLanguageModelProvider;
3233
use crate::provider::x_ai::XAiLanguageModelProvider;
@@ -324,10 +325,18 @@ fn register_language_model_providers(
324325
registry.register_provider(
325326
Arc::new(OpenCodeLanguageModelProvider::new(
326327
client.http_client(),
327-
credentials_provider,
328+
credentials_provider.clone(),
328329
cx,
329330
)),
330331
cx,
331332
);
332333
registry.register_provider(Arc::new(CopilotChatLanguageModelProvider::new(cx)), cx);
334+
registry.register_provider(
335+
Arc::new(OpenAiSubscribedProvider::new(
336+
client.http_client(),
337+
credentials_provider,
338+
cx,
339+
)),
340+
cx,
341+
);
333342
}

crates/language_models/src/provider.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ pub mod ollama;
1010
pub mod open_ai;
1111
pub mod open_ai_compatible;
1212
pub mod open_router;
13+
pub mod openai_subscribed;
1314
pub mod opencode;
1415

1516
pub mod vercel_ai_gateway;

crates/language_models/src/provider/open_ai.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,7 @@ impl OpenAiLanguageModel {
383383
&api_url,
384384
&api_key,
385385
request,
386+
vec![],
386387
);
387388
let response = request.await?;
388389
Ok(response)

crates/language_models/src/provider/open_ai_compatible.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ impl OpenAiCompatibleLanguageModel {
289289
&api_url,
290290
&api_key,
291291
request,
292+
vec![],
292293
);
293294
let response = request.await?;
294295
Ok(response)

0 commit comments

Comments
 (0)