From e118edc7f4c3075df58f94e08a05f5192fe34fc2 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Wed, 21 Jan 2026 13:11:31 +1100 Subject: [PATCH 01/10] working with subscription now --- crates/goose/src/providers/chatgpt_codex.rs | 836 ++++++++++++++++++++ crates/goose/src/providers/factory.rs | 5 + crates/goose/src/providers/mod.rs | 1 + 3 files changed, 842 insertions(+) create mode 100644 crates/goose/src/providers/chatgpt_codex.rs diff --git a/crates/goose/src/providers/chatgpt_codex.rs b/crates/goose/src/providers/chatgpt_codex.rs new file mode 100644 index 000000000000..11669727e41f --- /dev/null +++ b/crates/goose/src/providers/chatgpt_codex.rs @@ -0,0 +1,836 @@ +use crate::config::paths::Paths; +use crate::conversation::message::{Message, MessageContent}; +use crate::model::ModelConfig; +use crate::providers::api_client::AuthProvider; +use crate::providers::base::{ConfigKey, MessageStream, Provider, ProviderMetadata, ProviderUsage}; +use crate::providers::errors::ProviderError; +use crate::providers::formats::openai_responses::responses_api_to_streaming_message; +use crate::providers::retry::ProviderRetry; +use crate::providers::utils::handle_status_openai_compat; +use anyhow::{anyhow, Result}; +use async_stream::try_stream; +use async_trait::async_trait; +use axum::{extract::Query, response::Html, routing::get, Router}; +use base64::Engine; +use chrono::{DateTime, Utc}; +use futures::{StreamExt, TryStreamExt}; +use once_cell::sync::Lazy; +use rmcp::model::{RawContent, Role, Tool}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::Digest; +use std::io; +use std::net::SocketAddr; +use std::ops::Deref; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::pin; +use tokio::sync::{oneshot, Mutex as TokioMutex}; +use tokio_util::codec::{FramedRead, LinesCodec}; +use tokio_util::io::StreamReader; + +const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; +const ISSUER: &str = "https://auth.openai.com"; +const CODEX_API_ENDPOINT: &str = "https://chatgpt.com/backend-api/codex"; +const OAUTH_SCOPES: &[&str] = &["openid", "profile", "email", "offline_access"]; +const OAUTH_PORT: u16 = 1455; + +pub const CHATGPT_CODEX_DEFAULT_MODEL: &str = "gpt-5.1-codex"; +pub const CHATGPT_CODEX_KNOWN_MODELS: &[&str] = &[ + "gpt-5.2-codex", + "gpt-5.1-codex", + "gpt-5.1-codex-mini", + "gpt-5.1-codex-max", +]; + +const CHATGPT_CODEX_DOC_URL: &str = "https://openai.com/chatgpt"; + +static OAUTH_MUTEX: Lazy> = Lazy::new(|| TokioMutex::new(())); + +fn create_codex_request( + model_config: &ModelConfig, + system: &str, + messages: &[Message], + tools: &[Tool], +) -> Result { + let mut input_items = Vec::new(); + + for message in messages.iter().filter(|m| m.is_agent_visible()) { + let has_only_tool_content = message.content.iter().all(|c| { + matches!( + c, + MessageContent::ToolRequest(_) | MessageContent::ToolResponse(_) + ) + }); + + if has_only_tool_content { + continue; + } + + if message.role != Role::User && message.role != Role::Assistant { + continue; + } + + let role = match message.role { + Role::User => "user", + Role::Assistant => "assistant", + }; + + let mut content_items = Vec::new(); + for content in &message.content { + if let MessageContent::Text(text) = content { + if !text.text.is_empty() { + let content_type = if message.role == Role::Assistant { + "output_text" + } else { + "input_text" + }; + content_items.push(json!({ + "type": content_type, + "text": text.text + })); + } + } + } + + if !content_items.is_empty() { + input_items.push(json!({ + "role": role, + "content": content_items + })); + } + } + + for message in messages.iter().filter(|m| m.is_agent_visible()) { + if message.role == Role::Assistant { + for content in &message.content { + if let MessageContent::ToolRequest(request) = content { + if let Ok(tool_call) = &request.tool_call { + let arguments_str = tool_call + .arguments + .as_ref() + .map(|args| { + serde_json::to_string(args).unwrap_or_else(|_| "{}".to_string()) + }) + .unwrap_or_else(|| "{}".to_string()); + + input_items.push(json!({ + "type": "function_call", + "call_id": request.id, + "name": tool_call.name, + "arguments": arguments_str + })); + } + } + } + } + } + + for message in messages.iter().filter(|m| m.is_agent_visible()) { + for content in &message.content { + if let MessageContent::ToolResponse(response) = content { + match &response.tool_result { + Ok(contents) => { + let text_content: Vec = contents + .content + .iter() + .filter_map(|c| { + if let RawContent::Text(t) = c.deref() { + Some(t.text.clone()) + } else { + None + } + }) + .collect(); + + if !text_content.is_empty() { + input_items.push(json!({ + "type": "function_call_output", + "call_id": response.id, + "output": text_content.join("\n") + })); + } + } + Err(error_data) => { + input_items.push(json!({ + "type": "function_call_output", + "call_id": response.id, + "output": format!("Error: {}", error_data.message) + })); + } + } + } + } + } + + let mut payload = json!({ + "model": model_config.model_name, + "input": input_items, + "store": false, + "instructions": system, + }); + + if !tools.is_empty() { + let tools_spec: Vec = tools + .iter() + .map(|tool| { + json!({ + "type": "function", + "name": tool.name, + "description": tool.description, + "parameters": tool.input_schema, + }) + }) + .collect(); + + payload + .as_object_mut() + .unwrap() + .insert("tools".to_string(), json!(tools_spec)); + } + + if let Some(temp) = model_config.temperature { + payload + .as_object_mut() + .unwrap() + .insert("temperature".to_string(), json!(temp)); + } + + // Note: ChatGPT Codex API does not support max_output_tokens parameter + + Ok(payload) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct TokenData { + access_token: String, + refresh_token: String, + id_token: Option, + expires_at: DateTime, + account_id: Option, +} + +#[derive(Debug, Clone)] +struct TokenCache { + cache_path: PathBuf, +} + +fn get_cache_path() -> PathBuf { + Paths::in_config_dir("chatgpt_codex/tokens.json") +} + +impl TokenCache { + fn new() -> Self { + let cache_path = get_cache_path(); + if let Some(parent) = cache_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + Self { cache_path } + } + + fn load(&self) -> Option { + if let Ok(contents) = std::fs::read_to_string(&self.cache_path) { + serde_json::from_str(&contents).ok() + } else { + None + } + } + + fn save(&self, token_data: &TokenData) -> Result<()> { + if let Some(parent) = self.cache_path.parent() { + std::fs::create_dir_all(parent)?; + } + let contents = serde_json::to_string(token_data)?; + std::fs::write(&self.cache_path, contents)?; + Ok(()) + } + + fn clear(&self) { + let _ = std::fs::remove_file(&self.cache_path); + } +} + +#[derive(Debug, Deserialize)] +struct JwtClaims { + chatgpt_account_id: Option, + #[serde(rename = "https://api.openai.com/auth")] + auth_claims: Option, + organizations: Option>, +} + +#[derive(Debug, Deserialize)] +struct AuthClaims { + chatgpt_account_id: Option, +} + +#[derive(Debug, Deserialize)] +struct OrgInfo { + id: String, +} + +fn parse_jwt_claims(token: &str) -> Option { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return None; + } + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(parts[1]) + .ok()?; + serde_json::from_slice(&payload).ok() +} + +fn extract_account_id(token_data: &TokenData) -> Option { + if let Some(ref id_token) = token_data.id_token { + if let Some(claims) = parse_jwt_claims(id_token) { + if let Some(id) = claims.chatgpt_account_id { + return Some(id); + } + if let Some(auth) = claims.auth_claims { + if let Some(id) = auth.chatgpt_account_id { + return Some(id); + } + } + if let Some(orgs) = claims.organizations { + if let Some(org) = orgs.first() { + return Some(org.id.clone()); + } + } + } + } + if let Some(claims) = parse_jwt_claims(&token_data.access_token) { + if let Some(id) = claims.chatgpt_account_id { + return Some(id); + } + if let Some(auth) = claims.auth_claims { + if let Some(id) = auth.chatgpt_account_id { + return Some(id); + } + } + if let Some(orgs) = claims.organizations { + if let Some(org) = orgs.first() { + return Some(org.id.clone()); + } + } + } + None +} + +struct PkceChallenge { + verifier: String, + challenge: String, +} + +fn generate_pkce() -> PkceChallenge { + let verifier = nanoid::nanoid!(43); + let digest = sha2::Sha256::digest(verifier.as_bytes()); + let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest); + PkceChallenge { + verifier, + challenge, + } +} + +fn generate_state() -> String { + nanoid::nanoid!(32) +} + +fn build_authorize_url(redirect_uri: &str, pkce: &PkceChallenge, state: &str) -> String { + let scopes = OAUTH_SCOPES.join(" "); + let params = [ + ("response_type", "code"), + ("client_id", CLIENT_ID), + ("redirect_uri", redirect_uri), + ("scope", &scopes), + ("code_challenge", &pkce.challenge), + ("code_challenge_method", "S256"), + ("id_token_add_organizations", "true"), + ("codex_cli_simplified_flow", "true"), + ("state", state), + ("originator", "goose"), + ]; + format!( + "{}/oauth/authorize?{}", + ISSUER, + serde_urlencoded::to_string(params).unwrap() + ) +} + +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, + refresh_token: String, + id_token: Option, + expires_in: Option, +} + +async fn exchange_code_for_tokens( + code: &str, + redirect_uri: &str, + pkce: &PkceChallenge, +) -> Result { + let client = reqwest::Client::new(); + let params = [ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", redirect_uri), + ("client_id", CLIENT_ID), + ("code_verifier", &pkce.verifier), + ]; + + let resp = client + .post(format!("{}/oauth/token", ISSUER)) + .header("Content-Type", "application/x-www-form-urlencoded") + .form(¶ms) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + return Err(anyhow!("Token exchange failed ({}): {}", status, text)); + } + + Ok(resp.json().await?) +} + +async fn refresh_access_token(refresh_token: &str) -> Result { + let client = reqwest::Client::new(); + let params = [ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ("client_id", CLIENT_ID), + ]; + + let resp = client + .post(format!("{}/oauth/token", ISSUER)) + .header("Content-Type", "application/x-www-form-urlencoded") + .form(¶ms) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + return Err(anyhow!("Token refresh failed ({}): {}", status, text)); + } + + Ok(resp.json().await?) +} + +const HTML_SUCCESS: &str = r#" + + + Goose - ChatGPT Authorization Successful + + + +
+

Authorization Successful

+

You can close this window and return to Goose.

+
+ + +"#; + +fn html_error(error: &str) -> String { + format!( + r#" + + + Goose - ChatGPT Authorization Failed + + + +
+

Authorization Failed

+

An error occurred during authorization.

+
{}
+
+ +"#, + error + ) +} + +async fn perform_oauth_flow() -> Result { + let _guard = OAUTH_MUTEX.lock().await; + + let pkce = generate_pkce(); + let state = generate_state(); + let redirect_uri = format!("http://localhost:{}/auth/callback", OAUTH_PORT); + + let (tx, rx) = oneshot::channel::>(); + let tx = Arc::new(TokioMutex::new(Some(tx))); + let expected_state = state.clone(); + let pkce_for_handler = Arc::new(pkce.verifier.clone()); + + #[derive(Deserialize)] + struct CallbackParams { + code: Option, + state: Option, + error: Option, + error_description: Option, + } + + let tx_clone = tx.clone(); + let app = Router::new().route( + "/auth/callback", + get(move |Query(params): Query| { + let tx = tx_clone.clone(); + let expected = expected_state.clone(); + async move { + if let Some(error) = params.error { + let msg = params.error_description.unwrap_or(error); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err(anyhow!("{}", msg))); + } + return Html(html_error(&msg)); + } + + let code = match params.code { + Some(c) => c, + None => { + let msg = "Missing authorization code"; + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err(anyhow!("{}", msg))); + } + return Html(html_error(msg)); + } + }; + + if params.state.as_deref() != Some(&expected) { + let msg = "Invalid state - potential CSRF attack"; + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err(anyhow!("{}", msg))); + } + return Html(html_error(msg)); + } + + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Ok(code)); + } + Html(HTML_SUCCESS.to_string()) + } + }), + ); + + let addr = SocketAddr::from(([127, 0, 0, 1], OAUTH_PORT)); + let listener = tokio::net::TcpListener::bind(addr).await?; + let server_handle = tokio::spawn(async move { + let server = axum::serve(listener, app); + let _ = server.await; + }); + + let auth_url = build_authorize_url(&redirect_uri, &pkce, &state); + if webbrowser::open(&auth_url).is_err() { + println!("Please open this URL in your browser:\n{}", auth_url); + } + + let code = tokio::time::timeout(std::time::Duration::from_secs(300), rx) + .await + .map_err(|_| anyhow!("OAuth flow timed out"))?? + .map_err(|e| anyhow!("OAuth callback error: {}", e))?; + + server_handle.abort(); + + let pkce_challenge = PkceChallenge { + verifier: (*pkce_for_handler).clone(), + challenge: pkce.challenge, + }; + let tokens = exchange_code_for_tokens(&code, &redirect_uri, &pkce_challenge).await?; + + let expires_at = Utc::now() + chrono::Duration::seconds(tokens.expires_in.unwrap_or(3600)); + + let mut token_data = TokenData { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + id_token: tokens.id_token, + expires_at, + account_id: None, + }; + + token_data.account_id = extract_account_id(&token_data); + + Ok(token_data) +} + +#[derive(Debug)] +struct ChatGptCodexAuthProvider { + cache: TokenCache, +} + +impl ChatGptCodexAuthProvider { + fn new() -> Self { + Self { + cache: TokenCache::new(), + } + } + + async fn get_valid_token(&self) -> Result { + if let Some(mut token_data) = self.cache.load() { + if token_data.expires_at > Utc::now() + chrono::Duration::seconds(60) { + return Ok(token_data); + } + + tracing::debug!("Token expired, attempting refresh"); + match refresh_access_token(&token_data.refresh_token).await { + Ok(new_tokens) => { + token_data.access_token = new_tokens.access_token; + token_data.refresh_token = new_tokens.refresh_token; + if new_tokens.id_token.is_some() { + token_data.id_token = new_tokens.id_token; + } + token_data.expires_at = Utc::now() + + chrono::Duration::seconds(new_tokens.expires_in.unwrap_or(3600)); + if token_data.account_id.is_none() { + token_data.account_id = extract_account_id(&token_data); + } + self.cache.save(&token_data)?; + tracing::info!("Token refreshed successfully"); + return Ok(token_data); + } + Err(e) => { + tracing::warn!("Token refresh failed, will re-authenticate: {}", e); + self.cache.clear(); + } + } + } + + tracing::info!("Starting OAuth flow for ChatGPT Codex"); + let token_data = perform_oauth_flow().await?; + self.cache.save(&token_data)?; + Ok(token_data) + } +} + +#[async_trait] +impl AuthProvider for ChatGptCodexAuthProvider { + async fn get_auth_header(&self) -> Result<(String, String)> { + let token_data = self.get_valid_token().await?; + Ok(( + "Authorization".to_string(), + format!("Bearer {}", token_data.access_token), + )) + } +} + +#[derive(Debug, serde::Serialize)] +pub struct ChatGptCodexProvider { + #[serde(skip)] + auth_provider: Arc, + model: ModelConfig, + #[serde(skip)] + name: String, +} + +impl ChatGptCodexProvider { + pub async fn from_env(model: ModelConfig) -> Result { + let auth_provider = Arc::new(ChatGptCodexAuthProvider::new()); + + Ok(Self { + auth_provider, + model, + name: Self::metadata().name, + }) + } + + async fn post_streaming(&self, payload: &Value) -> Result { + let token_data = self + .auth_provider + .get_valid_token() + .await + .map_err(|e| ProviderError::Authentication(e.to_string()))?; + + let mut headers = reqwest::header::HeaderMap::new(); + if let Some(account_id) = &token_data.account_id { + headers.insert( + reqwest::header::HeaderName::from_static("chatgpt-account-id"), + reqwest::header::HeaderValue::from_str(account_id) + .map_err(|e| ProviderError::ExecutionError(e.to_string()))?, + ); + } + + let client = reqwest::Client::new(); + let response = client + .post(format!("{}/responses", CODEX_API_ENDPOINT)) + .header( + "Authorization", + format!("Bearer {}", token_data.access_token), + ) + .header("Content-Type", "application/json") + .headers(headers) + .json(payload) + .send() + .await + .map_err(|e| ProviderError::RequestFailed(e.to_string()))?; + + handle_status_openai_compat(response).await + } +} + +#[async_trait] +impl Provider for ChatGptCodexProvider { + fn metadata() -> ProviderMetadata { + ProviderMetadata::new( + "chatgpt_codex", + "ChatGPT Codex", + "Use your ChatGPT Plus/Pro subscription for GPT-5 Codex models via OAuth", + CHATGPT_CODEX_DEFAULT_MODEL, + CHATGPT_CODEX_KNOWN_MODELS.to_vec(), + CHATGPT_CODEX_DOC_URL, + vec![ConfigKey::new_oauth( + "CHATGPT_CODEX_TOKEN", + true, + true, + None, + )], + ) + } + + fn get_name(&self) -> &str { + &self.name + } + + fn get_model_config(&self) -> ModelConfig { + self.model.clone() + } + + #[tracing::instrument( + skip(self, model_config, system, messages, tools), + fields(model_config, input, output, input_tokens, output_tokens, total_tokens) + )] + async fn complete_with_model( + &self, + model_config: &ModelConfig, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + // ChatGPT Codex API requires streaming - collect the stream into a single response + let mut payload = create_codex_request(model_config, system, messages, tools) + .map_err(|e| ProviderError::ExecutionError(e.to_string()))?; + payload["stream"] = serde_json::Value::Bool(true); + + let response = self + .with_retry(|| async { + let payload_clone = payload.clone(); + self.post_streaming(&payload_clone).await + }) + .await?; + + let stream = response.bytes_stream().map_err(io::Error::other); + let stream_reader = StreamReader::new(stream); + let framed = + FramedRead::new(stream_reader, LinesCodec::new()).map_err(anyhow::Error::from); + + let message_stream = responses_api_to_streaming_message(framed); + pin!(message_stream); + + let mut final_message: Option = None; + let mut final_usage: Option = None; + + while let Some(result) = message_stream.next().await { + let (message, usage) = result.map_err(|e| { + ProviderError::RequestFailed(format!("Stream decode error: {}", e)) + })?; + if let Some(msg) = message { + final_message = Some(msg); + } + if let Some(u) = usage { + final_usage = Some(u); + } + } + + let message = final_message.ok_or_else(|| { + ProviderError::ExecutionError("No message received from stream".to_string()) + })?; + let usage = final_usage.unwrap_or_else(|| { + ProviderUsage::new( + model_config.model_name.clone(), + crate::providers::base::Usage::default(), + ) + }); + + Ok((message, usage)) + } + + fn supports_streaming(&self) -> bool { + true + } + + async fn stream( + &self, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result { + let mut payload = create_codex_request(&self.model, system, messages, tools) + .map_err(|e| ProviderError::ExecutionError(e.to_string()))?; + payload["stream"] = serde_json::Value::Bool(true); + + let response = self + .with_retry(|| async { + let payload_clone = payload.clone(); + self.post_streaming(&payload_clone).await + }) + .await?; + + let stream = response.bytes_stream().map_err(io::Error::other); + + Ok(Box::pin(try_stream! { + let stream_reader = StreamReader::new(stream); + let framed = FramedRead::new(stream_reader, LinesCodec::new()).map_err(anyhow::Error::from); + + let message_stream = responses_api_to_streaming_message(framed); + pin!(message_stream); + while let Some(message) = message_stream.next().await { + let (message, usage) = message.map_err(|e| ProviderError::RequestFailed(format!("Stream decode error: {}", e)))?; + yield (message, usage); + } + })) + } + + async fn configure_oauth(&self) -> Result<(), ProviderError> { + self.auth_provider + .get_valid_token() + .await + .map_err(|e| ProviderError::Authentication(format!("OAuth flow failed: {}", e)))?; + Ok(()) + } +} diff --git a/crates/goose/src/providers/factory.rs b/crates/goose/src/providers/factory.rs index b051087c120a..286b2ea40edc 100644 --- a/crates/goose/src/providers/factory.rs +++ b/crates/goose/src/providers/factory.rs @@ -5,6 +5,7 @@ use super::{ azure::AzureProvider, base::{Provider, ProviderMetadata}, bedrock::BedrockProvider, + chatgpt_codex::ChatGptCodexProvider, claude_code::ClaudeCodeProvider, codex::CodexProvider, cursor_agent::CursorAgentProvider, @@ -46,6 +47,10 @@ async fn init_registry() -> RwLock { .register::(|m| Box::pin(AnthropicProvider::from_env(m)), true); registry.register::(|m| Box::pin(AzureProvider::from_env(m)), false); registry.register::(|m| Box::pin(BedrockProvider::from_env(m)), false); + registry.register::( + |m| Box::pin(ChatGptCodexProvider::from_env(m)), + true, + ); registry .register::(|m| Box::pin(ClaudeCodeProvider::from_env(m)), true); registry.register::(|m| Box::pin(CodexProvider::from_env(m)), true); diff --git a/crates/goose/src/providers/mod.rs b/crates/goose/src/providers/mod.rs index f1dfeaf9df36..abfc499d657b 100644 --- a/crates/goose/src/providers/mod.rs +++ b/crates/goose/src/providers/mod.rs @@ -6,6 +6,7 @@ pub mod azureauth; pub mod base; pub mod bedrock; pub mod canonical; +pub mod chatgpt_codex; pub mod claude_code; pub mod codex; pub mod cursor_agent; From 1d2b0e44805f87d0bc7988ac285eb5be9ec611c8 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Wed, 21 Jan 2026 13:56:17 +1100 Subject: [PATCH 02/10] UI oauth flow --- crates/goose-server/src/openapi.rs | 1 + .../src/routes/config_management.rs | 41 ++++++ crates/goose/src/providers/chatgpt_codex.rs | 8 +- ui/desktop/openapi.json | 27 ++++ ui/desktop/src/api/index.ts | 4 +- ui/desktop/src/api/sdk.gen.ts | 4 +- ui/desktop/src/api/types.gen.ts | 26 ++++ .../modal/ProviderConfiguationModal.tsx | 119 +++++++++++++----- 8 files changed, 191 insertions(+), 39 deletions(-) diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index be4634b17de2..8874fa41db69 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -352,6 +352,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::config_management::remove_custom_provider, super::routes::config_management::check_provider, super::routes::config_management::set_config_provider, + super::routes::config_management::configure_provider_oauth, super::routes::config_management::get_pricing, super::routes::prompts::get_prompts, super::routes::prompts::get_prompt, diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs index d1d5a18902a4..3fae5cdf4abd 100644 --- a/crates/goose-server/src/routes/config_management.rs +++ b/crates/goose-server/src/routes/config_management.rs @@ -818,6 +818,43 @@ pub async fn set_config_provider( Ok(()) } +#[utoipa::path( + post, + path = "/config/providers/{name}/oauth", + params( + ("name" = String, Path, description = "Provider name") + ), + responses( + (status = 200, description = "OAuth configuration completed"), + (status = 400, description = "OAuth configuration failed") + ) +)] +pub async fn configure_provider_oauth( + Path(provider_name): Path, +) -> Result, (StatusCode, String)> { + use goose::model::ModelConfig; + use goose::providers::create; + + let temp_model = + ModelConfig::new("temp").map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + + let provider = create(&provider_name, temp_model).await.map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!("Failed to create provider: {}", e), + ) + })?; + + provider.configure_oauth().await.map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!("OAuth configuration failed: {}", e), + ) + })?; + + Ok(Json("OAuth configuration completed".to_string())) +} + pub fn routes(state: Arc) -> Router { Router::new() .route("/config", get(read_all_config)) @@ -846,6 +883,10 @@ pub fn routes(state: Arc) -> Router { .route("/config/custom-providers/{id}", get(get_custom_provider)) .route("/config/check_provider", post(check_provider)) .route("/config/set_provider", post(set_config_provider)) + .route( + "/config/providers/{name}/oauth", + post(configure_provider_oauth), + ) .with_state(state) } diff --git a/crates/goose/src/providers/chatgpt_codex.rs b/crates/goose/src/providers/chatgpt_codex.rs index 11669727e41f..f2389b6ef202 100644 --- a/crates/goose/src/providers/chatgpt_codex.rs +++ b/crates/goose/src/providers/chatgpt_codex.rs @@ -756,8 +756,7 @@ impl Provider for ChatGptCodexProvider { let stream = response.bytes_stream().map_err(io::Error::other); let stream_reader = StreamReader::new(stream); - let framed = - FramedRead::new(stream_reader, LinesCodec::new()).map_err(anyhow::Error::from); + let framed = FramedRead::new(stream_reader, LinesCodec::new()).map_err(anyhow::Error::from); let message_stream = responses_api_to_streaming_message(framed); pin!(message_stream); @@ -766,9 +765,8 @@ impl Provider for ChatGptCodexProvider { let mut final_usage: Option = None; while let Some(result) = message_stream.next().await { - let (message, usage) = result.map_err(|e| { - ProviderError::RequestFailed(format!("Stream decode error: {}", e)) - })?; + let (message, usage) = result + .map_err(|e| ProviderError::RequestFailed(format!("Stream decode error: {}", e)))?; if let Some(msg) = message { final_message = Some(msg); } diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 267cdfa8a653..377323813233 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -1214,6 +1214,33 @@ } } }, + "/config/providers/{name}/oauth": { + "post": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "configure_provider_oauth", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "Provider name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OAuth configuration completed" + }, + "400": { + "description": "OAuth configuration failed" + } + } + } + }, "/config/read": { "post": { "tags": [ diff --git a/ui/desktop/src/api/index.ts b/ui/desktop/src/api/index.ts index aa0b2b31fe7f..e8ba10190597 100644 --- a/ui/desktop/src/api/index.ts +++ b/ui/desktop/src/api/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { addExtension, agentAddExtension, agentRemoveExtension, backupConfig, callTool, checkProvider, confirmToolAction, createCustomProvider, createRecipe, createSchedule, decodeRecipe, deleteRecipe, deleteSchedule, deleteSession, detectProvider, diagnostics, editMessage, encodeRecipe, exportSession, getCustomProvider, getExtensions, getPricing, getPrompt, getPrompts, getProviderModels, getSession, getSessionExtensions, getSessionInsights, getSlashCommands, getTools, getTunnelStatus, importSession, initConfig, inspectRunningJob, killRunningJob, listApps, listRecipes, listSchedules, listSessions, mcpUiProxy, type Options, parseRecipe, pauseSchedule, providers, readAllConfig, readConfig, readResource, recipeToYaml, recoverConfig, removeConfig, removeCustomProvider, removeExtension, reply, resetPrompt, restartAgent, resumeAgent, runNowHandler, savePrompt, saveRecipe, scanRecipe, scheduleRecipe, sendTelemetryEvent, sessionsHandler, setConfigProvider, setRecipeSlashCommand, startAgent, startOpenrouterSetup, startTetrateSetup, startTunnel, status, stopAgent, stopTunnel, systemInfo, unpauseSchedule, updateAgentProvider, updateCustomProvider, updateFromSession, updateSchedule, updateSessionName, updateSessionUserRecipeValues, updateWorkingDir, upsertConfig, upsertPermissions, validateConfig } from './sdk.gen'; -export type { ActionRequired, ActionRequiredData, AddExtensionData, AddExtensionErrors, AddExtensionRequest, AddExtensionResponse, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponse, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponse, AgentRemoveExtensionResponses, Annotations, Author, AuthorRequest, BackupConfigData, BackupConfigErrors, BackupConfigResponse, BackupConfigResponses, CallToolData, CallToolErrors, CallToolRequest, CallToolResponse, CallToolResponse2, CallToolResponses, ChatRequest, CheckProviderData, CheckProviderRequest, ClientOptions, CommandType, ConfigKey, ConfigKeyQuery, ConfigResponse, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionRequest, ConfirmToolActionResponses, Content, Conversation, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponse, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeRequest, CreateRecipeResponse, CreateRecipeResponse2, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleRequest, CreateScheduleResponse, CreateScheduleResponses, CspMetadata, DeclarativeProviderConfig, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeRequest, DecodeRecipeResponse, DecodeRecipeResponse2, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeRequest, DeleteRecipeResponse, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponse, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderRequest, DetectProviderResponse, DetectProviderResponse2, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponse, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageRequest, EditMessageResponse, EditMessageResponse2, EditMessageResponses, EditType, EmbeddedResource, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeRequest, EncodeRecipeResponse, EncodeRecipeResponse2, EncodeRecipeResponses, Envs, ErrorResponse, ExportSessionData, ExportSessionErrors, ExportSessionResponse, ExportSessionResponses, ExtensionConfig, ExtensionData, ExtensionEntry, ExtensionLoadResult, ExtensionQuery, ExtensionResponse, FrontendToolRequest, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponse, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponse, GetExtensionsResponses, GetPricingData, GetPricingResponse, GetPricingResponses, GetPromptData, GetPromptErrors, GetPromptResponse, GetPromptResponses, GetPromptsData, GetPromptsResponse, GetPromptsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponse, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponse, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponse, GetSessionInsightsResponses, GetSessionResponse, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponse, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsQuery, GetToolsResponse, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponse, GetTunnelStatusResponses, GooseApp, Icon, ImageContent, ImportSessionData, ImportSessionErrors, ImportSessionRequest, ImportSessionResponse, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponse, InitConfigResponses, InspectJobResponse, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponse, InspectRunningJobResponses, JsonObject, KillJobResponse, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsError, ListAppsErrors, ListAppsRequest, ListAppsResponse, ListAppsResponse2, ListAppsResponses, ListRecipeResponse, ListRecipesData, ListRecipesErrors, ListRecipesResponse, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponse, ListSchedulesResponse2, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponse, ListSessionsResponses, LoadedProvider, McpAppResource, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, Message, MessageContent, MessageEvent, MessageMetadata, ModelConfig, ModelInfo, ParseRecipeData, ParseRecipeError, ParseRecipeErrors, ParseRecipeRequest, ParseRecipeResponse, ParseRecipeResponse2, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponse, PauseScheduleResponses, PermissionLevel, PricingData, PricingQuery, PricingResponse, PrincipalType, PromptContentResponse, PromptsListResponse, ProviderDetails, ProviderEngine, ProviderMetadata, ProvidersData, ProvidersResponse, ProvidersResponse2, ProvidersResponses, ProviderType, RawAudioContent, RawEmbeddedResource, RawImageContent, RawResource, RawTextContent, ReadAllConfigData, ReadAllConfigResponse, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceRequest, ReadResourceResponse, ReadResourceResponse2, ReadResourceResponses, Recipe, RecipeManifest, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement, RecipeToYamlData, RecipeToYamlError, RecipeToYamlErrors, RecipeToYamlRequest, RecipeToYamlResponse, RecipeToYamlResponse2, RecipeToYamlResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponse, RecoverConfigResponses, RedactedThinkingContent, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponse, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponse, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionRequest, RemoveExtensionResponse, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponse, ReplyResponses, ResetPromptData, ResetPromptErrors, ResetPromptResponse, ResetPromptResponses, ResourceContents, ResourceMetadata, Response, RestartAgentData, RestartAgentErrors, RestartAgentRequest, RestartAgentResponse, RestartAgentResponse2, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentRequest, ResumeAgentResponse, ResumeAgentResponse2, ResumeAgentResponses, RetryConfig, Role, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponse, RunNowHandlerResponses, RunNowResponse, SavePromptData, SavePromptErrors, SavePromptRequest, SavePromptResponse, SavePromptResponses, SaveRecipeData, SaveRecipeError, SaveRecipeErrors, SaveRecipeRequest, SaveRecipeResponse, SaveRecipeResponse2, SaveRecipeResponses, ScanRecipeData, ScanRecipeRequest, ScanRecipeResponse, ScanRecipeResponse2, ScanRecipeResponses, ScheduledJob, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeRequest, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, Session, SessionDisplayInfo, SessionExtensionsResponse, SessionInsights, SessionListResponse, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponse, SessionsHandlerResponses, SessionsQuery, SessionType, SetConfigProviderData, SetProviderRequest, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, SetSlashCommandRequest, Settings, SetupResponse, SlashCommand, SlashCommandsResponse, StartAgentData, StartAgentError, StartAgentErrors, StartAgentRequest, StartAgentResponse, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponse, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponse, StartTetrateSetupResponses, StartTunnelData, StartTunnelError, StartTunnelErrors, StartTunnelResponse, StartTunnelResponses, StatusData, StatusResponse, StatusResponses, StopAgentData, StopAgentErrors, StopAgentRequest, StopAgentResponse, StopAgentResponses, StopTunnelData, StopTunnelError, StopTunnelErrors, StopTunnelResponses, SubRecipe, SuccessCheck, SystemInfo, SystemInfoData, SystemInfoResponse, SystemInfoResponses, SystemNotificationContent, SystemNotificationType, TelemetryEventRequest, Template, TextContent, ThinkingContent, TokenState, Tool, ToolAnnotations, ToolConfirmationRequest, ToolInfo, ToolPermission, ToolRequest, ToolResponse, TunnelInfo, TunnelState, UiMetadata, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponse, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderRequest, UpdateCustomProviderResponse, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionRequest, UpdateFromSessionResponses, UpdateProviderRequest, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleRequest, UpdateScheduleResponse, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameRequest, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesError, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesRequest, UpdateSessionUserRecipeValuesResponse, UpdateSessionUserRecipeValuesResponse2, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirRequest, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigQuery, UpsertConfigResponse, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsQuery, UpsertPermissionsResponse, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponse, ValidateConfigResponses, WindowProps } from './types.gen'; +export { addExtension, agentAddExtension, agentRemoveExtension, backupConfig, callTool, checkProvider, configureProviderOauth, confirmToolAction, createCustomProvider, createRecipe, createSchedule, decodeRecipe, deleteRecipe, deleteSchedule, deleteSession, detectProvider, diagnostics, editMessage, encodeRecipe, exportSession, getCustomProvider, getExtensions, getPricing, getPrompt, getPrompts, getProviderModels, getSession, getSessionExtensions, getSessionInsights, getSlashCommands, getTools, getTunnelStatus, importSession, initConfig, inspectRunningJob, killRunningJob, listApps, listRecipes, listSchedules, listSessions, mcpUiProxy, type Options, parseRecipe, pauseSchedule, providers, readAllConfig, readConfig, readResource, recipeToYaml, recoverConfig, removeConfig, removeCustomProvider, removeExtension, reply, resetPrompt, restartAgent, resumeAgent, runNowHandler, savePrompt, saveRecipe, scanRecipe, scheduleRecipe, sendTelemetryEvent, sessionsHandler, setConfigProvider, setRecipeSlashCommand, startAgent, startOpenrouterSetup, startTetrateSetup, startTunnel, status, stopAgent, stopTunnel, systemInfo, unpauseSchedule, updateAgentProvider, updateCustomProvider, updateFromSession, updateSchedule, updateSessionName, updateSessionUserRecipeValues, updateWorkingDir, upsertConfig, upsertPermissions, validateConfig } from './sdk.gen'; +export type { ActionRequired, ActionRequiredData, AddExtensionData, AddExtensionErrors, AddExtensionRequest, AddExtensionResponse, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponse, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponse, AgentRemoveExtensionResponses, Annotations, Author, AuthorRequest, BackupConfigData, BackupConfigErrors, BackupConfigResponse, BackupConfigResponses, CallToolData, CallToolErrors, CallToolRequest, CallToolResponse, CallToolResponse2, CallToolResponses, ChatRequest, CheckProviderData, CheckProviderRequest, ClientOptions, CommandType, ConfigKey, ConfigKeyQuery, ConfigResponse, ConfigureProviderOauthData, ConfigureProviderOauthErrors, ConfigureProviderOauthResponses, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionRequest, ConfirmToolActionResponses, Content, Conversation, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponse, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeRequest, CreateRecipeResponse, CreateRecipeResponse2, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleRequest, CreateScheduleResponse, CreateScheduleResponses, CspMetadata, DeclarativeProviderConfig, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeRequest, DecodeRecipeResponse, DecodeRecipeResponse2, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeRequest, DeleteRecipeResponse, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponse, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderRequest, DetectProviderResponse, DetectProviderResponse2, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponse, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageRequest, EditMessageResponse, EditMessageResponse2, EditMessageResponses, EditType, EmbeddedResource, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeRequest, EncodeRecipeResponse, EncodeRecipeResponse2, EncodeRecipeResponses, Envs, ErrorResponse, ExportSessionData, ExportSessionErrors, ExportSessionResponse, ExportSessionResponses, ExtensionConfig, ExtensionData, ExtensionEntry, ExtensionLoadResult, ExtensionQuery, ExtensionResponse, FrontendToolRequest, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponse, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponse, GetExtensionsResponses, GetPricingData, GetPricingResponse, GetPricingResponses, GetPromptData, GetPromptErrors, GetPromptResponse, GetPromptResponses, GetPromptsData, GetPromptsResponse, GetPromptsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponse, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponse, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponse, GetSessionInsightsResponses, GetSessionResponse, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponse, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsQuery, GetToolsResponse, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponse, GetTunnelStatusResponses, GooseApp, Icon, ImageContent, ImportSessionData, ImportSessionErrors, ImportSessionRequest, ImportSessionResponse, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponse, InitConfigResponses, InspectJobResponse, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponse, InspectRunningJobResponses, JsonObject, KillJobResponse, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsError, ListAppsErrors, ListAppsRequest, ListAppsResponse, ListAppsResponse2, ListAppsResponses, ListRecipeResponse, ListRecipesData, ListRecipesErrors, ListRecipesResponse, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponse, ListSchedulesResponse2, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponse, ListSessionsResponses, LoadedProvider, McpAppResource, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, Message, MessageContent, MessageEvent, MessageMetadata, ModelConfig, ModelInfo, ParseRecipeData, ParseRecipeError, ParseRecipeErrors, ParseRecipeRequest, ParseRecipeResponse, ParseRecipeResponse2, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponse, PauseScheduleResponses, PermissionLevel, PricingData, PricingQuery, PricingResponse, PrincipalType, PromptContentResponse, PromptsListResponse, ProviderDetails, ProviderEngine, ProviderMetadata, ProvidersData, ProvidersResponse, ProvidersResponse2, ProvidersResponses, ProviderType, RawAudioContent, RawEmbeddedResource, RawImageContent, RawResource, RawTextContent, ReadAllConfigData, ReadAllConfigResponse, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceRequest, ReadResourceResponse, ReadResourceResponse2, ReadResourceResponses, Recipe, RecipeManifest, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement, RecipeToYamlData, RecipeToYamlError, RecipeToYamlErrors, RecipeToYamlRequest, RecipeToYamlResponse, RecipeToYamlResponse2, RecipeToYamlResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponse, RecoverConfigResponses, RedactedThinkingContent, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponse, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponse, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionRequest, RemoveExtensionResponse, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponse, ReplyResponses, ResetPromptData, ResetPromptErrors, ResetPromptResponse, ResetPromptResponses, ResourceContents, ResourceMetadata, Response, RestartAgentData, RestartAgentErrors, RestartAgentRequest, RestartAgentResponse, RestartAgentResponse2, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentRequest, ResumeAgentResponse, ResumeAgentResponse2, ResumeAgentResponses, RetryConfig, Role, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponse, RunNowHandlerResponses, RunNowResponse, SavePromptData, SavePromptErrors, SavePromptRequest, SavePromptResponse, SavePromptResponses, SaveRecipeData, SaveRecipeError, SaveRecipeErrors, SaveRecipeRequest, SaveRecipeResponse, SaveRecipeResponse2, SaveRecipeResponses, ScanRecipeData, ScanRecipeRequest, ScanRecipeResponse, ScanRecipeResponse2, ScanRecipeResponses, ScheduledJob, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeRequest, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, Session, SessionDisplayInfo, SessionExtensionsResponse, SessionInsights, SessionListResponse, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponse, SessionsHandlerResponses, SessionsQuery, SessionType, SetConfigProviderData, SetProviderRequest, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, SetSlashCommandRequest, Settings, SetupResponse, SlashCommand, SlashCommandsResponse, StartAgentData, StartAgentError, StartAgentErrors, StartAgentRequest, StartAgentResponse, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponse, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponse, StartTetrateSetupResponses, StartTunnelData, StartTunnelError, StartTunnelErrors, StartTunnelResponse, StartTunnelResponses, StatusData, StatusResponse, StatusResponses, StopAgentData, StopAgentErrors, StopAgentRequest, StopAgentResponse, StopAgentResponses, StopTunnelData, StopTunnelError, StopTunnelErrors, StopTunnelResponses, SubRecipe, SuccessCheck, SystemInfo, SystemInfoData, SystemInfoResponse, SystemInfoResponses, SystemNotificationContent, SystemNotificationType, TelemetryEventRequest, Template, TextContent, ThinkingContent, TokenState, Tool, ToolAnnotations, ToolConfirmationRequest, ToolInfo, ToolPermission, ToolRequest, ToolResponse, TunnelInfo, TunnelState, UiMetadata, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponse, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderRequest, UpdateCustomProviderResponse, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionRequest, UpdateFromSessionResponses, UpdateProviderRequest, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleRequest, UpdateScheduleResponse, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameRequest, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesError, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesRequest, UpdateSessionUserRecipeValuesResponse, UpdateSessionUserRecipeValuesResponse2, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirRequest, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigQuery, UpsertConfigResponse, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsQuery, UpsertPermissionsResponse, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponse, ValidateConfigResponses, WindowProps } from './types.gen'; diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 2026f2d8f050..8cdd4bf8939c 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CallToolData, CallToolErrors, CallToolResponses, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetPricingData, GetPricingResponses, GetPromptData, GetPromptErrors, GetPromptResponses, GetPromptsData, GetPromptsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsErrors, ListAppsResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecipeToYamlData, RecipeToYamlErrors, RecipeToYamlResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResetPromptData, ResetPromptErrors, ResetPromptResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SavePromptData, SavePromptErrors, SavePromptResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopAgentData, StopAgentErrors, StopAgentResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, SystemInfoData, SystemInfoResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; +import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CallToolData, CallToolErrors, CallToolResponses, CheckProviderData, ConfigureProviderOauthData, ConfigureProviderOauthErrors, ConfigureProviderOauthResponses, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetPricingData, GetPricingResponses, GetPromptData, GetPromptErrors, GetPromptResponses, GetPromptsData, GetPromptsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsErrors, ListAppsResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecipeToYamlData, RecipeToYamlErrors, RecipeToYamlResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResetPromptData, ResetPromptErrors, ResetPromptResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SavePromptData, SavePromptErrors, SavePromptResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopAgentData, StopAgentErrors, StopAgentResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, SystemInfoData, SystemInfoResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; export type Options = Options2 & { /** @@ -226,6 +226,8 @@ export const providers = (options?: Option export const getProviderModels = (options: Options) => (options.client ?? client).get({ url: '/config/providers/{name}/models', ...options }); +export const configureProviderOauth = (options: Options) => (options.client ?? client).post({ url: '/config/providers/{name}/oauth', ...options }); + export const readConfig = (options: Options) => (options.client ?? client).post({ url: '/config/read', ...options, diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 63a51b16aa8b..9d704768eadf 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -2191,6 +2191,32 @@ export type GetProviderModelsResponses = { export type GetProviderModelsResponse = GetProviderModelsResponses[keyof GetProviderModelsResponses]; +export type ConfigureProviderOauthData = { + body?: never; + path: { + /** + * Provider name + */ + name: string; + }; + query?: never; + url: '/config/providers/{name}/oauth'; +}; + +export type ConfigureProviderOauthErrors = { + /** + * OAuth configuration failed + */ + 400: unknown; +}; + +export type ConfigureProviderOauthResponses = { + /** + * OAuth configuration completed + */ + 200: unknown; +}; + export type ReadConfigData = { body: ConfigKeyQuery; path?: never; diff --git a/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx b/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx index a0b76483cad3..270c2acd9820 100644 --- a/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx +++ b/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx @@ -16,8 +16,8 @@ import { SecureStorageNotice } from './subcomponents/SecureStorageNotice'; import { providerConfigSubmitHandler } from './subcomponents/handlers/DefaultSubmitHandler'; import { useConfig } from '../../../ConfigContext'; import { useModelAndProvider } from '../../../ModelAndProviderContext'; -import { AlertTriangle } from 'lucide-react'; -import { ProviderDetails, removeCustomProvider } from '../../../../api'; +import { AlertTriangle, LogIn } from 'lucide-react'; +import { ProviderDetails, removeCustomProvider, configureProviderOauth } from '../../../../api'; import { Button } from '../../../../components/ui/button'; interface ProviderConfigurationModalProps { @@ -38,11 +38,15 @@ export default function ProviderConfigurationModal({ const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false); const [isActiveProvider, setIsActiveProvider] = useState(false); const [error, setError] = useState(null); + const [isOAuthLoading, setIsOAuthLoading] = useState(false); const requiredParameters = provider.metadata.config_keys.filter( (param) => param.required === true ); + // Check if this provider uses OAuth for configuration + const isOAuthProvider = provider.metadata.config_keys.some((key) => key.oauth_flow); + const isConfigured = provider.is_configured; const headerText = showDeleteConfirmation ? `Delete configuration for ${provider.metadata.display_name}` @@ -52,7 +56,28 @@ export default function ProviderConfigurationModal({ ? isActiveProvider ? `You cannot delete this provider while it's currently in use. Please switch to a different model first.` : 'This will permanently delete the current provider configuration.' - : `Add your API key(s) for this provider to integrate into Goose`; + : isOAuthProvider + ? `Sign in with your ${provider.metadata.display_name} account to use this provider` + : `Add your API key(s) for this provider to integrate into Goose`; + + const handleOAuthLogin = async () => { + setIsOAuthLoading(true); + setError(null); + try { + await configureProviderOauth({ + path: { name: provider.name }, + }); + if (onConfigured) { + onConfigured(provider); + } else { + onClose(); + } + } catch (err) { + setError(`OAuth login failed: ${err}`); + } finally { + setIsOAuthLoading(false); + } + }; const handleSubmitForm = async (e: React.FormEvent) => { e.preventDefault(); @@ -179,38 +204,70 @@ export default function ProviderConfigurationModal({ {/* Contains information used to set up each provider */} {/* Only show the form when NOT in delete confirmation mode */} {!showDeleteConfirmation ? ( - <> - {/* Contains information used to set up each provider */} - - - {requiredParameters.length > 0 && - provider.metadata.config_keys && - provider.metadata.config_keys.length > 0 && } - + isOAuthProvider ? ( +
+ +

+ A browser window will open for you to complete the login. +

+
+ ) : ( + <> + {/* Contains information used to set up each provider */} + + + {requiredParameters.length > 0 && + provider.metadata.config_keys && + provider.metadata.config_keys.length > 0 && } + + ) ) : null} - { - setIsActiveProvider(false); - setShowDeleteConfirmation(false); - }} - canDelete={isConfigured && !isActiveProvider} - providerName={provider.metadata.display_name} - isActiveProvider={isActiveProvider} - /> + {isOAuthProvider && !showDeleteConfirmation ? ( +
+ + {isConfigured && ( + + )} +
+ ) : ( + { + setIsActiveProvider(false); + setShowDeleteConfirmation(false); + }} + canDelete={isConfigured && !isActiveProvider} + providerName={provider.metadata.display_name} + isActiveProvider={isActiveProvider} + /> + )}
From 1a1cf3abb6b24ef222b3d96763d096a25989e3fa Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Wed, 21 Jan 2026 15:07:24 +1100 Subject: [PATCH 03/10] now can do it from ui --- .../src/routes/config_management.rs | 7 ++ crates/goose-server/src/routes/utils.rs | 10 ++ .../src/providers/canonical/name_builder.rs | 5 +- crates/goose/src/providers/chatgpt_codex.rs | 9 ++ ui/desktop/src/components/ProviderGuard.tsx | 96 ++++++++++++++++++- ui/desktop/src/components/icons/ChatGPT.tsx | 17 ++++ ui/desktop/src/components/icons/index.tsx | 2 + ui/desktop/src/utils/analytics.ts | 8 +- ui/desktop/src/utils/chatgptCodexSetup.ts | 21 ++++ 9 files changed, 165 insertions(+), 10 deletions(-) create mode 100644 ui/desktop/src/components/icons/ChatGPT.tsx create mode 100644 ui/desktop/src/utils/chatgptCodexSetup.ts diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs index 3fae5cdf4abd..d1a951e86ade 100644 --- a/crates/goose-server/src/routes/config_management.rs +++ b/crates/goose-server/src/routes/config_management.rs @@ -852,6 +852,13 @@ pub async fn configure_provider_oauth( ) })?; + // Mark the provider as configured after successful OAuth + let configured_marker = format!("{}_configured", provider_name); + let config = goose::config::Config::global(); + config + .set_param(&configured_marker, true) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + Ok(Json("OAuth configuration completed".to_string())) } diff --git a/crates/goose-server/src/routes/utils.rs b/crates/goose-server/src/routes/utils.rs index 9d5e19957d9d..e9a4d5ed6d0f 100644 --- a/crates/goose-server/src/routes/utils.rs +++ b/crates/goose-server/src/routes/utils.rs @@ -101,6 +101,16 @@ pub fn check_provider_configured(metadata: &ProviderMetadata, provider_type: Pro .is_ok(); } } + + // Special case: OAuth providers - check for configured marker + let has_oauth_key = metadata.config_keys.iter().any(|key| key.oauth_flow); + if has_oauth_key { + let configured_marker = format!("{}_configured", metadata.name); + if config.get_param::(&configured_marker).is_ok() { + return true; + } + } + // Special case: Zero-config providers (no config keys) if metadata.config_keys.is_empty() { // Check if the provider has been explicitly configured via the UI diff --git a/crates/goose/src/providers/canonical/name_builder.rs b/crates/goose/src/providers/canonical/name_builder.rs index 5765cfcbd82a..03953fcea3e2 100644 --- a/crates/goose/src/providers/canonical/name_builder.rs +++ b/crates/goose/src/providers/canonical/name_builder.rs @@ -133,7 +133,10 @@ fn swap_claude_word_order(model: &str) -> Option { } fn is_hosting_provider(provider: &str) -> bool { - matches!(provider, "databricks" | "openrouter" | "azure" | "bedrock") + matches!( + provider, + "databricks" | "openrouter" | "azure" | "bedrock" | "chatgpt_codex" + ) } /// Infer the real provider from model name patterns diff --git a/crates/goose/src/providers/chatgpt_codex.rs b/crates/goose/src/providers/chatgpt_codex.rs index f2389b6ef202..efa710e54b60 100644 --- a/crates/goose/src/providers/chatgpt_codex.rs +++ b/crates/goose/src/providers/chatgpt_codex.rs @@ -831,4 +831,13 @@ impl Provider for ChatGptCodexProvider { .map_err(|e| ProviderError::Authentication(format!("OAuth flow failed: {}", e)))?; Ok(()) } + + async fn fetch_supported_models(&self) -> Result>, ProviderError> { + Ok(Some( + CHATGPT_CODEX_KNOWN_MODELS + .iter() + .map(|s| s.to_string()) + .collect(), + )) + } } diff --git a/ui/desktop/src/components/ProviderGuard.tsx b/ui/desktop/src/components/ProviderGuard.tsx index 09b020462969..5c7553e7ac42 100644 --- a/ui/desktop/src/components/ProviderGuard.tsx +++ b/ui/desktop/src/components/ProviderGuard.tsx @@ -4,6 +4,7 @@ import { useConfig } from './ConfigContext'; import { SetupModal } from './SetupModal'; import { startOpenRouterSetup } from '../utils/openRouterSetup'; import { startTetrateSetup } from '../utils/tetrateSetup'; +import { startChatGptCodexSetup } from '../utils/chatgptCodexSetup'; import WelcomeGooseLogo from './WelcomeGooseLogo'; import { toastService } from '../toasts'; import { OllamaSetup } from './OllamaSetup'; @@ -19,7 +20,7 @@ import { trackOnboardingSetupFailed, } from '../utils/analytics'; -import { Goose, OpenRouter, Tetrate } from './icons'; +import { Goose, OpenRouter, Tetrate, ChatGPT } from './icons'; interface ProviderGuardProps { didSelectProvider: boolean; @@ -69,6 +70,14 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG autoClose?: number; } | null>(null); + const [chatgptCodexSetupState, setChatgptCodexSetupState] = useState<{ + show: boolean; + title: string; + message: string; + showRetry: boolean; + autoClose?: number; + } | null>(null); + const handleTetrateSetup = async () => { trackOnboardingProviderSelected('tetrate'); try { @@ -97,6 +106,34 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG } }; + const handleChatGptCodexSetup = async () => { + trackOnboardingProviderSelected('chatgpt_codex'); + try { + const result = await startChatGptCodexSetup(); + if (result.success) { + setSwitchModelProvider('chatgpt_codex'); + setShowSwitchModelModal(true); + } else { + trackOnboardingSetupFailed('chatgpt_codex', result.message); + setChatgptCodexSetupState({ + show: true, + title: 'Setup Failed', + message: result.message, + showRetry: true, + }); + } + } catch (error) { + console.error('ChatGPT Codex setup error:', error); + trackOnboardingSetupFailed('chatgpt_codex', 'unexpected_error'); + setChatgptCodexSetupState({ + show: true, + title: 'Setup Error', + message: 'An unexpected error occurred during setup.', + showRetry: true, + }); + } + }; + const handleApiKeySuccess = async (provider: string, _model: string, apiKey: string) => { trackOnboardingProviderSelected('api_key'); const keyName = `${provider.toUpperCase()}_API_KEY`; @@ -163,21 +200,26 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG setShowOllamaSetup(false); }; - const handleRetrySetup = (setupType: 'openrouter' | 'tetrate') => { + const handleRetrySetup = (setupType: 'openrouter' | 'tetrate' | 'chatgpt_codex') => { if (setupType === 'openrouter') { setOpenRouterSetupState(null); handleOpenRouterSetup(); - } else { + } else if (setupType === 'tetrate') { setTetrateSetupState(null); handleTetrateSetup(); + } else { + setChatgptCodexSetupState(null); + handleChatGptCodexSetup(); } }; - const closeSetupModal = (setupType: 'openrouter' | 'tetrate') => { + const closeSetupModal = (setupType: 'openrouter' | 'tetrate' | 'chatgpt_codex') => { if (setupType === 'openrouter') { setOpenRouterSetupState(null); - } else { + } else if (setupType === 'tetrate') { setTetrateSetupState(null); + } else { + setChatgptCodexSetupState(null); } }; @@ -353,6 +395,39 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG

+ {/* ChatGPT Subscription Card - Full Width */} +
+
+
+ + + ChatGPT Subscription + +
+
+ + + +
+
+

+ Use your ChatGPT Plus/Pro subscription for GPT-5 Codex models. +

+
+ {/* Other providers section */}

@@ -415,6 +490,17 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG /> )} + {chatgptCodexSetupState?.show && ( + handleRetrySetup('chatgpt_codex')} + onClose={() => closeSetupModal('chatgpt_codex')} + autoClose={chatgptCodexSetupState.autoClose} + /> + )} + {showSwitchModelModal && ( + + + ); +} diff --git a/ui/desktop/src/components/icons/index.tsx b/ui/desktop/src/components/icons/index.tsx index bbc21ee091d8..c648d2ce6348 100644 --- a/ui/desktop/src/components/icons/index.tsx +++ b/ui/desktop/src/components/icons/index.tsx @@ -2,6 +2,7 @@ import ArrowDown from './ArrowDown'; import ArrowUp from './ArrowUp'; import Attach from './Attach'; import Back from './Back'; +import ChatGPT from './ChatGPT'; import { Bird1 } from './Bird1'; import { Bird2 } from './Bird2'; import { Bird3 } from './Bird3'; @@ -59,6 +60,7 @@ export { ArrowUp, Attach, Back, + ChatGPT, Bird1, Bird2, Bird3, diff --git a/ui/desktop/src/utils/analytics.ts b/ui/desktop/src/utils/analytics.ts index b0aa53425ab7..3705f8927884 100644 --- a/ui/desktop/src/utils/analytics.ts +++ b/ui/desktop/src/utils/analytics.ts @@ -69,7 +69,7 @@ export type AnalyticsEvent = | { name: 'onboarding_started'; properties: Record } | { name: 'onboarding_provider_selected'; - properties: { method: 'api_key' | 'openrouter' | 'tetrate' | 'ollama' | 'other' }; + properties: { method: 'api_key' | 'openrouter' | 'tetrate' | 'chatgpt_codex' | 'ollama' | 'other' }; } | { name: 'onboarding_completed'; @@ -78,7 +78,7 @@ export type AnalyticsEvent = | { name: 'onboarding_abandoned'; properties: { step: string; duration_seconds?: number } } | { name: 'onboarding_setup_failed'; - properties: { provider: 'openrouter' | 'tetrate'; error_message?: string }; + properties: { provider: 'openrouter' | 'tetrate' | 'chatgpt_codex'; error_message?: string }; } | { name: 'error_occurred'; @@ -282,7 +282,7 @@ export function trackOnboardingStarted(): void { } export function trackOnboardingProviderSelected( - method: 'api_key' | 'openrouter' | 'tetrate' | 'ollama' | 'other' + method: 'api_key' | 'openrouter' | 'tetrate' | 'chatgpt_codex' | 'ollama' | 'other' ): void { trackEvent({ name: 'onboarding_provider_selected', @@ -315,7 +315,7 @@ export function trackOnboardingAbandoned(step: string): void { } export function trackOnboardingSetupFailed( - provider: 'openrouter' | 'tetrate', + provider: 'openrouter' | 'tetrate' | 'chatgpt_codex', errorMessage?: string ): void { trackEvent({ diff --git a/ui/desktop/src/utils/chatgptCodexSetup.ts b/ui/desktop/src/utils/chatgptCodexSetup.ts new file mode 100644 index 000000000000..32c6bc886f2b --- /dev/null +++ b/ui/desktop/src/utils/chatgptCodexSetup.ts @@ -0,0 +1,21 @@ +import { configureProviderOauth } from '../api'; + +export interface ChatGptCodexSetupStatus { + isRunning: boolean; + error: string | null; +} + +export async function startChatGptCodexSetup(): Promise<{ success: boolean; message: string }> { + try { + await configureProviderOauth({ + path: { name: 'chatgpt_codex' }, + throwOnError: true, + }); + return { success: true, message: 'ChatGPT Codex setup completed' }; + } catch (e) { + return { + success: false, + message: `Failed to start ChatGPT Codex setup: ${e}`, + }; + } +} From 46ff22f13ae9e6be3261e4ebcd0449e563c15cde Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Wed, 21 Jan 2026 15:14:59 +1100 Subject: [PATCH 04/10] knows about 5.2 now --- .../data/canonical_mapping_report.json | 4 ++++ .../canonical/data/canonical_models.json | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/crates/goose/src/providers/canonical/data/canonical_mapping_report.json b/crates/goose/src/providers/canonical/data/canonical_mapping_report.json index d4323ac233e0..ddacdcf0b14a 100644 --- a/crates/goose/src/providers/canonical/data/canonical_mapping_report.json +++ b/crates/goose/src/providers/canonical/data/canonical_mapping_report.json @@ -3024,6 +3024,10 @@ "provider_model": "gpt-5.1-codex-mini", "canonical_model": "openai/gpt-5.1-codex-mini" }, + { + "provider_model": "gpt-5.2-codex", + "canonical_model": "openai/gpt-5.2-codex" + }, { "provider_model": "gpt-5.2", "canonical_model": "openai/gpt-5.2" diff --git a/crates/goose/src/providers/canonical/data/canonical_models.json b/crates/goose/src/providers/canonical/data/canonical_models.json index abc79e4f8b52..8eddcd0dd8f0 100644 --- a/crates/goose/src/providers/canonical/data/canonical_models.json +++ b/crates/goose/src/providers/canonical/data/canonical_models.json @@ -2181,6 +2181,27 @@ "image": 0.0 } }, + { + "id": "openai/gpt-5.2-codex", + "name": "OpenAI: GPT-5.2-Codex", + "context_length": 400000, + "max_completion_tokens": 128000, + "input_modalities": [ + "file", + "image", + "text" + ], + "output_modalities": [ + "text" + ], + "supports_tools": true, + "pricing": { + "prompt": 1.75e-6, + "completion": 0.000014, + "request": 0.0, + "image": 0.0 + } + }, { "id": "openai/gpt-5.2-pro", "name": "OpenAI: GPT-5.2 Pro", From 676e3867f042a42fdf5fadb1dea79fb5a1b406c3 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Thu, 22 Jan 2026 12:38:09 +1100 Subject: [PATCH 05/10] clean dead code and fix cold start --- .../settings/models/subcomponents/SwitchModelModal.tsx | 5 +++-- ui/desktop/src/utils/chatgptCodexSetup.ts | 5 ----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx index b1f84e551eb0..c94630811b8f 100644 --- a/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx +++ b/ui/desktop/src/components/settings/models/subcomponents/SwitchModelModal.tsx @@ -187,7 +187,8 @@ export const SwitchModelModal = ({ // Load providers for manual model selection (async () => { try { - const providersResponse = await getProviders(false); + // Force refresh if initialProvider is set (OAuth flow needs fresh data) + const providersResponse = await getProviders(!!initialProvider); const activeProviders = providersResponse.filter((provider) => provider.is_configured); // Create provider options and add "Use other provider" option setProviderOptions([ @@ -260,7 +261,7 @@ export const SwitchModelModal = ({ setLoadingModels(false); } })(); - }, [getProviders, getProviderModels, usePredefinedModels, read]); + }, [getProviders, getProviderModels, usePredefinedModels, read, initialProvider]); const filteredModelOptions = provider ? modelOptions.filter((group) => group.options[0]?.provider === provider) diff --git a/ui/desktop/src/utils/chatgptCodexSetup.ts b/ui/desktop/src/utils/chatgptCodexSetup.ts index 32c6bc886f2b..2593b0dd9b68 100644 --- a/ui/desktop/src/utils/chatgptCodexSetup.ts +++ b/ui/desktop/src/utils/chatgptCodexSetup.ts @@ -1,10 +1,5 @@ import { configureProviderOauth } from '../api'; -export interface ChatGptCodexSetupStatus { - isRunning: boolean; - error: string | null; -} - export async function startChatGptCodexSetup(): Promise<{ success: boolean; message: string }> { try { await configureProviderOauth({ From 98550209b05ea3fd1a03fa155017da18061f0667 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Thu, 22 Jan 2026 13:37:29 +1100 Subject: [PATCH 06/10] repairing --- crates/goose/src/providers/chatgpt_codex.rs | 157 +++++++++++--------- 1 file changed, 85 insertions(+), 72 deletions(-) diff --git a/crates/goose/src/providers/chatgpt_codex.rs b/crates/goose/src/providers/chatgpt_codex.rs index efa710e54b60..5ffe63cdde62 100644 --- a/crates/goose/src/providers/chatgpt_codex.rs +++ b/crates/goose/src/providers/chatgpt_codex.rs @@ -47,14 +47,8 @@ const CHATGPT_CODEX_DOC_URL: &str = "https://openai.com/chatgpt"; static OAUTH_MUTEX: Lazy> = Lazy::new(|| TokioMutex::new(())); -fn create_codex_request( - model_config: &ModelConfig, - system: &str, - messages: &[Message], - tools: &[Tool], -) -> Result { - let mut input_items = Vec::new(); - +fn extract_message_content(messages: &[Message]) -> Vec { + let mut items = Vec::new(); for message in messages.iter().filter(|m| m.is_agent_visible()) { let has_only_tool_content = message.content.iter().all(|c| { matches!( @@ -63,11 +57,8 @@ fn create_codex_request( ) }); - if has_only_tool_content { - continue; - } - - if message.role != Role::User && message.role != Role::Assistant { + if has_only_tool_content || (message.role != Role::User && message.role != Role::Assistant) + { continue; } @@ -76,34 +67,37 @@ fn create_codex_request( Role::Assistant => "assistant", }; - let mut content_items = Vec::new(); - for content in &message.content { - if let MessageContent::Text(text) = content { - if !text.text.is_empty() { - let content_type = if message.role == Role::Assistant { - "output_text" - } else { - "input_text" - }; - content_items.push(json!({ - "type": content_type, - "text": text.text - })); + let content_items: Vec = message + .content + .iter() + .filter_map(|content| { + if let MessageContent::Text(text) = content { + if !text.text.is_empty() { + let content_type = if message.role == Role::Assistant { + "output_text" + } else { + "input_text" + }; + return Some(json!({ "type": content_type, "text": text.text })); + } } - } - } + None + }) + .collect(); if !content_items.is_empty() { - input_items.push(json!({ - "role": role, - "content": content_items - })); + items.push(json!({ "role": role, "content": content_items })); } } + items +} - for message in messages.iter().filter(|m| m.is_agent_visible()) { - if message.role == Role::Assistant { - for content in &message.content { +fn extract_function_calls(messages: &[Message]) -> Vec { + messages + .iter() + .filter(|m| m.is_agent_visible() && m.role == Role::Assistant) + .flat_map(|message| { + message.content.iter().filter_map(|content| { if let MessageContent::ToolRequest(request) = content { if let Ok(tool_call) = &request.tool_call { let arguments_str = tool_call @@ -114,7 +108,7 @@ fn create_codex_request( }) .unwrap_or_else(|| "{}".to_string()); - input_items.push(json!({ + return Some(json!({ "type": "function_call", "call_id": request.id, "name": tool_call.name, @@ -122,46 +116,65 @@ fn create_codex_request( })); } } - } - } - } - - for message in messages.iter().filter(|m| m.is_agent_visible()) { - for content in &message.content { - if let MessageContent::ToolResponse(response) = content { - match &response.tool_result { - Ok(contents) => { - let text_content: Vec = contents - .content - .iter() - .filter_map(|c| { - if let RawContent::Text(t) = c.deref() { - Some(t.text.clone()) - } else { - None - } - }) - .collect(); + None + }) + }) + .collect() +} - if !text_content.is_empty() { - input_items.push(json!({ +fn extract_tool_responses(messages: &[Message]) -> Vec { + messages + .iter() + .filter(|m| m.is_agent_visible()) + .flat_map(|message| { + message.content.iter().filter_map(|content| { + if let MessageContent::ToolResponse(response) = content { + match &response.tool_result { + Ok(contents) => { + let text_content: Vec = contents + .content + .iter() + .filter_map(|c| { + if let RawContent::Text(t) = c.deref() { + Some(t.text.clone()) + } else { + None + } + }) + .collect(); + + if !text_content.is_empty() { + return Some(json!({ + "type": "function_call_output", + "call_id": response.id, + "output": text_content.join("\n") + })); + } + } + Err(error_data) => { + return Some(json!({ "type": "function_call_output", "call_id": response.id, - "output": text_content.join("\n") + "output": format!("Error: {}", error_data.message) })); } } - Err(error_data) => { - input_items.push(json!({ - "type": "function_call_output", - "call_id": response.id, - "output": format!("Error: {}", error_data.message) - })); - } } - } - } - } + None + }) + }) + .collect() +} + +fn create_codex_request( + model_config: &ModelConfig, + system: &str, + messages: &[Message], + tools: &[Tool], +) -> Result { + let mut input_items = extract_message_content(messages); + input_items.extend(extract_function_calls(messages)); + input_items.extend(extract_tool_responses(messages)); let mut payload = json!({ "model": model_config.model_name, @@ -196,8 +209,6 @@ fn create_codex_request( .insert("temperature".to_string(), json!(temp)); } - // Note: ChatGPT Codex API does not support max_output_tokens parameter - Ok(payload) } @@ -737,6 +748,7 @@ impl Provider for ChatGptCodexProvider { )] async fn complete_with_model( &self, + _session_id: &str, model_config: &ModelConfig, system: &str, messages: &[Message], @@ -794,6 +806,7 @@ impl Provider for ChatGptCodexProvider { async fn stream( &self, + _session_id: &str, system: &str, messages: &[Message], tools: &[Tool], @@ -832,7 +845,7 @@ impl Provider for ChatGptCodexProvider { Ok(()) } - async fn fetch_supported_models(&self) -> Result>, ProviderError> { + async fn fetch_supported_models(&self, _session_id: &str) -> Result>, ProviderError> { Ok(Some( CHATGPT_CODEX_KNOWN_MODELS .iter() From 42bf0093a40b522e9f8cd08022cf3afb96e52bf5 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Thu, 22 Jan 2026 13:52:30 +1100 Subject: [PATCH 07/10] layout and wording --- ui/desktop/src/components/ApiKeyTester.tsx | 15 ++--- ui/desktop/src/components/ProviderGuard.tsx | 75 +++++++++++---------- 2 files changed, 48 insertions(+), 42 deletions(-) diff --git a/ui/desktop/src/components/ApiKeyTester.tsx b/ui/desktop/src/components/ApiKeyTester.tsx index 56ca288c5b59..e26e789ddafa 100644 --- a/ui/desktop/src/components/ApiKeyTester.tsx +++ b/ui/desktop/src/components/ApiKeyTester.tsx @@ -73,20 +73,19 @@ export default function ApiKeyTester({ onSuccess, onStartTesting }: ApiKeyTester

-
-
-
- +
+
+ +

Quick Setup with API Key

+ + Auto-detect your provider +
-

- Enter your API key and we'll automatically detect which provider it works with. -

-
+ {/* ChatGPT Subscription Card - Full Width */} +
+
+ + Recommended if you have ChatGPT Plus/Pro + +
+ +
+
+
+ + + ChatGPT Subscription + +
+
+ + + +
+
+

+ Use your ChatGPT Plus/Pro subscription for GPT-5 Codex models. +

+
+
+ {/* Tetrate Card - Full Width */}
- {/* Recommended pill */}
Recommended for new users @@ -395,39 +435,6 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG

- {/* ChatGPT Subscription Card - Full Width */} -
-
-
- - - ChatGPT Subscription - -
-
- - - -
-
-

- Use your ChatGPT Plus/Pro subscription for GPT-5 Codex models. -

-
- {/* Other providers section */}

From 6e51fbd862739cd5e5af5b901a33b14b7cb7f617 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Thu, 22 Jan 2026 13:53:01 +1100 Subject: [PATCH 08/10] forgot this --- crates/goose/src/providers/chatgpt_codex.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/goose/src/providers/chatgpt_codex.rs b/crates/goose/src/providers/chatgpt_codex.rs index 5ffe63cdde62..57589bf85c67 100644 --- a/crates/goose/src/providers/chatgpt_codex.rs +++ b/crates/goose/src/providers/chatgpt_codex.rs @@ -845,7 +845,10 @@ impl Provider for ChatGptCodexProvider { Ok(()) } - async fn fetch_supported_models(&self, _session_id: &str) -> Result>, ProviderError> { + async fn fetch_supported_models( + &self, + _session_id: &str, + ) -> Result>, ProviderError> { Ok(Some( CHATGPT_CODEX_KNOWN_MODELS .iter() From 9d0ea01d4cc32ded2c9a4fdc291f3788ffe92157 Mon Sep 17 00:00:00 2001 From: Adrian Cole Date: Thu, 22 Jan 2026 15:17:55 +0900 Subject: [PATCH 09/10] polish Signed-off-by: Adrian Cole --- clippy-baselines/too_many_lines.txt | 13 +- crates/goose-server/src/routes/utils.rs | 2 +- crates/goose/src/providers/chatgpt_codex.rs | 694 ++++++++++++++------ scripts/clippy-baseline.sh | 4 +- 4 files changed, 505 insertions(+), 208 deletions(-) diff --git a/clippy-baselines/too_many_lines.txt b/clippy-baselines/too_many_lines.txt index 5e58ce86c936..ae7ca874e58c 100644 --- a/clippy-baselines/too_many_lines.txt +++ b/clippy-baselines/too_many_lines.txt @@ -1,17 +1,10 @@ -crates/goose-bench/src/eval_suites/core/developer/simple_repo_clone_test.rs::run -crates/goose-cli/src/cli.rs::cli -crates/goose-cli/src/commands/configure.rs::configure_extensions_dialog crates/goose-cli/src/commands/configure.rs::configure_provider_dialog crates/goose-cli/src/commands/configure.rs::configure_tool_permissions_dialog -crates/goose-cli/src/commands/configure.rs::handle_configure crates/goose-cli/src/commands/project.rs::handle_project_default crates/goose-cli/src/commands/project.rs::handle_projects_interactive -crates/goose-cli/src/commands/web.rs::process_message_streaming crates/goose-cli/src/session/builder.rs::build_session crates/goose-cli/src/session/export.rs::tool_response_to_markdown -crates/goose-cli/src/session/mod.rs::interactive crates/goose-cli/src/session/mod.rs::process_agent_response -crates/goose-mcp/src/computercontroller/docx_tool.rs::docx_tool crates/goose-mcp/src/computercontroller/mod.rs::new crates/goose-mcp/src/computercontroller/pdf_tool.rs::pdf_tool crates/goose-mcp/src/memory/mod.rs::new @@ -23,12 +16,10 @@ crates/goose/src/agents/agent.rs::create_recipe crates/goose/src/agents/agent.rs::dispatch_tool_call crates/goose/src/agents/agent.rs::reply crates/goose/src/agents/agent.rs::reply_internal -crates/goose/src/agents/extension_manager.rs::add_extension crates/goose/src/providers/formats/anthropic.rs::format_messages -crates/goose/src/providers/formats/anthropic.rs::response_to_streaming_message +crates/goose/src/providers/formats/anthropic.rs::response_to_streaming_message crates/goose/src/providers/formats/databricks.rs::format_messages crates/goose/src/providers/formats/google.rs::format_messages crates/goose/src/providers/formats/openai.rs::format_messages -crates/goose/src/providers/formats/openai.rs::response_to_streaming_message -crates/goose/src/providers/gcpvertexai.rs::post_with_location +crates/goose/src/providers/formats/openai.rs::response_to_streaming_message crates/goose/src/providers/snowflake.rs::post diff --git a/crates/goose-server/src/routes/utils.rs b/crates/goose-server/src/routes/utils.rs index e9a4d5ed6d0f..78a78151d893 100644 --- a/crates/goose-server/src/routes/utils.rs +++ b/crates/goose-server/src/routes/utils.rs @@ -106,7 +106,7 @@ pub fn check_provider_configured(metadata: &ProviderMetadata, provider_type: Pro let has_oauth_key = metadata.config_keys.iter().any(|key| key.oauth_flow); if has_oauth_key { let configured_marker = format!("{}_configured", metadata.name); - if config.get_param::(&configured_marker).is_ok() { + if matches!(config.get_param::(&configured_marker), Ok(true)) { return true; } } diff --git a/crates/goose/src/providers/chatgpt_codex.rs b/crates/goose/src/providers/chatgpt_codex.rs index 57589bf85c67..9463cb7ad1bc 100644 --- a/crates/goose/src/providers/chatgpt_codex.rs +++ b/crates/goose/src/providers/chatgpt_codex.rs @@ -1,4 +1,4 @@ -use crate::config::paths::Paths; +use crate::config::Config; use crate::conversation::message::{Message, MessageContent}; use crate::model::ModelConfig; use crate::providers::api_client::AuthProvider; @@ -14,7 +14,8 @@ use axum::{extract::Query, response::Html, routing::get, Router}; use base64::Engine; use chrono::{DateTime, Utc}; use futures::{StreamExt, TryStreamExt}; -use once_cell::sync::Lazy; +use jsonwebtoken::jwk::JwkSet; +use jsonwebtoken::{decode, decode_header, DecodingKey, Validation}; use rmcp::model::{RawContent, Role, Tool}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -22,8 +23,7 @@ use sha2::Digest; use std::io; use std::net::SocketAddr; use std::ops::Deref; -use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use tokio::pin; use tokio::sync::{oneshot, Mutex as TokioMutex}; use tokio_util::codec::{FramedRead, LinesCodec}; @@ -34,6 +34,7 @@ const ISSUER: &str = "https://auth.openai.com"; const CODEX_API_ENDPOINT: &str = "https://chatgpt.com/backend-api/codex"; const OAUTH_SCOPES: &[&str] = &["openid", "profile", "email", "offline_access"]; const OAUTH_PORT: u16 = 1455; +const CHATGPT_CODEX_TOKEN_KEY: &str = "CHATGPT_CODEX_TOKEN"; pub const CHATGPT_CODEX_DEFAULT_MODEL: &str = "gpt-5.1-codex"; pub const CHATGPT_CODEX_KNOWN_MODELS: &[&str] = &[ @@ -45,70 +46,68 @@ pub const CHATGPT_CODEX_KNOWN_MODELS: &[&str] = &[ const CHATGPT_CODEX_DOC_URL: &str = "https://openai.com/chatgpt"; -static OAUTH_MUTEX: Lazy> = Lazy::new(|| TokioMutex::new(())); - -fn extract_message_content(messages: &[Message]) -> Vec { - let mut items = Vec::new(); - for message in messages.iter().filter(|m| m.is_agent_visible()) { - let has_only_tool_content = message.content.iter().all(|c| { - matches!( - c, - MessageContent::ToolRequest(_) | MessageContent::ToolResponse(_) - ) - }); +#[derive(Debug)] +struct ChatGptCodexAuthState { + oauth_mutex: TokioMutex<()>, + jwks_cache: TokioMutex>, +} - if has_only_tool_content || (message.role != Role::User && message.role != Role::Assistant) - { - continue; +impl ChatGptCodexAuthState { + fn new() -> Self { + Self { + oauth_mutex: TokioMutex::new(()), + jwks_cache: TokioMutex::new(None), } + } + fn instance() -> Arc { + Arc::clone(&CHATGPT_CODEX_AUTH_STATE) + } +} + +static CHATGPT_CODEX_AUTH_STATE: LazyLock> = + LazyLock::new(|| Arc::new(ChatGptCodexAuthState::new())); + +fn build_input_items(messages: &[Message]) -> Result> { + let mut items = Vec::new(); + + for message in messages.iter().filter(|m| m.is_agent_visible()) { let role = match message.role { - Role::User => "user", - Role::Assistant => "assistant", + Role::User => Some("user"), + Role::Assistant => Some("assistant"), }; + let mut content_items: Vec = Vec::new(); - let content_items: Vec = message - .content - .iter() - .filter_map(|content| { - if let MessageContent::Text(text) = content { + let flush_text = |items: &mut Vec, role: Option<&str>, content: &mut Vec| { + if let Some(role) = role { + if !content.is_empty() { + items.push(json!({ "role": role, "content": std::mem::take(content) })); + } + } else { + content.clear(); + } + }; + + for content in &message.content { + match content { + MessageContent::Text(text) => { if !text.text.is_empty() { let content_type = if message.role == Role::Assistant { "output_text" } else { "input_text" }; - return Some(json!({ "type": content_type, "text": text.text })); + content_items.push(json!({ "type": content_type, "text": text.text })); } } - None - }) - .collect(); - - if !content_items.is_empty() { - items.push(json!({ "role": role, "content": content_items })); - } - } - items -} - -fn extract_function_calls(messages: &[Message]) -> Vec { - messages - .iter() - .filter(|m| m.is_agent_visible() && m.role == Role::Assistant) - .flat_map(|message| { - message.content.iter().filter_map(|content| { - if let MessageContent::ToolRequest(request) = content { + MessageContent::ToolRequest(request) => { + flush_text(&mut items, role, &mut content_items); if let Ok(tool_call) = &request.tool_call { - let arguments_str = tool_call - .arguments - .as_ref() - .map(|args| { - serde_json::to_string(args).unwrap_or_else(|_| "{}".to_string()) - }) - .unwrap_or_else(|| "{}".to_string()); - - return Some(json!({ + let arguments_str = match tool_call.arguments.as_ref() { + Some(args) => serde_json::to_string(args)?, + None => "{}".to_string(), + }; + items.push(json!({ "type": "function_call", "call_id": request.id, "name": tool_call.name, @@ -116,19 +115,8 @@ fn extract_function_calls(messages: &[Message]) -> Vec { })); } } - None - }) - }) - .collect() -} - -fn extract_tool_responses(messages: &[Message]) -> Vec { - messages - .iter() - .filter(|m| m.is_agent_visible()) - .flat_map(|message| { - message.content.iter().filter_map(|content| { - if let MessageContent::ToolResponse(response) = content { + MessageContent::ToolResponse(response) => { + flush_text(&mut items, role, &mut content_items); match &response.tool_result { Ok(contents) => { let text_content: Vec = contents @@ -142,9 +130,8 @@ fn extract_tool_responses(messages: &[Message]) -> Vec { } }) .collect(); - if !text_content.is_empty() { - return Some(json!({ + items.push(json!({ "type": "function_call_output", "call_id": response.id, "output": text_content.join("\n") @@ -152,7 +139,7 @@ fn extract_tool_responses(messages: &[Message]) -> Vec { } } Err(error_data) => { - return Some(json!({ + items.push(json!({ "type": "function_call_output", "call_id": response.id, "output": format!("Error: {}", error_data.message) @@ -160,10 +147,14 @@ fn extract_tool_responses(messages: &[Message]) -> Vec { } } } - None - }) - }) - .collect() + _ => {} + } + } + + flush_text(&mut items, role, &mut content_items); + } + + Ok(items) } fn create_codex_request( @@ -172,9 +163,7 @@ fn create_codex_request( messages: &[Message], tools: &[Tool], ) -> Result { - let mut input_items = extract_message_content(messages); - input_items.extend(extract_function_calls(messages)); - input_items.extend(extract_tool_responses(messages)); + let input_items = build_input_items(messages)?; let mut payload = json!({ "model": model_config.model_name, @@ -183,6 +172,10 @@ fn create_codex_request( "instructions": system, }); + let payload_obj = payload + .as_object_mut() + .ok_or_else(|| anyhow!("Codex payload must be a JSON object"))?; + if !tools.is_empty() { let tools_spec: Vec = tools .iter() @@ -196,17 +189,11 @@ fn create_codex_request( }) .collect(); - payload - .as_object_mut() - .unwrap() - .insert("tools".to_string(), json!(tools_spec)); + payload_obj.insert("tools".to_string(), json!(tools_spec)); } if let Some(temp) = model_config.temperature { - payload - .as_object_mut() - .unwrap() - .insert("temperature".to_string(), json!(temp)); + payload_obj.insert("temperature".to_string(), json!(temp)); } Ok(payload) @@ -222,43 +209,20 @@ struct TokenData { } #[derive(Debug, Clone)] -struct TokenCache { - cache_path: PathBuf, -} - -fn get_cache_path() -> PathBuf { - Paths::in_config_dir("chatgpt_codex/tokens.json") -} +struct TokenCache {} impl TokenCache { - fn new() -> Self { - let cache_path = get_cache_path(); - if let Some(parent) = cache_path.parent() { - let _ = std::fs::create_dir_all(parent); - } - Self { cache_path } - } - fn load(&self) -> Option { - if let Ok(contents) = std::fs::read_to_string(&self.cache_path) { - serde_json::from_str(&contents).ok() - } else { - None - } + let config = Config::global(); + let token = config.get_secret::(CHATGPT_CODEX_TOKEN_KEY); + token.ok() } fn save(&self, token_data: &TokenData) -> Result<()> { - if let Some(parent) = self.cache_path.parent() { - std::fs::create_dir_all(parent)?; - } - let contents = serde_json::to_string(token_data)?; - std::fs::write(&self.cache_path, contents)?; + let config = Config::global(); + config.set_secret(CHATGPT_CODEX_TOKEN_KEY, token_data)?; Ok(()) } - - fn clear(&self) { - let _ = std::fs::remove_file(&self.cache_path); - } } #[derive(Debug, Deserialize)] @@ -279,7 +243,61 @@ struct OrgInfo { id: String, } -fn parse_jwt_claims(token: &str) -> Option { +#[derive(Debug, Deserialize)] +struct OidcConfiguration { + jwks_uri: String, +} + +async fn fetch_jwks_for(issuer: &str) -> Result { + let client = reqwest::Client::new(); + let config_url = format!("{}/.well-known/openid-configuration", issuer); + let config = client + .get(config_url) + .send() + .await? + .error_for_status()? + .json::() + .await?; + + let jwks = client + .get(config.jwks_uri) + .send() + .await? + .error_for_status()? + .json::() + .await?; + + Ok(jwks) +} + +async fn get_jwks(state: &ChatGptCodexAuthState) -> Result { + let mut cache = state.jwks_cache.lock().await; + if let Some(jwks) = cache.clone() { + return Ok(jwks); + } + let jwks = fetch_jwks_for(ISSUER).await?; + *cache = Some(jwks.clone()); + Ok(jwks) +} + +fn parse_jwt_claims_with_jwks(token: &str, jwks: &JwkSet) -> Result { + let header = decode_header(token)?; + let kid = header + .kid + .ok_or_else(|| anyhow!("JWT header missing kid"))?; + let jwk = jwks + .find(&kid) + .ok_or_else(|| anyhow!("JWT signing key not found"))?; + let decoding_key = DecodingKey::from_jwk(jwk)?; + + let mut validation = Validation::new(header.alg); + validation.validate_aud = false; + + let token_data = decode::(token, &decoding_key, &validation)?; + Ok(token_data.claims) +} + +fn parse_jwt_claims_unverified(token: &str) -> Option { let parts: Vec<&str> = token.split('.').collect(); if parts.len() != 3 { return None; @@ -290,40 +308,47 @@ fn parse_jwt_claims(token: &str) -> Option { serde_json::from_slice(&payload).ok() } -fn extract_account_id(token_data: &TokenData) -> Option { - if let Some(ref id_token) = token_data.id_token { - if let Some(claims) = parse_jwt_claims(id_token) { - if let Some(id) = claims.chatgpt_account_id { - return Some(id); - } - if let Some(auth) = claims.auth_claims { - if let Some(id) = auth.chatgpt_account_id { - return Some(id); - } - } - if let Some(orgs) = claims.organizations { - if let Some(org) = orgs.first() { - return Some(org.id.clone()); - } - } +async fn parse_jwt_claims(token: &str, state: &ChatGptCodexAuthState) -> Option { + if let Ok(jwks) = get_jwks(state).await { + if let Ok(claims) = parse_jwt_claims_with_jwks(token, &jwks) { + return Some(claims); } } - if let Some(claims) = parse_jwt_claims(&token_data.access_token) { - if let Some(id) = claims.chatgpt_account_id { - return Some(id); + parse_jwt_claims_unverified(token) +} + +fn account_id_from_claims(claims: &JwtClaims) -> Option { + if let Some(id) = claims.chatgpt_account_id.as_ref() { + return Some(id.clone()); + } + if let Some(auth) = claims.auth_claims.as_ref() { + if let Some(id) = auth.chatgpt_account_id.as_ref() { + return Some(id.clone()); } - if let Some(auth) = claims.auth_claims { - if let Some(id) = auth.chatgpt_account_id { - return Some(id); - } + } + if let Some(orgs) = claims.organizations.as_ref() { + if let Some(org) = orgs.first() { + return Some(org.id.clone()); } - if let Some(orgs) = claims.organizations { - if let Some(org) = orgs.first() { - return Some(org.id.clone()); + } + None +} + +async fn extract_account_id( + token_data: &TokenData, + state: &ChatGptCodexAuthState, +) -> Option { + if let Some(id_token) = token_data.id_token.as_deref() { + if let Some(claims) = parse_jwt_claims(id_token, state).await { + if let Some(account_id) = account_id_from_claims(&claims) { + return Some(account_id); } } } - None + + parse_jwt_claims(&token_data.access_token, state) + .await + .and_then(|claims| account_id_from_claims(&claims)) } struct PkceChallenge { @@ -345,7 +370,7 @@ fn generate_state() -> String { nanoid::nanoid!(32) } -fn build_authorize_url(redirect_uri: &str, pkce: &PkceChallenge, state: &str) -> String { +fn build_authorize_url(redirect_uri: &str, pkce: &PkceChallenge, state: &str) -> Result { let scopes = OAUTH_SCOPES.join(" "); let params = [ ("response_type", "code"), @@ -359,11 +384,8 @@ fn build_authorize_url(redirect_uri: &str, pkce: &PkceChallenge, state: &str) -> ("state", state), ("originator", "goose"), ]; - format!( - "{}/oauth/authorize?{}", - ISSUER, - serde_urlencoded::to_string(params).unwrap() - ) + let query = serde_urlencoded::to_string(params)?; + Ok(format!("{}/oauth/authorize?{}", ISSUER, query)) } #[derive(Debug, Deserialize)] @@ -374,7 +396,8 @@ struct TokenResponse { expires_in: Option, } -async fn exchange_code_for_tokens( +async fn exchange_code_for_tokens_with_issuer( + issuer: &str, code: &str, redirect_uri: &str, pkce: &PkceChallenge, @@ -389,7 +412,7 @@ async fn exchange_code_for_tokens( ]; let resp = client - .post(format!("{}/oauth/token", ISSUER)) + .post(format!("{}/oauth/token", issuer)) .header("Content-Type", "application/x-www-form-urlencoded") .form(¶ms) .send() @@ -404,7 +427,10 @@ async fn exchange_code_for_tokens( Ok(resp.json().await?) } -async fn refresh_access_token(refresh_token: &str) -> Result { +async fn refresh_access_token_with_issuer( + issuer: &str, + refresh_token: &str, +) -> Result { let client = reqwest::Client::new(); let params = [ ("grant_type", "refresh_token"), @@ -413,7 +439,7 @@ async fn refresh_access_token(refresh_token: &str) -> Result { ]; let resp = client - .post(format!("{}/oauth/token", ISSUER)) + .post(format!("{}/oauth/token", issuer)) .header("Content-Type", "application/x-www-form-urlencoded") .form(¶ms) .send() @@ -499,31 +525,22 @@ fn html_error(error: &str) -> String { ) } -async fn perform_oauth_flow() -> Result { - let _guard = OAUTH_MUTEX.lock().await; - - let pkce = generate_pkce(); - let state = generate_state(); - let redirect_uri = format!("http://localhost:{}/auth/callback", OAUTH_PORT); - - let (tx, rx) = oneshot::channel::>(); - let tx = Arc::new(TokioMutex::new(Some(tx))); - let expected_state = state.clone(); - let pkce_for_handler = Arc::new(pkce.verifier.clone()); - - #[derive(Deserialize)] - struct CallbackParams { - code: Option, - state: Option, - error: Option, - error_description: Option, - } +#[derive(Deserialize)] +struct CallbackParams { + code: Option, + state: Option, + error: Option, + error_description: Option, +} - let tx_clone = tx.clone(); - let app = Router::new().route( +fn oauth_callback_router( + expected_state: String, + tx: Arc>>>>, +) -> Router { + Router::new().route( "/auth/callback", get(move |Query(params): Query| { - let tx = tx_clone.clone(); + let tx = tx.clone(); let expected = expected_state.clone(); async move { if let Some(error) = params.error { @@ -559,32 +576,47 @@ async fn perform_oauth_flow() -> Result { Html(HTML_SUCCESS.to_string()) } }), - ); + ) +} +async fn spawn_oauth_server(app: Router) -> Result> { let addr = SocketAddr::from(([127, 0, 0, 1], OAUTH_PORT)); let listener = tokio::net::TcpListener::bind(addr).await?; - let server_handle = tokio::spawn(async move { + Ok(tokio::spawn(async move { let server = axum::serve(listener, app); let _ = server.await; - }); + })) +} - let auth_url = build_authorize_url(&redirect_uri, &pkce, &state); +async fn wait_for_oauth_code(rx: oneshot::Receiver>) -> Result { + let code_result = tokio::time::timeout(std::time::Duration::from_secs(300), rx).await; + code_result + .map_err(|_| anyhow!("OAuth flow timed out"))?? + .map_err(|e| anyhow!("OAuth callback error: {}", e)) +} + +async fn perform_oauth_flow(auth_state: &ChatGptCodexAuthState) -> Result { + let _guard = auth_state.oauth_mutex.lock().await; + + let pkce = generate_pkce(); + let csrf_state = generate_state(); + let redirect_uri = format!("http://localhost:{}/auth/callback", OAUTH_PORT); + + let (tx, rx) = oneshot::channel::>(); + let tx = Arc::new(TokioMutex::new(Some(tx))); + let app = oauth_callback_router(csrf_state.clone(), tx); + let server_handle = spawn_oauth_server(app).await?; + + let auth_url = build_authorize_url(&redirect_uri, &pkce, &csrf_state)?; if webbrowser::open(&auth_url).is_err() { println!("Please open this URL in your browser:\n{}", auth_url); } - let code = tokio::time::timeout(std::time::Duration::from_secs(300), rx) - .await - .map_err(|_| anyhow!("OAuth flow timed out"))?? - .map_err(|e| anyhow!("OAuth callback error: {}", e))?; - + let code_result = wait_for_oauth_code(rx).await; server_handle.abort(); + let code = code_result?; - let pkce_challenge = PkceChallenge { - verifier: (*pkce_for_handler).clone(), - challenge: pkce.challenge, - }; - let tokens = exchange_code_for_tokens(&code, &redirect_uri, &pkce_challenge).await?; + let tokens = exchange_code_for_tokens_with_issuer(ISSUER, &code, &redirect_uri, &pkce).await?; let expires_at = Utc::now() + chrono::Duration::seconds(tokens.expires_in.unwrap_or(3600)); @@ -596,7 +628,7 @@ async fn perform_oauth_flow() -> Result { account_id: None, }; - token_data.account_id = extract_account_id(&token_data); + token_data.account_id = extract_account_id(&token_data, auth_state).await; Ok(token_data) } @@ -604,12 +636,14 @@ async fn perform_oauth_flow() -> Result { #[derive(Debug)] struct ChatGptCodexAuthProvider { cache: TokenCache, + state: Arc, } impl ChatGptCodexAuthProvider { - fn new() -> Self { + fn new(state: Arc) -> Self { Self { - cache: TokenCache::new(), + cache: TokenCache {}, + state, } } @@ -620,7 +654,7 @@ impl ChatGptCodexAuthProvider { } tracing::debug!("Token expired, attempting refresh"); - match refresh_access_token(&token_data.refresh_token).await { + match refresh_access_token_with_issuer(ISSUER, &token_data.refresh_token).await { Ok(new_tokens) => { token_data.access_token = new_tokens.access_token; token_data.refresh_token = new_tokens.refresh_token; @@ -630,7 +664,8 @@ impl ChatGptCodexAuthProvider { token_data.expires_at = Utc::now() + chrono::Duration::seconds(new_tokens.expires_in.unwrap_or(3600)); if token_data.account_id.is_none() { - token_data.account_id = extract_account_id(&token_data); + token_data.account_id = + extract_account_id(&token_data, self.state.as_ref()).await; } self.cache.save(&token_data)?; tracing::info!("Token refreshed successfully"); @@ -638,13 +673,13 @@ impl ChatGptCodexAuthProvider { } Err(e) => { tracing::warn!("Token refresh failed, will re-authenticate: {}", e); - self.cache.clear(); + let _ = Config::global().delete_secret(CHATGPT_CODEX_TOKEN_KEY); } } } tracing::info!("Starting OAuth flow for ChatGPT Codex"); - let token_data = perform_oauth_flow().await?; + let token_data = perform_oauth_flow(self.state.as_ref()).await?; self.cache.save(&token_data)?; Ok(token_data) } @@ -672,7 +707,9 @@ pub struct ChatGptCodexProvider { impl ChatGptCodexProvider { pub async fn from_env(model: ModelConfig) -> Result { - let auth_provider = Arc::new(ChatGptCodexAuthProvider::new()); + let auth_provider = Arc::new(ChatGptCodexAuthProvider::new( + ChatGptCodexAuthState::instance(), + )); Ok(Self { auth_provider, @@ -857,3 +894,270 @@ impl Provider for ChatGptCodexProvider { )) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::conversation::message::Message; + use jsonwebtoken::{Algorithm, EncodingKey, Header}; + use rmcp::model::{CallToolRequestParam, CallToolResult, Content, ErrorCode, ErrorData}; + use rmcp::object; + use test_case::test_case; + use wiremock::matchers::{body_string_contains, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn input_kinds(payload: &Value) -> Vec { + payload["input"] + .as_array() + .map(|items| { + items + .iter() + .map(|item| { + if let Some(role) = item.get("role").and_then(|r| r.as_str()) { + format!("message:{role}") + } else { + item.get("type") + .and_then(|t| t.as_str()) + .unwrap_or("unknown") + .to_string() + } + }) + .collect() + }) + .unwrap_or_default() + } + + #[test_case( + vec![ + Message::user().with_text("user text"), + Message::assistant().with_text("assistant prelude").with_tool_request( + "call-1", + Ok(CallToolRequestParam { + task: None, + name: "tool_name".into(), + arguments: Some(object!({"param": "value"})), + }), + ), + Message::user().with_tool_response( + "call-1", + Ok(CallToolResult::success(vec![Content::text("tool output")])), + ), + Message::assistant().with_text("assistant follow-up"), + ], + vec![ + "message:user".to_string(), + "message:assistant".to_string(), + "function_call".to_string(), + "function_call_output".to_string(), + "message:assistant".to_string(), + ]; + "preserves order when assistant includes text" + )] + #[test_case( + vec![ + Message::user().with_text("user text"), + Message::assistant().with_tool_request( + "call-1", + Ok(CallToolRequestParam { + task: None, + name: "tool_name".into(), + arguments: Some(object!({"param": "value"})), + }), + ), + Message::user().with_tool_response( + "call-1", + Ok(CallToolResult::success(vec![Content::text("tool output")])), + ), + Message::assistant().with_text("assistant follow-up"), + ], + vec![ + "message:user".to_string(), + "function_call".to_string(), + "function_call_output".to_string(), + "message:assistant".to_string(), + ]; + "skips empty assistant message and preserves tool order" + )] + #[test_case( + vec![ + Message::user().with_text("user text"), + Message::assistant().with_tool_request( + "call-1", + Ok(CallToolRequestParam { + task: None, + name: "tool_name".into(), + arguments: Some(object!({"param": "value"})), + }), + ), + Message::user().with_tool_response( + "call-1", + Err(ErrorData::new(ErrorCode::INTERNAL_ERROR, "boom", None)), + ), + ], + vec![ + "message:user".to_string(), + "function_call".to_string(), + "function_call_output".to_string(), + ]; + "includes tool error output" + )] + fn test_codex_input_order(messages: Vec, expected: Vec) { + let items = build_input_items(&messages).unwrap(); + let payload = json!({ "input": items }); + let kinds = input_kinds(&payload); + assert_eq!(kinds, expected); + } + + #[test_case( + JwtClaims { + chatgpt_account_id: Some("account-1".to_string()), + auth_claims: None, + organizations: None, + }, + Some("account-1".to_string()); + "uses top-level account id" + )] + #[test_case( + JwtClaims { + chatgpt_account_id: None, + auth_claims: Some(AuthClaims { + chatgpt_account_id: Some("account-2".to_string()), + }), + organizations: None, + }, + Some("account-2".to_string()); + "uses auth claims account id" + )] + #[test_case( + JwtClaims { + chatgpt_account_id: None, + auth_claims: None, + organizations: Some(vec![OrgInfo { + id: "org-1".to_string(), + }]), + }, + Some("org-1".to_string()); + "falls back to first organization" + )] + fn test_account_id_from_claims(claims: JwtClaims, expected: Option) { + assert_eq!(account_id_from_claims(&claims), expected); + } + + #[tokio::test] + async fn test_exchange_code_for_tokens() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .and(body_string_contains("grant_type=authorization_code")) + .and(body_string_contains("code=code-123")) + .and(body_string_contains( + "redirect_uri=http%3A%2F%2Flocalhost%2Fcallback", + )) + .and(body_string_contains("code_verifier=verifier-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "access-1", + "refresh_token": "refresh-1", + "id_token": "id-1", + "expires_in": 3600 + }))) + .mount(&server) + .await; + + let pkce = PkceChallenge { + verifier: "verifier-123".to_string(), + challenge: "challenge-123".to_string(), + }; + let tokens = exchange_code_for_tokens_with_issuer( + &server.uri(), + "code-123", + "http://localhost/callback", + &pkce, + ) + .await + .unwrap(); + + assert_eq!(tokens.access_token, "access-1"); + assert_eq!(tokens.refresh_token, "refresh-1"); + assert_eq!(tokens.id_token.as_deref(), Some("id-1")); + assert_eq!(tokens.expires_in, Some(3600)); + } + + #[tokio::test] + async fn test_refresh_access_token() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .and(body_string_contains("grant_type=refresh_token")) + .and(body_string_contains("refresh_token=refresh-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "access-2", + "refresh_token": "refresh-2", + "id_token": "id-2", + "expires_in": 1800 + }))) + .mount(&server) + .await; + + let tokens = refresh_access_token_with_issuer(&server.uri(), "refresh-123") + .await + .unwrap(); + + assert_eq!(tokens.access_token, "access-2"); + assert_eq!(tokens.refresh_token, "refresh-2"); + assert_eq!(tokens.id_token.as_deref(), Some("id-2")); + assert_eq!(tokens.expires_in, Some(1800)); + } + + #[derive(Serialize)] + struct TestClaims { + exp: usize, + chatgpt_account_id: Option, + } + + #[tokio::test] + async fn test_parse_jwt_claims_verified_with_issuer() { + let server = MockServer::start().await; + let jwks_uri = format!("{}/jwks", server.uri()); + Mock::given(method("GET")) + .and(path("/.well-known/openid-configuration")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "jwks_uri": jwks_uri + }))) + .mount(&server) + .await; + + let secret = "test-secret"; + let key = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(secret); + Mock::given(method("GET")) + .and(path("/jwks")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "keys": [{ + "kty": "oct", + "alg": "HS256", + "kid": "test-kid", + "k": key + }] + }))) + .mount(&server) + .await; + + let mut header = Header::new(Algorithm::HS256); + header.kid = Some("test-kid".to_string()); + + let claims = TestClaims { + exp: (Utc::now() + chrono::Duration::seconds(60)).timestamp() as usize, + chatgpt_account_id: Some("account-1".to_string()), + }; + let token = jsonwebtoken::encode( + &header, + &claims, + &EncodingKey::from_secret(secret.as_bytes()), + ) + .unwrap(); + + let jwks = fetch_jwks_for(&server.uri()).await.unwrap(); + let claims = parse_jwt_claims_with_jwks(&token, &jwks).unwrap(); + + assert_eq!(claims.chatgpt_account_id.as_deref(), Some("account-1")); + } +} diff --git a/scripts/clippy-baseline.sh b/scripts/clippy-baseline.sh index e0ed230f874c..3c492ddb1427 100755 --- a/scripts/clippy-baseline.sh +++ b/scripts/clippy-baseline.sh @@ -27,7 +27,9 @@ parse_violation() { case "$violation_parser" in "function_name") jq -r 'select(.message.code.code == "'"$rule_code"'") | - "\(.message.spans[0].file_name)::\(.message.spans[0].text[0].text | split("fn ")[1] | split("(")[0])"' + .message.spans[0] as $span | + ($span.text | map(.text) | map(select(test("\\bfn\\b"))) | first // "") as $line | + if $line == "" then empty else "\($span.file_name)::\($line | capture("fn\\s+(?[A-Za-z0-9_]+)") | .name)" end' ;; "type_name") jq -r 'select(.message.code.code == "'"$rule_code"'") | From 5b86822e81fc1c76af2b1d7385e70ab5bd2aef4b Mon Sep 17 00:00:00 2001 From: Adrian Cole Date: Thu, 22 Jan 2026 17:14:12 +0900 Subject: [PATCH 10/10] bot-gods Signed-off-by: Adrian Cole --- Cargo.lock | 7 ++ .../src/routes/config_management.rs | 11 ++ crates/goose/Cargo.toml | 1 + crates/goose/src/providers/chatgpt_codex.rs | 117 ++++++++++++++---- scripts/clippy-baseline.sh | 2 +- .../modal/ProviderConfiguationModal.tsx | 34 +++-- 6 files changed, 138 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 181fe82ecfd1..6aebf48de709 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3050,6 +3050,7 @@ dependencies = [ "urlencoding", "utoipa", "uuid", + "v_htmlescape", "webbrowser", "which 8.0.0", "winapi", @@ -8584,6 +8585,12 @@ dependencies = [ "vsimd", ] +[[package]] +name = "v_htmlescape" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" + [[package]] name = "valuable" version = "0.1.1" diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs index 51ae9577900e..3dbc23d4f8fe 100644 --- a/crates/goose-server/src/routes/config_management.rs +++ b/crates/goose-server/src/routes/config_management.rs @@ -210,6 +210,13 @@ fn mask_secret(secret: Value) -> String { format!("{}{}", visible, mask) } +fn is_valid_provider_name(provider_name: &str) -> bool { + !provider_name.is_empty() + && provider_name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') +} + #[utoipa::path( post, path = "/config/read", @@ -840,6 +847,10 @@ pub async fn configure_provider_oauth( use goose::model::ModelConfig; use goose::providers::create; + if !is_valid_provider_name(&provider_name) { + return Err((StatusCode::BAD_REQUEST, "Invalid provider name".to_string())); + } + let temp_model = ModelConfig::new("temp").map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index 4758621ded71..e76a8fe15404 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -77,6 +77,7 @@ rand = "0.8.5" utoipa = { version = "4.1", features = ["chrono"] } tokio-cron-scheduler = "0.14.0" urlencoding = "2.1" +v_htmlescape = "0.15" sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "chrono", "json"] } # For Bedrock provider diff --git a/crates/goose/src/providers/chatgpt_codex.rs b/crates/goose/src/providers/chatgpt_codex.rs index 9463cb7ad1bc..0a96dc9fb35a 100644 --- a/crates/goose/src/providers/chatgpt_codex.rs +++ b/crates/goose/src/providers/chatgpt_codex.rs @@ -1,4 +1,4 @@ -use crate::config::Config; +use crate::config::paths::Paths; use crate::conversation::message::{Message, MessageContent}; use crate::model::ModelConfig; use crate::providers::api_client::AuthProvider; @@ -23,6 +23,7 @@ use sha2::Digest; use std::io; use std::net::SocketAddr; use std::ops::Deref; +use std::path::PathBuf; use std::sync::{Arc, LazyLock}; use tokio::pin; use tokio::sync::{oneshot, Mutex as TokioMutex}; @@ -33,8 +34,12 @@ const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; const ISSUER: &str = "https://auth.openai.com"; const CODEX_API_ENDPOINT: &str = "https://chatgpt.com/backend-api/codex"; const OAUTH_SCOPES: &[&str] = &["openid", "profile", "email", "offline_access"]; +// Canonical localhost callback port for Codex OAuth (default localhost:1455 per OpenAI docs). +// https://developers.openai.com/codex/auth/ const OAUTH_PORT: u16 = 1455; -const CHATGPT_CODEX_TOKEN_KEY: &str = "CHATGPT_CODEX_TOKEN"; +// Allow time for users to complete the browser-based OAuth flow. +const OAUTH_TIMEOUT_SECS: u64 = 300; +const HTML_AUTO_CLOSE_TIMEOUT_MS: u64 = 2000; pub const CHATGPT_CODEX_DEFAULT_MODEL: &str = "gpt-5.1-codex"; pub const CHATGPT_CODEX_KNOWN_MODELS: &[&str] = &[ @@ -209,20 +214,43 @@ struct TokenData { } #[derive(Debug, Clone)] -struct TokenCache {} +struct TokenCache { + cache_path: PathBuf, +} + +fn get_cache_path() -> PathBuf { + Paths::in_config_dir("chatgpt_codex/tokens.json") +} impl TokenCache { + fn new() -> Self { + let cache_path = get_cache_path(); + if let Some(parent) = cache_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + Self { cache_path } + } + fn load(&self) -> Option { - let config = Config::global(); - let token = config.get_secret::(CHATGPT_CODEX_TOKEN_KEY); - token.ok() + if let Ok(contents) = std::fs::read_to_string(&self.cache_path) { + serde_json::from_str(&contents).ok() + } else { + None + } } fn save(&self, token_data: &TokenData) -> Result<()> { - let config = Config::global(); - config.set_secret(CHATGPT_CODEX_TOKEN_KEY, token_data)?; + if let Some(parent) = self.cache_path.parent() { + std::fs::create_dir_all(parent)?; + } + let contents = serde_json::to_string(token_data)?; + std::fs::write(&self.cache_path, contents)?; Ok(()) } + + fn clear(&self) { + let _ = std::fs::remove_file(&self.cache_path); + } } #[derive(Debug, Deserialize)] @@ -454,10 +482,10 @@ async fn refresh_access_token_with_issuer( Ok(resp.json().await?) } -const HTML_SUCCESS: &str = r#" +const HTML_SUCCESS_TEMPLATE: &str = r#" - Goose - ChatGPT Authorization Successful + goose - ChatGPT Authorization Successful