feat: support config-driven OAuth endpoints on the openai and gemini clients
This commit is contained in:
6 files changed
+1261
-161
No files matched your search
+4
-1
@@ -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
|
||||
api_key: '{{VOYAGEAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
|
||||
+120
-33
@@ -71,6 +71,16 @@ async fn prepare_chat_completions(
|
||||
client: &ReqwestClient,
|
||||
data: ChatCompletionsData,
|
||||
) -> Result<RequestData> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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();
|
||||
|
||||
|
||||
+385
-15
@@ -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<String>,
|
||||
pub api_base: Option<String>,
|
||||
pub auth: Option<String>,
|
||||
pub oauth: Option<Box<OAuthConfig>>,
|
||||
#[serde(default)]
|
||||
pub models: Vec<ModelData>,
|
||||
pub patch: Option<RequestPatch>,
|
||||
@@ -85,9 +86,17 @@ async fn prepare_chat_completions(
|
||||
client: &ReqwestClient,
|
||||
data: ChatCompletionsData,
|
||||
) -> Result<RequestData> {
|
||||
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<RequestData> {
|
||||
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<String> {
|
||||
resolve_api_base_against(self_, &ALL_PROVIDER_MODELS)
|
||||
}
|
||||
|
||||
fn resolve_api_base_against(
|
||||
self_: &GeminiClient,
|
||||
all_provider_models: &[ProviderModels],
|
||||
) -> Result<String> {
|
||||
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<dyn OAuthProvider>, 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<EmbeddingsOutput> {
|
||||
let res = builder.send().await?;
|
||||
let status = res.status();
|
||||
@@ -219,3 +296,296 @@ struct EmbeddingsResBody {
|
||||
struct EmbeddingsResBodyEmbedding {
|
||||
values: Vec<f32>,
|
||||
}
|
||||
|
||||
#[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<OAuthConfig>) -> 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<ClientConfig>) -> 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<RequestData> {
|
||||
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<RequestData> {
|
||||
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<ProviderModels> = 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<ProviderModels> =
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
+173
-5
@@ -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<Box<dyn OAuthProvider>> {
|
||||
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<dyn OAuthProvider>,
|
||||
) -> (Box<dyn OAuthProvider>, 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<dyn OAuthProvider>, 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<dyn OAuthProvider>, 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<dyn OAuthProvider>, 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<OAuthConfig>,
|
||||
) -> 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<OAuthConfig>,
|
||||
) -> 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();
|
||||
|
||||
+558
-107
@@ -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<String>,
|
||||
pub organization_id: Option<String>,
|
||||
pub auth: Option<String>,
|
||||
pub oauth: Option<Box<OAuthConfig>>,
|
||||
#[serde(default)]
|
||||
pub models: Vec<ModelData>,
|
||||
pub patch: Option<RequestPatch>,
|
||||
@@ -46,9 +47,7 @@ impl Client for OpenAIClient {
|
||||
client: &ReqwestClient,
|
||||
data: ChatCompletionsData,
|
||||
) -> Result<ChatCompletionsOutput> {
|
||||
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<RequestData> {
|
||||
) -> 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<RequestData> {
|
||||
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<String> {
|
||||
resolve_api_base_against(self_, &ALL_PROVIDER_MODELS)
|
||||
}
|
||||
|
||||
fn resolve_api_base_against(
|
||||
self_: &OpenAIClient,
|
||||
client: &ReqwestClient,
|
||||
data: &EmbeddingsData,
|
||||
) -> Result<RequestData> {
|
||||
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<String> {
|
||||
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<dyn OAuthProvider>, 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<Value> = 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<Value> =
|
||||
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<ChatCompletionsOu
|
||||
} else {
|
||||
text.to_string()
|
||||
};
|
||||
let output = ChatCompletionsOutput { text, tool_calls, ..Default::default() };
|
||||
let output = ChatCompletionsOutput {
|
||||
text,
|
||||
tool_calls,
|
||||
..Default::default()
|
||||
};
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
@@ -699,7 +779,11 @@ pub fn openai_extract_responses(data: &Value) -> Result<ChatCompletionsOutput> {
|
||||
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<OAuthConfig>) -> 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<ClientConfig>) -> 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<RequestData> {
|
||||
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<ProviderModels> = 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user