From 0d09d3e5d75bdb24c9b30e6bc9db7e645067004d Mon Sep 17 00:00:00 2001 From: morgankrey Date: Sat, 4 Apr 2026 14:26:28 -0500 Subject: [PATCH 01/36] Add ChatGPT subscription provider via OAuth 2.0 PKCE 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 --- Cargo.lock | 2 + crates/language_models/Cargo.toml | 2 + crates/language_models/src/language_models.rs | 11 +- crates/language_models/src/provider.rs | 1 + .../language_models/src/provider/open_ai.rs | 2 + .../src/provider/open_ai_compatible.rs | 1 + .../src/provider/openai_subscribed.rs | 786 ++++++++++++++++++ .../language_models/src/provider/opencode.rs | 1 + crates/open_ai/src/responses.rs | 11 +- 9 files changed, 813 insertions(+), 4 deletions(-) create mode 100644 crates/language_models/src/provider/openai_subscribed.rs diff --git a/Cargo.lock b/Cargo.lock index 279fcec10f1efb..c1fb88e4a4d8cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9390,12 +9390,14 @@ dependencies = [ "opencode", "partial-json-fixer", "pretty_assertions", + "rand 0.9.2", "release_channel", "schemars", "semver", "serde", "serde_json", "settings", + "sha2", "smol", "strum 0.27.2", "thiserror 2.0.17", diff --git a/crates/language_models/Cargo.toml b/crates/language_models/Cargo.toml index 4ebfce695e5872..1111862ded0e13 100644 --- a/crates/language_models/Cargo.toml +++ b/crates/language_models/Cargo.toml @@ -50,8 +50,10 @@ open_ai = { workspace = true, features = ["schemars"] } opencode = { workspace = true, features = ["schemars"] } open_router = { workspace = true, features = ["schemars"] } partial-json-fixer.workspace = true +rand.workspace = true release_channel.workspace = true schemars.workspace = true +sha2.workspace = true semver.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index 3154db91a43d13..a572e4e5dc5524 100644 --- a/crates/language_models/src/language_models.rs +++ b/crates/language_models/src/language_models.rs @@ -25,6 +25,7 @@ use crate::provider::ollama::OllamaLanguageModelProvider; use crate::provider::open_ai::OpenAiLanguageModelProvider; use crate::provider::open_ai_compatible::OpenAiCompatibleLanguageModelProvider; use crate::provider::open_router::OpenRouterLanguageModelProvider; +use crate::provider::openai_subscribed::OpenAiSubscribedProvider; use crate::provider::opencode::OpenCodeLanguageModelProvider; use crate::provider::vercel::VercelLanguageModelProvider; use crate::provider::vercel_ai_gateway::VercelAiGatewayLanguageModelProvider; @@ -286,10 +287,18 @@ fn register_language_model_providers( registry.register_provider( Arc::new(OpenCodeLanguageModelProvider::new( client.http_client(), - credentials_provider, + credentials_provider.clone(), cx, )), cx, ); registry.register_provider(Arc::new(CopilotChatLanguageModelProvider::new(cx)), cx); + registry.register_provider( + Arc::new(OpenAiSubscribedProvider::new( + client.http_client(), + credentials_provider, + cx, + )), + cx, + ); } diff --git a/crates/language_models/src/provider.rs b/crates/language_models/src/provider.rs index d3c43397459939..d9f8d9d77eb456 100644 --- a/crates/language_models/src/provider.rs +++ b/crates/language_models/src/provider.rs @@ -11,6 +11,7 @@ pub mod open_ai; pub mod open_ai_compatible; pub mod open_router; pub mod opencode; +pub mod openai_subscribed; mod util; pub mod vercel; pub mod vercel_ai_gateway; diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index 6a2313487f4a19..fca5cd1d97ba7d 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -290,6 +290,7 @@ impl OpenAiLanguageModel { &api_url, &api_key, request, + vec![], ); let response = request.await?; Ok(response) @@ -633,6 +634,7 @@ pub fn into_open_ai_response( effort, summary: Some(open_ai::responses::ReasoningSummaryMode::Auto), }), + store: None, } } diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index 1c3268749c3340..e418d08ac63b98 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -289,6 +289,7 @@ impl OpenAiCompatibleLanguageModel { &api_url, &api_key, request, + vec![], ); let response = request.await?; Ok(response) diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs new file mode 100644 index 00000000000000..36916c1a37c6d9 --- /dev/null +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -0,0 +1,786 @@ +use anyhow::{Context as _, Result, anyhow}; +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use credentials_provider::CredentialsProvider; +use futures::{FutureExt, StreamExt, future::BoxFuture}; +use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; +use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest}; +use language_model::{ + AuthenticateError, IconOrSvg, LanguageModel, LanguageModelCompletionError, + LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, + LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, + LanguageModelRequest, LanguageModelToolChoice, RateLimiter, +}; +use open_ai::{ReasoningEffort, responses::stream_response}; +use rand::RngCore as _; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use smol::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; +use ui::{ConfiguredApiCard, prelude::*}; +use util::ResultExt as _; + +use crate::provider::open_ai::{OpenAiResponseEventMapper, into_open_ai_response}; + +const PROVIDER_ID: LanguageModelProviderId = + LanguageModelProviderId::new("openai-subscribed"); +const PROVIDER_NAME: LanguageModelProviderName = + LanguageModelProviderName::new("ChatGPT Subscription"); + +const CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api/codex"; +const OPENAI_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; +const OPENAI_AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize"; +const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; +const REDIRECT_URI: &str = "http://localhost:1455/auth/callback"; +const CREDENTIALS_KEY: &str = "https://chatgpt.com/backend-api/codex"; +const TOKEN_REFRESH_BUFFER_MS: u64 = 5 * 60 * 1000; + +#[derive(Serialize, Deserialize, Clone, Debug)] +struct CodexCredentials { + access_token: String, + refresh_token: String, + expires_at_ms: u64, + account_id: Option, + email: Option, +} + +impl CodexCredentials { + fn is_expired(&self) -> bool { + let now = now_ms(); + now + TOKEN_REFRESH_BUFFER_MS >= self.expires_at_ms + } +} + +pub struct State { + credentials: Option, + sign_in_task: Option>>, + credentials_provider: Arc, +} + +impl State { + fn is_authenticated(&self) -> bool { + self.credentials.is_some() + } + + fn email(&self) -> Option<&str> { + self.credentials + .as_ref() + .and_then(|c| c.email.as_deref()) + } + + fn is_signing_in(&self) -> bool { + self.sign_in_task.is_some() + } +} + +pub struct OpenAiSubscribedProvider { + http_client: Arc, + state: Entity, +} + +impl OpenAiSubscribedProvider { + pub fn new( + http_client: Arc, + credentials_provider: Arc, + cx: &mut App, + ) -> Self { + let state = cx.new(|_cx| State { + credentials: None, + sign_in_task: None, + credentials_provider, + }); + + let provider = Self { + http_client, + state: state.clone(), + }; + + provider.load_credentials(cx); + + provider + } + + fn load_credentials(&self, cx: &mut App) { + let state = self.state.downgrade(); + cx.spawn(async move |cx| { + let credentials_provider = + state.read_with(&*cx, |s, _| s.credentials_provider.clone())?; + let result = credentials_provider + .read_credentials(CREDENTIALS_KEY, &*cx) + .await; + state.update(cx, |s, cx| { + if let Ok(Some((_, bytes))) = result { + if let Ok(creds) = serde_json::from_slice::(&bytes) { + s.credentials = Some(creds); + } + } + cx.notify(); + }) + }) + .detach(); + } + + fn sign_in(&self, cx: &mut App) { + let state = self.state.downgrade(); + let http_client = self.http_client.clone(); + + let task = cx.spawn(async move |cx| { + match do_oauth_flow(http_client, &*cx).await { + Ok(creds) => { + let credentials_provider = + state.read_with(&*cx, |s, _| s.credentials_provider.clone())?; + let json = serde_json::to_vec(&creds)?; + credentials_provider + .write_credentials(CREDENTIALS_KEY, "Bearer", &json, &*cx) + .await?; + state.update(cx, |s, cx| { + s.credentials = Some(creds); + s.sign_in_task = None; + cx.notify(); + })?; + } + Err(err) => { + log::error!("ChatGPT subscription sign-in failed: {err:?}"); + state + .update(cx, |s, cx| { + s.sign_in_task = None; + cx.notify(); + }) + .log_err(); + } + } + anyhow::Ok(()) + }); + + self.state.update(cx, |s, cx| { + s.sign_in_task = Some(task); + cx.notify(); + }); + } + + fn sign_out(&self, cx: &mut App) { + let state = self.state.downgrade(); + cx.spawn(async move |cx| { + let credentials_provider = + state.read_with(&*cx, |s, _| s.credentials_provider.clone())?; + credentials_provider + .delete_credentials(CREDENTIALS_KEY, &*cx) + .await + .log_err(); + state.update(cx, |s, cx| { + s.credentials = None; + cx.notify(); + })?; + anyhow::Ok(()) + }) + .detach(); + } + + fn create_language_model(&self, model: CodexModel) -> Arc { + Arc::new(OpenAiSubscribedLanguageModel { + model, + state: self.state.clone(), + http_client: self.http_client.clone(), + request_limiter: RateLimiter::new(4), + }) + } +} + +impl LanguageModelProviderState for OpenAiSubscribedProvider { + type ObservableEntity = State; + + fn observable_entity(&self) -> Option> { + Some(self.state.clone()) + } +} + +impl LanguageModelProvider for OpenAiSubscribedProvider { + fn id(&self) -> LanguageModelProviderId { + PROVIDER_ID + } + + fn name(&self) -> LanguageModelProviderName { + PROVIDER_NAME + } + + fn icon(&self) -> IconOrSvg { + IconOrSvg::Icon(IconName::AiOpenAi) + } + + fn default_model(&self, _cx: &App) -> Option> { + Some(self.create_language_model(CodexModel::O4Mini)) + } + + fn default_fast_model(&self, _cx: &App) -> Option> { + Some(self.create_language_model(CodexModel::CodexMini)) + } + + fn provided_models(&self, _cx: &App) -> Vec> { + CodexModel::all() + .into_iter() + .map(|m| self.create_language_model(m)) + .collect() + } + + fn is_authenticated(&self, cx: &App) -> bool { + self.state.read(cx).is_authenticated() + } + + fn authenticate(&self, cx: &mut App) -> Task> { + if self.is_authenticated(cx) { + return Task::ready(Ok(())); + } + Task::ready(Err(anyhow!( + "Sign in with your ChatGPT Plus or Pro subscription to use this provider." + ) + .into())) + } + + fn configuration_view( + &self, + _target_agent: language_model::ConfigurationViewTargetAgent, + _window: &mut Window, + cx: &mut App, + ) -> AnyView { + let state = self.state.clone(); + let http_client = self.http_client.clone(); + cx.new(|_cx| ConfigurationView { + state, + http_client, + }) + .into() + } + + fn reset_credentials(&self, cx: &mut App) -> Task> { + self.sign_out(cx); + Task::ready(Ok(())) + } +} + +// --- Models --- + +#[derive(Clone, Debug, PartialEq)] +pub enum CodexModel { + CodexMini, + O4Mini, + O3, +} + +impl CodexModel { + pub fn all() -> Vec { + vec![Self::CodexMini, Self::O4Mini, Self::O3] + } + + fn id(&self) -> &str { + match self { + Self::CodexMini => "codex-mini-latest", + Self::O4Mini => "o4-mini", + Self::O3 => "o3", + } + } + + fn display_name(&self) -> &str { + match self { + Self::CodexMini => "Codex Mini", + Self::O4Mini => "o4-mini", + Self::O3 => "o3", + } + } + + fn max_token_count(&self) -> u64 { + 200_000 + } + + fn reasoning_effort(&self) -> Option { + match self { + Self::CodexMini => None, + Self::O4Mini | Self::O3 => Some(ReasoningEffort::Medium), + } + } +} + +// --- Language model --- + +struct OpenAiSubscribedLanguageModel { + model: CodexModel, + state: Entity, + http_client: Arc, + request_limiter: RateLimiter, +} + +impl LanguageModel for OpenAiSubscribedLanguageModel { + fn id(&self) -> LanguageModelId { + LanguageModelId::from(self.model.id().to_string()) + } + + fn name(&self) -> LanguageModelName { + LanguageModelName::from(self.model.display_name().to_string()) + } + + fn provider_id(&self) -> LanguageModelProviderId { + PROVIDER_ID + } + + fn provider_name(&self) -> LanguageModelProviderName { + PROVIDER_NAME + } + + fn supports_tools(&self) -> bool { + true + } + + fn supports_images(&self) -> bool { + false + } + + fn supports_tool_choice(&self, _choice: LanguageModelToolChoice) -> bool { + true + } + + fn supports_streaming_tools(&self) -> bool { + true + } + + fn supports_thinking(&self) -> bool { + self.model.reasoning_effort().is_some() + } + + fn telemetry_id(&self) -> String { + format!("openai-subscribed/{}", self.model.id()) + } + + fn max_token_count(&self) -> u64 { + self.model.max_token_count() + } + + fn count_tokens( + &self, + _request: LanguageModelRequest, + _cx: &App, + ) -> BoxFuture<'static, Result> { + futures::future::ready(Ok(0)).boxed() + } + + fn stream_completion( + &self, + request: LanguageModelRequest, + cx: &AsyncApp, + ) -> BoxFuture< + 'static, + Result< + futures::stream::BoxStream< + 'static, + Result, + >, + LanguageModelCompletionError, + >, + > { + let mut responses_request = into_open_ai_response( + request, + self.model.id(), + true, // supports_parallel_tool_calls + false, // supports_prompt_cache_key + None, // max_output_tokens — not supported by Codex backend + self.model.reasoning_effort(), + ); + responses_request.store = Some(false); + responses_request.max_output_tokens = None; + + let state = self.state.downgrade(); + let http_client = self.http_client.clone(); + let request_limiter = self.request_limiter.clone(); + + let future = cx.spawn(async move |cx| { + let creds = get_fresh_credentials(&state, &http_client, &*cx).await?; + + let mut extra_headers: Vec<(String, String)> = vec![ + ("originator".into(), "zed".into()), + ("OpenAI-Beta".into(), "responses=experimental".into()), + ]; + if let Some(ref id) = creds.account_id { + if !id.is_empty() { + extra_headers.push(("ChatGPT-Account-Id".into(), id.clone())); + } + } + + let access_token = creds.access_token.clone(); + request_limiter + .stream(async move { + stream_response( + http_client.as_ref(), + PROVIDER_NAME.0.as_str(), + CODEX_BASE_URL, + &access_token, + responses_request, + extra_headers, + ) + .await + .map_err(LanguageModelCompletionError::from) + }) + .await + }); + + async move { + let mapper = OpenAiResponseEventMapper::new(); + Ok(mapper.map_stream(future.await?.boxed()).boxed()) + } + .boxed() + } +} + +// --- Credential refresh --- + +async fn get_fresh_credentials( + state: &gpui::WeakEntity, + http_client: &Arc, + cx: &AsyncApp, +) -> Result { + let creds = state + .read_with(cx, |s, _| s.credentials.clone()) + .map_err(|e| LanguageModelCompletionError::Other(e.into()))? + .ok_or(LanguageModelCompletionError::NoApiKey { + provider: PROVIDER_NAME, + })?; + + if !creds.is_expired() { + return Ok(creds); + } + + let refreshed = refresh_token(http_client, &creds.refresh_token) + .await + .map_err(LanguageModelCompletionError::Other)?; + + let credentials_provider = state + .read_with(cx, |s, _| s.credentials_provider.clone()) + .map_err(|e| LanguageModelCompletionError::Other(e.into()))?; + + let json = serde_json::to_vec(&refreshed) + .map_err(|e| LanguageModelCompletionError::Other(e.into()))?; + + credentials_provider + .write_credentials(CREDENTIALS_KEY, "Bearer", &json, cx) + .await + .map_err(LanguageModelCompletionError::Other)?; + + // The entity state will get the updated credentials on next login/load; + // for this request we use the freshly-fetched token. + Ok(refreshed) +} + +// --- OAuth PKCE flow --- + +#[derive(Deserialize)] +struct TokenResponse { + access_token: String, + refresh_token: String, + #[serde(default)] + id_token: Option, + expires_in: u64, + #[serde(default)] + email: Option, +} + +async fn do_oauth_flow( + http_client: Arc, + cx: &AsyncApp, +) -> Result { + // PKCE verifier: 32 random bytes → base64url (no padding) + let mut verifier_bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut verifier_bytes); + let verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); + + // PKCE challenge: SHA-256(verifier) → base64url + let mut hasher = Sha256::new(); + hasher.update(verifier.as_bytes()); + let challenge = URL_SAFE_NO_PAD.encode(hasher.finalize().as_slice()); + + // CSRF state: 16 random bytes → hex string + let mut state_bytes = [0u8; 16]; + rand::rng().fill_bytes(&mut state_bytes); + let oauth_state: String = state_bytes.iter().map(|b| format!("{b:02x}")).collect(); + + let auth_url = format!( + "{OPENAI_AUTHORIZE_URL}?client_id={CLIENT_ID}&redirect_uri={encoded_redirect}&scope=openid+profile+email+offline_access&response_type=code&code_challenge={challenge}&code_challenge_method=S256&state={oauth_state}&codex_cli_simplified_flow=true&originator=zed", + encoded_redirect = percent_encode(REDIRECT_URI), + ); + + cx.update(|cx| cx.open_url(&auth_url)); + + let code = await_oauth_callback(&oauth_state) + .await + .context("OAuth callback failed")?; + + let tokens = exchange_code(&http_client, &code, &verifier) + .await + .context("Token exchange failed")?; + + let jwt = tokens + .id_token + .as_deref() + .unwrap_or(tokens.access_token.as_str()); + let account_id = extract_account_id(jwt); + + Ok(CodexCredentials { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + expires_at_ms: now_ms() + tokens.expires_in * 1000, + account_id, + email: tokens.email, + }) +} + +async fn await_oauth_callback(expected_state: &str) -> Result { + let listener = smol::net::TcpListener::bind("127.0.0.1:1455") + .await + .context("Failed to bind to port 1455 for OAuth callback. Another application may be using this port.")?; + + let (mut stream, _) = listener.accept().await?; + + let mut buffer = vec![0u8; 4096]; + let n = stream.read(&mut buffer).await?; + let request_text = std::str::from_utf8(&buffer[..n])?; + + // First line: "GET /auth/callback?code=...&state=... HTTP/1.1" + let path = request_text + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .ok_or_else(|| anyhow!("Invalid HTTP request from browser"))?; + + let query = path.split('?').nth(1).unwrap_or(""); + let mut code: Option = None; + let mut received_state: Option = None; + for part in query.split('&') { + if let Some(v) = part.strip_prefix("code=") { + code = Some(percent_decode(v)); + } else if let Some(v) = part.strip_prefix("state=") { + received_state = Some(percent_decode(v)); + } + } + + let html = b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n\ +

Signed in to Zed

\ +

Authentication successful. You can close this tab.

\ + "; + stream.write_all(html).await.log_err(); + + let received_state = + received_state.ok_or_else(|| anyhow!("Missing state in OAuth callback"))?; + if received_state != expected_state { + return Err(anyhow!("OAuth state mismatch")); + } + + code.ok_or_else(|| anyhow!("Missing authorization code in OAuth callback")) +} + +async fn exchange_code( + client: &Arc, + code: &str, + verifier: &str, +) -> Result { + let body = format!( + "grant_type=authorization_code&client_id={CLIENT_ID}&code={code}&redirect_uri={encoded_redirect}&code_verifier={verifier}", + encoded_redirect = percent_encode(REDIRECT_URI), + ); + + let request = HttpRequest::builder() + .method(Method::POST) + .uri(OPENAI_TOKEN_URL) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(AsyncBody::from(body))?; + + let mut response = client.send(request).await?; + let mut body = String::new(); + smol::io::AsyncReadExt::read_to_string(response.body_mut(), &mut body).await?; + + if !response.status().is_success() { + return Err(anyhow!( + "Token exchange failed (HTTP {}): {body}", + response.status() + )); + } + + serde_json::from_str::(&body).context("Failed to parse token response") +} + +async fn refresh_token( + client: &Arc, + refresh_token: &str, +) -> Result { + let body = format!( + "grant_type=refresh_token&client_id={CLIENT_ID}&refresh_token={refresh_token}" + ); + + let request = HttpRequest::builder() + .method(Method::POST) + .uri(OPENAI_TOKEN_URL) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(AsyncBody::from(body))?; + + let mut response = client.send(request).await?; + let mut body = String::new(); + smol::io::AsyncReadExt::read_to_string(response.body_mut(), &mut body).await?; + + if !response.status().is_success() { + return Err(anyhow!( + "Token refresh failed (HTTP {}): {body}", + response.status() + )); + } + + let tokens: TokenResponse = serde_json::from_str(&body)?; + let jwt = tokens + .id_token + .as_deref() + .unwrap_or(tokens.access_token.as_str()); + let account_id = extract_account_id(jwt); + + Ok(CodexCredentials { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + expires_at_ms: now_ms() + tokens.expires_in * 1000, + account_id, + email: tokens.email, + }) +} + +/// Extract chatgpt_account_id from a JWT payload (base64url middle segment). +/// Checks three claim locations, matching Roo Code's implementation. +fn extract_account_id(jwt: &str) -> Option { + let payload_b64 = jwt.split('.').nth(1)?; + let payload = URL_SAFE_NO_PAD.decode(payload_b64).ok()?; + let claims: serde_json::Value = serde_json::from_slice(&payload).ok()?; + + if let Some(id) = claims.get("chatgpt_account_id").and_then(|v| v.as_str()) { + return Some(id.to_owned()); + } + if let Some(id) = claims + .get("https://api.openai.com/auth") + .and_then(|v| v.get("chatgpt_account_id")) + .and_then(|v| v.as_str()) + { + return Some(id.to_owned()); + } + claims + .get("organizations") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|org| org.get("id")) + .and_then(|v| v.as_str()) + .map(|s| s.to_owned()) +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +fn percent_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for byte in s.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(byte as char); + } + b => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +fn percent_decode(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut bytes = s.bytes().peekable(); + while let Some(b) = bytes.next() { + if b == b'%' { + let h1 = bytes.next().unwrap_or(b'0'); + let h2 = bytes.next().unwrap_or(b'0'); + let hex = [h1, h2]; + if let Ok(hex_str) = std::str::from_utf8(&hex) { + if let Ok(decoded) = u8::from_str_radix(hex_str, 16) { + result.push(decoded as char); + continue; + } + } + } else if b == b'+' { + result.push(' '); + continue; + } + result.push(b as char); + } + result +} + +// --- Configuration view --- + +struct ConfigurationView { + state: Entity, + http_client: Arc, +} + +impl Render for ConfigurationView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let state = self.state.read(cx); + + if state.is_authenticated() { + let label = state + .email() + .map(|e| format!("Signed in as {e}")) + .unwrap_or_else(|| "Signed in".to_string()); + + let weak_state = self.state.downgrade(); + return v_flex() + .child( + ConfiguredApiCard::new(SharedString::from(label)).on_click( + cx.listener(move |_this, _, _window, cx| { + let weak_state = weak_state.clone(); + cx.spawn(async move |_this, cx| { + let credentials_provider = + weak_state.read_with(&*cx, |s, _| s.credentials_provider.clone())?; + credentials_provider + .delete_credentials(CREDENTIALS_KEY, &*cx) + .await + .log_err(); + weak_state.update(cx, |s, cx| { + s.credentials = None; + cx.notify(); + })?; + anyhow::Ok(()) + }) + .detach(); + }), + ), + ) + .into_any_element(); + } + + if state.is_signing_in() { + return v_flex() + .child(Label::new("Signing in…").color(Color::Muted)) + .into_any_element(); + } + + let provider_state = self.state.clone(); + let http_client = self.http_client.clone(); + + v_flex() + .gap_2() + .child(Label::new( + "Sign in with your ChatGPT Plus or Pro subscription to use o3, o4-mini, and Codex models in Zed's agent.", + )) + .child( + Button::new("sign-in", "Sign in with ChatGPT") + .on_click(move |_, _window, cx| { + let provider = OpenAiSubscribedProvider { + state: provider_state.clone(), + http_client: http_client.clone(), + }; + provider.sign_in(cx); + }), + ) + .into_any_element() + } +} diff --git a/crates/language_models/src/provider/opencode.rs b/crates/language_models/src/provider/opencode.rs index aae3a552544ebf..5e32f3ab285d8a 100644 --- a/crates/language_models/src/provider/opencode.rs +++ b/crates/language_models/src/provider/opencode.rs @@ -339,6 +339,7 @@ impl OpenCodeLanguageModel { &api_url, &api_key, request, + vec![], ); let response = request.await?; Ok(response) diff --git a/crates/open_ai/src/responses.rs b/crates/open_ai/src/responses.rs index 34dbd46c372a67..390220a0d105fa 100644 --- a/crates/open_ai/src/responses.rs +++ b/crates/open_ai/src/responses.rs @@ -29,6 +29,8 @@ pub struct Request { pub prompt_cache_key: Option, #[serde(skip_serializing_if = "Option::is_none")] pub reasoning: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub store: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -315,17 +317,20 @@ pub async fn stream_response( api_url: &str, api_key: &str, request: Request, + extra_headers: Vec<(String, String)>, ) -> Result>, RequestError> { let uri = format!("{api_url}/responses"); - let request_builder = HttpRequest::builder() + let mut request_builder = HttpRequest::builder() .method(Method::POST) .uri(uri) .header("Content-Type", "application/json") .header("Authorization", format!("Bearer {}", api_key.trim())); + for (name, value) in &extra_headers { + request_builder = request_builder.header(name.as_str(), value.as_str()); + } let is_streaming = request.stream; - let request = request_builder - .body(AsyncBody::from( + let request = request_builder.body(AsyncBody::from( serde_json::to_string(&request).map_err(|e| RequestError::Other(e.into()))?, )) .map_err(|e| RequestError::Other(e.into()))?; From e9e580fcf1c23d76b3a0e5071de3c3347774fd7e Mon Sep 17 00:00:00 2001 From: morgankrey Date: Sat, 4 Apr 2026 15:16:53 -0500 Subject: [PATCH 02/36] Fix auth state reliability issues in ChatGPT subscription provider - 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 --- .../src/provider/openai_subscribed.rs | 60 +++++++++++++------ 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 36916c1a37c6d9..881efad4068f7f 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -128,17 +128,37 @@ impl OpenAiSubscribedProvider { let task = cx.spawn(async move |cx| { match do_oauth_flow(http_client, &*cx).await { Ok(creds) => { - let credentials_provider = - state.read_with(&*cx, |s, _| s.credentials_provider.clone())?; - let json = serde_json::to_vec(&creds)?; - credentials_provider - .write_credentials(CREDENTIALS_KEY, "Bearer", &json, &*cx) - .await?; - state.update(cx, |s, cx| { - s.credentials = Some(creds); - s.sign_in_task = None; - cx.notify(); - })?; + let persist_result = async { + let credentials_provider = + state.read_with(&*cx, |s, _| s.credentials_provider.clone())?; + let json = serde_json::to_vec(&creds)?; + credentials_provider + .write_credentials(CREDENTIALS_KEY, "Bearer", &json, &*cx) + .await?; + anyhow::Ok(()) + } + .await; + + match persist_result { + Ok(()) => { + state + .update(cx, |s, cx| { + s.credentials = Some(creds); + s.sign_in_task = None; + cx.notify(); + }) + .log_err(); + } + Err(err) => { + log::error!("ChatGPT subscription sign-in failed to persist credentials: {err:?}"); + state + .update(cx, |s, cx| { + s.sign_in_task = None; + cx.notify(); + }) + .log_err(); + } + } } Err(err) => { log::error!("ChatGPT subscription sign-in failed: {err:?}"); @@ -392,7 +412,7 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { let request_limiter = self.request_limiter.clone(); let future = cx.spawn(async move |cx| { - let creds = get_fresh_credentials(&state, &http_client, &*cx).await?; + let creds = get_fresh_credentials(&state, &http_client, cx).await?; let mut extra_headers: Vec<(String, String)> = vec![ ("originator".into(), "zed".into()), @@ -434,10 +454,10 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { async fn get_fresh_credentials( state: &gpui::WeakEntity, http_client: &Arc, - cx: &AsyncApp, + cx: &mut AsyncApp, ) -> Result { let creds = state - .read_with(cx, |s, _| s.credentials.clone()) + .read_with(&*cx, |s, _| s.credentials.clone()) .map_err(|e| LanguageModelCompletionError::Other(e.into()))? .ok_or(LanguageModelCompletionError::NoApiKey { provider: PROVIDER_NAME, @@ -452,19 +472,23 @@ async fn get_fresh_credentials( .map_err(LanguageModelCompletionError::Other)?; let credentials_provider = state - .read_with(cx, |s, _| s.credentials_provider.clone()) + .read_with(&*cx, |s, _| s.credentials_provider.clone()) .map_err(|e| LanguageModelCompletionError::Other(e.into()))?; let json = serde_json::to_vec(&refreshed) .map_err(|e| LanguageModelCompletionError::Other(e.into()))?; credentials_provider - .write_credentials(CREDENTIALS_KEY, "Bearer", &json, cx) + .write_credentials(CREDENTIALS_KEY, "Bearer", &json, &*cx) .await .map_err(LanguageModelCompletionError::Other)?; - // The entity state will get the updated credentials on next login/load; - // for this request we use the freshly-fetched token. + state + .update(cx, |s, _| { + s.credentials = Some(refreshed.clone()); + }) + .map_err(|e| LanguageModelCompletionError::Other(e.into()))?; + Ok(refreshed) } From be0d675fc222a7606fd8460abc51dbbc8649cd92 Mon Sep 17 00:00:00 2001 From: morgankrey Date: Sat, 4 Apr 2026 15:39:57 -0500 Subject: [PATCH 03/36] Fix clippy and fmt warnings in openai_subscribed provider --- .../src/provider/openai_subscribed.rs | 39 ++++++++----------- 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 881efad4068f7f..1685ff01e181fa 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -23,8 +23,7 @@ use util::ResultExt as _; use crate::provider::open_ai::{OpenAiResponseEventMapper, into_open_ai_response}; -const PROVIDER_ID: LanguageModelProviderId = - LanguageModelProviderId::new("openai-subscribed"); +const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("openai-subscribed"); const PROVIDER_NAME: LanguageModelProviderName = LanguageModelProviderName::new("ChatGPT Subscription"); @@ -64,9 +63,7 @@ impl State { } fn email(&self) -> Option<&str> { - self.credentials - .as_ref() - .and_then(|c| c.email.as_deref()) + self.credentials.as_ref().and_then(|c| c.email.as_deref()) } fn is_signing_in(&self) -> bool { @@ -93,7 +90,7 @@ impl OpenAiSubscribedProvider { let provider = Self { http_client, - state: state.clone(), + state, }; provider.load_credentials(cx); @@ -265,11 +262,8 @@ impl LanguageModelProvider for OpenAiSubscribedProvider { ) -> AnyView { let state = self.state.clone(); let http_client = self.http_client.clone(); - cx.new(|_cx| ConfigurationView { - state, - http_client, - }) - .into() + cx.new(|_cx| ConfigurationView { state, http_client }) + .into() } fn reset_credentials(&self, cx: &mut App) -> Task> { @@ -458,7 +452,7 @@ async fn get_fresh_credentials( ) -> Result { let creds = state .read_with(&*cx, |s, _| s.credentials.clone()) - .map_err(|e| LanguageModelCompletionError::Other(e.into()))? + .map_err(LanguageModelCompletionError::Other)? .ok_or(LanguageModelCompletionError::NoApiKey { provider: PROVIDER_NAME, })?; @@ -473,7 +467,7 @@ async fn get_fresh_credentials( let credentials_provider = state .read_with(&*cx, |s, _| s.credentials_provider.clone()) - .map_err(|e| LanguageModelCompletionError::Other(e.into()))?; + .map_err(LanguageModelCompletionError::Other)?; let json = serde_json::to_vec(&refreshed) .map_err(|e| LanguageModelCompletionError::Other(e.into()))?; @@ -487,7 +481,7 @@ async fn get_fresh_credentials( .update(cx, |s, _| { s.credentials = Some(refreshed.clone()); }) - .map_err(|e| LanguageModelCompletionError::Other(e.into()))?; + .map_err(LanguageModelCompletionError::Other)?; Ok(refreshed) } @@ -632,9 +626,8 @@ async fn refresh_token( client: &Arc, refresh_token: &str, ) -> Result { - let body = format!( - "grant_type=refresh_token&client_id={CLIENT_ID}&refresh_token={refresh_token}" - ); + let body = + format!("grant_type=refresh_token&client_id={CLIENT_ID}&refresh_token={refresh_token}"); let request = HttpRequest::builder() .method(Method::POST) @@ -758,12 +751,12 @@ impl Render for ConfigurationView { let weak_state = self.state.downgrade(); return v_flex() .child( - ConfiguredApiCard::new(SharedString::from(label)).on_click( - cx.listener(move |_this, _, _window, cx| { + ConfiguredApiCard::new(SharedString::from(label)).on_click(cx.listener( + move |_this, _, _window, cx| { let weak_state = weak_state.clone(); cx.spawn(async move |_this, cx| { - let credentials_provider = - weak_state.read_with(&*cx, |s, _| s.credentials_provider.clone())?; + let credentials_provider = weak_state + .read_with(&*cx, |s, _| s.credentials_provider.clone())?; credentials_provider .delete_credentials(CREDENTIALS_KEY, &*cx) .await @@ -775,8 +768,8 @@ impl Render for ConfigurationView { anyhow::Ok(()) }) .detach(); - }), - ), + }, + )), ) .into_any_element(); } From 7bed3b0d527f81e36a64ce76d3a45d971ba316bf Mon Sep 17 00:00:00 2001 From: morgankrey Date: Sat, 4 Apr 2026 15:42:40 -0500 Subject: [PATCH 04/36] Fix fmt: collapse single-line struct literal --- crates/language_models/src/provider/openai_subscribed.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 1685ff01e181fa..8f3f7da156df2c 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -88,10 +88,7 @@ impl OpenAiSubscribedProvider { credentials_provider, }); - let provider = Self { - http_client, - state, - }; + let provider = Self { http_client, state }; provider.load_credentials(cx); From 3b3f68fe2a3ab5389fafb14ac5c7d856792aab2d Mon Sep 17 00:00:00 2001 From: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:01:30 +0000 Subject: [PATCH 05/36] Autofix --- crates/language_models/src/provider.rs | 2 +- crates/open_ai/src/responses.rs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/language_models/src/provider.rs b/crates/language_models/src/provider.rs index d9f8d9d77eb456..da4d2b808cf8a6 100644 --- a/crates/language_models/src/provider.rs +++ b/crates/language_models/src/provider.rs @@ -10,8 +10,8 @@ pub mod ollama; pub mod open_ai; pub mod open_ai_compatible; pub mod open_router; -pub mod opencode; pub mod openai_subscribed; +pub mod opencode; mod util; pub mod vercel; pub mod vercel_ai_gateway; diff --git a/crates/open_ai/src/responses.rs b/crates/open_ai/src/responses.rs index 390220a0d105fa..fce923c494de3d 100644 --- a/crates/open_ai/src/responses.rs +++ b/crates/open_ai/src/responses.rs @@ -330,7 +330,8 @@ pub async fn stream_response( } let is_streaming = request.stream; - let request = request_builder.body(AsyncBody::from( + let request = request_builder + .body(AsyncBody::from( serde_json::to_string(&request).map_err(|e| RequestError::Other(e.into()))?, )) .map_err(|e| RequestError::Other(e.into()))?; From a603feafbd548c29b15723f3e400778a92f1fb8f Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 6 Apr 2026 13:45:25 -0400 Subject: [PATCH 06/36] openai_subscribed: Show all OpenAI models in ChatGPT Subscription provider - 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. --- .../language_models/src/provider/open_ai.rs | 5 +- .../src/provider/openai_subscribed.rs | 137 ++++++++++-------- crates/open_ai/src/open_ai.rs | 14 +- crates/open_ai/src/responses.rs | 2 + 4 files changed, 96 insertions(+), 62 deletions(-) diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index fca5cd1d97ba7d..6ed2c3e098cb40 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -337,7 +337,8 @@ impl LanguageModel for OpenAiLanguageModel { | Model::FivePointFour | Model::FivePointFourPro | Model::O1 - | Model::O3 => true, + | Model::O3 + | Model::O4Mini => true, Model::ThreePointFiveTurbo | Model::Four | Model::FourTurbo @@ -609,6 +610,7 @@ pub fn into_open_ai_response( ResponseRequest { model: model_id.into(), + instructions: None, input: input_items, stream, temperature, @@ -1265,6 +1267,7 @@ pub fn count_open_ai_tokens( | Model::O1 | Model::O3 | Model::O3Mini + | Model::O4Mini | Model::Five | Model::FiveCodex | Model::FiveMini diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 8f3f7da156df2c..545bed3373725b 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -11,17 +11,20 @@ use language_model::{ LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, RateLimiter, }; -use open_ai::{ReasoningEffort, responses::stream_response}; +use open_ai::responses::stream_response; use rand::RngCore as _; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use smol::io::{AsyncReadExt as _, AsyncWriteExt as _}; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; +use strum::IntoEnumIterator as _; use ui::{ConfiguredApiCard, prelude::*}; use util::ResultExt as _; -use crate::provider::open_ai::{OpenAiResponseEventMapper, into_open_ai_response}; +use crate::provider::open_ai::{ + OpenAiResponseEventMapper, count_open_ai_tokens, into_open_ai_response, +}; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("openai-subscribed"); const PROVIDER_NAME: LanguageModelProviderName = @@ -191,8 +194,9 @@ impl OpenAiSubscribedProvider { .detach(); } - fn create_language_model(&self, model: CodexModel) -> Arc { + fn create_language_model(&self, model: open_ai::Model) -> Arc { Arc::new(OpenAiSubscribedLanguageModel { + id: LanguageModelId::from(model.id().to_string()), model, state: self.state.clone(), http_client: self.http_client.clone(), @@ -223,18 +227,30 @@ impl LanguageModelProvider for OpenAiSubscribedProvider { } fn default_model(&self, _cx: &App) -> Option> { - Some(self.create_language_model(CodexModel::O4Mini)) + Some(self.create_language_model(open_ai::Model::default())) } fn default_fast_model(&self, _cx: &App) -> Option> { - Some(self.create_language_model(CodexModel::CodexMini)) + Some(self.create_language_model(open_ai::Model::default_fast())) } fn provided_models(&self, _cx: &App) -> Vec> { - CodexModel::all() - .into_iter() + let mut models: Vec> = open_ai::Model::iter() + .filter(|m| !matches!(m, open_ai::Model::Custom { .. })) .map(|m| self.create_language_model(m)) - .collect() + .collect(); + + models.push(self.create_language_model(open_ai::Model::Custom { + name: "codex-mini-latest".into(), + display_name: Some("Codex Mini".into()), + max_tokens: 200_000, + max_output_tokens: None, + max_completion_tokens: None, + reasoning_effort: None, + supports_chat_completions: false, + })); + + models } fn is_authenticated(&self, cx: &App) -> bool { @@ -269,52 +285,11 @@ impl LanguageModelProvider for OpenAiSubscribedProvider { } } -// --- Models --- - -#[derive(Clone, Debug, PartialEq)] -pub enum CodexModel { - CodexMini, - O4Mini, - O3, -} - -impl CodexModel { - pub fn all() -> Vec { - vec![Self::CodexMini, Self::O4Mini, Self::O3] - } - - fn id(&self) -> &str { - match self { - Self::CodexMini => "codex-mini-latest", - Self::O4Mini => "o4-mini", - Self::O3 => "o3", - } - } - - fn display_name(&self) -> &str { - match self { - Self::CodexMini => "Codex Mini", - Self::O4Mini => "o4-mini", - Self::O3 => "o3", - } - } - - fn max_token_count(&self) -> u64 { - 200_000 - } - - fn reasoning_effort(&self) -> Option { - match self { - Self::CodexMini => None, - Self::O4Mini | Self::O3 => Some(ReasoningEffort::Medium), - } - } -} - // --- Language model --- struct OpenAiSubscribedLanguageModel { - model: CodexModel, + id: LanguageModelId, + model: open_ai::Model, state: Entity, http_client: Arc, request_limiter: RateLimiter, @@ -322,7 +297,7 @@ struct OpenAiSubscribedLanguageModel { impl LanguageModel for OpenAiSubscribedLanguageModel { fn id(&self) -> LanguageModelId { - LanguageModelId::from(self.model.id().to_string()) + self.id.clone() } fn name(&self) -> LanguageModelName { @@ -342,7 +317,29 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { } fn supports_images(&self) -> bool { - false + use open_ai::Model; + match &self.model { + Model::FourOmniMini + | Model::FourPointOneNano + | Model::Five + | Model::FiveCodex + | Model::FiveMini + | Model::FiveNano + | Model::FivePointOne + | Model::FivePointTwo + | Model::FivePointTwoCodex + | Model::FivePointThreeCodex + | Model::FivePointFour + | Model::FivePointFourPro + | Model::O1 + | Model::O3 + | Model::O4Mini => true, + Model::ThreePointFiveTurbo + | Model::Four + | Model::FourTurbo + | Model::O3Mini + | Model::Custom { .. } => false, + } } fn supports_tool_choice(&self, _choice: LanguageModelToolChoice) -> bool { @@ -365,12 +362,16 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { self.model.max_token_count() } + fn max_output_tokens(&self) -> Option { + self.model.max_output_tokens() + } + fn count_tokens( &self, - _request: LanguageModelRequest, - _cx: &App, + request: LanguageModelRequest, + cx: &App, ) -> BoxFuture<'static, Result> { - futures::future::ready(Ok(0)).boxed() + count_open_ai_tokens(request, self.model.clone(), cx) } fn stream_completion( @@ -390,7 +391,7 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { let mut responses_request = into_open_ai_response( request, self.model.id(), - true, // supports_parallel_tool_calls + self.model.supports_parallel_tool_calls(), false, // supports_prompt_cache_key None, // max_output_tokens — not supported by Codex backend self.model.reasoning_effort(), @@ -398,6 +399,26 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { responses_request.store = Some(false); responses_request.max_output_tokens = None; + // The Codex backend requires system messages to be in the top-level + // `instructions` field rather than as input items. + let mut instructions = Vec::new(); + responses_request.input.retain(|item| { + if let open_ai::responses::ResponseInputItem::Message(msg) = item { + if msg.role == open_ai::Role::System { + for part in &msg.content { + if let open_ai::responses::ResponseInputContent::Text { text } = part { + instructions.push(text.clone()); + } + } + return false; + } + } + true + }); + if !instructions.is_empty() { + responses_request.instructions = Some(instructions.join("\n\n")); + } + let state = self.state.downgrade(); let http_client = self.http_client.clone(); let request_limiter = self.request_limiter.clone(); @@ -783,7 +804,7 @@ impl Render for ConfigurationView { v_flex() .gap_2() .child(Label::new( - "Sign in with your ChatGPT Plus or Pro subscription to use o3, o4-mini, and Codex models in Zed's agent.", + "Sign in with your ChatGPT Plus or Pro subscription to use OpenAI models in Zed's agent.", )) .child( Button::new("sign-in", "Sign in with ChatGPT") diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index c4a3e078d76eb0..161326467e3cb4 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -73,6 +73,8 @@ pub enum Model { O3Mini, #[serde(rename = "o3")] O3, + #[serde(rename = "o4-mini")] + O4Mini, #[serde(rename = "gpt-5")] Five, #[serde(rename = "gpt-5-codex")] @@ -127,6 +129,7 @@ impl Model { "o1" => Ok(Self::O1), "o3-mini" => Ok(Self::O3Mini), "o3" => Ok(Self::O3), + "o4-mini" => Ok(Self::O4Mini), "gpt-5" => Ok(Self::Five), "gpt-5-codex" => Ok(Self::FiveCodex), "gpt-5-mini" => Ok(Self::FiveMini), @@ -151,6 +154,7 @@ impl Model { Self::O1 => "o1", Self::O3Mini => "o3-mini", Self::O3 => "o3", + Self::O4Mini => "o4-mini", Self::Five => "gpt-5", Self::FiveCodex => "gpt-5-codex", Self::FiveMini => "gpt-5-mini", @@ -175,6 +179,7 @@ impl Model { Self::O1 => "o1", Self::O3Mini => "o3-mini", Self::O3 => "o3", + Self::O4Mini => "o4-mini", Self::Five => "gpt-5", Self::FiveCodex => "gpt-5-codex", Self::FiveMini => "gpt-5-mini", @@ -199,6 +204,7 @@ impl Model { Self::O1 => 200_000, Self::O3Mini => 200_000, Self::O3 => 200_000, + Self::O4Mini => 200_000, Self::Five => 272_000, Self::FiveCodex => 272_000, Self::FiveMini => 400_000, @@ -226,6 +232,7 @@ impl Model { Self::O1 => Some(100_000), Self::O3Mini => Some(100_000), Self::O3 => Some(100_000), + Self::O4Mini => Some(100_000), Self::Five => Some(128_000), Self::FiveCodex => Some(128_000), Self::FiveMini => Some(128_000), @@ -244,7 +251,7 @@ impl Model { Self::Custom { reasoning_effort, .. } => reasoning_effort.to_owned(), - Self::FivePointThreeCodex | Self::FivePointFourPro => Some(ReasoningEffort::Medium), + Self::O4Mini | Self::FivePointThreeCodex | Self::FivePointFourPro => Some(ReasoningEffort::Medium), _ => None, } } @@ -255,7 +262,8 @@ impl Model { supports_chat_completions, .. } => *supports_chat_completions, - Self::FiveCodex + Self::O4Mini + | Self::FiveCodex | Self::FivePointTwoCodex | Self::FivePointThreeCodex | Self::FivePointFourPro => false, @@ -283,7 +291,7 @@ impl Model { | Self::FivePointFour | Self::FivePointFourPro | Self::FiveNano => true, - Self::O1 | Self::O3 | Self::O3Mini | Model::Custom { .. } => false, + Self::O1 | Self::O3 | Self::O3Mini | Self::O4Mini | Model::Custom { .. } => false, } } diff --git a/crates/open_ai/src/responses.rs b/crates/open_ai/src/responses.rs index fce923c494de3d..aeeae8c81124a9 100644 --- a/crates/open_ai/src/responses.rs +++ b/crates/open_ai/src/responses.rs @@ -9,6 +9,8 @@ use crate::{ReasoningEffort, RequestError, Role, ToolChoice}; #[derive(Serialize, Debug)] pub struct Request { pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub instructions: Option, #[serde(skip_serializing_if = "Vec::is_empty")] pub input: Vec, #[serde(default)] From ec2ce945232d7eb8a768e5f0608e6132f150e5d4 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 6 Apr 2026 14:00:27 -0400 Subject: [PATCH 07/36] openai_subscribed: Curate model list for Codex backend 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. --- .../src/provider/openai_subscribed.rs | 170 ++++++++++++------ 1 file changed, 119 insertions(+), 51 deletions(-) diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 545bed3373725b..485181323ad0b7 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -11,20 +11,17 @@ use language_model::{ LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, RateLimiter, }; -use open_ai::responses::stream_response; +use open_ai::{ReasoningEffort, responses::stream_response}; use rand::RngCore as _; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use smol::io::{AsyncReadExt as _, AsyncWriteExt as _}; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; -use strum::IntoEnumIterator as _; use ui::{ConfiguredApiCard, prelude::*}; use util::ResultExt as _; -use crate::provider::open_ai::{ - OpenAiResponseEventMapper, count_open_ai_tokens, into_open_ai_response, -}; +use crate::provider::open_ai::{OpenAiResponseEventMapper, into_open_ai_response}; const PROVIDER_ID: LanguageModelProviderId = LanguageModelProviderId::new("openai-subscribed"); const PROVIDER_NAME: LanguageModelProviderName = @@ -194,7 +191,7 @@ impl OpenAiSubscribedProvider { .detach(); } - fn create_language_model(&self, model: open_ai::Model) -> Arc { + fn create_language_model(&self, model: ChatGptModel) -> Arc { Arc::new(OpenAiSubscribedLanguageModel { id: LanguageModelId::from(model.id().to_string()), model, @@ -227,30 +224,18 @@ impl LanguageModelProvider for OpenAiSubscribedProvider { } fn default_model(&self, _cx: &App) -> Option> { - Some(self.create_language_model(open_ai::Model::default())) + Some(self.create_language_model(ChatGptModel::Gpt53Codex)) } fn default_fast_model(&self, _cx: &App) -> Option> { - Some(self.create_language_model(open_ai::Model::default_fast())) + Some(self.create_language_model(ChatGptModel::Gpt54Mini)) } fn provided_models(&self, _cx: &App) -> Vec> { - let mut models: Vec> = open_ai::Model::iter() - .filter(|m| !matches!(m, open_ai::Model::Custom { .. })) + ChatGptModel::all() + .into_iter() .map(|m| self.create_language_model(m)) - .collect(); - - models.push(self.create_language_model(open_ai::Model::Custom { - name: "codex-mini-latest".into(), - display_name: Some("Codex Mini".into()), - max_tokens: 200_000, - max_output_tokens: None, - max_completion_tokens: None, - reasoning_effort: None, - supports_chat_completions: false, - })); - - models + .collect() } fn is_authenticated(&self, cx: &App) -> bool { @@ -285,11 +270,116 @@ impl LanguageModelProvider for OpenAiSubscribedProvider { } } +// --- Models available through the Codex backend --- +// +// 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, +} + +impl ChatGptModel { + fn all() -> Vec { + vec![ + Self::Gpt54, + Self::Gpt54Mini, + Self::Gpt53Codex, + Self::Gpt53CodexSpark, + Self::Gpt52Codex, + Self::Gpt52, + Self::Gpt51CodexMax, + Self::Gpt51Codex, + Self::Gpt51CodexMini, + Self::Gpt51, + Self::Gpt5Codex, + Self::Gpt5CodexMini, + Self::Gpt5, + ] + } + + fn id(&self) -> &str { + match self { + Self::Gpt5 => "gpt-5", + Self::Gpt5Codex => "gpt-5-codex", + Self::Gpt5CodexMini => "gpt-5-codex-mini", + Self::Gpt51 => "gpt-5.1", + Self::Gpt51Codex => "gpt-5.1-codex", + Self::Gpt51CodexMax => "gpt-5.1-codex-max", + Self::Gpt51CodexMini => "gpt-5.1-codex-mini", + Self::Gpt52 => "gpt-5.2", + Self::Gpt52Codex => "gpt-5.2-codex", + Self::Gpt53Codex => "gpt-5.3-codex", + Self::Gpt53CodexSpark => "gpt-5.3-codex-spark", + Self::Gpt54 => "gpt-5.4", + Self::Gpt54Mini => "gpt-5.4-mini", + } + } + + fn display_name(&self) -> &str { + match self { + Self::Gpt5 => "GPT-5", + Self::Gpt5Codex => "GPT-5 Codex", + Self::Gpt5CodexMini => "GPT-5 Codex Mini", + Self::Gpt51 => "GPT-5.1", + Self::Gpt51Codex => "GPT-5.1 Codex", + Self::Gpt51CodexMax => "GPT-5.1 Codex Max", + Self::Gpt51CodexMini => "GPT-5.1 Codex Mini", + Self::Gpt52 => "GPT-5.2", + Self::Gpt52Codex => "GPT-5.2 Codex", + Self::Gpt53Codex => "GPT-5.3 Codex", + Self::Gpt53CodexSpark => "GPT-5.3 Codex Spark", + Self::Gpt54 => "GPT-5.4", + Self::Gpt54Mini => "GPT-5.4 Mini", + } + } + + fn max_token_count(&self) -> u64 { + match self { + Self::Gpt53CodexSpark => 128_000, + Self::Gpt54 | Self::Gpt54Mini => 1_050_000, + _ => 400_000, + } + } + + fn max_output_tokens(&self) -> Option { + match self { + Self::Gpt53CodexSpark => Some(8_192), + _ => Some(128_000), + } + } + + fn supports_images(&self) -> bool { + !matches!(self, Self::Gpt53CodexSpark) + } + + fn reasoning_effort(&self) -> Option { + match self { + Self::Gpt54 | Self::Gpt54Mini => None, + _ => Some(ReasoningEffort::Medium), + } + } +} + // --- Language model --- struct OpenAiSubscribedLanguageModel { id: LanguageModelId, - model: open_ai::Model, + model: ChatGptModel, state: Entity, http_client: Arc, request_limiter: RateLimiter, @@ -317,29 +407,7 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { } fn supports_images(&self) -> bool { - use open_ai::Model; - match &self.model { - Model::FourOmniMini - | Model::FourPointOneNano - | Model::Five - | Model::FiveCodex - | Model::FiveMini - | Model::FiveNano - | Model::FivePointOne - | Model::FivePointTwo - | Model::FivePointTwoCodex - | Model::FivePointThreeCodex - | Model::FivePointFour - | Model::FivePointFourPro - | Model::O1 - | Model::O3 - | Model::O4Mini => true, - Model::ThreePointFiveTurbo - | Model::Four - | Model::FourTurbo - | Model::O3Mini - | Model::Custom { .. } => false, - } + self.model.supports_images() } fn supports_tool_choice(&self, _choice: LanguageModelToolChoice) -> bool { @@ -368,10 +436,10 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { fn count_tokens( &self, - request: LanguageModelRequest, - cx: &App, + _request: LanguageModelRequest, + _cx: &App, ) -> BoxFuture<'static, Result> { - count_open_ai_tokens(request, self.model.clone(), cx) + futures::future::ready(Ok(0)).boxed() } fn stream_completion( @@ -391,7 +459,7 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { let mut responses_request = into_open_ai_response( request, self.model.id(), - self.model.supports_parallel_tool_calls(), + true, // supports_parallel_tool_calls false, // supports_prompt_cache_key None, // max_output_tokens — not supported by Codex backend self.model.reasoning_effort(), From d0fc5df1da78040e0105a6b9e4f3e8db4d39127b Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 6 Apr 2026 14:01:06 -0400 Subject: [PATCH 08/36] openai_subscribed: Default to GPT-5.4 --- crates/language_models/src/provider/openai_subscribed.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 485181323ad0b7..79bb08e9e0ee16 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -224,7 +224,7 @@ impl LanguageModelProvider for OpenAiSubscribedProvider { } fn default_model(&self, _cx: &App) -> Option> { - Some(self.create_language_model(ChatGptModel::Gpt53Codex)) + Some(self.create_language_model(ChatGptModel::Gpt54)) } fn default_fast_model(&self, _cx: &App) -> Option> { From d2e4cd8a1992144249380df05cc7198a5fae64f2 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 6 Apr 2026 13:56:18 -0400 Subject: [PATCH 09/36] Deduplicate concurrent credential refresh with shared task 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 to State so the first caller to notice expiry spawns the refresh task and subsequent callers join the same future. --- .../src/provider/openai_subscribed.rs | 93 ++++++++++++++----- 1 file changed, 72 insertions(+), 21 deletions(-) diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 79bb08e9e0ee16..5ce6b44b2bcb9c 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -2,7 +2,7 @@ use anyhow::{Context as _, Result, anyhow}; use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use credentials_provider::CredentialsProvider; -use futures::{FutureExt, StreamExt, future::BoxFuture}; +use futures::{FutureExt, StreamExt, future::BoxFuture, future::Shared}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest}; use language_model::{ @@ -54,6 +54,7 @@ impl CodexCredentials { pub struct State { credentials: Option, sign_in_task: Option>>, + refresh_task: Option>>>>, credentials_provider: Arc, } @@ -85,6 +86,7 @@ impl OpenAiSubscribedProvider { let state = cx.new(|_cx| State { credentials: None, sign_in_task: None, + refresh_task: None, credentials_provider, }); @@ -536,40 +538,89 @@ async fn get_fresh_credentials( http_client: &Arc, cx: &mut AsyncApp, ) -> Result { - let creds = state - .read_with(&*cx, |s, _| s.credentials.clone()) - .map_err(LanguageModelCompletionError::Other)? - .ok_or(LanguageModelCompletionError::NoApiKey { - provider: PROVIDER_NAME, - })?; + let (creds, existing_task) = state + .read_with(&*cx, |s, _| (s.credentials.clone(), s.refresh_task.clone())) + .map_err(LanguageModelCompletionError::Other)?; + + let creds = creds.ok_or(LanguageModelCompletionError::NoApiKey { + provider: PROVIDER_NAME, + })?; if !creds.is_expired() { return Ok(creds); } - let refreshed = refresh_token(http_client, &creds.refresh_token) - .await - .map_err(LanguageModelCompletionError::Other)?; + // If another caller is already refreshing, await their result. + if let Some(shared_task) = existing_task { + return shared_task + .await + .map_err(|e| LanguageModelCompletionError::Other(anyhow::anyhow!("{e}"))); + } - let credentials_provider = state - .read_with(&*cx, |s, _| s.credentials_provider.clone()) - .map_err(LanguageModelCompletionError::Other)?; + // We are the first caller to notice expiry — spawn the refresh task. + let http_client_clone = http_client.clone(); + let state_clone = state.clone(); + let refresh_token_value = creds.refresh_token.clone(); - let json = serde_json::to_vec(&refreshed) - .map_err(|e| LanguageModelCompletionError::Other(e.into()))?; + let shared_task = cx + .spawn(async move |cx| { + let result = refresh_token(&http_client_clone, &refresh_token_value).await; - credentials_provider - .write_credentials(CREDENTIALS_KEY, "Bearer", &json, &*cx) - .await - .map_err(LanguageModelCompletionError::Other)?; + match result { + Ok(refreshed) => { + let persist_result: Result> = async { + let credentials_provider = state_clone + .read_with(&*cx, |s, _| s.credentials_provider.clone()) + .map_err(|e| Arc::new(e))?; + + let json = + serde_json::to_vec(&refreshed).map_err(|e| Arc::new(e.into()))?; + + credentials_provider + .write_credentials(CREDENTIALS_KEY, "Bearer", &json, &*cx) + .await + .map_err(|e| Arc::new(e))?; + state_clone + .update(cx, |s, _| { + s.credentials = Some(refreshed.clone()); + s.refresh_task = None; + }) + .map_err(|e| Arc::new(e))?; + + Ok(refreshed) + } + .await; + + // Clear refresh_task on failure too. + if persist_result.is_err() { + let _ = state_clone.update(cx, |s, _| { + s.refresh_task = None; + }); + } + + persist_result + } + Err(e) => { + let _ = state_clone.update(cx, |s, _| { + s.refresh_task = None; + }); + Err(Arc::new(e)) + } + } + }) + .shared(); + + // Store the shared task so concurrent callers can join on it. state .update(cx, |s, _| { - s.credentials = Some(refreshed.clone()); + s.refresh_task = Some(shared_task.clone()); }) .map_err(LanguageModelCompletionError::Other)?; - Ok(refreshed) + shared_task + .await + .map_err(|e| LanguageModelCompletionError::Other(anyhow::anyhow!("{e}"))) } // --- OAuth PKCE flow --- From 7772c88d7b207bc11313679a4628b9cef157a6f6 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 6 Apr 2026 13:57:18 -0400 Subject: [PATCH 10/36] Add 2-minute timeout to OAuth callback listener 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. --- .../src/provider/openai_subscribed.rs | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 5ce6b44b2bcb9c..4a6d9cc9fef8da 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -2,7 +2,7 @@ use anyhow::{Context as _, Result, anyhow}; use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use credentials_provider::CredentialsProvider; -use futures::{FutureExt, StreamExt, future::BoxFuture, future::Shared}; +use futures::{FutureExt, StreamExt, future::BoxFuture, future::Either, future::Shared}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest}; use language_model::{ @@ -17,7 +17,7 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use smol::io::{AsyncReadExt as _, AsyncWriteExt as _}; use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use ui::{ConfiguredApiCard, prelude::*}; use util::ResultExt as _; @@ -690,7 +690,22 @@ async fn await_oauth_callback(expected_state: &str) -> Result { .await .context("Failed to bind to port 1455 for OAuth callback. Another application may be using this port.")?; - let (mut stream, _) = listener.accept().await?; + let accept_future = listener.accept(); + let timeout_future = smol::Timer::after(Duration::from_secs(120)); + + let (mut stream, _) = match futures::future::select( + std::pin::pin!(accept_future), + std::pin::pin!(timeout_future), + ) + .await + { + Either::Left((result, _)) => result?, + Either::Right((_, _)) => { + return Err(anyhow!( + "OAuth sign-in timed out after 2 minutes. Please try again." + )); + } + }; let mut buffer = vec![0u8; 4096]; let n = stream.read(&mut buffer).await?; From a2e689c548ac8b4f6504f6fa9321e97ae9f0a0b3 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 6 Apr 2026 13:58:27 -0400 Subject: [PATCH 11/36] Use url::form_urlencoded for OAuth form body encoding 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 +. --- crates/language_models/Cargo.toml | 1 + .../src/provider/openai_subscribed.rs | 19 +++++++++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/language_models/Cargo.toml b/crates/language_models/Cargo.toml index 1111862ded0e13..e349cf10045608 100644 --- a/crates/language_models/Cargo.toml +++ b/crates/language_models/Cargo.toml @@ -65,6 +65,7 @@ tiktoken-rs.workspace = true tokio = { workspace = true, features = ["rt", "rt-multi-thread"] } ui.workspace = true ui_input.workspace = true +url.workspace = true util.workspace = true vercel = { workspace = true, features = ["schemars"] } x_ai = { workspace = true, features = ["schemars"] } diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 4a6d9cc9fef8da..c9b363216e77ad 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -19,6 +19,7 @@ use smol::io::{AsyncReadExt as _, AsyncWriteExt as _}; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use ui::{ConfiguredApiCard, prelude::*}; +use url::form_urlencoded; use util::ResultExt as _; use crate::provider::open_ai::{OpenAiResponseEventMapper, into_open_ai_response}; @@ -749,10 +750,13 @@ async fn exchange_code( code: &str, verifier: &str, ) -> Result { - let body = format!( - "grant_type=authorization_code&client_id={CLIENT_ID}&code={code}&redirect_uri={encoded_redirect}&code_verifier={verifier}", - encoded_redirect = percent_encode(REDIRECT_URI), - ); + let body = form_urlencoded::Serializer::new(String::new()) + .append_pair("grant_type", "authorization_code") + .append_pair("client_id", CLIENT_ID) + .append_pair("code", code) + .append_pair("redirect_uri", REDIRECT_URI) + .append_pair("code_verifier", verifier) + .finish(); let request = HttpRequest::builder() .method(Method::POST) @@ -778,8 +782,11 @@ async fn refresh_token( client: &Arc, refresh_token: &str, ) -> Result { - let body = - format!("grant_type=refresh_token&client_id={CLIENT_ID}&refresh_token={refresh_token}"); + let body = form_urlencoded::Serializer::new(String::new()) + .append_pair("grant_type", "refresh_token") + .append_pair("client_id", CLIENT_ID) + .append_pair("refresh_token", refresh_token) + .finish(); let request = HttpRequest::builder() .method(Method::POST) From a0760b199c64291daa3242d6bac8b15c1c0d0811 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 6 Apr 2026 13:59:03 -0400 Subject: [PATCH 12/36] Guard sign_in against concurrent invocations 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. --- crates/language_models/src/provider/openai_subscribed.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index c9b363216e77ad..a0b9d16fa9e3f0 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -119,6 +119,10 @@ impl OpenAiSubscribedProvider { } fn sign_in(&self, cx: &mut App) { + if self.state.read(cx).is_signing_in() { + return; + } + let state = self.state.downgrade(); let http_client = self.http_client.clone(); From a3e89278aec0d90244f3636e2eadf1c4cbb60161 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 6 Apr 2026 13:59:33 -0400 Subject: [PATCH 13/36] Cancel in-flight sign-in task on sign-out Dropping sign_in_task prevents a completing OAuth flow from writing credentials back into state after the user has signed out. --- crates/language_models/src/provider/openai_subscribed.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index a0b9d16fa9e3f0..98d52b8c621a2b 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -191,6 +191,7 @@ impl OpenAiSubscribedProvider { .log_err(); state.update(cx, |s, cx| { s.credentials = None; + s.sign_in_task = None; cx.notify(); })?; anyhow::Ok(()) From 59286a2acb64c9fcc0bbf457c3e7755bf7698bc8 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 6 Apr 2026 14:02:22 -0400 Subject: [PATCH 14/36] Extract do_sign_in and do_sign_out free functions 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. --- .../src/provider/openai_subscribed.rs | 184 ++++++++---------- 1 file changed, 86 insertions(+), 98 deletions(-) diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 98d52b8c621a2b..9dbecfbdccb742 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -118,85 +118,8 @@ impl OpenAiSubscribedProvider { .detach(); } - fn sign_in(&self, cx: &mut App) { - if self.state.read(cx).is_signing_in() { - return; - } - - let state = self.state.downgrade(); - let http_client = self.http_client.clone(); - - let task = cx.spawn(async move |cx| { - match do_oauth_flow(http_client, &*cx).await { - Ok(creds) => { - let persist_result = async { - let credentials_provider = - state.read_with(&*cx, |s, _| s.credentials_provider.clone())?; - let json = serde_json::to_vec(&creds)?; - credentials_provider - .write_credentials(CREDENTIALS_KEY, "Bearer", &json, &*cx) - .await?; - anyhow::Ok(()) - } - .await; - - match persist_result { - Ok(()) => { - state - .update(cx, |s, cx| { - s.credentials = Some(creds); - s.sign_in_task = None; - cx.notify(); - }) - .log_err(); - } - Err(err) => { - log::error!("ChatGPT subscription sign-in failed to persist credentials: {err:?}"); - state - .update(cx, |s, cx| { - s.sign_in_task = None; - cx.notify(); - }) - .log_err(); - } - } - } - Err(err) => { - log::error!("ChatGPT subscription sign-in failed: {err:?}"); - state - .update(cx, |s, cx| { - s.sign_in_task = None; - cx.notify(); - }) - .log_err(); - } - } - anyhow::Ok(()) - }); - - self.state.update(cx, |s, cx| { - s.sign_in_task = Some(task); - cx.notify(); - }); - } - fn sign_out(&self, cx: &mut App) { - let state = self.state.downgrade(); - cx.spawn(async move |cx| { - let credentials_provider = - state.read_with(&*cx, |s, _| s.credentials_provider.clone())?; - credentials_provider - .delete_credentials(CREDENTIALS_KEY, &*cx) - .await - .log_err(); - state.update(cx, |s, cx| { - s.credentials = None; - s.sign_in_task = None; - cx.notify(); - })?; - anyhow::Ok(()) - }) - .detach(); + do_sign_out(&self.state.downgrade(), cx); } fn create_language_model(&self, model: ChatGptModel) -> Arc { @@ -895,6 +818,89 @@ fn percent_decode(s: &str) -> String { result } +fn do_sign_in(state: &Entity, http_client: &Arc, cx: &mut App) { + if state.read(cx).is_signing_in() { + return; + } + + let weak_state = state.downgrade(); + let http_client = http_client.clone(); + + let task = cx.spawn(async move |cx| { + match do_oauth_flow(http_client, &*cx).await { + Ok(creds) => { + let persist_result = async { + let credentials_provider = + weak_state.read_with(&*cx, |s, _| s.credentials_provider.clone())?; + let json = serde_json::to_vec(&creds)?; + credentials_provider + .write_credentials(CREDENTIALS_KEY, "Bearer", &json, &*cx) + .await?; + anyhow::Ok(()) + } + .await; + + match persist_result { + Ok(()) => { + weak_state + .update(cx, |s, cx| { + s.credentials = Some(creds); + s.sign_in_task = None; + cx.notify(); + }) + .log_err(); + } + Err(err) => { + log::error!( + "ChatGPT subscription sign-in failed to persist credentials: {err:?}" + ); + weak_state + .update(cx, |s, cx| { + s.sign_in_task = None; + cx.notify(); + }) + .log_err(); + } + } + } + Err(err) => { + log::error!("ChatGPT subscription sign-in failed: {err:?}"); + weak_state + .update(cx, |s, cx| { + s.sign_in_task = None; + cx.notify(); + }) + .log_err(); + } + } + anyhow::Ok(()) + }); + + state.update(cx, |s, cx| { + s.sign_in_task = Some(task); + cx.notify(); + }); +} + +fn do_sign_out(state: &gpui::WeakEntity, cx: &mut App) { + let weak_state = state.clone(); + cx.spawn(async move |cx| { + let credentials_provider = + weak_state.read_with(&*cx, |s, _| s.credentials_provider.clone())?; + credentials_provider + .delete_credentials(CREDENTIALS_KEY, &*cx) + .await + .log_err(); + weak_state.update(cx, |s, cx| { + s.credentials = None; + s.sign_in_task = None; + cx.notify(); + })?; + anyhow::Ok(()) + }) + .detach(); +} + // --- Configuration view --- struct ConfigurationView { @@ -917,21 +923,7 @@ impl Render for ConfigurationView { .child( ConfiguredApiCard::new(SharedString::from(label)).on_click(cx.listener( move |_this, _, _window, cx| { - let weak_state = weak_state.clone(); - cx.spawn(async move |_this, cx| { - let credentials_provider = weak_state - .read_with(&*cx, |s, _| s.credentials_provider.clone())?; - credentials_provider - .delete_credentials(CREDENTIALS_KEY, &*cx) - .await - .log_err(); - weak_state.update(cx, |s, cx| { - s.credentials = None; - cx.notify(); - })?; - anyhow::Ok(()) - }) - .detach(); + do_sign_out(&weak_state, cx); }, )), ) @@ -955,11 +947,7 @@ impl Render for ConfigurationView { .child( Button::new("sign-in", "Sign in with ChatGPT") .on_click(move |_, _window, cx| { - let provider = OpenAiSubscribedProvider { - state: provider_state.clone(), - http_client: http_client.clone(), - }; - provider.sign_in(cx); + do_sign_in(&provider_state, &http_client, cx); }), ) .into_any_element() From 1ec185699b75a49e0eb05e70e071532b44aa0b66 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 6 Apr 2026 14:04:17 -0400 Subject: [PATCH 15/36] Extract email from JWT claims for display in config view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/provider/openai_subscribed.rs | 83 +++++++++++++------ 1 file changed, 56 insertions(+), 27 deletions(-) diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 9dbecfbdccb742..2c481dca01a82f 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -603,14 +603,14 @@ async fn do_oauth_flow( .id_token .as_deref() .unwrap_or(tokens.access_token.as_str()); - let account_id = extract_account_id(jwt); + let claims = extract_jwt_claims(jwt); Ok(CodexCredentials { access_token: tokens.access_token, refresh_token: tokens.refresh_token, expires_at_ms: now_ms() + tokens.expires_in * 1000, - account_id, - email: tokens.email, + account_id: claims.account_id, + email: claims.email.or(tokens.email), }) } @@ -738,41 +738,70 @@ async fn refresh_token( .id_token .as_deref() .unwrap_or(tokens.access_token.as_str()); - let account_id = extract_account_id(jwt); + let claims = extract_jwt_claims(jwt); Ok(CodexCredentials { access_token: tokens.access_token, refresh_token: tokens.refresh_token, expires_at_ms: now_ms() + tokens.expires_in * 1000, - account_id, - email: tokens.email, + account_id: claims.account_id, + email: claims.email.or(tokens.email), }) } -/// Extract chatgpt_account_id from a JWT payload (base64url middle segment). -/// Checks three claim locations, matching Roo Code's implementation. -fn extract_account_id(jwt: &str) -> Option { - let payload_b64 = jwt.split('.').nth(1)?; - let payload = URL_SAFE_NO_PAD.decode(payload_b64).ok()?; - let claims: serde_json::Value = serde_json::from_slice(&payload).ok()?; +struct JwtClaims { + account_id: Option, + email: Option, +} - if let Some(id) = claims.get("chatgpt_account_id").and_then(|v| v.as_str()) { - return Some(id.to_owned()); - } - if let Some(id) = claims - .get("https://api.openai.com/auth") - .and_then(|v| v.get("chatgpt_account_id")) +/// Extract claims from a JWT payload (base64url middle segment). +/// Extracts `chatgpt_account_id` from three possible locations (matching Roo Code's +/// implementation) and the `email` claim. +fn extract_jwt_claims(jwt: &str) -> JwtClaims { + let Some(payload_b64) = jwt.split('.').nth(1) else { + return JwtClaims { + account_id: None, + email: None, + }; + }; + let Ok(payload) = URL_SAFE_NO_PAD.decode(payload_b64) else { + return JwtClaims { + account_id: None, + email: None, + }; + }; + let Ok(claims) = serde_json::from_slice::(&payload) else { + return JwtClaims { + account_id: None, + email: None, + }; + }; + + let account_id = claims + .get("chatgpt_account_id") .and_then(|v| v.as_str()) - { - return Some(id.to_owned()); - } - claims - .get("organizations") - .and_then(|v| v.as_array()) - .and_then(|arr| arr.first()) - .and_then(|org| org.get("id")) + .or_else(|| { + claims + .get("https://api.openai.com/auth") + .and_then(|v| v.get("chatgpt_account_id")) + .and_then(|v| v.as_str()) + }) + .or_else(|| { + claims + .get("organizations") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|org| org.get("id")) + .and_then(|v| v.as_str()) + }) + .map(|s| s.to_owned()); + + let email = claims + .get("email") .and_then(|v| v.as_str()) - .map(|s| s.to_owned()) + .map(|s| s.to_owned()); + + JwtClaims { account_id, email } } fn now_ms() -> u64 { From 979a036f277eeb0d38bd2a6b6865e9cdf944ddf1 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 6 Apr 2026 14:05:50 -0400 Subject: [PATCH 16/36] Replace hand-rolled percent encoding with url crate 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. --- .../src/provider/openai_subscribed.rs | 64 ++++++------------- 1 file changed, 18 insertions(+), 46 deletions(-) diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 2c481dca01a82f..c549498458a81f 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -584,12 +584,20 @@ async fn do_oauth_flow( rand::rng().fill_bytes(&mut state_bytes); let oauth_state: String = state_bytes.iter().map(|b| format!("{b:02x}")).collect(); - let auth_url = format!( - "{OPENAI_AUTHORIZE_URL}?client_id={CLIENT_ID}&redirect_uri={encoded_redirect}&scope=openid+profile+email+offline_access&response_type=code&code_challenge={challenge}&code_challenge_method=S256&state={oauth_state}&codex_cli_simplified_flow=true&originator=zed", - encoded_redirect = percent_encode(REDIRECT_URI), - ); + let mut auth_url = url::Url::parse(OPENAI_AUTHORIZE_URL).expect("valid base URL"); + auth_url + .query_pairs_mut() + .append_pair("client_id", CLIENT_ID) + .append_pair("redirect_uri", REDIRECT_URI) + .append_pair("scope", "openid profile email offline_access") + .append_pair("response_type", "code") + .append_pair("code_challenge", &challenge) + .append_pair("code_challenge_method", "S256") + .append_pair("state", &oauth_state) + .append_pair("codex_cli_simplified_flow", "true") + .append_pair("originator", "zed"); - cx.update(|cx| cx.open_url(&auth_url)); + cx.update(|cx| cx.open_url(auth_url.as_str())); let code = await_oauth_callback(&oauth_state) .await @@ -650,11 +658,11 @@ async fn await_oauth_callback(expected_state: &str) -> Result { let query = path.split('?').nth(1).unwrap_or(""); let mut code: Option = None; let mut received_state: Option = None; - for part in query.split('&') { - if let Some(v) = part.strip_prefix("code=") { - code = Some(percent_decode(v)); - } else if let Some(v) = part.strip_prefix("state=") { - received_state = Some(percent_decode(v)); + for (key, value) in form_urlencoded::parse(query.as_bytes()) { + match key.as_ref() { + "code" => code = Some(value.into_owned()), + "state" => received_state = Some(value.into_owned()), + _ => {} } } @@ -811,42 +819,6 @@ fn now_ms() -> u64 { .unwrap_or(0) } -fn percent_encode(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for byte in s.bytes() { - match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - out.push(byte as char); - } - b => out.push_str(&format!("%{b:02X}")), - } - } - out -} - -fn percent_decode(s: &str) -> String { - let mut result = String::with_capacity(s.len()); - let mut bytes = s.bytes().peekable(); - while let Some(b) = bytes.next() { - if b == b'%' { - let h1 = bytes.next().unwrap_or(b'0'); - let h2 = bytes.next().unwrap_or(b'0'); - let hex = [h1, h2]; - if let Ok(hex_str) = std::str::from_utf8(&hex) { - if let Ok(decoded) = u8::from_str_radix(hex_str, 16) { - result.push(decoded as char); - continue; - } - } - } else if b == b'+' { - result.push(' '); - continue; - } - result.push(b as char); - } - result -} - fn do_sign_in(state: &Entity, http_client: &Arc, cx: &mut App) { if state.read(cx).is_signing_in() { return; From fab5caea2875871c4d4a7c04279d988654e8b59e Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 6 Apr 2026 14:06:29 -0400 Subject: [PATCH 17/36] Log warning when credential deserialization fails Previously a schema change or corruption would silently show the user as signed out. Now a log::warn helps diagnose the issue. --- crates/language_models/src/provider/openai_subscribed.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index c549498458a81f..3cedf85ff2db87 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -108,8 +108,13 @@ impl OpenAiSubscribedProvider { .await; state.update(cx, |s, cx| { if let Ok(Some((_, bytes))) = result { - if let Ok(creds) = serde_json::from_slice::(&bytes) { - s.credentials = Some(creds); + match serde_json::from_slice::(&bytes) { + Ok(creds) => s.credentials = Some(creds), + Err(err) => { + log::warn!( + "Failed to deserialize ChatGPT subscription credentials: {err}" + ); + } } } cx.notify(); From 66a62a1a97f09bb43e667570f0259b5f81be10cd Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 6 Apr 2026 14:07:08 -0400 Subject: [PATCH 18/36] Log error instead of silently returning 0 in now_ms 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. --- crates/language_models/src/provider/openai_subscribed.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 3cedf85ff2db87..ac623e11f039c3 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -821,7 +821,10 @@ fn now_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_millis() as u64) - .unwrap_or(0) + .unwrap_or_else(|err| { + log::error!("System clock is before UNIX epoch: {err}"); + 0 + }) } fn do_sign_in(state: &Entity, http_client: &Arc, cx: &mut App) { From ce732a63ae150cd26dd0558c718a99bab3e93bf8 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 6 Apr 2026 14:08:06 -0400 Subject: [PATCH 19/36] Read OAuth callback in a loop to handle TCP fragmentation 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. --- .../src/provider/openai_subscribed.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index ac623e11f039c3..8751d8ad32d2be 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -650,8 +650,21 @@ async fn await_oauth_callback(expected_state: &str) -> Result { }; let mut buffer = vec![0u8; 4096]; - let n = stream.read(&mut buffer).await?; - let request_text = std::str::from_utf8(&buffer[..n])?; + let mut total = 0; + loop { + if total >= buffer.len() { + return Err(anyhow!("OAuth callback request too large")); + } + let n = stream.read(&mut buffer[total..]).await?; + if n == 0 { + break; + } + total += n; + if buffer[..total].windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + let request_text = std::str::from_utf8(&buffer[..total])?; // First line: "GET /auth/callback?code=...&state=... HTTP/1.1" let path = request_text From 0caff5b45c0fa2b6eaade76067820767520ba6de Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 6 Apr 2026 14:09:40 -0400 Subject: [PATCH 20/36] Update Cargo.lock for url dependency --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index c1fb88e4a4d8cb..63abadce0ce0bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9405,6 +9405,7 @@ dependencies = [ "tokio", "ui", "ui_input", + "url", "util", "vercel", "x_ai", From cf0892e081d8b4d9904f178133d5df45b16e74a0 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 6 Apr 2026 14:16:03 -0400 Subject: [PATCH 21/36] Share styled OAuth callback page between providers 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. --- crates/context_server/src/oauth.rs | 12 ++- crates/http_client/src/http_client.rs | 86 +++++++++++++++++++ .../src/provider/openai_subscribed.rs | 15 ++-- 3 files changed, 104 insertions(+), 9 deletions(-) diff --git a/crates/context_server/src/oauth.rs b/crates/context_server/src/oauth.rs index 1a314de2fca9b9..44bcbbd557a67f 100644 --- a/crates/context_server/src/oauth.rs +++ b/crates/context_server/src/oauth.rs @@ -1107,15 +1107,19 @@ pub async fn start_callback_server() -> Result<( let (status_code, body) = match &result { Ok(_) => ( 200, - "

Authorization successful

\ -

You can close this tab and return to Zed.

", + http_client::oauth_callback_page( + "Authorization Successful", + "You can close this tab and return to Zed.", + ), ), Err(err) => { log::error!("OAuth callback error: {}", err); ( 400, - "

Authorization failed

\ -

Something went wrong. Please try again from Zed.

", + http_client::oauth_callback_page( + "Authorization Failed", + "Something went wrong. Please try again from Zed.", + ), ) } }; diff --git a/crates/http_client/src/http_client.rs b/crates/http_client/src/http_client.rs index bbbe3b1a832332..b1f097fdac6210 100644 --- a/crates/http_client/src/http_client.rs +++ b/crates/http_client/src/http_client.rs @@ -291,6 +291,92 @@ impl HttpClient for HttpClientWithUrl { } } +/// Generate a styled HTML page for OAuth callback responses. +/// +/// Returns a complete HTML document (no HTTP headers) with a centered card +/// layout styled to match Zed's dark theme. The `title` is rendered as a +/// heading and `message` as body text below it. +pub fn oauth_callback_page(title: &str, message: &str) -> String { + format!( + r#" + + + + +{title} — Zed + + + +
+
+ +
+

{title}

+

{message}

+
Zed
+
+ +"#, + title = title, + message = message, + ) +} + pub fn read_proxy_from_env() -> Option { const ENV_VARS: &[&str] = &[ "ALL_PROXY", diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 8751d8ad32d2be..e8fb7f058710b9 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -684,11 +684,16 @@ async fn await_oauth_callback(expected_state: &str) -> Result { } } - let html = b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n\ -

Signed in to Zed

\ -

Authentication successful. You can close this tab.

\ - "; - stream.write_all(html).await.log_err(); + let page = http_client::oauth_callback_page( + "Signed In", + "You've signed into Zed via your ChatGPT subscription. You can close this tab and return to Zed.", + ); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\n\r\n{}", + page.len(), + page + ); + stream.write_all(response.as_bytes()).await.log_err(); let received_state = received_state.ok_or_else(|| anyhow!("Missing state in OAuth callback"))?; From d2251fa7bf66aa0e5e65fd00d4d9f8a2d96434bf Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 6 Apr 2026 14:47:07 -0400 Subject: [PATCH 22/36] Run cargo fmt on open_ai.rs --- crates/open_ai/src/open_ai.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index 161326467e3cb4..ef4a20c9153c7f 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -251,7 +251,9 @@ impl Model { Self::Custom { reasoning_effort, .. } => reasoning_effort.to_owned(), - Self::O4Mini | Self::FivePointThreeCodex | Self::FivePointFourPro => Some(ReasoningEffort::Medium), + Self::O4Mini | Self::FivePointThreeCodex | Self::FivePointFourPro => { + Some(ReasoningEffort::Medium) + } _ => None, } } From 64069d53f5ea96c6bb681a11cc221e586ab51b63 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 6 Apr 2026 14:51:54 -0400 Subject: [PATCH 23/36] Use gpui executor timer instead of disallowed smol::Timer 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. --- crates/language_models/src/provider/openai_subscribed.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index e8fb7f058710b9..fa7af5dfce74ea 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -604,7 +604,7 @@ async fn do_oauth_flow( cx.update(|cx| cx.open_url(auth_url.as_str())); - let code = await_oauth_callback(&oauth_state) + let code = await_oauth_callback(&oauth_state, cx) .await .context("OAuth callback failed")?; @@ -627,13 +627,13 @@ async fn do_oauth_flow( }) } -async fn await_oauth_callback(expected_state: &str) -> Result { +async fn await_oauth_callback(expected_state: &str, cx: &AsyncApp) -> Result { let listener = smol::net::TcpListener::bind("127.0.0.1:1455") .await .context("Failed to bind to port 1455 for OAuth callback. Another application may be using this port.")?; let accept_future = listener.accept(); - let timeout_future = smol::Timer::after(Duration::from_secs(120)); + let timeout_future = cx.background_executor().timer(Duration::from_secs(120)); let (mut stream, _) = match futures::future::select( std::pin::pin!(accept_future), From 5e412a02fc28e5eb813966757832d2ada93e41bb Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 6 Apr 2026 15:20:06 -0400 Subject: [PATCH 24/36] Remove section separator comments --- crates/language_models/src/provider/openai_subscribed.rs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index fa7af5dfce74ea..134a3a84ee01dd 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -206,7 +206,6 @@ impl LanguageModelProvider for OpenAiSubscribedProvider { } } -// --- Models available through the Codex backend --- // // 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 @@ -311,8 +310,6 @@ impl ChatGptModel { } } -// --- Language model --- - struct OpenAiSubscribedLanguageModel { id: LanguageModelId, model: ChatGptModel, @@ -465,8 +462,6 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { } } -// --- Credential refresh --- - async fn get_fresh_credentials( state: &gpui::WeakEntity, http_client: &Arc, @@ -557,8 +552,6 @@ async fn get_fresh_credentials( .map_err(|e| LanguageModelCompletionError::Other(anyhow::anyhow!("{e}"))) } -// --- OAuth PKCE flow --- - #[derive(Deserialize)] struct TokenResponse { access_token: String, @@ -928,8 +921,6 @@ fn do_sign_out(state: &gpui::WeakEntity, cx: &mut App) { .detach(); } -// --- Configuration view --- - struct ConfigurationView { state: Entity, http_client: Arc, From b57ef838927b0a341319041f899ad97801f5a743 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 6 Apr 2026 15:24:45 -0400 Subject: [PATCH 25/36] Delegate model capabilities instead of hardcoding them 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. --- .../src/provider/openai_subscribed.rs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 134a3a84ee01dd..ea503c8a3f1f6b 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -308,6 +308,17 @@ impl ChatGptModel { _ => Some(ReasoningEffort::Medium), } } + + fn supports_parallel_tool_calls(&self) -> bool { + match self { + Self::Gpt54 | Self::Gpt54Mini => true, + _ => false, + } + } + + fn supports_prompt_cache_key(&self) -> bool { + true + } } struct OpenAiSubscribedLanguageModel { @@ -392,13 +403,12 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { let mut responses_request = into_open_ai_response( request, self.model.id(), - true, // supports_parallel_tool_calls - false, // supports_prompt_cache_key - None, // max_output_tokens — not supported by Codex backend + self.model.supports_parallel_tool_calls(), + self.model.supports_prompt_cache_key(), + self.max_output_tokens(), self.model.reasoning_effort(), ); responses_request.store = Some(false); - responses_request.max_output_tokens = None; // The Codex backend requires system messages to be in the top-level // `instructions` field rather than as input items. From 90dfa77fa324a809c8b0750c515b5d531dfb5c15 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 6 Apr 2026 15:33:37 -0400 Subject: [PATCH 26/36] Add tests for credential refresh deduplication 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 --- crates/language_models/Cargo.toml | 3 + .../src/provider/openai_subscribed.rs | 208 ++++++++++++++++++ 2 files changed, 211 insertions(+) diff --git a/crates/language_models/Cargo.toml b/crates/language_models/Cargo.toml index e349cf10045608..4b141205053efc 100644 --- a/crates/language_models/Cargo.toml +++ b/crates/language_models/Cargo.toml @@ -71,6 +71,9 @@ vercel = { workspace = true, features = ["schemars"] } x_ai = { workspace = true, features = ["schemars"] } [dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } +http_client = { workspace = true, features = ["test-support"] } language_model = { workspace = true, features = ["test-support"] } +parking_lot.workspace = true pretty_assertions.workspace = true diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index ea503c8a3f1f6b..53096696d6b9d6 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -981,3 +981,211 @@ impl Render for ConfigurationView { .into_any_element() } } + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + use http_client::FakeHttpClient; + use parking_lot::Mutex; + use std::future::Future; + use std::pin::Pin; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct FakeCredentialsProvider { + storage: Mutex)>>, + } + + impl FakeCredentialsProvider { + fn new() -> Self { + Self { + storage: Mutex::new(None), + } + } + } + + impl CredentialsProvider for FakeCredentialsProvider { + fn read_credentials<'a>( + &'a self, + _url: &'a str, + _cx: &'a AsyncApp, + ) -> Pin)>>> + 'a>> { + Box::pin(async { Ok(self.storage.lock().clone()) }) + } + + fn write_credentials<'a>( + &'a self, + _url: &'a str, + username: &'a str, + password: &'a [u8], + _cx: &'a AsyncApp, + ) -> Pin> + 'a>> { + self.storage + .lock() + .replace((username.to_string(), password.to_vec())); + Box::pin(async { Ok(()) }) + } + + fn delete_credentials<'a>( + &'a self, + _url: &'a str, + _cx: &'a AsyncApp, + ) -> Pin> + 'a>> { + *self.storage.lock() = None; + Box::pin(async { Ok(()) }) + } + } + + fn make_expired_credentials() -> CodexCredentials { + CodexCredentials { + access_token: "old_access".to_string(), + refresh_token: "old_refresh".to_string(), + expires_at_ms: 0, + account_id: None, + email: None, + } + } + + fn make_fresh_credentials() -> CodexCredentials { + CodexCredentials { + access_token: "fresh_access".to_string(), + refresh_token: "fresh_refresh".to_string(), + expires_at_ms: now_ms() + 3_600_000, + account_id: None, + email: None, + } + } + + fn fake_token_response() -> String { + serde_json::json!({ + "access_token": "fresh_access", + "refresh_token": "fresh_refresh", + "expires_in": 3600 + }) + .to_string() + } + + #[gpui::test] + async fn test_concurrent_refresh_deduplicates(cx: &mut TestAppContext) { + let refresh_count = Arc::new(AtomicUsize::new(0)); + let refresh_count_clone = refresh_count.clone(); + + let http_client = FakeHttpClient::create(move |_request| { + let refresh_count = refresh_count_clone.clone(); + async move { + refresh_count.fetch_add(1, Ordering::SeqCst); + let body = fake_token_response(); + Ok(http_client::Response::builder() + .status(200) + .body(http_client::AsyncBody::from(body))?) + } + }); + + let state = cx.new(|_cx| State { + credentials: Some(make_expired_credentials()), + sign_in_task: None, + refresh_task: None, + credentials_provider: Arc::new(FakeCredentialsProvider::new()), + }); + + let weak_state = cx.read(|_cx| state.downgrade()); + let http: Arc = http_client; + + // Spawn two concurrent refresh attempts. + let weak1 = weak_state.clone(); + let http1 = http.clone(); + let task1 = + cx.spawn(async move |mut cx| get_fresh_credentials(&weak1, &http1, &mut cx).await); + + let weak2 = weak_state.clone(); + let http2 = http.clone(); + let task2 = + cx.spawn(async move |mut cx| get_fresh_credentials(&weak2, &http2, &mut cx).await); + + // Drive both to completion. + cx.run_until_parked(); + let result1 = task1.await; + let result2 = task2.await; + + assert!(result1.is_ok(), "first refresh should succeed"); + assert!(result2.is_ok(), "second refresh should succeed"); + assert_eq!(result1.unwrap().access_token, "fresh_access"); + assert_eq!(result2.unwrap().access_token, "fresh_access"); + assert_eq!( + refresh_count.load(Ordering::SeqCst), + 1, + "refresh_token should only be called once despite two concurrent callers" + ); + } + + #[gpui::test] + async fn test_fresh_credentials_skip_refresh(cx: &mut TestAppContext) { + let refresh_count = Arc::new(AtomicUsize::new(0)); + let refresh_count_clone = refresh_count.clone(); + + let http_client = FakeHttpClient::create(move |_request| { + let refresh_count = refresh_count_clone.clone(); + async move { + refresh_count.fetch_add(1, Ordering::SeqCst); + let body = fake_token_response(); + Ok(http_client::Response::builder() + .status(200) + .body(http_client::AsyncBody::from(body))?) + } + }); + + let state = cx.new(|_cx| State { + credentials: Some(make_fresh_credentials()), + sign_in_task: None, + refresh_task: None, + credentials_provider: Arc::new(FakeCredentialsProvider::new()), + }); + + let weak_state = cx.read(|_cx| state.downgrade()); + let http: Arc = http_client; + + let weak = weak_state.clone(); + let http_clone = http.clone(); + let result = cx + .spawn(async move |mut cx| get_fresh_credentials(&weak, &http_clone, &mut cx).await) + .await; + + assert!(result.is_ok()); + assert_eq!(result.unwrap().access_token, "fresh_access"); + assert_eq!( + refresh_count.load(Ordering::SeqCst), + 0, + "no refresh should happen when credentials are fresh" + ); + } + + #[gpui::test] + async fn test_no_credentials_returns_no_api_key(cx: &mut TestAppContext) { + let http_client = FakeHttpClient::create(|_| async { + Ok(http_client::Response::builder() + .status(200) + .body(http_client::AsyncBody::default())?) + }); + + let state = cx.new(|_cx| State { + credentials: None, + sign_in_task: None, + refresh_task: None, + credentials_provider: Arc::new(FakeCredentialsProvider::new()), + }); + + let weak_state = cx.read(|_cx| state.downgrade()); + let http: Arc = http_client; + + let weak = weak_state.clone(); + let http_clone = http.clone(); + let result = cx + .spawn(async move |mut cx| get_fresh_credentials(&weak, &http_clone, &mut cx).await) + .await; + + assert!(matches!( + result, + Err(LanguageModelCompletionError::NoApiKey { .. }) + )); + } +} From 0d1f225f1f86805326bbf32db96558eaf02ae667 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 6 Apr 2026 15:48:14 -0400 Subject: [PATCH 27/36] Show "Sign Out" instead of "Reset Key" for ChatGPT subscription The default ConfiguredApiCard button label is "Reset Key" which makes sense for API key providers but not for an OAuth session. --- crates/language_models/src/provider/openai_subscribed.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 53096696d6b9d6..3f7a767723af41 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -949,11 +949,11 @@ impl Render for ConfigurationView { let weak_state = self.state.downgrade(); return v_flex() .child( - ConfiguredApiCard::new(SharedString::from(label)).on_click(cx.listener( - move |_this, _, _window, cx| { + ConfiguredApiCard::new(SharedString::from(label)) + .button_label("Sign Out") + .on_click(cx.listener(move |_this, _, _window, cx| { do_sign_out(&weak_state, cx); - }, - )), + })), ) .into_any_element(); } From 9aeff59da8f9b2b29135ee66f43652a0d8fc33b7 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 7 Apr 2026 00:21:49 -0400 Subject: [PATCH 28/36] Address code review: shared OAuth callback server, auth lifecycle fixes, 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 --- Cargo.lock | 3 +- crates/context_server/Cargo.toml | 1 - crates/context_server/src/oauth.rs | 169 +----- crates/http_client/Cargo.toml | 1 + crates/http_client/src/http_client.rs | 220 ++++++- .../src/provider/openai_subscribed.rs | 557 +++++++++++++----- 6 files changed, 663 insertions(+), 288 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 63abadce0ce0bb..a782de048787ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3454,7 +3454,6 @@ dependencies = [ "smol", "tempfile", "terminal", - "tiny_http", "url", "util", ] @@ -8217,6 +8216,7 @@ dependencies = [ "serde_urlencoded", "sha2", "tempfile", + "tiny_http", "url", "util", ] @@ -9388,6 +9388,7 @@ dependencies = [ "open_ai", "open_router", "opencode", + "parking_lot", "partial-json-fixer", "pretty_assertions", "rand 0.9.2", diff --git a/crates/context_server/Cargo.toml b/crates/context_server/Cargo.toml index 0a9c94a54d7019..15a4c3ab557b16 100644 --- a/crates/context_server/Cargo.toml +++ b/crates/context_server/Cargo.toml @@ -35,7 +35,6 @@ sha2.workspace = true slotmap.workspace = true smol.workspace = true tempfile.workspace = true -tiny_http.workspace = true url = { workspace = true, features = ["serde"] } util.workspace = true terminal.workspace = true diff --git a/crates/context_server/src/oauth.rs b/crates/context_server/src/oauth.rs index 44bcbbd557a67f..a21741d262ec7f 100644 --- a/crates/context_server/src/oauth.rs +++ b/crates/context_server/src/oauth.rs @@ -27,11 +27,9 @@ use rand::Rng as _; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use std::str::FromStr; use std::sync::Arc; use std::time::{Duration, SystemTime}; use url::Url; -use util::ResultExt as _; /// The CIMD URL where Zed's OAuth client metadata document is hosted. pub const CIMD_URL: &str = "https://zed.dev/oauth/client-metadata.json"; @@ -992,58 +990,14 @@ impl OAuthCallback { /// Parse the query string from a callback URL like /// `http://127.0.0.1:/callback?code=...&state=...`. pub fn parse_query(query: &str) -> Result { - let mut code: Option = None; - let mut state: Option = None; - let mut error: Option = None; - let mut error_description: Option = None; - - for (key, value) in url::form_urlencoded::parse(query.as_bytes()) { - match key.as_ref() { - "code" => { - if !value.is_empty() { - code = Some(value.into_owned()); - } - } - "state" => { - if !value.is_empty() { - state = Some(value.into_owned()); - } - } - "error" => { - if !value.is_empty() { - error = Some(value.into_owned()); - } - } - "error_description" => { - if !value.is_empty() { - error_description = Some(value.into_owned()); - } - } - _ => {} - } - } - - // Check for OAuth error response (RFC 6749 Section 4.1.2.1) before - // checking for missing code/state. - if let Some(error_code) = error { - bail!( - "OAuth authorization failed: {} ({})", - error_code, - error_description.as_deref().unwrap_or("no description") - ); - } - - let code = code.ok_or_else(|| anyhow!("missing 'code' parameter in OAuth callback"))?; - let state = state.ok_or_else(|| anyhow!("missing 'state' parameter in OAuth callback"))?; - - Ok(Self { code, state }) + let params = http_client::OAuthCallbackParams::parse_query(query)?; + Ok(Self { + code: params.code, + state: params.state, + }) } } -/// How long to wait for the browser to complete the OAuth flow before giving -/// up and releasing the loopback port. -const CALLBACK_TIMEOUT: Duration = Duration::from_secs(2 * 60); - /// Start a loopback HTTP server to receive the OAuth authorization callback. /// /// Binds to an ephemeral loopback port for each flow. @@ -1057,107 +1011,26 @@ const CALLBACK_TIMEOUT: Duration = Duration::from_secs(2 * 60); /// HTML page telling the user they can close the tab, and shuts down. /// /// The callback server shuts down when the returned oneshot receiver is dropped -/// (e.g. because the authentication task was cancelled), or after a timeout -/// ([CALLBACK_TIMEOUT]). +/// (e.g. because the authentication task was cancelled), or after a timeout. pub async fn start_callback_server() -> Result<( String, futures::channel::oneshot::Receiver>, )> { - let server = tiny_http::Server::http("127.0.0.1:0") - .map_err(|e| anyhow!(e).context("Failed to bind loopback listener for OAuth callback"))?; - let port = server - .server_addr() - .to_ip() - .context("server not bound to a TCP address")? - .port(); - - let redirect_uri = format!("http://127.0.0.1:{}/callback", port); - - let (tx, rx) = futures::channel::oneshot::channel(); - - // `tiny_http` is blocking, so we run it on a background thread. - // The `recv_timeout` loop lets us check for cancellation (the receiver - // being dropped) and enforce an overall timeout. - std::thread::spawn(move || { - let deadline = std::time::Instant::now() + CALLBACK_TIMEOUT; - - loop { - if tx.is_canceled() { - return; - } - let remaining = deadline.saturating_duration_since(std::time::Instant::now()); - if remaining.is_zero() { - return; - } - - let timeout = remaining.min(Duration::from_millis(500)); - let Some(request) = (match server.recv_timeout(timeout) { - Ok(req) => req, - Err(_) => { - let _ = tx.send(Err(anyhow!("OAuth callback server I/O error"))); - return; - } - }) else { - // Timeout with no request — loop back and check cancellation. - continue; - }; - - let result = handle_callback_request(&request); - - let (status_code, body) = match &result { - Ok(_) => ( - 200, - http_client::oauth_callback_page( - "Authorization Successful", - "You can close this tab and return to Zed.", - ), - ), - Err(err) => { - log::error!("OAuth callback error: {}", err); - ( - 400, - http_client::oauth_callback_page( - "Authorization Failed", - "Something went wrong. Please try again from Zed.", - ), - ) - } - }; - - let response = tiny_http::Response::from_string(body) - .with_status_code(status_code) - .with_header( - tiny_http::Header::from_str("Content-Type: text/html") - .expect("failed to construct response header"), - ) - .with_header( - tiny_http::Header::from_str("Keep-Alive: timeout=0,max=0") - .expect("failed to construct response header"), - ); - request.respond(response).log_err(); - - let _ = tx.send(result); - return; - } - }); - - Ok((redirect_uri, rx)) -} - -/// Extract the `code` and `state` query parameters from an OAuth callback -/// request to `/callback`. -fn handle_callback_request(request: &tiny_http::Request) -> Result { - let url = Url::parse(&format!("http://localhost{}", request.url())) - .context("malformed callback request URL")?; - - if url.path() != "/callback" { - bail!("unexpected path in OAuth callback: {}", url.path()); - } - - let query = url - .query() - .ok_or_else(|| anyhow!("OAuth callback has no query string"))?; - OAuthCallback::parse_query(query) + let (redirect_uri, rx) = http_client::start_oauth_callback_server()?; + let (tx, mapped_rx) = futures::channel::oneshot::channel(); + smol::spawn(async move { + let result = match rx.await { + Ok(Ok(params)) => Ok(OAuthCallback { + code: params.code, + state: params.state, + }), + Ok(Err(e)) => Err(e), + Err(_) => Err(anyhow!("OAuth callback channel was cancelled")), + }; + let _ = tx.send(result); + }) + .detach(); + Ok((redirect_uri, mapped_rx)) } // -- JSON fetch helper ------------------------------------------------------- diff --git a/crates/http_client/Cargo.toml b/crates/http_client/Cargo.toml index 6273d773d8c465..09bdadde0e5bc1 100644 --- a/crates/http_client/Cargo.toml +++ b/crates/http_client/Cargo.toml @@ -37,3 +37,4 @@ async-fs.workspace = true async-tar.workspace = true sha2.workspace = true tempfile.workspace = true +tiny_http.workspace = true diff --git a/crates/http_client/src/http_client.rs b/crates/http_client/src/http_client.rs index b1f097fdac6210..7681465575172c 100644 --- a/crates/http_client/src/http_client.rs +++ b/crates/http_client/src/http_client.rs @@ -291,12 +291,43 @@ impl HttpClient for HttpClientWithUrl { } } +fn html_escape(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + for ch in input.chars() { + match ch { + '&' => output.push_str("&"), + '<' => output.push_str("<"), + '>' => output.push_str(">"), + '"' => output.push_str("""), + '\'' => output.push_str("'"), + _ => output.push(ch), + } + } + output +} + /// Generate a styled HTML page for OAuth callback responses. /// /// Returns a complete HTML document (no HTTP headers) with a centered card /// layout styled to match Zed's dark theme. The `title` is rendered as a /// heading and `message` as body text below it. -pub fn oauth_callback_page(title: &str, message: &str) -> String { +/// +/// When `is_error` is true, a red X icon is shown instead of the green +/// checkmark. +pub fn oauth_callback_page(title: &str, message: &str, is_error: bool) -> String { + let title = html_escape(title); + let message = html_escape(message); + let (icon_bg, icon_svg) = if is_error { + ( + "#f38ba8", + r#""#, + ) + } else { + ( + "#a6e3a1", + r#""#, + ) + }; format!( r#" @@ -329,7 +360,7 @@ pub fn oauth_callback_page(title: &str, message: &str) -> String { width: 48px; height: 48px; margin: 0 auto 1.5rem; - background: #a6e3a1; + background: {icon_bg}; border-radius: 50%; display: flex; align-items: center; @@ -364,7 +395,7 @@ pub fn oauth_callback_page(title: &str, message: &str) -> String {
- + {icon_svg}

{title}

{message}

@@ -374,6 +405,8 @@ pub fn oauth_callback_page(title: &str, message: &str) -> String { "#, title = title, message = message, + icon_bg = icon_bg, + icon_svg = icon_svg, ) } @@ -529,3 +562,184 @@ impl HttpClient for FakeHttpClient { self } } + +// --------------------------------------------------------------------------- +// Shared OAuth callback server (non-wasm only) +// --------------------------------------------------------------------------- + +#[cfg(not(target_family = "wasm"))] +mod oauth_callback_server { + use super::*; + use anyhow::Context as _; + use std::str::FromStr; + use std::time::Duration; + + /// Parsed OAuth callback parameters from the authorization server redirect. + pub struct OAuthCallbackParams { + pub code: String, + pub state: String, + } + + impl OAuthCallbackParams { + /// Parse the query string from a callback URL like + /// `http://127.0.0.1:/callback?code=...&state=...`. + pub fn parse_query(query: &str) -> Result { + let mut code: Option = None; + let mut state: Option = None; + let mut error: Option = None; + let mut error_description: Option = None; + + for (key, value) in url::form_urlencoded::parse(query.as_bytes()) { + match key.as_ref() { + "code" => { + if !value.is_empty() { + code = Some(value.into_owned()); + } + } + "state" => { + if !value.is_empty() { + state = Some(value.into_owned()); + } + } + "error" => { + if !value.is_empty() { + error = Some(value.into_owned()); + } + } + "error_description" => { + if !value.is_empty() { + error_description = Some(value.into_owned()); + } + } + _ => {} + } + } + + if let Some(error_code) = error { + anyhow::bail!( + "OAuth authorization failed: {} ({})", + error_code, + error_description.as_deref().unwrap_or("no description") + ); + } + + let code = code.ok_or_else(|| anyhow!("missing 'code' parameter in OAuth callback"))?; + let state = + state.ok_or_else(|| anyhow!("missing 'state' parameter in OAuth callback"))?; + + Ok(Self { code, state }) + } + } + + /// How long to wait for the browser to complete the OAuth flow before giving + /// up and releasing the loopback port. + const OAUTH_CALLBACK_TIMEOUT: Duration = Duration::from_secs(2 * 60); + + /// Start a loopback HTTP server to receive the OAuth authorization callback. + /// + /// Binds to an ephemeral loopback port. Returns `(redirect_uri, callback_future)`. + /// The caller should use the redirect URI in the authorization request, open + /// the browser, then await the future to receive the callback. + pub fn start_oauth_callback_server() -> Result<( + String, + futures::channel::oneshot::Receiver>, + )> { + let server = tiny_http::Server::http("127.0.0.1:0").map_err(|e| { + anyhow!(e).context("Failed to bind loopback listener for OAuth callback") + })?; + let port = server + .server_addr() + .to_ip() + .ok_or_else(|| anyhow!("server not bound to a TCP address"))? + .port(); + + let redirect_uri = format!("http://127.0.0.1:{}/callback", port); + + let (tx, rx) = futures::channel::oneshot::channel(); + + std::thread::spawn(move || { + let deadline = std::time::Instant::now() + OAUTH_CALLBACK_TIMEOUT; + + loop { + if tx.is_canceled() { + return; + } + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + return; + } + + let timeout = remaining.min(Duration::from_millis(500)); + let Some(request) = (match server.recv_timeout(timeout) { + Ok(req) => req, + Err(_) => { + let _ = tx.send(Err(anyhow!("OAuth callback server I/O error"))); + return; + } + }) else { + continue; + }; + + let result = handle_oauth_callback_request(&request); + + let (status_code, body) = match &result { + Ok(_) => ( + 200, + oauth_callback_page( + "Authorization Successful", + "You can close this tab and return to Zed.", + false, + ), + ), + Err(err) => { + log::error!("OAuth callback error: {}", err); + ( + 400, + oauth_callback_page( + "Authorization Failed", + "Something went wrong. Please try again from Zed.", + true, + ), + ) + } + }; + + let response = tiny_http::Response::from_string(body) + .with_status_code(status_code) + .with_header( + tiny_http::Header::from_str("Content-Type: text/html") + .expect("failed to construct response header"), + ) + .with_header( + tiny_http::Header::from_str("Keep-Alive: timeout=0,max=0") + .expect("failed to construct response header"), + ); + if let Err(err) = request.respond(response) { + log::error!("Failed to send OAuth callback response: {}", err); + } + + let _ = tx.send(result); + return; + } + }); + + Ok((redirect_uri, rx)) + } + + fn handle_oauth_callback_request(request: &tiny_http::Request) -> Result { + let url = Url::parse(&format!("http://localhost{}", request.url())) + .context("malformed callback request URL")?; + + if url.path() != "/callback" { + anyhow::bail!("unexpected path in OAuth callback: {}", url.path()); + } + + let query = url + .query() + .ok_or_else(|| anyhow!("OAuth callback has no query string"))?; + OAuthCallbackParams::parse_query(query) + } +} + +#[cfg(not(target_family = "wasm"))] +pub use oauth_callback_server::{OAuthCallbackParams, start_oauth_callback_server}; diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 3f7a767723af41..bceb446bb600cc 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -2,7 +2,7 @@ use anyhow::{Context as _, Result, anyhow}; use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use credentials_provider::CredentialsProvider; -use futures::{FutureExt, StreamExt, future::BoxFuture, future::Either, future::Shared}; +use futures::{FutureExt, StreamExt, future::BoxFuture, future::Shared}; use gpui::{AnyView, App, AsyncApp, Context, Entity, SharedString, Task, Window}; use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest}; use language_model::{ @@ -15,9 +15,8 @@ use open_ai::{ReasoningEffort, responses::stream_response}; use rand::RngCore as _; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use smol::io::{AsyncReadExt as _, AsyncWriteExt as _}; use std::sync::Arc; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{SystemTime, UNIX_EPOCH}; use ui::{ConfiguredApiCard, prelude::*}; use url::form_urlencoded; use util::ResultExt as _; @@ -32,7 +31,7 @@ const CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api/codex"; const OPENAI_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; const OPENAI_AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize"; const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; -const REDIRECT_URI: &str = "http://localhost:1455/auth/callback"; + const CREDENTIALS_KEY: &str = "https://chatgpt.com/backend-api/codex"; const TOKEN_REFRESH_BUFFER_MS: u64 = 5 * 60 * 1000; @@ -56,7 +55,25 @@ pub struct State { credentials: Option, sign_in_task: Option>>, refresh_task: Option>>>>, + load_task: Option>>>>, credentials_provider: Arc, + auth_generation: u64, + last_auth_error: Option, +} + +#[derive(Debug)] +enum RefreshError { + Fatal(anyhow::Error), + Transient(anyhow::Error), +} + +impl std::fmt::Display for RefreshError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RefreshError::Fatal(e) => write!(f, "{e}"), + RefreshError::Transient(e) => write!(f, "{e}"), + } + } } impl State { @@ -88,7 +105,10 @@ impl OpenAiSubscribedProvider { credentials: None, sign_in_task: None, refresh_task: None, + load_task: None, credentials_provider, + auth_generation: 0, + last_auth_error: None, }); let provider = Self { http_client, state }; @@ -100,31 +120,38 @@ impl OpenAiSubscribedProvider { fn load_credentials(&self, cx: &mut App) { let state = self.state.downgrade(); - cx.spawn(async move |cx| { - let credentials_provider = - state.read_with(&*cx, |s, _| s.credentials_provider.clone())?; - let result = credentials_provider - .read_credentials(CREDENTIALS_KEY, &*cx) - .await; - state.update(cx, |s, cx| { - if let Ok(Some((_, bytes))) = result { - match serde_json::from_slice::(&bytes) { - Ok(creds) => s.credentials = Some(creds), - Err(err) => { - log::warn!( - "Failed to deserialize ChatGPT subscription credentials: {err}" - ); + let load_task = cx + .spawn(async move |cx| { + let credentials_provider = + state.read_with(&*cx, |s, _| s.credentials_provider.clone())?; + let result = credentials_provider + .read_credentials(CREDENTIALS_KEY, &*cx) + .await; + state.update(cx, |s, cx| { + if let Ok(Some((_, bytes))) = result { + match serde_json::from_slice::(&bytes) { + Ok(creds) => s.credentials = Some(creds), + Err(err) => { + log::warn!( + "Failed to deserialize ChatGPT subscription credentials: {err}" + ); + } } } - } - cx.notify(); + s.load_task = None; + cx.notify(); + })?; + Ok::<(), Arc>(()) }) - }) - .detach(); + .shared(); + + self.state.update(cx, |s, _| { + s.load_task = Some(load_task); + }); } - fn sign_out(&self, cx: &mut App) { - do_sign_out(&self.state.downgrade(), cx); + fn sign_out(&self, cx: &mut App) -> Task> { + do_sign_out(&self.state.downgrade(), cx) } fn create_language_model(&self, model: ChatGptModel) -> Arc { @@ -182,10 +209,29 @@ impl LanguageModelProvider for OpenAiSubscribedProvider { if self.is_authenticated(cx) { return Task::ready(Ok(())); } - Task::ready(Err(anyhow!( - "Sign in with your ChatGPT Plus or Pro subscription to use this provider." - ) - .into())) + let load_task = self.state.read(cx).load_task.clone(); + if let Some(load_task) = load_task { + let weak_state = self.state.downgrade(); + cx.spawn(async move |cx| { + let _ = load_task.await; + let is_auth = weak_state + .read_with(&*cx, |s, _| s.is_authenticated()) + .unwrap_or(false); + if is_auth { + Ok(()) + } else { + Err(anyhow!( + "Sign in with your ChatGPT Plus or Pro subscription to use this provider." + ) + .into()) + } + }) + } else { + Task::ready(Err(anyhow!( + "Sign in with your ChatGPT Plus or Pro subscription to use this provider." + ) + .into())) + } } fn configuration_view( @@ -201,8 +247,7 @@ impl LanguageModelProvider for OpenAiSubscribedProvider { } fn reset_credentials(&self, cx: &mut App) -> Task> { - self.sign_out(cx); - Task::ready(Ok(())) + self.sign_out(cx) } } @@ -380,10 +425,20 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { fn count_tokens( &self, - _request: LanguageModelRequest, - _cx: &App, + request: LanguageModelRequest, + cx: &App, ) -> BoxFuture<'static, Result> { - futures::future::ready(Ok(0)).boxed() + let max_token_count = self.model.max_token_count(); + cx.background_spawn(async move { + let messages = crate::provider::open_ai::collect_tiktoken_messages(request); + let model = if max_token_count >= 100_000 { + "gpt-4o" + } else { + "gpt-4" + }; + tiktoken_rs::num_tokens_from_messages(model, &messages).map(|tokens| tokens as u64) + }) + .boxed() } fn stream_completion( @@ -501,6 +556,11 @@ async fn get_fresh_credentials( let state_clone = state.clone(); let refresh_token_value = creds.refresh_token.clone(); + // Capture the generation so we can detect sign-outs that happened during refresh. + let generation = state + .read_with(&*cx, |s, _| s.auth_generation) + .map_err(LanguageModelCompletionError::Other)?; + let shared_task = cx .spawn(async move |cx| { let result = refresh_token(&http_client_clone, &refresh_token_value).await; @@ -508,6 +568,16 @@ async fn get_fresh_credentials( match result { Ok(refreshed) => { let persist_result: Result> = async { + // Check if auth_generation changed (sign-out during refresh). + let current_generation = state_clone + .read_with(&*cx, |s, _| s.auth_generation) + .map_err(|e| Arc::new(e))?; + if current_generation != generation { + return Err(Arc::new(anyhow!( + "Sign-out occurred during token refresh" + ))); + } + let credentials_provider = state_clone .read_with(&*cx, |s, _| s.credentials_provider.clone()) .map_err(|e| Arc::new(e))?; @@ -540,7 +610,28 @@ async fn get_fresh_credentials( persist_result } - Err(e) => { + Err(RefreshError::Fatal(e)) => { + log::error!("ChatGPT subscription token refresh failed fatally: {e:?}"); + let _ = state_clone.update(cx, |s, cx| { + s.refresh_task = None; + s.credentials = None; + s.last_auth_error = + Some("Your session has expired. Please sign in again.".into()); + cx.notify(); + }); + // Also clear the keychain so stale credentials aren't loaded next time. + if let Ok(credentials_provider) = + state_clone.read_with(&*cx, |s, _| s.credentials_provider.clone()) + { + credentials_provider + .delete_credentials(CREDENTIALS_KEY, &*cx) + .await + .log_err(); + } + Err(Arc::new(e)) + } + Err(RefreshError::Transient(e)) => { + log::warn!("ChatGPT subscription token refresh failed transiently: {e:?}"); let _ = state_clone.update(cx, |s, _| { s.refresh_task = None; }); @@ -577,6 +668,10 @@ async fn do_oauth_flow( http_client: Arc, cx: &AsyncApp, ) -> Result { + // Start the callback server FIRST so the redirect URI is ready + let (redirect_uri, callback_rx) = http_client::start_oauth_callback_server() + .context("Failed to start OAuth callback server")?; + // PKCE verifier: 32 random bytes → base64url (no padding) let mut verifier_bytes = [0u8; 32]; rand::rng().fill_bytes(&mut verifier_bytes); @@ -596,7 +691,7 @@ async fn do_oauth_flow( auth_url .query_pairs_mut() .append_pair("client_id", CLIENT_ID) - .append_pair("redirect_uri", REDIRECT_URI) + .append_pair("redirect_uri", &redirect_uri) .append_pair("scope", "openid profile email offline_access") .append_pair("response_type", "code") .append_pair("code_challenge", &challenge) @@ -605,13 +700,21 @@ async fn do_oauth_flow( .append_pair("codex_cli_simplified_flow", "true") .append_pair("originator", "zed"); + // Open browser AFTER the listener is ready cx.update(|cx| cx.open_url(auth_url.as_str())); - let code = await_oauth_callback(&oauth_state, cx) + // Await the callback + let callback = callback_rx .await + .map_err(|_| anyhow!("OAuth callback was cancelled"))? .context("OAuth callback failed")?; - let tokens = exchange_code(&http_client, &code, &verifier) + // Validate CSRF state + if callback.state != oauth_state { + return Err(anyhow!("OAuth state mismatch")); + } + + let tokens = exchange_code(&http_client, &callback.code, &verifier, &redirect_uri) .await .context("Token exchange failed")?; @@ -630,93 +733,17 @@ async fn do_oauth_flow( }) } -async fn await_oauth_callback(expected_state: &str, cx: &AsyncApp) -> Result { - let listener = smol::net::TcpListener::bind("127.0.0.1:1455") - .await - .context("Failed to bind to port 1455 for OAuth callback. Another application may be using this port.")?; - - let accept_future = listener.accept(); - let timeout_future = cx.background_executor().timer(Duration::from_secs(120)); - - let (mut stream, _) = match futures::future::select( - std::pin::pin!(accept_future), - std::pin::pin!(timeout_future), - ) - .await - { - Either::Left((result, _)) => result?, - Either::Right((_, _)) => { - return Err(anyhow!( - "OAuth sign-in timed out after 2 minutes. Please try again." - )); - } - }; - - let mut buffer = vec![0u8; 4096]; - let mut total = 0; - loop { - if total >= buffer.len() { - return Err(anyhow!("OAuth callback request too large")); - } - let n = stream.read(&mut buffer[total..]).await?; - if n == 0 { - break; - } - total += n; - if buffer[..total].windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - } - let request_text = std::str::from_utf8(&buffer[..total])?; - - // First line: "GET /auth/callback?code=...&state=... HTTP/1.1" - let path = request_text - .lines() - .next() - .and_then(|line| line.split_whitespace().nth(1)) - .ok_or_else(|| anyhow!("Invalid HTTP request from browser"))?; - - let query = path.split('?').nth(1).unwrap_or(""); - let mut code: Option = None; - let mut received_state: Option = None; - for (key, value) in form_urlencoded::parse(query.as_bytes()) { - match key.as_ref() { - "code" => code = Some(value.into_owned()), - "state" => received_state = Some(value.into_owned()), - _ => {} - } - } - - let page = http_client::oauth_callback_page( - "Signed In", - "You've signed into Zed via your ChatGPT subscription. You can close this tab and return to Zed.", - ); - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\n\r\n{}", - page.len(), - page - ); - stream.write_all(response.as_bytes()).await.log_err(); - - let received_state = - received_state.ok_or_else(|| anyhow!("Missing state in OAuth callback"))?; - if received_state != expected_state { - return Err(anyhow!("OAuth state mismatch")); - } - - code.ok_or_else(|| anyhow!("Missing authorization code in OAuth callback")) -} - async fn exchange_code( client: &Arc, code: &str, verifier: &str, + redirect_uri: &str, ) -> Result { let body = form_urlencoded::Serializer::new(String::new()) .append_pair("grant_type", "authorization_code") .append_pair("client_id", CLIENT_ID) .append_pair("code", code) - .append_pair("redirect_uri", REDIRECT_URI) + .append_pair("redirect_uri", redirect_uri) .append_pair("code_verifier", verifier) .finish(); @@ -743,7 +770,7 @@ async fn exchange_code( async fn refresh_token( client: &Arc, refresh_token: &str, -) -> Result { +) -> Result { let body = form_urlencoded::Serializer::new(String::new()) .append_pair("grant_type", "refresh_token") .append_pair("client_id", CLIENT_ID) @@ -754,20 +781,34 @@ async fn refresh_token( .method(Method::POST) .uri(OPENAI_TOKEN_URL) .header("Content-Type", "application/x-www-form-urlencoded") - .body(AsyncBody::from(body))?; + .body(AsyncBody::from(body)) + .map_err(|e| RefreshError::Transient(e.into()))?; - let mut response = client.send(request).await?; + let mut response = client + .send(request) + .await + .map_err(|e| RefreshError::Transient(e))?; + let status = response.status(); let mut body = String::new(); - smol::io::AsyncReadExt::read_to_string(response.body_mut(), &mut body).await?; - - if !response.status().is_success() { - return Err(anyhow!( - "Token refresh failed (HTTP {}): {body}", - response.status() - )); + smol::io::AsyncReadExt::read_to_string(response.body_mut(), &mut body) + .await + .map_err(|e| RefreshError::Transient(e.into()))?; + + if !status.is_success() { + let err = anyhow!("Token refresh failed (HTTP {}): {body}", status); + // 400/401/403 indicate a revoked or invalid refresh token. + // 5xx and other errors are treated as transient. + if status == http_client::StatusCode::BAD_REQUEST + || status == http_client::StatusCode::UNAUTHORIZED + || status == http_client::StatusCode::FORBIDDEN + { + return Err(RefreshError::Fatal(err)); + } + return Err(RefreshError::Transient(err)); } - let tokens: TokenResponse = serde_json::from_str(&body)?; + let tokens: TokenResponse = + serde_json::from_str(&body).map_err(|e| RefreshError::Transient(e.into()))?; let jwt = tokens .id_token .as_deref() @@ -876,6 +917,7 @@ fn do_sign_in(state: &Entity, http_client: &Arc, cx: &mut .update(cx, |s, cx| { s.credentials = Some(creds); s.sign_in_task = None; + s.last_auth_error = None; cx.notify(); }) .log_err(); @@ -887,6 +929,8 @@ fn do_sign_in(state: &Entity, http_client: &Arc, cx: &mut weak_state .update(cx, |s, cx| { s.sign_in_task = None; + s.last_auth_error = + Some("Failed to save credentials. Please try again.".into()); cx.notify(); }) .log_err(); @@ -898,6 +942,7 @@ fn do_sign_in(state: &Entity, http_client: &Arc, cx: &mut weak_state .update(cx, |s, cx| { s.sign_in_task = None; + s.last_auth_error = Some("Sign-in failed. Please try again.".into()); cx.notify(); }) .log_err(); @@ -907,28 +952,36 @@ fn do_sign_in(state: &Entity, http_client: &Arc, cx: &mut }); state.update(cx, |s, cx| { + s.last_auth_error = None; s.sign_in_task = Some(task); cx.notify(); }); } -fn do_sign_out(state: &gpui::WeakEntity, cx: &mut App) { +fn do_sign_out(state: &gpui::WeakEntity, cx: &mut App) -> Task> { let weak_state = state.clone(); + // Clear credentials and cancel in-flight work immediately so the UI + // reflects the sign-out right away. + weak_state + .update(cx, |s, cx| { + s.auth_generation += 1; + s.credentials = None; + s.sign_in_task = None; + s.refresh_task = None; + s.last_auth_error = None; + cx.notify(); + }) + .log_err(); + cx.spawn(async move |cx| { let credentials_provider = weak_state.read_with(&*cx, |s, _| s.credentials_provider.clone())?; credentials_provider .delete_credentials(CREDENTIALS_KEY, &*cx) .await - .log_err(); - weak_state.update(cx, |s, cx| { - s.credentials = None; - s.sign_in_task = None; - cx.notify(); - })?; + .context("Failed to delete ChatGPT subscription credentials from keychain")?; anyhow::Ok(()) }) - .detach(); } struct ConfigurationView { @@ -952,7 +1005,7 @@ impl Render for ConfigurationView { ConfiguredApiCard::new(SharedString::from(label)) .button_label("Sign Out") .on_click(cx.listener(move |_this, _, _window, cx| { - do_sign_out(&weak_state, cx); + do_sign_out(&weak_state, cx).detach_and_log_err(cx); })), ) .into_any_element(); @@ -964,11 +1017,15 @@ impl Render for ConfigurationView { .into_any_element(); } + let last_auth_error = state.last_auth_error.clone(); let provider_state = self.state.clone(); let http_client = self.http_client.clone(); v_flex() .gap_2() + .when_some(last_auth_error, |this, error| { + this.child(Label::new(error).color(Color::Error)) + }) .child(Label::new( "Sign in with your ChatGPT Plus or Pro subscription to use OpenAI models in Zed's agent.", )) @@ -1085,7 +1142,10 @@ mod tests { credentials: Some(make_expired_credentials()), sign_in_task: None, refresh_task: None, + load_task: None, credentials_provider: Arc::new(FakeCredentialsProvider::new()), + auth_generation: 0, + last_auth_error: None, }); let weak_state = cx.read(|_cx| state.downgrade()); @@ -1138,7 +1198,10 @@ mod tests { credentials: Some(make_fresh_credentials()), sign_in_task: None, refresh_task: None, + load_task: None, credentials_provider: Arc::new(FakeCredentialsProvider::new()), + auth_generation: 0, + last_auth_error: None, }); let weak_state = cx.read(|_cx| state.downgrade()); @@ -1171,7 +1234,10 @@ mod tests { credentials: None, sign_in_task: None, refresh_task: None, + load_task: None, credentials_provider: Arc::new(FakeCredentialsProvider::new()), + auth_generation: 0, + last_auth_error: None, }); let weak_state = cx.read(|_cx| state.downgrade()); @@ -1188,4 +1254,225 @@ mod tests { Err(LanguageModelCompletionError::NoApiKey { .. }) )); } + + #[gpui::test] + async fn test_fatal_refresh_clears_auth_state(cx: &mut TestAppContext) { + let http_client = FakeHttpClient::create(move |_request| async move { + Ok(http_client::Response::builder() + .status(401) + .body(http_client::AsyncBody::from(r#"{"error":"invalid_grant"}"#))?) + }); + + let creds_provider = Arc::new(FakeCredentialsProvider::new()); + let state = cx.new(|_cx| State { + credentials: Some(make_expired_credentials()), + sign_in_task: None, + refresh_task: None, + load_task: None, + credentials_provider: creds_provider.clone(), + auth_generation: 0, + last_auth_error: None, + }); + + let weak_state = cx.read(|_cx| state.downgrade()); + let http: Arc = http_client; + + let weak = weak_state.clone(); + let http_clone = http.clone(); + let result = cx + .spawn(async move |mut cx| get_fresh_credentials(&weak, &http_clone, &mut cx).await) + .await; + + cx.run_until_parked(); + + assert!(result.is_err(), "fatal refresh should return an error"); + cx.read(|cx| { + let s = state.read(cx); + assert!( + s.credentials.is_none(), + "credentials should be cleared on fatal refresh failure" + ); + assert!( + s.last_auth_error.is_some(), + "last_auth_error should be set on fatal refresh failure" + ); + }); + } + + #[gpui::test] + async fn test_transient_refresh_keeps_credentials(cx: &mut TestAppContext) { + let http_client = FakeHttpClient::create(move |_request| async move { + Ok(http_client::Response::builder() + .status(500) + .body(http_client::AsyncBody::from("Internal Server Error"))?) + }); + + let state = cx.new(|_cx| State { + credentials: Some(make_expired_credentials()), + sign_in_task: None, + refresh_task: None, + load_task: None, + credentials_provider: Arc::new(FakeCredentialsProvider::new()), + auth_generation: 0, + last_auth_error: None, + }); + + let weak_state = cx.read(|_cx| state.downgrade()); + let http: Arc = http_client; + + let weak = weak_state.clone(); + let http_clone = http.clone(); + let result = cx + .spawn(async move |mut cx| get_fresh_credentials(&weak, &http_clone, &mut cx).await) + .await; + + cx.run_until_parked(); + + assert!(result.is_err(), "transient refresh should return an error"); + cx.read(|cx| { + let s = state.read(cx); + assert!( + s.credentials.is_some(), + "credentials should be kept on transient refresh failure" + ); + assert!( + s.last_auth_error.is_none(), + "last_auth_error should not be set on transient refresh failure" + ); + }); + } + + #[gpui::test] + async fn test_sign_out_during_refresh_discards_result(cx: &mut TestAppContext) { + let (gate_tx, gate_rx) = futures::channel::oneshot::channel::<()>(); + let gate_rx = Arc::new(Mutex::new(Some(gate_rx))); + let gate_rx_clone = gate_rx.clone(); + + let http_client = FakeHttpClient::create(move |_request| { + let gate_rx = gate_rx_clone.clone(); + async move { + // Wait until the gate is opened, simulating a slow network. + let rx = gate_rx.lock().take(); + if let Some(rx) = rx { + let _ = rx.await; + } + let body = fake_token_response(); + Ok(http_client::Response::builder() + .status(200) + .body(http_client::AsyncBody::from(body))?) + } + }); + + let creds_provider = Arc::new(FakeCredentialsProvider::new()); + let state = cx.new(|_cx| State { + credentials: Some(make_expired_credentials()), + sign_in_task: None, + refresh_task: None, + load_task: None, + credentials_provider: creds_provider.clone(), + auth_generation: 0, + last_auth_error: None, + }); + + let weak_state = cx.read(|_cx| state.downgrade()); + let http: Arc = http_client; + + // Start a refresh + let weak = weak_state.clone(); + let http_clone = http.clone(); + let refresh_task = + cx.spawn(async move |mut cx| get_fresh_credentials(&weak, &http_clone, &mut cx).await); + + cx.run_until_parked(); + + // Sign out while the refresh is in-flight + cx.update(|cx| { + do_sign_out(&weak_state, cx).detach(); + }); + cx.run_until_parked(); + + // Now let the refresh respond by opening the gate + let _ = gate_tx.send(()); + cx.run_until_parked(); + + let result = refresh_task.await; + assert!(result.is_err(), "refresh should fail after sign-out"); + + cx.read(|cx| { + let s = state.read(cx); + assert!( + s.credentials.is_none(), + "sign-out should have cleared credentials" + ); + }); + } + + #[gpui::test] + async fn test_sign_out_completes_fully(cx: &mut TestAppContext) { + let creds_provider = Arc::new(FakeCredentialsProvider::new()); + // Pre-populate the credential store + creds_provider + .storage + .lock() + .replace(("Bearer".to_string(), b"some-creds".to_vec())); + + let state = cx.new(|_cx| State { + credentials: Some(make_fresh_credentials()), + sign_in_task: None, + refresh_task: None, + load_task: None, + credentials_provider: creds_provider.clone(), + auth_generation: 0, + last_auth_error: None, + }); + + let weak_state = cx.read(|_cx| state.downgrade()); + let sign_out_task = cx.update(|cx| do_sign_out(&weak_state, cx)); + + cx.run_until_parked(); + sign_out_task.await.expect("sign-out should succeed"); + + assert!( + creds_provider.storage.lock().is_none(), + "credential store should be empty after sign-out" + ); + cx.read(|cx| { + assert!( + !state.read(cx).is_authenticated(), + "state should show not authenticated" + ); + }); + } + + #[gpui::test] + async fn test_authenticate_awaits_initial_load(cx: &mut TestAppContext) { + let creds = make_fresh_credentials(); + let creds_json = serde_json::to_vec(&creds).unwrap(); + let creds_provider = Arc::new(FakeCredentialsProvider::new()); + creds_provider + .storage + .lock() + .replace(("Bearer".to_string(), creds_json)); + + let http_client = FakeHttpClient::create(|_| async { + Ok(http_client::Response::builder() + .status(200) + .body(http_client::AsyncBody::default())?) + }); + + let provider = + cx.update(|cx| OpenAiSubscribedProvider::new(http_client, creds_provider, cx)); + + // Before load completes, authenticate should still await the load. + let auth_task = cx.update(|cx| provider.authenticate(cx)); + + // Drive the load to completion. + cx.run_until_parked(); + + let result = auth_task.await; + assert!( + result.is_ok(), + "authenticate should succeed after load completes with valid credentials" + ); + } } From 87176d4825fe011faa8258a3de781e60cc16178f Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Thu, 14 May 2026 15:28:31 -0400 Subject: [PATCH 29/36] Fix ChatGPT subscription OAuth flow to match Codex backend auth.openai.com was rejecting authorize requests with a generic AuthApiFailure / unknown_error because the redirect_uri and authorize query parameters did not match what is registered for the Codex CLI OAuth client that openai_subscribed reuses (CLIENT_ID app_EMoamEEZ73f0CkXaXp7hrann). OAuth servers compare redirect_uri by exact string match per RFC 6749 section 3.1.2, so host, port, and path all must match the per-client allow-list. http_client: - Add OAuthCallbackServerConfig (host, preferred_port, optional fallback_port, path) and start_oauth_callback_server_with_config. - Keep the existing start_oauth_callback_server as a thin wrapper using defaults (127.0.0.1, ephemeral port, /callback) so the MCP OAuth flow in context_server is unaffected. - Handle inbound GET /cancel in the worker to allow a new sign-in to evict a previous listener still bound to the port. - On bind failure with AddrInUse, send GET /cancel to the existing listener, retry up to 10 times with 200 ms backoff, then fall back to the optional secondary port and repeat. This mirrors the Codex CLI's bind_server / send_cancel_request handshake. openai_subscribed: - Bind the OAuth callback on localhost:1455 with fallback 1457 and path /auth/callback (the Codex CLI's Hydra-registered redirect URIs). - Add the authorize query parameters the Codex CLI sends and the OAuth client expects: extend scope with api.connectors.read and api.connectors.invoke, and send id_token_add_organizations=true. - Keep originator=zed (rather than codex_cli_rs); OpenAI appears to treat originator as telemetry rather than as an authorization decision, so honest attribution works here. --- crates/http_client/src/http_client.rs | 206 +++++++++++++++++- .../src/provider/openai_subscribed.rs | 28 ++- 2 files changed, 223 insertions(+), 11 deletions(-) diff --git a/crates/http_client/src/http_client.rs b/crates/http_client/src/http_client.rs index 7681465575172c..bd78ef1070ea2d 100644 --- a/crates/http_client/src/http_client.rs +++ b/crates/http_client/src/http_client.rs @@ -580,6 +580,35 @@ mod oauth_callback_server { pub state: String, } + /// Configuration for the loopback OAuth callback server. + /// + /// OAuth servers compare `redirect_uri` against a per-client allow-list using + /// exact string matching (RFC 6749 §3.1.2), so the `host`, `preferred_port`, + /// and `path` here must match what's registered for the OAuth client_id. + #[derive(Clone, Copy)] + pub struct OAuthCallbackServerConfig { + /// Host portion of the redirect URI (typically `127.0.0.1` or `localhost`). + pub host: &'static str, + /// Preferred port. Use `0` for an OS-assigned ephemeral port. + pub preferred_port: u16, + /// Optional fallback port if `preferred_port` is unavailable. Only used + /// when `preferred_port` is non-zero. + pub fallback_port: Option, + /// Callback path on the redirect URI (e.g. `/callback`, `/auth/callback`). + pub path: &'static str, + } + + impl Default for OAuthCallbackServerConfig { + fn default() -> Self { + Self { + host: "127.0.0.1", + preferred_port: 0, + fallback_port: None, + path: "/callback", + } + } + } + impl OAuthCallbackParams { /// Parse the query string from a callback URL like /// `http://127.0.0.1:/callback?code=...&state=...`. @@ -644,16 +673,28 @@ mod oauth_callback_server { String, futures::channel::oneshot::Receiver>, )> { - let server = tiny_http::Server::http("127.0.0.1:0").map_err(|e| { - anyhow!(e).context("Failed to bind loopback listener for OAuth callback") - })?; + start_oauth_callback_server_with_config(OAuthCallbackServerConfig::default()) + } + + /// Start a loopback HTTP server with custom host/port/path. + /// + /// Use this when the OAuth client requires a specific redirect URI that the + /// default ephemeral-port `http://127.0.0.1:/callback` doesn't match. + pub fn start_oauth_callback_server_with_config( + config: OAuthCallbackServerConfig, + ) -> Result<( + String, + futures::channel::oneshot::Receiver>, + )> { + let server = bind_callback_server(&config)?; let port = server .server_addr() .to_ip() .ok_or_else(|| anyhow!("server not bound to a TCP address"))? .port(); - let redirect_uri = format!("http://127.0.0.1:{}/callback", port); + let redirect_uri = format!("http://{}:{}{}", config.host, port, config.path); + let expected_path = config.path; let (tx, rx) = futures::channel::oneshot::channel(); @@ -680,7 +721,29 @@ mod oauth_callback_server { continue; }; - let result = handle_oauth_callback_request(&request); + let raw_url = request.url().to_string(); + let raw_path = raw_url.split('?').next().unwrap_or(&raw_url); + if raw_path == CANCEL_PATH { + let response = tiny_http::Response::from_string("Cancelled") + .with_status_code(200) + .with_header( + tiny_http::Header::from_str("Content-Type: text/plain") + .expect("failed to construct response header"), + ) + .with_header( + tiny_http::Header::from_str("Connection: close") + .expect("failed to construct response header"), + ); + if let Err(err) = request.respond(response) { + log::error!("Failed to send OAuth cancel response: {}", err); + } + let _ = tx.send(Err(anyhow!( + "OAuth callback server was cancelled by another sign-in attempt" + ))); + return; + } + + let result = handle_oauth_callback_request(&request, expected_path); let (status_code, body) = match &result { Ok(_) => ( @@ -726,11 +789,14 @@ mod oauth_callback_server { Ok((redirect_uri, rx)) } - fn handle_oauth_callback_request(request: &tiny_http::Request) -> Result { + fn handle_oauth_callback_request( + request: &tiny_http::Request, + expected_path: &str, + ) -> Result { let url = Url::parse(&format!("http://localhost{}", request.url())) .context("malformed callback request URL")?; - if url.path() != "/callback" { + if url.path() != expected_path { anyhow::bail!("unexpected path in OAuth callback: {}", url.path()); } @@ -739,7 +805,131 @@ mod oauth_callback_server { .ok_or_else(|| anyhow!("OAuth callback has no query string"))?; OAuthCallbackParams::parse_query(query) } + + /// Callback path reserved for evicting a previously-running OAuth callback + /// server bound to the same port. Always handled, regardless of `config.path`. + const CANCEL_PATH: &str = "/cancel"; + + const BIND_MAX_ATTEMPTS: u32 = 10; + const BIND_RETRY_DELAY: Duration = Duration::from_millis(200); + const CANCEL_REQUEST_TIMEOUT: Duration = Duration::from_secs(2); + + fn bind_callback_server(config: &OAuthCallbackServerConfig) -> Result { + // Ephemeral ports always succeed; skip the cancel-retry dance entirely. + if config.preferred_port == 0 { + let addr = format!("{}:0", config.host); + return tiny_http::Server::http(&addr).map_err(|err| { + anyhow!(err).context(format!( + "Failed to bind loopback listener for OAuth callback on {addr}" + )) + }); + } + + match try_bind_with_cancel(config.host, config.preferred_port) { + Ok(server) => Ok(server), + Err(primary_err) => { + let Some(fallback_port) = config.fallback_port else { + return Err(primary_err.context(format!( + "Failed to bind loopback listener for OAuth callback on {}:{}", + config.host, config.preferred_port, + ))); + }; + log::warn!( + "OAuth callback port {}:{} unavailable; falling back to port {}", + config.host, + config.preferred_port, + fallback_port, + ); + try_bind_with_cancel(config.host, fallback_port).map_err(|fallback_err| { + fallback_err.context(format!( + "Failed to bind loopback listener for OAuth callback on {}:{} or {}:{}", + config.host, config.preferred_port, config.host, fallback_port, + )) + }) + } + } + } + + /// Attempts to bind to a fixed `host:port`. On `AddrInUse`, sends a single + /// `GET /cancel` to the existing listener (to evict a previous OAuth flow + /// from this or a compatible client) and retries. + fn try_bind_with_cancel(host: &'static str, port: u16) -> Result { + let addr = format!("{host}:{port}"); + let mut cancel_attempted = false; + let mut last_err: Option = None; + + for _ in 0..BIND_MAX_ATTEMPTS { + match tiny_http::Server::http(&addr) { + Ok(server) => return Ok(server), + Err(err) => { + let is_addr_in_use = err + .downcast_ref::() + .map(|io_err| io_err.kind() == std::io::ErrorKind::AddrInUse) + .unwrap_or(false); + + if !is_addr_in_use { + return Err(anyhow!(err).context(format!( + "Failed to bind loopback listener for OAuth callback on {addr}" + ))); + } + + if !cancel_attempted { + cancel_attempted = true; + if let Err(cancel_err) = send_cancel_request(host, port) { + log::warn!( + "Failed to cancel previous OAuth callback server on {addr}: {cancel_err}" + ); + } + } + + last_err = Some(anyhow!(err)); + std::thread::sleep(BIND_RETRY_DELAY); + } + } + } + + Err(last_err + .unwrap_or_else(|| anyhow!("unknown bind error")) + .context(format!( + "OAuth callback port {addr} remained in use after {BIND_MAX_ATTEMPTS} attempts" + ))) + } + + /// Sends `GET /cancel` to a listener on `host:port`, asking it to shut down. + /// + /// Best-effort: errors here are surfaced to the caller for logging but do + /// not block the subsequent rebind attempt. + fn send_cancel_request(host: &str, port: u16) -> std::io::Result<()> { + use std::io::{Read as _, Write as _}; + use std::net::{TcpStream, ToSocketAddrs as _}; + + let addr = format!("{host}:{port}") + .to_socket_addrs()? + .next() + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("could not resolve {host}:{port}"), + ) + })?; + let mut stream = TcpStream::connect_timeout(&addr, CANCEL_REQUEST_TIMEOUT)?; + stream.set_read_timeout(Some(CANCEL_REQUEST_TIMEOUT))?; + stream.set_write_timeout(Some(CANCEL_REQUEST_TIMEOUT))?; + + stream.write_all(b"GET /cancel HTTP/1.1\r\n")?; + stream.write_all(format!("Host: {host}:{port}\r\n").as_bytes())?; + stream.write_all(b"Connection: close\r\n\r\n")?; + + // Drain the response so the server can close cleanly. We don't care + // about the body; errors here are harmless. + let mut buf = [0u8; 64]; + let _ = stream.read(&mut buf); + Ok(()) + } } #[cfg(not(target_family = "wasm"))] -pub use oauth_callback_server::{OAuthCallbackParams, start_oauth_callback_server}; +pub use oauth_callback_server::{ + OAuthCallbackParams, OAuthCallbackServerConfig, start_oauth_callback_server, + start_oauth_callback_server_with_config, +}; diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 2d90185961ec6f..60df395776565c 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -651,13 +651,31 @@ struct TokenResponse { email: Option, } +// The OAuth client registered for `CLIENT_ID` (the Codex CLI's client) only allows +// `http://localhost:1455/auth/callback` and `http://localhost:1457/auth/callback` +// as redirect URIs; using anything else (different host, port, or path) causes +// auth.openai.com to reject the authorize request with a generic `unknown_error` +// before redirecting back. Keep these in sync with the Codex CLI's redirect URI +// allow-list (see codex-rs/login/src/server.rs in openai/codex). +const CODEX_CALLBACK_HOST: &str = "localhost"; +const CODEX_CALLBACK_PORT: u16 = 1455; +const CODEX_CALLBACK_FALLBACK_PORT: u16 = 1457; +const CODEX_CALLBACK_PATH: &str = "/auth/callback"; + async fn do_oauth_flow( http_client: Arc, cx: &AsyncApp, ) -> Result { // Start the callback server FIRST so the redirect URI is ready - let (redirect_uri, callback_rx) = http_client::start_oauth_callback_server() - .context("Failed to start OAuth callback server")?; + let (redirect_uri, callback_rx) = http_client::start_oauth_callback_server_with_config( + http_client::OAuthCallbackServerConfig { + host: CODEX_CALLBACK_HOST, + preferred_port: CODEX_CALLBACK_PORT, + fallback_port: Some(CODEX_CALLBACK_FALLBACK_PORT), + path: CODEX_CALLBACK_PATH, + }, + ) + .context("Failed to start OAuth callback server")?; // PKCE verifier: 32 random bytes → base64url (no padding) let mut verifier_bytes = [0u8; 32]; @@ -679,10 +697,14 @@ async fn do_oauth_flow( .query_pairs_mut() .append_pair("client_id", CLIENT_ID) .append_pair("redirect_uri", &redirect_uri) - .append_pair("scope", "openid profile email offline_access") + .append_pair( + "scope", + "openid profile email offline_access api.connectors.read api.connectors.invoke", + ) .append_pair("response_type", "code") .append_pair("code_challenge", &challenge) .append_pair("code_challenge_method", "S256") + .append_pair("id_token_add_organizations", "true") .append_pair("state", &oauth_state) .append_pair("codex_cli_simplified_flow", "true") .append_pair("originator", "zed"); From 9ed8afb0b1106d612c32289dcbb61712b9176c70 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Thu, 14 May 2026 15:29:01 -0400 Subject: [PATCH 30/36] Drop unsupported max_output_tokens from ChatGPT subscription requests The Codex backend at chatgpt.com/backend-api/codex/responses rejects `max_output_tokens` with `{"detail":"Unsupported parameter: max_output_tokens"}`, unlike the public OpenAI Responses API at api.openai.com/v1/responses which accepts it. The Codex CLI's build_responses_request in codex-rs/core/src/client.rs never sets that field on its ResponsesApiRequest for this reason. Pass `None` to into_open_ai_response so `ResponseRequest:: max_output_tokens` stays `None` and, because the field is annotated `#[serde(skip_serializing_if = "Option::is_none")]`, it is omitted from the JSON body entirely. The model's max_output_tokens metadata is preserved on the LanguageModel for token-counting and capability checks; only the on-the-wire field is suppressed. --- crates/language_models/src/provider/openai_subscribed.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 60df395776565c..27f47c1034cca7 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -441,12 +441,15 @@ impl LanguageModel for OpenAiSubscribedLanguageModel { LanguageModelCompletionError, >, > { + // The Codex backend rejects `max_output_tokens` (`Unsupported parameter`), + // unlike the public OpenAI Responses API. Pass `None` so the field is + // omitted from the serialized request body entirely. let mut responses_request = into_open_ai_response( request, self.model.id(), self.model.supports_parallel_tool_calls(), self.model.supports_prompt_cache_key(), - self.max_output_tokens(), + /*max_output_tokens*/ None, self.model.reasoning_effort(), self.model.supports_none_reasoning_effort(), ); From dd57d9609ed0ea94b7347754f13bfdc65af7eb4b Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Thu, 14 May 2026 15:30:07 -0400 Subject: [PATCH 31/36] Trim ChatGPT subscription model list to match Codex backend The hardcoded list contained nine slugs the Codex backend rejects with "The '' model is not supported when using Codex with a ChatGPT account": gpt-5, gpt-5-codex, gpt-5-codex-mini, gpt-5.1, gpt-5.1-codex, gpt-5.1-codex-max, gpt-5.1-codex-mini, gpt-5.2-codex, gpt-5.3-codex-spark. Replace with the five picker-visible models from OpenAI's bundled catalog (codex-rs/models-manager/models.json in openai/codex), which is what the Codex CLI ships as its authoritative fallback list when the dynamic /models endpoint isn't reachable: gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.3-codex, gpt-5.2. Per-model metadata is also corrected to match the bundled catalog: - Context window is 272K for every model. The Codex backend caps inputs at 272K even when the underlying model advertises a larger context window via the public Responses API (e.g. gpt-5.4 has max_context_window 1M). - All five models support parallel tool calls and image inputs. - Default reasoning effort is Medium for all five. - supports_none_reasoning_effort is now false everywhere: the bundled catalog's supported_reasoning_levels for every model is low/medium/high/xhigh, and sending `none` is rejected by the backend. Make gpt-5.5 the default model. Keep gpt-5.4-mini as the fast model (no gpt-5.5-mini exists yet; OpenAI's Codex docs recommend gpt-5.4-mini as the fast/cheap default alongside gpt-5.5). Add a TODO noting that even this corrected static list cannot represent per-account access gating (free accounts cannot use gpt-5.4 or gpt-5.3-codex even though paid accounts can); fully fixing that requires the dynamic /models fetch. --- .../src/provider/openai_subscribed.rs | 99 +++++++------------ 1 file changed, 36 insertions(+), 63 deletions(-) diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 27f47c1034cca7..9fd397c42eeb6c 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -187,10 +187,12 @@ impl LanguageModelProvider for OpenAiSubscribedProvider { } fn default_model(&self, _cx: &App) -> Option> { - Some(self.create_language_model(ChatGptModel::Gpt54)) + Some(self.create_language_model(ChatGptModel::Gpt55)) } fn default_fast_model(&self, _cx: &App) -> Option> { + // No GPT-5.5 Mini exists yet; per the OpenAI Codex docs, gpt-5.4-mini + // is the recommended fast/cheap default alongside gpt-5.5. Some(self.create_language_model(ChatGptModel::Gpt54Mini)) } @@ -255,114 +257,85 @@ impl LanguageModelProvider for OpenAiSubscribedProvider { // 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). - +// +// TODO: The Codex CLI fetches this list dynamically from +// `GET /models?client_version=...` (see +// codex-rs/codex-api/src/endpoint/models.rs in openai/codex) and falls back to +// a bundled models.json. Beyond going stale, the static approach also can't +// model per-account access (e.g. free accounts cannot use gpt-5.4 or +// gpt-5.3-codex even though paid accounts can), so the backend still rejects +// some requests. The bundled list at +// codex-rs/models-manager/models.json (openai/codex) is the closest +// approximation; the entries below mirror that file's picker-visible models. #[derive(Clone, Debug, PartialEq)] enum ChatGptModel { - Gpt5, - Gpt5Codex, - Gpt5CodexMini, - Gpt51, - Gpt51Codex, - Gpt51CodexMax, - Gpt51CodexMini, - Gpt52, - Gpt52Codex, - Gpt53Codex, - Gpt53CodexSpark, + Gpt55, Gpt54, Gpt54Mini, + Gpt53Codex, + Gpt52, } impl ChatGptModel { fn all() -> Vec { vec![ + Self::Gpt55, Self::Gpt54, Self::Gpt54Mini, Self::Gpt53Codex, - Self::Gpt53CodexSpark, - Self::Gpt52Codex, Self::Gpt52, - Self::Gpt51CodexMax, - Self::Gpt51Codex, - Self::Gpt51CodexMini, - Self::Gpt51, - Self::Gpt5Codex, - Self::Gpt5CodexMini, - Self::Gpt5, ] } fn id(&self) -> &str { match self { - Self::Gpt5 => "gpt-5", - Self::Gpt5Codex => "gpt-5-codex", - Self::Gpt5CodexMini => "gpt-5-codex-mini", - Self::Gpt51 => "gpt-5.1", - Self::Gpt51Codex => "gpt-5.1-codex", - Self::Gpt51CodexMax => "gpt-5.1-codex-max", - Self::Gpt51CodexMini => "gpt-5.1-codex-mini", - Self::Gpt52 => "gpt-5.2", - Self::Gpt52Codex => "gpt-5.2-codex", - Self::Gpt53Codex => "gpt-5.3-codex", - Self::Gpt53CodexSpark => "gpt-5.3-codex-spark", + Self::Gpt55 => "gpt-5.5", Self::Gpt54 => "gpt-5.4", Self::Gpt54Mini => "gpt-5.4-mini", + Self::Gpt53Codex => "gpt-5.3-codex", + Self::Gpt52 => "gpt-5.2", } } fn display_name(&self) -> &str { match self { - Self::Gpt5 => "GPT-5", - Self::Gpt5Codex => "GPT-5 Codex", - Self::Gpt5CodexMini => "GPT-5 Codex Mini", - Self::Gpt51 => "GPT-5.1", - Self::Gpt51Codex => "GPT-5.1 Codex", - Self::Gpt51CodexMax => "GPT-5.1 Codex Max", - Self::Gpt51CodexMini => "GPT-5.1 Codex Mini", - Self::Gpt52 => "GPT-5.2", - Self::Gpt52Codex => "GPT-5.2 Codex", - Self::Gpt53Codex => "GPT-5.3 Codex", - Self::Gpt53CodexSpark => "GPT-5.3 Codex Spark", + Self::Gpt55 => "GPT-5.5", Self::Gpt54 => "GPT-5.4", Self::Gpt54Mini => "GPT-5.4 Mini", + Self::Gpt53Codex => "GPT-5.3 Codex", + Self::Gpt52 => "GPT-5.2", } } fn max_token_count(&self) -> u64 { - match self { - Self::Gpt53CodexSpark => 128_000, - Self::Gpt54 | Self::Gpt54Mini => 1_050_000, - _ => 400_000, - } + // All Codex-supported models share the backend's 272K input cap, even + // when the raw model exposes a larger context window via the public + // API (e.g. gpt-5.4 has max_context_window 1M, but the Codex backend + // caps it at 272K). Source: openai/codex models-manager/models.json. + 272_000 } fn max_output_tokens(&self) -> Option { - match self { - Self::Gpt53CodexSpark => Some(8_192), - _ => Some(128_000), - } + Some(128_000) } fn supports_images(&self) -> bool { - !matches!(self, Self::Gpt53CodexSpark) + true } fn reasoning_effort(&self) -> Option { - match self { - Self::Gpt54 | Self::Gpt54Mini => None, - _ => Some(ReasoningEffort::Medium), - } + // Codex bundled models all default to Medium reasoning effort. + Some(ReasoningEffort::Medium) } fn supports_none_reasoning_effort(&self) -> bool { - matches!(self, Self::Gpt54 | Self::Gpt54Mini) + // The Codex backend's supported_reasoning_levels for every model in + // this list is low/medium/high/xhigh -- sending `none` is rejected. + false } fn supports_parallel_tool_calls(&self) -> bool { - match self { - Self::Gpt54 | Self::Gpt54Mini => true, - _ => false, - } + true } fn supports_prompt_cache_key(&self) -> bool { From 24703c9e683a4ff86c34811db2798611a6698302 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Thu, 14 May 2026 15:40:00 -0400 Subject: [PATCH 32/36] Drop smol::spawn from MCP OAuth callback wrapper The previous version spawned a detached smol task whose only job was to translate `http_client::OAuthCallbackParams` into `OAuthCallback` and forward through a second oneshot channel. That introduced a hardcoded dependency on smol's executor and added a Task that the project's executor abstraction couldn't see. There's no work to spawn here: the translation is a pure mapping over the result of the inner receiver. Return a `BoxFuture` whose poll implementation awaits the inner receiver and maps the result inline, so there is no executor dependency at all. Make `start_callback_server` non-async (its body never awaited anything) and update the single caller in context_server_store. The caller's separate map_err for the canceled case is no longer needed because the future now produces the same anyhow error itself. --- crates/context_server/src/oauth.rs | 29 +++++++++++----------- crates/project/src/context_server_store.rs | 8 ++---- 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/crates/context_server/src/oauth.rs b/crates/context_server/src/oauth.rs index d9513207289890..7b2ff3845d5309 100644 --- a/crates/context_server/src/oauth.rs +++ b/crates/context_server/src/oauth.rs @@ -20,7 +20,9 @@ use anyhow::{Context as _, Result, anyhow, bail}; use async_trait::async_trait; use base64::Engine as _; use futures::AsyncReadExt as _; +use futures::FutureExt as _; use futures::channel::mpsc; +use futures::future::BoxFuture; use http_client::{AsyncBody, HttpClient, Request}; use parking_lot::Mutex as SyncMutex; use rand::Rng as _; @@ -1010,27 +1012,24 @@ impl OAuthCallback { /// contains `code` and `state` query parameters, responds with a minimal /// HTML page telling the user they can close the tab, and shuts down. /// -/// The callback server shuts down when the returned oneshot receiver is dropped -/// (e.g. because the authentication task was cancelled), or after a timeout. -pub async fn start_callback_server() -> Result<( - String, - futures::channel::oneshot::Receiver>, -)> { +/// The callback server shuts down when the returned future is dropped (e.g. +/// because the authentication task was cancelled), or after a timeout. +pub fn start_callback_server() -> Result<(String, BoxFuture<'static, Result>)> { let (redirect_uri, rx) = http_client::start_oauth_callback_server()?; - let (tx, mapped_rx) = futures::channel::oneshot::channel(); - smol::spawn(async move { - let result = match rx.await { + let future = async move { + match rx.await { Ok(Ok(params)) => Ok(OAuthCallback { code: params.code, state: params.state, }), Ok(Err(e)) => Err(e), - Err(_) => Err(anyhow!("OAuth callback channel was cancelled")), - }; - let _ = tx.send(result); - }) - .detach(); - Ok((redirect_uri, mapped_rx)) + Err(_) => Err(anyhow!( + "OAuth callback server was shut down before receiving a response" + )), + } + } + .boxed(); + Ok((redirect_uri, future)) } // -- JSON fetch helper ------------------------------------------------------- diff --git a/crates/project/src/context_server_store.rs b/crates/project/src/context_server_store.rs index 9effe4a4638762..de2e1e3ceffdc1 100644 --- a/crates/project/src/context_server_store.rs +++ b/crates/project/src/context_server_store.rs @@ -1063,9 +1063,8 @@ impl ContextServerStore { // Start a loopback HTTP server on an ephemeral port. The redirect URI // includes this port so the browser sends the callback directly to our // process. - let (redirect_uri, callback_rx) = oauth::start_callback_server() - .await - .context("Failed to start OAuth callback server")?; + let (redirect_uri, callback_rx) = + oauth::start_callback_server().context("Failed to start OAuth callback server")?; let http_client = cx.update(|cx| cx.http_client()); let credentials_provider = cx.update(|cx| zed_credentials_provider::global(cx)); @@ -1093,9 +1092,6 @@ impl ContextServerStore { let callback = callback_rx .await - .map_err(|_| { - anyhow::anyhow!("OAuth callback server was shut down before receiving a response") - })? .context("OAuth callback server received an invalid request")?; if callback.state != state_param { From 24716081e09ee2339c663f2121099b7bcfee33dc Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Thu, 14 May 2026 15:48:51 -0400 Subject: [PATCH 33/36] Remove o4-mini from open_ai::Model (again) o4-mini was deliberately removed from the BYOK OpenAI provider in #49082 (0b8424a14c) on 2026-02-12 because OpenAI was retiring it (ChatGPT workspace support ended 2026-04-03). It was reintroduced as a `Model::O4Mini` variant in this branch's a603feafbd ("openai_subscribed: Show all OpenAI models in ChatGPT Subscription provider") because that refactor had the subscribed provider reuse `open_ai::Model` directly, and the Codex backend still accepted o4-mini for ChatGPT-subscription users at the time. That reuse is no longer in place: dd57d9609e ("Trim ChatGPT subscription model list to match Codex backend") replaced it with a dedicated `ChatGptModel` enum scoped to the five picker-visible models from OpenAI's bundled catalog (gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.3-codex, gpt-5.2). Nothing on this branch needs `O4Mini` anymore, and its presence re-pollutes the BYOK provider dropdown with a model the previous deprecation PR removed. Remove the variant and its arms in `crates/open_ai/src/open_ai.rs` (`from_id`, `id`, `display_name`, `max_token_count`, `max_output_tokens`, `reasoning_effort`, `supports_parallel_tool_calls`) and the corresponding `supports_images` arm in `crates/language_models/src/provider/open_ai.rs`. The `"o4-mini"` strings in `crates/migrator/src/migrator.rs` test fixtures stay: those test the migration path for users whose old settings referenced o4-mini, which is still the correct behavior even after the model itself is gone. --- crates/language_models/src/provider/open_ai.rs | 3 +-- crates/open_ai/src/open_ai.rs | 10 +--------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index 83d794f3e39a24..ddf8b0e688597a 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -430,8 +430,7 @@ impl LanguageModel for OpenAiLanguageModel { | Model::FivePointFourPro | Model::FivePointFive | Model::FivePointFivePro - | Model::O3 - | Model::O4Mini => true, + | Model::O3 => true, Model::Four => false, Model::Custom { supports_images, .. diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index 51afc9eeeed16e..b756e8a6122d48 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -64,8 +64,6 @@ pub enum Model { FourOmniMini, #[serde(rename = "o3")] O3, - #[serde(rename = "o4-mini")] - O4Mini, #[serde(rename = "gpt-5")] Five, #[serde(rename = "gpt-5-mini")] @@ -125,7 +123,6 @@ impl Model { "gpt-4" => Ok(Self::Four), "gpt-4o-mini" => Ok(Self::FourOmniMini), "o3" => Ok(Self::O3), - "o4-mini" => Ok(Self::O4Mini), "gpt-5" => Ok(Self::Five), "gpt-5-mini" => Ok(Self::FiveMini), "gpt-5-nano" => Ok(Self::FiveNano), @@ -147,7 +144,6 @@ impl Model { Self::Four => "gpt-4", Self::FourOmniMini => "gpt-4o-mini", Self::O3 => "o3", - Self::O4Mini => "o4-mini", Self::Five => "gpt-5", Self::FiveMini => "gpt-5-mini", Self::FiveNano => "gpt-5-nano", @@ -169,7 +165,6 @@ impl Model { Self::Four => "gpt-4", Self::FourOmniMini => "gpt-4o-mini", Self::O3 => "o3", - Self::O4Mini => "o4-mini", Self::Five => "gpt-5", Self::FiveMini => "gpt-5-mini", Self::FiveNano => "gpt-5-nano", @@ -191,7 +186,6 @@ impl Model { Self::Four => 8_192, Self::FourOmniMini => 128_000, Self::O3 => 200_000, - Self::O4Mini => 200_000, Self::Five => 272_000, Self::FiveMini => 400_000, Self::FiveNano => 400_000, @@ -216,7 +210,6 @@ impl Model { Self::Four => Some(8_192), Self::FourOmniMini => Some(16_384), Self::O3 => Some(100_000), - Self::O4Mini => Some(100_000), Self::Five => Some(128_000), Self::FiveMini => Some(128_000), Self::FiveNano => Some(128_000), @@ -243,7 +236,6 @@ impl Model { | Self::FivePointFourMini | Self::FivePointFourNano => Some(ReasoningEffort::None), Self::O3 - | Self::O4Mini | Self::Five | Self::FiveMini | Self::FiveNano @@ -340,7 +332,7 @@ impl Model { | Self::FivePointFive | Self::FivePointFivePro | Self::FiveNano => true, - Self::O3 | Self::O4Mini | Model::Custom { .. } => false, + Self::O3 | Model::Custom { .. } => false, } } From 831c244b60e9a38bf62149b9fb0ab8efb17dbf99 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Thu, 14 May 2026 16:00:48 -0400 Subject: [PATCH 34/36] Extract OAuth callback server into its own crate The shared OAuth callback infrastructure (loopback HTTP listener, `OAuthCallbackParams`/`OAuthCallbackServerConfig`, `start_oauth_callback_server[_with_config]`, and the styled HTML response page) was living in `http_client`, which is not really "HTTP client" responsibility -- the callback flow is a server, not a client, and the hardcoded HTML response page is purely OAuth-flow presentation. The tight coupling also forced `http_client` to depend on `tiny_http`, which is unrelated to its actual surface. Move the module to a new `oauth_callback_server` crate at `crates/oauth_callback_server/`. The crate exposes the same public API (renamed nothing) so consumers only need an import path change: - `http_client::OAuthCallbackParams` -> `oauth_callback_server::OAuthCallbackParams` - `http_client::OAuthCallbackServerConfig` -> `oauth_callback_server::OAuthCallbackServerConfig` - `http_client::start_oauth_callback_server` -> `oauth_callback_server::start_oauth_callback_server` - `http_client::start_oauth_callback_server_with_config` -> `oauth_callback_server::start_oauth_callback_server_with_config` - `http_client::oauth_callback_page` -> `oauth_callback_server::oauth_callback_page` Update the two consumers (`context_server` and `language_models`) to depend on the new crate, drop `tiny_http` from `http_client`'s non-wasm dependencies (it has no remaining uses there), and remove the moved code from `http_client.rs`. The non-wasm-only server module stays gated with `#[cfg(not(target_family = "wasm"))]` inside the new crate; the `oauth_callback_page` HTML helper is unconditional because it just returns a `String`. --- Cargo.lock | 14 +- Cargo.toml | 2 + crates/context_server/Cargo.toml | 1 + crates/context_server/src/oauth.rs | 4 +- crates/http_client/Cargo.toml | 1 - crates/http_client/src/http_client.rs | 489 ----------------- crates/language_models/Cargo.toml | 1 + .../src/provider/openai_subscribed.rs | 19 +- crates/oauth_callback_server/Cargo.toml | 23 + .../src/oauth_callback_server.rs | 493 ++++++++++++++++++ 10 files changed, 545 insertions(+), 502 deletions(-) create mode 100644 crates/oauth_callback_server/Cargo.toml create mode 100644 crates/oauth_callback_server/src/oauth_callback_server.rs diff --git a/Cargo.lock b/Cargo.lock index 6074b830458f53..1f6215293b6c8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3599,6 +3599,7 @@ dependencies = [ "http_client", "log", "net", + "oauth_callback_server", "parking_lot", "pollster 0.4.0", "postage", @@ -8464,7 +8465,6 @@ dependencies = [ "serde_urlencoded", "sha2", "tempfile", - "tiny_http", "url", "util", ] @@ -9686,6 +9686,7 @@ dependencies = [ "log", "menu", "mistral", + "oauth_callback_server", "ollama", "open_ai", "open_router", @@ -11517,6 +11518,17 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "oauth_callback_server" +version = "0.1.0" +dependencies = [ + "anyhow", + "futures 0.3.32", + "log", + "tiny_http", + "url", +] + [[package]] name = "objc" version = "0.2.7" diff --git a/Cargo.toml b/Cargo.toml index 5544fa1cc1757d..07c94aa33ee2c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -140,6 +140,7 @@ members = [ "crates/net", "crates/node_runtime", "crates/notifications", + "crates/oauth_callback_server", "crates/ollama", "crates/onboarding", "crates/opencode", @@ -399,6 +400,7 @@ nc = { path = "crates/nc" } net = { path = "crates/net" } node_runtime = { path = "crates/node_runtime" } notifications = { path = "crates/notifications" } +oauth_callback_server = { path = "crates/oauth_callback_server" } ollama = { path = "crates/ollama" } onboarding = { path = "crates/onboarding" } opencode = { path = "crates/opencode" } diff --git a/crates/context_server/Cargo.toml b/crates/context_server/Cargo.toml index 0dd68bdd150d62..8986a07dd95b58 100644 --- a/crates/context_server/Cargo.toml +++ b/crates/context_server/Cargo.toml @@ -26,6 +26,7 @@ gpui.workspace = true http_client = { workspace = true, features = ["test-support"] } log.workspace = true net.workspace = true +oauth_callback_server.workspace = true parking_lot.workspace = true rand.workspace = true postage.workspace = true diff --git a/crates/context_server/src/oauth.rs b/crates/context_server/src/oauth.rs index 7b2ff3845d5309..c5afa28c9b8649 100644 --- a/crates/context_server/src/oauth.rs +++ b/crates/context_server/src/oauth.rs @@ -992,7 +992,7 @@ impl OAuthCallback { /// Parse the query string from a callback URL like /// `http://127.0.0.1:/callback?code=...&state=...`. pub fn parse_query(query: &str) -> Result { - let params = http_client::OAuthCallbackParams::parse_query(query)?; + let params = oauth_callback_server::OAuthCallbackParams::parse_query(query)?; Ok(Self { code: params.code, state: params.state, @@ -1015,7 +1015,7 @@ impl OAuthCallback { /// The callback server shuts down when the returned future is dropped (e.g. /// because the authentication task was cancelled), or after a timeout. pub fn start_callback_server() -> Result<(String, BoxFuture<'static, Result>)> { - let (redirect_uri, rx) = http_client::start_oauth_callback_server()?; + let (redirect_uri, rx) = oauth_callback_server::start_oauth_callback_server()?; let future = async move { match rx.await { Ok(Ok(params)) => Ok(OAuthCallback { diff --git a/crates/http_client/Cargo.toml b/crates/http_client/Cargo.toml index 09bdadde0e5bc1..6273d773d8c465 100644 --- a/crates/http_client/Cargo.toml +++ b/crates/http_client/Cargo.toml @@ -37,4 +37,3 @@ async-fs.workspace = true async-tar.workspace = true sha2.workspace = true tempfile.workspace = true -tiny_http.workspace = true diff --git a/crates/http_client/src/http_client.rs b/crates/http_client/src/http_client.rs index bd78ef1070ea2d..b30a7022e5d478 100644 --- a/crates/http_client/src/http_client.rs +++ b/crates/http_client/src/http_client.rs @@ -291,125 +291,6 @@ impl HttpClient for HttpClientWithUrl { } } -fn html_escape(input: &str) -> String { - let mut output = String::with_capacity(input.len()); - for ch in input.chars() { - match ch { - '&' => output.push_str("&"), - '<' => output.push_str("<"), - '>' => output.push_str(">"), - '"' => output.push_str("""), - '\'' => output.push_str("'"), - _ => output.push(ch), - } - } - output -} - -/// Generate a styled HTML page for OAuth callback responses. -/// -/// Returns a complete HTML document (no HTTP headers) with a centered card -/// layout styled to match Zed's dark theme. The `title` is rendered as a -/// heading and `message` as body text below it. -/// -/// When `is_error` is true, a red X icon is shown instead of the green -/// checkmark. -pub fn oauth_callback_page(title: &str, message: &str, is_error: bool) -> String { - let title = html_escape(title); - let message = html_escape(message); - let (icon_bg, icon_svg) = if is_error { - ( - "#f38ba8", - r#""#, - ) - } else { - ( - "#a6e3a1", - r#""#, - ) - }; - format!( - r#" - - - - -{title} — Zed - - - -
-
- {icon_svg} -
-

{title}

-

{message}

-
Zed
-
- -"#, - title = title, - message = message, - icon_bg = icon_bg, - icon_svg = icon_svg, - ) -} - pub fn read_proxy_from_env() -> Option { const ENV_VARS: &[&str] = &[ "ALL_PROXY", @@ -563,373 +444,3 @@ impl HttpClient for FakeHttpClient { } } -// --------------------------------------------------------------------------- -// Shared OAuth callback server (non-wasm only) -// --------------------------------------------------------------------------- - -#[cfg(not(target_family = "wasm"))] -mod oauth_callback_server { - use super::*; - use anyhow::Context as _; - use std::str::FromStr; - use std::time::Duration; - - /// Parsed OAuth callback parameters from the authorization server redirect. - pub struct OAuthCallbackParams { - pub code: String, - pub state: String, - } - - /// Configuration for the loopback OAuth callback server. - /// - /// OAuth servers compare `redirect_uri` against a per-client allow-list using - /// exact string matching (RFC 6749 §3.1.2), so the `host`, `preferred_port`, - /// and `path` here must match what's registered for the OAuth client_id. - #[derive(Clone, Copy)] - pub struct OAuthCallbackServerConfig { - /// Host portion of the redirect URI (typically `127.0.0.1` or `localhost`). - pub host: &'static str, - /// Preferred port. Use `0` for an OS-assigned ephemeral port. - pub preferred_port: u16, - /// Optional fallback port if `preferred_port` is unavailable. Only used - /// when `preferred_port` is non-zero. - pub fallback_port: Option, - /// Callback path on the redirect URI (e.g. `/callback`, `/auth/callback`). - pub path: &'static str, - } - - impl Default for OAuthCallbackServerConfig { - fn default() -> Self { - Self { - host: "127.0.0.1", - preferred_port: 0, - fallback_port: None, - path: "/callback", - } - } - } - - impl OAuthCallbackParams { - /// Parse the query string from a callback URL like - /// `http://127.0.0.1:/callback?code=...&state=...`. - pub fn parse_query(query: &str) -> Result { - let mut code: Option = None; - let mut state: Option = None; - let mut error: Option = None; - let mut error_description: Option = None; - - for (key, value) in url::form_urlencoded::parse(query.as_bytes()) { - match key.as_ref() { - "code" => { - if !value.is_empty() { - code = Some(value.into_owned()); - } - } - "state" => { - if !value.is_empty() { - state = Some(value.into_owned()); - } - } - "error" => { - if !value.is_empty() { - error = Some(value.into_owned()); - } - } - "error_description" => { - if !value.is_empty() { - error_description = Some(value.into_owned()); - } - } - _ => {} - } - } - - if let Some(error_code) = error { - anyhow::bail!( - "OAuth authorization failed: {} ({})", - error_code, - error_description.as_deref().unwrap_or("no description") - ); - } - - let code = code.ok_or_else(|| anyhow!("missing 'code' parameter in OAuth callback"))?; - let state = - state.ok_or_else(|| anyhow!("missing 'state' parameter in OAuth callback"))?; - - Ok(Self { code, state }) - } - } - - /// How long to wait for the browser to complete the OAuth flow before giving - /// up and releasing the loopback port. - const OAUTH_CALLBACK_TIMEOUT: Duration = Duration::from_secs(2 * 60); - - /// Start a loopback HTTP server to receive the OAuth authorization callback. - /// - /// Binds to an ephemeral loopback port. Returns `(redirect_uri, callback_future)`. - /// The caller should use the redirect URI in the authorization request, open - /// the browser, then await the future to receive the callback. - pub fn start_oauth_callback_server() -> Result<( - String, - futures::channel::oneshot::Receiver>, - )> { - start_oauth_callback_server_with_config(OAuthCallbackServerConfig::default()) - } - - /// Start a loopback HTTP server with custom host/port/path. - /// - /// Use this when the OAuth client requires a specific redirect URI that the - /// default ephemeral-port `http://127.0.0.1:/callback` doesn't match. - pub fn start_oauth_callback_server_with_config( - config: OAuthCallbackServerConfig, - ) -> Result<( - String, - futures::channel::oneshot::Receiver>, - )> { - let server = bind_callback_server(&config)?; - let port = server - .server_addr() - .to_ip() - .ok_or_else(|| anyhow!("server not bound to a TCP address"))? - .port(); - - let redirect_uri = format!("http://{}:{}{}", config.host, port, config.path); - let expected_path = config.path; - - let (tx, rx) = futures::channel::oneshot::channel(); - - std::thread::spawn(move || { - let deadline = std::time::Instant::now() + OAUTH_CALLBACK_TIMEOUT; - - loop { - if tx.is_canceled() { - return; - } - let remaining = deadline.saturating_duration_since(std::time::Instant::now()); - if remaining.is_zero() { - return; - } - - let timeout = remaining.min(Duration::from_millis(500)); - let Some(request) = (match server.recv_timeout(timeout) { - Ok(req) => req, - Err(_) => { - let _ = tx.send(Err(anyhow!("OAuth callback server I/O error"))); - return; - } - }) else { - continue; - }; - - let raw_url = request.url().to_string(); - let raw_path = raw_url.split('?').next().unwrap_or(&raw_url); - if raw_path == CANCEL_PATH { - let response = tiny_http::Response::from_string("Cancelled") - .with_status_code(200) - .with_header( - tiny_http::Header::from_str("Content-Type: text/plain") - .expect("failed to construct response header"), - ) - .with_header( - tiny_http::Header::from_str("Connection: close") - .expect("failed to construct response header"), - ); - if let Err(err) = request.respond(response) { - log::error!("Failed to send OAuth cancel response: {}", err); - } - let _ = tx.send(Err(anyhow!( - "OAuth callback server was cancelled by another sign-in attempt" - ))); - return; - } - - let result = handle_oauth_callback_request(&request, expected_path); - - let (status_code, body) = match &result { - Ok(_) => ( - 200, - oauth_callback_page( - "Authorization Successful", - "You can close this tab and return to Zed.", - false, - ), - ), - Err(err) => { - log::error!("OAuth callback error: {}", err); - ( - 400, - oauth_callback_page( - "Authorization Failed", - "Something went wrong. Please try again from Zed.", - true, - ), - ) - } - }; - - let response = tiny_http::Response::from_string(body) - .with_status_code(status_code) - .with_header( - tiny_http::Header::from_str("Content-Type: text/html") - .expect("failed to construct response header"), - ) - .with_header( - tiny_http::Header::from_str("Keep-Alive: timeout=0,max=0") - .expect("failed to construct response header"), - ); - if let Err(err) = request.respond(response) { - log::error!("Failed to send OAuth callback response: {}", err); - } - - let _ = tx.send(result); - return; - } - }); - - Ok((redirect_uri, rx)) - } - - fn handle_oauth_callback_request( - request: &tiny_http::Request, - expected_path: &str, - ) -> Result { - let url = Url::parse(&format!("http://localhost{}", request.url())) - .context("malformed callback request URL")?; - - if url.path() != expected_path { - anyhow::bail!("unexpected path in OAuth callback: {}", url.path()); - } - - let query = url - .query() - .ok_or_else(|| anyhow!("OAuth callback has no query string"))?; - OAuthCallbackParams::parse_query(query) - } - - /// Callback path reserved for evicting a previously-running OAuth callback - /// server bound to the same port. Always handled, regardless of `config.path`. - const CANCEL_PATH: &str = "/cancel"; - - const BIND_MAX_ATTEMPTS: u32 = 10; - const BIND_RETRY_DELAY: Duration = Duration::from_millis(200); - const CANCEL_REQUEST_TIMEOUT: Duration = Duration::from_secs(2); - - fn bind_callback_server(config: &OAuthCallbackServerConfig) -> Result { - // Ephemeral ports always succeed; skip the cancel-retry dance entirely. - if config.preferred_port == 0 { - let addr = format!("{}:0", config.host); - return tiny_http::Server::http(&addr).map_err(|err| { - anyhow!(err).context(format!( - "Failed to bind loopback listener for OAuth callback on {addr}" - )) - }); - } - - match try_bind_with_cancel(config.host, config.preferred_port) { - Ok(server) => Ok(server), - Err(primary_err) => { - let Some(fallback_port) = config.fallback_port else { - return Err(primary_err.context(format!( - "Failed to bind loopback listener for OAuth callback on {}:{}", - config.host, config.preferred_port, - ))); - }; - log::warn!( - "OAuth callback port {}:{} unavailable; falling back to port {}", - config.host, - config.preferred_port, - fallback_port, - ); - try_bind_with_cancel(config.host, fallback_port).map_err(|fallback_err| { - fallback_err.context(format!( - "Failed to bind loopback listener for OAuth callback on {}:{} or {}:{}", - config.host, config.preferred_port, config.host, fallback_port, - )) - }) - } - } - } - - /// Attempts to bind to a fixed `host:port`. On `AddrInUse`, sends a single - /// `GET /cancel` to the existing listener (to evict a previous OAuth flow - /// from this or a compatible client) and retries. - fn try_bind_with_cancel(host: &'static str, port: u16) -> Result { - let addr = format!("{host}:{port}"); - let mut cancel_attempted = false; - let mut last_err: Option = None; - - for _ in 0..BIND_MAX_ATTEMPTS { - match tiny_http::Server::http(&addr) { - Ok(server) => return Ok(server), - Err(err) => { - let is_addr_in_use = err - .downcast_ref::() - .map(|io_err| io_err.kind() == std::io::ErrorKind::AddrInUse) - .unwrap_or(false); - - if !is_addr_in_use { - return Err(anyhow!(err).context(format!( - "Failed to bind loopback listener for OAuth callback on {addr}" - ))); - } - - if !cancel_attempted { - cancel_attempted = true; - if let Err(cancel_err) = send_cancel_request(host, port) { - log::warn!( - "Failed to cancel previous OAuth callback server on {addr}: {cancel_err}" - ); - } - } - - last_err = Some(anyhow!(err)); - std::thread::sleep(BIND_RETRY_DELAY); - } - } - } - - Err(last_err - .unwrap_or_else(|| anyhow!("unknown bind error")) - .context(format!( - "OAuth callback port {addr} remained in use after {BIND_MAX_ATTEMPTS} attempts" - ))) - } - - /// Sends `GET /cancel` to a listener on `host:port`, asking it to shut down. - /// - /// Best-effort: errors here are surfaced to the caller for logging but do - /// not block the subsequent rebind attempt. - fn send_cancel_request(host: &str, port: u16) -> std::io::Result<()> { - use std::io::{Read as _, Write as _}; - use std::net::{TcpStream, ToSocketAddrs as _}; - - let addr = format!("{host}:{port}") - .to_socket_addrs()? - .next() - .ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("could not resolve {host}:{port}"), - ) - })?; - let mut stream = TcpStream::connect_timeout(&addr, CANCEL_REQUEST_TIMEOUT)?; - stream.set_read_timeout(Some(CANCEL_REQUEST_TIMEOUT))?; - stream.set_write_timeout(Some(CANCEL_REQUEST_TIMEOUT))?; - - stream.write_all(b"GET /cancel HTTP/1.1\r\n")?; - stream.write_all(format!("Host: {host}:{port}\r\n").as_bytes())?; - stream.write_all(b"Connection: close\r\n\r\n")?; - - // Drain the response so the server can close cleanly. We don't care - // about the body; errors here are harmless. - let mut buf = [0u8; 64]; - let _ = stream.read(&mut buf); - Ok(()) - } -} - -#[cfg(not(target_family = "wasm"))] -pub use oauth_callback_server::{ - OAuthCallbackParams, OAuthCallbackServerConfig, start_oauth_callback_server, - start_oauth_callback_server_with_config, -}; diff --git a/crates/language_models/Cargo.toml b/crates/language_models/Cargo.toml index b2287398033264..7e2d2618ea9695 100644 --- a/crates/language_models/Cargo.toml +++ b/crates/language_models/Cargo.toml @@ -47,6 +47,7 @@ lmstudio = { workspace = true, features = ["schemars"] } log.workspace = true menu.workspace = true mistral = { workspace = true, features = ["schemars"] } +oauth_callback_server.workspace = true ollama = { workspace = true, features = ["schemars"] } open_ai = { workspace = true, features = ["schemars"] } opencode = { workspace = true, features = ["schemars"] } diff --git a/crates/language_models/src/provider/openai_subscribed.rs b/crates/language_models/src/provider/openai_subscribed.rs index 9fd397c42eeb6c..822db3b1f7ddc6 100644 --- a/crates/language_models/src/provider/openai_subscribed.rs +++ b/crates/language_models/src/provider/openai_subscribed.rs @@ -643,15 +643,16 @@ async fn do_oauth_flow( cx: &AsyncApp, ) -> Result { // Start the callback server FIRST so the redirect URI is ready - let (redirect_uri, callback_rx) = http_client::start_oauth_callback_server_with_config( - http_client::OAuthCallbackServerConfig { - host: CODEX_CALLBACK_HOST, - preferred_port: CODEX_CALLBACK_PORT, - fallback_port: Some(CODEX_CALLBACK_FALLBACK_PORT), - path: CODEX_CALLBACK_PATH, - }, - ) - .context("Failed to start OAuth callback server")?; + let (redirect_uri, callback_rx) = + oauth_callback_server::start_oauth_callback_server_with_config( + oauth_callback_server::OAuthCallbackServerConfig { + host: CODEX_CALLBACK_HOST, + preferred_port: CODEX_CALLBACK_PORT, + fallback_port: Some(CODEX_CALLBACK_FALLBACK_PORT), + path: CODEX_CALLBACK_PATH, + }, + ) + .context("Failed to start OAuth callback server")?; // PKCE verifier: 32 random bytes → base64url (no padding) let mut verifier_bytes = [0u8; 32]; diff --git a/crates/oauth_callback_server/Cargo.toml b/crates/oauth_callback_server/Cargo.toml new file mode 100644 index 00000000000000..5fcc6233af56d5 --- /dev/null +++ b/crates/oauth_callback_server/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "oauth_callback_server" +version = "0.1.0" +edition.workspace = true +publish = false +license = "Apache-2.0" +description = "Loopback OAuth 2.0 callback server and shared HTML response page for Zed sign-in flows" + +[lints] +workspace = true + +[lib] +path = "src/oauth_callback_server.rs" +doctest = false + +[dependencies] +anyhow.workspace = true + +[target.'cfg(not(target_family = "wasm"))'.dependencies] +futures.workspace = true +log.workspace = true +tiny_http.workspace = true +url.workspace = true diff --git a/crates/oauth_callback_server/src/oauth_callback_server.rs b/crates/oauth_callback_server/src/oauth_callback_server.rs new file mode 100644 index 00000000000000..44ea22947347ef --- /dev/null +++ b/crates/oauth_callback_server/src/oauth_callback_server.rs @@ -0,0 +1,493 @@ +//! Loopback OAuth 2.0 callback server and shared HTML response page. +//! +//! Used by Zed's OAuth-based sign-in flows (e.g. MCP servers, ChatGPT +//! Subscription) to receive the authorization code redirect from the user's +//! browser. The HTML response page rendered to the browser is kept alongside +//! the server so all OAuth callback presentation lives in one place. + +/// Generate a styled HTML page for OAuth callback responses. +/// +/// Returns a complete HTML document (no HTTP headers) with a centered card +/// layout styled to match Zed's dark theme. The `title` is rendered as a +/// heading and `message` as body text below it. +/// +/// When `is_error` is true, a red X icon is shown instead of the green +/// checkmark. +pub fn oauth_callback_page(title: &str, message: &str, is_error: bool) -> String { + let title = html_escape(title); + let message = html_escape(message); + let (icon_bg, icon_svg) = if is_error { + ( + "#f38ba8", + r#""#, + ) + } else { + ( + "#a6e3a1", + r#""#, + ) + }; + format!( + r#" + + + + +{title} — Zed + + + +
+
+ {icon_svg} +
+

{title}

+

{message}

+
Zed
+
+ +"#, + title = title, + message = message, + icon_bg = icon_bg, + icon_svg = icon_svg, + ) +} + +fn html_escape(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + for ch in input.chars() { + match ch { + '&' => output.push_str("&"), + '<' => output.push_str("<"), + '>' => output.push_str(">"), + '"' => output.push_str("""), + '\'' => output.push_str("'"), + _ => output.push(ch), + } + } + output +} + +#[cfg(not(target_family = "wasm"))] +mod server { + use super::oauth_callback_page; + use anyhow::{Context as _, Result, anyhow}; + use std::str::FromStr; + use std::time::Duration; + use url::Url; + + /// Parsed OAuth callback parameters from the authorization server redirect. + pub struct OAuthCallbackParams { + pub code: String, + pub state: String, + } + + /// Configuration for the loopback OAuth callback server. + /// + /// OAuth servers compare `redirect_uri` against a per-client allow-list using + /// exact string matching (RFC 6749 §3.1.2), so the `host`, `preferred_port`, + /// and `path` here must match what's registered for the OAuth client_id. + #[derive(Clone, Copy)] + pub struct OAuthCallbackServerConfig { + /// Host portion of the redirect URI (typically `127.0.0.1` or `localhost`). + pub host: &'static str, + /// Preferred port. Use `0` for an OS-assigned ephemeral port. + pub preferred_port: u16, + /// Optional fallback port if `preferred_port` is unavailable. Only used + /// when `preferred_port` is non-zero. + pub fallback_port: Option, + /// Callback path on the redirect URI (e.g. `/callback`, `/auth/callback`). + pub path: &'static str, + } + + impl Default for OAuthCallbackServerConfig { + fn default() -> Self { + Self { + host: "127.0.0.1", + preferred_port: 0, + fallback_port: None, + path: "/callback", + } + } + } + + impl OAuthCallbackParams { + /// Parse the query string from a callback URL like + /// `http://127.0.0.1:/callback?code=...&state=...`. + pub fn parse_query(query: &str) -> Result { + let mut code: Option = None; + let mut state: Option = None; + let mut error: Option = None; + let mut error_description: Option = None; + + for (key, value) in url::form_urlencoded::parse(query.as_bytes()) { + match key.as_ref() { + "code" => { + if !value.is_empty() { + code = Some(value.into_owned()); + } + } + "state" => { + if !value.is_empty() { + state = Some(value.into_owned()); + } + } + "error" => { + if !value.is_empty() { + error = Some(value.into_owned()); + } + } + "error_description" => { + if !value.is_empty() { + error_description = Some(value.into_owned()); + } + } + _ => {} + } + } + + if let Some(error_code) = error { + anyhow::bail!( + "OAuth authorization failed: {} ({})", + error_code, + error_description.as_deref().unwrap_or("no description") + ); + } + + let code = code.ok_or_else(|| anyhow!("missing 'code' parameter in OAuth callback"))?; + let state = + state.ok_or_else(|| anyhow!("missing 'state' parameter in OAuth callback"))?; + + Ok(Self { code, state }) + } + } + + /// How long to wait for the browser to complete the OAuth flow before giving + /// up and releasing the loopback port. + const OAUTH_CALLBACK_TIMEOUT: Duration = Duration::from_secs(2 * 60); + + /// Start a loopback HTTP server to receive the OAuth authorization callback. + /// + /// Binds to an ephemeral loopback port. Returns `(redirect_uri, callback_future)`. + /// The caller should use the redirect URI in the authorization request, open + /// the browser, then await the future to receive the callback. + pub fn start_oauth_callback_server() -> Result<( + String, + futures::channel::oneshot::Receiver>, + )> { + start_oauth_callback_server_with_config(OAuthCallbackServerConfig::default()) + } + + /// Start a loopback HTTP server with custom host/port/path. + /// + /// Use this when the OAuth client requires a specific redirect URI that the + /// default ephemeral-port `http://127.0.0.1:/callback` doesn't match. + pub fn start_oauth_callback_server_with_config( + config: OAuthCallbackServerConfig, + ) -> Result<( + String, + futures::channel::oneshot::Receiver>, + )> { + let server = bind_callback_server(&config)?; + let port = server + .server_addr() + .to_ip() + .ok_or_else(|| anyhow!("server not bound to a TCP address"))? + .port(); + + let redirect_uri = format!("http://{}:{}{}", config.host, port, config.path); + let expected_path = config.path; + + let (tx, rx) = futures::channel::oneshot::channel(); + + std::thread::spawn(move || { + let deadline = std::time::Instant::now() + OAUTH_CALLBACK_TIMEOUT; + + loop { + if tx.is_canceled() { + return; + } + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + return; + } + + let timeout = remaining.min(Duration::from_millis(500)); + let Some(request) = (match server.recv_timeout(timeout) { + Ok(req) => req, + Err(_) => { + let _ = tx.send(Err(anyhow!("OAuth callback server I/O error"))); + return; + } + }) else { + continue; + }; + + let raw_url = request.url().to_string(); + let raw_path = raw_url.split('?').next().unwrap_or(&raw_url); + if raw_path == CANCEL_PATH { + let response = tiny_http::Response::from_string("Cancelled") + .with_status_code(200) + .with_header( + tiny_http::Header::from_str("Content-Type: text/plain") + .expect("failed to construct response header"), + ) + .with_header( + tiny_http::Header::from_str("Connection: close") + .expect("failed to construct response header"), + ); + if let Err(err) = request.respond(response) { + log::error!("Failed to send OAuth cancel response: {}", err); + } + let _ = tx.send(Err(anyhow!( + "OAuth callback server was cancelled by another sign-in attempt" + ))); + return; + } + + let result = handle_oauth_callback_request(&request, expected_path); + + let (status_code, body) = match &result { + Ok(_) => ( + 200, + oauth_callback_page( + "Authorization Successful", + "You can close this tab and return to Zed.", + false, + ), + ), + Err(err) => { + log::error!("OAuth callback error: {}", err); + ( + 400, + oauth_callback_page( + "Authorization Failed", + "Something went wrong. Please try again from Zed.", + true, + ), + ) + } + }; + + let response = tiny_http::Response::from_string(body) + .with_status_code(status_code) + .with_header( + tiny_http::Header::from_str("Content-Type: text/html") + .expect("failed to construct response header"), + ) + .with_header( + tiny_http::Header::from_str("Keep-Alive: timeout=0,max=0") + .expect("failed to construct response header"), + ); + if let Err(err) = request.respond(response) { + log::error!("Failed to send OAuth callback response: {}", err); + } + + let _ = tx.send(result); + return; + } + }); + + Ok((redirect_uri, rx)) + } + + fn handle_oauth_callback_request( + request: &tiny_http::Request, + expected_path: &str, + ) -> Result { + let url = Url::parse(&format!("http://localhost{}", request.url())) + .context("malformed callback request URL")?; + + if url.path() != expected_path { + anyhow::bail!("unexpected path in OAuth callback: {}", url.path()); + } + + let query = url + .query() + .ok_or_else(|| anyhow!("OAuth callback has no query string"))?; + OAuthCallbackParams::parse_query(query) + } + + /// Callback path reserved for evicting a previously-running OAuth callback + /// server bound to the same port. Always handled, regardless of `config.path`. + const CANCEL_PATH: &str = "/cancel"; + + const BIND_MAX_ATTEMPTS: u32 = 10; + const BIND_RETRY_DELAY: Duration = Duration::from_millis(200); + const CANCEL_REQUEST_TIMEOUT: Duration = Duration::from_secs(2); + + fn bind_callback_server(config: &OAuthCallbackServerConfig) -> Result { + // Ephemeral ports always succeed; skip the cancel-retry dance entirely. + if config.preferred_port == 0 { + let addr = format!("{}:0", config.host); + return tiny_http::Server::http(&addr).map_err(|err| { + anyhow!(err).context(format!( + "Failed to bind loopback listener for OAuth callback on {addr}" + )) + }); + } + + match try_bind_with_cancel(config.host, config.preferred_port) { + Ok(server) => Ok(server), + Err(primary_err) => { + let Some(fallback_port) = config.fallback_port else { + return Err(primary_err.context(format!( + "Failed to bind loopback listener for OAuth callback on {}:{}", + config.host, config.preferred_port, + ))); + }; + log::warn!( + "OAuth callback port {}:{} unavailable; falling back to port {}", + config.host, + config.preferred_port, + fallback_port, + ); + try_bind_with_cancel(config.host, fallback_port).map_err(|fallback_err| { + fallback_err.context(format!( + "Failed to bind loopback listener for OAuth callback on {}:{} or {}:{}", + config.host, config.preferred_port, config.host, fallback_port, + )) + }) + } + } + } + + /// Attempts to bind to a fixed `host:port`. On `AddrInUse`, sends a single + /// `GET /cancel` to the existing listener (to evict a previous OAuth flow + /// from this or a compatible client) and retries. + fn try_bind_with_cancel(host: &'static str, port: u16) -> Result { + let addr = format!("{host}:{port}"); + let mut cancel_attempted = false; + let mut last_err: Option = None; + + for _ in 0..BIND_MAX_ATTEMPTS { + match tiny_http::Server::http(&addr) { + Ok(server) => return Ok(server), + Err(err) => { + let is_addr_in_use = err + .downcast_ref::() + .map(|io_err| io_err.kind() == std::io::ErrorKind::AddrInUse) + .unwrap_or(false); + + if !is_addr_in_use { + return Err(anyhow!(err).context(format!( + "Failed to bind loopback listener for OAuth callback on {addr}" + ))); + } + + if !cancel_attempted { + cancel_attempted = true; + if let Err(cancel_err) = send_cancel_request(host, port) { + log::warn!( + "Failed to cancel previous OAuth callback server on {addr}: {cancel_err}" + ); + } + } + + last_err = Some(anyhow!(err)); + std::thread::sleep(BIND_RETRY_DELAY); + } + } + } + + Err(last_err + .unwrap_or_else(|| anyhow!("unknown bind error")) + .context(format!( + "OAuth callback port {addr} remained in use after {BIND_MAX_ATTEMPTS} attempts" + ))) + } + + /// Sends `GET /cancel` to a listener on `host:port`, asking it to shut down. + /// + /// Best-effort: errors here are surfaced to the caller for logging but do + /// not block the subsequent rebind attempt. + fn send_cancel_request(host: &str, port: u16) -> std::io::Result<()> { + use std::io::{Read as _, Write as _}; + use std::net::{TcpStream, ToSocketAddrs as _}; + + let addr = format!("{host}:{port}") + .to_socket_addrs()? + .next() + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("could not resolve {host}:{port}"), + ) + })?; + let mut stream = TcpStream::connect_timeout(&addr, CANCEL_REQUEST_TIMEOUT)?; + stream.set_read_timeout(Some(CANCEL_REQUEST_TIMEOUT))?; + stream.set_write_timeout(Some(CANCEL_REQUEST_TIMEOUT))?; + + stream.write_all(b"GET /cancel HTTP/1.1\r\n")?; + stream.write_all(format!("Host: {host}:{port}\r\n").as_bytes())?; + stream.write_all(b"Connection: close\r\n\r\n")?; + + // Drain the response so the server can close cleanly. We don't care + // about the body; errors here are harmless. + let mut buf = [0u8; 64]; + let _ = stream.read(&mut buf); + Ok(()) + } +} + +#[cfg(not(target_family = "wasm"))] +pub use server::{ + OAuthCallbackParams, OAuthCallbackServerConfig, start_oauth_callback_server, + start_oauth_callback_server_with_config, +}; From 4f21d621ab510b59e1efeada296af8048bf5336f Mon Sep 17 00:00:00 2001 From: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 20:14:04 +0000 Subject: [PATCH 35/36] Autofix --- crates/http_client/src/http_client.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/http_client/src/http_client.rs b/crates/http_client/src/http_client.rs index b30a7022e5d478..bbbe3b1a832332 100644 --- a/crates/http_client/src/http_client.rs +++ b/crates/http_client/src/http_client.rs @@ -443,4 +443,3 @@ impl HttpClient for FakeHttpClient { self } } - From ae42c9efc354771a5cf393fb52cd8f0e1a8867d1 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Thu, 14 May 2026 16:54:04 -0400 Subject: [PATCH 36/36] Fix CI for oauth_callback_server extraction Two CI failures from 831c244b60 ("Extract OAuth callback server into its own crate"): - check_licenses: new crates need a LICENSE-{APACHE,GPL} symlink to the repo-root license file. Add LICENSE-APACHE symlink pointing to ../../LICENSE-APACHE, matching the crate's `license = "Apache-2.0"` metadata and the convention used by other Apache-licensed crates (e.g. http_client). - check_dependencies (cargo machete): `smol` is unused in context_server since 24703c9e68 ("Drop smol::spawn from MCP OAuth callback wrapper"). Remove the now-unused workspace dep from context_server/Cargo.toml. --- Cargo.lock | 1 - crates/context_server/Cargo.toml | 1 - crates/oauth_callback_server/LICENSE-APACHE | 1 + 3 files changed, 1 insertion(+), 2 deletions(-) create mode 120000 crates/oauth_callback_server/LICENSE-APACHE diff --git a/Cargo.lock b/Cargo.lock index 1f6215293b6c8e..49a7f336b374af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3610,7 +3610,6 @@ dependencies = [ "settings", "sha2", "slotmap", - "smol", "tempfile", "url", "util", diff --git a/crates/context_server/Cargo.toml b/crates/context_server/Cargo.toml index 8986a07dd95b58..efea3dd250f800 100644 --- a/crates/context_server/Cargo.toml +++ b/crates/context_server/Cargo.toml @@ -36,7 +36,6 @@ serde.workspace = true settings.workspace = true sha2.workspace = true slotmap.workspace = true -smol.workspace = true tempfile.workspace = true url = { workspace = true, features = ["serde"] } util.workspace = true diff --git a/crates/oauth_callback_server/LICENSE-APACHE b/crates/oauth_callback_server/LICENSE-APACHE new file mode 120000 index 00000000000000..1cd601d0a3affa --- /dev/null +++ b/crates/oauth_callback_server/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file