feat: support config-driven OAuth endpoints on the openai and gemini clients

This commit is contained in:
Dark-Alex-17 committed 2026-09-16 10:17:05 -06:00
1 parent 4cc5563ba8
commit 5046aebca3
6 files changed
+1210 -110

No files matched your search

+3
View File
@@ -338,6 +338,9 @@ clients:
api_base: https://api.openai.com/v1 # Optional 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 api_key: '{{OPENAI_API_KEY}}' # You can either hard-code or inject secrets from the Coyote vault
organization_id: org-xxx # Optional 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 # For any platform compatible with OpenAI's API
- type: openai-compatible - type: openai-compatible
+120 -33
View File
@@ -71,6 +71,16 @@ async fn prepare_chat_completions(
client: &ReqwestClient, client: &ReqwestClient,
data: ChatCompletionsData, data: ChatCompletionsData,
) -> Result<RequestData> { ) -> 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 api_base = resolve_api_base(self_)?;
let url = format!("{}/messages", api_base.trim_end_matches('/')); 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"); 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 { if uses_oauth {
let client_name = self_.name(); let client_name = self_.name();
let app_config = self_.app_config(); let cc = locate_client_config(self_)?;
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 (provider, uses_stock_provider) = let (provider, uses_stock_provider) =
oauth::claude_oauth_provider_for_client(cc, &ALL_PROVIDER_MODELS); oauth::claude_oauth_provider_for_client(cc, &ALL_PROVIDER_MODELS);
let ready = oauth::prepare_oauth_access_token(client, &*provider, client_name).await?; let ready = oauth::prepare_oauth_access_token(client, &*provider, client_name).await?;
@@ -139,19 +128,48 @@ async fn prepare_chat_completions(
Ok(request_data) 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> { fn resolve_api_base(self_: &ClaudeClient) -> Result<String> {
match self_.get_api_base() { resolve_api_base_against(self_, &ALL_PROVIDER_MODELS)
Ok(api_base) => Ok(api_base), }
Err(_) if self_.config.oauth.is_none() => Ok(API_BASE.to_string()),
Err(_) => bail!( fn resolve_api_base_against(
"'{}' 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.", 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(), self_.name(),
API_BASE, API_BASE,
self_.name() 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 /// Anthropic requires OAuth-authenticated requests to include a Claude Code
@@ -951,7 +969,9 @@ mod tests {
ClaudeConfig { ClaudeConfig {
name: Some(name.into()), name: Some(name.into()),
api_key: None, 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), auth: auth.map(str::to_string),
oauth: oauth.map(Box::new), oauth: oauth.map(Box::new),
models: vec![], models: vec![],
@@ -1031,7 +1051,12 @@ mod tests {
let name = "claude-gate-gateway-test"; let name = "claude-gate-gateway-test";
let config = claude_config(name, Some("oauth"), Some(minimal_oauth_config())); let config = claude_config(name, Some("oauth"), Some(minimal_oauth_config()));
let client = make_client(config.clone(), vec![ClientConfig::ClaudeConfig(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(); 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] #[test]
fn oauth_block_with_api_base_resolves_it() { fn oauth_block_with_api_base_resolves_it() {
let name = "claude-gate-apibase-set-test"; let name = "claude-gate-apibase-set-test";
@@ -1145,7 +1227,12 @@ mod tests {
ClientConfig::ClaudeConfig(config), 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(); let request_data = prepare(&client).unwrap();
+385 -15
View File
@@ -1,10 +1,10 @@
use super::access_token::get_access_token; use super::access_token::get_access_token;
use super::gemini_oauth::GeminiOAuthProvider; use super::gemini_oauth::GeminiOAuthProvider;
use super::oauth; use super::oauth::{self, OAuthConfig, OAuthProvider};
use super::vertexai::*; use super::vertexai::*;
use super::*; use super::*;
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, anyhow, bail};
use reqwest::{Client as ReqwestClient, RequestBuilder}; use reqwest::{Client as ReqwestClient, RequestBuilder};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -17,6 +17,7 @@ pub struct GeminiConfig {
pub api_key: Option<String>, pub api_key: Option<String>,
pub api_base: Option<String>, pub api_base: Option<String>,
pub auth: 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>,
@@ -85,9 +86,17 @@ async fn prepare_chat_completions(
client: &ReqwestClient, client: &ReqwestClient,
data: ChatCompletionsData, data: ChatCompletionsData,
) -> Result<RequestData> { ) -> Result<RequestData> {
let api_base = self_ let uses_oauth = self_.config.auth.as_deref() == Some("oauth");
.get_api_base()
.unwrap_or_else(|_| API_BASE.to_string()); 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 { let func = match data.stream {
true => "streamGenerateContent", true => "streamGenerateContent",
@@ -104,11 +113,9 @@ async fn prepare_chat_completions(
let body = gemini_build_chat_completions_body(data, &self_.model)?; let body = gemini_build_chat_completions_body(data, &self_.model)?;
let mut request_data = RequestData::new(url, body); let mut request_data = RequestData::new(url, body);
let uses_oauth = self_.config.auth.as_deref() == Some("oauth");
if uses_oauth { if uses_oauth {
let provider = GeminiOAuthProvider; let (provider, _) = resolve_oauth_provider(self_)?;
let ready = oauth::prepare_oauth_access_token(client, &provider, self_.name()).await?; let ready = oauth::prepare_oauth_access_token(client, &*provider, self_.name()).await?;
if !ready { if !ready {
bail!( bail!(
"OAuth configured but no tokens found for '{}'. Run: 'coyote --authenticate {}' or '.authenticate' in the REPL", "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())?; let token = get_access_token(self_.name())?;
request_data.bearer_auth(token); 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() { } else if let Ok(api_key) = self_.get_api_key() {
request_data.header("x-goog-api-key", api_key); request_data.header("x-goog-api-key", api_key);
} else { } else {
@@ -136,12 +146,18 @@ async fn prepare_embeddings(
client: &ReqwestClient, client: &ReqwestClient,
data: &EmbeddingsData, data: &EmbeddingsData,
) -> Result<RequestData> { ) -> 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"); 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 { let url = if uses_oauth {
format!( format!(
"{}/models/{}:batchEmbedContents", "{}/models/{}:batchEmbedContents",
@@ -177,8 +193,8 @@ async fn prepare_embeddings(
let mut request_data = RequestData::new(url, body); let mut request_data = RequestData::new(url, body);
if uses_oauth { if uses_oauth {
let provider = GeminiOAuthProvider; let (provider, _) = resolve_oauth_provider(self_)?;
let ready = oauth::prepare_oauth_access_token(client, &provider, self_.name()).await?; let ready = oauth::prepare_oauth_access_token(client, &*provider, self_.name()).await?;
if !ready { if !ready {
bail!( bail!(
"OAuth configured but no tokens found for '{}'. Run: 'coyote --authenticate {}' or '.authenticate' in the REPL", "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())?; let token = get_access_token(self_.name())?;
request_data.bearer_auth(token); request_data.bearer_auth(token);
for (key, value) in provider.extra_request_headers() {
request_data.header(key, value);
}
} }
Ok(request_data) 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> { async fn embeddings(builder: RequestBuilder, _model: &Model) -> Result<EmbeddingsOutput> {
let res = builder.send().await?; let res = builder.send().await?;
let status = res.status(); let status = res.status();
@@ -219,3 +296,296 @@ struct EmbeddingsResBody {
struct EmbeddingsResBodyEmbedding { struct EmbeddingsResBodyEmbedding {
values: Vec<f32>, 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"
);
}
}
+21
View File
@@ -536,4 +536,25 @@ reasoning_levels: [none, low, medium, high]
assert_eq!(data.default_reasoning_effort.as_deref(), Some("none")); 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
View File
@@ -1,5 +1,8 @@
use super::access_token::{clear_rejected, is_rejected, is_valid_access_token, set_access_token}; 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_compatible_oauth::OpenAICompatibleOAuthProvider;
use super::openai_oauth::OpenAIOAuthProvider;
use super::{ClientConfig, ProviderModels}; use super::{ClientConfig, ProviderModels};
use crate::config::paths; use crate::config::paths;
use anyhow::{Context, Error, Result, anyhow, bail}; 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>> { pub fn get_oauth_provider(provider_type: &str) -> Option<Box<dyn OAuthProvider>> {
match provider_type { match provider_type {
"claude" => Some(Box::new(super::claude_oauth::ClaudeOAuthProvider)), "claude" => Some(Box::new(ClaudeOAuthProvider)),
"gemini" => Some(Box::new(super::gemini_oauth::GeminiOAuthProvider)), "gemini" => Some(Box::new(GeminiOAuthProvider)),
"openai" => Some(Box::new(super::openai_oauth::OpenAIOAuthProvider)), "openai" => Some(Box::new(OpenAIOAuthProvider)),
_ => None, _ => None,
} }
} }
@@ -1071,6 +1074,8 @@ pub(crate) fn config_oauth_for_client(
let user_oauth = match client_config { let user_oauth = match client_config {
ClientConfig::OpenAICompatibleConfig(c) => c.oauth.clone().map(|b| *b), ClientConfig::OpenAICompatibleConfig(c) => c.oauth.clone().map(|b| *b),
ClientConfig::ClaudeConfig(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, _ => return None,
}; };
let base = all_provider_models 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, client_config: &ClientConfig,
all_provider_models: &[ProviderModels], all_provider_models: &[ProviderModels],
stock: fn() -> Box<dyn OAuthProvider>,
) -> (Box<dyn OAuthProvider>, bool) { ) -> (Box<dyn OAuthProvider>, bool) {
let (client_name, _, _) = client_config_info(client_config); let (client_name, _, _) = client_config_info(client_config);
match config_oauth_for_client(client_config, all_provider_models) { match config_oauth_for_client(client_config, all_provider_models) {
@@ -1099,10 +1107,37 @@ pub(crate) fn claude_oauth_provider_for_client(
}), }),
false, 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( pub fn get_oauth_provider_for_client(
client_config: &ClientConfig, client_config: &ClientConfig,
all_provider_models: &[ProviderModels], 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); claude_oauth_provider_for_client(client_config, all_provider_models);
Some(provider) 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), _ => get_oauth_provider(provider_type),
} }
} }
@@ -1192,6 +1237,8 @@ mod tests {
use super::*; use super::*;
use crate::client::access_token::{distrust_access_token, get_access_token}; use crate::client::access_token::{distrust_access_token, get_access_token};
use crate::client::claude::ClaudeConfig; use crate::client::claude::ClaudeConfig;
use crate::client::gemini::GeminiConfig;
use crate::client::openai::OpenAIConfig;
use crate::client::openai_compatible::OpenAICompatibleConfig; use crate::client::openai_compatible::OpenAICompatibleConfig;
use crate::client::{ModelData, ProviderModels}; use crate::client::{ModelData, ProviderModels};
use crate::utils::get_env_name; 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] #[test]
fn get_oauth_provider_for_client_merges_defaults_with_user_override() { fn get_oauth_provider_for_client_merges_defaults_with_user_override() {
let base = base_config(); let base = base_config();
@@ -1580,6 +1662,92 @@ echo_pkce_in_token_exchange: true
assert!(!provider.requires_issuer_stamp()); 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] #[test]
fn openai_compatible_provider_joins_scopes_with_spaces() { fn openai_compatible_provider_joins_scopes_with_spaces() {
let mut cfg = base_config(); let mut cfg = base_config();
+508 -57
View File
@@ -1,11 +1,11 @@
use super::access_token::{get_access_token, get_access_token_account_id}; 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::openai_oauth::OpenAIOAuthProvider;
use super::*; use super::*;
use crate::utils::strip_think_tag; use crate::utils::strip_think_tag;
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, anyhow, bail};
use reqwest::{Client as ReqwestClient, RequestBuilder}; use reqwest::{Client as ReqwestClient, RequestBuilder};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -20,6 +20,7 @@ pub struct OpenAIConfig {
pub api_base: Option<String>, pub api_base: Option<String>,
pub organization_id: Option<String>, pub organization_id: Option<String>,
pub auth: 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>,
@@ -46,9 +47,7 @@ impl Client for OpenAIClient {
client: &ReqwestClient, client: &ReqwestClient,
data: ChatCompletionsData, data: ChatCompletionsData,
) -> Result<ChatCompletionsOutput> { ) -> Result<ChatCompletionsOutput> {
let uses_codex = let (request_data, uses_codex) = prepare_chat_completions(self, client, data).await?;
self.config.auth.as_deref() == Some("oauth") && self.get_api_base().is_err();
let request_data = prepare_chat_completions(self, client, data).await?;
let builder = self.request_builder(client, request_data); let builder = self.request_builder(client, request_data);
if uses_codex { if uses_codex {
openai_responses_chat_completions(builder, self.model()).await openai_responses_chat_completions(builder, self.model()).await
@@ -63,9 +62,7 @@ impl Client for OpenAIClient {
handler: &mut SseHandler, handler: &mut SseHandler,
data: ChatCompletionsData, data: ChatCompletionsData,
) -> Result<()> { ) -> Result<()> {
let uses_codex = let (request_data, uses_codex) = prepare_chat_completions(self, client, data).await?;
self.config.auth.as_deref() == Some("oauth") && self.get_api_base().is_err();
let request_data = prepare_chat_completions(self, client, data).await?;
let builder = self.request_builder(client, request_data); let builder = self.request_builder(client, request_data);
if uses_codex { if uses_codex {
@@ -100,18 +97,30 @@ async fn prepare_chat_completions(
self_: &OpenAIClient, self_: &OpenAIClient,
client: &ReqwestClient, client: &ReqwestClient,
data: ChatCompletionsData, data: ChatCompletionsData,
) -> Result<RequestData> { ) -> Result<(RequestData, bool)> {
let uses_oauth = self_.config.auth.as_deref() == Some("oauth"); 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 { let url = if uses_codex {
CODEX_API_ENDPOINT.to_string() CODEX_API_ENDPOINT.to_string()
} else { } else {
let api_base = self_ let api_base = resolve_api_base(self_)?;
.get_api_base()
.unwrap_or_else(|_| API_BASE.to_string());
format!("{}/chat/completions", api_base.trim_end_matches('/')) 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); let mut request_data = RequestData::new(url, body);
if uses_oauth { if let Some((provider, _)) = oauth_provider {
let provider = OpenAIOAuthProvider; let ready = oauth::prepare_oauth_access_token(client, &*provider, self_.name()).await?;
let ready = oauth::prepare_oauth_access_token(client, &provider, self_.name()).await?;
if !ready { if !ready {
bail!( bail!(
@@ -138,7 +146,7 @@ async fn prepare_chat_completions(
let token = get_access_token(self_.name())?; let token = get_access_token(self_.name())?;
request_data.bearer_auth(token); 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); request_data.header("ChatGPT-Account-Id", account_id);
} }
@@ -159,53 +167,120 @@ async fn prepare_chat_completions(
request_data.header("OpenAI-Organization", organization_id); 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) 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, self_: &OpenAIClient,
client: &ReqwestClient, all_provider_models: &[ProviderModels],
data: &EmbeddingsData, ) -> Result<String> {
) -> Result<RequestData> { if let Ok(api_base) = self_.get_api_base() {
let api_base = self_ return Ok(api_base);
.get_api_base() }
.unwrap_or_else(|_| API_BASE.to_string()); let uses_config_oauth = self_.config.auth.as_deref() == Some("oauth")
&& match locate_client_config(self_) {
let url = format!("{api_base}/embeddings"); 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.
let body = openai_build_embeddings_body(data, &self_.model); Err(_) => self_.config.oauth.is_some(),
};
let mut request_data = RequestData::new(url, body); if uses_config_oauth {
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!( bail!(
"OAuth configured but no tokens found for '{}'. Run: 'coyote --authenticate {}' or '.authenticate' in the REPL", "'{}' 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(), self_.name(),
API_BASE,
self_.name() self_.name()
); );
} }
Ok(API_BASE.to_string())
}
let token = get_access_token(self_.name())?; fn locate_client_config(self_: &OpenAIClient) -> Result<&ClientConfig> {
request_data.bearer_auth(token); let client_name = self_.name();
} else if let Ok(api_key) = self_.get_api_key() { self_
request_data.bearer_auth(api_key); .app_config()
} else { .clients
bail!( .iter()
"No authentication configured for '{}'. Set `api_key` or use `auth: oauth` with `coyote --authenticate {}`.", .find(|cc| {
self_.name(), matches!(
self_.name() 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),
} }
if let Some(organization_id) = &self_.config.organization_id {
request_data.header("OpenAI-Organization", organization_id);
}
Ok(request_data)
} }
pub async fn openai_chat_completions( pub async fn openai_chat_completions(
@@ -362,7 +437,8 @@ pub fn openai_build_chat_completions_body(data: ChatCompletionsData, model: &Mod
} = data; } = data;
let messages_len = messages.len(); let messages_len = messages.len();
let messages: Vec<Value> = messages let messages: Vec<Value> =
messages
.into_iter() .into_iter()
.enumerate() .enumerate()
.flat_map(|(i, message)| { .flat_map(|(i, message)| {
@@ -536,7 +612,11 @@ pub fn openai_extract_chat_completions(data: &Value) -> Result<ChatCompletionsOu
} else { } else {
text.to_string() text.to_string()
}; };
let output = ChatCompletionsOutput { text, tool_calls, ..Default::default() }; let output = ChatCompletionsOutput {
text,
tool_calls,
..Default::default()
};
Ok(output) Ok(output)
} }
@@ -699,7 +779,11 @@ pub fn openai_extract_responses(data: &Value) -> Result<ChatCompletionsOutput> {
if text.is_empty() && tool_calls.is_empty() { if text.is_empty() && tool_calls.is_empty() {
bail!("Invalid response data: {data}"); 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( pub async fn openai_responses_streaming(
@@ -752,6 +836,10 @@ pub async fn openai_responses_streaming(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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 { fn build_body(sequence: bool) -> Value {
let data = ChatCompletionsData { let data = ChatCompletionsData {
@@ -792,4 +880,367 @@ mod tests {
assert_eq!(messages.len(), 1, "body: {body}"); 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"
);
}
} }