Add ChatGPT subscription provider via OAuth 2.0 PKCE

morgankrey created

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

Change summary

Cargo.lock                                                |   2 
crates/language_models/Cargo.toml                         |   2 
crates/language_models/src/language_models.rs             |  11 
crates/language_models/src/provider.rs                    |   1 
crates/language_models/src/provider/open_ai.rs            |   2 
crates/language_models/src/provider/open_ai_compatible.rs |   1 
crates/language_models/src/provider/openai_subscribed.rs  | 786 +++++++++
crates/language_models/src/provider/opencode.rs           |   1 
crates/open_ai/src/responses.rs                           |  11 
9 files changed, 813 insertions(+), 4 deletions(-)

Detailed changes

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",

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

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,
+    );
 }

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;

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,
     }
 }
 

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<String>,
+    email: Option<String>,
+}
+
+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<CodexCredentials>,
+    sign_in_task: Option<Task<Result<()>>>,
+    credentials_provider: Arc<dyn CredentialsProvider>,
+}
+
+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<dyn HttpClient>,
+    state: Entity<State>,
+}
+
+impl OpenAiSubscribedProvider {
+    pub fn new(
+        http_client: Arc<dyn HttpClient>,
+        credentials_provider: Arc<dyn CredentialsProvider>,
+        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::<CodexCredentials>(&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<dyn LanguageModel> {
+        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<Entity<Self::ObservableEntity>> {
+        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<Arc<dyn LanguageModel>> {
+        Some(self.create_language_model(CodexModel::O4Mini))
+    }
+
+    fn default_fast_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
+        Some(self.create_language_model(CodexModel::CodexMini))
+    }
+
+    fn provided_models(&self, _cx: &App) -> Vec<Arc<dyn LanguageModel>> {
+        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<Result<(), AuthenticateError>> {
+        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<Result<()>> {
+        self.sign_out(cx);
+        Task::ready(Ok(()))
+    }
+}
+
+// --- Models ---
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum CodexModel {
+    CodexMini,
+    O4Mini,
+    O3,
+}
+
+impl CodexModel {
+    pub fn all() -> Vec<Self> {
+        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<ReasoningEffort> {
+        match self {
+            Self::CodexMini => None,
+            Self::O4Mini | Self::O3 => Some(ReasoningEffort::Medium),
+        }
+    }
+}
+
+// --- Language model ---
+
+struct OpenAiSubscribedLanguageModel {
+    model: CodexModel,
+    state: Entity<State>,
+    http_client: Arc<dyn HttpClient>,
+    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<u64>> {
+        futures::future::ready(Ok(0)).boxed()
+    }
+
+    fn stream_completion(
+        &self,
+        request: LanguageModelRequest,
+        cx: &AsyncApp,
+    ) -> BoxFuture<
+        'static,
+        Result<
+            futures::stream::BoxStream<
+                'static,
+                Result<LanguageModelCompletionEvent, LanguageModelCompletionError>,
+            >,
+            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<State>,
+    http_client: &Arc<dyn HttpClient>,
+    cx: &AsyncApp,
+) -> Result<CodexCredentials, LanguageModelCompletionError> {
+    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<String>,
+    expires_in: u64,
+    #[serde(default)]
+    email: Option<String>,
+}
+
+async fn do_oauth_flow(
+    http_client: Arc<dyn HttpClient>,
+    cx: &AsyncApp,
+) -> Result<CodexCredentials> {
+    // 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<String> {
+    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<String> = None;
+    let mut received_state: Option<String> = 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\
+        <html><body><h1>Signed in to Zed</h1>\
+        <p>Authentication successful. You can close this tab.</p>\
+        </body></html>";
+    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<dyn HttpClient>,
+    code: &str,
+    verifier: &str,
+) -> Result<TokenResponse> {
+    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::<TokenResponse>(&body).context("Failed to parse token response")
+}
+
+async fn refresh_token(
+    client: &Arc<dyn HttpClient>,
+    refresh_token: &str,
+) -> Result<CodexCredentials> {
+    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<String> {
+    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<State>,
+    http_client: Arc<dyn HttpClient>,
+}
+
+impl Render for ConfigurationView {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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()
+    }
+}

crates/open_ai/src/responses.rs πŸ”—

@@ -29,6 +29,8 @@ pub struct Request {
     pub prompt_cache_key: Option<String>,
     #[serde(skip_serializing_if = "Option::is_none")]
     pub reasoning: Option<ReasoningConfig>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub store: Option<bool>,
 }
 
 #[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<BoxStream<'static, Result<StreamEvent>>, 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()))?;