mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 08:14:33 +08:00
feat(claude): add API format selector for third-party providers
Replace the OpenRouter-specific compatibility toggle with a generic
API format selector that allows all Claude providers to choose between:
- Anthropic Messages (native): Direct passthrough, no conversion
- OpenAI Chat Completions: Enables Anthropic ↔ OpenAI format conversion
Changes:
- Add ClaudeApiFormat type ("anthropic" | "openai_chat") to types.ts
- Replace openRouterCompatToggle with apiFormat dropdown in ClaudeFormFields
- Update ProviderForm to manage apiFormat state via settingsConfig.api_format
- Refactor claude.rs: add get_api_format() method, update needs_transform()
- Maintain backward compatibility with legacy openrouter_compat_mode field
- Update i18n translations (zh, en, ja)
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
//! Claude (Anthropic) Provider Adapter
|
||||
//!
|
||||
//! 支持透传模式和 OpenRouter 兼容模式
|
||||
//! 支持透传模式和 OpenAI Chat Completions 格式转换模式
|
||||
//!
|
||||
//! ## API 格式
|
||||
//! - **anthropic** (默认): Anthropic Messages API 格式,直接透传
|
||||
//! - **openai_chat**: OpenAI Chat Completions 格式,需要 Anthropic ↔ OpenAI 转换
|
||||
//!
|
||||
//! ## 认证模式
|
||||
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
|
||||
//! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key)
|
||||
//! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传(保留旧转换逻辑备用)
|
||||
//! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传
|
||||
|
||||
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
|
||||
use crate::provider::Provider;
|
||||
@@ -48,23 +52,44 @@ impl ClaudeAdapter {
|
||||
false
|
||||
}
|
||||
|
||||
/// 检测 OpenRouter 是否启用兼容模式
|
||||
fn is_openrouter_compat_enabled(&self, provider: &Provider) -> bool {
|
||||
if !self.is_openrouter(provider) {
|
||||
return false;
|
||||
/// 获取 API 格式
|
||||
///
|
||||
/// 从 settings_config.api_format 读取格式设置:
|
||||
/// - "anthropic" (默认): Anthropic Messages API 格式,直接透传
|
||||
/// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
||||
///
|
||||
/// 为了向后兼容,如果存在旧的 openrouter_compat_mode=true,也会启用 openai_chat 格式
|
||||
fn get_api_format(&self, provider: &Provider) -> &'static str {
|
||||
// 1. 首先检查新的 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",
|
||||
_ => "anthropic",
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 向后兼容:检查旧的 openrouter_compat_mode 字段
|
||||
let raw = provider.settings_config.get("openrouter_compat_mode");
|
||||
match raw {
|
||||
let is_compat_enabled = match raw {
|
||||
Some(serde_json::Value::Bool(enabled)) => *enabled,
|
||||
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"
|
||||
}
|
||||
// OpenRouter now supports Claude Code compatible API, default to passthrough
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if is_compat_enabled {
|
||||
return "openai_chat";
|
||||
}
|
||||
|
||||
// 3. 默认使用 Anthropic 原生格式
|
||||
"anthropic"
|
||||
}
|
||||
|
||||
/// 检测是否为仅 Bearer 认证模式
|
||||
@@ -253,13 +278,11 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
fn needs_transform(&self, _provider: &Provider) -> bool {
|
||||
// NOTE:
|
||||
// OpenRouter 已推出 Claude Code 兼容接口(可直接处理 `/v1/messages`),默认不再启用
|
||||
// Anthropic ↔ OpenAI 的格式转换。
|
||||
//
|
||||
// 如果未来需要回退到旧的 OpenAI Chat Completions 方案,可恢复下面这行:
|
||||
self.is_openrouter_compat_enabled(_provider)
|
||||
fn needs_transform(&self, provider: &Provider) -> bool {
|
||||
// 根据 api_format 配置决定是否需要格式转换
|
||||
// - "anthropic" (默认): 直接透传,无需转换
|
||||
// - "openai_chat": 需要 Anthropic ↔ OpenAI 格式转换
|
||||
self.get_api_format(provider) == "openai_chat"
|
||||
}
|
||||
|
||||
fn transform_request(
|
||||
@@ -459,6 +482,7 @@ mod tests {
|
||||
fn test_needs_transform() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
|
||||
// Default: no transform (anthropic format)
|
||||
let anthropic_provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.anthropic.com"
|
||||
@@ -466,29 +490,50 @@ mod tests {
|
||||
}));
|
||||
assert!(!adapter.needs_transform(&anthropic_provider));
|
||||
|
||||
// OpenRouter provider without explicit setting now defaults to passthrough (no transform)
|
||||
let openrouter_provider = create_provider(json!({
|
||||
// Explicit anthropic format: no transform
|
||||
let explicit_anthropic = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
|
||||
}
|
||||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||||
},
|
||||
"api_format": "anthropic"
|
||||
}));
|
||||
assert!(!adapter.needs_transform(&openrouter_provider));
|
||||
assert!(!adapter.needs_transform(&explicit_anthropic));
|
||||
|
||||
// OpenRouter provider with explicit compat mode enabled should transform
|
||||
let openrouter_enabled = create_provider(json!({
|
||||
// OpenAI Chat format: needs transform
|
||||
let openai_chat_provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
|
||||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||||
},
|
||||
"api_format": "openai_chat"
|
||||
}));
|
||||
assert!(adapter.needs_transform(&openai_chat_provider));
|
||||
|
||||
// Backward compatibility: openrouter_compat_mode=true should enable transform
|
||||
let legacy_compat_enabled = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||||
},
|
||||
"openrouter_compat_mode": true
|
||||
}));
|
||||
assert!(adapter.needs_transform(&openrouter_enabled));
|
||||
assert!(adapter.needs_transform(&legacy_compat_enabled));
|
||||
|
||||
let openrouter_disabled = create_provider(json!({
|
||||
// Backward compatibility: openrouter_compat_mode=false should not enable transform
|
||||
let legacy_compat_disabled = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
|
||||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||||
},
|
||||
"openrouter_compat_mode": false
|
||||
}));
|
||||
assert!(!adapter.needs_transform(&openrouter_disabled));
|
||||
assert!(!adapter.needs_transform(&legacy_compat_disabled));
|
||||
|
||||
// api_format takes precedence over openrouter_compat_mode
|
||||
let format_precedence = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||||
},
|
||||
"api_format": "anthropic",
|
||||
"openrouter_compat_mode": true
|
||||
}));
|
||||
assert!(!adapter.needs_transform(&format_precedence));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FormLabel } from "@/components/ui/form";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import EndpointSpeedTest from "./EndpointSpeedTest";
|
||||
import { ApiKeySection, EndpointField } from "./shared";
|
||||
import type { ProviderCategory } from "@/types";
|
||||
import type { ProviderCategory, ClaudeApiFormat } from "@/types";
|
||||
import type { TemplateValueConfig } from "@/config/claudeProviderPresets";
|
||||
|
||||
interface EndpointCandidate {
|
||||
@@ -59,10 +65,9 @@ interface ClaudeFormFieldsProps {
|
||||
// Speed Test Endpoints
|
||||
speedTestEndpoints: EndpointCandidate[];
|
||||
|
||||
// OpenRouter Compat
|
||||
showOpenRouterCompatToggle: boolean;
|
||||
openRouterCompatEnabled: boolean;
|
||||
onOpenRouterCompatChange: (enabled: boolean) => void;
|
||||
// API Format (for third-party providers that use OpenAI Chat Completions format)
|
||||
apiFormat: ClaudeApiFormat;
|
||||
onApiFormatChange: (format: ClaudeApiFormat) => void;
|
||||
}
|
||||
|
||||
export function ClaudeFormFields({
|
||||
@@ -95,9 +100,8 @@ export function ClaudeFormFields({
|
||||
defaultOpusModel,
|
||||
onModelChange,
|
||||
speedTestEndpoints,
|
||||
showOpenRouterCompatToggle,
|
||||
openRouterCompatEnabled,
|
||||
onOpenRouterCompatChange,
|
||||
apiFormat,
|
||||
onApiFormatChange,
|
||||
}: ClaudeFormFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -180,25 +184,34 @@ export function ClaudeFormFields({
|
||||
/>
|
||||
)}
|
||||
|
||||
{showOpenRouterCompatToggle && (
|
||||
<div className="flex items-center justify-between rounded-lg border border-white/10 bg-background/60 p-4">
|
||||
<div className="space-y-1">
|
||||
<FormLabel>
|
||||
{t("providerForm.openrouterCompatMode", {
|
||||
defaultValue: "OpenRouter 兼容模式",
|
||||
})}
|
||||
</FormLabel>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.openrouterCompatModeHint", {
|
||||
defaultValue:
|
||||
"使用 OpenAI Chat Completions 接口并转换为 Anthropic SSE。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={openRouterCompatEnabled}
|
||||
onCheckedChange={onOpenRouterCompatChange}
|
||||
/>
|
||||
{/* API 格式选择(仅非官方供应商显示) */}
|
||||
{shouldShowModelSelector && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="apiFormat">
|
||||
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
|
||||
</FormLabel>
|
||||
<Select value={apiFormat} onValueChange={onApiFormatChange}>
|
||||
<SelectTrigger id="apiFormat" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="anthropic">
|
||||
{t("providerForm.apiFormatAnthropic", {
|
||||
defaultValue: "Anthropic Messages (原生)",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="openai_chat">
|
||||
{t("providerForm.apiFormatOpenAIChat", {
|
||||
defaultValue: "OpenAI Chat Completions (需转换)",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerForm.apiFormatHint", {
|
||||
defaultValue: "选择供应商 API 的输入格式",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
ProviderMeta,
|
||||
ProviderTestConfig,
|
||||
ProviderProxyConfig,
|
||||
ClaudeApiFormat,
|
||||
} from "@/types";
|
||||
import {
|
||||
providerPresets,
|
||||
@@ -284,45 +285,31 @@ export function ProviderForm({
|
||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
});
|
||||
|
||||
const isOpenRouterProvider = useMemo(() => {
|
||||
if (appId !== "claude") return false;
|
||||
const normalized = baseUrl.trim().toLowerCase();
|
||||
if (normalized.includes("openrouter.ai")) {
|
||||
return true;
|
||||
}
|
||||
// Claude API Format state
|
||||
// Read initial value from settingsConfig.api_format, default to "anthropic"
|
||||
const apiFormat = useMemo<ClaudeApiFormat>(() => {
|
||||
if (appId !== "claude") return "anthropic";
|
||||
try {
|
||||
const config = JSON.parse(settingsConfigValue || "{}");
|
||||
const envUrl = config?.env?.ANTHROPIC_BASE_URL;
|
||||
return typeof envUrl === "string" && envUrl.includes("openrouter.ai");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [appId, baseUrl, settingsConfigValue]);
|
||||
|
||||
const openRouterCompatEnabled = useMemo(() => {
|
||||
if (!isOpenRouterProvider) return false;
|
||||
try {
|
||||
const config = JSON.parse(settingsConfigValue || "{}");
|
||||
const raw = config?.openrouter_compat_mode;
|
||||
if (typeof raw === "boolean") return raw;
|
||||
if (typeof raw === "number") return raw !== 0;
|
||||
if (typeof raw === "string") {
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
return normalized === "true" || normalized === "1";
|
||||
}
|
||||
const format = config?.api_format;
|
||||
if (format === "openai_chat") return "openai_chat";
|
||||
// Backward compatibility: if old openrouter_compat_mode is true, treat as openai_chat
|
||||
if (config?.openrouter_compat_mode === true) return "openai_chat";
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return false; // OpenRouter now supports Claude Code compatible API, no need for transform
|
||||
}, [isOpenRouterProvider, settingsConfigValue]);
|
||||
return "anthropic";
|
||||
}, [appId, settingsConfigValue]);
|
||||
|
||||
const handleOpenRouterCompatChange = useCallback(
|
||||
(enabled: boolean) => {
|
||||
const handleApiFormatChange = useCallback(
|
||||
(format: ClaudeApiFormat) => {
|
||||
try {
|
||||
const currentConfig = JSON.parse(
|
||||
form.getValues("settingsConfig") || "{}",
|
||||
);
|
||||
currentConfig.openrouter_compat_mode = enabled;
|
||||
currentConfig.api_format = format;
|
||||
// Clean up legacy field
|
||||
delete currentConfig.openrouter_compat_mode;
|
||||
form.setValue("settingsConfig", JSON.stringify(currentConfig, null, 2));
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -1294,9 +1281,8 @@ export function ProviderForm({
|
||||
defaultOpusModel={defaultOpusModel}
|
||||
onModelChange={handleModelChange}
|
||||
speedTestEndpoints={speedTestEndpoints}
|
||||
showOpenRouterCompatToggle={false}
|
||||
openRouterCompatEnabled={openRouterCompatEnabled}
|
||||
onOpenRouterCompatChange={handleOpenRouterCompatChange}
|
||||
apiFormat={apiFormat}
|
||||
onApiFormatChange={handleApiFormatChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -472,8 +472,10 @@
|
||||
"anthropicModel": "Main Model",
|
||||
"anthropicSmallFastModel": "Fast Model",
|
||||
"anthropicReasoningModel": "Reasoning Model (Thinking)",
|
||||
"openrouterCompatMode": "OpenRouter Compatibility Mode",
|
||||
"openrouterCompatModeHint": "Use OpenAI Chat Completions interface and convert to Anthropic SSE.",
|
||||
"apiFormat": "API Format",
|
||||
"apiFormatHint": "Select the input format for the provider's API",
|
||||
"apiFormatAnthropic": "Anthropic Messages (Native)",
|
||||
"apiFormatOpenAIChat": "OpenAI Chat Completions (Requires conversion)",
|
||||
"anthropicDefaultHaikuModel": "Default Haiku Model",
|
||||
"anthropicDefaultSonnetModel": "Default Sonnet Model",
|
||||
"anthropicDefaultOpusModel": "Default Opus Model",
|
||||
|
||||
@@ -472,8 +472,10 @@
|
||||
"anthropicModel": "メインモデル",
|
||||
"anthropicSmallFastModel": "高速モデル",
|
||||
"anthropicReasoningModel": "推論モデル(Thinking)",
|
||||
"openrouterCompatMode": "OpenRouter 互換モード",
|
||||
"openrouterCompatModeHint": "OpenAI Chat Completions インターフェースを使用し、Anthropic SSE に変換します。",
|
||||
"apiFormat": "API フォーマット",
|
||||
"apiFormatHint": "プロバイダー API の入力フォーマットを選択",
|
||||
"apiFormatAnthropic": "Anthropic Messages(ネイティブ)",
|
||||
"apiFormatOpenAIChat": "OpenAI Chat Completions(変換が必要)",
|
||||
"anthropicDefaultHaikuModel": "既定 Haiku モデル",
|
||||
"anthropicDefaultSonnetModel": "既定 Sonnet モデル",
|
||||
"anthropicDefaultOpusModel": "既定 Opus モデル",
|
||||
|
||||
@@ -472,8 +472,10 @@
|
||||
"anthropicModel": "主模型",
|
||||
"anthropicSmallFastModel": "快速模型",
|
||||
"anthropicReasoningModel": "推理模型 (Thinking)",
|
||||
"openrouterCompatMode": "OpenRouter 兼容模式",
|
||||
"openrouterCompatModeHint": "使用 OpenAI Chat Completions 接口并转换为 Anthropic SSE。",
|
||||
"apiFormat": "API 格式",
|
||||
"apiFormatHint": "选择供应商 API 的输入格式",
|
||||
"apiFormatAnthropic": "Anthropic Messages (原生)",
|
||||
"apiFormatOpenAIChat": "OpenAI Chat Completions (需转换)",
|
||||
"anthropicDefaultHaikuModel": "Haiku 默认模型",
|
||||
"anthropicDefaultSonnetModel": "Sonnet 默认模型",
|
||||
"anthropicDefaultOpusModel": "Opus 默认模型",
|
||||
|
||||
@@ -144,6 +144,11 @@ export interface ProviderMeta {
|
||||
// Skill 同步方式
|
||||
export type SkillSyncMethod = "auto" | "symlink" | "copy";
|
||||
|
||||
// Claude API 格式类型
|
||||
// - "anthropic": 原生 Anthropic Messages API 格式,直接透传
|
||||
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
||||
export type ClaudeApiFormat = "anthropic" | "openai_chat";
|
||||
|
||||
// 主页面显示的应用配置
|
||||
export interface VisibleApps {
|
||||
claude: boolean;
|
||||
|
||||
Reference in New Issue
Block a user