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
+ {/* 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
-
-
-
-
+
+
+
+
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