diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e791acef..bf91a7382 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,8 @@ jobs: include: - os: windows-2022 - os: ubuntu-22.04 + - os: ubuntu-22.04-arm + arch: arm64 - os: macos-14 steps: @@ -85,8 +87,8 @@ jobs: uses: actions/cache@v4 with: path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: ${{ runner.os }}-pnpm-store- + key: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: ${{ runner.os }}-${{ runner.arch }}-pnpm-store- - name: Install frontend deps run: pnpm install --frozen-lockfile @@ -256,10 +258,11 @@ jobs: set -euxo pipefail mkdir -p release-assets VERSION="${GITHUB_REF_NAME}" # e.g., v3.5.0 + ARCH="${{ matrix.arch || 'x86_64' }}" # Updater artifact: AppImage(含对应 .sig) APPIMAGE=$(find src-tauri/target/release/bundle -name "*.AppImage" | head -1 || true) if [ -n "$APPIMAGE" ]; then - NEW_APPIMAGE="CC-Switch-${VERSION}-Linux.AppImage" + NEW_APPIMAGE="CC-Switch-${VERSION}-Linux-${ARCH}.AppImage" cp "$APPIMAGE" "release-assets/$NEW_APPIMAGE" [ -f "$APPIMAGE.sig" ] && cp "$APPIMAGE.sig" "release-assets/$NEW_APPIMAGE.sig" || echo ".sig for AppImage not found" echo "AppImage copied: $NEW_APPIMAGE" @@ -269,18 +272,16 @@ jobs: # 额外上传 .deb(用于手动安装,不参与 Updater) DEB=$(find src-tauri/target/release/bundle -name "*.deb" | head -1 || true) if [ -n "$DEB" ]; then - NEW_DEB="CC-Switch-${VERSION}-Linux.deb" - cp "$DEB" "release-assets/$NEW_DEB" - echo "Deb package copied: $NEW_DEB" + cp "$DEB" "release-assets/CC-Switch-${VERSION}-Linux-${ARCH}.deb" + echo "Deb package copied: CC-Switch-${VERSION}-Linux-${ARCH}.deb" else echo "No .deb found (optional)" fi # 额外上传 .rpm(用于 Fedora/RHEL/openSUSE 等,不参与 Updater) RPM=$(find src-tauri/target/release/bundle -name "*.rpm" | head -1 || true) if [ -n "$RPM" ]; then - NEW_RPM="CC-Switch-${VERSION}-Linux.rpm" - cp "$RPM" "release-assets/$NEW_RPM" - echo "RPM package copied: $NEW_RPM" + cp "$RPM" "release-assets/CC-Switch-${VERSION}-Linux-${ARCH}.rpm" + echo "RPM package copied: CC-Switch-${VERSION}-Linux-${ARCH}.rpm" else echo "No .rpm found (optional)" fi @@ -312,7 +313,8 @@ jobs: - **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)或 `CC-Switch-${{ github.ref_name }}-macOS.tar.gz`(Homebrew) - **Windows**: `CC-Switch-${{ github.ref_name }}-Windows.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-Portable.zip`(绿色版) - - **Linux**: `CC-Switch-${{ github.ref_name }}-Linux.AppImage`(AppImage)或 `CC-Switch-${{ github.ref_name }}-Linux.deb`(Debian/Ubuntu)或 `CC-Switch-${{ github.ref_name }}-Linux.rpm`(Fedora/RHEL/openSUSE) + - **Linux (x86_64)**: `CC-Switch-${{ github.ref_name }}-Linux-x86_64.AppImage` / `.deb` / `.rpm` + - **Linux (ARM64)**: `CC-Switch-${{ github.ref_name }}-Linux-arm64.AppImage` / `.deb` / `.rpm` --- 提示:macOS 如遇"已损坏"提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"` @@ -360,7 +362,8 @@ jobs: # 初始化空平台映射 mac_url=""; mac_sig="" win_url=""; win_sig="" - linux_url=""; linux_sig="" + linux_x64_url=""; linux_x64_sig="" + linux_arm64_url=""; linux_arm64_sig="" shopt -s nullglob for sig in dl/*.sig; do base=${sig%.sig} @@ -371,8 +374,10 @@ jobs: *.tar.gz) # 视为 macOS updater artifact mac_url="$url"; mac_sig="$sig_content";; - *.AppImage|*.appimage) - linux_url="$url"; linux_sig="$sig_content";; + *-Linux-arm64.AppImage|*-Linux-arm64.appimage) + linux_arm64_url="$url"; linux_arm64_sig="$sig_content";; + *-Linux-x86_64.AppImage|*-Linux-x86_64.appimage) + linux_x64_url="$url"; linux_x64_sig="$sig_content";; *.msi|*.exe) win_url="$url"; win_sig="$sig_content";; esac @@ -399,9 +404,14 @@ jobs: echo " \"windows-x86_64\": {\"signature\": \"$win_sig\", \"url\": \"$win_url\"}" first=0 fi - if [ -n "$linux_url" ] && [ -n "$linux_sig" ]; then + if [ -n "$linux_x64_url" ] && [ -n "$linux_x64_sig" ]; then [ $first -eq 0 ] && echo ',' - echo " \"linux-x86_64\": {\"signature\": \"$linux_sig\", \"url\": \"$linux_url\"}" + echo " \"linux-x86_64\": {\"signature\": \"$linux_x64_sig\", \"url\": \"$linux_x64_url\"}" + first=0 + fi + if [ -n "$linux_arm64_url" ] && [ -n "$linux_arm64_sig" ]; then + [ $first -eq 0 ] && echo ',' + echo " \"linux-aarch64\": {\"signature\": \"$linux_arm64_sig\", \"url\": \"$linux_arm64_url\"}" first=0 fi echo ' }' diff --git a/CHANGELOG.md b/CHANGELOG.md index 34616c691..614ce4ee0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [3.10.3] - 2026-01-30 + +### Feature Release + +This release introduces a generic API format selector, pricing configuration enhancements, and multiple UX improvements. + +### Added + +- **API Key Link for OpenCode**: API key link support for OpenCode provider form, enabling quick access to provider key management pages +- **AICodeMirror Partner Preset**: Added AICodeMirror partner preset for all apps (Claude, Codex, Gemini, OpenCode) +- **API Format Selector**: Generic API format chooser for Claude providers, replacing the OpenRouter-specific toggle. Supports Anthropic Messages (native) and OpenAI Chat Completions format +- **API Format Presets**: Allow preset providers to specify API format (anthropic or openai_chat) for third-party proxy services +- **Proxy Hint**: Display info toast when switching to OpenAI Chat format provider, reminding users to enable proxy +- **Pricing Config Enhancement**: Per-provider cost multiplier, pricing model source (request/response), request model logging, and enriched usage UI (#781) +- **Skills ZIP Install**: Install skills directly from local ZIP files with recursive scanning support +- **Preferred Terminal**: Choose preferred terminal app per platform (macOS: Terminal.app/iTerm2/Alacritty/Kitty/Ghostty; Windows: cmd/PowerShell/Windows Terminal; Linux: GNOME Terminal/Konsole/Xfce4/Alacritty/Kitty/Ghostty) +- **Silent Startup**: Option to prevent window popup on launch (#713) +- **OpenCode Environment Check**: Version detection with Go path scanning and one-click install from GitHub Releases +- **OpenCode Directory Sync**: Auto-sync all providers to live config on directory change with additive mode support +- **NVIDIA NIM Preset**: New provider preset for Claude and OpenCode with nvidia.svg icon +- **n1n.ai Preset**: New provider preset (#667) +- **Update Badge Icon**: Replace update badge dot with ArrowUpCircle icon +- **Linux ARM64**: CI build support for Linux ARM64 architecture + +### Changed + +- **API Format Migration**: Migrate api_format from settings_config to ProviderMeta to prevent polluting ~/.claude/settings.json +- **DeepSeek max_tokens**: Remove max_tokens clamp from proxy transform layer +- **Terminal Functions**: Consolidate redundant terminal launch functions +- **Home Dir Utility**: Consolidate get_home_dir into single public function +- **Kimi/Moonshot**: Upgrade provider presets to k2.5 model + +### Fixed + +- **Codex 404 & Timeout**: Fix 404 errors and connection timeout with custom base_url; improve /v1 prefix handling and system proxy detection (#760) +- **Proxy URL Building**: Fix duplicate /v1/v1 in URL; extend ?beta=true to /v1/chat/completions endpoint +- **OpenRouter Compat Mode**: Improve backward compatibility supporting number and string types +- **Gemini Visibility**: Correct Gemini default visibility to true (#818) +- **Footer Layout**: Correct footer layout in advanced settings tab +- **Claude Code Detection**: Prioritize native install path for detection +- **Tray Menu**: Simplify title labels and optimize menu separators (#796) +- **Duplicate Skills**: Prevent duplicate skill installation from different repos (#778) +- **Windows Tests**: Stabilize test environment (#644) +- **i18n**: Update apiFormatOpenAIChat label to mention proxy requirement +- **Error Display**: Use extractErrorMessage for complete error display in mutations +- **Sponsors**: Add AICodeMirror and reorder sponsor list + +--- + ## [3.10.2] - 2026-01-24 ### Patch Release diff --git a/cc-switch-main/src/config/universalProviderPresets.ts b/cc-switch-main/src/config/universalProviderPresets.ts new file mode 100644 index 000000000..e2f9cd04b --- /dev/null +++ b/cc-switch-main/src/config/universalProviderPresets.ts @@ -0,0 +1,162 @@ +/** + * 统一供应商(Universal Provider)预设配置 + * + * 统一供应商是跨应用共享的配置,修改后会自动同步到 Claude、Codex、Gemini 三个应用。 + * 适用于 NewAPI 等支持多种协议的 API 网关。 + */ + +import type { + UniversalProvider, + UniversalProviderApps, + UniversalProviderModels, +} from "@/types"; + +/** + * 统一供应商预设接口 + */ +export interface UniversalProviderPreset { + /** 预设名称 */ + name: string; + /** 供应商类型标识 */ + providerType: string; + /** 默认启用的应用 */ + defaultApps: UniversalProviderApps; + /** 默认模型配置 */ + defaultModels: UniversalProviderModels; + /** 网站链接 */ + websiteUrl?: string; + /** 图标名称 */ + icon?: string; + /** 图标颜色 */ + iconColor?: string; + /** 描述 */ + description?: string; + /** 是否为自定义模板(允许用户完全自定义) */ + isCustomTemplate?: boolean; +} + +/** + * NewAPI 默认模型配置 + */ +const NEWAPI_DEFAULT_MODELS: UniversalProviderModels = { + claude: { + model: "claude-sonnet-4-20250514", + haikuModel: "claude-haiku-4-20250514", + sonnetModel: "claude-sonnet-4-20250514", + opusModel: "claude-sonnet-4-20250514", + }, + codex: { + model: "gpt-4o", + reasoningEffort: "high", + }, + gemini: { + model: "gemini-2.5-pro", + }, +}; + +const N1N_DEFAULT_MODELS: UniversalProviderModels = { + claude: { + model: "claude-3-5-sonnet-20240620", + haikuModel: "claude-3-haiku-20240307", + sonnetModel: "claude-3-5-sonnet-20240620", + opusModel: "claude-3-opus-20240229", + }, + codex: { + model: "gpt-4o", + reasoningEffort: "high", + }, + gemini: { + model: "gemini-1.5-pro-latest", + }, +}; + +/** + * 统一供应商预设列表 + */ +export const universalProviderPresets: UniversalProviderPreset[] = [ + { + name: "n1n.ai", + providerType: "n1n", + defaultApps: { + claude: true, + codex: true, + gemini: true, + }, + defaultModels: N1N_DEFAULT_MODELS, + websiteUrl: "https://n1n.ai", + icon: "openai", + iconColor: "#000000", + description: + "n1n.ai - 聚合 OpenAI, Anthropic, Google 等主流大模型的一站式 AI 服务平台", + }, + { + name: "NewAPI", + providerType: "newapi", + defaultApps: { + claude: true, + codex: true, + gemini: true, + }, + defaultModels: NEWAPI_DEFAULT_MODELS, + websiteUrl: "https://www.newapi.pro", + icon: "newapi", + iconColor: "#00A67E", + description: + "NewAPI 是一个可自部署的 API 网关,支持 Anthropic、OpenAI、Gemini 等多种协议", + }, + { + name: "自定义网关", + providerType: "custom_gateway", + defaultApps: { + claude: true, + codex: true, + gemini: true, + }, + defaultModels: NEWAPI_DEFAULT_MODELS, + icon: "openai", + iconColor: "#6366F1", + description: "自定义配置的 API 网关", + isCustomTemplate: true, + }, +]; + +/** + * 根据预设创建统一供应商 + */ +export function createUniversalProviderFromPreset( + preset: UniversalProviderPreset, + id: string, + baseUrl: string, + apiKey: string, + customName?: string, +): UniversalProvider { + return { + id, + name: customName || preset.name, + providerType: preset.providerType, + apps: { ...preset.defaultApps }, + baseUrl, + apiKey, + models: JSON.parse(JSON.stringify(preset.defaultModels)), // Deep copy + websiteUrl: preset.websiteUrl, + icon: preset.icon, + iconColor: preset.iconColor, + createdAt: Date.now(), + }; +} + +/** + * 获取预设的显示名称(用于 UI) + */ +export function getPresetDisplayName(preset: UniversalProviderPreset): string { + return preset.name; +} + +/** + * 根据类型查找预设 + */ +export function findPresetByType( + providerType: string, +): UniversalProviderPreset | undefined { + return universalProviderPresets.find((p) => p.providerType === providerType); +} diff --git a/package.json b/package.json index 90d47c23f..466590d00 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cc-switch", - "version": "3.10.2", + "version": "3.10.3", "description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8f0a0751e..b814be2e3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -701,7 +701,7 @@ dependencies = [ [[package]] name = "cc-switch" -version = "3.10.2" +version = "3.10.3" dependencies = [ "anyhow", "async-stream", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e4b134f76..cb571fd00 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cc-switch" -version = "3.10.2" +version = "3.10.3" description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI" authors = ["Jason Young"] license = "MIT" diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 593651e99..b159bd8b2 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -89,7 +89,7 @@ pub struct ToolVersion { #[tauri::command] pub async fn get_tool_versions() -> Result, String> { - let tools = vec!["claude", "codex", "gemini"]; + let tools = vec!["claude", "codex", "gemini", "opencode"]; let mut results = Vec::new(); // 使用全局 HTTP 客户端(已包含代理配置) @@ -116,6 +116,7 @@ pub async fn get_tool_versions() -> Result, String> { "claude" => fetch_npm_latest_version(&client, "@anthropic-ai/claude-code").await, "codex" => fetch_npm_latest_version(&client, "@openai/codex").await, "gemini" => fetch_npm_latest_version(&client, "@google/gemini-cli").await, + "opencode" => fetch_github_latest_version(&client, "anomalyco/opencode").await, _ => None, }; @@ -148,6 +149,29 @@ async fn fetch_npm_latest_version(client: &reqwest::Client, package: &str) -> Op } } +/// Helper function to fetch latest version from GitHub releases +async fn fetch_github_latest_version(client: &reqwest::Client, repo: &str) -> Option { + let url = format!("https://api.github.com/repos/{repo}/releases/latest"); + match client + .get(&url) + .header("User-Agent", "cc-switch") + .header("Accept", "application/vnd.github+json") + .send() + .await + { + Ok(resp) => { + if let Ok(json) = resp.json::().await { + json.get("tag_name") + .and_then(|v| v.as_str()) + .map(|s| s.strip_prefix('v').unwrap_or(s).to_string()) + } else { + None + } + } + Err(_) => None, + } +} + /// 预编译的版本号正则表达式 static VERSION_RE: Lazy = Lazy::new(|| Regex::new(r"\d+\.\d+\.\d+(-[\w.]+)?").expect("Invalid version regex")); @@ -224,7 +248,7 @@ fn try_get_version_wsl(tool: &str, distro: &str) -> (Option, Option (Option, Option) { } } + // 添加 Go 路径支持 (opencode 使用 go install 安装) + if tool == "opencode" { + search_paths.push(home.join("go/bin")); // go install 默认路径 + if let Ok(gopath) = std::env::var("GOPATH") { + search_paths.push(std::path::PathBuf::from(gopath).join("bin")); + } + } + // 在每个路径中查找工具 for path in &search_paths { let tool_path = if cfg!(target_os = "windows") { @@ -405,6 +437,7 @@ fn wsl_distro_for_tool(tool: &str) -> Option { "claude" => crate::settings::get_claude_override_dir(), "codex" => crate::settings::get_codex_override_dir(), "gemini" => crate::settings::get_gemini_override_dir(), + "opencode" => crate::settings::get_opencode_override_dir(), _ => None, }?; diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index e38dab040..50854726d 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -253,6 +253,11 @@ pub struct ProviderMeta { skip_serializing_if = "Option::is_none" )] pub common_config_enabled_by_app: Option, + /// Claude API 格式(仅 Claude 供应商使用) + /// - "anthropic": 原生 Anthropic Messages API,直接透传 + /// - "openai_chat": OpenAI Chat Completions 格式,需要转换 + #[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")] + pub api_format: Option, } impl ProviderManager { @@ -551,54 +556,6 @@ requires_openai_auth = true"# } } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn universal_codex_provider_origin_base_url_adds_v1() { - let mut p = UniversalProvider::new( - "id".to_string(), - "Test".to_string(), - "custom".to_string(), - "https://api.openai.com".to_string(), - "sk-test".to_string(), - ); - p.apps.codex = true; - - let provider = p.to_codex_provider().expect("should build codex provider"); - let toml = provider - .settings_config - .get("config") - .and_then(|v| v.as_str()) - .expect("config should be a toml string"); - - assert!(toml.contains("base_url = \"https://api.openai.com/v1\"")); - } - - #[test] - fn universal_codex_provider_custom_prefix_does_not_force_v1() { - let mut p = UniversalProvider::new( - "id".to_string(), - "Test".to_string(), - "custom".to_string(), - "https://example.com/openai".to_string(), - "sk-test".to_string(), - ); - p.apps.codex = true; - - let provider = p.to_codex_provider().expect("should build codex provider"); - let toml = provider - .settings_config - .get("config") - .and_then(|v| v.as_str()) - .expect("config should be a toml string"); - - assert!(toml.contains("base_url = \"https://example.com/openai\"")); - assert!(!toml.contains("https://example.com/openai/v1")); - } -} - // ============================================================================ // OpenCode 供应商配置结构 // ============================================================================ @@ -958,4 +915,47 @@ mod provider_tests { assert!(config.options.headers.is_none()); assert!(config.options.extra.is_empty()); } + + #[test] + fn universal_codex_provider_origin_base_url_adds_v1() { + let mut p = UniversalProvider::new( + "id".to_string(), + "Test".to_string(), + "custom".to_string(), + "https://api.openai.com".to_string(), + "sk-test".to_string(), + ); + p.apps.codex = true; + + let provider = p.to_codex_provider().expect("should build codex provider"); + let toml = provider + .settings_config + .get("config") + .and_then(|v| v.as_str()) + .expect("config should be a toml string"); + + assert!(toml.contains("base_url = \"https://api.openai.com/v1\"")); + } + + #[test] + fn universal_codex_provider_custom_prefix_does_not_force_v1() { + let mut p = UniversalProvider::new( + "id".to_string(), + "Test".to_string(), + "custom".to_string(), + "https://example.com/openai".to_string(), + "sk-test".to_string(), + ); + p.apps.codex = true; + + let provider = p.to_codex_provider().expect("should build codex provider"); + let toml = provider + .settings_config + .get("config") + .and_then(|v| v.as_str()) + .expect("config should be a toml string"); + + assert!(toml.contains("base_url = \"https://example.com/openai\"")); + assert!(!toml.contains("https://example.com/openai/v1")); + } } diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 8924d9b2f..2c741b8a6 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -666,7 +666,11 @@ impl RequestForwarder { // 输出请求信息日志 let tag = adapter.name(); - log::info!("[{tag}] >>> 请求 URL: {url}"); + let request_model = filtered_body + .get("model") + .and_then(|v| v.as_str()) + .unwrap_or(""); + log::info!("[{tag}] >>> 请求 URL: {url} (model={request_model})"); if let Ok(body_str) = serde_json::to_string(&filtered_body) { log::debug!( "[{tag}] >>> 请求体内容 ({}字节): {}", diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index 04d4ec6ff..ac8b2c8e4 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -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,22 +52,52 @@ impl ClaudeAdapter { false } - /// 检测 OpenRouter 是否启用兼容模式 - fn is_openrouter_compat_enabled(&self, provider: &Provider) -> bool { - if !self.is_openrouter(provider) { - return false; + /// 获取 API 格式 + /// + /// 从 provider.meta.api_format 读取格式设置: + /// - "anthropic" (默认): Anthropic Messages API 格式,直接透传 + /// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换 + fn get_api_format(&self, 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 if api_format == "openai_chat" { + "openai_chat" + } else { + "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 if api_format == "openai_chat" { + "openai_chat" + } else { + "anthropic" + }; + } + + // 3) Backward compatibility: legacy openrouter_compat_mode (bool/number/string) let raw = provider.settings_config.get("openrouter_compat_mode"); - match raw { - Some(serde_json::Value::Bool(enabled)) => *enabled, + 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" } - // OpenRouter now supports Claude Code compatible API, default to passthrough _ => false, + }; + + if enabled { + "openai_chat" + } else { + "anthropic" } } @@ -218,15 +252,23 @@ impl ProviderAdapter for ClaudeAdapter { // 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。 // 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。 - let base = format!( + let mut base = format!( "{}/{}", base_url.trim_end_matches('/'), endpoint.trim_start_matches('/') ); - // 为 /v1/messages 端点添加 ?beta=true 参数 + // 去除重复的 /v1/v1(可能由 base_url 与 endpoint 都带版本导致) + while base.contains("/v1/v1") { + base = base.replace("/v1/v1", "/v1"); + } + + // 为 Claude 相关端点添加 ?beta=true 参数 // 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数 - if endpoint.contains("/v1/messages") && !endpoint.contains("?") { + // 注:openai_chat 模式下会转发到 /v1/chat/completions,此处也需要保持一致 + if (endpoint.contains("/v1/messages") || endpoint.contains("/v1/chat/completions")) + && !endpoint.contains('?') + { format!("{base}?beta=true") } else { base @@ -253,21 +295,19 @@ 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( &self, body: serde_json::Value, - provider: &Provider, + _provider: &Provider, ) -> Result { - super::transform::anthropic_to_openai(body, provider) + super::transform::anthropic_to_openai(body) } fn transform_response(&self, body: serde_json::Value) -> Result { @@ -278,6 +318,7 @@ impl ProviderAdapter for ClaudeAdapter { #[cfg(test)] mod tests { use super::*; + use crate::provider::ProviderMeta; use serde_json::json; fn create_provider(config: serde_json::Value) -> Provider { @@ -297,6 +338,23 @@ mod tests { } } + 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(); @@ -459,6 +517,7 @@ mod tests { 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" @@ -466,29 +525,96 @@ 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!({ - "env": { - "ANTHROPIC_BASE_URL": "https://openrouter.ai/api" - } - })); - assert!(!adapter.needs_transform(&openrouter_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)); - // OpenRouter provider with explicit compat mode enabled should transform - let openrouter_enabled = create_provider(json!({ + // Legacy settings_config.api_format: openai_chat should enable transform + let legacy_settings_api_format = 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(&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(&openrouter_enabled)); + assert!(adapter.needs_transform(&legacy_openrouter_bool)); - let openrouter_disabled = create_provider(json!({ + let legacy_openrouter_num = create_provider(json!({ "env": { - "ANTHROPIC_BASE_URL": "https://openrouter.ai/api" + "ANTHROPIC_BASE_URL": "https://api.example.com" }, - "openrouter_compat_mode": false + "openrouter_compat_mode": 1 })); - assert!(!adapter.needs_transform(&openrouter_disabled)); + 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)); + + // 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)); } } diff --git a/src-tauri/src/proxy/providers/transform.rs b/src-tauri/src/proxy/providers/transform.rs index 1efbb0956..414debcf8 100644 --- a/src-tauri/src/proxy/providers/transform.rs +++ b/src-tauri/src/proxy/providers/transform.rs @@ -3,77 +3,16 @@ //! 实现 Anthropic ↔ OpenAI 格式转换,用于 OpenRouter 支持 //! 参考: anthropic-proxy-rs -use crate::provider::Provider; use crate::proxy::error::ProxyError; use serde_json::{json, Value}; -/// 从 Provider 配置中获取模型映射 -fn get_model_from_provider(model: &str, provider: &Provider, body: &Value) -> String { - let env = provider.settings_config.get("env"); - let model_lower = model.to_lowercase(); - - // 检测 thinking 参数 - let has_thinking = body - .get("thinking") - .and_then(|v| v.as_object()) - .and_then(|o| o.get("type")) - .and_then(|t| t.as_str()) - == Some("enabled"); - - if let Some(env) = env { - // 如果启用 thinking,优先使用推理模型 - if has_thinking { - if let Some(m) = env - .get("ANTHROPIC_REASONING_MODEL") - .and_then(|v| v.as_str()) - { - log::debug!("[Transform] 使用推理模型: {m}"); - return m.to_string(); - } - } - - // 根据模型类型选择配置模型 - if model_lower.contains("haiku") { - if let Some(m) = env - .get("ANTHROPIC_DEFAULT_HAIKU_MODEL") - .and_then(|v| v.as_str()) - { - return m.to_string(); - } - } - if model_lower.contains("opus") { - if let Some(m) = env - .get("ANTHROPIC_DEFAULT_OPUS_MODEL") - .and_then(|v| v.as_str()) - { - return m.to_string(); - } - } - if model_lower.contains("sonnet") { - if let Some(m) = env - .get("ANTHROPIC_DEFAULT_SONNET_MODEL") - .and_then(|v| v.as_str()) - { - return m.to_string(); - } - } - // 默认使用 ANTHROPIC_MODEL - if let Some(m) = env.get("ANTHROPIC_MODEL").and_then(|v| v.as_str()) { - return m.to_string(); - } - } - - model.to_string() -} - /// Anthropic 请求 → OpenAI 请求 -pub fn anthropic_to_openai(body: Value, provider: &Provider) -> Result { +pub fn anthropic_to_openai(body: Value) -> Result { let mut result = json!({}); - // 模型映射:使用 Provider 配置中的模型(支持 thinking 参数) + // NOTE: 模型映射由上游统一处理(proxy::model_mapper),格式转换层只做结构转换。 if let Some(model) = body.get("model").and_then(|m| m.as_str()) { - let mapped_model = get_model_from_provider(model, provider, &body); - result["model"] = json!(mapped_model); + result["model"] = json!(model); } let mut messages = Vec::new(); @@ -381,45 +320,16 @@ pub fn openai_to_anthropic(body: Value) -> Result { mod tests { use super::*; - fn create_provider(env_config: Value) -> Provider { - Provider { - id: "test".to_string(), - name: "Test Provider".to_string(), - settings_config: json!({"env": env_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, - } - } - - fn create_openrouter_provider() -> Provider { - create_provider(json!({ - "ANTHROPIC_BASE_URL": "https://openrouter.ai/api", - "ANTHROPIC_MODEL": "anthropic/claude-sonnet-4.5", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "anthropic/claude-haiku-4.5", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "anthropic/claude-sonnet-4.5", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "anthropic/claude-opus-4.5" - })) - } - #[test] fn test_anthropic_to_openai_simple() { - let provider = create_openrouter_provider(); let input = json!({ "model": "claude-3-opus", "max_tokens": 1024, "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_openai(input, &provider).unwrap(); - // opus 模型映射到配置的 ANTHROPIC_DEFAULT_OPUS_MODEL - assert_eq!(result["model"], "anthropic/claude-opus-4.5"); + let result = anthropic_to_openai(input).unwrap(); + assert_eq!(result["model"], "claude-3-opus"); assert_eq!(result["max_tokens"], 1024); assert_eq!(result["messages"][0]["role"], "user"); assert_eq!(result["messages"][0]["content"], "Hello"); @@ -427,7 +337,6 @@ mod tests { #[test] fn test_anthropic_to_openai_with_system() { - let provider = create_openrouter_provider(); let input = json!({ "model": "claude-3-sonnet", "max_tokens": 1024, @@ -435,7 +344,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_openai(input, &provider).unwrap(); + let result = anthropic_to_openai(input).unwrap(); assert_eq!(result["messages"][0]["role"], "system"); assert_eq!( result["messages"][0]["content"], @@ -446,7 +355,6 @@ mod tests { #[test] fn test_anthropic_to_openai_with_tools() { - let provider = create_openrouter_provider(); let input = json!({ "model": "claude-3-opus", "max_tokens": 1024, @@ -458,14 +366,13 @@ mod tests { }] }); - let result = anthropic_to_openai(input, &provider).unwrap(); + let result = anthropic_to_openai(input).unwrap(); assert_eq!(result["tools"][0]["type"], "function"); assert_eq!(result["tools"][0]["function"]["name"], "get_weather"); } #[test] fn test_anthropic_to_openai_tool_use() { - let provider = create_openrouter_provider(); let input = json!({ "model": "claude-3-opus", "max_tokens": 1024, @@ -478,7 +385,7 @@ mod tests { }] }); - let result = anthropic_to_openai(input, &provider).unwrap(); + let result = anthropic_to_openai(input).unwrap(); let msg = &result["messages"][0]; assert_eq!(msg["role"], "assistant"); assert!(msg.get("tool_calls").is_some()); @@ -487,7 +394,6 @@ mod tests { #[test] fn test_anthropic_to_openai_tool_result() { - let provider = create_openrouter_provider(); let input = json!({ "model": "claude-3-opus", "max_tokens": 1024, @@ -499,7 +405,7 @@ mod tests { }] }); - let result = anthropic_to_openai(input, &provider).unwrap(); + let result = anthropic_to_openai(input).unwrap(); let msg = &result["messages"][0]; assert_eq!(msg["role"], "tool"); assert_eq!(msg["tool_call_id"], "call_123"); @@ -563,78 +469,15 @@ mod tests { } #[test] - fn test_model_mapping_from_provider() { - let provider = create_openrouter_provider(); - let body = json!({"model": "test"}); - - // sonnet 模型 - assert_eq!( - get_model_from_provider("claude-sonnet-4-5-20250929", &provider, &body), - "anthropic/claude-sonnet-4.5" - ); - - // haiku 模型 - assert_eq!( - get_model_from_provider("claude-haiku-4-5-20250929", &provider, &body), - "anthropic/claude-haiku-4.5" - ); - - // opus 模型 - assert_eq!( - get_model_from_provider("claude-opus-4-5", &provider, &body), - "anthropic/claude-opus-4.5" - ); - } - - #[test] - fn test_anthropic_to_openai_model_mapping() { - let provider = create_openrouter_provider(); + fn test_model_passthrough() { + // 格式转换层只做结构转换,模型映射由上游 proxy::model_mapper 处理 let input = json!({ - "model": "claude-sonnet-4-5-20250929", + "model": "gpt-4o", "max_tokens": 1024, "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_openai(input, &provider).unwrap(); - assert_eq!(result["model"], "anthropic/claude-sonnet-4.5"); - } - - #[test] - fn test_thinking_parameter_detection() { - let mut provider = create_openrouter_provider(); - // 添加推理模型配置 - if let Some(env) = provider.settings_config.get_mut("env") { - env["ANTHROPIC_REASONING_MODEL"] = json!("anthropic/claude-sonnet-4.5:extended"); - } - - let input = json!({ - "model": "claude-sonnet-4-5", - "max_tokens": 1024, - "thinking": {"type": "enabled"}, - "messages": [{"role": "user", "content": "Solve this problem"}] - }); - - let result = anthropic_to_openai(input, &provider).unwrap(); - // 应该使用推理模型 - assert_eq!(result["model"], "anthropic/claude-sonnet-4.5:extended"); - } - - #[test] - fn test_thinking_parameter_disabled() { - let mut provider = create_openrouter_provider(); - if let Some(env) = provider.settings_config.get_mut("env") { - env["ANTHROPIC_REASONING_MODEL"] = json!("anthropic/claude-sonnet-4.5:extended"); - } - - let input = json!({ - "model": "claude-sonnet-4-5", - "max_tokens": 1024, - "thinking": {"type": "disabled"}, - "messages": [{"role": "user", "content": "Hello"}] - }); - - let result = anthropic_to_openai(input, &provider).unwrap(); - // 应该使用普通模型 - assert_eq!(result["model"], "anthropic/claude-sonnet-4.5"); + let result = anthropic_to_openai(input).unwrap(); + assert_eq!(result["model"], "gpt-4o"); } } diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index 229eebdfb..a226e93ae 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -1,4 +1,4 @@ -use super::provider::ProviderService; +use super::provider::{sanitize_claude_settings_for_live, ProviderService}; use crate::app_config::{AppType, MultiAppConfig}; use crate::error::AppError; use crate::provider::Provider; @@ -181,7 +181,8 @@ impl ConfigService { fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; } - write_json_file(&settings_path, &provider.settings_config)?; + let settings = sanitize_claude_settings_for_live(&provider.settings_config); + write_json_file(&settings_path, &settings)?; let live_after = read_json_file::(&settings_path)?; if let Some(manager) = config.get_manager_mut(&AppType::Claude) { diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index b7d13a418..c62ca1b54 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -28,6 +28,18 @@ use super::gemini_auth::{ }; use super::normalize_claude_models_in_value; +pub(crate) fn sanitize_claude_settings_for_live(settings: &Value) -> Value { + let mut v = settings.clone(); + if let Some(obj) = v.as_object_mut() { + // Internal-only fields - never write to Claude Code settings.json + obj.remove("api_format"); + obj.remove("apiFormat"); + obj.remove("openrouter_compat_mode"); + obj.remove("openrouterCompatMode"); + } + v +} + /// Live configuration snapshot for backup/restore #[derive(Clone)] #[allow(dead_code)] @@ -163,7 +175,8 @@ fn write_live_snapshot_internal( match app_type { AppType::Claude => { let path = get_claude_settings_path(); - write_json_file(&path, config_to_write)?; + let settings = sanitize_claude_settings_for_live(config_to_write); + write_json_file(&path, &settings)?; } AppType::Codex => { let obj = config_to_write.as_object().ok_or_else(|| { diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 9e7bebcfb..c6c0f1041 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -29,6 +29,7 @@ pub use live::{ }; // Internal re-exports (pub(crate)) +pub(crate) use live::sanitize_claude_settings_for_live; pub(crate) use live::{write_live_snapshot, write_live_snapshot_with_merge}; // Internal re-exports diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 4ac68d222..08e42692c 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -1699,7 +1699,8 @@ impl ProxyService { fn write_claude_live(&self, config: &Value) -> Result<(), String> { let path = get_claude_settings_path(); - write_json_file(&path, config).map_err(|e| format!("写入 Claude 配置失败: {e}")) + let settings = crate::services::provider::sanitize_claude_settings_for_live(config); + write_json_file(&path, &settings).map_err(|e| format!("写入 Claude 配置失败: {e}")) } fn read_codex_live(&self) -> Result { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index cb5feaa3f..504c10fba 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CC Switch", - "version": "3.10.2", + "version": "3.10.3", "identifier": "com.ccswitch.desktop", "build": { "frontendDist": "../dist", diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index d16dd9669..75181972f 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -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 && ( -
-
- - {t("providerForm.openrouterCompatMode", { - defaultValue: "OpenRouter 兼容模式", - })} - -

- {t("providerForm.openrouterCompatModeHint", { - defaultValue: - "使用 OpenAI Chat Completions 接口并转换为 Anthropic SSE。", - })} -

-
- + {/* API 格式选择(仅非官方供应商显示) */} + {shouldShowModelSelector && ( +
+ + {t("providerForm.apiFormat", { defaultValue: "API 格式" })} + + +

+ {t("providerForm.apiFormatHint", { + defaultValue: "选择供应商 API 的输入格式", + })} +

)} diff --git a/src/components/providers/forms/OpenCodeFormFields.tsx b/src/components/providers/forms/OpenCodeFormFields.tsx index 49f08e25b..56f7415c2 100644 --- a/src/components/providers/forms/OpenCodeFormFields.tsx +++ b/src/components/providers/forms/OpenCodeFormFields.tsx @@ -139,6 +139,8 @@ interface OpenCodeFormFieldsProps { category?: ProviderCategory; shouldShowApiKeyLink: boolean; websiteUrl: string; + isPartner?: boolean; + partnerPromotionKey?: string; // Base URL baseUrl: string; @@ -161,6 +163,8 @@ export function OpenCodeFormFields({ category, shouldShowApiKeyLink, websiteUrl, + isPartner, + partnerPromotionKey, baseUrl, onBaseUrlChange, models, @@ -376,6 +380,8 @@ export function OpenCodeFormFields({ category={category} shouldShowLink={shouldShowApiKeyLink} websiteUrl={websiteUrl} + isPartner={isPartner} + partnerPromotionKey={partnerPromotionKey} /> {/* Base URL */} diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 7469e0bff..0925321cf 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -15,6 +15,7 @@ import type { ProviderTestConfig, ProviderProxyConfig, CommonConfigEnabledByApp, + ClaudeApiFormat, } from "@/types"; import { providerPresets, @@ -285,8 +286,6 @@ export function ProviderForm({ mode: "onSubmit", }); - const settingsConfigValue = form.getValues("settingsConfig"); - // 使用 API Key hook const { apiKey, @@ -325,52 +324,16 @@ 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; - } - 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]); + // Claude API Format state - stored in meta, not settingsConfig + // Read initial value from meta.apiFormat, default to "anthropic" + const [localApiFormat, setLocalApiFormat] = useState(() => { + if (appId !== "claude") return "anthropic"; + return initialData?.meta?.apiFormat ?? "anthropic"; + }); - 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"; - } - } catch { - // ignore - } - return false; // OpenRouter now supports Claude Code compatible API, no need for transform - }, [isOpenRouterProvider, settingsConfigValue]); - - const handleOpenRouterCompatChange = useCallback( - (enabled: boolean) => { - try { - const currentConfig = JSON.parse( - form.getValues("settingsConfig") || "{}", - ); - currentConfig.openrouter_compat_mode = enabled; - form.setValue("settingsConfig", JSON.stringify(currentConfig, null, 2)); - } catch { - // ignore - } - }, - [form], - ); + const handleApiFormatChange = useCallback((format: ClaudeApiFormat) => { + setLocalApiFormat(format); + }, []); // 使用 Codex 配置 hook (仅 Codex 模式) const { @@ -1212,6 +1175,11 @@ export function ProviderForm({ ...(shouldPersistCommonConfigEnabledByApp ? { commonConfigEnabledByApp } : {}), + // Claude API 格式(仅非官方 Claude 供应商使用) + apiFormat: + appId === "claude" && category !== "official" + ? localApiFormat + : undefined, }; onSubmit(payload); @@ -1279,6 +1247,20 @@ export function ProviderForm({ formWebsiteUrl: form.watch("websiteUrl") || "", }); + // 使用 API Key 链接 hook (OpenCode) + const { + shouldShowApiKeyLink: shouldShowOpencodeApiKeyLink, + websiteUrl: opencodeWebsiteUrl, + isPartner: isOpencodePartner, + partnerPromotionKey: opencodePartnerPromotionKey, + } = useApiKeyLink({ + appId: "opencode", + category, + selectedPresetId, + presetEntries, + formWebsiteUrl: form.watch("websiteUrl") || "", + }); + // 使用端点测速候选 hook const speedTestEndpoints = useSpeedTestEndpoints({ appId, @@ -1408,6 +1390,14 @@ export function ProviderForm({ preset.templateValues, ); + // Sync preset's apiFormat to local state (for Claude providers) + if (preset.apiFormat) { + setLocalApiFormat(preset.apiFormat); + } else { + // Reset to default if preset doesn't specify apiFormat + setLocalApiFormat("anthropic"); + } + form.reset({ name: preset.name, websiteUrl: preset.websiteUrl ?? "", @@ -1531,9 +1521,8 @@ export function ProviderForm({ defaultOpusModel={defaultOpusModel} onModelChange={handleModelChange} speedTestEndpoints={speedTestEndpoints} - showOpenRouterCompatToggle={false} - openRouterCompatEnabled={openRouterCompatEnabled} - onOpenRouterCompatChange={handleOpenRouterCompatChange} + apiFormat={localApiFormat} + onApiFormatChange={handleApiFormatChange} /> )} @@ -1603,8 +1592,10 @@ export function ProviderForm({ apiKey={opencodeApiKey} onApiKeyChange={handleOpencodeApiKeyChange} category={category} - shouldShowApiKeyLink={false} - websiteUrl="" + shouldShowApiKeyLink={shouldShowOpencodeApiKeyLink} + websiteUrl={opencodeWebsiteUrl} + isPartner={isOpencodePartner} + partnerPromotionKey={opencodePartnerPromotionKey} baseUrl={opencodeBaseUrl} onBaseUrlChange={handleOpencodeBaseUrlChange} models={opencodeModels} diff --git a/src/components/providers/forms/hooks/useApiKeyLink.ts b/src/components/providers/forms/hooks/useApiKeyLink.ts index e02689246..92d3b3a70 100644 --- a/src/components/providers/forms/hooks/useApiKeyLink.ts +++ b/src/components/providers/forms/hooks/useApiKeyLink.ts @@ -4,10 +4,15 @@ import type { ProviderCategory } from "@/types"; import type { ProviderPreset } from "@/config/claudeProviderPresets"; import type { CodexProviderPreset } from "@/config/codexProviderPresets"; import type { GeminiProviderPreset } from "@/config/geminiProviderPresets"; +import type { OpenCodeProviderPreset } from "@/config/opencodeProviderPresets"; type PresetEntry = { id: string; - preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset; + preset: + | ProviderPreset + | CodexProviderPreset + | GeminiProviderPreset + | OpenCodeProviderPreset; }; interface UseApiKeyLinkProps { @@ -74,7 +79,10 @@ export function useApiKeyLink({ return { shouldShowApiKeyLink: - appId === "claude" || appId === "codex" || appId === "gemini" + appId === "claude" || + appId === "codex" || + appId === "gemini" || + appId === "opencode" ? shouldShowApiKeyLink : false, websiteUrl: getWebsiteUrl, diff --git a/src/components/providers/forms/hooks/useProviderCategory.ts b/src/components/providers/forms/hooks/useProviderCategory.ts index 30808cc41..f28d8af0f 100644 --- a/src/components/providers/forms/hooks/useProviderCategory.ts +++ b/src/components/providers/forms/hooks/useProviderCategory.ts @@ -4,6 +4,7 @@ import type { AppId } from "@/lib/api"; import { providerPresets } from "@/config/claudeProviderPresets"; import { codexProviderPresets } from "@/config/codexProviderPresets"; import { geminiProviderPresets } from "@/config/geminiProviderPresets"; +import { opencodeProviderPresets } from "@/config/opencodeProviderPresets"; interface UseProviderCategoryProps { appId: AppId; @@ -42,7 +43,9 @@ export function useProviderCategory({ if (!selectedPresetId) return; // 从预设 ID 提取索引 - const match = selectedPresetId.match(/^(claude|codex|gemini)-(\d+)$/); + const match = selectedPresetId.match( + /^(claude|codex|gemini|opencode)-(\d+)$/, + ); if (!match) return; const [, type, indexStr] = match; @@ -67,6 +70,11 @@ export function useProviderCategory({ if (preset) { setCategory(preset.category || undefined); } + } else if (type === "opencode" && appId === "opencode") { + const preset = opencodeProviderPresets[index]; + if (preset) { + setCategory(preset.category || undefined); + } } }, [appId, selectedPresetId, isEditMode, initialCategory]); diff --git a/src/components/settings/AboutSection.tsx b/src/components/settings/AboutSection.tsx index f301e2fbe..dab867d92 100644 --- a/src/components/settings/AboutSection.tsx +++ b/src/components/settings/AboutSection.tsx @@ -37,7 +37,9 @@ curl -fsSL https://claude.ai/install.sh | bash # Codex npm i -g @openai/codex@latest # Gemini CLI -npm i -g @google/gemini-cli@latest`; +npm i -g @google/gemini-cli@latest +# OpenCode +curl -fsSL https://opencode.ai/install | bash`; export function AboutSection({ isPortable }: AboutSectionProps) { // ... (use hooks as before) ... @@ -315,10 +317,14 @@ export function AboutSection({ isPortable }: AboutSectionProps) { {isLoadingTools ? t("common.refreshing") : t("common.refresh")}
-
- {["claude", "codex", "gemini"].map((toolName, index) => { +
+ {["claude", "codex", "gemini", "opencode"].map((toolName, index) => { const tool = toolVersions.find((item) => item.name === toolName); - const displayName = tool?.name ?? toolName; + // Special case for OpenCode (capital C), others use capitalize + const displayName = + toolName === "opencode" + ? "OpenCode" + : toolName.charAt(0).toUpperCase() + toolName.slice(1); const title = tool?.version || tool?.error || t("common.unknown"); return ( @@ -333,9 +339,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
- - {displayName} - + {displayName}
{isLoadingTools ? ( diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index bb6abc479..cb725c74c 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -43,6 +43,11 @@ export interface ProviderPreset { // 图标配置 icon?: string; // 图标名称 iconColor?: string; // 图标颜色 + + // Claude API 格式(仅 Claude 供应商使用) + // - "anthropic" (默认): Anthropic Messages API 格式,直接透传 + // - "openai_chat": OpenAI Chat Completions 格式,需要格式转换 + apiFormat?: "anthropic" | "openai_chat"; } export const providerPresets: ProviderPreset[] = [ @@ -461,6 +466,26 @@ export const providerPresets: ProviderPreset[] = [ icon: "rc", iconColor: "#E96B2C", }, + { + name: "AICodeMirror", + websiteUrl: "https://www.aicodemirror.com", + apiKeyUrl: "https://www.aicodemirror.com/register?invitecode=9915W3", + settingsConfig: { + env: { + ANTHROPIC_BASE_URL: "https://api.aicodemirror.com/api/claudecode", + ANTHROPIC_AUTH_TOKEN: "", + }, + }, + endpointCandidates: [ + "https://api.aicodemirror.com/api/claudecode", + "https://api.claudecode.net.cn/api/claudecode", + ], + category: "third_party", + isPartner: true, // 合作伙伴 + partnerPromotionKey: "aicodemirror", // 促销信息 i18n key + icon: "aicodemirror", + iconColor: "#000000", + }, { name: "OpenRouter", websiteUrl: "https://openrouter.ai", @@ -479,6 +504,25 @@ export const providerPresets: ProviderPreset[] = [ icon: "openrouter", iconColor: "#6566F1", }, + { + name: "Nvidia", + websiteUrl: "https://build.nvidia.com", + apiKeyUrl: "https://build.nvidia.com/settings/api-keys", + settingsConfig: { + env: { + ANTHROPIC_BASE_URL: "https://integrate.api.nvidia.com", + ANTHROPIC_AUTH_TOKEN: "", + ANTHROPIC_MODEL: "moonshotai/kimi-k2.5", + ANTHROPIC_DEFAULT_HAIKU_MODEL: "moonshotai/kimi-k2.5", + ANTHROPIC_DEFAULT_SONNET_MODEL: "moonshotai/kimi-k2.5", + ANTHROPIC_DEFAULT_OPUS_MODEL: "moonshotai/kimi-k2.5", + }, + }, + category: "aggregator", + apiFormat: "openai_chat", + icon: "nvidia", + iconColor: "#000000", + }, { name: "Xiaomi MiMo", websiteUrl: "https://platform.xiaomimimo.com", diff --git a/src/config/codexProviderPresets.ts b/src/config/codexProviderPresets.ts index 6e46dd03a..d1c897965 100644 --- a/src/config/codexProviderPresets.ts +++ b/src/config/codexProviderPresets.ts @@ -208,6 +208,25 @@ requires_openai_auth = true`, icon: "rc", iconColor: "#E96B2C", }, + { + name: "AICodeMirror", + websiteUrl: "https://www.aicodemirror.com", + apiKeyUrl: "https://www.aicodemirror.com/register?invitecode=9915W3", + auth: generateThirdPartyAuth(""), + config: generateThirdPartyConfig( + "aicodemirror", + "https://api.aicodemirror.com/api/codex/backend-api/codex", + "gpt-5.2", + ), + endpointCandidates: [ + "https://api.aicodemirror.com/api/codex/backend-api/codex", + "https://api.claudecode.net.cn/api/codex/backend-api/codex", + ], + isPartner: true, + partnerPromotionKey: "aicodemirror", + icon: "aicodemirror", + iconColor: "#000000", + }, { name: "OpenRouter", websiteUrl: "https://openrouter.ai", diff --git a/src/config/geminiProviderPresets.ts b/src/config/geminiProviderPresets.ts index f868acaa3..ffdc70745 100644 --- a/src/config/geminiProviderPresets.ts +++ b/src/config/geminiProviderPresets.ts @@ -116,6 +116,29 @@ export const geminiProviderPresets: GeminiProviderPreset[] = [ icon: "aigocode", iconColor: "#5B7FFF", }, + { + name: "AICodeMirror", + websiteUrl: "https://www.aicodemirror.com", + apiKeyUrl: "https://www.aicodemirror.com/register?invitecode=9915W3", + settingsConfig: { + env: { + GOOGLE_GEMINI_BASE_URL: "https://api.aicodemirror.com/api/gemini", + GEMINI_MODEL: "gemini-3-pro", + }, + }, + baseURL: "https://api.aicodemirror.com/api/gemini", + model: "gemini-3-pro", + description: "AICodeMirror", + category: "third_party", + isPartner: true, + partnerPromotionKey: "aicodemirror", + endpointCandidates: [ + "https://api.aicodemirror.com/api/gemini", + "https://api.claudecode.net.cn/api/gemini", + ], + icon: "aicodemirror", + iconColor: "#000000", + }, { name: "OpenRouter", websiteUrl: "https://openrouter.ai", diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts index 7dd3d6337..a110c227e 100644 --- a/src/config/opencodeProviderPresets.ts +++ b/src/config/opencodeProviderPresets.ts @@ -558,6 +558,32 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, }, }, + { + name: "Nvidia", + websiteUrl: "https://build.nvidia.com", + apiKeyUrl: "https://build.nvidia.com/settings/api-keys", + settingsConfig: { + npm: "@ai-sdk/openai-compatible", + name: "Nvidia", + options: { + baseURL: "https://integrate.api.nvidia.com/v1", + apiKey: "", + }, + models: { + "moonshotai/kimi-k2.5": { name: "Kimi K2.5" }, + }, + }, + category: "aggregator", + icon: "nvidia", + iconColor: "#000000", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + }, // ========== 第三方合作伙伴 ========== { @@ -675,6 +701,35 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ }, }, }, + { + name: "AICodeMirror", + websiteUrl: "https://www.aicodemirror.com", + apiKeyUrl: "https://www.aicodemirror.com/register?invitecode=9915W3", + settingsConfig: { + npm: "@ai-sdk/anthropic", + name: "AICodeMirror", + options: { + baseURL: "https://api.aicodemirror.com/api/claudecode", + apiKey: "", + }, + models: { + "claude-sonnet-4.5": { name: "Claude Sonnet 4.5" }, + "claude-opus-4.5": { name: "Claude Opus 4.5" }, + }, + }, + category: "third_party", + isPartner: true, + partnerPromotionKey: "aicodemirror", + icon: "aicodemirror", + iconColor: "#000000", + templateValues: { + apiKey: { + label: "API Key", + placeholder: "", + editorValue: "", + }, + }, + }, // ========== 自定义模板 ========== { diff --git a/src/hooks/useProviderActions.ts b/src/hooks/useProviderActions.ts index cc46cca89..ede55f8a2 100644 --- a/src/hooks/useProviderActions.ts +++ b/src/hooks/useProviderActions.ts @@ -84,11 +84,43 @@ export function useProviderActions(activeApp: AppId) { try { await switchProviderMutation.mutateAsync(provider.id); await syncClaudePlugin(provider); + + // 根据供应商类型显示不同的成功提示 + if ( + activeApp === "claude" && + provider.category !== "official" && + provider.meta?.apiFormat === "openai_chat" + ) { + // OpenAI Chat 格式供应商:显示代理提示 + toast.info( + t("notifications.openAIChatFormatHint", { + defaultValue: + "此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用", + }), + { + duration: 5000, + closeButton: true, + }, + ); + } else { + // 普通供应商:显示切换成功 + // OpenCode: show "added to config" message instead of "switched" + const messageKey = + activeApp === "opencode" + ? "notifications.addToConfigSuccess" + : "notifications.switchSuccess"; + const defaultMessage = + activeApp === "opencode" ? "已添加到配置" : "切换成功!"; + + toast.success(t(messageKey, { defaultValue: defaultMessage }), { + closeButton: true, + }); + } } catch { - // 错误提示由 mutation 与同步函数处理 + // 错误提示由 mutation 处理 } }, - [switchProviderMutation, syncClaudePlugin], + [switchProviderMutation, syncClaudePlugin, activeApp, t], ); // 删除供应商 diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 3dcfdb3ac..f4efa2c42 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -163,7 +163,8 @@ "deleteSuccess": "Provider deleted", "deleteFailed": "Failed to delete provider: {{error}}", "settingsSaved": "Settings saved", - "settingsSaveFailed": "Failed to save settings: {{error}}" + "settingsSaveFailed": "Failed to save settings: {{error}}", + "openAIChatFormatHint": "This provider uses OpenAI Chat format and requires the proxy service to be enabled" }, "confirm": { "deleteProvider": "Delete Provider", @@ -359,7 +360,7 @@ "viewReleaseNotes": "View release notes for this version", "viewCurrentReleaseNotes": "View current version release notes", "oneClickInstall": "One-click Install", - "oneClickInstallHint": "Install Claude Code / Codex / Gemini CLI", + "oneClickInstallHint": "Install Claude Code / Codex / Gemini CLI / OpenCode", "localEnvCheck": "Local environment check", "installCommandsCopied": "Install commands copied", "installCommandsCopyFailed": "Copy failed, please copy manually.", @@ -455,7 +456,8 @@ "dmxapi": "Claude Code exclusive model 66% OFF now!", "cubence": "Cubence is an official partner of CC Switch. Register using this link and enter \"CCSWITCH\" promo code during recharge to get 10% off every top-up", "aigocode": "AIGoCode is an official partner of CC Switch. Register using this link and get 10% bonus credit on your first top-up!", - "rightcode": "RightCode is an official partner of CC Switch. Register using this link and get 5% bonus credit on every top-up!" + "rightcode": "RightCode is an official partner of CC Switch. Register using this link and get 5% bonus credit on every top-up!", + "aicodemirror": "AICodeMirror is an official partner of CC Switch. Register using this link to get 20% off!" }, "parameterConfig": "Parameter Config - {{name}} *", "mainModel": "Main Model (optional)", @@ -480,8 +482,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 proxy)", "anthropicDefaultHaikuModel": "Default Haiku Model", "anthropicDefaultSonnetModel": "Default Sonnet Model", "anthropicDefaultOpusModel": "Default Opus Model", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 0dab7941f..356a74cdb 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -163,7 +163,8 @@ "deleteSuccess": "プロバイダーを削除しました", "deleteFailed": "プロバイダーの削除に失敗しました: {{error}}", "settingsSaved": "設定を保存しました", - "settingsSaveFailed": "設定の保存に失敗しました: {{error}}" + "settingsSaveFailed": "設定の保存に失敗しました: {{error}}", + "openAIChatFormatHint": "このプロバイダーは OpenAI Chat フォーマットを使用しており、プロキシサービスの有効化が必要です" }, "confirm": { "deleteProvider": "プロバイダーを削除", @@ -359,7 +360,7 @@ "viewReleaseNotes": "このバージョンのリリースノートを見る", "viewCurrentReleaseNotes": "現在のバージョンのリリースノートを見る", "oneClickInstall": "ワンクリックインストール", - "oneClickInstallHint": "Claude Code / Codex / Gemini CLI をインストール", + "oneClickInstallHint": "Claude Code / Codex / Gemini CLI / OpenCode をインストール", "localEnvCheck": "ローカル環境チェック", "installCommandsCopied": "インストールコマンドをコピーしました", "installCommandsCopyFailed": "コピーに失敗しました。手動でコピーしてください。", @@ -455,7 +456,8 @@ "dmxapi": "Claude Code 専用モデル 66% OFF 実施中!", "cubence": "Cubence は CC Switch の公式パートナーです。登録後チャージ時に \"CCSWITCH\" を入力すると、毎回 10% オフ", "aigocode": "AIGoCode は CC Switch の公式パートナーです。このリンクから登録すると、初回チャージ時に 10% のボーナスクレジットがもらえます!", - "rightcode": "RightCode は CC Switch の公式パートナーです。このリンクから登録すると、毎回のチャージに 5% のボーナスクレジットがもらえます!" + "rightcode": "RightCode は CC Switch の公式パートナーです。このリンクから登録すると、毎回のチャージに 5% のボーナスクレジットがもらえます!", + "aicodemirror": "AICodeMirror は CC Switch の公式パートナーです。このリンクから登録すると20%オフ!" }, "parameterConfig": "パラメーター設定 - {{name}} *", "mainModel": "メインモデル(任意)", @@ -480,8 +482,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 モデル", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 1a9782ca3..845d3d99a 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -163,7 +163,8 @@ "deleteSuccess": "供应商已删除", "deleteFailed": "删除供应商失败:{{error}}", "settingsSaved": "设置已保存", - "settingsSaveFailed": "保存设置失败:{{error}}" + "settingsSaveFailed": "保存设置失败:{{error}}", + "openAIChatFormatHint": "此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用" }, "confirm": { "deleteProvider": "删除供应商", @@ -359,7 +360,7 @@ "viewReleaseNotes": "查看该版本更新日志", "viewCurrentReleaseNotes": "查看当前版本更新日志", "oneClickInstall": "一键安装", - "oneClickInstallHint": "安装 Claude Code / Codex / Gemini CLI", + "oneClickInstallHint": "安装 Claude Code / Codex / Gemini CLI / OpenCode", "localEnvCheck": "本地环境检查", "installCommandsCopied": "安装命令已复制", "installCommandsCopyFailed": "复制失败,请手动复制。", @@ -455,7 +456,8 @@ "dmxapi": "Claude Code 专属模型 3.4 折优惠进行中!", "cubence": "Cubence 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"CCSWITCH\" 优惠码,每次充值均可享受9折优惠", "aigocode": "AIGoCode 是 CC Switch 的官方合作伙伴,使用此链接注册首次充值时可以获得10%额度奖励!", - "rightcode": "RightCode 是 CC Switch 的官方合作伙伴,使用此链接注册每次充值均可赠送5%额外额度!" + "rightcode": "RightCode 是 CC Switch 的官方合作伙伴,使用此链接注册每次充值均可赠送5%额外额度!", + "aicodemirror": "AICodeMirror 是 CC Switch 的官方合作伙伴,使用此链接注册可享受8折优惠!" }, "parameterConfig": "参数配置 - {{name}} *", "mainModel": "主模型 (可选)", @@ -480,8 +482,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 默认模型", diff --git a/src/icons/extracted/aicodemirror.svg b/src/icons/extracted/aicodemirror.svg new file mode 100644 index 000000000..d4388632a --- /dev/null +++ b/src/icons/extracted/aicodemirror.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/src/icons/extracted/index.ts b/src/icons/extracted/index.ts index 2e451d36f..a1edddcb9 100644 --- a/src/icons/extracted/index.ts +++ b/src/icons/extracted/index.ts @@ -2,6 +2,7 @@ // Do not edit manually export const icons: Record = { + aicodemirror: `AICodeMirror`, aigocode: `AiGoCode`, alibaba: `Alibaba`, anthropic: `Anthropic`, @@ -58,6 +59,7 @@ export const icons: Record = { siliconflow: `SiliconCloud`, catcoder: `KwaiKAT`, mcp: `ModelContextProtocol`, + nvidia: `Nvidia`, }; export const iconList = Object.keys(icons); diff --git a/src/icons/extracted/metadata.ts b/src/icons/extracted/metadata.ts index a8f678da9..4a79f2201 100644 --- a/src/icons/extracted/metadata.ts +++ b/src/icons/extracted/metadata.ts @@ -359,6 +359,13 @@ export const iconMetadata: Record = { keywords: ["xiaomimimo", "xiaomi", "mimo"], defaultColor: "#000000", }, + nvidia: { + name: "nvidia", + displayName: "NVIDIA", + category: "ai-provider", + keywords: ["nvidia", "nim", "gpu"], + defaultColor: "#74B71B", + }, }; export function getIconMetadata(name: string): IconMetadata | undefined { diff --git a/src/icons/extracted/nvidia.svg b/src/icons/extracted/nvidia.svg new file mode 100644 index 000000000..a9683c2e0 --- /dev/null +++ b/src/icons/extracted/nvidia.svg @@ -0,0 +1 @@ +Nvidia \ No newline at end of file diff --git a/src/lib/query/mutations.ts b/src/lib/query/mutations.ts index 0aa84356d..cc664a314 100644 --- a/src/lib/query/mutations.ts +++ b/src/lib/query/mutations.ts @@ -61,10 +61,11 @@ export const useAddProviderMutation = (appId: AppId) => { ); }, onError: (error: Error) => { + const detail = extractErrorMessage(error) || t("common.unknown"); toast.error( t("notifications.addFailed", { defaultValue: "添加供应商失败: {{error}}", - error: error.message, + error: detail, }), ); }, @@ -92,10 +93,11 @@ export const useUpdateProviderMutation = (appId: AppId) => { ); }, onError: (error: Error) => { + const detail = extractErrorMessage(error) || t("common.unknown"); toast.error( t("notifications.updateFailed", { defaultValue: "更新供应商失败: {{error}}", - error: error.message, + error: detail, }), ); }, @@ -133,10 +135,11 @@ export const useDeleteProviderMutation = (appId: AppId) => { ); }, onError: (error: Error) => { + const detail = extractErrorMessage(error) || t("common.unknown"); toast.error( t("notifications.deleteFailed", { defaultValue: "删除供应商失败: {{error}}", - error: error.message, + error: detail, }), ); }, @@ -171,17 +174,8 @@ export const useSwitchProviderMutation = (appId: AppId) => { ); } - // OpenCode: show "added to config" message instead of "switched" - const messageKey = - appId === "opencode" - ? "notifications.addToConfigSuccess" - : "notifications.switchSuccess"; - const defaultMessage = - appId === "opencode" ? "已添加到配置" : "切换供应商成功"; - - toast.success(t(messageKey, { defaultValue: defaultMessage }), { - closeButton: true, - }); + // Note: Success toast is handled by useProviderActions.switchProvider + // to allow customization based on provider properties (e.g., apiFormat) }, onError: (error: Error) => { const detail = extractErrorMessage(error) || t("common.unknown"); diff --git a/src/types.ts b/src/types.ts index 210060888..6943ee22f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -149,11 +149,20 @@ export interface ProviderMeta { commonConfigEnabled?: boolean; // 按应用记录通用配置启用状态(优先于 commonConfigEnabled) commonConfigEnabledByApp?: CommonConfigEnabledByApp; + // Claude API 格式(仅 Claude 供应商使用) + // - "anthropic": 原生 Anthropic Messages API 格式,直接透传 + // - "openai_chat": OpenAI Chat Completions 格式,需要格式转换 + apiFormat?: "anthropic" | "openai_chat"; } // 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;