From 0d09d3e5d75bdb24c9b30e6bc9db7e645067004d Mon Sep 17 00:00:00 2001 From: morgankrey Date: Sat, 4 Apr 2026 14:26:28 -0500 Subject: [PATCH] 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 279fcec10f1efb4c3174bfdd8e28192cda2f6a0c..c1fb88e4a4d8cb05a0b78d6bbc84bba4e27e788d 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 4ebfce695e587265ea39077c67c84ce9b01e5352..1111862ded0e13646d63ac47ebccb9e5e75bec19 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 3154db91a43d1381f5b3f122a724be249adeb79b..a572e4e5dc5524b2f3bfa11ec7abc1bd6e720f74 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 d3c433974599399160e602b8f201b9fd0af874cb..d9f8d9d77eb45621d9ff497da5de7c4a9b4fce9c 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 6a2313487f4a1922cdc2aa20d23ede01c4b7d158..fca5cd1d97ba7dd1b3f26ee2d4f114a7c62a7532 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 1c3268749c3340826cd2f50d29e80eecfa1826d4..e418d08ac63b985606926355e0503e56539f028a 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 0000000000000000000000000000000000000000..36916c1a37c6d9a1cbcb4ec207227dcd8562de68 --- /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 aae3a552544ebf2cc59255da954d84cf7b78c7da..5e32f3ab285d8a16779245efc90643d3120124fb 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 34dbd46c372a672840d6c7c91f4785dbe8d80521..390220a0d105fa8c03e879951d6246152805cc7e 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()))?;