mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-04-25 19:32:39 +08:00
785 lines
27 KiB
Rust
785 lines
27 KiB
Rust
//! Claude (Anthropic) Provider Adapter
|
||
//!
|
||
//! 支持透传模式和 OpenAI 格式转换模式
|
||
//!
|
||
//! ## API 格式
|
||
//! - **anthropic** (默认): Anthropic Messages API 格式,直接透传
|
||
//! - **openai_chat**: OpenAI Chat Completions 格式,需要 Anthropic ↔ OpenAI 转换
|
||
//! - **openai_responses**: OpenAI Responses API 格式,需要 Anthropic ↔ Responses 转换
|
||
//!
|
||
//! ## 认证模式
|
||
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
|
||
//! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key)
|
||
//! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传
|
||
//! - **GitHubCopilot**: GitHub Copilot (OAuth + Copilot Token)
|
||
|
||
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
|
||
use crate::provider::Provider;
|
||
use crate::proxy::error::ProxyError;
|
||
use reqwest::RequestBuilder;
|
||
|
||
/// 获取 Claude 供应商的 API 格式
|
||
///
|
||
/// 供 handler/forwarder 外部使用的公开函数。
|
||
/// 优先级:meta.apiFormat > settings_config.api_format > openrouter_compat_mode > 默认 "anthropic"
|
||
pub fn get_claude_api_format(provider: &Provider) -> &'static str {
|
||
// 1) Preferred: meta.apiFormat (SSOT, never written to Claude Code config)
|
||
if let Some(meta) = provider.meta.as_ref() {
|
||
if let Some(api_format) = meta.api_format.as_deref() {
|
||
return match api_format {
|
||
"openai_chat" => "openai_chat",
|
||
"openai_responses" => "openai_responses",
|
||
_ => "anthropic",
|
||
};
|
||
}
|
||
}
|
||
|
||
// 2) Backward compatibility: legacy settings_config.api_format
|
||
if let Some(api_format) = provider
|
||
.settings_config
|
||
.get("api_format")
|
||
.and_then(|v| v.as_str())
|
||
{
|
||
return match api_format {
|
||
"openai_chat" => "openai_chat",
|
||
"openai_responses" => "openai_responses",
|
||
_ => "anthropic",
|
||
};
|
||
}
|
||
|
||
// 3) Backward compatibility: legacy openrouter_compat_mode (bool/number/string)
|
||
let raw = provider.settings_config.get("openrouter_compat_mode");
|
||
let enabled = match raw {
|
||
Some(serde_json::Value::Bool(v)) => *v,
|
||
Some(serde_json::Value::Number(num)) => num.as_i64().unwrap_or(0) != 0,
|
||
Some(serde_json::Value::String(value)) => {
|
||
let normalized = value.trim().to_lowercase();
|
||
normalized == "true" || normalized == "1"
|
||
}
|
||
_ => false,
|
||
};
|
||
|
||
if enabled {
|
||
"openai_chat"
|
||
} else {
|
||
"anthropic"
|
||
}
|
||
}
|
||
|
||
/// Claude 适配器
|
||
pub struct ClaudeAdapter;
|
||
|
||
impl ClaudeAdapter {
|
||
pub fn new() -> Self {
|
||
Self
|
||
}
|
||
|
||
/// 获取供应商类型
|
||
///
|
||
/// 根据 base_url 和 auth_mode 检测具体的供应商类型:
|
||
/// - GitHubCopilot: meta.provider_type 为 github_copilot 或 base_url 包含 githubcopilot.com
|
||
/// - OpenRouter: base_url 包含 openrouter.ai
|
||
/// - ClaudeAuth: auth_mode 为 bearer_only
|
||
/// - Claude: 默认 Anthropic 官方
|
||
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
|
||
// 检测 GitHub Copilot
|
||
if self.is_github_copilot(provider) {
|
||
return ProviderType::GitHubCopilot;
|
||
}
|
||
|
||
// 检测 OpenRouter
|
||
if self.is_openrouter(provider) {
|
||
return ProviderType::OpenRouter;
|
||
}
|
||
|
||
// 检测 ClaudeAuth (仅 Bearer 认证)
|
||
if self.is_bearer_only_mode(provider) {
|
||
return ProviderType::ClaudeAuth;
|
||
}
|
||
|
||
ProviderType::Claude
|
||
}
|
||
|
||
/// 检测是否为 GitHub Copilot 供应商
|
||
fn is_github_copilot(&self, provider: &Provider) -> bool {
|
||
// 方式1: 检查 meta.provider_type
|
||
if let Some(meta) = provider.meta.as_ref() {
|
||
if meta.provider_type.as_deref() == Some("github_copilot") {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// 方式2: 检查 base_url(兼容旧数据的 fallback,后续应优先依赖 providerType)
|
||
if let Ok(base_url) = self.extract_base_url(provider) {
|
||
if base_url.contains("githubcopilot.com") {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
false
|
||
}
|
||
|
||
/// 检测是否使用 OpenRouter
|
||
fn is_openrouter(&self, provider: &Provider) -> bool {
|
||
if let Ok(base_url) = self.extract_base_url(provider) {
|
||
return base_url.contains("openrouter.ai");
|
||
}
|
||
false
|
||
}
|
||
|
||
/// 获取 API 格式
|
||
///
|
||
/// 从 provider.meta.api_format 读取格式设置:
|
||
/// - "anthropic" (默认): Anthropic Messages API 格式,直接透传
|
||
/// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
||
/// - "openai_responses": OpenAI Responses API 格式,需要格式转换
|
||
fn get_api_format(&self, provider: &Provider) -> &'static str {
|
||
get_claude_api_format(provider)
|
||
}
|
||
|
||
/// 检测是否为仅 Bearer 认证模式
|
||
fn is_bearer_only_mode(&self, provider: &Provider) -> bool {
|
||
// 检查 settings_config 中的 auth_mode
|
||
if let Some(auth_mode) = provider
|
||
.settings_config
|
||
.get("auth_mode")
|
||
.and_then(|v| v.as_str())
|
||
{
|
||
if auth_mode == "bearer_only" {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// 检查 env 中的 AUTH_MODE
|
||
if let Some(env) = provider.settings_config.get("env") {
|
||
if let Some(auth_mode) = env.get("AUTH_MODE").and_then(|v| v.as_str()) {
|
||
if auth_mode == "bearer_only" {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
|
||
false
|
||
}
|
||
|
||
/// 从 Provider 配置中提取 API Key
|
||
fn extract_key(&self, provider: &Provider) -> Option<String> {
|
||
if let Some(env) = provider.settings_config.get("env") {
|
||
// Anthropic 标准 key
|
||
if let Some(key) = env
|
||
.get("ANTHROPIC_AUTH_TOKEN")
|
||
.and_then(|v| v.as_str())
|
||
.filter(|s| !s.is_empty())
|
||
{
|
||
log::debug!("[Claude] 使用 ANTHROPIC_AUTH_TOKEN");
|
||
return Some(key.to_string());
|
||
}
|
||
if let Some(key) = env
|
||
.get("ANTHROPIC_API_KEY")
|
||
.and_then(|v| v.as_str())
|
||
.filter(|s| !s.is_empty())
|
||
{
|
||
log::debug!("[Claude] 使用 ANTHROPIC_API_KEY");
|
||
return Some(key.to_string());
|
||
}
|
||
// OpenRouter key
|
||
if let Some(key) = env
|
||
.get("OPENROUTER_API_KEY")
|
||
.and_then(|v| v.as_str())
|
||
.filter(|s| !s.is_empty())
|
||
{
|
||
log::debug!("[Claude] 使用 OPENROUTER_API_KEY");
|
||
return Some(key.to_string());
|
||
}
|
||
// 备选 OpenAI key (用于 OpenRouter)
|
||
if let Some(key) = env
|
||
.get("OPENAI_API_KEY")
|
||
.and_then(|v| v.as_str())
|
||
.filter(|s| !s.is_empty())
|
||
{
|
||
log::debug!("[Claude] 使用 OPENAI_API_KEY");
|
||
return Some(key.to_string());
|
||
}
|
||
}
|
||
|
||
// 尝试直接获取
|
||
if let Some(key) = provider
|
||
.settings_config
|
||
.get("apiKey")
|
||
.or_else(|| provider.settings_config.get("api_key"))
|
||
.and_then(|v| v.as_str())
|
||
.filter(|s| !s.is_empty())
|
||
{
|
||
log::debug!("[Claude] 使用 apiKey/api_key");
|
||
return Some(key.to_string());
|
||
}
|
||
|
||
log::warn!("[Claude] 未找到有效的 API Key");
|
||
None
|
||
}
|
||
}
|
||
|
||
impl Default for ClaudeAdapter {
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|
||
|
||
impl ProviderAdapter for ClaudeAdapter {
|
||
fn name(&self) -> &'static str {
|
||
"Claude"
|
||
}
|
||
|
||
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
|
||
// 1. 从 env 中获取
|
||
if let Some(env) = provider.settings_config.get("env") {
|
||
if let Some(url) = env.get("ANTHROPIC_BASE_URL").and_then(|v| v.as_str()) {
|
||
return Ok(url.trim_end_matches('/').to_string());
|
||
}
|
||
}
|
||
|
||
// 2. 尝试直接获取
|
||
if let Some(url) = provider
|
||
.settings_config
|
||
.get("base_url")
|
||
.and_then(|v| v.as_str())
|
||
{
|
||
return Ok(url.trim_end_matches('/').to_string());
|
||
}
|
||
|
||
if let Some(url) = provider
|
||
.settings_config
|
||
.get("baseURL")
|
||
.and_then(|v| v.as_str())
|
||
{
|
||
return Ok(url.trim_end_matches('/').to_string());
|
||
}
|
||
|
||
if let Some(url) = provider
|
||
.settings_config
|
||
.get("apiEndpoint")
|
||
.and_then(|v| v.as_str())
|
||
{
|
||
return Ok(url.trim_end_matches('/').to_string());
|
||
}
|
||
|
||
Err(ProxyError::ConfigError(
|
||
"Claude Provider 缺少 base_url 配置".to_string(),
|
||
))
|
||
}
|
||
|
||
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
|
||
let provider_type = self.provider_type(provider);
|
||
|
||
// GitHub Copilot 使用特殊的认证策略
|
||
// 实际的 token 会在代理请求时动态获取
|
||
if provider_type == ProviderType::GitHubCopilot {
|
||
// 返回一个占位符,实际 token 由 CopilotAuthManager 动态提供
|
||
return Some(AuthInfo::new(
|
||
"copilot_placeholder".to_string(),
|
||
AuthStrategy::GitHubCopilot,
|
||
));
|
||
}
|
||
|
||
let strategy = match provider_type {
|
||
ProviderType::OpenRouter => AuthStrategy::Bearer,
|
||
ProviderType::ClaudeAuth => AuthStrategy::ClaudeAuth,
|
||
_ => AuthStrategy::Anthropic,
|
||
};
|
||
|
||
self.extract_key(provider)
|
||
.map(|key| AuthInfo::new(key, strategy))
|
||
}
|
||
|
||
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||
// NOTE:
|
||
// 过去 OpenRouter 只有 OpenAI Chat Completions 兼容接口,需要把 Claude 的 `/v1/messages`
|
||
// 映射到 `/v1/chat/completions`,并做 Anthropic ↔ OpenAI 的格式转换。
|
||
//
|
||
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
|
||
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
|
||
//
|
||
let mut base = format!(
|
||
"{}/{}",
|
||
base_url.trim_end_matches('/'),
|
||
endpoint.trim_start_matches('/')
|
||
);
|
||
|
||
// 去除重复的 /v1/v1(可能由 base_url 与 endpoint 都带版本导致)
|
||
while base.contains("/v1/v1") {
|
||
base = base.replace("/v1/v1", "/v1");
|
||
}
|
||
|
||
base
|
||
}
|
||
|
||
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||
// 注意:anthropic-version 由 forwarder.rs 统一处理(透传客户端值或设置默认值)
|
||
// 这里不再设置 anthropic-version,避免 header 重复
|
||
match auth.strategy {
|
||
// Anthropic 官方: Authorization Bearer + x-api-key
|
||
AuthStrategy::Anthropic => request
|
||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||
.header("x-api-key", &auth.api_key),
|
||
// ClaudeAuth 中转服务: 仅 Bearer,无 x-api-key
|
||
AuthStrategy::ClaudeAuth => {
|
||
request.header("Authorization", format!("Bearer {}", auth.api_key))
|
||
}
|
||
// OpenRouter: Bearer
|
||
AuthStrategy::Bearer => {
|
||
request.header("Authorization", format!("Bearer {}", auth.api_key))
|
||
}
|
||
// GitHub Copilot: Bearer + 统一指纹头
|
||
AuthStrategy::GitHubCopilot => request
|
||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||
.header("editor-version", super::copilot_auth::COPILOT_EDITOR_VERSION)
|
||
.header("editor-plugin-version", super::copilot_auth::COPILOT_PLUGIN_VERSION)
|
||
.header("copilot-integration-id", super::copilot_auth::COPILOT_INTEGRATION_ID)
|
||
.header("user-agent", super::copilot_auth::COPILOT_USER_AGENT)
|
||
.header("x-github-api-version", super::copilot_auth::COPILOT_API_VERSION)
|
||
.header("openai-intent", "conversation-panel"),
|
||
_ => request,
|
||
}
|
||
}
|
||
|
||
fn needs_transform(&self, provider: &Provider) -> bool {
|
||
// GitHub Copilot 总是需要格式转换 (Anthropic → OpenAI)
|
||
if self.is_github_copilot(provider) {
|
||
return true;
|
||
}
|
||
|
||
// 根据 api_format 配置决定是否需要格式转换
|
||
// - "anthropic" (默认): 直接透传,无需转换
|
||
// - "openai_chat": 需要 Anthropic ↔ OpenAI Chat Completions 格式转换
|
||
// - "openai_responses": 需要 Anthropic ↔ OpenAI Responses API 格式转换
|
||
matches!(
|
||
self.get_api_format(provider),
|
||
"openai_chat" | "openai_responses"
|
||
)
|
||
}
|
||
|
||
fn transform_request(
|
||
&self,
|
||
body: serde_json::Value,
|
||
provider: &Provider,
|
||
) -> Result<serde_json::Value, ProxyError> {
|
||
// Use meta.prompt_cache_key if set by user, otherwise fall back to provider.id
|
||
let cache_key = provider
|
||
.meta
|
||
.as_ref()
|
||
.and_then(|m| m.prompt_cache_key.as_deref())
|
||
.unwrap_or(&provider.id);
|
||
|
||
match self.get_api_format(provider) {
|
||
"openai_responses" => {
|
||
super::transform_responses::anthropic_to_responses(body, Some(cache_key))
|
||
}
|
||
_ => super::transform::anthropic_to_openai(body, Some(cache_key)),
|
||
}
|
||
}
|
||
|
||
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
|
||
// Heuristic: detect response format by presence of top-level fields.
|
||
// The ProviderAdapter trait's transform_response doesn't receive the Provider
|
||
// config, so we can't check api_format here. Instead we rely on the fact that
|
||
// Responses API always returns "output" while Chat Completions returns "choices".
|
||
// This is safe because the two formats are structurally disjoint.
|
||
if body.get("output").is_some() {
|
||
super::transform_responses::responses_to_anthropic(body)
|
||
} else {
|
||
super::transform::openai_to_anthropic(body)
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::provider::ProviderMeta;
|
||
use serde_json::json;
|
||
|
||
fn create_provider(config: serde_json::Value) -> Provider {
|
||
Provider {
|
||
id: "test".to_string(),
|
||
name: "Test Claude".to_string(),
|
||
settings_config: config,
|
||
website_url: None,
|
||
category: Some("claude".to_string()),
|
||
created_at: None,
|
||
sort_index: None,
|
||
notes: None,
|
||
meta: None,
|
||
icon: None,
|
||
icon_color: None,
|
||
in_failover_queue: false,
|
||
}
|
||
}
|
||
|
||
fn create_provider_with_meta(config: serde_json::Value, meta: ProviderMeta) -> Provider {
|
||
Provider {
|
||
id: "test".to_string(),
|
||
name: "Test Claude".to_string(),
|
||
settings_config: config,
|
||
website_url: None,
|
||
category: Some("claude".to_string()),
|
||
created_at: None,
|
||
sort_index: None,
|
||
notes: None,
|
||
meta: Some(meta),
|
||
icon: None,
|
||
icon_color: None,
|
||
in_failover_queue: false,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_base_url_from_env() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
||
}
|
||
}));
|
||
|
||
let url = adapter.extract_base_url(&provider).unwrap();
|
||
assert_eq!(url, "https://api.anthropic.com");
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_auth_anthropic() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
||
"ANTHROPIC_AUTH_TOKEN": "sk-ant-test-key"
|
||
}
|
||
}));
|
||
|
||
let auth = adapter.extract_auth(&provider).unwrap();
|
||
assert_eq!(auth.api_key, "sk-ant-test-key");
|
||
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_auth_anthropic_api_key() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
||
"ANTHROPIC_API_KEY": "sk-ant-test-key"
|
||
}
|
||
}));
|
||
|
||
let auth = adapter.extract_auth(&provider).unwrap();
|
||
assert_eq!(auth.api_key, "sk-ant-test-key");
|
||
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_auth_openrouter() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
|
||
"OPENROUTER_API_KEY": "sk-or-test-key"
|
||
}
|
||
}));
|
||
|
||
let auth = adapter.extract_auth(&provider).unwrap();
|
||
assert_eq!(auth.api_key, "sk-or-test-key");
|
||
assert_eq!(auth.strategy, AuthStrategy::Bearer);
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_auth_claude_auth_mode() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
|
||
"ANTHROPIC_AUTH_TOKEN": "sk-proxy-key"
|
||
},
|
||
"auth_mode": "bearer_only"
|
||
}));
|
||
|
||
let auth = adapter.extract_auth(&provider).unwrap();
|
||
assert_eq!(auth.api_key, "sk-proxy-key");
|
||
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
|
||
}
|
||
|
||
#[test]
|
||
fn test_extract_auth_claude_auth_env_mode() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let provider = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
|
||
"ANTHROPIC_AUTH_TOKEN": "sk-proxy-key",
|
||
"AUTH_MODE": "bearer_only"
|
||
}
|
||
}));
|
||
|
||
let auth = adapter.extract_auth(&provider).unwrap();
|
||
assert_eq!(auth.api_key, "sk-proxy-key");
|
||
assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth);
|
||
}
|
||
|
||
#[test]
|
||
fn test_provider_type_detection() {
|
||
let adapter = ClaudeAdapter::new();
|
||
|
||
// Anthropic 官方
|
||
let anthropic = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
||
"ANTHROPIC_AUTH_TOKEN": "sk-ant-test"
|
||
}
|
||
}));
|
||
assert_eq!(adapter.provider_type(&anthropic), ProviderType::Claude);
|
||
|
||
// OpenRouter
|
||
let openrouter = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
|
||
"OPENROUTER_API_KEY": "sk-or-test"
|
||
}
|
||
}));
|
||
assert_eq!(adapter.provider_type(&openrouter), ProviderType::OpenRouter);
|
||
|
||
// ClaudeAuth
|
||
let claude_auth = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
|
||
"ANTHROPIC_AUTH_TOKEN": "sk-test"
|
||
},
|
||
"auth_mode": "bearer_only"
|
||
}));
|
||
assert_eq!(
|
||
adapter.provider_type(&claude_auth),
|
||
ProviderType::ClaudeAuth
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_url_anthropic() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages");
|
||
assert_eq!(url, "https://api.anthropic.com/v1/messages");
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_url_openrouter() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages");
|
||
assert_eq!(url, "https://openrouter.ai/api/v1/messages");
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_url_no_beta_for_other_endpoints() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let url = adapter.build_url("https://api.anthropic.com", "/v1/complete");
|
||
assert_eq!(url, "https://api.anthropic.com/v1/complete");
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_url_preserve_existing_query() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages?foo=bar");
|
||
assert_eq!(url, "https://api.anthropic.com/v1/messages?foo=bar");
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_url_no_beta_for_github_copilot() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let url = adapter.build_url("https://api.githubcopilot.com", "/v1/messages");
|
||
assert_eq!(url, "https://api.githubcopilot.com/v1/messages");
|
||
}
|
||
|
||
#[test]
|
||
fn test_build_url_no_beta_for_openai_chat_completions() {
|
||
let adapter = ClaudeAdapter::new();
|
||
let url = adapter.build_url("https://integrate.api.nvidia.com", "/v1/chat/completions");
|
||
assert_eq!(url, "https://integrate.api.nvidia.com/v1/chat/completions");
|
||
}
|
||
|
||
#[test]
|
||
fn test_needs_transform() {
|
||
let adapter = ClaudeAdapter::new();
|
||
|
||
// Default: no transform (anthropic format) - no meta
|
||
let anthropic_provider = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
||
}
|
||
}));
|
||
assert!(!adapter.needs_transform(&anthropic_provider));
|
||
|
||
// Explicit anthropic format in meta: no transform
|
||
let explicit_anthropic = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
api_format: Some("anthropic".to_string()),
|
||
..Default::default()
|
||
},
|
||
);
|
||
assert!(!adapter.needs_transform(&explicit_anthropic));
|
||
|
||
// Legacy settings_config.api_format: openai_chat should enable transform
|
||
let legacy_settings_api_format = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||
},
|
||
"api_format": "openai_chat"
|
||
}));
|
||
assert!(adapter.needs_transform(&legacy_settings_api_format));
|
||
|
||
// Legacy openrouter_compat_mode: bool/number/string should enable transform
|
||
let legacy_openrouter_bool = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||
},
|
||
"openrouter_compat_mode": true
|
||
}));
|
||
assert!(adapter.needs_transform(&legacy_openrouter_bool));
|
||
|
||
let legacy_openrouter_num = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||
},
|
||
"openrouter_compat_mode": 1
|
||
}));
|
||
assert!(adapter.needs_transform(&legacy_openrouter_num));
|
||
|
||
let legacy_openrouter_str = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||
},
|
||
"openrouter_compat_mode": "true"
|
||
}));
|
||
assert!(adapter.needs_transform(&legacy_openrouter_str));
|
||
|
||
// OpenAI Chat format in meta: needs transform
|
||
let openai_chat_provider = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
api_format: Some("openai_chat".to_string()),
|
||
..Default::default()
|
||
},
|
||
);
|
||
assert!(adapter.needs_transform(&openai_chat_provider));
|
||
|
||
// OpenAI Responses format in meta: needs transform
|
||
let openai_responses_provider = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
api_format: Some("openai_responses".to_string()),
|
||
..Default::default()
|
||
},
|
||
);
|
||
assert!(adapter.needs_transform(&openai_responses_provider));
|
||
|
||
// meta takes precedence over legacy settings_config fields
|
||
let meta_precedence_over_settings = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||
},
|
||
"api_format": "openai_chat",
|
||
"openrouter_compat_mode": true
|
||
}),
|
||
ProviderMeta {
|
||
api_format: Some("anthropic".to_string()),
|
||
..Default::default()
|
||
},
|
||
);
|
||
assert!(!adapter.needs_transform(&meta_precedence_over_settings));
|
||
|
||
// Unknown format in meta: default to anthropic (no transform)
|
||
let unknown_format = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
api_format: Some("unknown".to_string()),
|
||
..Default::default()
|
||
},
|
||
);
|
||
assert!(!adapter.needs_transform(&unknown_format));
|
||
}
|
||
|
||
#[test]
|
||
fn test_github_copilot_detection_by_url() {
|
||
let adapter = ClaudeAdapter::new();
|
||
|
||
// GitHub Copilot by base_url
|
||
let copilot = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
|
||
}
|
||
}));
|
||
assert_eq!(adapter.provider_type(&copilot), ProviderType::GitHubCopilot);
|
||
}
|
||
|
||
#[test]
|
||
fn test_github_copilot_detection_by_meta() {
|
||
let adapter = ClaudeAdapter::new();
|
||
|
||
// GitHub Copilot by meta.provider_type
|
||
let copilot_meta = create_provider_with_meta(
|
||
json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||
}
|
||
}),
|
||
ProviderMeta {
|
||
provider_type: Some("github_copilot".to_string()),
|
||
..Default::default()
|
||
},
|
||
);
|
||
assert_eq!(
|
||
adapter.provider_type(&copilot_meta),
|
||
ProviderType::GitHubCopilot
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_github_copilot_auth() {
|
||
let adapter = ClaudeAdapter::new();
|
||
|
||
let copilot = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
|
||
}
|
||
}));
|
||
|
||
let auth = adapter.extract_auth(&copilot).unwrap();
|
||
assert_eq!(auth.strategy, AuthStrategy::GitHubCopilot);
|
||
}
|
||
|
||
#[test]
|
||
fn test_github_copilot_needs_transform() {
|
||
let adapter = ClaudeAdapter::new();
|
||
|
||
let copilot = create_provider(json!({
|
||
"env": {
|
||
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
|
||
}
|
||
}));
|
||
|
||
// GitHub Copilot always needs transform
|
||
assert!(adapter.needs_transform(&copilot));
|
||
}
|
||
}
|