Files
CC-Switch/src-tauri/src/proxy/providers/mod.rs
T
Jason 72bc912e0d feat: route Codex Chat providers through Stream Check
Codex Chat providers (apiFormat=openai_chat, e.g. DeepSeek, MiniMax, Kimi)
were incorrectly probed via /v1/responses by Stream Check, which the upstream
rejects. Detect chat routing via codex_provider_uses_chat_completions and
issue the probe against /chat/completions with a Chat-shaped body.

Also align URL fallback order with the production CodexAdapter::build_url:
origin-only base URLs (https://api.deepseek.com) must hit /v1/<endpoint>
first; bare /<endpoint> only as a fallback. The previous order made any
non-404 error on the bare path (401/400/405) flag the provider as down even
though /v1/<endpoint> would have succeeded.

- Lift origin-only detection into proxy::providers::is_origin_only_url so
  both build_url and Stream Check share a single source of truth.
- Collapse resolve_codex_stream_urls and resolve_codex_chat_stream_urls into
  a generic resolve_codex_endpoint_urls(base_url, is_full_url, endpoint),
  which also gives the Responses path a symmetric "full endpoint without
  is_full_url flag" fallback for free.
- Restrict reasoning_effort propagation in the Chat branch to OpenAI o-series
  models, mirroring transform_codex_chat's runtime check.
2026-05-25 22:20:33 +08:00

518 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Provider Adapters Module
//!
//! 供应商适配器模块,提供统一的接口抽象不同上游供应商的处理逻辑。
//!
//! ## 模块结构
//! - `adapter`: 定义 `ProviderAdapter` trait
//! - `auth`: 认证类型和策略
//! - `claude`: Claude (Anthropic) 适配器
//! - `codex`: Codex (OpenAI) 适配器
//! - `gemini`: Gemini (Google) 适配器
//! - `models`: API 数据模型
//! - `transform`: 格式转换
mod adapter;
mod auth;
mod claude;
mod codex;
pub(crate) mod codex_chat_common;
pub mod codex_chat_history;
pub mod codex_oauth_auth;
pub mod copilot_auth;
pub mod copilot_model_map;
mod gemini;
pub(crate) mod gemini_schema;
pub mod gemini_shadow;
pub mod models;
pub mod streaming;
pub mod streaming_codex_chat;
pub mod streaming_gemini;
pub mod streaming_responses;
pub mod transform;
pub mod transform_codex_chat;
pub mod transform_gemini;
pub mod transform_responses;
use crate::app_config::AppType;
use crate::provider::Provider;
use serde::{Deserialize, Serialize};
// 公开导出
pub use adapter::ProviderAdapter;
pub use auth::{AuthInfo, AuthStrategy};
pub use claude::{
claude_api_format_needs_transform, get_claude_api_format,
transform_claude_request_for_api_format, ClaudeAdapter,
};
pub use codex::CodexAdapter;
pub use codex::{
apply_codex_chat_upstream_model, codex_provider_upstream_model,
codex_provider_uses_chat_completions, is_origin_only_url,
should_convert_codex_responses_to_chat,
};
pub use gemini::GeminiAdapter;
/// 供应商类型枚举
///
/// 区分不同供应商的具体实现方式,决定认证和请求处理逻辑。
/// 比 AppType 更细粒度,支持同一 AppType 下的多种变体。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProviderType {
/// Anthropic 官方 API (x-api-key + anthropic-version)
Claude,
/// Claude 中转服务 (仅 Bearer 认证,无 x-api-key)
ClaudeAuth,
/// OpenAI Codex Response API
Codex,
/// Google Gemini API (x-goog-api-key)
Gemini,
/// Google Gemini CLI (OAuth Bearer)
GeminiCli,
/// OpenRouter(已支持 Claude Code 兼容接口,默认透传;保留旧转换逻辑备用)
OpenRouter,
/// GitHub Copilot (OAuth + Copilot Token,需要 Anthropic ↔ OpenAI 转换)
GitHubCopilot,
/// OpenAI Codex (ChatGPT Plus/Pro OAuth,需要 Anthropic ↔ Responses API 转换)
CodexOAuth,
}
impl ProviderType {
/// 是否需要格式转换
///
/// 过去 OpenRouter 需要将 Anthropic 格式转换为 OpenAI 格式;
/// 现在默认关闭转换(因为 OpenRouter 已支持 Claude Code 兼容接口)。
/// GitHub Copilot 需要转换(Anthropic → OpenAI 格式)。
#[allow(dead_code)]
pub fn needs_transform(&self) -> bool {
match self {
ProviderType::GitHubCopilot => true,
ProviderType::CodexOAuth => true,
ProviderType::OpenRouter => false,
_ => false,
}
}
/// 获取默认端点
#[allow(dead_code)]
pub fn default_endpoint(&self) -> &'static str {
match self {
ProviderType::Claude | ProviderType::ClaudeAuth => "https://api.anthropic.com",
ProviderType::Codex => "https://api.openai.com",
ProviderType::Gemini | ProviderType::GeminiCli => {
"https://generativelanguage.googleapis.com"
}
ProviderType::OpenRouter => "https://openrouter.ai/api",
ProviderType::GitHubCopilot => "https://api.githubcopilot.com",
ProviderType::CodexOAuth => "https://chatgpt.com/backend-api/codex",
}
}
/// 从 AppType 和 Provider 配置推断供应商类型
///
/// 根据配置中的 base_url、auth_mode、api_key 格式等信息推断具体的供应商类型
#[allow(dead_code)]
pub fn from_app_type_and_config(app_type: &AppType, provider: &Provider) -> Self {
match app_type {
AppType::Claude | AppType::ClaudeDesktop => {
if get_claude_api_format(provider) == "gemini_native" {
let adapter = ClaudeAdapter::new();
return match adapter.extract_auth(provider).map(|auth| auth.strategy) {
Some(AuthStrategy::GoogleOAuth) => ProviderType::GeminiCli,
_ => ProviderType::Gemini,
};
}
// 检测是否为 GitHub Copilot
if let Some(meta) = provider.meta.as_ref() {
if meta.provider_type.as_deref() == Some("github_copilot") {
return ProviderType::GitHubCopilot;
}
if meta.provider_type.as_deref() == Some("codex_oauth") {
return ProviderType::CodexOAuth;
}
}
// 检测 base_url 是否为 GitHub Copilot
let adapter = ClaudeAdapter::new();
if let Ok(base_url) = adapter.extract_base_url(provider) {
if base_url.contains("githubcopilot.com") {
return ProviderType::GitHubCopilot;
}
// 检测是否为 OpenRouter
if base_url.contains("openrouter.ai") {
return ProviderType::OpenRouter;
}
}
// 检测是否为中转服务(仅 Bearer 认证)
// 注意:ProviderMeta 没有直接的 auth_mode 字段,
// 我们通过检查 settings_config 中的配置来判断
// 检查 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 ProviderType::ClaudeAuth;
}
}
// 检查 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 ProviderType::ClaudeAuth;
}
}
}
ProviderType::Claude
}
AppType::Codex => ProviderType::Codex,
AppType::Gemini => {
// 检测是否为 CLI 模式(OAuth
let adapter = GeminiAdapter::new();
if let Some(auth) = adapter.extract_auth(provider) {
let key = &auth.api_key;
// OAuth access_token 以 ya29. 开头
if key.starts_with("ya29.") {
return ProviderType::GeminiCli;
}
// JSON 格式的 OAuth 凭证
if key.starts_with('{') {
return ProviderType::GeminiCli;
}
}
ProviderType::Gemini
}
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
// These apps don't support proxy, fallback to Codex-like type
ProviderType::Codex
}
}
}
/// 转换为字符串表示
pub fn as_str(&self) -> &'static str {
match self {
ProviderType::Claude => "claude",
ProviderType::ClaudeAuth => "claude_auth",
ProviderType::Codex => "codex",
ProviderType::Gemini => "gemini",
ProviderType::GeminiCli => "gemini_cli",
ProviderType::OpenRouter => "openrouter",
ProviderType::GitHubCopilot => "github_copilot",
ProviderType::CodexOAuth => "codex_oauth",
}
}
}
impl std::fmt::Display for ProviderType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl std::str::FromStr for ProviderType {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"claude" => Ok(ProviderType::Claude),
"claude_auth" | "claude-auth" => Ok(ProviderType::ClaudeAuth),
"codex" => Ok(ProviderType::Codex),
"gemini" => Ok(ProviderType::Gemini),
"gemini_cli" | "gemini-cli" => Ok(ProviderType::GeminiCli),
"openrouter" => Ok(ProviderType::OpenRouter),
"github_copilot" | "github-copilot" | "githubcopilot" => {
Ok(ProviderType::GitHubCopilot)
}
"codex_oauth" | "codex-oauth" | "codexoauth" => Ok(ProviderType::CodexOAuth),
_ => Err(format!("Invalid provider type: {s}")),
}
}
}
/// 根据 AppType 获取对应的适配器
pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
match app_type {
AppType::Claude | AppType::ClaudeDesktop => Box::new(ClaudeAdapter::new()),
AppType::Codex => Box::new(CodexAdapter::new()),
AppType::Gemini => Box::new(GeminiAdapter::new()),
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
// These apps don't support proxy, fallback to Codex adapter
Box::new(CodexAdapter::new())
}
}
}
/// 根据 ProviderType 获取对应的适配器
#[allow(dead_code)]
pub fn get_adapter_for_provider_type(provider_type: &ProviderType) -> Box<dyn ProviderAdapter> {
match provider_type {
ProviderType::Claude
| ProviderType::ClaudeAuth
| ProviderType::OpenRouter
| ProviderType::GitHubCopilot
| ProviderType::CodexOAuth => Box::new(ClaudeAdapter::new()),
ProviderType::Codex => Box::new(CodexAdapter::new()),
ProviderType::Gemini | ProviderType::GeminiCli => Box::new(GeminiAdapter::new()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn create_provider(config: serde_json::Value) -> Provider {
Provider {
id: "test".to_string(),
name: "Test Provider".to_string(),
settings_config: config,
website_url: None,
category: None,
created_at: None,
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
#[test]
fn test_provider_type_needs_transform() {
assert!(!ProviderType::Claude.needs_transform());
assert!(!ProviderType::ClaudeAuth.needs_transform());
assert!(!ProviderType::Codex.needs_transform());
assert!(!ProviderType::Gemini.needs_transform());
assert!(!ProviderType::GeminiCli.needs_transform());
assert!(!ProviderType::OpenRouter.needs_transform());
assert!(ProviderType::GitHubCopilot.needs_transform());
}
#[test]
fn test_provider_type_default_endpoint() {
assert_eq!(
ProviderType::Claude.default_endpoint(),
"https://api.anthropic.com"
);
assert_eq!(
ProviderType::ClaudeAuth.default_endpoint(),
"https://api.anthropic.com"
);
assert_eq!(
ProviderType::Codex.default_endpoint(),
"https://api.openai.com"
);
assert_eq!(
ProviderType::Gemini.default_endpoint(),
"https://generativelanguage.googleapis.com"
);
assert_eq!(
ProviderType::GeminiCli.default_endpoint(),
"https://generativelanguage.googleapis.com"
);
assert_eq!(
ProviderType::OpenRouter.default_endpoint(),
"https://openrouter.ai/api"
);
assert_eq!(
ProviderType::GitHubCopilot.default_endpoint(),
"https://api.githubcopilot.com"
);
}
#[test]
fn test_provider_type_from_str() {
assert_eq!(
"claude".parse::<ProviderType>().unwrap(),
ProviderType::Claude
);
assert_eq!(
"claude_auth".parse::<ProviderType>().unwrap(),
ProviderType::ClaudeAuth
);
assert_eq!(
"claude-auth".parse::<ProviderType>().unwrap(),
ProviderType::ClaudeAuth
);
assert_eq!(
"codex".parse::<ProviderType>().unwrap(),
ProviderType::Codex
);
assert_eq!(
"gemini".parse::<ProviderType>().unwrap(),
ProviderType::Gemini
);
assert_eq!(
"gemini_cli".parse::<ProviderType>().unwrap(),
ProviderType::GeminiCli
);
assert_eq!(
"gemini-cli".parse::<ProviderType>().unwrap(),
ProviderType::GeminiCli
);
assert_eq!(
"openrouter".parse::<ProviderType>().unwrap(),
ProviderType::OpenRouter
);
assert_eq!(
"github_copilot".parse::<ProviderType>().unwrap(),
ProviderType::GitHubCopilot
);
assert_eq!(
"github-copilot".parse::<ProviderType>().unwrap(),
ProviderType::GitHubCopilot
);
assert_eq!(
"githubcopilot".parse::<ProviderType>().unwrap(),
ProviderType::GitHubCopilot
);
assert!("invalid".parse::<ProviderType>().is_err());
}
#[test]
fn test_provider_type_as_str() {
assert_eq!(ProviderType::Claude.as_str(), "claude");
assert_eq!(ProviderType::ClaudeAuth.as_str(), "claude_auth");
assert_eq!(ProviderType::Codex.as_str(), "codex");
assert_eq!(ProviderType::Gemini.as_str(), "gemini");
assert_eq!(ProviderType::GeminiCli.as_str(), "gemini_cli");
assert_eq!(ProviderType::OpenRouter.as_str(), "openrouter");
assert_eq!(ProviderType::GitHubCopilot.as_str(), "github_copilot");
}
#[test]
fn test_provider_type_serde() {
// Test serialization
let claude = ProviderType::Claude;
let serialized = serde_json::to_string(&claude).unwrap();
assert_eq!(serialized, "\"claude\"");
let claude_auth = ProviderType::ClaudeAuth;
let serialized = serde_json::to_string(&claude_auth).unwrap();
assert_eq!(serialized, "\"claude_auth\"");
// Test deserialization
let deserialized: ProviderType = serde_json::from_str("\"claude\"").unwrap();
assert_eq!(deserialized, ProviderType::Claude);
let deserialized: ProviderType = serde_json::from_str("\"gemini_cli\"").unwrap();
assert_eq!(deserialized, ProviderType::GeminiCli);
}
#[test]
fn test_from_app_type_claude_direct() {
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
"ANTHROPIC_AUTH_TOKEN": "sk-ant-test"
}
}));
let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider);
assert_eq!(provider_type, ProviderType::Claude);
}
#[test]
fn test_from_app_type_claude_openrouter() {
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api",
"OPENROUTER_API_KEY": "sk-or-test"
}
}));
let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider);
assert_eq!(provider_type, ProviderType::OpenRouter);
}
#[test]
fn test_from_app_type_claude_auth() {
let provider = create_provider(json!({
"env": {
"ANTHROPIC_BASE_URL": "https://some-proxy.com",
"ANTHROPIC_AUTH_TOKEN": "sk-test"
},
"auth_mode": "bearer_only"
}));
let provider_type = ProviderType::from_app_type_and_config(&AppType::Claude, &provider);
assert_eq!(provider_type, ProviderType::ClaudeAuth);
}
#[test]
fn test_from_app_type_codex() {
let provider = create_provider(json!({
"env": {
"OPENAI_API_KEY": "sk-test"
}
}));
let provider_type = ProviderType::from_app_type_and_config(&AppType::Codex, &provider);
assert_eq!(provider_type, ProviderType::Codex);
}
#[test]
fn test_from_app_type_gemini_api_key() {
let provider = create_provider(json!({
"env": {
"GEMINI_API_KEY": "AIza-test-key"
}
}));
let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider);
assert_eq!(provider_type, ProviderType::Gemini);
}
#[test]
fn test_from_app_type_gemini_cli_oauth() {
let provider = create_provider(json!({
"env": {
"GEMINI_API_KEY": "ya29.test-access-token"
}
}));
let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider);
assert_eq!(provider_type, ProviderType::GeminiCli);
}
#[test]
fn test_from_app_type_gemini_cli_json() {
let provider = create_provider(json!({
"env": {
"GEMINI_API_KEY": "{\"access_token\":\"ya29.test\",\"refresh_token\":\"1//test\"}"
}
}));
let provider_type = ProviderType::from_app_type_and_config(&AppType::Gemini, &provider);
assert_eq!(provider_type, ProviderType::GeminiCli);
}
#[test]
fn test_get_adapter_for_provider_type() {
let adapter = get_adapter_for_provider_type(&ProviderType::Claude);
assert_eq!(adapter.name(), "Claude");
let adapter = get_adapter_for_provider_type(&ProviderType::ClaudeAuth);
assert_eq!(adapter.name(), "Claude");
let adapter = get_adapter_for_provider_type(&ProviderType::OpenRouter);
assert_eq!(adapter.name(), "Claude");
let adapter = get_adapter_for_provider_type(&ProviderType::GitHubCopilot);
assert_eq!(adapter.name(), "Claude");
let adapter = get_adapter_for_provider_type(&ProviderType::Codex);
assert_eq!(adapter.name(), "Codex");
let adapter = get_adapter_for_provider_type(&ProviderType::Gemini);
assert_eq!(adapter.name(), "Gemini");
let adapter = get_adapter_for_provider_type(&ProviderType::GeminiCli);
assert_eq!(adapter.name(), "Gemini");
}
}