From 5046aebca311b0aae5153a2ab29d6714e6dd8d9c Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 16 Sep 2026 10:17:05 -0600 Subject: [PATCH] feat: support config-driven OAuth endpoints on the openai and gemini clients --- config.example.yaml | 5 +- src/client/claude.rs | 153 +++++++--- src/client/gemini.rs | 400 +++++++++++++++++++++++++- src/client/model.rs | 21 ++ src/client/oauth.rs | 178 +++++++++++- src/client/openai.rs | 665 ++++++++++++++++++++++++++++++++++++------- 6 files changed, 1261 insertions(+), 161 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index d9ef541..8be737d 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -338,6 +338,9 @@ clients: api_base: https://api.openai.com/v1 # Optional api_key: '{{OPENAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault organization_id: org-xxx # Optional + auth: null # When set to 'oauth', Coyote will use OAuth instead of an API key + # Authenticate with `coyote --authenticate` or `.authenticate` in the REPL + # Supports a custom `oauth:` block like the claude client below. `api_base` is REQUIRED with it # For any platform compatible with OpenAI's API - type: openai-compatible @@ -582,4 +585,4 @@ clients: - type: openai-compatible name: voyageai api_base: https://api.voyageai.com/v1 - api_key: '{{VOYAGEAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault \ No newline at end of file + api_key: '{{VOYAGEAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault diff --git a/src/client/claude.rs b/src/client/claude.rs index 5e4fe68..2cb67e0 100644 --- a/src/client/claude.rs +++ b/src/client/claude.rs @@ -71,6 +71,16 @@ async fn prepare_chat_completions( client: &ReqwestClient, data: ChatCompletionsData, ) -> Result { + let uses_oauth = self_.config.auth.as_deref() == Some("oauth"); + + if !uses_oauth && self_.config.oauth.is_some() { + bail!( + "'{}' has an `oauth:` block configured but `auth: oauth` is not set; the oauth block would be ignored. Set `auth: oauth` (and run 'coyote --authenticate {}') or remove the oauth block.", + self_.name(), + self_.name() + ); + } + let api_base = resolve_api_base(self_)?; let url = format!("{}/messages", api_base.trim_end_matches('/')); @@ -84,30 +94,9 @@ async fn prepare_chat_completions( request_data.header("anthropic-version", "2023-06-01"); - let uses_oauth = self_.config.auth.as_deref() == Some("oauth"); - - if !uses_oauth && self_.config.oauth.is_some() { - bail!( - "'{}' has an `oauth:` block configured but `auth: oauth` is not set; the oauth block would be ignored. Set `auth: oauth` (and run 'coyote --authenticate {}') or remove the oauth block.", - self_.name(), - self_.name() - ); - } - if uses_oauth { let client_name = self_.name(); - let app_config = self_.app_config(); - let cc = app_config - .clients - .iter() - .find(|cc| { - matches!( - cc, - ClientConfig::ClaudeConfig(c) - if c.name.as_deref().unwrap_or(ClaudeClient::NAME) == client_name - ) - }) - .ok_or_else(|| anyhow!("Could not locate ClientConfig entry for '{}'", client_name))?; + let cc = locate_client_config(self_)?; let (provider, uses_stock_provider) = oauth::claude_oauth_provider_for_client(cc, &ALL_PROVIDER_MODELS); let ready = oauth::prepare_oauth_access_token(client, &*provider, client_name).await?; @@ -139,19 +128,48 @@ async fn prepare_chat_completions( Ok(request_data) } -/// A config-driven `oauth:` block requires an explicit `api_base`; the stock fallback would send -/// the oauth token to the wrong host. fn resolve_api_base(self_: &ClaudeClient) -> Result { - match self_.get_api_base() { - Ok(api_base) => Ok(api_base), - Err(_) if self_.config.oauth.is_none() => Ok(API_BASE.to_string()), - Err(_) => bail!( - "'{}' has a custom `oauth:` block but no `api_base`; refusing to fall back to {} (the oauth token would be sent to the wrong host). Set `api_base` on the '{}' client entry.", + resolve_api_base_against(self_, &ALL_PROVIDER_MODELS) +} + +fn resolve_api_base_against( + self_: &ClaudeClient, + all_provider_models: &[ProviderModels], +) -> Result { + if let Ok(api_base) = self_.get_api_base() { + return Ok(api_base); + } + let uses_config_oauth = self_.config.auth.as_deref() == Some("oauth") + && match locate_client_config(self_) { + Ok(cc) => oauth::config_oauth_for_client(cc, all_provider_models).is_some(), + // Safe: oauth prepare paths bail on this locate failure before attaching any token. + Err(_) => self_.config.oauth.is_some(), + }; + if uses_config_oauth { + bail!( + "'{}' has a config-driven OAuth configuration (an `oauth:` block in the client entry or models catalog) but no `api_base`; refusing to fall back to {} (the oauth token would be sent to the wrong host). Set `api_base` on the '{}' client entry.", self_.name(), API_BASE, self_.name() - ), + ); } + Ok(API_BASE.to_string()) +} + +fn locate_client_config(self_: &ClaudeClient) -> Result<&ClientConfig> { + let client_name = self_.name(); + self_ + .app_config() + .clients + .iter() + .find(|cc| { + matches!( + cc, + ClientConfig::ClaudeConfig(c) + if c.name.as_deref().unwrap_or(ClaudeClient::NAME) == client_name + ) + }) + .ok_or_else(|| anyhow!("Could not locate ClientConfig entry for '{}'", client_name)) } /// Anthropic requires OAuth-authenticated requests to include a Claude Code @@ -951,7 +969,9 @@ mod tests { ClaudeConfig { name: Some(name.into()), api_key: None, - api_base: oauth.as_ref().map(|_| "https://gateway.example/v1".to_string()), + api_base: oauth + .as_ref() + .map(|_| "https://gateway.example/v1".to_string()), auth: auth.map(str::to_string), oauth: oauth.map(Box::new), models: vec![], @@ -1031,7 +1051,12 @@ mod tests { let name = "claude-gate-gateway-test"; let config = claude_config(name, Some("oauth"), Some(minimal_oauth_config())); let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]); - set_access_token(name, "gateway-at".into(), Utc::now().timestamp() + 3600, None); + set_access_token( + name, + "gateway-at".into(), + Utc::now().timestamp() + 3600, + None, + ); let request_data = prepare(&client).unwrap(); @@ -1109,6 +1134,63 @@ mod tests { ); } + #[test] + fn catalog_only_oauth_block_without_api_base_is_rejected() { + let name = "claude-gate-catalog-oauth-test"; + let config = claude_config(name, Some("oauth"), None); + let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]); + let catalog = vec![ProviderModels { + provider: name.into(), + oauth: Some(minimal_oauth_config()), + models: vec![], + }]; + + let err = resolve_api_base_against(&client, &catalog) + .unwrap_err() + .to_string(); + + assert!( + err.contains(name) && err.contains("refusing to fall back"), + "unexpected error: {err}" + ); + } + + #[test] + fn catalog_oauth_block_with_api_key_auth_falls_back_to_stock_api_base() { + let name = "claude-gate-catalog-apikey-test"; + let mut config = claude_config(name, None, None); + config.api_key = Some("sk-test".into()); + let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]); + let catalog = vec![ProviderModels { + provider: name.into(), + oauth: Some(minimal_oauth_config()), + models: vec![], + }]; + + let api_base = resolve_api_base_against(&client, &catalog).unwrap(); + + assert_eq!(api_base, API_BASE); + } + + #[test] + fn auth_mismatch_bails_before_missing_api_base_guard() { + let name = "claude-gate-bail-order-test"; + let mut config = claude_config(name, None, Some(minimal_oauth_config())); + config.api_base = None; + let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(config)]); + + let err = match prepare(&client) { + Ok(_) => panic!("expected the contradictory config to be rejected"), + Err(err) => err.to_string(), + }; + + assert!( + err.contains("has an `oauth:` block configured but `auth: oauth` is not set") + && !err.contains("refusing to fall back"), + "unexpected error: {err}" + ); + } + #[test] fn oauth_block_with_api_base_resolves_it() { let name = "claude-gate-apibase-set-test"; @@ -1145,7 +1227,12 @@ mod tests { ClientConfig::ClaudeConfig(config), ], ); - set_access_token(name, "lookup-at".into(), Utc::now().timestamp() + 3600, None); + set_access_token( + name, + "lookup-at".into(), + Utc::now().timestamp() + 3600, + None, + ); let request_data = prepare(&client).unwrap(); diff --git a/src/client/gemini.rs b/src/client/gemini.rs index 32bd0c2..daab901 100644 --- a/src/client/gemini.rs +++ b/src/client/gemini.rs @@ -1,10 +1,10 @@ use super::access_token::get_access_token; use super::gemini_oauth::GeminiOAuthProvider; -use super::oauth; +use super::oauth::{self, OAuthConfig, OAuthProvider}; use super::vertexai::*; use super::*; -use anyhow::{Context, Result, bail}; +use anyhow::{Context, Result, anyhow, bail}; use reqwest::{Client as ReqwestClient, RequestBuilder}; use serde::Deserialize; use serde_json::{Value, json}; @@ -17,6 +17,7 @@ pub struct GeminiConfig { pub api_key: Option, pub api_base: Option, pub auth: Option, + pub oauth: Option>, #[serde(default)] pub models: Vec, pub patch: Option, @@ -85,9 +86,17 @@ async fn prepare_chat_completions( client: &ReqwestClient, data: ChatCompletionsData, ) -> Result { - let api_base = self_ - .get_api_base() - .unwrap_or_else(|_| API_BASE.to_string()); + let uses_oauth = self_.config.auth.as_deref() == Some("oauth"); + + if !uses_oauth && self_.config.oauth.is_some() { + bail!( + "'{}' has an `oauth:` block configured but `auth: oauth` is not set; the oauth block would be ignored. Set `auth: oauth` (and run 'coyote --authenticate {}') or remove the oauth block.", + self_.name(), + self_.name() + ); + } + + let api_base = resolve_api_base(self_)?; let func = match data.stream { true => "streamGenerateContent", @@ -104,11 +113,9 @@ async fn prepare_chat_completions( let body = gemini_build_chat_completions_body(data, &self_.model)?; let mut request_data = RequestData::new(url, body); - let uses_oauth = self_.config.auth.as_deref() == Some("oauth"); - if uses_oauth { - let provider = GeminiOAuthProvider; - let ready = oauth::prepare_oauth_access_token(client, &provider, self_.name()).await?; + let (provider, _) = resolve_oauth_provider(self_)?; + let ready = oauth::prepare_oauth_access_token(client, &*provider, self_.name()).await?; if !ready { bail!( "OAuth configured but no tokens found for '{}'. Run: 'coyote --authenticate {}' or '.authenticate' in the REPL", @@ -118,6 +125,9 @@ async fn prepare_chat_completions( } let token = get_access_token(self_.name())?; request_data.bearer_auth(token); + for (key, value) in provider.extra_request_headers() { + request_data.header(key, value); + } } else if let Ok(api_key) = self_.get_api_key() { request_data.header("x-goog-api-key", api_key); } else { @@ -136,12 +146,18 @@ async fn prepare_embeddings( client: &ReqwestClient, data: &EmbeddingsData, ) -> Result { - let api_base = self_ - .get_api_base() - .unwrap_or_else(|_| API_BASE.to_string()); - let uses_oauth = self_.config.auth.as_deref() == Some("oauth"); + if !uses_oauth && self_.config.oauth.is_some() { + bail!( + "'{}' has an `oauth:` block configured but `auth: oauth` is not set; the oauth block would be ignored. Set `auth: oauth` (and run 'coyote --authenticate {}') or remove the oauth block.", + self_.name(), + self_.name() + ); + } + + let api_base = resolve_api_base(self_)?; + let url = if uses_oauth { format!( "{}/models/{}:batchEmbedContents", @@ -177,8 +193,8 @@ async fn prepare_embeddings( let mut request_data = RequestData::new(url, body); if uses_oauth { - let provider = GeminiOAuthProvider; - let ready = oauth::prepare_oauth_access_token(client, &provider, self_.name()).await?; + let (provider, _) = resolve_oauth_provider(self_)?; + let ready = oauth::prepare_oauth_access_token(client, &*provider, self_.name()).await?; if !ready { bail!( "OAuth configured but no tokens found for '{}'. Run: 'coyote --authenticate {}' or '.authenticate' in the REPL", @@ -188,11 +204,72 @@ async fn prepare_embeddings( } let token = get_access_token(self_.name())?; request_data.bearer_auth(token); + for (key, value) in provider.extra_request_headers() { + request_data.header(key, value); + } } Ok(request_data) } +fn resolve_api_base(self_: &GeminiClient) -> Result { + resolve_api_base_against(self_, &ALL_PROVIDER_MODELS) +} + +fn resolve_api_base_against( + self_: &GeminiClient, + all_provider_models: &[ProviderModels], +) -> Result { + if let Ok(api_base) = self_.get_api_base() { + return Ok(api_base); + } + + let uses_config_oauth = self_.config.auth.as_deref() == Some("oauth") + && match locate_client_config(self_) { + Ok(cc) => oauth::config_oauth_for_client(cc, all_provider_models).is_some(), + // Safe: on locate failure, provider resolution bails when an inline oauth block exists; otherwise the stock provider is used and issuer-stamped gateway tokens are rejected before attach. + Err(_) => self_.config.oauth.is_some(), + }; + + if uses_config_oauth { + bail!( + "'{}' has a config-driven OAuth configuration (an `oauth:` block in the client entry or models catalog) but no `api_base`; refusing to fall back to {} (the oauth token would be sent to the wrong host). Set `api_base` on the '{}' client entry.", + self_.name(), + API_BASE, + self_.name() + ); + } + + Ok(API_BASE.to_string()) +} + +fn locate_client_config(self_: &GeminiClient) -> Result<&ClientConfig> { + let client_name = self_.name(); + self_ + .app_config() + .clients + .iter() + .find(|cc| { + matches!( + cc, + ClientConfig::GeminiConfig(c) + if c.name.as_deref().unwrap_or(GeminiClient::NAME) == client_name + ) + }) + .ok_or_else(|| anyhow!("Could not locate ClientConfig entry for '{}'", client_name)) +} + +fn resolve_oauth_provider(self_: &GeminiClient) -> Result<(Box, bool)> { + match locate_client_config(self_) { + Ok(cc) => Ok(oauth::gemini_oauth_provider_for_client( + cc, + &ALL_PROVIDER_MODELS, + )), + Err(_) if self_.config.oauth.is_none() => Ok((Box::new(GeminiOAuthProvider), true)), + Err(err) => Err(err), + } +} + async fn embeddings(builder: RequestBuilder, _model: &Model) -> Result { let res = builder.send().await?; let status = res.status(); @@ -219,3 +296,296 @@ struct EmbeddingsResBody { struct EmbeddingsResBodyEmbedding { values: Vec, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::access_token::set_access_token; + use crate::config::AppConfig; + use chrono::Utc; + use std::sync::Arc; + + fn gemini_config(name: &str, auth: Option<&str>, oauth: Option) -> GeminiConfig { + GeminiConfig { + name: Some(name.into()), + api_base: oauth + .as_ref() + .map(|_| "https://gateway.example/v1".to_string()), + auth: auth.map(str::to_string), + oauth: oauth.map(Box::new), + ..Default::default() + } + } + + fn make_client(config: GeminiConfig, clients: Vec) -> GeminiClient { + GeminiClient { + app_config: Arc::new(AppConfig { + clients, + ..AppConfig::default() + }), + config, + model: Model::new("gemini", "gemini-test"), + } + } + + fn minimal_oauth_config() -> OAuthConfig { + serde_yaml::from_str("client_id: gateway\ntoken_url: https://gateway.example/token") + .unwrap() + } + + fn prepare_chat(client: &GeminiClient) -> Result { + let data = ChatCompletionsData { + messages: vec![Message::new( + MessageRole::User, + MessageContent::Text("hello".to_string()), + )], + temperature: None, + top_p: None, + reasoning_effort: None, + functions: None, + stream: false, + }; + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(prepare_chat_completions( + client, + &ReqwestClient::new(), + data, + )) + } + + fn prepare_embed(client: &GeminiClient) -> Result { + let data = EmbeddingsData::new(vec!["hello".to_string()], false); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(prepare_embeddings(client, &ReqwestClient::new(), &data)) + } + + #[test] + fn oauth_block_without_api_base_is_rejected() { + let name = "gemini-gate-apibase-missing-test"; + let mut config = gemini_config(name, Some("oauth"), Some(minimal_oauth_config())); + config.api_base = None; + let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]); + + let err = resolve_api_base(&client).unwrap_err().to_string(); + + assert!( + err.contains(name) && err.contains("refusing to fall back"), + "unexpected error: {err}" + ); + } + + #[test] + fn catalog_only_oauth_block_without_api_base_is_rejected() { + let name = "gemini-gate-catalog-oauth-test"; + let config = gemini_config(name, Some("oauth"), None); + let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]); + let catalog = vec![ProviderModels { + provider: name.into(), + oauth: Some(minimal_oauth_config()), + models: vec![], + }]; + + let err = resolve_api_base_against(&client, &catalog) + .unwrap_err() + .to_string(); + + assert!( + err.contains(name) && err.contains("refusing to fall back"), + "unexpected error: {err}" + ); + } + + #[test] + fn oauth_block_with_api_base_resolves_it() { + let name = "gemini-gate-apibase-set-test"; + let config = gemini_config(name, Some("oauth"), Some(minimal_oauth_config())); + let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]); + + let api_base = resolve_api_base(&client).unwrap(); + + assert_eq!(api_base, "https://gateway.example/v1"); + } + + #[test] + fn no_oauth_block_falls_back_to_stock_api_base() { + let name = "gemini-gate-apibase-fallback-test"; + let config = gemini_config(name, None, None); + let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]); + + let api_base = resolve_api_base(&client).unwrap(); + + assert_eq!(api_base, API_BASE); + } + + #[test] + fn oauth_block_without_auth_oauth_is_rejected() { + let name = "gemini-gate-contradiction-test"; + let mut config = gemini_config(name, None, Some(minimal_oauth_config())); + config.api_key = Some("sk-test".into()); + let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]); + + let err = match prepare_chat(&client) { + Ok(_) => panic!("expected the contradictory config to be rejected"), + Err(err) => err.to_string(), + }; + + assert!( + err.contains("has an `oauth:` block configured but `auth: oauth` is not set"), + "unexpected error: {err}" + ); + } + + #[test] + fn config_oauth_embeddings_url_omits_key_param() { + let name = "gemini-gate-embed-oauth-test"; + let config = gemini_config(name, Some("oauth"), Some(minimal_oauth_config())); + let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]); + set_access_token( + name, + "gateway-at".into(), + Utc::now().timestamp() + 3600, + None, + ); + + let request_data = prepare_embed(&client).unwrap(); + + assert_eq!( + request_data.url, + "https://gateway.example/v1/models/gemini-test:batchEmbedContents" + ); + assert_eq!( + request_data + .headers + .get("authorization") + .map(String::as_str), + Some("Bearer gateway-at") + ); + } + + #[test] + fn api_key_embeddings_url_appends_key_param() { + let name = "gemini-gate-embed-apikey-test"; + let mut config = gemini_config(name, None, None); + config.api_key = Some("sk-test".into()); + let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]); + + let request_data = prepare_embed(&client).unwrap(); + + assert_eq!( + request_data.url, + format!("{API_BASE}/models/gemini-test:batchEmbedContents?key=sk-test") + ); + } + + #[test] + fn config_oauth_extra_request_headers_reach_request() { + let name = "gemini-gate-extra-headers-test"; + let oauth: OAuthConfig = serde_yaml::from_str( + "client_id: gateway\ntoken_url: https://gateway.example/token\nextra_request_headers:\n x-gateway-tenant: acme", + ) + .unwrap(); + let config = gemini_config(name, Some("oauth"), Some(oauth)); + let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]); + set_access_token( + name, + "gateway-at".into(), + Utc::now().timestamp() + 3600, + None, + ); + + let request_data = prepare_chat(&client).unwrap(); + + assert_eq!( + request_data + .headers + .get("x-gateway-tenant") + .map(String::as_str), + Some("acme") + ); + } + + #[test] + fn catalog_oauth_block_with_api_key_auth_falls_back_to_stock_api_base() { + let name = "gemini-gate-catalog-apikey-test"; + let mut config = gemini_config(name, None, None); + config.api_key = Some("sk-test".into()); + let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]); + let catalog = vec![ProviderModels { + provider: name.into(), + oauth: Some(minimal_oauth_config()), + models: vec![], + }]; + + let api_base = resolve_api_base_against(&client, &catalog).unwrap(); + + assert_eq!(api_base, API_BASE); + } + + #[test] + fn missing_config_entry_without_inline_oauth_resolves_stock_provider() { + let config = gemini_config("gemini-gate-missing-entry-test", Some("oauth"), None); + let client = make_client(config, vec![]); + + let (_, is_stock) = resolve_oauth_provider(&client).unwrap(); + + assert!(is_stock); + } + + #[test] + fn missing_config_entry_with_inline_oauth_is_a_hard_error() { + let config = gemini_config( + "gemini-gate-missing-entry-inline-test", + Some("oauth"), + Some(minimal_oauth_config()), + ); + let client = make_client(config, vec![]); + + let err = match resolve_oauth_provider(&client) { + Ok(_) => panic!("expected the missing ClientConfig entry to be a hard error"), + Err(err) => err.to_string(), + }; + + assert!( + err.contains("Could not locate ClientConfig entry"), + "unexpected error: {err}" + ); + } + + #[test] + fn auth_mismatch_bails_before_missing_api_base_guard() { + let name = "gemini-gate-bail-order-test"; + let mut config = gemini_config(name, None, Some(minimal_oauth_config())); + config.api_base = None; + let client = make_client(config.clone(), vec![ClientConfig::GeminiConfig(config)]); + + let err = match prepare_chat(&client) { + Ok(_) => panic!("expected the contradictory config to be rejected"), + Err(err) => err.to_string(), + }; + + assert!( + err.contains("has an `oauth:` block configured but `auth: oauth` is not set") + && !err.contains("refusing to fall back"), + "unexpected error: {err}" + ); + } + + #[test] + fn shipped_catalog_resolves_stock_gemini_provider() { + let cc = ClientConfig::GeminiConfig(gemini_config("gemini", Some("oauth"), None)); + let bundled: Vec = serde_yaml::from_str(MODELS_YAML).unwrap(); + + let (_, is_stock) = oauth::gemini_oauth_provider_for_client(&cc, &bundled); + + assert!( + is_stock, + "bundled models.yaml must not carry a gemini oauth block: it would silently replace the stock Google oauth provider" + ); + } +} diff --git a/src/client/model.rs b/src/client/model.rs index 4c1249f..0558cb8 100644 --- a/src/client/model.rs +++ b/src/client/model.rs @@ -536,4 +536,25 @@ reasoning_levels: [none, low, medium, high] assert_eq!(data.default_reasoning_effort.as_deref(), Some("none")); } + + #[test] + fn shipped_catalog_carries_no_credentials_and_only_intended_oauth_defaults() { + assert!( + !super::super::common::MODELS_YAML.contains("api_key"), + "the shipped models.yaml must never carry an api key: the model catalog is credential-free by policy" + ); + + let bundled: Vec = + serde_yaml::from_str(super::super::common::MODELS_YAML).unwrap(); + let with_oauth: Vec<&str> = bundled + .iter() + .filter(|p| p.oauth.is_some()) + .map(|p| p.provider.as_str()) + .collect(); + assert_eq!( + with_oauth, + ["xai"], + "oauth blocks in the shipped models.yaml are limited to providers with no built-in stock oauth path (currently only xai's bundled defaults); adding one for claude/openai/gemini would silently swap stock oauth users onto a non-stock provider" + ); + } } diff --git a/src/client/oauth.rs b/src/client/oauth.rs index 6c39321..e456591 100644 --- a/src/client/oauth.rs +++ b/src/client/oauth.rs @@ -1,5 +1,8 @@ use super::access_token::{clear_rejected, is_rejected, is_valid_access_token, set_access_token}; +use super::claude_oauth::ClaudeOAuthProvider; +use super::gemini_oauth::GeminiOAuthProvider; use super::openai_compatible_oauth::OpenAICompatibleOAuthProvider; +use super::openai_oauth::OpenAIOAuthProvider; use super::{ClientConfig, ProviderModels}; use crate::config::paths; use anyhow::{Context, Error, Result, anyhow, bail}; @@ -1056,9 +1059,9 @@ fn listen_for_oauth_callback(redirect_uri: &str) -> Result<(String, String)> { pub fn get_oauth_provider(provider_type: &str) -> Option> { match provider_type { - "claude" => Some(Box::new(super::claude_oauth::ClaudeOAuthProvider)), - "gemini" => Some(Box::new(super::gemini_oauth::GeminiOAuthProvider)), - "openai" => Some(Box::new(super::openai_oauth::OpenAIOAuthProvider)), + "claude" => Some(Box::new(ClaudeOAuthProvider)), + "gemini" => Some(Box::new(GeminiOAuthProvider)), + "openai" => Some(Box::new(OpenAIOAuthProvider)), _ => None, } } @@ -1071,6 +1074,8 @@ pub(crate) fn config_oauth_for_client( let user_oauth = match client_config { ClientConfig::OpenAICompatibleConfig(c) => c.oauth.clone().map(|b| *b), ClientConfig::ClaudeConfig(c) => c.oauth.clone().map(|b| *b), + ClientConfig::OpenAIConfig(c) => c.oauth.clone().map(|b| *b), + ClientConfig::GeminiConfig(c) => c.oauth.clone().map(|b| *b), _ => return None, }; let base = all_provider_models @@ -1085,9 +1090,12 @@ pub(crate) fn config_oauth_for_client( } } -pub(crate) fn claude_oauth_provider_for_client( +/// Resolves a config-driven `oauth:` block to the generic provider, or falls back to `stock`. +/// Returns the provider and whether the stock provider is in use. +fn oauth_provider_for_client( client_config: &ClientConfig, all_provider_models: &[ProviderModels], + stock: fn() -> Box, ) -> (Box, bool) { let (client_name, _, _) = client_config_info(client_config); match config_oauth_for_client(client_config, all_provider_models) { @@ -1099,10 +1107,37 @@ pub(crate) fn claude_oauth_provider_for_client( }), false, ), - None => (Box::new(super::claude_oauth::ClaudeOAuthProvider), true), + None => (stock(), true), } } +pub(crate) fn claude_oauth_provider_for_client( + client_config: &ClientConfig, + all_provider_models: &[ProviderModels], +) -> (Box, bool) { + oauth_provider_for_client(client_config, all_provider_models, || { + Box::new(ClaudeOAuthProvider) + }) +} + +pub(crate) fn openai_oauth_provider_for_client( + client_config: &ClientConfig, + all_provider_models: &[ProviderModels], +) -> (Box, bool) { + oauth_provider_for_client(client_config, all_provider_models, || { + Box::new(OpenAIOAuthProvider) + }) +} + +pub(crate) fn gemini_oauth_provider_for_client( + client_config: &ClientConfig, + all_provider_models: &[ProviderModels], +) -> (Box, bool) { + oauth_provider_for_client(client_config, all_provider_models, || { + Box::new(GeminiOAuthProvider) + }) +} + pub fn get_oauth_provider_for_client( client_config: &ClientConfig, all_provider_models: &[ProviderModels], @@ -1126,6 +1161,16 @@ pub fn get_oauth_provider_for_client( claude_oauth_provider_for_client(client_config, all_provider_models); Some(provider) } + ClientConfig::OpenAIConfig(_) => { + let (provider, _) = + openai_oauth_provider_for_client(client_config, all_provider_models); + Some(provider) + } + ClientConfig::GeminiConfig(_) => { + let (provider, _) = + gemini_oauth_provider_for_client(client_config, all_provider_models); + Some(provider) + } _ => get_oauth_provider(provider_type), } } @@ -1192,6 +1237,8 @@ mod tests { use super::*; use crate::client::access_token::{distrust_access_token, get_access_token}; use crate::client::claude::ClaudeConfig; + use crate::client::gemini::GeminiConfig; + use crate::client::openai::OpenAIConfig; use crate::client::openai_compatible::OpenAICompatibleConfig; use crate::client::{ModelData, ProviderModels}; use crate::utils::get_env_name; @@ -1437,6 +1484,41 @@ echo_pkce_in_token_exchange: true }) } + fn make_openai_client( + name: &str, + auth: Option<&str>, + oauth: Option, + ) -> ClientConfig { + ClientConfig::OpenAIConfig(OpenAIConfig { + name: Some(name.into()), + api_key: None, + api_base: None, + organization_id: None, + auth: auth.map(str::to_string), + oauth: oauth.map(Box::new), + models: vec![], + patch: None, + extra: None, + }) + } + + fn make_gemini_client( + name: &str, + auth: Option<&str>, + oauth: Option, + ) -> ClientConfig { + ClientConfig::GeminiConfig(GeminiConfig { + name: Some(name.into()), + api_key: None, + api_base: None, + auth: auth.map(str::to_string), + oauth: oauth.map(Box::new), + models: vec![], + patch: None, + extra: None, + }) + } + #[test] fn get_oauth_provider_for_client_merges_defaults_with_user_override() { let base = base_config(); @@ -1580,6 +1662,92 @@ echo_pkce_in_token_exchange: true assert!(!provider.requires_issuer_stamp()); } + #[test] + fn openai_client_with_user_oauth_block_uses_generic_provider() { + let user = empty_user_override("gateway-id", "https://gateway.example/token"); + let cc = make_openai_client("openai", Some("oauth"), Some(user)); + + let provider = get_oauth_provider_for_client(&cc, &[]).unwrap(); + + assert_eq!(provider.provider_name(), "openai"); + assert_eq!(provider.client_id(), "gateway-id"); + assert_eq!(provider.token_url(), "https://gateway.example/token"); + } + + #[test] + fn openai_client_without_oauth_block_falls_back_to_stock_provider() { + let cc = make_openai_client("openai", Some("oauth"), None); + + let provider = get_oauth_provider_for_client(&cc, &[]).unwrap(); + + assert_eq!(provider.provider_name(), "openai"); + assert_eq!(provider.client_id(), "app_EMoamEEZ73f0CkXaXp7hrann"); + } + + #[test] + fn openai_config_provider_requires_issuer_stamp() { + let user = empty_user_override("gateway-id", "https://gateway.example/token"); + let cc = make_openai_client("openai", Some("oauth"), Some(user)); + + let (provider, is_stock) = openai_oauth_provider_for_client(&cc, &[]); + + assert!(!is_stock); + assert!(provider.requires_issuer_stamp()); + } + + #[test] + fn openai_stock_provider_grandfathers_legacy_tokens() { + let cc = make_openai_client("openai", Some("oauth"), None); + + let (provider, is_stock) = openai_oauth_provider_for_client(&cc, &[]); + + assert!(is_stock); + assert!(!provider.requires_issuer_stamp()); + } + + #[test] + fn gemini_client_with_user_oauth_block_uses_generic_provider() { + let user = empty_user_override("gateway-id", "https://gateway.example/token"); + let cc = make_gemini_client("gemini", Some("oauth"), Some(user)); + + let provider = get_oauth_provider_for_client(&cc, &[]).unwrap(); + + assert_eq!(provider.provider_name(), "gemini"); + assert_eq!(provider.client_id(), "gateway-id"); + assert_eq!(provider.token_url(), "https://gateway.example/token"); + } + + #[test] + fn gemini_client_without_oauth_block_falls_back_to_stock_provider() { + let cc = make_gemini_client("gemini", Some("oauth"), None); + + let provider = get_oauth_provider_for_client(&cc, &[]).unwrap(); + + assert_eq!(provider.provider_name(), "gemini"); + assert_eq!(provider.token_url(), "https://oauth2.googleapis.com/token"); + } + + #[test] + fn gemini_config_provider_requires_issuer_stamp() { + let user = empty_user_override("gateway-id", "https://gateway.example/token"); + let cc = make_gemini_client("gemini", Some("oauth"), Some(user)); + + let (provider, is_stock) = gemini_oauth_provider_for_client(&cc, &[]); + + assert!(!is_stock); + assert!(provider.requires_issuer_stamp()); + } + + #[test] + fn gemini_stock_provider_grandfathers_legacy_tokens() { + let cc = make_gemini_client("gemini", Some("oauth"), None); + + let (provider, is_stock) = gemini_oauth_provider_for_client(&cc, &[]); + + assert!(is_stock); + assert!(!provider.requires_issuer_stamp()); + } + #[test] fn openai_compatible_provider_joins_scopes_with_spaces() { let mut cfg = base_config(); diff --git a/src/client/openai.rs b/src/client/openai.rs index 2c47d87..5011ca5 100644 --- a/src/client/openai.rs +++ b/src/client/openai.rs @@ -1,11 +1,11 @@ use super::access_token::{get_access_token, get_access_token_account_id}; -use super::oauth::{self, OAuthProvider}; +use super::oauth::{self, OAuthConfig, OAuthProvider}; use super::openai_oauth::OpenAIOAuthProvider; use super::*; use crate::utils::strip_think_tag; -use anyhow::{Context, Result, bail}; +use anyhow::{Context, Result, anyhow, bail}; use reqwest::{Client as ReqwestClient, RequestBuilder}; use serde::Deserialize; use serde_json::{Value, json}; @@ -20,6 +20,7 @@ pub struct OpenAIConfig { pub api_base: Option, pub organization_id: Option, pub auth: Option, + pub oauth: Option>, #[serde(default)] pub models: Vec, pub patch: Option, @@ -46,9 +47,7 @@ impl Client for OpenAIClient { client: &ReqwestClient, data: ChatCompletionsData, ) -> Result { - let uses_codex = - self.config.auth.as_deref() == Some("oauth") && self.get_api_base().is_err(); - let request_data = prepare_chat_completions(self, client, data).await?; + let (request_data, uses_codex) = prepare_chat_completions(self, client, data).await?; let builder = self.request_builder(client, request_data); if uses_codex { openai_responses_chat_completions(builder, self.model()).await @@ -63,9 +62,7 @@ impl Client for OpenAIClient { handler: &mut SseHandler, data: ChatCompletionsData, ) -> Result<()> { - let uses_codex = - self.config.auth.as_deref() == Some("oauth") && self.get_api_base().is_err(); - let request_data = prepare_chat_completions(self, client, data).await?; + let (request_data, uses_codex) = prepare_chat_completions(self, client, data).await?; let builder = self.request_builder(client, request_data); if uses_codex { @@ -100,18 +97,30 @@ async fn prepare_chat_completions( self_: &OpenAIClient, client: &ReqwestClient, data: ChatCompletionsData, -) -> Result { +) -> Result<(RequestData, bool)> { let uses_oauth = self_.config.auth.as_deref() == Some("oauth"); - let has_custom_base = self_.get_api_base().is_ok(); - let uses_codex = uses_oauth && !has_custom_base; + if !uses_oauth && self_.config.oauth.is_some() { + bail!( + "'{}' has an `oauth:` block configured but `auth: oauth` is not set; the oauth block would be ignored. Set `auth: oauth` (and run 'coyote --authenticate {}') or remove the oauth block.", + self_.name(), + self_.name() + ); + } + + let oauth_provider = if uses_oauth { + Some(resolve_oauth_provider(self_)?) + } else { + None + }; + let uses_stock_provider = matches!(oauth_provider, Some((_, true))); + // Stock oauth with no `api_base` routes to the ChatGPT codex backend (Responses API). + let uses_codex = uses_stock_provider && self_.get_api_base().is_err(); let url = if uses_codex { CODEX_API_ENDPOINT.to_string() } else { - let api_base = self_ - .get_api_base() - .unwrap_or_else(|_| API_BASE.to_string()); + let api_base = resolve_api_base(self_)?; format!("{}/chat/completions", api_base.trim_end_matches('/')) }; @@ -123,9 +132,8 @@ async fn prepare_chat_completions( let mut request_data = RequestData::new(url, body); - if uses_oauth { - let provider = OpenAIOAuthProvider; - let ready = oauth::prepare_oauth_access_token(client, &provider, self_.name()).await?; + if let Some((provider, _)) = oauth_provider { + let ready = oauth::prepare_oauth_access_token(client, &*provider, self_.name()).await?; if !ready { bail!( @@ -138,7 +146,7 @@ async fn prepare_chat_completions( let token = get_access_token(self_.name())?; request_data.bearer_auth(token); - if let Some(account_id) = get_access_token_account_id(self_.name()) { + if uses_stock_provider && let Some(account_id) = get_access_token_account_id(self_.name()) { request_data.header("ChatGPT-Account-Id", account_id); } @@ -159,53 +167,120 @@ async fn prepare_chat_completions( request_data.header("OpenAI-Organization", organization_id); } + Ok((request_data, uses_codex)) +} + +async fn prepare_embeddings( + self_: &OpenAIClient, + client: &ReqwestClient, + data: &EmbeddingsData, +) -> Result { + let uses_oauth = self_.config.auth.as_deref() == Some("oauth"); + + if !uses_oauth && self_.config.oauth.is_some() { + bail!( + "'{}' has an `oauth:` block configured but `auth: oauth` is not set; the oauth block would be ignored. Set `auth: oauth` (and run 'coyote --authenticate {}') or remove the oauth block.", + self_.name(), + self_.name() + ); + } + + let api_base = resolve_api_base(self_)?; + + let url = format!("{}/embeddings", api_base.trim_end_matches('/')); + + let body = openai_build_embeddings_body(data, &self_.model); + + let mut request_data = RequestData::new(url, body); + + if uses_oauth { + let (provider, _) = resolve_oauth_provider(self_)?; + let ready = oauth::prepare_oauth_access_token(client, &*provider, self_.name()).await?; + + if !ready { + bail!( + "OAuth configured but no tokens found for '{}'. Run: 'coyote --authenticate {}' or '.authenticate' in the REPL", + self_.name(), + self_.name() + ); + } + + let token = get_access_token(self_.name())?; + request_data.bearer_auth(token); + + for (key, value) in provider.extra_request_headers() { + request_data.header(key, value); + } + } else if let Ok(api_key) = self_.get_api_key() { + request_data.bearer_auth(api_key); + } else { + bail!( + "No authentication configured for '{}'. Set `api_key` or use `auth: oauth` with `coyote --authenticate {}`.", + self_.name(), + self_.name() + ); + } + + if let Some(organization_id) = &self_.config.organization_id { + request_data.header("OpenAI-Organization", organization_id); + } + Ok(request_data) } -async fn prepare_embeddings( +fn resolve_api_base(self_: &OpenAIClient) -> Result { + resolve_api_base_against(self_, &ALL_PROVIDER_MODELS) +} + +fn resolve_api_base_against( self_: &OpenAIClient, - client: &ReqwestClient, - data: &EmbeddingsData, -) -> Result { - let api_base = self_ - .get_api_base() - .unwrap_or_else(|_| API_BASE.to_string()); - - let url = format!("{api_base}/embeddings"); - - let body = openai_build_embeddings_body(data, &self_.model); - - let mut request_data = RequestData::new(url, body); - - if self_.config.auth.as_deref() == Some("oauth") { - let provider = OpenAIOAuthProvider; - let ready = oauth::prepare_oauth_access_token(client, &provider, self_.name()).await?; - - if !ready { - bail!( - "OAuth configured but no tokens found for '{}'. Run: 'coyote --authenticate {}' or '.authenticate' in the REPL", - self_.name(), - self_.name() - ); - } - - let token = get_access_token(self_.name())?; - request_data.bearer_auth(token); - } else if let Ok(api_key) = self_.get_api_key() { - request_data.bearer_auth(api_key); - } else { + all_provider_models: &[ProviderModels], +) -> Result { + if let Ok(api_base) = self_.get_api_base() { + return Ok(api_base); + } + let uses_config_oauth = self_.config.auth.as_deref() == Some("oauth") + && match locate_client_config(self_) { + Ok(cc) => oauth::config_oauth_for_client(cc, all_provider_models).is_some(), + // Safe: on locate failure, provider resolution bails when an inline oauth block exists; otherwise the stock provider is used and issuer-stamped gateway tokens are rejected before attach. + Err(_) => self_.config.oauth.is_some(), + }; + if uses_config_oauth { bail!( - "No authentication configured for '{}'. Set `api_key` or use `auth: oauth` with `coyote --authenticate {}`.", + "'{}' has a config-driven OAuth configuration (an `oauth:` block in the client entry or models catalog) but no `api_base`; refusing to fall back to {} (the oauth token would be sent to the wrong host). Set `api_base` on the '{}' client entry.", self_.name(), + API_BASE, self_.name() ); } + Ok(API_BASE.to_string()) +} - if let Some(organization_id) = &self_.config.organization_id { - request_data.header("OpenAI-Organization", organization_id); +fn locate_client_config(self_: &OpenAIClient) -> Result<&ClientConfig> { + let client_name = self_.name(); + self_ + .app_config() + .clients + .iter() + .find(|cc| { + matches!( + cc, + ClientConfig::OpenAIConfig(c) + if c.name.as_deref().unwrap_or(OpenAIClient::NAME) == client_name + ) + }) + .ok_or_else(|| anyhow!("Could not locate ClientConfig entry for '{}'", client_name)) +} + +fn resolve_oauth_provider(self_: &OpenAIClient) -> Result<(Box, bool)> { + match locate_client_config(self_) { + Ok(cc) => Ok(oauth::openai_oauth_provider_for_client( + cc, + &ALL_PROVIDER_MODELS, + )), + Err(_) if self_.config.oauth.is_none() => Ok((Box::new(OpenAIOAuthProvider), true)), + Err(err) => Err(err), } - - Ok(request_data) } pub async fn openai_chat_completions( @@ -362,50 +437,51 @@ pub fn openai_build_chat_completions_body(data: ChatCompletionsData, model: &Mod } = data; let messages_len = messages.len(); - let messages: Vec = messages - .into_iter() - .enumerate() - .flat_map(|(i, message)| { - let Message { role, content } = message; - match content { - MessageContent::ToolCalls(MessageContentToolCalls { - tool_results, - text, - sequence, - }) => { - // Empty tool_results (reachable via deserialized sessions) must not emit empty tool_calls. - if tool_results.is_empty() { - vec![] - } else if !sequence { - let tool_calls: Vec<_> = tool_results - .iter() - .map(|tool_result| { - json!({ - "id": tool_result.call.id, - "type": "function", - "function": { - "name": tool_result.call.name, - "arguments": tool_result.call.arguments.to_string(), - }, + let messages: Vec = + messages + .into_iter() + .enumerate() + .flat_map(|(i, message)| { + let Message { role, content } = message; + match content { + MessageContent::ToolCalls(MessageContentToolCalls { + tool_results, + text, + sequence, + }) => { + // Empty tool_results (reachable via deserialized sessions) must not emit empty tool_calls. + if tool_results.is_empty() { + vec![] + } else if !sequence { + let tool_calls: Vec<_> = tool_results + .iter() + .map(|tool_result| { + json!({ + "id": tool_result.call.id, + "type": "function", + "function": { + "name": tool_result.call.name, + "arguments": tool_result.call.arguments.to_string(), + }, + }) }) - }) - .collect(); - let mut assistant_message = - json!({ "role": MessageRole::Assistant, "tool_calls": tool_calls }); - if !text.is_empty() { - assistant_message["content"] = strip_think_tag(&text).into(); - } - let mut messages = vec![assistant_message]; - for tool_result in tool_results { - messages.push(json!({ - "role": "tool", - "content": tool_result.output.to_string(), - "tool_call_id": tool_result.call.id, - })); - } - messages - } else { - tool_results.into_iter().enumerate().flat_map(|(index, tool_result)| { + .collect(); + let mut assistant_message = + json!({ "role": MessageRole::Assistant, "tool_calls": tool_calls }); + if !text.is_empty() { + assistant_message["content"] = strip_think_tag(&text).into(); + } + let mut messages = vec![assistant_message]; + for tool_result in tool_results { + messages.push(json!({ + "role": "tool", + "content": tool_result.output.to_string(), + "tool_call_id": tool_result.call.id, + })); + } + messages + } else { + tool_results.into_iter().enumerate().flat_map(|(index, tool_result)| { let round_text = if index == 0 && !text.is_empty() { Some(text.clone()) } else { @@ -437,16 +513,16 @@ pub fn openai_build_chat_completions_body(data: ChatCompletionsData, model: &Mod ] }).collect() + } } + MessageContent::Text(text) if role.is_assistant() && i != messages_len - 1 => { + vec![json!({ "role": role, "content": strip_think_tag(&text) } + )] + } + _ => vec![json!({ "role": role, "content": content })], } - MessageContent::Text(text) if role.is_assistant() && i != messages_len - 1 => { - vec![json!({ "role": role, "content": strip_think_tag(&text) } - )] - } - _ => vec![json!({ "role": role, "content": content })], - } - }) - .collect(); + }) + .collect(); let mut body = json!({ "model": &model.real_name(), @@ -536,7 +612,11 @@ pub fn openai_extract_chat_completions(data: &Value) -> Result Result { if text.is_empty() && tool_calls.is_empty() { bail!("Invalid response data: {data}"); } - Ok(ChatCompletionsOutput { text, tool_calls, ..Default::default() }) + Ok(ChatCompletionsOutput { + text, + tool_calls, + ..Default::default() + }) } pub async fn openai_responses_streaming( @@ -752,6 +836,10 @@ pub async fn openai_responses_streaming( #[cfg(test)] mod tests { use super::*; + use crate::client::access_token::set_access_token; + use crate::config::AppConfig; + use chrono::Utc; + use std::sync::Arc; fn build_body(sequence: bool) -> Value { let data = ChatCompletionsData { @@ -792,4 +880,367 @@ mod tests { assert_eq!(messages.len(), 1, "body: {body}"); } + + fn openai_config(name: &str, auth: Option<&str>, oauth: Option) -> OpenAIConfig { + OpenAIConfig { + name: Some(name.into()), + api_base: oauth + .as_ref() + .map(|_| "https://gateway.example/v1".to_string()), + auth: auth.map(str::to_string), + oauth: oauth.map(Box::new), + ..Default::default() + } + } + + fn make_client(config: OpenAIConfig, clients: Vec) -> OpenAIClient { + OpenAIClient { + app_config: Arc::new(AppConfig { + clients, + ..AppConfig::default() + }), + config, + model: Model::new("openai", "gpt-test"), + } + } + + fn minimal_oauth_config() -> OAuthConfig { + serde_yaml::from_str("client_id: gateway\ntoken_url: https://gateway.example/token") + .unwrap() + } + + fn prepare(client: &OpenAIClient) -> Result<(RequestData, bool)> { + let data = ChatCompletionsData { + messages: vec![Message::new( + MessageRole::User, + MessageContent::Text("hello".to_string()), + )], + temperature: None, + top_p: None, + reasoning_effort: None, + functions: None, + stream: false, + }; + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(prepare_chat_completions( + client, + &ReqwestClient::new(), + data, + )) + } + + fn prepare_embed(client: &OpenAIClient) -> Result { + let data = EmbeddingsData::new(vec!["hello".to_string()], false); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(prepare_embeddings(client, &ReqwestClient::new(), &data)) + } + + #[test] + fn oauth_block_without_api_base_is_rejected() { + let name = "openai-gate-apibase-missing-test"; + let mut config = openai_config(name, Some("oauth"), Some(minimal_oauth_config())); + config.api_base = None; + let client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]); + + let err = resolve_api_base(&client).unwrap_err().to_string(); + + assert!( + err.contains(name) && err.contains("refusing to fall back"), + "unexpected error: {err}" + ); + } + + #[test] + fn catalog_only_oauth_block_without_api_base_is_rejected() { + let name = "openai-gate-catalog-oauth-test"; + let config = openai_config(name, Some("oauth"), None); + let client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]); + let catalog = vec![ProviderModels { + provider: name.into(), + oauth: Some(minimal_oauth_config()), + models: vec![], + }]; + + let err = resolve_api_base_against(&client, &catalog) + .unwrap_err() + .to_string(); + + assert!( + err.contains(name) && err.contains("refusing to fall back"), + "unexpected error: {err}" + ); + } + + #[test] + fn oauth_block_with_api_base_resolves_it() { + let name = "openai-gate-apibase-set-test"; + let config = openai_config(name, Some("oauth"), Some(minimal_oauth_config())); + let client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]); + + let api_base = resolve_api_base(&client).unwrap(); + + assert_eq!(api_base, "https://gateway.example/v1"); + } + + #[test] + fn no_oauth_block_falls_back_to_stock_api_base() { + let name = "openai-gate-apibase-fallback-test"; + let config = openai_config(name, None, None); + let client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]); + + let api_base = resolve_api_base(&client).unwrap(); + + assert_eq!(api_base, API_BASE); + } + + #[test] + fn stock_oauth_without_api_base_routes_to_codex() { + let name = "openai-gate-codex-test"; + let config = openai_config(name, Some("oauth"), None); + let client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]); + set_access_token(name, "codex-at".into(), Utc::now().timestamp() + 3600, None); + + let (request_data, uses_codex) = prepare(&client).unwrap(); + + assert!(uses_codex); + assert_eq!(request_data.url, CODEX_API_ENDPOINT); + } + + #[test] + fn config_oauth_block_skips_codex_routing() { + let name = "openai-gate-gateway-codex-test"; + let config = openai_config(name, Some("oauth"), Some(minimal_oauth_config())); + let client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]); + set_access_token( + name, + "gateway-at".into(), + Utc::now().timestamp() + 3600, + None, + ); + + let (request_data, uses_codex) = prepare(&client).unwrap(); + + assert!(!uses_codex); + assert_eq!( + request_data.url, + "https://gateway.example/v1/chat/completions" + ); + } + + #[test] + fn oauth_block_without_auth_oauth_is_rejected() { + let name = "openai-gate-contradiction-test"; + let mut config = openai_config(name, None, Some(minimal_oauth_config())); + config.api_key = Some("sk-test".into()); + let client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]); + + let err = match prepare(&client) { + Ok(_) => panic!("expected the contradictory config to be rejected"), + Err(err) => err.to_string(), + }; + + assert!( + err.contains("has an `oauth:` block configured but `auth: oauth` is not set"), + "unexpected error: {err}" + ); + } + + #[test] + fn config_oauth_extra_request_headers_reach_request() { + let name = "openai-gate-extra-headers-test"; + let oauth: OAuthConfig = serde_yaml::from_str( + "client_id: gateway\ntoken_url: https://gateway.example/token\nextra_request_headers:\n x-gateway-tenant: acme", + ) + .unwrap(); + let config = openai_config(name, Some("oauth"), Some(oauth)); + let client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]); + set_access_token( + name, + "gateway-at".into(), + Utc::now().timestamp() + 3600, + None, + ); + + let (request_data, _) = prepare(&client).unwrap(); + + assert_eq!( + request_data + .headers + .get("x-gateway-tenant") + .map(String::as_str), + Some("acme") + ); + } + + #[test] + fn config_oauth_embeddings_url_trims_trailing_slash() { + let name = "openai-gate-embed-oauth-test"; + let mut config = openai_config(name, Some("oauth"), Some(minimal_oauth_config())); + config.api_base = Some("https://gateway.example/v1/".into()); + let client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]); + set_access_token( + name, + "gateway-at".into(), + Utc::now().timestamp() + 3600, + None, + ); + + let request_data = prepare_embed(&client).unwrap(); + + assert_eq!(request_data.url, "https://gateway.example/v1/embeddings"); + assert_eq!( + request_data + .headers + .get("authorization") + .map(String::as_str), + Some("Bearer gateway-at") + ); + } + + #[test] + fn oauth_block_without_api_base_is_rejected_in_embeddings() { + let name = "openai-gate-embed-apibase-missing-test"; + let mut config = openai_config(name, Some("oauth"), Some(minimal_oauth_config())); + config.api_base = None; + let client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]); + + let err = match prepare_embed(&client) { + Ok(_) => panic!("expected the missing api_base to be rejected"), + Err(err) => err.to_string(), + }; + + assert!( + err.contains(name) && err.contains("refusing to fall back"), + "unexpected error: {err}" + ); + } + + #[test] + fn catalog_oauth_block_with_api_key_auth_falls_back_to_stock_api_base() { + let name = "openai-gate-catalog-apikey-test"; + let mut config = openai_config(name, None, None); + config.api_key = Some("sk-test".into()); + let client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]); + let catalog = vec![ProviderModels { + provider: name.into(), + oauth: Some(minimal_oauth_config()), + models: vec![], + }]; + + let api_base = resolve_api_base_against(&client, &catalog).unwrap(); + + assert_eq!(api_base, API_BASE); + } + + #[test] + fn missing_config_entry_without_inline_oauth_resolves_stock_provider() { + let config = openai_config("openai-gate-missing-entry-test", Some("oauth"), None); + let client = make_client(config, vec![]); + + let (_, is_stock) = resolve_oauth_provider(&client).unwrap(); + + assert!(is_stock); + } + + #[test] + fn missing_config_entry_with_inline_oauth_is_a_hard_error() { + let config = openai_config( + "openai-gate-missing-entry-inline-test", + Some("oauth"), + Some(minimal_oauth_config()), + ); + let client = make_client(config, vec![]); + + let err = match resolve_oauth_provider(&client) { + Ok(_) => panic!("expected the missing ClientConfig entry to be a hard error"), + Err(err) => err.to_string(), + }; + + assert!( + err.contains("Could not locate ClientConfig entry"), + "unexpected error: {err}" + ); + } + + #[test] + fn stock_oauth_attaches_chatgpt_account_id_header() { + let name = "openai-gate-account-id-stock-test"; + let config = openai_config(name, Some("oauth"), None); + let client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]); + set_access_token( + name, + "codex-at".into(), + Utc::now().timestamp() + 3600, + Some("acct-123".into()), + ); + + let (request_data, _) = prepare(&client).unwrap(); + + assert_eq!( + request_data + .headers + .get("ChatGPT-Account-Id") + .map(String::as_str), + Some("acct-123") + ); + } + + #[test] + fn config_oauth_block_omits_chatgpt_account_id_header() { + let name = "openai-gate-account-id-gateway-test"; + let config = openai_config(name, Some("oauth"), Some(minimal_oauth_config())); + let client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]); + set_access_token( + name, + "gateway-at".into(), + Utc::now().timestamp() + 3600, + Some("acct-123".into()), + ); + + let (request_data, _) = prepare(&client).unwrap(); + + assert!( + !request_data.headers.contains_key("ChatGPT-Account-Id"), + "headers: {:?}", + request_data.headers + ); + } + + #[test] + fn auth_mismatch_bails_before_missing_api_base_guard() { + let name = "openai-gate-bail-order-test"; + let mut config = openai_config(name, None, Some(minimal_oauth_config())); + config.api_base = None; + let client = make_client(config.clone(), vec![ClientConfig::OpenAIConfig(config)]); + + let err = match prepare(&client) { + Ok(_) => panic!("expected the contradictory config to be rejected"), + Err(err) => err.to_string(), + }; + + assert!( + err.contains("has an `oauth:` block configured but `auth: oauth` is not set") + && !err.contains("refusing to fall back"), + "unexpected error: {err}" + ); + } + + #[test] + fn shipped_catalog_resolves_stock_openai_provider() { + let cc = ClientConfig::OpenAIConfig(openai_config("openai", Some("oauth"), None)); + let bundled: Vec = serde_yaml::from_str(MODELS_YAML).unwrap(); + + let (_, is_stock) = oauth::openai_oauth_provider_for_client(&cc, &bundled); + + assert!( + is_stock, + "bundled models.yaml must not carry an openai oauth block: it would silently disable codex routing for stock ChatGPT oauth users" + ); + } }