diff --git a/config.example.yaml b/config.example.yaml index 7efd90c..09a2c92 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -348,13 +348,14 @@ clients: api_base: https://api.mistral.ai/v1 api_key: '{{MISTRAL_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault - # See https://docs.x.ai/docs — OAuth via SuperGrok / X Premium+ subscription + # See https://docs.x.ai/docs - OAuth via SuperGrok / X Premium+ subscription - type: openai-compatible name: xai api_base: https://api.x.ai/v1 api_key: '{{XAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault - # auth: oauth # OR: OAuth (requires SuperGrok/X Premium+); endpoints from models.yaml - # After enabling `auth: oauth`, run: coyote --authenticate xai + auth: null # When set to 'oauth', Coyote will use OAuth instead of an API key + # Authenticate with `coyote --authenticate` or `.authenticate` in the REPL + # Note: Oauth requires SuperGrok/X Premium+ subscription # Example: private OpenAI-compatible gateway with client_credentials OAuth # - type: openai-compatible diff --git a/src/client/model.rs b/src/client/model.rs index 0df9edb..0137be9 100644 --- a/src/client/model.rs +++ b/src/client/model.rs @@ -6,6 +6,7 @@ use super::{ use crate::config::AppConfig; use crate::utils::{estimate_token_length, strip_think_tag}; +use super::oauth::OAuthConfig; use anyhow::{Result, bail}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -358,7 +359,7 @@ impl ModelData { pub struct ProviderModels { pub provider: String, #[serde(default)] - pub oauth: Option, + pub oauth: Option, pub models: Vec, } diff --git a/src/client/oauth.rs b/src/client/oauth.rs index 634e18d..1b30d2d 100644 --- a/src/client/oauth.rs +++ b/src/client/oauth.rs @@ -1,5 +1,6 @@ -use super::ClientConfig; use super::access_token::{is_valid_access_token, set_access_token}; +use super::openai_compatible_oauth::OpenAICompatibleOAuthProvider; +use super::{ClientConfig, ProviderModels}; use crate::config::paths; use anyhow::{Result, anyhow, bail}; use base64::Engine; @@ -33,11 +34,6 @@ pub enum OAuthFlow { ClientCredentials, } -/// Runtime OAuth configuration merged from `models.yaml` provider defaults -/// and user config `clients[i].oauth` overrides. -/// -/// Every field except `client_id`, `token_url`, and `flow` is optional so that -/// user config can override individual fields without restating the entire block. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OAuthConfig { pub client_id: String, @@ -69,9 +65,6 @@ fn default_true() -> bool { } impl OAuthConfig { - /// Merge a user override into `self` field-by-field. User values win. - /// Uses `json_patch::merge`-like semantics (see `common.rs:apply_patch`) — - /// `None` in override means "keep base"; explicit values replace. pub fn merge(mut self, override_cfg: OAuthConfig) -> Self { self.client_id = override_cfg.client_id; self.token_url = override_cfg.token_url; @@ -106,8 +99,10 @@ impl OAuthConfig { self.extra_request_headers .extend(override_cfg.extra_request_headers); } + self.echo_pkce_in_token_exchange = override_cfg.echo_pkce_in_token_exchange; self.include_state_in_token_exchange = override_cfg.include_state_in_token_exchange; + self } } @@ -341,6 +336,7 @@ async fn run_client_credentials_flow( client_name, provider.provider_name() ); + Ok(()) } @@ -502,7 +498,7 @@ fn listen_for_oauth_callback(redirect_uri: &str) -> Result<(String, String)> { println!("Waiting for OAuth callback on {redirect_uri} ..."); println!( - "(If the browser shows a 'paste this code' page, ignore it — Coyote captures the callback automatically.)\n" + "(If the browser shows a 'paste this code' page, ignore it. Coyote captures the callback automatically.)\n" ); let listener = TcpListener::bind(format!("{host}:{port}"))?; @@ -570,13 +566,9 @@ pub fn get_oauth_provider(provider_type: &str) -> Option> } } -/// Build an OAuthProvider for a given client, resolving config-driven providers -/// (openai-compatible) from a merged `models.yaml` + user-config OAuthConfig. -/// -/// For first-class providers (claude/gemini/openai), delegates to `get_oauth_provider`. pub fn get_oauth_provider_for_client( client_config: &ClientConfig, - all_provider_models: &[super::ProviderModels], + all_provider_models: &[ProviderModels], ) -> Option> { let (client_name, provider_type, auth) = client_config_info(client_config); if auth != Some("oauth") { @@ -596,12 +588,10 @@ pub fn get_oauth_provider_for_client( (None, Some(u)) => u, (Some(b), Some(u)) => b.merge(u), }; - Some(Box::new( - super::openai_compatible_oauth::OpenAICompatibleOAuthProvider { - config: merged, - client_name: client_name.to_string(), - }, - )) + Some(Box::new(OpenAICompatibleOAuthProvider { + config: merged, + client_name: client_name.to_string(), + })) } _ => get_oauth_provider(provider_type), } @@ -778,7 +768,9 @@ extra_authorize_params: referrer: coyote echo_pkce_in_token_exchange: true "#; + let cfg: OAuthConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(cfg.client_id, "xai-client"); assert_eq!(cfg.token_url, "https://auth.x.ai/oauth2/token"); assert_eq!(cfg.scopes.len(), 3); @@ -799,14 +791,18 @@ echo_pkce_in_token_exchange: true #[test] fn oauth_flow_defaults_to_pkce_when_missing() { let yaml = "client_id: x\ntoken_url: y"; + let cfg: OAuthConfig = serde_yaml::from_str(yaml).unwrap(); + assert!(matches!(cfg.flow, OAuthFlow::Pkce)); } #[test] fn oauth_flow_client_credentials_parses() { let yaml = "client_id: x\ntoken_url: y\nflow: client_credentials"; + let cfg: OAuthConfig = serde_yaml::from_str(yaml).unwrap(); + assert!(matches!(cfg.flow, OAuthFlow::ClientCredentials)); } @@ -844,6 +840,7 @@ echo_pkce_in_token_exchange: true let cc = make_openai_compat_client("acme", Some("oauth"), Some(user)); let provider = get_oauth_provider_for_client(&cc, &models).unwrap(); + assert_eq!(provider.client_id(), "user-id"); assert_eq!(provider.token_url(), "https://user.example/token"); assert!(provider.echo_pkce_in_token_exchange()); @@ -860,6 +857,7 @@ echo_pkce_in_token_exchange: true let cc = make_openai_compat_client("bundled-only", Some("oauth"), None); let provider = get_oauth_provider_for_client(&cc, &models).unwrap(); + assert_eq!(provider.client_id(), "base-id"); assert_eq!(provider.token_url(), "https://base.example/token"); } @@ -874,12 +872,14 @@ echo_pkce_in_token_exchange: true let cc = make_openai_compat_client("inline-only", Some("oauth"), Some(user)); let provider = get_oauth_provider_for_client(&cc, &[]).unwrap(); + assert_eq!(provider.client_id(), "inline-id"); } #[test] fn get_oauth_provider_for_client_returns_none_when_no_config_anywhere() { let cc = make_openai_compat_client("nothing", Some("oauth"), None); + assert!(get_oauth_provider_for_client(&cc, &[]).is_none()); } @@ -887,7 +887,9 @@ echo_pkce_in_token_exchange: true fn get_oauth_provider_for_client_returns_none_when_auth_not_oauth() { let base = base_config(); let models = vec![make_provider_models("api-key-client", Some(base))]; + let cc = make_openai_compat_client("api-key-client", None, None); + assert!(get_oauth_provider_for_client(&cc, &models).is_none()); } @@ -895,10 +897,12 @@ echo_pkce_in_token_exchange: true fn openai_compatible_provider_joins_scopes_with_spaces() { let mut cfg = base_config(); cfg.scopes = vec!["one".into(), "two".into(), "three".into()]; - let provider = super::super::openai_compatible_oauth::OpenAICompatibleOAuthProvider { + + let provider = OpenAICompatibleOAuthProvider { config: cfg, client_name: "test".into(), }; + assert_eq!(provider.scopes(), "one two three"); } @@ -907,10 +911,12 @@ echo_pkce_in_token_exchange: true let mut cfg = base_config(); cfg.redirect_uri = Some("https://custom.example/cb".into()); cfg.redirect_port = Some(9999); - let provider = super::super::openai_compatible_oauth::OpenAICompatibleOAuthProvider { + + let provider = OpenAICompatibleOAuthProvider { config: cfg, client_name: "test".into(), }; + assert_eq!( provider.fixed_redirect_uri().as_deref(), Some("https://custom.example/cb") @@ -922,10 +928,12 @@ echo_pkce_in_token_exchange: true let mut cfg = base_config(); cfg.redirect_uri = None; cfg.redirect_port = None; - let provider = super::super::openai_compatible_oauth::OpenAICompatibleOAuthProvider { + + let provider = OpenAICompatibleOAuthProvider { config: cfg, client_name: "test".into(), }; + assert!(provider.uses_localhost_redirect()); assert!(provider.fixed_redirect_uri().is_none()); } diff --git a/src/client/openai_compatible.rs b/src/client/openai_compatible.rs index aa36ad7..d407864 100644 --- a/src/client/openai_compatible.rs +++ b/src/client/openai_compatible.rs @@ -7,6 +7,7 @@ use anyhow::{Context, Result, anyhow, bail}; use reqwest::{Client as ReqwestClient, RequestBuilder}; use serde::Deserialize; use serde_json::{Value, json}; +use oauth::OAuthConfig; #[derive(Debug, Clone, Deserialize)] pub struct OpenAICompatibleConfig { @@ -14,7 +15,7 @@ pub struct OpenAICompatibleConfig { pub api_base: Option, pub api_key: Option, pub auth: Option, - pub oauth: Option>, + pub oauth: Option>, #[serde(default)] pub models: Vec, pub patch: Option, @@ -43,6 +44,7 @@ impl Client for OpenAICompatibleClient { ) -> Result { let request_data = prepare_chat_completions(self, client, data).await?; let builder = self.request_builder(client, request_data); + openai_chat_completions(builder, self.model()).await } @@ -54,6 +56,7 @@ impl Client for OpenAICompatibleClient { ) -> Result<()> { let request_data = prepare_chat_completions(self, client, data).await?; let builder = self.request_builder(client, request_data); + openai_chat_completions_streaming(builder, handler, self.model()).await } @@ -64,6 +67,7 @@ impl Client for OpenAICompatibleClient { ) -> Result { let request_data = prepare_embeddings(self, client, data).await?; let builder = self.request_builder(client, request_data); + openai_embeddings(builder, self.model()).await } @@ -74,6 +78,7 @@ impl Client for OpenAICompatibleClient { ) -> Result { let request_data = prepare_rerank(self, client, data).await?; let builder = self.request_builder(client, request_data); + generic_rerank(builder, self.model()).await } } @@ -87,7 +92,9 @@ async fn prepare_chat_completions( let url = format!("{api_base}/chat/completions"); let body = openai_build_chat_completions_body(data, &self_.model); let mut request_data = RequestData::new(url, body); + apply_auth(self_, client, &mut request_data).await?; + Ok(request_data) } @@ -100,7 +107,9 @@ async fn prepare_embeddings( let url = format!("{api_base}/embeddings"); let body = openai_build_embeddings_body(data, &self_.model); let mut request_data = RequestData::new(url, body); + apply_auth(self_, client, &mut request_data).await?; + Ok(request_data) } @@ -117,7 +126,9 @@ async fn prepare_rerank( }; let body = generic_build_rerank_body(data, &self_.model); let mut request_data = RequestData::new(url, body); + apply_auth(self_, client, &mut request_data).await?; + Ok(request_data) } @@ -149,6 +160,7 @@ async fn apply_auth( client_name ) })?; + let ready = oauth::prepare_oauth_access_token(client, &*provider, client_name).await?; if !ready { bail!( @@ -157,8 +169,10 @@ async fn apply_auth( client_name ); } + let token = get_access_token(client_name)?; request_data.bearer_auth(token); + for (key, value) in provider.extra_request_headers() { request_data.header(key, value); } diff --git a/src/client/openai_compatible_oauth.rs b/src/client/openai_compatible_oauth.rs index 2e5974e..1371c16 100644 --- a/src/client/openai_compatible_oauth.rs +++ b/src/client/openai_compatible_oauth.rs @@ -52,16 +52,6 @@ impl OAuthProvider for OpenAICompatibleOAuthProvider { self.config.redirect_uri.is_none() && self.config.redirect_port.is_none() } - fn fixed_redirect_uri(&self) -> Option { - if let Some(uri) = &self.config.redirect_uri { - return Some(uri.clone()); - } - if let Some(port) = self.config.redirect_port { - return Some(format!("http://127.0.0.1:{port}/callback")); - } - None - } - fn extra_token_headers(&self) -> Vec<(&str, &str)> { self.config .extra_token_headers @@ -78,6 +68,20 @@ impl OAuthProvider for OpenAICompatibleOAuthProvider { .collect() } + fn fixed_redirect_uri(&self) -> Option { + if let Some(uri) = &self.config.redirect_uri { + return Some(uri.clone()); + } + if let Some(port) = self.config.redirect_port { + return Some(format!("http://127.0.0.1:{port}/callback")); + } + None + } + + fn include_state_in_token_exchange(&self) -> bool { + self.config.include_state_in_token_exchange + } + fn flow(&self) -> OAuthFlow { self.config.flow } @@ -85,8 +89,4 @@ impl OAuthProvider for OpenAICompatibleOAuthProvider { fn echo_pkce_in_token_exchange(&self) -> bool { self.config.echo_pkce_in_token_exchange } - - fn include_state_in_token_exchange(&self) -> bool { - self.config.include_state_in_token_exchange - } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 6cdebd1..1af7d66 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -63,7 +63,7 @@ use indoc::formatdoc; use inquire::{Confirm, Select}; use serde::{Deserialize, Serialize}; use serde_json::json; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::LazyLock; use std::{ env, @@ -596,7 +596,7 @@ impl Config { }) .with_context(|| "Failed to load config from str")?; - let mut seen = std::collections::HashSet::new(); + let mut seen = HashSet::new(); for cc in &config.clients { let (name, _, _) = oauth::client_config_info(cc); if !seen.insert(name.to_string()) { diff --git a/src/main.rs b/src/main.rs index 555312d..8a75b54 100644 --- a/src/main.rs +++ b/src/main.rs @@ -785,8 +785,8 @@ fn resolve_oauth_client( 0 => bail!("No OAuth-capable clients configured."), 1 => find_by_name(&candidates[0]).unwrap(), _ => { - let choice = Select::new("Select a client to authenticate:", candidates.clone()) - .prompt()?; + let choice = + Select::new("Select a client to authenticate:", candidates.clone()).prompt()?; find_by_name(&choice) .ok_or_else(|| anyhow!("Selected client '{choice}' not found"))? } @@ -800,5 +800,6 @@ fn resolve_oauth_client( "Could not build OAuth provider for '{name}' (no oauth config in models.yaml or user config)" ) })?; + Ok((name, provider)) }