Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f1729ba00
|
||
|
|
107419966d
|
||
|
|
344ef7526f
|
||
|
|
13d31f850c
|
||
|
|
f6bd02dc73
|
||
|
|
31df1a720d
|
||
|
|
4e0e65fc8a
|
||
|
|
ab85a4f534
|
||
|
|
420447275c
|
||
|
|
cdc40f7302
|
||
|
|
c611685033
|
||
|
|
cac2a3eba0
|
||
|
|
66bbb34d7f
|
||
|
|
68177fdb6a
|
||
|
|
1acaad223f
|
||
|
|
aa0270602d
|
||
|
|
4669958bdd
|
||
|
|
559107073d
|
+16
-1
@@ -348,11 +348,26 @@ clients:
|
|||||||
api_base: https://api.mistral.ai/v1
|
api_base: https://api.mistral.ai/v1
|
||||||
api_key: '{{MISTRAL_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
api_key: '{{MISTRAL_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||||
|
|
||||||
# See https://docs.x.ai/docs
|
# See https://docs.x.ai/docs - OAuth via SuperGrok / X Premium+ subscription
|
||||||
- type: openai-compatible
|
- type: openai-compatible
|
||||||
name: xai
|
name: xai
|
||||||
api_base: https://api.x.ai/v1
|
api_base: https://api.x.ai/v1
|
||||||
api_key: '{{XAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
api_key: '{{XAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||||
|
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
|
||||||
|
# name: acme-gateway
|
||||||
|
# api_base: https://gateway.acme.com/v1
|
||||||
|
# auth: oauth
|
||||||
|
# oauth:
|
||||||
|
# client_id: '{{ACME_CLIENT_ID}}'
|
||||||
|
# client_secret: '{{ACME_CLIENT_SECRET}}'
|
||||||
|
# token_url: https://auth.acme.com/oauth/token
|
||||||
|
# scopes: [openai.chat]
|
||||||
|
# flow: client_credentials
|
||||||
|
|
||||||
# See https://docs.ai21.com/docs/overview
|
# See https://docs.ai21.com/docs/overview
|
||||||
- type: openai-compatible
|
- type: openai-compatible
|
||||||
|
|||||||
+52
@@ -806,6 +806,24 @@
|
|||||||
# - https://docs.x.ai/docs/models
|
# - https://docs.x.ai/docs/models
|
||||||
# - https://docs.x.ai/docs/api-reference#chat-completions
|
# - https://docs.x.ai/docs/api-reference#chat-completions
|
||||||
- provider: xai
|
- provider: xai
|
||||||
|
oauth:
|
||||||
|
client_id: b1a00492-073a-47ea-816f-4c329264a828
|
||||||
|
authorize_url: https://auth.x.ai/oauth2/authorize
|
||||||
|
token_url: https://auth.x.ai/oauth2/token
|
||||||
|
scopes:
|
||||||
|
- openid
|
||||||
|
- profile
|
||||||
|
- email
|
||||||
|
- offline_access
|
||||||
|
- grok-cli:access
|
||||||
|
- api:access
|
||||||
|
redirect_port: 56121
|
||||||
|
flow: pkce
|
||||||
|
token_request_format: form_url_encoded
|
||||||
|
extra_authorize_params:
|
||||||
|
plan: generic
|
||||||
|
referrer: coyote
|
||||||
|
echo_pkce_in_token_exchange: true
|
||||||
models:
|
models:
|
||||||
- name: grok-4.5
|
- name: grok-4.5
|
||||||
input_price: 2
|
input_price: 2
|
||||||
@@ -1677,6 +1695,30 @@
|
|||||||
# - https://platform.moonshot.cn/docs/api/chat#%E5%85%AC%E5%BC%80%E7%9A%84%E6%9C%8D%E5%8A%A1%E5%9C%B0%E5%9D%80
|
# - https://platform.moonshot.cn/docs/api/chat#%E5%85%AC%E5%BC%80%E7%9A%84%E6%9C%8D%E5%8A%A1%E5%9C%B0%E5%9D%80
|
||||||
- provider: moonshot
|
- provider: moonshot
|
||||||
models:
|
models:
|
||||||
|
- name: kimi-k3
|
||||||
|
max_input_tokens: 1048576
|
||||||
|
input_price: 3
|
||||||
|
output_price: 15
|
||||||
|
supports_vision: true
|
||||||
|
supports_function_calling: true
|
||||||
|
- name: kimi-k2.7-code
|
||||||
|
max_input_tokens: 262144
|
||||||
|
input_price: 0.95
|
||||||
|
output_price: 4
|
||||||
|
supports_vision: true
|
||||||
|
supports_function_calling: true
|
||||||
|
- name: kimi-k2.7-code-highspeed
|
||||||
|
max_input_tokens: 262144
|
||||||
|
input_price: 1.9
|
||||||
|
output_price: 8
|
||||||
|
supports_vision: true
|
||||||
|
supports_function_calling: true
|
||||||
|
- name: kimi-k2.6
|
||||||
|
max_input_tokens: 262144
|
||||||
|
input_price: 0.95
|
||||||
|
output_price: 4
|
||||||
|
supports_vision: true
|
||||||
|
supports_function_calling: true
|
||||||
- name: kimi-k2.5
|
- name: kimi-k2.5
|
||||||
max_input_tokens: 262144
|
max_input_tokens: 262144
|
||||||
input_price: 0.56
|
input_price: 0.56
|
||||||
@@ -1779,6 +1821,16 @@
|
|||||||
# - https://platform.minimaxi.com/document/ChatCompletion%20v2
|
# - https://platform.minimaxi.com/document/ChatCompletion%20v2
|
||||||
- provider: minimax
|
- provider: minimax
|
||||||
models:
|
models:
|
||||||
|
- name: minimax-m3
|
||||||
|
max_input_tokens: 1000000
|
||||||
|
input_price: 4.2
|
||||||
|
output_price: 16.8
|
||||||
|
supports_function_calling: true
|
||||||
|
- name: minimax-m2.7
|
||||||
|
max_input_tokens: 204800
|
||||||
|
input_price: 0.294
|
||||||
|
output_price: 1.176
|
||||||
|
supports_function_calling: true
|
||||||
- name: minimax-m2.5
|
- name: minimax-m2.5
|
||||||
max_input_tokens: 204800
|
max_input_tokens: 204800
|
||||||
input_price: 0.294
|
input_price: 0.294
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ impl OAuthProvider for ClaudeOAuthProvider {
|
|||||||
"https://console.anthropic.com/oauth/code/callback"
|
"https://console.anthropic.com/oauth/code/callback"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn scopes(&self) -> &str {
|
fn scopes(&self) -> String {
|
||||||
"org:create_api_key user:profile user:inference"
|
"org:create_api_key user:profile user:inference".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extra_authorize_params(&self) -> Vec<(&str, &str)> {
|
fn extra_authorize_params(&self) -> Vec<(&str, &str)> {
|
||||||
|
|||||||
+18
-3
@@ -403,9 +403,24 @@ pub async fn create_openai_compatible_client_config(
|
|||||||
};
|
};
|
||||||
config["api_base"] = api_base.into();
|
config["api_base"] = api_base.into();
|
||||||
|
|
||||||
let api_key = prompt_input_string("API Key", false, None)?;
|
let has_bundled_oauth = ALL_PROVIDER_MODELS
|
||||||
if !api_key.is_empty() {
|
.iter()
|
||||||
config["api_key"] = api_key.into();
|
.any(|p| p.provider == client && p.oauth.is_some());
|
||||||
|
|
||||||
|
let use_oauth = if has_bundled_oauth {
|
||||||
|
let choice = Select::new("Authentication method:", vec!["API Key", "OAuth"]).prompt()?;
|
||||||
|
choice == "OAuth"
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
if use_oauth {
|
||||||
|
config["auth"] = "oauth".into();
|
||||||
|
} else {
|
||||||
|
let api_key = prompt_input_string("API Key", false, None)?;
|
||||||
|
if !api_key.is_empty() {
|
||||||
|
config["api_key"] = api_key.into();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let model = set_client_models_config(&mut config, &name).await?;
|
let model = set_client_models_config(&mut config, &name).await?;
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ impl OAuthProvider for GeminiOAuthProvider {
|
|||||||
""
|
""
|
||||||
}
|
}
|
||||||
|
|
||||||
fn scopes(&self) -> &str {
|
fn scopes(&self) -> String {
|
||||||
"https://www.googleapis.com/auth/generative-language.peruserquota https://www.googleapis.com/auth/generative-language.retriever https://www.googleapis.com/auth/userinfo.email"
|
"https://www.googleapis.com/auth/generative-language.peruserquota https://www.googleapis.com/auth/generative-language.retriever https://www.googleapis.com/auth/userinfo.email".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn client_secret(&self) -> Option<&str> {
|
fn client_secret(&self) -> Option<&str> {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ mod common;
|
|||||||
mod gemini_oauth;
|
mod gemini_oauth;
|
||||||
mod message;
|
mod message;
|
||||||
pub mod oauth;
|
pub mod oauth;
|
||||||
|
mod openai_compatible_oauth;
|
||||||
mod openai_oauth;
|
mod openai_oauth;
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
mod macros;
|
mod macros;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use super::{
|
|||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
use crate::utils::{estimate_token_length, strip_think_tag};
|
use crate::utils::{estimate_token_length, strip_think_tag};
|
||||||
|
|
||||||
|
use super::oauth::OAuthConfig;
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -357,6 +358,8 @@ impl ModelData {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ProviderModels {
|
pub struct ProviderModels {
|
||||||
pub provider: String,
|
pub provider: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub oauth: Option<OAuthConfig>,
|
||||||
pub models: Vec<ModelData>,
|
pub models: Vec<ModelData>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+537
-63
@@ -1,10 +1,12 @@
|
|||||||
use super::ClientConfig;
|
|
||||||
use super::access_token::{is_valid_access_token, set_access_token};
|
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 crate::config::paths;
|
||||||
use anyhow::{Result, anyhow, bail};
|
use anyhow::{Result, anyhow, bail};
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
use indexmap::IndexMap;
|
||||||
use inquire::Text;
|
use inquire::Text;
|
||||||
use reqwest::{Client as ReqwestClient, RequestBuilder};
|
use reqwest::{Client as ReqwestClient, RequestBuilder};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -17,18 +19,101 @@ use std::net::TcpListener;
|
|||||||
use url::Url;
|
use url::Url;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum TokenRequestFormat {
|
pub enum TokenRequestFormat {
|
||||||
Json,
|
Json,
|
||||||
FormUrlEncoded,
|
FormUrlEncoded,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum OAuthFlow {
|
||||||
|
#[default]
|
||||||
|
Pkce,
|
||||||
|
ClientCredentials,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OAuthConfig {
|
||||||
|
pub client_id: String,
|
||||||
|
pub token_url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub flow: OAuthFlow,
|
||||||
|
|
||||||
|
pub client_secret: Option<String>,
|
||||||
|
pub authorize_url: Option<String>,
|
||||||
|
pub redirect_uri: Option<String>,
|
||||||
|
pub redirect_port: Option<u16>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub scopes: Vec<String>,
|
||||||
|
pub token_request_format: Option<TokenRequestFormat>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extra_authorize_params: IndexMap<String, String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extra_token_headers: IndexMap<String, String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extra_request_headers: IndexMap<String, String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub echo_pkce_in_token_exchange: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub include_state_in_token_exchange: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OAuthConfig {
|
||||||
|
pub fn merge(mut self, override_cfg: OAuthConfig) -> Self {
|
||||||
|
self.client_id = override_cfg.client_id;
|
||||||
|
self.token_url = override_cfg.token_url;
|
||||||
|
self.flow = override_cfg.flow;
|
||||||
|
if override_cfg.client_secret.is_some() {
|
||||||
|
self.client_secret = override_cfg.client_secret;
|
||||||
|
}
|
||||||
|
if override_cfg.authorize_url.is_some() {
|
||||||
|
self.authorize_url = override_cfg.authorize_url;
|
||||||
|
}
|
||||||
|
if override_cfg.redirect_uri.is_some() {
|
||||||
|
self.redirect_uri = override_cfg.redirect_uri;
|
||||||
|
}
|
||||||
|
if override_cfg.redirect_port.is_some() {
|
||||||
|
self.redirect_port = override_cfg.redirect_port;
|
||||||
|
}
|
||||||
|
if !override_cfg.scopes.is_empty() {
|
||||||
|
self.scopes = override_cfg.scopes;
|
||||||
|
}
|
||||||
|
if override_cfg.token_request_format.is_some() {
|
||||||
|
self.token_request_format = override_cfg.token_request_format;
|
||||||
|
}
|
||||||
|
if !override_cfg.extra_authorize_params.is_empty() {
|
||||||
|
self.extra_authorize_params
|
||||||
|
.extend(override_cfg.extra_authorize_params);
|
||||||
|
}
|
||||||
|
if !override_cfg.extra_token_headers.is_empty() {
|
||||||
|
self.extra_token_headers
|
||||||
|
.extend(override_cfg.extra_token_headers);
|
||||||
|
}
|
||||||
|
if !override_cfg.extra_request_headers.is_empty() {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub trait OAuthProvider: Send + Sync {
|
pub trait OAuthProvider: Send + Sync {
|
||||||
fn provider_name(&self) -> &str;
|
fn provider_name(&self) -> &str;
|
||||||
fn client_id(&self) -> &str;
|
fn client_id(&self) -> &str;
|
||||||
fn authorize_url(&self) -> &str;
|
fn authorize_url(&self) -> &str;
|
||||||
fn token_url(&self) -> &str;
|
fn token_url(&self) -> &str;
|
||||||
fn redirect_uri(&self) -> &str;
|
fn redirect_uri(&self) -> &str;
|
||||||
fn scopes(&self) -> &str;
|
fn scopes(&self) -> String;
|
||||||
|
|
||||||
fn client_secret(&self) -> Option<&str> {
|
fn client_secret(&self) -> Option<&str> {
|
||||||
None
|
None
|
||||||
@@ -65,6 +150,14 @@ pub trait OAuthProvider: Send + Sync {
|
|||||||
fn include_state_in_token_exchange(&self) -> bool {
|
fn include_state_in_token_exchange(&self) -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn flow(&self) -> OAuthFlow {
|
||||||
|
OAuthFlow::Pkce
|
||||||
|
}
|
||||||
|
|
||||||
|
fn echo_pkce_in_token_exchange(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -78,6 +171,13 @@ pub struct OAuthTokens {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run_oauth_flow(provider: &dyn OAuthProvider, client_name: &str) -> Result<()> {
|
pub async fn run_oauth_flow(provider: &dyn OAuthProvider, client_name: &str) -> Result<()> {
|
||||||
|
match provider.flow() {
|
||||||
|
OAuthFlow::Pkce => run_pkce_flow(provider, client_name).await,
|
||||||
|
OAuthFlow::ClientCredentials => run_client_credentials_flow(provider, client_name).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_pkce_flow(provider: &dyn OAuthProvider, client_name: &str) -> Result<()> {
|
||||||
let random_bytes: [u8; 32] = rand::random::<[u8; 32]>();
|
let random_bytes: [u8; 32] = rand::random::<[u8; 32]>();
|
||||||
let code_verifier = URL_SAFE_NO_PAD.encode(random_bytes);
|
let code_verifier = URL_SAFE_NO_PAD.encode(random_bytes);
|
||||||
|
|
||||||
@@ -99,7 +199,8 @@ pub async fn run_oauth_flow(provider: &dyn OAuthProvider, client_name: &str) ->
|
|||||||
(provider.redirect_uri().to_string(), false)
|
(provider.redirect_uri().to_string(), false)
|
||||||
};
|
};
|
||||||
|
|
||||||
let encoded_scopes = urlencoding::encode(provider.scopes());
|
let scopes = provider.scopes();
|
||||||
|
let encoded_scopes = urlencoding::encode(&scopes);
|
||||||
let encoded_redirect = urlencoding::encode(&redirect_uri);
|
let encoded_redirect = urlencoding::encode(&redirect_uri);
|
||||||
|
|
||||||
let mut authorize_url = format!(
|
let mut authorize_url = format!(
|
||||||
@@ -158,6 +259,10 @@ pub async fn run_oauth_flow(provider: &dyn OAuthProvider, client_name: &str) ->
|
|||||||
if provider.include_state_in_token_exchange() {
|
if provider.include_state_in_token_exchange() {
|
||||||
token_params.push(("state", state.as_str()));
|
token_params.push(("state", state.as_str()));
|
||||||
}
|
}
|
||||||
|
if provider.echo_pkce_in_token_exchange() {
|
||||||
|
token_params.push(("code_challenge", code_challenge.as_str()));
|
||||||
|
token_params.push(("code_challenge_method", "S256"));
|
||||||
|
}
|
||||||
let request = build_token_request(&client, provider, &token_params);
|
let request = build_token_request(&client, provider, &token_params);
|
||||||
|
|
||||||
let response: Value = request.send().await?.json().await?;
|
let response: Value = request.send().await?.json().await?;
|
||||||
@@ -193,6 +298,48 @@ pub async fn run_oauth_flow(provider: &dyn OAuthProvider, client_name: &str) ->
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn run_client_credentials_flow(
|
||||||
|
provider: &dyn OAuthProvider,
|
||||||
|
client_name: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let client = ReqwestClient::new();
|
||||||
|
let scopes = provider.scopes();
|
||||||
|
let mut params: Vec<(&str, &str)> = vec![
|
||||||
|
("grant_type", "client_credentials"),
|
||||||
|
("client_id", provider.client_id()),
|
||||||
|
];
|
||||||
|
if !scopes.is_empty() {
|
||||||
|
params.push(("scope", scopes.as_str()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = build_token_request(&client, provider, ¶ms);
|
||||||
|
let response: Value = request.send().await?.json().await?;
|
||||||
|
|
||||||
|
let access_token = response["access_token"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow!("Missing access_token in client_credentials response: {response}"))?
|
||||||
|
.to_string();
|
||||||
|
let expires_in = response["expires_in"]
|
||||||
|
.as_i64()
|
||||||
|
.ok_or_else(|| anyhow!("Missing expires_in in client_credentials response: {response}"))?;
|
||||||
|
let expires_at = Utc::now().timestamp() + expires_in;
|
||||||
|
|
||||||
|
let tokens = OAuthTokens {
|
||||||
|
access_token,
|
||||||
|
refresh_token: None,
|
||||||
|
expires_at,
|
||||||
|
account_id: provider.extract_account_id(&response),
|
||||||
|
};
|
||||||
|
save_oauth_tokens(client_name, &tokens)?;
|
||||||
|
println!(
|
||||||
|
"Successfully authenticated client '{}' with {} via OAuth (client_credentials). Tokens saved.",
|
||||||
|
client_name,
|
||||||
|
provider.provider_name()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn load_oauth_tokens(client_name: &str) -> Option<OAuthTokens> {
|
pub fn load_oauth_tokens(client_name: &str) -> Option<OAuthTokens> {
|
||||||
let path = paths::token_file(client_name);
|
let path = paths::token_file(client_name);
|
||||||
let content = fs::read_to_string(path).ok()?;
|
let content = fs::read_to_string(path).ok()?;
|
||||||
@@ -211,7 +358,7 @@ fn save_oauth_tokens(client_name: &str, tokens: &OAuthTokens) -> Result<()> {
|
|||||||
|
|
||||||
pub async fn refresh_oauth_token(
|
pub async fn refresh_oauth_token(
|
||||||
client: &ReqwestClient,
|
client: &ReqwestClient,
|
||||||
provider: &impl OAuthProvider,
|
provider: &dyn OAuthProvider,
|
||||||
client_name: &str,
|
client_name: &str,
|
||||||
tokens: &OAuthTokens,
|
tokens: &OAuthTokens,
|
||||||
) -> Result<OAuthTokens> {
|
) -> Result<OAuthTokens> {
|
||||||
@@ -265,7 +412,7 @@ pub async fn refresh_oauth_token(
|
|||||||
|
|
||||||
pub async fn prepare_oauth_access_token(
|
pub async fn prepare_oauth_access_token(
|
||||||
client: &ReqwestClient,
|
client: &ReqwestClient,
|
||||||
provider: &impl OAuthProvider,
|
provider: &dyn OAuthProvider,
|
||||||
client_name: &str,
|
client_name: &str,
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
if is_valid_access_token(client_name) {
|
if is_valid_access_token(client_name) {
|
||||||
@@ -278,16 +425,23 @@ pub async fn prepare_oauth_access_token(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let tokens = if Utc::now().timestamp() >= tokens.expires_at {
|
let tokens = if Utc::now().timestamp() >= tokens.expires_at {
|
||||||
refresh_oauth_token(client, provider, client_name, &tokens).await?
|
match provider.flow() {
|
||||||
|
OAuthFlow::Pkce => refresh_oauth_token(client, provider, client_name, &tokens).await?,
|
||||||
|
OAuthFlow::ClientCredentials => {
|
||||||
|
run_client_credentials_flow(provider, client_name).await?;
|
||||||
|
load_oauth_tokens(client_name)
|
||||||
|
.ok_or_else(|| anyhow!("Token file missing after client_credentials refresh"))?
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
tokens
|
tokens
|
||||||
};
|
};
|
||||||
|
|
||||||
set_access_token(
|
set_access_token(
|
||||||
client_name,
|
client_name,
|
||||||
tokens.access_token.clone(),
|
tokens.access_token,
|
||||||
tokens.expires_at,
|
tokens.expires_at,
|
||||||
tokens.account_id.clone(),
|
tokens.account_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -342,55 +496,65 @@ fn listen_for_oauth_callback(redirect_uri: &str) -> Result<(String, String)> {
|
|||||||
.ok_or_else(|| anyhow!("No port in redirect URI"))?;
|
.ok_or_else(|| anyhow!("No port in redirect URI"))?;
|
||||||
let path = url.path();
|
let path = url.path();
|
||||||
|
|
||||||
println!("Waiting for OAuth callback on {redirect_uri} ...\n");
|
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"
|
||||||
|
);
|
||||||
|
|
||||||
let listener = TcpListener::bind(format!("{host}:{port}"))?;
|
let listener = TcpListener::bind(format!("{host}:{port}"))?;
|
||||||
let (mut stream, _) = listener.accept()?;
|
|
||||||
|
|
||||||
let mut reader = BufReader::new(&stream);
|
loop {
|
||||||
let mut request_line = String::new();
|
let (mut stream, _) = listener.accept()?;
|
||||||
reader.read_line(&mut request_line)?;
|
let mut reader = BufReader::new(&stream);
|
||||||
|
let mut request_line = String::new();
|
||||||
|
if reader.read_line(&mut request_line).is_err() || request_line.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let request_path = request_line
|
let Some(request_path) = request_line.split_whitespace().nth(1) else {
|
||||||
.split_whitespace()
|
continue;
|
||||||
.nth(1)
|
};
|
||||||
.ok_or_else(|| anyhow!("Malformed HTTP request from OAuth callback"))?;
|
|
||||||
|
|
||||||
let full_url = format!("http://{host}:{port}{request_path}");
|
let Ok(parsed) = format!("http://{host}:{port}{request_path}").parse::<Url>() else {
|
||||||
let parsed: Url = full_url.parse()?;
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
if !parsed.path().starts_with(path) {
|
if !parsed.path().starts_with(path) {
|
||||||
bail!("Unexpected callback path: {}", parsed.path());
|
let _ = stream.write_all(
|
||||||
|
b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let code = parsed
|
||||||
|
.query_pairs()
|
||||||
|
.find(|(k, _)| k == "code")
|
||||||
|
.map(|(_, v)| v.to_string())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
let error = parsed
|
||||||
|
.query_pairs()
|
||||||
|
.find(|(k, _)| k == "error")
|
||||||
|
.map(|(_, v)| v.to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
anyhow!("OAuth callback returned error: {error}")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let returned_state = parsed
|
||||||
|
.query_pairs()
|
||||||
|
.find(|(k, _)| k == "state")
|
||||||
|
.map(|(_, v)| v.to_string())
|
||||||
|
.ok_or_else(|| anyhow!("Missing state parameter in OAuth callback"))?;
|
||||||
|
|
||||||
|
let response_body = "<html><body><h2>Authentication successful!</h2><p>You can close this tab and return to your terminal.</p></body></html>";
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||||
|
response_body.len(),
|
||||||
|
response_body
|
||||||
|
);
|
||||||
|
stream.write_all(response.as_bytes())?;
|
||||||
|
|
||||||
|
return Ok((code, returned_state));
|
||||||
}
|
}
|
||||||
|
|
||||||
let code = parsed
|
|
||||||
.query_pairs()
|
|
||||||
.find(|(k, _)| k == "code")
|
|
||||||
.map(|(_, v)| v.to_string())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
let error = parsed
|
|
||||||
.query_pairs()
|
|
||||||
.find(|(k, _)| k == "error")
|
|
||||||
.map(|(_, v)| v.to_string())
|
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
|
||||||
anyhow!("OAuth callback returned error: {error}")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let returned_state = parsed
|
|
||||||
.query_pairs()
|
|
||||||
.find(|(k, _)| k == "state")
|
|
||||||
.map(|(_, v)| v.to_string())
|
|
||||||
.ok_or_else(|| anyhow!("Missing state parameter in OAuth callback"))?;
|
|
||||||
|
|
||||||
let response_body = "<html><body><h2>Authentication successful!</h2><p>You can close this tab and return to your terminal.</p></body></html>";
|
|
||||||
let response = format!(
|
|
||||||
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
|
||||||
response_body.len(),
|
|
||||||
response_body
|
|
||||||
);
|
|
||||||
stream.write_all(response.as_bytes())?;
|
|
||||||
|
|
||||||
Ok((code, returned_state))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_oauth_provider(provider_type: &str) -> Option<Box<dyn OAuthProvider>> {
|
pub fn get_oauth_provider(provider_type: &str) -> Option<Box<dyn OAuthProvider>> {
|
||||||
@@ -402,25 +566,43 @@ pub fn get_oauth_provider(provider_type: &str) -> Option<Box<dyn OAuthProvider>>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_provider_type(client_name: &str, clients: &[ClientConfig]) -> Option<&'static str> {
|
pub fn get_oauth_provider_for_client(
|
||||||
for client_config in clients {
|
client_config: &ClientConfig,
|
||||||
let (config_name, provider_type, auth) = client_config_info(client_config);
|
all_provider_models: &[ProviderModels],
|
||||||
if config_name == client_name {
|
) -> Option<Box<dyn OAuthProvider>> {
|
||||||
if auth == Some("oauth") && get_oauth_provider(provider_type).is_some() {
|
let (client_name, provider_type, auth) = client_config_info(client_config);
|
||||||
return Some(provider_type);
|
if auth != Some("oauth") {
|
||||||
}
|
return None;
|
||||||
return None;
|
}
|
||||||
|
|
||||||
|
match client_config {
|
||||||
|
ClientConfig::OpenAICompatibleConfig(c) => {
|
||||||
|
let base = all_provider_models
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.provider == client_name)
|
||||||
|
.and_then(|p| p.oauth.clone());
|
||||||
|
let user_oauth = c.oauth.clone().map(|b| *b);
|
||||||
|
let merged = match (base, user_oauth) {
|
||||||
|
(None, None) => return None,
|
||||||
|
(Some(b), None) => b,
|
||||||
|
(None, Some(u)) => u,
|
||||||
|
(Some(b), Some(u)) => b.merge(u),
|
||||||
|
};
|
||||||
|
Some(Box::new(OpenAICompatibleOAuthProvider {
|
||||||
|
config: merged,
|
||||||
|
client_name: client_name.to_string(),
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
_ => get_oauth_provider(provider_type),
|
||||||
}
|
}
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_oauth_capable_clients(clients: &[ClientConfig]) -> Vec<String> {
|
pub fn list_oauth_capable_clients(clients: &[ClientConfig]) -> Vec<String> {
|
||||||
clients
|
clients
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|client_config| {
|
.filter_map(|client_config| {
|
||||||
let (name, provider_type, auth) = client_config_info(client_config);
|
let (name, _, auth) = client_config_info(client_config);
|
||||||
if auth == Some("oauth") && get_oauth_provider(provider_type).is_some() {
|
if auth == Some("oauth") {
|
||||||
Some(name.to_string())
|
Some(name.to_string())
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -429,7 +611,9 @@ pub fn list_oauth_capable_clients(clients: &[ClientConfig]) -> Vec<String> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn client_config_info(client_config: &ClientConfig) -> (&str, &'static str, Option<&str>) {
|
pub(crate) fn client_config_info(
|
||||||
|
client_config: &ClientConfig,
|
||||||
|
) -> (&str, &'static str, Option<&str>) {
|
||||||
match client_config {
|
match client_config {
|
||||||
ClientConfig::ClaudeConfig(c) => (
|
ClientConfig::ClaudeConfig(c) => (
|
||||||
c.name.as_deref().unwrap_or("claude"),
|
c.name.as_deref().unwrap_or("claude"),
|
||||||
@@ -444,7 +628,7 @@ fn client_config_info(client_config: &ClientConfig) -> (&str, &'static str, Opti
|
|||||||
ClientConfig::OpenAICompatibleConfig(c) => (
|
ClientConfig::OpenAICompatibleConfig(c) => (
|
||||||
c.name.as_deref().unwrap_or("openai-compatible"),
|
c.name.as_deref().unwrap_or("openai-compatible"),
|
||||||
"openai-compatible",
|
"openai-compatible",
|
||||||
None,
|
c.auth.as_deref(),
|
||||||
),
|
),
|
||||||
ClientConfig::GeminiConfig(c) => (
|
ClientConfig::GeminiConfig(c) => (
|
||||||
c.name.as_deref().unwrap_or("gemini"),
|
c.name.as_deref().unwrap_or("gemini"),
|
||||||
@@ -464,3 +648,293 @@ fn client_config_info(client_config: &ClientConfig) -> (&str, &'static str, Opti
|
|||||||
ClientConfig::Unknown => ("unknown", "unknown", None),
|
ClientConfig::Unknown => ("unknown", "unknown", None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::client::openai_compatible::OpenAICompatibleConfig;
|
||||||
|
use crate::client::{ModelData, ProviderModels};
|
||||||
|
|
||||||
|
fn base_config() -> OAuthConfig {
|
||||||
|
OAuthConfig {
|
||||||
|
client_id: "base-id".into(),
|
||||||
|
token_url: "https://base.example/token".into(),
|
||||||
|
flow: OAuthFlow::Pkce,
|
||||||
|
client_secret: Some("base-secret".into()),
|
||||||
|
authorize_url: Some("https://base.example/authorize".into()),
|
||||||
|
redirect_uri: None,
|
||||||
|
redirect_port: Some(1234),
|
||||||
|
scopes: vec!["a".into(), "b".into()],
|
||||||
|
token_request_format: Some(TokenRequestFormat::FormUrlEncoded),
|
||||||
|
extra_authorize_params: IndexMap::from([("plan".into(), "base".into())]),
|
||||||
|
extra_token_headers: IndexMap::new(),
|
||||||
|
extra_request_headers: IndexMap::new(),
|
||||||
|
echo_pkce_in_token_exchange: false,
|
||||||
|
include_state_in_token_exchange: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_user_override(client_id: &str, token_url: &str) -> OAuthConfig {
|
||||||
|
OAuthConfig {
|
||||||
|
client_id: client_id.into(),
|
||||||
|
token_url: token_url.into(),
|
||||||
|
flow: OAuthFlow::Pkce,
|
||||||
|
client_secret: None,
|
||||||
|
authorize_url: None,
|
||||||
|
redirect_uri: None,
|
||||||
|
redirect_port: None,
|
||||||
|
scopes: vec![],
|
||||||
|
token_request_format: None,
|
||||||
|
extra_authorize_params: IndexMap::new(),
|
||||||
|
extra_token_headers: IndexMap::new(),
|
||||||
|
extra_request_headers: IndexMap::new(),
|
||||||
|
echo_pkce_in_token_exchange: false,
|
||||||
|
include_state_in_token_exchange: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oauth_config_merge_user_wins_per_field() {
|
||||||
|
let base = base_config();
|
||||||
|
let mut user = empty_user_override("user-id", "https://user.example/token");
|
||||||
|
user.client_secret = Some("user-secret".into());
|
||||||
|
user.scopes = vec!["c".into()];
|
||||||
|
user.extra_authorize_params = IndexMap::from([("plan".into(), "user".into())]);
|
||||||
|
|
||||||
|
let merged = base.merge(user);
|
||||||
|
|
||||||
|
assert_eq!(merged.client_id, "user-id");
|
||||||
|
assert_eq!(merged.token_url, "https://user.example/token");
|
||||||
|
assert_eq!(merged.client_secret.as_deref(), Some("user-secret"));
|
||||||
|
assert_eq!(
|
||||||
|
merged.authorize_url.as_deref(),
|
||||||
|
Some("https://base.example/authorize")
|
||||||
|
);
|
||||||
|
assert_eq!(merged.redirect_port, Some(1234));
|
||||||
|
assert_eq!(merged.scopes, vec!["c"]);
|
||||||
|
assert_eq!(
|
||||||
|
merged
|
||||||
|
.extra_authorize_params
|
||||||
|
.get("plan")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("user")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oauth_config_merge_empty_user_keeps_base_optionals() {
|
||||||
|
let base = base_config();
|
||||||
|
let user = empty_user_override("user-id", "https://user.example/token");
|
||||||
|
|
||||||
|
let merged = base.merge(user);
|
||||||
|
|
||||||
|
assert_eq!(merged.client_id, "user-id");
|
||||||
|
assert_eq!(merged.token_url, "https://user.example/token");
|
||||||
|
assert_eq!(merged.client_secret.as_deref(), Some("base-secret"));
|
||||||
|
assert_eq!(
|
||||||
|
merged.authorize_url.as_deref(),
|
||||||
|
Some("https://base.example/authorize")
|
||||||
|
);
|
||||||
|
assert_eq!(merged.redirect_port, Some(1234));
|
||||||
|
assert_eq!(merged.scopes, vec!["a", "b"]);
|
||||||
|
assert!(matches!(
|
||||||
|
merged.token_request_format,
|
||||||
|
Some(TokenRequestFormat::FormUrlEncoded)
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
merged
|
||||||
|
.extra_authorize_params
|
||||||
|
.get("plan")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("base")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oauth_config_serde_roundtrip_from_yaml() {
|
||||||
|
let yaml = r#"
|
||||||
|
client_id: xai-client
|
||||||
|
token_url: https://auth.x.ai/oauth2/token
|
||||||
|
authorize_url: https://auth.x.ai/oauth2/authorize
|
||||||
|
scopes:
|
||||||
|
- openid
|
||||||
|
- profile
|
||||||
|
- api:access
|
||||||
|
redirect_port: 56121
|
||||||
|
flow: pkce
|
||||||
|
token_request_format: form_url_encoded
|
||||||
|
extra_authorize_params:
|
||||||
|
plan: generic
|
||||||
|
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);
|
||||||
|
assert_eq!(cfg.redirect_port, Some(56121));
|
||||||
|
assert!(matches!(cfg.flow, OAuthFlow::Pkce));
|
||||||
|
assert!(matches!(
|
||||||
|
cfg.token_request_format,
|
||||||
|
Some(TokenRequestFormat::FormUrlEncoded)
|
||||||
|
));
|
||||||
|
assert!(cfg.echo_pkce_in_token_exchange);
|
||||||
|
assert!(cfg.include_state_in_token_exchange);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.extra_authorize_params.get("plan").map(String::as_str),
|
||||||
|
Some("generic")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_provider_models(provider: &str, oauth: Option<OAuthConfig>) -> ProviderModels {
|
||||||
|
ProviderModels {
|
||||||
|
provider: provider.into(),
|
||||||
|
oauth,
|
||||||
|
models: vec![ModelData::new("some-model")],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_openai_compat_client(
|
||||||
|
name: &str,
|
||||||
|
auth: Option<&str>,
|
||||||
|
oauth: Option<OAuthConfig>,
|
||||||
|
) -> ClientConfig {
|
||||||
|
ClientConfig::OpenAICompatibleConfig(OpenAICompatibleConfig {
|
||||||
|
name: Some(name.into()),
|
||||||
|
api_base: Some("https://api.example/v1".into()),
|
||||||
|
api_key: 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();
|
||||||
|
let mut user = empty_user_override("user-id", "https://user.example/token");
|
||||||
|
user.echo_pkce_in_token_exchange = true;
|
||||||
|
let models = vec![make_provider_models("acme", Some(base))];
|
||||||
|
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());
|
||||||
|
assert_eq!(
|
||||||
|
provider.fixed_redirect_uri().as_deref(),
|
||||||
|
Some("http://127.0.0.1:1234/callback")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_oauth_provider_for_client_uses_bundled_defaults_only() {
|
||||||
|
let base = base_config();
|
||||||
|
let models = vec![make_provider_models("bundled-only", Some(base))];
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_oauth_provider_for_client_uses_inline_only() {
|
||||||
|
let user = OAuthConfig {
|
||||||
|
client_id: "inline-id".into(),
|
||||||
|
token_url: "https://inline.example/token".into(),
|
||||||
|
..empty_user_override("inline-id", "https://inline.example/token")
|
||||||
|
};
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn openai_compatible_provider_joins_scopes_with_spaces() {
|
||||||
|
let mut cfg = base_config();
|
||||||
|
cfg.scopes = vec!["one".into(), "two".into(), "three".into()];
|
||||||
|
|
||||||
|
let provider = OpenAICompatibleOAuthProvider {
|
||||||
|
config: cfg,
|
||||||
|
client_name: "test".into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(provider.scopes(), "one two three");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn openai_compatible_provider_prefers_redirect_uri_over_port() {
|
||||||
|
let mut cfg = base_config();
|
||||||
|
cfg.redirect_uri = Some("https://custom.example/cb".into());
|
||||||
|
cfg.redirect_port = Some(9999);
|
||||||
|
|
||||||
|
let provider = OpenAICompatibleOAuthProvider {
|
||||||
|
config: cfg,
|
||||||
|
client_name: "test".into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
provider.fixed_redirect_uri().as_deref(),
|
||||||
|
Some("https://custom.example/cb")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn openai_compatible_provider_ephemeral_when_no_redirect() {
|
||||||
|
let mut cfg = base_config();
|
||||||
|
cfg.redirect_uri = None;
|
||||||
|
cfg.redirect_port = None;
|
||||||
|
|
||||||
|
let provider = OpenAICompatibleOAuthProvider {
|
||||||
|
config: cfg,
|
||||||
|
client_name: "test".into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(provider.uses_localhost_redirect());
|
||||||
|
assert!(provider.fixed_redirect_uri().is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+122
-36
@@ -1,16 +1,21 @@
|
|||||||
|
use super::access_token::get_access_token;
|
||||||
|
use super::oauth;
|
||||||
use super::openai::*;
|
use super::openai::*;
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
use reqwest::RequestBuilder;
|
use reqwest::{Client as ReqwestClient, RequestBuilder};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
use oauth::OAuthConfig;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct OpenAICompatibleConfig {
|
pub struct OpenAICompatibleConfig {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub api_base: Option<String>,
|
pub api_base: Option<String>,
|
||||||
pub api_key: Option<String>,
|
pub api_key: Option<String>,
|
||||||
|
pub auth: Option<String>,
|
||||||
|
pub oauth: Option<Box<OAuthConfig>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub models: Vec<ModelData>,
|
pub models: Vec<ModelData>,
|
||||||
pub patch: Option<RequestPatch>,
|
pub patch: Option<RequestPatch>,
|
||||||
@@ -24,78 +29,159 @@ impl OpenAICompatibleClient {
|
|||||||
create_client_config!([]);
|
create_client_config!([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
impl_client_trait!(
|
#[async_trait::async_trait]
|
||||||
OpenAICompatibleClient,
|
impl Client for OpenAICompatibleClient {
|
||||||
(
|
client_common_fns!();
|
||||||
prepare_chat_completions,
|
|
||||||
openai_chat_completions,
|
|
||||||
openai_chat_completions_streaming
|
|
||||||
),
|
|
||||||
(prepare_embeddings, openai_embeddings),
|
|
||||||
(prepare_rerank, generic_rerank),
|
|
||||||
);
|
|
||||||
|
|
||||||
fn prepare_chat_completions(
|
fn supports_oauth(&self) -> bool {
|
||||||
|
self.config.auth.as_deref() == Some("oauth")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat_completions_inner(
|
||||||
|
&self,
|
||||||
|
client: &ReqwestClient,
|
||||||
|
data: ChatCompletionsData,
|
||||||
|
) -> Result<ChatCompletionsOutput> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat_completions_streaming_inner(
|
||||||
|
&self,
|
||||||
|
client: &ReqwestClient,
|
||||||
|
handler: &mut SseHandler,
|
||||||
|
data: ChatCompletionsData,
|
||||||
|
) -> 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
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn embeddings_inner(
|
||||||
|
&self,
|
||||||
|
client: &ReqwestClient,
|
||||||
|
data: &EmbeddingsData,
|
||||||
|
) -> Result<EmbeddingsOutput> {
|
||||||
|
let request_data = prepare_embeddings(self, client, data).await?;
|
||||||
|
let builder = self.request_builder(client, request_data);
|
||||||
|
|
||||||
|
openai_embeddings(builder, self.model()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rerank_inner(
|
||||||
|
&self,
|
||||||
|
client: &ReqwestClient,
|
||||||
|
data: &RerankData,
|
||||||
|
) -> Result<RerankOutput> {
|
||||||
|
let request_data = prepare_rerank(self, client, data).await?;
|
||||||
|
let builder = self.request_builder(client, request_data);
|
||||||
|
|
||||||
|
generic_rerank(builder, self.model()).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prepare_chat_completions(
|
||||||
self_: &OpenAICompatibleClient,
|
self_: &OpenAICompatibleClient,
|
||||||
|
client: &ReqwestClient,
|
||||||
data: ChatCompletionsData,
|
data: ChatCompletionsData,
|
||||||
) -> Result<RequestData> {
|
) -> Result<RequestData> {
|
||||||
let api_key = self_.get_api_key().ok();
|
|
||||||
let api_base = get_api_base_ext(self_)?;
|
let api_base = get_api_base_ext(self_)?;
|
||||||
|
|
||||||
let url = format!("{api_base}/chat/completions");
|
let url = format!("{api_base}/chat/completions");
|
||||||
|
|
||||||
let body = openai_build_chat_completions_body(data, &self_.model);
|
let body = openai_build_chat_completions_body(data, &self_.model);
|
||||||
|
|
||||||
let mut request_data = RequestData::new(url, body);
|
let mut request_data = RequestData::new(url, body);
|
||||||
|
|
||||||
if let Some(api_key) = api_key {
|
apply_auth(self_, client, &mut request_data).await?;
|
||||||
request_data.bearer_auth(api_key);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(request_data)
|
Ok(request_data)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prepare_embeddings(
|
async fn prepare_embeddings(
|
||||||
self_: &OpenAICompatibleClient,
|
self_: &OpenAICompatibleClient,
|
||||||
|
client: &ReqwestClient,
|
||||||
data: &EmbeddingsData,
|
data: &EmbeddingsData,
|
||||||
) -> Result<RequestData> {
|
) -> Result<RequestData> {
|
||||||
let api_key = self_.get_api_key().ok();
|
|
||||||
let api_base = get_api_base_ext(self_)?;
|
let api_base = get_api_base_ext(self_)?;
|
||||||
|
|
||||||
let url = format!("{api_base}/embeddings");
|
let url = format!("{api_base}/embeddings");
|
||||||
|
|
||||||
let body = openai_build_embeddings_body(data, &self_.model);
|
let body = openai_build_embeddings_body(data, &self_.model);
|
||||||
|
|
||||||
let mut request_data = RequestData::new(url, body);
|
let mut request_data = RequestData::new(url, body);
|
||||||
|
|
||||||
if let Some(api_key) = api_key {
|
apply_auth(self_, client, &mut request_data).await?;
|
||||||
request_data.bearer_auth(api_key);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(request_data)
|
Ok(request_data)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prepare_rerank(self_: &OpenAICompatibleClient, data: &RerankData) -> Result<RequestData> {
|
async fn prepare_rerank(
|
||||||
let api_key = self_.get_api_key().ok();
|
self_: &OpenAICompatibleClient,
|
||||||
|
client: &ReqwestClient,
|
||||||
|
data: &RerankData,
|
||||||
|
) -> Result<RequestData> {
|
||||||
let api_base = get_api_base_ext(self_)?;
|
let api_base = get_api_base_ext(self_)?;
|
||||||
|
|
||||||
let url = if self_.name().starts_with("ernie") {
|
let url = if self_.name().starts_with("ernie") {
|
||||||
format!("{api_base}/rerankers")
|
format!("{api_base}/rerankers")
|
||||||
} else {
|
} else {
|
||||||
format!("{api_base}/rerank")
|
format!("{api_base}/rerank")
|
||||||
};
|
};
|
||||||
|
|
||||||
let body = generic_build_rerank_body(data, &self_.model);
|
let body = generic_build_rerank_body(data, &self_.model);
|
||||||
|
|
||||||
let mut request_data = RequestData::new(url, body);
|
let mut request_data = RequestData::new(url, body);
|
||||||
|
|
||||||
if let Some(api_key) = api_key {
|
apply_auth(self_, client, &mut request_data).await?;
|
||||||
request_data.bearer_auth(api_key);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(request_data)
|
Ok(request_data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn apply_auth(
|
||||||
|
self_: &OpenAICompatibleClient,
|
||||||
|
client: &ReqwestClient,
|
||||||
|
request_data: &mut RequestData,
|
||||||
|
) -> Result<()> {
|
||||||
|
if self_.config.auth.as_deref() == Some("oauth") {
|
||||||
|
let client_name = self_.name();
|
||||||
|
let app_config = self_.app_config();
|
||||||
|
let cc = app_config
|
||||||
|
.clients
|
||||||
|
.iter()
|
||||||
|
.find(|cc| {
|
||||||
|
matches!(
|
||||||
|
cc,
|
||||||
|
ClientConfig::OpenAICompatibleConfig(c)
|
||||||
|
if c.name.as_deref().unwrap_or("openai-compatible") == client_name
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow!("Could not locate ClientConfig entry for '{}'", client_name)
|
||||||
|
})?;
|
||||||
|
let provider = oauth::get_oauth_provider_for_client(cc, &ALL_PROVIDER_MODELS)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow!(
|
||||||
|
"OAuth configured for '{}' but no oauth block resolved (missing from both models.yaml and user config)",
|
||||||
|
client_name
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let ready = oauth::prepare_oauth_access_token(client, &*provider, client_name).await?;
|
||||||
|
if !ready {
|
||||||
|
bail!(
|
||||||
|
"OAuth configured for '{}' but no tokens found. Run: 'coyote --authenticate {}' or '.authenticate' in the REPL",
|
||||||
|
client_name,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
} else if let Ok(api_key) = self_.get_api_key() {
|
||||||
|
request_data.bearer_auth(api_key);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn get_api_base_ext(self_: &OpenAICompatibleClient) -> Result<String> {
|
fn get_api_base_ext(self_: &OpenAICompatibleClient) -> Result<String> {
|
||||||
let api_base = match self_.get_api_base() {
|
let api_base = match self_.get_api_base() {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
use super::oauth::{OAuthConfig, OAuthFlow, OAuthProvider, TokenRequestFormat};
|
||||||
|
|
||||||
|
pub struct OpenAICompatibleOAuthProvider {
|
||||||
|
pub config: OAuthConfig,
|
||||||
|
pub client_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OAuthProvider for OpenAICompatibleOAuthProvider {
|
||||||
|
fn provider_name(&self) -> &str {
|
||||||
|
&self.client_name
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_id(&self) -> &str {
|
||||||
|
&self.config.client_id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn authorize_url(&self) -> &str {
|
||||||
|
self.config.authorize_url.as_deref().unwrap_or("")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_url(&self) -> &str {
|
||||||
|
&self.config.token_url
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redirect_uri(&self) -> &str {
|
||||||
|
self.config.redirect_uri.as_deref().unwrap_or("")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scopes(&self) -> String {
|
||||||
|
self.config.scopes.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_secret(&self) -> Option<&str> {
|
||||||
|
self.config.client_secret.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extra_authorize_params(&self) -> Vec<(&str, &str)> {
|
||||||
|
self.config
|
||||||
|
.extra_authorize_params
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_request_format(&self) -> TokenRequestFormat {
|
||||||
|
self.config
|
||||||
|
.token_request_format
|
||||||
|
.unwrap_or(TokenRequestFormat::FormUrlEncoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uses_localhost_redirect(&self) -> bool {
|
||||||
|
self.config.redirect_uri.is_none() && self.config.redirect_port.is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extra_token_headers(&self) -> Vec<(&str, &str)> {
|
||||||
|
self.config
|
||||||
|
.extra_token_headers
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extra_request_headers(&self) -> Vec<(&str, &str)> {
|
||||||
|
self.config
|
||||||
|
.extra_request_headers
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixed_redirect_uri(&self) -> Option<String> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
fn echo_pkce_in_token_exchange(&self) -> bool {
|
||||||
|
self.config.echo_pkce_in_token_exchange
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,8 +26,8 @@ impl OAuthProvider for OpenAIOAuthProvider {
|
|||||||
"http://localhost:1455/auth/callback"
|
"http://localhost:1455/auth/callback"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn scopes(&self) -> &str {
|
fn scopes(&self) -> String {
|
||||||
"openid profile email offline_access"
|
"openid profile email offline_access".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn token_request_format(&self) -> TokenRequestFormat {
|
fn token_request_format(&self) -> TokenRequestFormat {
|
||||||
|
|||||||
+14
-2
@@ -44,7 +44,7 @@ pub use self::skill_registry::SkillRegistry;
|
|||||||
pub use self::update::run_self_update;
|
pub use self::update::run_self_update;
|
||||||
use crate::client::{
|
use crate::client::{
|
||||||
ClientConfig, MessageContentToolCalls, Model, ModelType, OPENAI_COMPATIBLE_PROVIDERS,
|
ClientConfig, MessageContentToolCalls, Model, ModelType, OPENAI_COMPATIBLE_PROVIDERS,
|
||||||
ProviderModels, create_client_config, list_client_types,
|
ProviderModels, create_client_config, list_client_types, oauth,
|
||||||
};
|
};
|
||||||
use crate::function::{FunctionDeclaration, Functions};
|
use crate::function::{FunctionDeclaration, Functions};
|
||||||
use crate::rag::Rag;
|
use crate::rag::Rag;
|
||||||
@@ -63,7 +63,7 @@ use indoc::formatdoc;
|
|||||||
use inquire::{Confirm, Select};
|
use inquire::{Confirm, Select};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
use std::{
|
use std::{
|
||||||
env,
|
env,
|
||||||
@@ -596,6 +596,18 @@ impl Config {
|
|||||||
})
|
})
|
||||||
.with_context(|| "Failed to load config from str")?;
|
.with_context(|| "Failed to load config from str")?;
|
||||||
|
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
for cc in &config.clients {
|
||||||
|
let (name, _, _) = oauth::client_config_info(cc);
|
||||||
|
if !seen.insert(name.to_string()) {
|
||||||
|
bail!(
|
||||||
|
"Duplicate client name '{name}' in config.yaml. \
|
||||||
|
Client names must be unique across all `clients[]` entries \
|
||||||
|
to avoid OAuth token collisions."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+31
-22
@@ -769,28 +769,37 @@ fn resolve_oauth_client(
|
|||||||
explicit: Option<&str>,
|
explicit: Option<&str>,
|
||||||
clients: &[ClientConfig],
|
clients: &[ClientConfig],
|
||||||
) -> Result<(String, Box<dyn OAuthProvider>)> {
|
) -> Result<(String, Box<dyn OAuthProvider>)> {
|
||||||
if let Some(name) = explicit {
|
let find_by_name = |name: &str| -> Option<&ClientConfig> {
|
||||||
let provider_type = oauth::resolve_provider_type(name, clients)
|
clients.iter().find(|cc| {
|
||||||
.ok_or_else(|| anyhow!("Client '{name}' not found or doesn't support OAuth"))?;
|
let (n, _, auth) = oauth::client_config_info(cc);
|
||||||
let provider = oauth::get_oauth_provider(provider_type).unwrap();
|
n == name && auth == Some("oauth")
|
||||||
return Ok((name.to_string(), provider));
|
})
|
||||||
}
|
};
|
||||||
|
|
||||||
let candidates = oauth::list_oauth_capable_clients(clients);
|
let target = if let Some(name) = explicit {
|
||||||
match candidates.len() {
|
find_by_name(name)
|
||||||
0 => bail!("No OAuth-capable clients configured."),
|
.ok_or_else(|| anyhow!("Client '{name}' not found or doesn't support OAuth"))?
|
||||||
1 => {
|
} else {
|
||||||
let name = &candidates[0];
|
let candidates = oauth::list_oauth_capable_clients(clients);
|
||||||
let provider_type = oauth::resolve_provider_type(name, clients).unwrap();
|
match candidates.len() {
|
||||||
let provider = oauth::get_oauth_provider(provider_type).unwrap();
|
0 => bail!("No OAuth-capable clients configured."),
|
||||||
Ok((name.clone(), provider))
|
1 => find_by_name(&candidates[0]).unwrap(),
|
||||||
|
_ => {
|
||||||
|
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"))?
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
};
|
||||||
let choice =
|
|
||||||
Select::new("Select a client to authenticate:", candidates.clone()).prompt()?;
|
let name = oauth::client_config_info(target).0.to_string();
|
||||||
let provider_type = oauth::resolve_provider_type(&choice, clients).unwrap();
|
let provider = oauth::get_oauth_provider_for_client(target, &client::ALL_PROVIDER_MODELS)
|
||||||
let provider = oauth::get_oauth_provider(provider_type).unwrap();
|
.ok_or_else(|| {
|
||||||
Ok((choice, provider))
|
anyhow!(
|
||||||
}
|
"Could not build OAuth provider for '{name}' (no oauth config in models.yaml or user config)"
|
||||||
}
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok((name, provider))
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -61,8 +61,8 @@ impl OAuthProvider for McpOAuthProvider {
|
|||||||
""
|
""
|
||||||
}
|
}
|
||||||
|
|
||||||
fn scopes(&self) -> &str {
|
fn scopes(&self) -> String {
|
||||||
&self.scopes
|
self.scopes.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn token_request_format(&self) -> TokenRequestFormat {
|
fn token_request_format(&self) -> TokenRequestFormat {
|
||||||
|
|||||||
Reference in New Issue
Block a user