From 8ccfbd36d6cbfe6093cbce7c651b6bb28c7dda0f Mon Sep 17 00:00:00 2001 From: Zhou Mengze Date: Tue, 17 Mar 2026 23:57:58 +0800 Subject: [PATCH] feat(copilot): add GitHub Copilot reverse proxy support (#930) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(toolsearch): replace binary patch with ENABLE_TOOL_SEARCH env var toggle - Remove toolsearch_patch.rs binary patching mechanism (~590 lines) - Delete `toolsearch_patch.rs` and `commands/toolsearch.rs` - Remove auto-patch startup logic and command registration from lib.rs - Remove `tool_search_bypass` field from settings.rs - Remove frontend settings ToggleRow, useSettings hook sync logic, and API methods - Clean up zh/en/ja i18n keys (notifications + settings) - Add ENABLE_TOOL_SEARCH toggle to Claude provider form - Add checkbox in CommonConfigEditor.tsx (alongside teammates toggle) - When enabled, writes `"env": { "ENABLE_TOOL_SEARCH": "true" }` - When disabled, removes the key; takes effect on provider switch - Add zh/en/ja i18n key: `claudeConfig.enableToolSearch` Claude Code 2.1.76+ natively supports this env var, eliminating the need for binary patching. * feat(claude): add effortLevel high toggle to provider form - Add "high-effort thinking" checkbox to Claude provider config form - When checked, writes `"effortLevel": "high"`; when unchecked, removes the field - Add zh/en/ja i18n translations * refactor(claude): remove deprecated alwaysThinking toggle - Claude Code now enables extended thinking by default; alwaysThinkingEnabled is a no-op - Thinking control is now handled via effortLevel (added in prior commit) - Remove state, switch case, and checkbox UI from CommonConfigEditor - Clean up alwaysThinking i18n keys across zh/en/ja locales * feat(opencode): add setCacheKey: true to all provider presets - Add setCacheKey: true to options in all 33 regular presets - Add setCacheKey: true to OPENCODE_DEFAULT_CONFIG for custom providers - Exclude 2 OMO presets (Oh My OpenCode / Slim) which have their own config mechanism Closes #1523 * fix(codex): resolve 1M context window toggle causing MCP editor flicker - Add localValueRef to short-circuit duplicate CodeMirror updateListener callbacks, breaking the React state → CodeMirror → stale onChange → React state feedback loop - Use localValueRef.current in handleContextWindowToggle and handleCompactLimitChange to avoid stale closure reads - Change compact limit input from type="number" to type="text" with inputMode="numeric" to remove unnecessary spinner buttons * feat(codex): add 1M context window toggle utilities and i18n keys - Add extractCodexTopLevelInt, setCodexTopLevelInt, removeCodexTopLevelField TOML helpers in providerConfigUtils.ts - Add i18n keys for contextWindow1M, autoCompactLimit in zh/en/ja locales * feat(claude): collapse model mapping fields by default - Wrap 5 model mapping inputs in a Collapsible, collapsed by default - Auto-expand when any model value is present (including preset-filled) - Show hint text when collapsed explaining most users need no config - Add zh/en/ja i18n keys for toggle label and collapsed hint - Use variant={null} to avoid ghost button hover style clash in dark mode * feat(claude): merge advanced fields into single collapsible section - Merge API format, auth field, and model mapping into a unified "Advanced Options" collapsible - Extend smart-expand logic to detect non-default values across all advanced fields - Preserve model mapping sub-header and hint with a separator line - Update zh/en/ja i18n keys (advancedOptionsToggle, advancedOptionsHint, modelMappingLabel, modelMappingHint) * feat(copilot): add GitHub Copilot reverse proxy support Add GitHub Copilot as a Claude provider variant with OAuth device code authentication and Anthropic ↔ OpenAI format transformation. Backend: - Add CopilotAuthManager for GitHub OAuth device code flow - Implement Copilot token auto-refresh (60s before expiry) - Persist GitHub token to ~/.cc-switch/copilot_auth.json - Add ProviderType::GitHubCopilot and AuthStrategy::GitHubCopilot - Modify forwarder to use /chat/completions for Copilot - Add Copilot-specific headers (Editor-Version, Editor-Plugin-Version) Frontend: - Add CopilotAuthSection component for OAuth UI - Add useCopilotAuth hook for OAuth state management - Auto-copy user code to clipboard and open browser - Use 8-second polling interval to avoid GitHub rate limits - Skip API Key validation for Copilot providers - Add GitHub Copilot preset with claude-sonnet-4 model Co-Authored-By: Claude Haiku 4.5 * fix(copilot): remove is_expired() calls from tests Remove references to deleted is_expired() method in test code. Only is_expiring_soon() is needed for token refresh logic. Co-Authored-By: Claude Haiku 4.5 * feat(copilot): add real-time model listing from Copilot API - Add fetch_models() to CopilotAuthManager calling GET /models endpoint - Add copilot_get_models Tauri command - Add copilotGetModels() frontend API wrapper - Modify ClaudeFormFields to show model dropdown for Copilot providers - Fetches available models on component mount when isCopilotPreset - Groups models by vendor (Anthropic, OpenAI, Google, etc.) - Input + dropdown button combo allows both manual entry and selection - Non-Copilot providers keep original plain Input behavior Co-Authored-By: Claude Opus 4.6 * feat(copilot): add usage query integration - Add Copilot usage API integration (fetch_usage method) - Add copilot_get_usage Tauri command - Add GitHub Copilot template in usage query modal - Unify naming: copilot → github_copilot - Add constants management (TEMPLATE_TYPES, PROVIDER_TYPES) - Improve error handling with detailed error messages - Add database migration (v5 → v6) for template type update - Add i18n translations (zh, en, ja) - Improve type safety with TemplateType - Apply code formatting (cargo fmt, prettier) * 修复github 登录和注销问题 ,模型选择问题 * feat(copilot): add multi-account support for GitHub Copilot - Add multi-account storage structure with v1 to v2 migration - Add per-account token caching and auto-refresh - Add new Tauri commands for account management - Integrate account selection in Proxy forwarder - Add account selection UI in CopilotAuthSection - Save githubAccountId to ProviderMeta - Add i18n translations for multi-account features (zh/en/ja) * 修复用量查询Reset字段出现多余字符 * refactor(auth-binding): introduce generic provider auth binding primitives - add shared authBinding types in Rust and TypeScript while keeping githubAccountId as a compatibility field\n- resolve Copilot token, models, and usage through provider-bound account lookup instead of only the implicit default account\n- fix the Unix build regression in settings.rs by restoring std::io::Write for write_all()\n- remove the accidental .github ignore entry and drop leftover Copilot form debug logs\n- keep the first migration step non-breaking by writing both authBinding and the legacy githubAccountId field from the form * refactor(auth-service): add managed auth command surface and explicit default account state - introduce generic managed auth commands and frontend auth API wrappers for provider-scoped login, status, account listing, removal, logout, and default-account selection\n- store an explicit Copilot default_account_id instead of relying on HashMap iteration order, and use it consistently for fallback token/model/usage resolution\n- sort managed accounts deterministically and surface default-account state to the UI\n- refactor the Copilot form hook to wrap a generic useManagedAuth implementation while preserving the existing component contract\n- add default-account controls to the Copilot auth section and extend Copilot auth status serialization/tests for the new state * feat(auth-center): add a dedicated settings entrypoint for managed OAuth accounts - add an Auth Center tab to Settings so managed OAuth accounts are no longer hidden inside individual provider forms\n- introduce a first AuthCenterPanel that hosts GitHub Copilot account management as the initial managed auth provider\n- keep the provider form experience intact while establishing a global account-management surface for future providers such as OpenAI\n- validate that the new settings tab works cleanly with the generic managed auth hook and existing Copilot account controls * feat(add-provider): expose managed OAuth sources alongside universal providers - add an OAuth tab to the Add Provider flow so managed auth sources sit beside app-specific and universal providers\n- reuse the new Auth Center panel inside the dialog, keeping account management discoverable during provider creation\n- make the dialog footer adapt to the OAuth tab so account setup does not pretend to create a provider directly\n- align the add-provider UX with the new architecture where OAuth accounts are global assets and providers bind to them later * fix(auth-reliability): harden managed auth persistence and refresh behavior - replace direct Copilot auth store writes with private temp-file writes and atomic rename semantics, and document the local token storage limitation\n- add per-account refresh locks plus a double-check path so concurrent requests do not stampede GitHub token refresh\n- surface legacy migration failures through auth status, expose them in the UI, and add translated copy for the new account-state labels\n- stop writing the legacy githubAccountId field from the provider form while keeping compatibility reads in place\n- add logout error recovery and Copilot model-load toasts so auth failures are no longer silently swallowed * refactor(copilot-detection): prefer provider type before URL fallbacks - update forwarder endpoint rewriting to treat providerType as the primary GitHub Copilot signal\n- keep githubcopilot.com string matching only as a compatibility fallback for older provider records without providerType\n- reduce one more path where Copilot behavior depended purely on URL heuristics * fix(copilot-auth): add cancel button to error state in CopilotAuthSection - 错误状态下仅有"重试"按钮,用户无法退出(如不可恢复的 403 未订阅错误) - 新增"取消"按钮,复用已有的 cancelAuth 逻辑重置为 idle 状态 * 修复打包后github账号头像显示异常 * 修复github copilot 来源的模型测试报错 * feat(copilot-preset): add default model presets for GitHub Copilot - 补充 Copilot 预设的默认模型配置,用户选完预设即可直接使用 - ANTHROPIC_MODEL: claude-opus-4.6 - ANTHROPIC_DEFAULT_HAIKU_MODEL: claude-haiku-4.5 - ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-4.6 - ANTHROPIC_DEFAULT_OPUS_MODEL: claude-opus-4.6 --------- Co-authored-by: Jason Co-authored-by: 周梦泽 Co-authored-by: Claude Haiku 4.5 --- .gitignore | 6 +- src-tauri/src/commands/auth.rs | 182 +++ src-tauri/src/commands/copilot.rs | 212 +++ src-tauri/src/commands/mod.rs | 8 +- src-tauri/src/commands/provider.rs | 61 + src-tauri/src/commands/stream_check.rs | 76 +- src-tauri/src/commands/toolsearch.rs | 21 - src-tauri/src/database/schema.rs | 57 +- src-tauri/src/lib.rs | 63 +- src-tauri/src/provider.rs | 60 + src-tauri/src/proxy/forwarder.rs | 80 +- src-tauri/src/proxy/providers/auth.rs | 8 + src-tauri/src/proxy/providers/claude.rs | 116 ++ src-tauri/src/proxy/providers/copilot_auth.rs | 1313 +++++++++++++++++ src-tauri/src/proxy/providers/mod.rs | 51 +- src-tauri/src/services/stream_check.rs | 105 +- src-tauri/src/settings.rs | 4 - src-tauri/src/toolsearch_patch.rs | 569 ------- src-tauri/tauri.conf.json | 2 +- src/components/UsageScriptModal.tsx | 285 ++-- .../providers/AddProviderDialog.tsx | 28 +- src/components/providers/ProviderCard.tsx | 16 +- .../providers/forms/ClaudeFormFields.tsx | 537 ++++--- .../providers/forms/CodexConfigSections.tsx | 125 +- .../providers/forms/CommonConfigEditor.tsx | 60 +- .../providers/forms/CopilotAuthSection.tsx | 367 +++++ .../providers/forms/ProviderForm.tsx | 64 +- .../forms/helpers/opencodeFormUtils.ts | 1 + src/components/providers/forms/hooks/index.ts | 2 + .../providers/forms/hooks/useCopilotAuth.ts | 27 + .../providers/forms/hooks/useManagedAuth.ts | 233 +++ src/components/settings/AuthCenterPanel.tsx | 55 + src/components/settings/SettingsPage.tsx | 35 +- src/components/settings/WindowSettings.tsx | 10 +- src/config/claudeProviderPresets.ts | 26 + src/config/constants.ts | 15 + src/config/opencodeProviderPresets.ts | 33 + src/hooks/useSettings.ts | 40 +- src/hooks/useSettingsForm.ts | 3 - src/i18n/locales/en.json | 52 +- src/i18n/locales/ja.json | 52 +- src/i18n/locales/zh.json | 52 +- src/lib/api/auth.ts | 101 ++ src/lib/api/copilot.ts | 256 ++++ src/lib/api/index.ts | 13 + src/lib/api/settings.ts | 12 - src/lib/api/usage.ts | 3 +- src/lib/authBinding.ts | 21 + src/types.ts | 20 +- src/utils/providerConfigUtils.ts | 79 + 50 files changed, 4555 insertions(+), 1062 deletions(-) create mode 100644 src-tauri/src/commands/auth.rs create mode 100644 src-tauri/src/commands/copilot.rs delete mode 100644 src-tauri/src/commands/toolsearch.rs create mode 100644 src-tauri/src/proxy/providers/copilot_auth.rs delete mode 100644 src-tauri/src/toolsearch_patch.rs create mode 100644 src/components/providers/forms/CopilotAuthSection.tsx create mode 100644 src/components/providers/forms/hooks/useCopilotAuth.ts create mode 100644 src/components/providers/forms/hooks/useManagedAuth.ts create mode 100644 src/components/settings/AuthCenterPanel.tsx create mode 100644 src/config/constants.ts create mode 100644 src/lib/api/auth.ts create mode 100644 src/lib/api/copilot.ts create mode 100644 src/lib/authBinding.ts diff --git a/.gitignore b/.gitignore index bb1ef9cb..e5f979d2 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,8 @@ flatpak/cc-switch.deb flatpak-build/ flatpak-repo/ .worktrees/ -.spec-workflow/ \ No newline at end of file +.spec-workflow/ +copilot-api +.history +CODEBUDDY.md +.github diff --git a/src-tauri/src/commands/auth.rs b/src-tauri/src/commands/auth.rs new file mode 100644 index 00000000..03ca3e7e --- /dev/null +++ b/src-tauri/src/commands/auth.rs @@ -0,0 +1,182 @@ +use tauri::State; + +use crate::commands::copilot::CopilotAuthState; +use crate::proxy::providers::copilot_auth::{GitHubAccount, GitHubDeviceCodeResponse}; + +const AUTH_PROVIDER_GITHUB_COPILOT: &str = "github_copilot"; + +#[derive(Debug, Clone, serde::Serialize)] +pub struct ManagedAuthAccount { + pub id: String, + pub provider: String, + pub login: String, + pub avatar_url: Option, + pub authenticated_at: i64, + pub is_default: bool, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct ManagedAuthStatus { + pub provider: String, + pub authenticated: bool, + pub default_account_id: Option, + pub migration_error: Option, + pub accounts: Vec, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct ManagedAuthDeviceCodeResponse { + pub provider: String, + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + pub expires_in: u64, + pub interval: u64, +} + +fn ensure_auth_provider(auth_provider: &str) -> Result<&str, String> { + match auth_provider { + AUTH_PROVIDER_GITHUB_COPILOT => Ok(AUTH_PROVIDER_GITHUB_COPILOT), + _ => Err(format!("Unsupported auth provider: {auth_provider}")), + } +} + +fn map_account( + provider: &str, + account: GitHubAccount, + default_account_id: Option<&str>, +) -> ManagedAuthAccount { + ManagedAuthAccount { + is_default: default_account_id == Some(account.id.as_str()), + id: account.id, + provider: provider.to_string(), + login: account.login, + avatar_url: account.avatar_url, + authenticated_at: account.authenticated_at, + } +} + +fn map_device_code_response( + provider: &str, + response: GitHubDeviceCodeResponse, +) -> ManagedAuthDeviceCodeResponse { + ManagedAuthDeviceCodeResponse { + provider: provider.to_string(), + device_code: response.device_code, + user_code: response.user_code, + verification_uri: response.verification_uri, + expires_in: response.expires_in, + interval: response.interval, + } +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn auth_start_login( + auth_provider: String, + state: State<'_, CopilotAuthState>, +) -> Result { + let auth_provider = ensure_auth_provider(&auth_provider)?; + let auth_manager = state.0.read().await; + let response = auth_manager + .start_device_flow() + .await + .map_err(|e| e.to_string())?; + Ok(map_device_code_response(auth_provider, response)) +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn auth_poll_for_account( + auth_provider: String, + device_code: String, + state: State<'_, CopilotAuthState>, +) -> Result, String> { + let auth_provider = ensure_auth_provider(&auth_provider)?; + let auth_manager = state.0.write().await; + match auth_manager.poll_for_token(&device_code).await { + Ok(account) => { + let default_account_id = auth_manager.get_status().await.default_account_id; + Ok(account + .map(|account| map_account(auth_provider, account, default_account_id.as_deref()))) + } + Err(crate::proxy::providers::copilot_auth::CopilotAuthError::AuthorizationPending) => { + Ok(None) + } + Err(e) => Err(e.to_string()), + } +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn auth_list_accounts( + auth_provider: String, + state: State<'_, CopilotAuthState>, +) -> Result, String> { + let auth_provider = ensure_auth_provider(&auth_provider)?; + let auth_manager = state.0.read().await; + let status = auth_manager.get_status().await; + let default_account_id = status.default_account_id.clone(); + Ok(status + .accounts + .into_iter() + .map(|account| map_account(auth_provider, account, default_account_id.as_deref())) + .collect()) +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn auth_get_status( + auth_provider: String, + state: State<'_, CopilotAuthState>, +) -> Result { + let auth_provider = ensure_auth_provider(&auth_provider)?; + let auth_manager = state.0.read().await; + let status = auth_manager.get_status().await; + let default_account_id = status.default_account_id.clone(); + Ok(ManagedAuthStatus { + provider: auth_provider.to_string(), + authenticated: status.authenticated, + default_account_id: default_account_id.clone(), + migration_error: status.migration_error, + accounts: status + .accounts + .into_iter() + .map(|account| map_account(auth_provider, account, default_account_id.as_deref())) + .collect(), + }) +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn auth_remove_account( + auth_provider: String, + account_id: String, + state: State<'_, CopilotAuthState>, +) -> Result<(), String> { + ensure_auth_provider(&auth_provider)?; + let auth_manager = state.0.write().await; + auth_manager + .remove_account(&account_id) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn auth_set_default_account( + auth_provider: String, + account_id: String, + state: State<'_, CopilotAuthState>, +) -> Result<(), String> { + ensure_auth_provider(&auth_provider)?; + let auth_manager = state.0.write().await; + auth_manager + .set_default_account(&account_id) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn auth_logout( + auth_provider: String, + state: State<'_, CopilotAuthState>, +) -> Result<(), String> { + ensure_auth_provider(&auth_provider)?; + let auth_manager = state.0.write().await; + auth_manager.clear_auth().await.map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/copilot.rs b/src-tauri/src/commands/copilot.rs new file mode 100644 index 00000000..4e16ddd8 --- /dev/null +++ b/src-tauri/src/commands/copilot.rs @@ -0,0 +1,212 @@ +//! GitHub Copilot Tauri Commands +//! +//! 提供 Copilot OAuth 认证相关的 Tauri 命令,支持多账号管理。 + +use crate::proxy::providers::copilot_auth::{ + CopilotAuthManager, CopilotAuthStatus, CopilotModel, CopilotUsageResponse, GitHubAccount, + GitHubDeviceCodeResponse, +}; +use std::sync::Arc; +use tauri::State; +use tokio::sync::RwLock; + +/// Copilot 认证状态 +pub struct CopilotAuthState(pub Arc>); + +// ==================== 设备码流程 ==================== + +/// 启动设备码流程 +/// +/// 返回设备码和用户码,用于 OAuth 认证 +#[tauri::command] +pub async fn copilot_start_device_flow( + state: State<'_, CopilotAuthState>, +) -> Result { + let auth_manager = state.0.read().await; + auth_manager + .start_device_flow() + .await + .map_err(|e| e.to_string()) +} + +/// 轮询 OAuth Token(向后兼容) +/// +/// 使用设备码轮询 GitHub,等待用户完成授权 +/// 返回 true 表示授权成功,false 表示等待中 +#[tauri::command(rename_all = "camelCase")] +pub async fn copilot_poll_for_auth( + device_code: String, + state: State<'_, CopilotAuthState>, +) -> Result { + let auth_manager = state.0.write().await; + match auth_manager.poll_for_token(&device_code).await { + Ok(Some(_account)) => { + log::info!("[CopilotAuth] 用户已授权"); + Ok(true) + } + Ok(None) => Ok(false), + Err(crate::proxy::providers::copilot_auth::CopilotAuthError::AuthorizationPending) => { + Ok(false) + } + Err(e) => { + log::error!("[CopilotAuth] 轮询失败: {}", e); + Err(e.to_string()) + } + } +} + +/// 轮询 OAuth Token(多账号版本) +/// +/// 返回新添加的账号信息,如果授权成功 +#[tauri::command(rename_all = "camelCase")] +pub async fn copilot_poll_for_account( + device_code: String, + state: State<'_, CopilotAuthState>, +) -> Result, String> { + let auth_manager = state.0.write().await; + match auth_manager.poll_for_token(&device_code).await { + Ok(account) => Ok(account), + Err(crate::proxy::providers::copilot_auth::CopilotAuthError::AuthorizationPending) => { + Ok(None) + } + Err(e) => { + log::error!("[CopilotAuth] 轮询失败: {}", e); + Err(e.to_string()) + } + } +} + +// ==================== 多账号管理 ==================== + +/// 列出所有已认证的账号 +#[tauri::command] +pub async fn copilot_list_accounts( + state: State<'_, CopilotAuthState>, +) -> Result, String> { + let auth_manager = state.0.read().await; + Ok(auth_manager.list_accounts().await) +} + +/// 移除指定账号 +#[tauri::command(rename_all = "camelCase")] +pub async fn copilot_remove_account( + account_id: String, + state: State<'_, CopilotAuthState>, +) -> Result<(), String> { + let auth_manager = state.0.write().await; + auth_manager + .remove_account(&account_id) + .await + .map_err(|e| e.to_string()) +} + +/// 设置默认账号 +#[tauri::command(rename_all = "camelCase")] +pub async fn copilot_set_default_account( + account_id: String, + state: State<'_, CopilotAuthState>, +) -> Result<(), String> { + let auth_manager = state.0.write().await; + auth_manager + .set_default_account(&account_id) + .await + .map_err(|e| e.to_string()) +} + +// ==================== 状态查询 ==================== + +/// 获取认证状态(包含所有账号) +#[tauri::command] +pub async fn copilot_get_auth_status( + state: State<'_, CopilotAuthState>, +) -> Result { + let auth_manager = state.0.read().await; + Ok(auth_manager.get_status().await) +} + +/// 检查是否已认证(有任意账号) +#[tauri::command] +pub async fn copilot_is_authenticated(state: State<'_, CopilotAuthState>) -> Result { + let auth_manager = state.0.read().await; + Ok(auth_manager.is_authenticated().await) +} + +/// 注销所有 Copilot 认证 +#[tauri::command] +pub async fn copilot_logout(state: State<'_, CopilotAuthState>) -> Result<(), String> { + let auth_manager = state.0.write().await; + auth_manager.clear_auth().await.map_err(|e| e.to_string()) +} + +// ==================== Token 获取 ==================== + +/// 获取有效的 Copilot Token(向后兼容:使用第一个账号) +/// +/// 内部使用,用于代理请求 +#[tauri::command] +pub async fn copilot_get_token(state: State<'_, CopilotAuthState>) -> Result { + let auth_manager = state.0.read().await; + auth_manager + .get_valid_token() + .await + .map_err(|e| e.to_string()) +} + +/// 获取指定账号的有效 Copilot Token +#[tauri::command(rename_all = "camelCase")] +pub async fn copilot_get_token_for_account( + account_id: String, + state: State<'_, CopilotAuthState>, +) -> Result { + let auth_manager = state.0.read().await; + auth_manager + .get_valid_token_for_account(&account_id) + .await + .map_err(|e| e.to_string()) +} + +// ==================== 模型和使用量 ==================== + +/// 获取 Copilot 可用模型列表(向后兼容:使用第一个账号) +#[tauri::command] +pub async fn copilot_get_models( + state: State<'_, CopilotAuthState>, +) -> Result, String> { + let auth_manager = state.0.read().await; + auth_manager.fetch_models().await.map_err(|e| e.to_string()) +} + +/// 获取指定账号的 Copilot 可用模型列表 +#[tauri::command(rename_all = "camelCase")] +pub async fn copilot_get_models_for_account( + account_id: String, + state: State<'_, CopilotAuthState>, +) -> Result, String> { + let auth_manager = state.0.read().await; + auth_manager + .fetch_models_for_account(&account_id) + .await + .map_err(|e| e.to_string()) +} + +/// 获取 Copilot 使用量信息(向后兼容:使用第一个账号) +#[tauri::command] +pub async fn copilot_get_usage( + state: State<'_, CopilotAuthState>, +) -> Result { + let auth_manager = state.0.read().await; + auth_manager.fetch_usage().await.map_err(|e| e.to_string()) +} + +/// 获取指定账号的 Copilot 使用量信息 +#[tauri::command(rename_all = "camelCase")] +pub async fn copilot_get_usage_for_account( + account_id: String, + state: State<'_, CopilotAuthState>, +) -> Result { + let auth_manager = state.0.read().await; + auth_manager + .fetch_usage_for_account(&account_id) + .await + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 419b665d..9eb7c659 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,8 @@ #![allow(non_snake_case)] +mod auth; mod config; +mod copilot; mod deeplink; mod env; mod failover; @@ -19,12 +21,14 @@ mod settings; pub mod skill; mod stream_check; mod sync_support; -mod toolsearch; + mod usage; mod webdav_sync; mod workspace; +pub use auth::*; pub use config::*; +pub use copilot::*; pub use deeplink::*; pub use env::*; pub use failover::*; @@ -42,7 +46,7 @@ pub use session_manager::*; pub use settings::*; pub use skill::*; pub use stream_check::*; -pub use toolsearch::*; + pub use usage::*; pub use webdav_sync::*; pub use workspace::*; diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index 344b35ec..883f8d3b 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -2,6 +2,7 @@ use indexmap::IndexMap; use tauri::State; use crate::app_config::AppType; +use crate::commands::copilot::CopilotAuthState; use crate::error::AppError; use crate::provider::Provider; use crate::services::{ @@ -10,6 +11,11 @@ use crate::services::{ use crate::store::AppState; use std::str::FromStr; +// 常量定义 +const TEMPLATE_TYPE_GITHUB_COPILOT: &str = "github_copilot"; +const COPILOT_UNIT_PREMIUM: &str = "requests"; + +/// 获取所有供应商 #[tauri::command] pub fn get_providers( state: State<'_, AppState>, @@ -142,10 +148,65 @@ pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result< #[tauri::command] pub async fn queryProviderUsage( state: State<'_, AppState>, + copilot_state: State<'_, CopilotAuthState>, #[allow(non_snake_case)] providerId: String, // 使用 camelCase 匹配前端 app: String, ) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; + + // 检查是否为 GitHub Copilot 模板类型,并解析绑定账号 + let (is_copilot_template, copilot_account_id) = { + let providers = state + .db + .get_all_providers(app_type.as_str()) + .map_err(|e| format!("Failed to get providers: {}", e))?; + + let provider = providers.get(&providerId); + let is_copilot = provider + .and_then(|p| p.meta.as_ref()) + .and_then(|m| m.usage_script.as_ref()) + .and_then(|s| s.template_type.as_ref()) + .map(|t| t == TEMPLATE_TYPE_GITHUB_COPILOT) + .unwrap_or(false); + let account_id = provider + .and_then(|p| p.meta.as_ref()) + .and_then(|m| m.managed_account_id_for(TEMPLATE_TYPE_GITHUB_COPILOT)); + + (is_copilot, account_id) + }; + + if is_copilot_template { + // 使用 Copilot 专用 API + let auth_manager = copilot_state.0.read().await; + let usage = match copilot_account_id.as_deref() { + Some(account_id) => auth_manager + .fetch_usage_for_account(account_id) + .await + .map_err(|e| format!("Failed to fetch Copilot usage: {}", e))?, + None => auth_manager + .fetch_usage() + .await + .map_err(|e| format!("Failed to fetch Copilot usage: {}", e))?, + }; + let premium = &usage.quota_snapshots.premium_interactions; + let used = premium.entitlement - premium.remaining; + + return Ok(crate::provider::UsageResult { + success: true, + data: Some(vec![crate::provider::UsageData { + plan_name: Some(usage.copilot_plan), + remaining: Some(premium.remaining as f64), + total: Some(premium.entitlement as f64), + used: Some(used as f64), + unit: Some(COPILOT_UNIT_PREMIUM.to_string()), + is_valid: Some(true), + invalid_message: None, + extra: Some(format!("Reset: {}", usage.quota_reset_date)), + }]), + error: None, + }); + } + ProviderService::query_usage(state.inner(), app_type, &providerId) .await .map_err(|e| e.to_string()) diff --git a/src-tauri/src/commands/stream_check.rs b/src-tauri/src/commands/stream_check.rs index 218cdfab..38d5b09d 100644 --- a/src-tauri/src/commands/stream_check.rs +++ b/src-tauri/src/commands/stream_check.rs @@ -1,6 +1,7 @@ //! 流式健康检查命令 use crate::app_config::AppType; +use crate::commands::copilot::CopilotAuthState; use crate::error::AppError; use crate::services::stream_check::{ HealthStatus, StreamCheckConfig, StreamCheckResult, StreamCheckService, @@ -13,6 +14,7 @@ use tauri::State; #[tauri::command] pub async fn stream_check_provider( state: State<'_, AppState>, + copilot_state: State<'_, CopilotAuthState>, app_type: AppType, provider_id: String, ) -> Result { @@ -23,7 +25,9 @@ pub async fn stream_check_provider( .get(&provider_id) .ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?; - let result = StreamCheckService::check_with_retry(&app_type, provider, &config).await?; + let auth_override = resolve_copilot_auth_override(provider, &copilot_state).await?; + let result = + StreamCheckService::check_with_retry(&app_type, provider, &config, auth_override).await?; // 记录日志 let _ = @@ -38,6 +42,7 @@ pub async fn stream_check_provider( #[tauri::command] pub async fn stream_check_all_providers( state: State<'_, AppState>, + copilot_state: State<'_, CopilotAuthState>, app_type: AppType, proxy_targets_only: bool, ) -> Result, AppError> { @@ -67,18 +72,20 @@ pub async fn stream_check_all_providers( } } - let result = StreamCheckService::check_with_retry(&app_type, &provider, &config) - .await - .unwrap_or_else(|e| StreamCheckResult { - status: HealthStatus::Failed, - success: false, - message: e.to_string(), - response_time_ms: None, - http_status: None, - model_used: String::new(), - tested_at: chrono::Utc::now().timestamp(), - retry_count: 0, - }); + let auth_override = resolve_copilot_auth_override(&provider, &copilot_state).await?; + let result = + StreamCheckService::check_with_retry(&app_type, &provider, &config, auth_override) + .await + .unwrap_or_else(|e| StreamCheckResult { + status: HealthStatus::Failed, + success: false, + message: e.to_string(), + response_time_ms: None, + http_status: None, + model_used: String::new(), + tested_at: chrono::Utc::now().timestamp(), + retry_count: 0, + }); let _ = state .db @@ -104,3 +111,46 @@ pub fn save_stream_check_config( ) -> Result<(), AppError> { state.db.save_stream_check_config(&config) } + +async fn resolve_copilot_auth_override( + provider: &crate::provider::Provider, + copilot_state: &State<'_, CopilotAuthState>, +) -> Result, AppError> { + let is_copilot = provider + .meta + .as_ref() + .and_then(|meta| meta.provider_type.as_deref()) + == Some("github_copilot") + || provider + .settings_config + .pointer("/env/ANTHROPIC_BASE_URL") + .and_then(|value| value.as_str()) + .map(|url| url.contains("githubcopilot.com")) + .unwrap_or(false); + + if !is_copilot { + return Ok(None); + } + + let auth_manager = copilot_state.0.read().await; + let account_id = provider + .meta + .as_ref() + .and_then(|meta| meta.github_account_id.clone()); + + let token = match account_id.as_deref() { + Some(id) => auth_manager + .get_valid_token_for_account(id) + .await + .map_err(|e| AppError::Message(format!("GitHub Copilot 认证失败: {e}")))?, + None => auth_manager + .get_valid_token() + .await + .map_err(|e| AppError::Message(format!("GitHub Copilot 认证失败: {e}")))?, + }; + + Ok(Some(crate::proxy::providers::AuthInfo::new( + token, + crate::proxy::providers::AuthStrategy::GitHubCopilot, + ))) +} diff --git a/src-tauri/src/commands/toolsearch.rs b/src-tauri/src/commands/toolsearch.rs deleted file mode 100644 index 15ee14cb..00000000 --- a/src-tauri/src/commands/toolsearch.rs +++ /dev/null @@ -1,21 +0,0 @@ -#![allow(non_snake_case)] - -/// Check Tool Search patch status for the active Claude Code installation -#[tauri::command] -pub async fn check_toolsearch_status() -> Result -{ - crate::toolsearch_patch::check_toolsearch_status().map_err(|e| e.to_string()) -} - -/// Apply Tool Search patch (bypass domain restriction) to the active installation -#[tauri::command] -pub async fn apply_toolsearch_patch() -> Result, String> { - crate::toolsearch_patch::apply_toolsearch_patch().map_err(|e| e.to_string()) -} - -/// Restore Tool Search patch (re-enable domain restriction) for the active installation -#[tauri::command] -pub async fn restore_toolsearch_patch() -> Result, String> -{ - crate::toolsearch_patch::restore_toolsearch_patch().map_err(|e| e.to_string()) -} diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index eec1d8cd..30042133 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -4,7 +4,7 @@ use super::{lock_conn, Database, SCHEMA_VERSION}; use crate::error::AppError; -use rusqlite::Connection; +use rusqlite::{params, Connection}; use serde::Serialize; #[derive(Serialize)] @@ -389,7 +389,7 @@ impl Database { Self::set_user_version(conn, 5)?; } 5 => { - log::info!("迁移数据库从 v5 到 v6(使用量聚合表)"); + log::info!("迁移数据库从 v5 到 v6(使用量聚合表 + Copilot 模板类型统一)"); Self::migrate_v5_to_v6(conn)?; Self::set_user_version(conn, 6)?; } @@ -970,8 +970,9 @@ impl Database { Ok(()) } - /// v5 -> v6 迁移:添加使用量日聚合表 + /// v5 -> v6 迁移:添加使用量日聚合表 + 统一 Copilot 模板类型 fn migrate_v5_to_v6(conn: &Connection) -> Result<(), AppError> { + // 1. 添加使用量日聚合表 conn.execute( "CREATE TABLE IF NOT EXISTS usage_daily_rollups ( date TEXT NOT NULL, @@ -992,7 +993,55 @@ impl Database { ) .map_err(|e| AppError::Database(format!("创建 usage_daily_rollups 表失败: {e}")))?; - log::info!("v5 -> v6 迁移完成:已添加使用量日聚合表"); + // 2. 统一 Copilot 模板类型为 github_copilot + let mut stmt = conn + .prepare("SELECT id, app_type, meta FROM providers") + .map_err(|e| AppError::Database(e.to_string()))?; + + let rows = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }) + .map_err(|e| AppError::Database(e.to_string()))?; + + let mut updates = Vec::new(); + for row in rows { + let (id, app_type, meta_str) = row.map_err(|e| AppError::Database(e.to_string()))?; + + if let Ok(mut meta) = serde_json::from_str::(&meta_str) { + let mut updated = false; + + if let Some(usage_script) = meta.get_mut("usage_script") { + if let Some(template_type) = usage_script.get_mut("template_type") { + if template_type == "copilot" { + *template_type = + serde_json::Value::String("github_copilot".to_string()); + updated = true; + } + } + } + + if updated { + let new_meta_str = serde_json::to_string(&meta) + .map_err(|e| AppError::Database(e.to_string()))?; + updates.push((id, app_type, new_meta_str)); + } + } + } + + for (id, app_type, new_meta) in updates { + conn.execute( + "UPDATE providers SET meta = ?1 WHERE id = ?2 AND app_type = ?3", + params![new_meta, id, app_type], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + } + + log::info!("v5 -> v6 迁移完成:已添加使用量日聚合表,统一 copilot 模板类型"); Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5042eb1b..ef464d68 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -25,7 +25,7 @@ mod services; mod session_manager; mod settings; mod store; -mod toolsearch_patch; + mod tray; mod usage_script; @@ -690,6 +690,18 @@ pub fn run() { let skill_service = SkillService::new(); app.manage(commands::skill::SkillServiceState(Arc::new(skill_service))); + // 初始化 CopilotAuthManager + { + use crate::proxy::providers::copilot_auth::CopilotAuthManager; + use commands::CopilotAuthState; + use tokio::sync::RwLock; + + let app_config_dir = crate::config::get_app_config_dir(); + let copilot_auth_manager = CopilotAuthManager::new(app_config_dir); + app.manage(CopilotAuthState(Arc::new(RwLock::new(copilot_auth_manager)))); + log::info!("✓ CopilotAuthManager initialized"); + } + // 初始化全局出站代理 HTTP 客户端 { let db = &app.state::().db; @@ -806,26 +818,6 @@ pub fn run() { } } - // Tool Search bypass: auto-apply patch on startup if enabled - if settings.tool_search_bypass { - match crate::toolsearch_patch::apply_toolsearch_patch() { - Ok(results) => { - let success = results.iter().filter(|r| r.success).count(); - let total = results.len(); - if success > 0 { - log::info!("✓ Tool Search patch auto-applied ({success}/{total})"); - } - for r in results.iter().filter(|r| !r.success) { - if let Some(err) = &r.error { - log::warn!("✗ Tool Search patch failed for {}: {err}", r.path); - } - } - } - Err(e) => { - log::warn!("✗ Tool Search auto-patch skipped: {e}"); - } - } - } Ok(()) }) @@ -873,10 +865,6 @@ pub fn run() { commands::is_claude_plugin_applied, commands::apply_claude_onboarding_skip, commands::clear_claude_onboarding_skip, - // Tool Search patch - commands::check_toolsearch_status, - commands::apply_toolsearch_patch, - commands::restore_toolsearch_patch, // Claude MCP management commands::get_claude_mcp_status, commands::read_claude_mcp_config, @@ -1056,6 +1044,31 @@ pub fn run() { commands::scan_local_proxies, // Window theme control commands::set_window_theme, + // Generic managed auth commands + commands::auth_start_login, + commands::auth_poll_for_account, + commands::auth_list_accounts, + commands::auth_get_status, + commands::auth_remove_account, + commands::auth_set_default_account, + commands::auth_logout, + // Copilot OAuth commands (multi-account support) + commands::copilot_start_device_flow, + commands::copilot_poll_for_auth, + commands::copilot_poll_for_account, + commands::copilot_list_accounts, + commands::copilot_remove_account, + commands::copilot_set_default_account, + commands::copilot_get_auth_status, + commands::copilot_logout, + commands::copilot_is_authenticated, + commands::copilot_get_token, + commands::copilot_get_token_for_account, + commands::copilot_get_models, + commands::copilot_get_models_for_account, + commands::copilot_get_usage, + commands::copilot_get_usage_for_account, + // OMO commands commands::read_omo_local_file, commands::get_current_omo_provider_id, commands::disable_current_omo, diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index ed5e3514..ae8edbeb 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -191,6 +191,31 @@ pub struct ProviderProxyConfig { pub proxy_password: Option, } +/// 认证绑定来源 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum AuthBindingSource { + /// 从 provider 自身配置读取认证信息(默认) + #[default] + ProviderConfig, + /// 使用托管账号认证(如 GitHub Copilot OAuth) + ManagedAccount, +} + +/// 通用认证绑定 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AuthBinding { + /// 认证来源 + #[serde(default)] + pub source: AuthBindingSource, + /// 托管认证供应商标识(如 github_copilot) + #[serde(rename = "authProvider", skip_serializing_if = "Option::is_none")] + pub auth_provider: Option, + /// 托管账号 ID;为空表示跟随该认证供应商的默认账号 + #[serde(rename = "accountId", skip_serializing_if = "Option::is_none")] + pub account_id: Option, +} + /// 供应商元数据 #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ProviderMeta { @@ -242,14 +267,49 @@ pub struct ProviderMeta { /// - "openai_responses": OpenAI Responses API 格式,需要转换 #[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")] pub api_format: Option, + /// 通用认证绑定(provider_config / managed_account) + /// + /// 新代码应只写入该字段;githubAccountId 仅保留兼容读取。 + #[serde(rename = "authBinding", skip_serializing_if = "Option::is_none")] + pub auth_binding: Option, /// Claude 认证字段名("ANTHROPIC_AUTH_TOKEN" 或 "ANTHROPIC_API_KEY") #[serde(rename = "apiKeyField", skip_serializing_if = "Option::is_none")] pub api_key_field: Option, + /// Prompt cache key for OpenAI-compatible endpoints. /// When set, injected into converted requests to improve cache hit rate. /// If not set, provider ID is used automatically during format conversion. #[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")] pub prompt_cache_key: Option, + /// 供应商类型标识(用于特殊供应商检测) + /// - "github_copilot": GitHub Copilot 供应商 + #[serde(rename = "providerType", skip_serializing_if = "Option::is_none")] + pub provider_type: Option, + /// GitHub Copilot 关联账号 ID(仅 github_copilot 供应商使用) + /// 用于多账号支持,关联到特定的 GitHub 账号 + #[serde(rename = "githubAccountId", skip_serializing_if = "Option::is_none")] + pub github_account_id: Option, +} + +impl ProviderMeta { + /// 解析指定托管认证供应商绑定的账号 ID。 + /// + /// 新版优先读取 authBinding,旧版继续兼容 githubAccountId。 + pub fn managed_account_id_for(&self, auth_provider: &str) -> Option { + if let Some(binding) = self.auth_binding.as_ref() { + if binding.source == AuthBindingSource::ManagedAccount + && binding.auth_provider.as_deref() == Some(auth_provider) + { + return binding.account_id.clone(); + } + } + + if auth_provider == "github_copilot" { + return self.github_account_id.clone(); + } + + None + } } impl ProviderManager { diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 15449579..864e5771 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -8,7 +8,7 @@ use super::{ failover_switch::FailoverSwitchManager, log_codes::fwd as log_fwd, provider_router::ProviderRouter, - providers::{get_adapter, ProviderAdapter, ProviderType}, + providers::{get_adapter, AuthInfo, AuthStrategy, ProviderAdapter, ProviderType}, thinking_budget_rectifier::{rectify_thinking_budget, should_rectify_thinking_budget}, thinking_rectifier::{ normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature, @@ -16,10 +16,13 @@ use super::{ types::{OptimizerConfig, ProxyStatus, RectifierConfig}, ProxyError, }; +use crate::commands::CopilotAuthState; +use crate::proxy::providers::copilot_auth::CopilotAuthManager; use crate::{app_config::AppType, provider::Provider}; use reqwest::Response; use serde_json::Value; use std::sync::Arc; +use tauri::Manager; use tokio::sync::RwLock; /// Headers 黑名单 - 不透传到上游的 Headers @@ -792,14 +795,27 @@ impl RequestForwarder { // 检查是否需要格式转换 let needs_transform = adapter.needs_transform(provider); + // 确定有效端点 + // GitHub Copilot API 使用 /chat/completions(无 /v1 前缀) + let is_copilot = provider + .meta + .as_ref() + .and_then(|m| m.provider_type.as_deref()) + == Some("github_copilot") + || base_url.contains("githubcopilot.com"); let effective_endpoint = if needs_transform && adapter.name() == "Claude" && endpoint == "/v1/messages" { - // 根据 api_format 选择目标端点 - let api_format = super::providers::get_claude_api_format(provider); - if api_format == "openai_responses" { - "/v1/responses" + if is_copilot { + // GitHub Copilot uses /chat/completions without /v1 prefix + "/chat/completions" } else { - "/v1/chat/completions" + // 根据 api_format 选择目标端点 + let api_format = super::providers::get_claude_api_format(provider); + if api_format == "openai_responses" { + "/v1/responses" + } else { + "/v1/chat/completions" + } } } else { endpoint @@ -892,7 +908,57 @@ impl RequestForwarder { } // 使用适配器添加认证头 - if let Some(auth) = adapter.extract_auth(provider) { + if let Some(mut auth) = adapter.extract_auth(provider) { + // GitHub Copilot 特殊处理:从 CopilotAuthManager 获取真实 token + if auth.strategy == AuthStrategy::GitHubCopilot { + if let Some(app_handle) = &self.app_handle { + let copilot_state = app_handle.state::(); + let copilot_auth: tokio::sync::RwLockReadGuard<'_, CopilotAuthManager> = + copilot_state.0.read().await; + + // 从 provider.meta 获取关联的 GitHub 账号 ID(多账号支持) + let account_id = provider + .meta + .as_ref() + .and_then(|m| m.managed_account_id_for("github_copilot")); + + // 根据账号 ID 获取对应 token(向后兼容:无账号 ID 时使用第一个账号) + let token_result = match &account_id { + Some(id) => { + log::debug!("[Copilot] 使用指定账号 {id} 获取 token"); + copilot_auth.get_valid_token_for_account(id).await + } + None => { + log::debug!("[Copilot] 使用默认账号获取 token"); + copilot_auth.get_valid_token().await + } + }; + + match token_result { + Ok(token) => { + auth = AuthInfo::new(token, AuthStrategy::GitHubCopilot); + log::debug!( + "[Copilot] 成功获取 Copilot token (account={})", + account_id.as_deref().unwrap_or("default") + ); + } + Err(e) => { + log::error!( + "[Copilot] 获取 Copilot token 失败 (account={}): {e}", + account_id.as_deref().unwrap_or("default") + ); + return Err(ProxyError::AuthError(format!( + "GitHub Copilot 认证失败: {e}" + ))); + } + } + } else { + log::error!("[Copilot] AppHandle 不可用"); + return Err(ProxyError::AuthError( + "GitHub Copilot 认证不可用(无 AppHandle)".to_string(), + )); + } + } request = adapter.add_auth_headers(request, &auth); } diff --git a/src-tauri/src/proxy/providers/auth.rs b/src-tauri/src/proxy/providers/auth.rs index 77385932..3ec6b8bd 100644 --- a/src-tauri/src/proxy/providers/auth.rs +++ b/src-tauri/src/proxy/providers/auth.rs @@ -112,6 +112,13 @@ pub enum AuthStrategy { /// /// 用于 Gemini CLI 等需要 OAuth 的场景 GoogleOAuth, + + /// GitHub Copilot 认证方式 + /// + /// - Header: `Authorization: Bearer ` + /// + /// 使用动态获取的 Copilot Token(通过 GitHub OAuth 设备码流程获取) + GitHubCopilot, } #[cfg(test)] @@ -226,6 +233,7 @@ mod tests { AuthStrategy::Bearer, AuthStrategy::Google, AuthStrategy::GoogleOAuth, + AuthStrategy::GitHubCopilot, ]; for (i, s1) in strategies.iter().enumerate() { diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index fe33930c..6c63a050 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -11,6 +11,7 @@ //! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version) //! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key) //! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传 +//! - **GitHubCopilot**: GitHub Copilot (OAuth + Copilot Token) use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType}; use crate::provider::Provider; @@ -76,10 +77,16 @@ impl ClaudeAdapter { /// 获取供应商类型 /// /// 根据 base_url 和 auth_mode 检测具体的供应商类型: + /// - GitHubCopilot: meta.provider_type 为 github_copilot 或 base_url 包含 githubcopilot.com /// - OpenRouter: base_url 包含 openrouter.ai /// - ClaudeAuth: auth_mode 为 bearer_only /// - Claude: 默认 Anthropic 官方 pub fn provider_type(&self, provider: &Provider) -> ProviderType { + // 检测 GitHub Copilot + if self.is_github_copilot(provider) { + return ProviderType::GitHubCopilot; + } + // 检测 OpenRouter if self.is_openrouter(provider) { return ProviderType::OpenRouter; @@ -93,6 +100,25 @@ impl ClaudeAdapter { ProviderType::Claude } + /// 检测是否为 GitHub Copilot 供应商 + fn is_github_copilot(&self, provider: &Provider) -> bool { + // 方式1: 检查 meta.provider_type + if let Some(meta) = provider.meta.as_ref() { + if meta.provider_type.as_deref() == Some("github_copilot") { + return true; + } + } + + // 方式2: 检查 base_url(兼容旧数据的 fallback,后续应优先依赖 providerType) + if let Ok(base_url) = self.extract_base_url(provider) { + if base_url.contains("githubcopilot.com") { + return true; + } + } + + false + } + /// 检测是否使用 OpenRouter fn is_openrouter(&self, provider: &Provider) -> bool { if let Ok(base_url) = self.extract_base_url(provider) { @@ -244,6 +270,17 @@ impl ProviderAdapter for ClaudeAdapter { fn extract_auth(&self, provider: &Provider) -> Option { let provider_type = self.provider_type(provider); + + // GitHub Copilot 使用特殊的认证策略 + // 实际的 token 会在代理请求时动态获取 + if provider_type == ProviderType::GitHubCopilot { + // 返回一个占位符,实际 token 由 CopilotAuthManager 动态提供 + return Some(AuthInfo::new( + "copilot_placeholder".to_string(), + AuthStrategy::GitHubCopilot, + )); + } + let strategy = match provider_type { ProviderType::OpenRouter => AuthStrategy::Bearer, ProviderType::ClaudeAuth => AuthStrategy::ClaudeAuth, @@ -273,6 +310,11 @@ impl ProviderAdapter for ClaudeAdapter { base = base.replace("/v1/v1", "/v1"); } + // GitHub Copilot 不需要 ?beta=true 参数 + if base_url.contains("githubcopilot.com") { + return base; + } + // 为 Claude 原生 /v1/messages 端点添加 ?beta=true 参数 // 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数 // 注意:不要为 OpenAI Chat Completions (/v1/chat/completions) 添加此参数 @@ -304,11 +346,22 @@ impl ProviderAdapter for ClaudeAdapter { AuthStrategy::Bearer => { request.header("Authorization", format!("Bearer {}", auth.api_key)) } + // GitHub Copilot: Bearer + 特定的 Editor headers + AuthStrategy::GitHubCopilot => request + .header("Authorization", format!("Bearer {}", auth.api_key)) + .header("Editor-Version", "vscode/1.85.0") + .header("Editor-Plugin-Version", "copilot/1.150.0") + .header("Copilot-Integration-Id", "vscode-chat"), _ => request, } } fn needs_transform(&self, provider: &Provider) -> bool { + // GitHub Copilot 总是需要格式转换 (Anthropic → OpenAI) + if self.is_github_copilot(provider) { + return true; + } + // 根据 api_format 配置决定是否需要格式转换 // - "anthropic" (默认): 直接透传,无需转换 // - "openai_chat": 需要 Anthropic ↔ OpenAI Chat Completions 格式转换 @@ -678,4 +731,67 @@ mod tests { ); assert!(!adapter.needs_transform(&unknown_format)); } + + #[test] + fn test_github_copilot_detection_by_url() { + let adapter = ClaudeAdapter::new(); + + // GitHub Copilot by base_url + let copilot = create_provider(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.githubcopilot.com" + } + })); + assert_eq!(adapter.provider_type(&copilot), ProviderType::GitHubCopilot); + } + + #[test] + fn test_github_copilot_detection_by_meta() { + let adapter = ClaudeAdapter::new(); + + // GitHub Copilot by meta.provider_type + let copilot_meta = create_provider_with_meta( + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.example.com" + } + }), + ProviderMeta { + provider_type: Some("github_copilot".to_string()), + ..Default::default() + }, + ); + assert_eq!( + adapter.provider_type(&copilot_meta), + ProviderType::GitHubCopilot + ); + } + + #[test] + fn test_github_copilot_auth() { + let adapter = ClaudeAdapter::new(); + + let copilot = create_provider(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.githubcopilot.com" + } + })); + + let auth = adapter.extract_auth(&copilot).unwrap(); + assert_eq!(auth.strategy, AuthStrategy::GitHubCopilot); + } + + #[test] + fn test_github_copilot_needs_transform() { + let adapter = ClaudeAdapter::new(); + + let copilot = create_provider(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.githubcopilot.com" + } + })); + + // GitHub Copilot always needs transform + assert!(adapter.needs_transform(&copilot)); + } } diff --git a/src-tauri/src/proxy/providers/copilot_auth.rs b/src-tauri/src/proxy/providers/copilot_auth.rs new file mode 100644 index 00000000..7fcbf428 --- /dev/null +++ b/src-tauri/src/proxy/providers/copilot_auth.rs @@ -0,0 +1,1313 @@ +//! GitHub Copilot Authentication Module +//! +//! 实现 GitHub OAuth 设备码流程和 Copilot 令牌管理。 +//! 支持多账号认证,每个 Provider 可关联不同的 GitHub 账号。 +//! +//! ## 认证流程 +//! 1. 启动设备码流程,获取 device_code 和 user_code +//! 2. 用户在浏览器中完成 GitHub 授权 +//! 3. 轮询获取 access_token +//! 4. 使用 GitHub token 获取 Copilot token +//! 5. 自动刷新 Copilot token(到期前 60 秒) +//! +//! ## 多账号支持 (v3) +//! - 每个 GitHub 账号独立存储 token +//! - Provider 通过 meta.authBinding 关联账号 +//! - 自动迁移 v1 单账号格式到 v3 多账号 + 默认账号格式 + +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::io::Write; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::{Mutex, RwLock}; + +/// GitHub OAuth 客户端 ID(VS Code 使用的 ID) +const GITHUB_CLIENT_ID: &str = "Iv1.b507a08c87ecfe98"; + +/// GitHub 设备码 URL +const GITHUB_DEVICE_CODE_URL: &str = "https://github.com/login/device/code"; + +/// GitHub OAuth Token URL +const GITHUB_OAUTH_TOKEN_URL: &str = "https://github.com/login/oauth/access_token"; + +/// Copilot Token URL +const COPILOT_TOKEN_URL: &str = "https://api.github.com/copilot_internal/v2/token"; + +/// GitHub User API URL +const GITHUB_USER_URL: &str = "https://api.github.com/user"; + +/// Token 刷新提前量(秒) +const TOKEN_REFRESH_BUFFER_SECONDS: i64 = 60; + +/// Copilot API 端点 +const COPILOT_MODELS_URL: &str = "https://api.githubcopilot.com/models"; + +/// Copilot API Header 常量 +const COPILOT_EDITOR_VERSION: &str = "vscode/1.96.0"; +const COPILOT_PLUGIN_VERSION: &str = "copilot-chat/0.26.7"; +const COPILOT_USER_AGENT: &str = "GitHubCopilotChat/0.26.7"; +const COPILOT_API_VERSION: &str = "2025-04-01"; + +/// Copilot 使用量 API URL +const COPILOT_USAGE_URL: &str = "https://api.github.com/copilot_internal/user"; + +/// Copilot 使用量响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CopilotUsageResponse { + /// Copilot 计划类型 + pub copilot_plan: String, + /// 配额重置日期 + pub quota_reset_date: String, + /// 配额快照 + pub quota_snapshots: QuotaSnapshots, +} + +/// 配额快照 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QuotaSnapshots { + /// Chat 配额 + pub chat: QuotaDetail, + /// Completions 配额 + pub completions: QuotaDetail, + /// Premium 交互配额 + pub premium_interactions: QuotaDetail, +} + +/// 配额详情 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QuotaDetail { + /// 总配额 + pub entitlement: i64, + /// 剩余配额 + pub remaining: i64, + /// 剩余百分比 + pub percent_remaining: f64, + /// 是否无限 + pub unlimited: bool, +} + +/// Copilot 可用模型 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CopilotModel { + /// 模型 ID(用于 API 调用) + pub id: String, + /// 模型显示名称 + pub name: String, + /// 模型供应商 + pub vendor: String, + /// 是否在模型选择器中显示 + pub model_picker_enabled: bool, +} + +/// Copilot Models API 响应 +#[derive(Debug, Deserialize)] +struct CopilotModelsResponse { + data: Vec, +} + +/// Copilot Models API 响应项 +#[derive(Debug, Deserialize)] +struct CopilotModelsResponseItem { + id: String, + name: String, + vendor: String, + model_picker_enabled: bool, +} + +/// Copilot 认证错误 +#[derive(Debug, thiserror::Error)] +pub enum CopilotAuthError { + #[error("设备码流程未启动")] + DeviceFlowNotStarted, + + #[error("等待用户授权中")] + AuthorizationPending, + + #[error("用户拒绝授权")] + AccessDenied, + + #[error("设备码已过期")] + ExpiredToken, + + #[error("GitHub 令牌无效或已过期")] + GitHubTokenInvalid, + + #[error("Copilot 令牌获取失败: {0}")] + CopilotTokenFetchFailed(String), + + #[error("网络错误: {0}")] + NetworkError(String), + + #[error("解析错误: {0}")] + ParseError(String), + + #[error("IO 错误: {0}")] + IoError(String), + + #[error("用户未订阅 Copilot")] + NoCopilotSubscription, + + #[error("账号不存在: {0}")] + AccountNotFound(String), +} + +impl From for CopilotAuthError { + fn from(err: reqwest::Error) -> Self { + CopilotAuthError::NetworkError(err.to_string()) + } +} + +impl From for CopilotAuthError { + fn from(err: std::io::Error) -> Self { + CopilotAuthError::IoError(err.to_string()) + } +} + +/// GitHub 设备码响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GitHubDeviceCodeResponse { + /// 设备码(用于轮询) + pub device_code: String, + /// 用户码(显示给用户) + pub user_code: String, + /// 验证 URL + pub verification_uri: String, + /// 过期时间(秒) + pub expires_in: u64, + /// 轮询间隔(秒) + pub interval: u64, +} + +/// GitHub OAuth Token 响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +struct GitHubOAuthResponse { + access_token: Option, + token_type: Option, + scope: Option, + error: Option, + error_description: Option, +} + +/// Copilot Token +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CopilotToken { + /// JWT Token + pub token: String, + /// 过期时间戳(Unix 秒) + pub expires_at: i64, +} + +impl CopilotToken { + /// 检查令牌是否即将过期(提前 60 秒) + pub fn is_expiring_soon(&self) -> bool { + let now = chrono::Utc::now().timestamp(); + self.expires_at - now < TOKEN_REFRESH_BUFFER_SECONDS + } +} + +/// Copilot Token API 响应 +#[derive(Debug, Deserialize)] +struct CopilotTokenResponse { + token: String, + expires_at: i64, + #[allow(dead_code)] + refresh_in: Option, +} + +/// GitHub 用户信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GitHubUser { + pub login: String, + pub id: u64, + pub avatar_url: Option, +} + +/// GitHub 账号(公开信息,返回给前端) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GitHubAccount { + /// GitHub 用户 ID(字符串形式,作为唯一标识) + pub id: String, + /// GitHub 用户名 + pub login: String, + /// 头像 URL + pub avatar_url: Option, + /// 认证时间戳 + pub authenticated_at: i64, +} + +impl From<&GitHubAccountData> for GitHubAccount { + fn from(data: &GitHubAccountData) -> Self { + GitHubAccount { + id: data.user.id.to_string(), + login: data.user.login.clone(), + avatar_url: data.user.avatar_url.clone(), + authenticated_at: data.authenticated_at, + } + } +} + +/// Copilot 认证状态(支持多账号) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CopilotAuthStatus { + /// 所有已认证的账号 + pub accounts: Vec, + /// 默认账号 ID(显式状态,避免依赖 HashMap 顺序) + pub default_account_id: Option, + /// 旧认证数据迁移失败时的状态消息(用于前端提示) + pub migration_error: Option, + /// 是否已认证(向后兼容:有任意账号即为 true) + pub authenticated: bool, + /// GitHub 用户名(向后兼容:第一个账号的用户名) + pub username: Option, + /// Copilot 令牌过期时间(向后兼容:第一个账号的过期时间) + pub expires_at: Option, +} + +/// 账号数据(内部存储结构) +#[derive(Debug, Clone, Serialize, Deserialize)] +struct GitHubAccountData { + /// GitHub OAuth Token + /// + /// 安全说明:为了复用登录状态,本地会持久化该令牌。 + /// 当前实现未接入系统钥匙串,依赖私有文件权限(Unix 下 0600)保护。 + pub github_token: String, + /// 用户信息 + pub user: GitHubUser, + /// 认证时间戳 + pub authenticated_at: i64, +} + +/// 持久化存储结构(v3 多账号 + 默认账号格式) +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +struct CopilotAuthStore { + /// 存储格式版本(3 = 多账号 + 默认账号格式) + #[serde(default)] + version: u32, + /// 多账号数据(key = GitHub user ID) + #[serde(default)] + accounts: HashMap, + /// 默认账号 ID + #[serde(skip_serializing_if = "Option::is_none")] + default_account_id: Option, + /// 兼容 v1 单账号格式的字段 + #[serde(skip_serializing_if = "Option::is_none")] + github_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + authenticated_at: Option, +} + +/// Copilot 认证管理器(支持多账号) +pub struct CopilotAuthManager { + /// 所有 GitHub 账号(key = GitHub user ID) + accounts: Arc>>, + /// 默认账号 ID + default_account_id: Arc>>, + /// 每个账号的刷新锁,避免并发刷新重复打 GitHub API + refresh_locks: Arc>>>>, + /// Copilot Token 缓存(key = GitHub user ID,内存缓存,自动刷新) + copilot_tokens: Arc>>, + /// HTTP 客户端 + http_client: Client, + /// 存储路径 + storage_path: PathBuf, + /// 待迁移的旧格式 token + pending_migration: Arc>>, + /// 旧认证数据迁移失败时的状态消息 + migration_error: Arc>>, +} + +impl CopilotAuthManager { + /// 创建新的认证管理器 + pub fn new(data_dir: PathBuf) -> Self { + let storage_path = data_dir.join("copilot_auth.json"); + + let manager = Self { + accounts: Arc::new(RwLock::new(HashMap::new())), + default_account_id: Arc::new(RwLock::new(None)), + refresh_locks: Arc::new(RwLock::new(HashMap::new())), + copilot_tokens: Arc::new(RwLock::new(HashMap::new())), + http_client: Client::new(), + storage_path, + pending_migration: Arc::new(RwLock::new(None)), + migration_error: Arc::new(RwLock::new(None)), + }; + + // 尝试从磁盘加载(同步,不发起网络请求) + if let Err(e) = manager.load_from_disk_sync() { + log::warn!("[CopilotAuth] 加载存储失败: {}", e); + } + + manager + } + + // ==================== 多账号管理方法 ==================== + + /// 列出所有已认证的账号 + pub async fn list_accounts(&self) -> Vec { + let accounts = self.accounts.read().await.clone(); + let default_account_id = self.resolve_default_account_id().await; + Self::sorted_accounts(&accounts, default_account_id.as_deref()) + } + + /// 获取指定账号信息 + pub async fn get_account(&self, account_id: &str) -> Option { + let accounts = self.accounts.read().await; + accounts.get(account_id).map(GitHubAccount::from) + } + + /// 移除指定账号 + pub async fn remove_account(&self, account_id: &str) -> Result<(), CopilotAuthError> { + log::info!("[CopilotAuth] 移除账号: {}", account_id); + + { + let mut accounts = self.accounts.write().await; + if accounts.remove(account_id).is_none() { + return Err(CopilotAuthError::AccountNotFound(account_id.to_string())); + } + } + + // 同时移除缓存的 Copilot token + { + let mut tokens = self.copilot_tokens.write().await; + tokens.remove(account_id); + } + { + let mut refresh_locks = self.refresh_locks.write().await; + refresh_locks.remove(account_id); + } + + { + let accounts = self.accounts.read().await; + let mut default_account_id = self.default_account_id.write().await; + if default_account_id.as_deref() == Some(account_id) { + *default_account_id = Self::fallback_default_account_id(&accounts); + } + } + + // 持久化 + self.save_to_disk().await?; + + Ok(()) + } + + /// 添加新账号(内部方法,在 OAuth 完成后调用) + async fn add_account_internal( + &self, + github_token: String, + user: GitHubUser, + ) -> Result { + let account_id = user.id.to_string(); + let now = chrono::Utc::now().timestamp(); + + let account_data = GitHubAccountData { + github_token, + user: user.clone(), + authenticated_at: now, + }; + + let account = GitHubAccount { + id: account_id.clone(), + login: user.login.clone(), + avatar_url: user.avatar_url.clone(), + authenticated_at: now, + }; + + { + let mut accounts = self.accounts.write().await; + accounts.insert(account_id, account_data); + } + + { + let mut default_account_id = self.default_account_id.write().await; + if default_account_id.is_none() { + *default_account_id = Some(account.id.clone()); + } + } + + self.set_migration_error(None).await; + + // 持久化 + self.save_to_disk().await?; + + log::info!("[CopilotAuth] 添加账号成功: {}", user.login); + + Ok(account) + } + + /// 设置默认账号 + pub async fn set_default_account(&self, account_id: &str) -> Result<(), CopilotAuthError> { + { + let accounts = self.accounts.read().await; + if !accounts.contains_key(account_id) { + return Err(CopilotAuthError::AccountNotFound(account_id.to_string())); + } + } + + { + let mut default_account_id = self.default_account_id.write().await; + *default_account_id = Some(account_id.to_string()); + } + + self.save_to_disk().await?; + Ok(()) + } + + // ==================== 设备码流程 ==================== + + /// 启动设备码流程 + pub async fn start_device_flow(&self) -> Result { + log::info!("[CopilotAuth] 启动设备码流程"); + + let response = self + .http_client + .post(GITHUB_DEVICE_CODE_URL) + .header("Accept", "application/json") + .form(&[("client_id", GITHUB_CLIENT_ID), ("scope", "read:user")]) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + return Err(CopilotAuthError::NetworkError(format!( + "GitHub 设备码请求失败: {} - {}", + status, text + ))); + } + + let device_code: GitHubDeviceCodeResponse = response + .json() + .await + .map_err(|e| CopilotAuthError::ParseError(e.to_string()))?; + + log::info!( + "[CopilotAuth] 获取设备码成功,user_code: {}", + device_code.user_code + ); + + Ok(device_code) + } + + /// 轮询获取 OAuth Token(返回新添加的账号,如果成功) + pub async fn poll_for_token( + &self, + device_code: &str, + ) -> Result, CopilotAuthError> { + log::debug!("[CopilotAuth] 轮询 OAuth Token"); + + let response = self + .http_client + .post(GITHUB_OAUTH_TOKEN_URL) + .header("Accept", "application/json") + .form(&[ + ("client_id", GITHUB_CLIENT_ID), + ("device_code", device_code), + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ]) + .send() + .await?; + + let oauth_response: GitHubOAuthResponse = response + .json() + .await + .map_err(|e| CopilotAuthError::ParseError(e.to_string()))?; + + // 检查错误 + if let Some(error) = oauth_response.error { + return match error.as_str() { + "authorization_pending" => Err(CopilotAuthError::AuthorizationPending), + "slow_down" => Err(CopilotAuthError::AuthorizationPending), + "expired_token" => Err(CopilotAuthError::ExpiredToken), + "access_denied" => Err(CopilotAuthError::AccessDenied), + _ => Err(CopilotAuthError::NetworkError(format!( + "{}: {}", + error, + oauth_response.error_description.unwrap_or_default() + ))), + }; + } + + // 获取 access_token + let access_token = oauth_response + .access_token + .ok_or_else(|| CopilotAuthError::ParseError("缺少 access_token".to_string()))?; + + log::info!("[CopilotAuth] OAuth Token 获取成功"); + + // 获取用户信息 + let user = self.fetch_user_info_with_token(&access_token).await?; + + // 验证 Copilot 订阅(获取 Copilot Token) + self.fetch_copilot_token_with_github_token(&access_token, &user.id.to_string()) + .await?; + + // 添加账号 + let account = self.add_account_internal(access_token, user).await?; + + Ok(Some(account)) + } + + // ==================== Token 获取方法 ==================== + + /// 获取指定账号的有效 Copilot Token(自动刷新) + pub async fn get_valid_token_for_account( + &self, + account_id: &str, + ) -> Result { + // 确保迁移完成 + self.ensure_migration_complete().await?; + + // 检查缓存的 token + { + let tokens = self.copilot_tokens.read().await; + if let Some(copilot_token) = tokens.get(account_id) { + if !copilot_token.is_expiring_soon() { + return Ok(copilot_token.token.clone()); + } + } + } + + // 需要刷新 + log::info!( + "[CopilotAuth] 账号 {} 的 Copilot Token 需要刷新", + account_id + ); + + let refresh_lock = self.get_refresh_lock(account_id).await; + let _refresh_guard = refresh_lock.lock().await; + + // double-check:等待锁期间可能已由其他请求刷新完成 + { + let tokens = self.copilot_tokens.read().await; + if let Some(copilot_token) = tokens.get(account_id) { + if !copilot_token.is_expiring_soon() { + return Ok(copilot_token.token.clone()); + } + } + } + + // 获取账号的 GitHub token + let github_token = { + let accounts = self.accounts.read().await; + accounts + .get(account_id) + .map(|a| a.github_token.clone()) + .ok_or_else(|| CopilotAuthError::AccountNotFound(account_id.to_string()))? + }; + + // 刷新 Copilot token + self.fetch_copilot_token_with_github_token(&github_token, account_id) + .await?; + + // 返回新 token + let tokens = self.copilot_tokens.read().await; + tokens.get(account_id).map(|t| t.token.clone()).ok_or( + CopilotAuthError::CopilotTokenFetchFailed("刷新后仍无令牌".to_string()), + ) + } + + /// 获取有效的 Copilot Token(向后兼容:使用第一个账号) + pub async fn get_valid_token(&self) -> Result { + // 确保迁移完成 + self.ensure_migration_complete().await?; + + match self.resolve_default_account_id().await { + Some(id) => self.get_valid_token_for_account(&id).await, + None => Err(CopilotAuthError::GitHubTokenInvalid), + } + } + + // ==================== 模型和使用量 ==================== + + /// 获取指定账号的 Copilot 可用模型列表 + pub async fn fetch_models_for_account( + &self, + account_id: &str, + ) -> Result, CopilotAuthError> { + let copilot_token = self.get_valid_token_for_account(account_id).await?; + + log::info!("[CopilotAuth] 获取账号 {} 的 Copilot 可用模型", account_id); + + let response = self + .http_client + .get(COPILOT_MODELS_URL) + .header("Authorization", format!("Bearer {}", copilot_token)) + .header("Content-Type", "application/json") + .header("copilot-integration-id", "vscode-chat") + .header("editor-version", COPILOT_EDITOR_VERSION) + .header("editor-plugin-version", COPILOT_PLUGIN_VERSION) + .header("user-agent", COPILOT_USER_AGENT) + .header("x-github-api-version", COPILOT_API_VERSION) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + return Err(CopilotAuthError::CopilotTokenFetchFailed(format!( + "获取模型列表失败: {} - {}", + status, text + ))); + } + + let models_response: CopilotModelsResponse = response + .json() + .await + .map_err(|e| CopilotAuthError::ParseError(e.to_string()))?; + + let models: Vec = models_response + .data + .into_iter() + .filter(|m| m.model_picker_enabled) + .map(|m| CopilotModel { + id: m.id, + name: m.name, + vendor: m.vendor, + model_picker_enabled: m.model_picker_enabled, + }) + .collect(); + + log::info!("[CopilotAuth] 获取到 {} 个可用模型", models.len()); + + Ok(models) + } + + /// 获取 Copilot 可用模型列表(向后兼容:使用第一个账号) + pub async fn fetch_models(&self) -> Result, CopilotAuthError> { + match self.resolve_default_account_id().await { + Some(id) => self.fetch_models_for_account(&id).await, + None => Err(CopilotAuthError::GitHubTokenInvalid), + } + } + + /// 获取指定账号的 Copilot 使用量信息 + pub async fn fetch_usage_for_account( + &self, + account_id: &str, + ) -> Result { + let github_token = { + let accounts = self.accounts.read().await; + accounts + .get(account_id) + .map(|a| a.github_token.clone()) + .ok_or_else(|| CopilotAuthError::AccountNotFound(account_id.to_string()))? + }; + + log::info!("[CopilotAuth] 获取账号 {} 的 Copilot 使用量", account_id); + + let response = self + .http_client + .get(COPILOT_USAGE_URL) + .header("Authorization", format!("token {}", github_token)) + .header("Content-Type", "application/json") + .header("editor-version", COPILOT_EDITOR_VERSION) + .header("editor-plugin-version", COPILOT_PLUGIN_VERSION) + .header("user-agent", COPILOT_USER_AGENT) + .header("x-github-api-version", COPILOT_API_VERSION) + .send() + .await?; + + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err(CopilotAuthError::GitHubTokenInvalid); + } + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + return Err(CopilotAuthError::CopilotTokenFetchFailed(format!( + "获取使用量失败: {} - {}", + status, text + ))); + } + + let usage: CopilotUsageResponse = response + .json() + .await + .map_err(|e| CopilotAuthError::ParseError(e.to_string()))?; + + log::info!( + "[CopilotAuth] 获取使用量成功,计划: {}, 重置日期: {}", + usage.copilot_plan, + usage.quota_reset_date + ); + + Ok(usage) + } + + /// 获取 Copilot 使用量信息(向后兼容:使用第一个账号) + pub async fn fetch_usage(&self) -> Result { + match self.resolve_default_account_id().await { + Some(id) => self.fetch_usage_for_account(&id).await, + None => Err(CopilotAuthError::GitHubTokenInvalid), + } + } + + // ==================== 状态查询 ==================== + + /// 获取认证状态(支持多账号) + pub async fn get_status(&self) -> CopilotAuthStatus { + // 确保迁移完成 + let _ = self.ensure_migration_complete().await; + + let accounts = self.accounts.read().await.clone(); + let default_account_id = self.resolve_default_account_id().await; + let copilot_tokens = self.copilot_tokens.read().await.clone(); + let migration_error = self.migration_error.read().await.clone(); + + let account_list = Self::sorted_accounts(&accounts, default_account_id.as_deref()); + let authenticated = !account_list.is_empty(); + let username = default_account_id + .as_ref() + .and_then(|id| accounts.get(id)) + .map(|a| a.user.login.clone()) + .or_else(|| account_list.first().map(|a| a.login.clone())); + + // 获取默认账号的过期时间 + let expires_at = default_account_id + .as_ref() + .and_then(|id| copilot_tokens.get(id)) + .map(|t| t.expires_at); + + CopilotAuthStatus { + accounts: account_list, + default_account_id, + migration_error, + authenticated, + username, + expires_at, + } + } + + /// 检查是否已认证(有任意账号) + pub async fn is_authenticated(&self) -> bool { + let accounts = self.accounts.read().await; + !accounts.is_empty() + } + + /// 清除所有认证(登出所有账号) + pub async fn clear_auth(&self) -> Result<(), CopilotAuthError> { + log::info!("[CopilotAuth] 清除所有认证"); + + { + let mut accounts = self.accounts.write().await; + accounts.clear(); + } + { + let mut default_account_id = self.default_account_id.write().await; + default_account_id.take(); + } + self.set_migration_error(None).await; + { + let mut tokens = self.copilot_tokens.write().await; + tokens.clear(); + } + { + let mut refresh_locks = self.refresh_locks.write().await; + refresh_locks.clear(); + } + + // 删除存储文件 + if self.storage_path.exists() { + std::fs::remove_file(&self.storage_path)?; + } + + Ok(()) + } + + // ==================== 内部方法 ==================== + + fn fallback_default_account_id( + accounts: &HashMap, + ) -> Option { + accounts + .iter() + .max_by(|(id_a, a), (id_b, b)| { + a.authenticated_at + .cmp(&b.authenticated_at) + .then_with(|| id_b.cmp(id_a)) + }) + .map(|(id, _)| id.clone()) + } + + fn sorted_accounts( + accounts: &HashMap, + default_account_id: Option<&str>, + ) -> Vec { + let mut account_list: Vec = + accounts.values().map(GitHubAccount::from).collect(); + account_list.sort_by(|a, b| { + let a_default = default_account_id == Some(a.id.as_str()); + let b_default = default_account_id == Some(b.id.as_str()); + + b_default + .cmp(&a_default) + .then_with(|| b.authenticated_at.cmp(&a.authenticated_at)) + .then_with(|| a.login.cmp(&b.login)) + }); + account_list + } + + async fn resolve_default_account_id(&self) -> Option { + let stored_default = self.default_account_id.read().await.clone(); + let accounts = self.accounts.read().await; + + if let Some(default_id) = stored_default { + if accounts.contains_key(&default_id) { + return Some(default_id); + } + } + + Self::fallback_default_account_id(&accounts) + } + + async fn get_refresh_lock(&self, account_id: &str) -> Arc> { + { + let refresh_locks = self.refresh_locks.read().await; + if let Some(lock) = refresh_locks.get(account_id) { + return Arc::clone(lock); + } + } + + let mut refresh_locks = self.refresh_locks.write().await; + Arc::clone( + refresh_locks + .entry(account_id.to_string()) + .or_insert_with(|| Arc::new(Mutex::new(()))), + ) + } + + async fn set_migration_error(&self, message: Option) { + let mut migration_error = self.migration_error.write().await; + *migration_error = message; + } + + fn write_store_atomic(&self, content: &str) -> Result<(), CopilotAuthError> { + if let Some(parent) = self.storage_path.parent() { + fs::create_dir_all(parent)?; + } + + let parent = self + .storage_path + .parent() + .ok_or_else(|| CopilotAuthError::IoError("无效的存储路径".to_string()))?; + let file_name = self + .storage_path + .file_name() + .ok_or_else(|| CopilotAuthError::IoError("无效的存储文件名".to_string()))? + .to_string_lossy() + .to_string(); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let tmp_path = parent.join(format!("{file_name}.tmp.{ts}")); + + #[cfg(unix)] + { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let mut file = fs::OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(&tmp_path)?; + file.write_all(content.as_bytes())?; + file.flush()?; + + fs::rename(&tmp_path, &self.storage_path)?; + fs::set_permissions(&self.storage_path, fs::Permissions::from_mode(0o600))?; + } + + #[cfg(windows)] + { + let mut file = fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&tmp_path)?; + file.write_all(content.as_bytes())?; + file.flush()?; + + if self.storage_path.exists() { + let _ = fs::remove_file(&self.storage_path); + } + fs::rename(&tmp_path, &self.storage_path)?; + } + + Ok(()) + } + + /// 使用指定 token 获取 GitHub 用户信息 + async fn fetch_user_info_with_token( + &self, + github_token: &str, + ) -> Result { + let response = self + .http_client + .get(GITHUB_USER_URL) + .header("Authorization", format!("token {}", github_token)) + .header("User-Agent", "CC-Switch") + .send() + .await?; + + if !response.status().is_success() { + return Err(CopilotAuthError::GitHubTokenInvalid); + } + + let user: GitHubUser = response + .json() + .await + .map_err(|e| CopilotAuthError::ParseError(e.to_string()))?; + + log::info!("[CopilotAuth] 获取用户信息成功: {}", user.login); + + Ok(user) + } + + /// 使用 GitHub token 获取 Copilot Token + async fn fetch_copilot_token_with_github_token( + &self, + github_token: &str, + account_id: &str, + ) -> Result<(), CopilotAuthError> { + log::debug!("[CopilotAuth] 获取账号 {} 的 Copilot Token", account_id); + + let response = self + .http_client + .get(COPILOT_TOKEN_URL) + .header("Authorization", format!("token {}", github_token)) + .header("User-Agent", "CC-Switch") + .header("Editor-Version", "vscode/1.85.0") + .header("Editor-Plugin-Version", "copilot/1.150.0") + .send() + .await?; + + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err(CopilotAuthError::GitHubTokenInvalid); + } + + if response.status() == reqwest::StatusCode::FORBIDDEN { + return Err(CopilotAuthError::NoCopilotSubscription); + } + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + return Err(CopilotAuthError::CopilotTokenFetchFailed(format!( + "{}: {}", + status, text + ))); + } + + let token_response: CopilotTokenResponse = response + .json() + .await + .map_err(|e| CopilotAuthError::ParseError(e.to_string()))?; + + log::info!( + "[CopilotAuth] 账号 {} 的 Copilot Token 获取成功,过期时间: {}", + account_id, + token_response.expires_at + ); + + let copilot_token = CopilotToken { + token: token_response.token, + expires_at: token_response.expires_at, + }; + + let mut tokens = self.copilot_tokens.write().await; + tokens.insert(account_id.to_string(), copilot_token); + + Ok(()) + } + + // ==================== 存储和迁移 ==================== + + /// 从磁盘加载(仅加载 token,不发起网络请求) + fn load_from_disk_sync(&self) -> Result<(), CopilotAuthError> { + if !self.storage_path.exists() { + return Ok(()); + } + + let content = std::fs::read_to_string(&self.storage_path)?; + let store: CopilotAuthStore = serde_json::from_str(&content) + .map_err(|e| CopilotAuthError::ParseError(e.to_string()))?; + + if store.version >= 2 { + // v2 多账号格式 + if let Ok(mut accounts) = self.accounts.try_write() { + *accounts = store.accounts; + log::info!("[CopilotAuth] 从磁盘加载 {} 个账号", accounts.len()); + } + if let Ok(mut default_account_id) = self.default_account_id.try_write() { + *default_account_id = store.default_account_id; + if default_account_id.is_none() { + if let Ok(accounts) = self.accounts.try_read() { + *default_account_id = Self::fallback_default_account_id(&accounts); + } + } + } + } else if store.github_token.is_some() { + // v1 单账号格式,标记待迁移 + log::info!("[CopilotAuth] 检测到旧格式,将在首次访问时迁移"); + if let Ok(mut pending) = self.pending_migration.try_write() { + *pending = store.github_token; + } + } + + Ok(()) + } + + /// 确保迁移完成 + async fn ensure_migration_complete(&self) -> Result<(), CopilotAuthError> { + let pending = { + let guard = self.pending_migration.read().await; + guard.clone() + }; + + if let Some(legacy_token) = pending { + log::info!("[CopilotAuth] 执行旧格式迁移"); + + // 获取用户信息 + match self.fetch_user_info_with_token(&legacy_token).await { + Ok(user) => { + let account_id = user.id.to_string(); + + // 尝试获取 Copilot token 验证订阅 + if let Err(e) = self + .fetch_copilot_token_with_github_token(&legacy_token, &account_id) + .await + { + log::warn!("[CopilotAuth] 迁移时验证 Copilot 订阅失败: {}", e); + } + + // 添加账号 + self.add_account_internal(legacy_token, user).await?; + self.set_migration_error(None).await; + + log::info!("[CopilotAuth] 旧格式迁移完成"); + } + Err(e) => { + self.set_migration_error(Some(format!( + "Legacy Copilot auth migration failed: {e}" + ))) + .await; + log::warn!("[CopilotAuth] 迁移失败,旧 token 可能已失效: {}", e); + } + } + + // 清除待迁移标记 + { + let mut pending = self.pending_migration.write().await; + *pending = None; + } + } + + Ok(()) + } + + /// 保存到磁盘 + async fn save_to_disk(&self) -> Result<(), CopilotAuthError> { + let accounts = self.accounts.read().await.clone(); + let default_account_id = self.resolve_default_account_id().await; + + let store = CopilotAuthStore { + version: 3, + accounts, + default_account_id, + github_token: None, + authenticated_at: None, + }; + + let content = serde_json::to_string_pretty(&store) + .map_err(|e| CopilotAuthError::ParseError(e.to_string()))?; + + self.write_store_atomic(&content)?; + + log::info!( + "[CopilotAuth] 保存到磁盘成功({} 个账号)", + store.accounts.len() + ); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_copilot_token_expiry() { + let now = chrono::Utc::now().timestamp(); + + // 未过期的 token (1小时后过期,不在60秒缓冲期内) + let token = CopilotToken { + token: "test".to_string(), + expires_at: now + 3600, + }; + assert!(!token.is_expiring_soon()); + + // 即将过期的 token (30秒后过期,在60秒缓冲期内) + let token = CopilotToken { + token: "test".to_string(), + expires_at: now + 30, + }; + assert!(token.is_expiring_soon()); + + // 已过期的 token (也在缓冲期内) + let token = CopilotToken { + token: "test".to_string(), + expires_at: now - 100, + }; + assert!(token.is_expiring_soon()); + } + + #[test] + fn test_auth_status_serialization() { + let status = CopilotAuthStatus { + accounts: vec![GitHubAccount { + id: "12345".to_string(), + login: "testuser".to_string(), + avatar_url: Some("https://example.com/avatar.png".to_string()), + authenticated_at: 1234567890, + }], + default_account_id: Some("12345".to_string()), + migration_error: None, + authenticated: true, + username: Some("testuser".to_string()), + expires_at: Some(1234567890), + }; + + let json = serde_json::to_string(&status).unwrap(); + let parsed: CopilotAuthStatus = serde_json::from_str(&json).unwrap(); + + assert!(parsed.authenticated); + assert_eq!(parsed.default_account_id, Some("12345".to_string())); + assert_eq!(parsed.username, Some("testuser".to_string())); + assert_eq!(parsed.expires_at, Some(1234567890)); + assert_eq!(parsed.accounts.len(), 1); + assert_eq!(parsed.accounts[0].id, "12345"); + assert_eq!(parsed.accounts[0].login, "testuser"); + } + + #[test] + fn test_multi_account_store_serialization() { + let mut accounts = HashMap::new(); + accounts.insert( + "12345".to_string(), + GitHubAccountData { + github_token: "gho_test_token".to_string(), + user: GitHubUser { + login: "alice".to_string(), + id: 12345, + avatar_url: Some("https://example.com/alice.png".to_string()), + }, + authenticated_at: 1700000000, + }, + ); + accounts.insert( + "67890".to_string(), + GitHubAccountData { + github_token: "gho_test_token_2".to_string(), + user: GitHubUser { + login: "bob".to_string(), + id: 67890, + avatar_url: None, + }, + authenticated_at: 1700000001, + }, + ); + + let store = CopilotAuthStore { + version: 3, + accounts, + default_account_id: Some("67890".to_string()), + github_token: None, + authenticated_at: None, + }; + + let json = serde_json::to_string_pretty(&store).unwrap(); + let parsed: CopilotAuthStore = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed.version, 3); + assert_eq!(parsed.default_account_id, Some("67890".to_string())); + assert_eq!(parsed.accounts.len(), 2); + assert!(parsed.accounts.contains_key("12345")); + assert!(parsed.accounts.contains_key("67890")); + assert_eq!(parsed.accounts["12345"].user.login, "alice"); + assert_eq!(parsed.accounts["67890"].user.login, "bob"); + } + + #[test] + fn test_legacy_format_detection() { + // 旧格式(v1) + let legacy_json = r#"{ + "github_token": "gho_legacy_token", + "authenticated_at": 1700000000 + }"#; + + let store: CopilotAuthStore = serde_json::from_str(legacy_json).unwrap(); + assert_eq!(store.version, 0); // 默认值 + assert!(store.github_token.is_some()); + assert!(store.accounts.is_empty()); + } + + #[test] + fn test_github_account_from_data() { + let data = GitHubAccountData { + github_token: "gho_test".to_string(), + user: GitHubUser { + login: "testuser".to_string(), + id: 99999, + avatar_url: Some("https://example.com/avatar.png".to_string()), + }, + authenticated_at: 1700000000, + }; + + let account = GitHubAccount::from(&data); + assert_eq!(account.id, "99999"); + assert_eq!(account.login, "testuser"); + assert_eq!( + account.avatar_url, + Some("https://example.com/avatar.png".to_string()) + ); + assert_eq!(account.authenticated_at, 1700000000); + } + + #[test] + fn test_fallback_default_account_prefers_latest_authenticated() { + let mut accounts = HashMap::new(); + accounts.insert( + "12345".to_string(), + GitHubAccountData { + github_token: "gho_test_token".to_string(), + user: GitHubUser { + login: "alice".to_string(), + id: 12345, + avatar_url: None, + }, + authenticated_at: 1700000000, + }, + ); + accounts.insert( + "67890".to_string(), + GitHubAccountData { + github_token: "gho_test_token_2".to_string(), + user: GitHubUser { + login: "bob".to_string(), + id: 67890, + avatar_url: None, + }, + authenticated_at: 1700000001, + }, + ); + + assert_eq!( + CopilotAuthManager::fallback_default_account_id(&accounts), + Some("67890".to_string()) + ); + } +} diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index a7176203..d3e646c5 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -15,6 +15,7 @@ mod adapter; mod auth; mod claude; mod codex; +pub mod copilot_auth; mod gemini; pub mod models; pub mod streaming; @@ -52,6 +53,8 @@ pub enum ProviderType { GeminiCli, /// OpenRouter(已支持 Claude Code 兼容接口,默认透传;保留旧转换逻辑备用) OpenRouter, + /// GitHub Copilot (OAuth + Copilot Token,需要 Anthropic ↔ OpenAI 转换) + GitHubCopilot, } impl ProviderType { @@ -59,9 +62,11 @@ 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::OpenRouter => false, _ => false, } @@ -77,6 +82,7 @@ impl ProviderType { "https://generativelanguage.googleapis.com" } ProviderType::OpenRouter => "https://openrouter.ai/api", + ProviderType::GitHubCopilot => "https://api.githubcopilot.com", } } @@ -87,9 +93,20 @@ impl ProviderType { pub fn from_app_type_and_config(app_type: &AppType, provider: &Provider) -> Self { match app_type { AppType::Claude => { - // 检测是否为 OpenRouter + // 检测是否为 GitHub Copilot + if let Some(meta) = provider.meta.as_ref() { + if meta.provider_type.as_deref() == Some("github_copilot") { + return ProviderType::GitHubCopilot; + } + } + + // 检测 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; } @@ -154,6 +171,7 @@ impl ProviderType { ProviderType::Gemini => "gemini", ProviderType::GeminiCli => "gemini_cli", ProviderType::OpenRouter => "openrouter", + ProviderType::GitHubCopilot => "github_copilot", } } } @@ -175,6 +193,9 @@ impl std::str::FromStr for ProviderType { "gemini" => Ok(ProviderType::Gemini), "gemini_cli" | "gemini-cli" => Ok(ProviderType::GeminiCli), "openrouter" => Ok(ProviderType::OpenRouter), + "github_copilot" | "github-copilot" | "githubcopilot" => { + Ok(ProviderType::GitHubCopilot) + } _ => Err(format!("Invalid provider type: {s}")), } } @@ -201,9 +222,10 @@ pub fn get_adapter(app_type: &AppType) -> Box { #[allow(dead_code)] pub fn get_adapter_for_provider_type(provider_type: &ProviderType) -> Box { match provider_type { - ProviderType::Claude | ProviderType::ClaudeAuth | ProviderType::OpenRouter => { - Box::new(ClaudeAdapter::new()) - } + ProviderType::Claude + | ProviderType::ClaudeAuth + | ProviderType::OpenRouter + | ProviderType::GitHubCopilot => Box::new(ClaudeAdapter::new()), ProviderType::Codex => Box::new(CodexAdapter::new()), ProviderType::Gemini | ProviderType::GeminiCli => Box::new(GeminiAdapter::new()), } @@ -239,6 +261,7 @@ mod tests { assert!(!ProviderType::Gemini.needs_transform()); assert!(!ProviderType::GeminiCli.needs_transform()); assert!(!ProviderType::OpenRouter.needs_transform()); + assert!(ProviderType::GitHubCopilot.needs_transform()); } #[test] @@ -267,6 +290,10 @@ mod tests { ProviderType::OpenRouter.default_endpoint(), "https://openrouter.ai/api" ); + assert_eq!( + ProviderType::GitHubCopilot.default_endpoint(), + "https://api.githubcopilot.com" + ); } #[test] @@ -303,6 +330,18 @@ mod tests { "openrouter".parse::().unwrap(), ProviderType::OpenRouter ); + assert_eq!( + "github_copilot".parse::().unwrap(), + ProviderType::GitHubCopilot + ); + assert_eq!( + "github-copilot".parse::().unwrap(), + ProviderType::GitHubCopilot + ); + assert_eq!( + "githubcopilot".parse::().unwrap(), + ProviderType::GitHubCopilot + ); assert!("invalid".parse::().is_err()); } @@ -314,6 +353,7 @@ mod tests { 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] @@ -434,6 +474,9 @@ mod tests { 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"); diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index 158f615b..b889ad46 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -12,6 +12,7 @@ use std::time::Instant; use crate::app_config::AppType; use crate::error::AppError; use crate::provider::Provider; +use crate::proxy::providers::transform::anthropic_to_openai; use crate::proxy::providers::{get_adapter, AuthInfo, AuthStrategy}; /// 健康状态枚举 @@ -84,13 +85,16 @@ impl StreamCheckService { app_type: &AppType, provider: &Provider, config: &StreamCheckConfig, + auth_override: Option, ) -> Result { // 合并供应商单独配置和全局配置 let effective_config = Self::merge_provider_config(provider, config); let mut last_result = None; for attempt in 0..=effective_config.max_retries { - let result = Self::check_once(app_type, provider, &effective_config).await; + let result = + Self::check_once(app_type, provider, &effective_config, auth_override.clone()) + .await; match &result { Ok(r) if r.success => { @@ -178,6 +182,7 @@ impl StreamCheckService { app_type: &AppType, provider: &Provider, config: &StreamCheckConfig, + auth_override: Option, ) -> Result { let start = Instant::now(); let adapter = get_adapter(app_type); @@ -186,8 +191,8 @@ impl StreamCheckService { .extract_base_url(provider) .map_err(|e| AppError::Message(format!("Failed to extract base_url: {e}")))?; - let auth = adapter - .extract_auth(provider) + let auth = auth_override + .or_else(|| adapter.extract_auth(provider)) .ok_or_else(|| AppError::Message("API Key not found".to_string()))?; // 获取 HTTP 客户端:优先使用供应商单独代理配置,否则使用全局客户端 @@ -297,6 +302,7 @@ impl StreamCheckService { provider: &Provider, ) -> Result<(u16, String), AppError> { let base = base_url.trim_end_matches('/'); + let is_github_copilot = auth.strategy == AuthStrategy::GitHubCopilot; // Detect api_format: meta.api_format > settings_config.api_format > default "anthropic" let api_format = provider @@ -311,10 +317,15 @@ impl StreamCheckService { }) .unwrap_or("anthropic"); - let is_openai_chat = api_format == "openai_chat"; + let is_openai_chat = is_github_copilot || api_format == "openai_chat"; - // URL: /v1/chat/completions for openai_chat, /v1/messages?beta=true for anthropic - let url = if is_openai_chat { + // URL: + // - GitHub Copilot: /chat/completions (no /v1 prefix) + // - OpenAI-compatible: /v1/chat/completions + // - Anthropic native: /v1/messages?beta=true + let url = if is_github_copilot { + format!("{base}/chat/completions") + } else if is_openai_chat { if base.ends_with("/v1") { format!("{base}/chat/completions") } else { @@ -329,22 +340,38 @@ impl StreamCheckService { } }; - // Body: identical structure for minimal test (both APIs accept messages array) - let body = json!({ + // Build from Anthropic-native shape first, then convert for OpenAI-compatible targets. + let anthropic_body = json!({ "model": model, "max_tokens": 1, "messages": [{ "role": "user", "content": test_prompt }], "stream": true }); + let body = if is_openai_chat { + anthropic_to_openai(anthropic_body, Some(&provider.id)) + .map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))? + } else { + anthropic_body + }; let mut request_builder = client.post(&url); - if is_openai_chat { + if is_github_copilot { + request_builder = request_builder + .header("authorization", format!("Bearer {}", auth.api_key)) + .header("content-type", "application/json") + .header("accept", "text/event-stream") + .header("accept-encoding", "identity") + .header("editor-version", "vscode/1.85.0") + .header("editor-plugin-version", "copilot/1.150.0") + .header("copilot-integration-id", "vscode-chat"); + } else if is_openai_chat { // OpenAI-compatible: Bearer auth + standard headers only request_builder = request_builder .header("authorization", format!("Bearer {}", auth.api_key)) .header("content-type", "application/json") - .header("accept", "application/json"); + .header("accept", "text/event-stream") + .header("accept-encoding", "identity"); } else { // Anthropic native: full Claude CLI headers let os_name = Self::get_os_name(); @@ -692,6 +719,31 @@ impl StreamCheckService { other => other, } } + + #[cfg(test)] + fn resolve_claude_stream_url( + base_url: &str, + auth_strategy: AuthStrategy, + api_format: &str, + ) -> String { + let base = base_url.trim_end_matches('/'); + let is_github_copilot = auth_strategy == AuthStrategy::GitHubCopilot; + let is_openai_chat = is_github_copilot || api_format == "openai_chat"; + + if is_github_copilot { + format!("{base}/chat/completions") + } else if is_openai_chat { + if base.ends_with("/v1") { + format!("{base}/chat/completions") + } else { + format!("{base}/v1/chat/completions") + } + } else if base.ends_with("/v1") { + format!("{base}/messages?beta=true") + } else { + format!("{base}/v1/messages?beta=true") + } + } } #[cfg(test)] @@ -794,4 +846,37 @@ mod tests { assert_eq!(claude_auth, AuthStrategy::ClaudeAuth); assert_eq!(bearer, AuthStrategy::Bearer); } + + #[test] + fn test_resolve_claude_stream_url_for_github_copilot() { + let url = StreamCheckService::resolve_claude_stream_url( + "https://api.githubcopilot.com", + AuthStrategy::GitHubCopilot, + "anthropic", + ); + + assert_eq!(url, "https://api.githubcopilot.com/chat/completions"); + } + + #[test] + fn test_resolve_claude_stream_url_for_openai_chat() { + let url = StreamCheckService::resolve_claude_stream_url( + "https://example.com/v1", + AuthStrategy::Bearer, + "openai_chat", + ); + + assert_eq!(url, "https://example.com/v1/chat/completions"); + } + + #[test] + fn test_resolve_claude_stream_url_for_anthropic() { + let url = StreamCheckService::resolve_claude_stream_url( + "https://api.anthropic.com", + AuthStrategy::Anthropic, + "anthropic", + ); + + assert_eq!(url, "https://api.anthropic.com/v1/messages?beta=true"); + } } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 8d7ba94e..2358e622 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -181,9 +181,6 @@ pub struct AppSettings { /// 是否跳过 Claude Code 初次安装确认 #[serde(default)] pub skip_claude_onboarding: bool, - /// 是否解除 Tool Search 域名限制 - #[serde(default)] - pub tool_search_bypass: bool, /// 是否开机自启 #[serde(default)] pub launch_on_startup: bool, @@ -289,7 +286,6 @@ impl Default for AppSettings { minimize_to_tray_on_close: true, enable_claude_plugin_integration: false, skip_claude_onboarding: false, - tool_search_bypass: false, launch_on_startup: false, silent_startup: false, enable_local_proxy: false, diff --git a/src-tauri/src/toolsearch_patch.rs b/src-tauri/src/toolsearch_patch.rs deleted file mode 100644 index e586b84c..00000000 --- a/src-tauri/src/toolsearch_patch.rs +++ /dev/null @@ -1,569 +0,0 @@ -//! Tool Search domain restriction bypass patch for Claude Code. -//! -//! Resolves the current active `claude` command from PATH and patches the -//! domain whitelist check -//! `return["api.anthropic.com"].includes(x)}catch{return!1}` -//! to always return true via equal-length byte replacement. - -use regex::bytes::Regex; -use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; -use std::process::Command; - -use sha2::{Digest, Sha256}; - -use crate::error::AppError; - -const BACKUP_SUFFIX: &str = ".toolsearch-bak"; - -/// Encode bytes as lowercase hex string (avoids adding `hex` crate dependency). -fn to_hex(bytes: &[u8]) -> String { - bytes.iter().map(|b| format!("{b:02x}")).collect() -} - -// Regex matching the domain whitelist check with any JS identifier as variable name -const PATCH_TARGET_PATTERN: &str = - r#"return\["api\.anthropic\.com"\]\.includes\([A-Za-z_$][A-Za-z0-9_$]*\)\}catch\{return!1\}"#; - -// Regex matching already-patched code -const PATCHED_PATTERN: &str = r#"return!0/\* *\*/\}catch\{return!0\}"#; - -/// Single Claude Code installation info -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ClaudeInstallation { - pub path: String, - pub source: String, - pub patched: bool, - pub has_backup: bool, -} - -/// Result of a patch/restore operation on one installation -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PatchResult { - pub path: String, - pub success: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -/// Overall Tool Search patch status -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ToolSearchStatus { - pub installations: Vec, - pub all_patched: bool, - pub any_found: bool, -} - -// ── Patch status detection ────────────────────────────────────────── - -fn get_patch_status(data: &[u8]) -> &'static str { - let target_re = Regex::new(PATCH_TARGET_PATTERN).unwrap(); - let patched_re = Regex::new(PATCHED_PATTERN).unwrap(); - if target_re.is_match(data) { - "unpatched" - } else if patched_re.is_match(data) { - "patched" - } else { - "unknown" - } -} - -/// Build equal-length replacement bytes: `return!0/* */}catch{return!0}` -fn build_patched_bytes(original_len: usize) -> Result, AppError> { - let prefix = b"return!0/*"; - let suffix = b"*/}catch{return!0}"; - let padding = original_len - .checked_sub(prefix.len() + suffix.len()) - .ok_or_else(|| AppError::Config("Patch template too long for match".into()))?; - let mut out = Vec::with_capacity(original_len); - out.extend_from_slice(prefix); - out.extend(std::iter::repeat_n(b' ', padding)); - out.extend_from_slice(suffix); - Ok(out) -} - -/// Apply byte-level patch to file data, returns (patched_data, replacement_count) -fn patch_bytes(data: &[u8]) -> Result<(Vec, usize), AppError> { - let re = Regex::new(PATCH_TARGET_PATTERN).unwrap(); - let mut count = 0usize; - let result = re.replace_all(data, |caps: ®ex::bytes::Captures| { - count += 1; - build_patched_bytes(caps[0].len()).unwrap_or_else(|_| caps[0].to_vec()) - }); - Ok((result.into_owned(), count)) -} - -// ── Installation detection ────────────────────────────────────────── - -/// Run a command and return stdout, or empty string on failure -fn run_cmd(cmd: &str, args: &[&str]) -> String { - Command::new(cmd) - .args(args) - .output() - .ok() - .filter(|o| o.status.success()) - .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim().to_string()) - .unwrap_or_default() -} - -/// Search a package directory for JS files containing the domain check -fn find_patch_target_in_pkg(pkg_dir: &Path) -> Option { - let marker = b"api.anthropic.com"; - // Check cli.js first (most common) - let cli_js = pkg_dir.join("cli.js"); - if cli_js.is_file() { - if let Ok(data) = std::fs::read(&cli_js) { - if data.windows(marker.len()).any(|w| w == marker) { - return Some(cli_js); - } - } - } - // Search other JS files - find_js_with_marker(pkg_dir) -} - -fn find_js_with_marker(dir: &Path) -> Option { - let marker = b"api.anthropic.com"; - let entries = match std::fs::read_dir(dir) { - Ok(e) => e, - Err(_) => return None, - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - if let Some(found) = find_js_with_marker(&path) { - return Some(found); - } - } else if path.extension().and_then(|e| e.to_str()) == Some("js") { - if let Ok(meta) = path.metadata() { - if meta.len() < 1000 { - continue; - } - } - if let Ok(data) = std::fs::read(&path) { - if data.windows(marker.len()).any(|w| w == marker) { - return Some(path); - } - } - } - } - None -} - -/// Resolve symlinks to actual file path -fn resolve_target(path: &Path) -> PathBuf { - std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) -} - -fn get_patch_status_for_path(path: &Path) -> Option<&'static str> { - let data = std::fs::read(path).ok()?; - Some(get_patch_status(&data)) -} - -fn package_dir_from_ancestors(path: &Path) -> Option { - for ancestor in path.ancestors() { - if ancestor.file_name().and_then(|v| v.to_str()) == Some("claude-code") - && ancestor - .parent() - .and_then(|v| v.file_name()) - .and_then(|v| v.to_str()) - == Some("@anthropic-ai") - { - return Some(ancestor.to_path_buf()); - } - } - None -} - -fn push_candidate_package_dir( - candidates: &mut Vec, - seen: &mut std::collections::HashSet, - path: PathBuf, -) { - if path.is_dir() && seen.insert(path.clone()) { - candidates.push(path); - } -} - -fn resolve_active_patch_target(command_path: &Path) -> Option { - let resolved_command = resolve_target(command_path); - if matches!( - get_patch_status_for_path(&resolved_command), - Some("patched" | "unpatched") - ) { - return Some(resolved_command); - } - - let mut candidates = Vec::new(); - let mut seen = std::collections::HashSet::new(); - - for path in [command_path, resolved_command.as_path()] { - if let Some(pkg_dir) = package_dir_from_ancestors(path) { - push_candidate_package_dir(&mut candidates, &mut seen, pkg_dir); - } - - if let Some(bin_dir) = path.parent() { - if let Some(prefix) = bin_dir.parent() { - push_candidate_package_dir( - &mut candidates, - &mut seen, - prefix - .join("lib") - .join("node_modules") - .join("@anthropic-ai") - .join("claude-code"), - ); - push_candidate_package_dir( - &mut candidates, - &mut seen, - prefix - .join("node_modules") - .join("@anthropic-ai") - .join("claude-code"), - ); - } - } - } - - candidates - .into_iter() - .find_map(|pkg_dir| find_patch_target_in_pkg(&pkg_dir).map(|p| resolve_target(&p))) -} - -#[cfg(target_os = "windows")] -fn find_active_command_path() -> Option { - run_cmd("where.exe", &["claude"]) - .lines() - .next() - .map(PathBuf::from) - .filter(|path| path.is_file()) -} - -#[cfg(not(target_os = "windows"))] -fn find_active_command_path() -> Option { - run_cmd("which", &["claude"]) - .lines() - .next() - .map(PathBuf::from) - .filter(|path| path.is_file()) -} - -fn find_active_installation() -> Option<(PathBuf, String)> { - let command_path = find_active_command_path()?; - let patch_target = resolve_active_patch_target(&command_path)?; - Some(( - patch_target, - format!("active claude ({})", command_path.display()), - )) -} - -fn require_active_installation() -> Result<(PathBuf, String), AppError> { - find_active_installation() - .ok_or_else(|| AppError::Config("No active Claude Code installation found in PATH".into())) -} - -// ── macOS codesign ────────────────────────────────────────────────── - -#[cfg(target_os = "macos")] -fn codesign_adhoc(path: &Path) -> Result<(), AppError> { - let output = Command::new("codesign") - .args(["--force", "--sign", "-"]) - .arg(path) - .output() - .map_err(|e| AppError::IoContext { - context: format!("Failed to run codesign for {}", path.display()), - source: e, - })?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(AppError::Config(format!( - "codesign failed for {}: {}", - path.display(), - stderr.trim() - ))); - } - Ok(()) -} - -#[cfg(not(target_os = "macos"))] -fn codesign_adhoc(_path: &Path) -> Result<(), AppError> { - Ok(()) -} - -// ── Backup directory helpers ───────────────────────────────────────── - -/// Get the centralized backup directory: `~/.cc-switch/toolsearch-backups/` -fn get_backup_dir() -> Result { - let dir = crate::config::get_app_config_dir().join("toolsearch-backups"); - if !dir.exists() { - std::fs::create_dir_all(&dir).map_err(|e| AppError::io(&dir, e))?; - } - Ok(dir) -} - -/// Derive a stable backup filename from the original path using SHA-256. -fn backup_name_for(path: &Path) -> String { - let mut hasher = Sha256::new(); - hasher.update(path.to_string_lossy().as_bytes()); - to_hex(&hasher.finalize()) -} - -/// Get the backup file path for a given target file. -fn get_backup_path(path: &Path) -> Result { - let dir = get_backup_dir()?; - let name = backup_name_for(path); - Ok(dir.join(format!("{name}.bak"))) -} - -/// Get the metadata file path (records original path for debugging). -fn get_meta_path(path: &Path) -> Result { - let dir = get_backup_dir()?; - let name = backup_name_for(path); - Ok(dir.join(format!("{name}.meta"))) -} - -// ── Patch / Restore single file ───────────────────────────────────── - -fn patch_single_file(path: &Path) -> Result<(), AppError> { - let data = std::fs::read(path).map_err(|e| AppError::io(path, e))?; - let status = get_patch_status(&data); - - if status == "patched" { - return Ok(()); // Already patched - } - if status == "unknown" { - return Err(AppError::Config(format!( - "Target pattern not found in {}, possibly incompatible version", - path.display() - ))); - } - - let (patched_data, count) = patch_bytes(&data)?; - if count == 0 { - return Err(AppError::Config(format!( - "No replacements made in {}", - path.display() - ))); - } - - // Create backup in centralized directory - let backup_path = get_backup_path(path)?; - std::fs::copy(path, &backup_path).map_err(|e| AppError::io(&backup_path, e))?; - - // Write metadata file for debugging - let meta_path = get_meta_path(path)?; - let _ = std::fs::write(&meta_path, path.to_string_lossy().as_bytes()); - - // Write patched data - if let Err(e) = std::fs::write(path, &patched_data) { - // Try rename trick on Windows - #[cfg(target_os = "windows")] - { - if let Ok(()) = write_via_rename(path, &patched_data) { - codesign_adhoc(path)?; - return Ok(()); - } - } - return Err(AppError::io(path, e)); - } - - codesign_adhoc(path)?; - Ok(()) -} - -#[cfg(target_os = "windows")] -fn write_via_rename(target: &Path, data: &[u8]) -> Result<(), AppError> { - let tmp_path = target.with_extension("tmp"); - let old_path = target.with_extension("old"); - - let _ = std::fs::remove_file(&tmp_path); - let _ = std::fs::remove_file(&old_path); - - std::fs::write(&tmp_path, data).map_err(|e| AppError::io(&tmp_path, e))?; - std::fs::rename(target, &old_path).map_err(|e| AppError::io(target, e))?; - std::fs::rename(&tmp_path, target).map_err(|e| AppError::io(target, e))?; - let _ = std::fs::remove_file(&old_path); - Ok(()) -} - -fn restore_single_file(path: &Path) -> Result<(), AppError> { - let current_status = get_patch_status_for_path(path); - if matches!(current_status, Some("unpatched")) { - return Ok(()); - } - - // Try centralized backup directory first - let backup_path = get_backup_path(path)?; - // Fallback: legacy backup path (adjacent `.toolsearch-bak` file) - let legacy_backup = PathBuf::from(format!("{}{}", path.display(), BACKUP_SUFFIX)); - - let actual_backup = if backup_path.is_file() { - &backup_path - } else if legacy_backup.is_file() { - &legacy_backup - } else { - return Err(AppError::Config(format!( - "No backup found for {}", - path.display() - ))); - }; - - let backup_data = std::fs::read(actual_backup).map_err(|e| AppError::io(actual_backup, e))?; - - if let Err(e) = std::fs::write(path, &backup_data) { - #[cfg(target_os = "windows")] - { - if let Ok(()) = write_via_rename(path, &backup_data) { - codesign_adhoc(path)?; - return Ok(()); - } - } - return Err(AppError::io(path, e)); - } - - codesign_adhoc(path)?; - Ok(()) -} - -// ── Public API ────────────────────────────────────────────────────── - -/// Check Tool Search patch status for the current active Claude Code installation. -pub fn check_toolsearch_status() -> Result { - let installations = find_active_installation() - .into_iter() - .map(|(path, source)| { - let data = std::fs::read(&path).unwrap_or_default(); - let status = get_patch_status(&data); - // Check centralized backup first, then legacy - let has_backup = get_backup_path(&path).map(|p| p.is_file()).unwrap_or(false) - || PathBuf::from(format!("{}{}", path.display(), BACKUP_SUFFIX)).is_file(); - ClaudeInstallation { - path: path.to_string_lossy().to_string(), - source: source.clone(), - patched: status == "patched", - has_backup, - } - }) - .collect::>(); - - let any_found = !installations.is_empty(); - let all_patched = any_found && installations.iter().all(|i| i.patched); - - Ok(ToolSearchStatus { - installations, - all_patched, - any_found, - }) -} - -/// Apply the Tool Search patch to the current active Claude Code installation. -pub fn apply_toolsearch_patch() -> Result, AppError> { - let (path, _) = require_active_installation()?; - Ok(vec![match patch_single_file(&path) { - Ok(()) => PatchResult { - path: path.to_string_lossy().to_string(), - success: true, - error: None, - }, - Err(e) => PatchResult { - path: path.to_string_lossy().to_string(), - success: false, - error: Some(e.to_string()), - }, - }]) -} - -/// Restore the current active Claude Code installation from backup. -pub fn restore_toolsearch_patch() -> Result, AppError> { - let (path, _) = require_active_installation()?; - Ok(vec![match restore_single_file(&path) { - Ok(()) => PatchResult { - path: path.to_string_lossy().to_string(), - success: true, - error: None, - }, - Err(e) => PatchResult { - path: path.to_string_lossy().to_string(), - success: false, - error: Some(e.to_string()), - }, - }]) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_patch_bytes_replaces_correctly() { - let input = br#"return["api.anthropic.com"].includes(x)}catch{return!1}"#; - let (patched, count) = patch_bytes(input).unwrap(); - assert_eq!(count, 1); - assert_eq!(patched.len(), input.len()); - assert!(patched.starts_with(b"return!0/*")); - assert!(patched.ends_with(b"*/}catch{return!0}")); - } - - #[test] - fn test_patch_status_detection() { - let unpatched = br#"return["api.anthropic.com"].includes(x)}catch{return!1}"#; - assert_eq!(get_patch_status(unpatched), "unpatched"); - - let (patched, _) = patch_bytes(unpatched).unwrap(); - assert_eq!(get_patch_status(&patched), "patched"); - - assert_eq!(get_patch_status(b"some random data"), "unknown"); - } - - #[test] - fn test_build_patched_bytes_length() { - for len in 50..70 { - let result = build_patched_bytes(len).unwrap(); - assert_eq!(result.len(), len); - } - } - - #[test] - fn test_resolve_active_patch_target_from_npm_style_bin_path() { - let tmp = tempfile::tempdir().unwrap(); - let prefix = tmp.path().join("prefix"); - let bin_dir = prefix.join("bin"); - let pkg_dir = prefix - .join("lib") - .join("node_modules") - .join("@anthropic-ai") - .join("claude-code"); - std::fs::create_dir_all(&bin_dir).unwrap(); - std::fs::create_dir_all(&pkg_dir).unwrap(); - - let command_path = bin_dir.join("claude"); - std::fs::write(&command_path, b"#!/usr/bin/env node\n").unwrap(); - let cli_path = pkg_dir.join("cli.js"); - std::fs::write( - &cli_path, - br#"return["api.anthropic.com"].includes(x)}catch{return!1}"#, - ) - .unwrap(); - - assert_eq!( - resolve_active_patch_target(&command_path), - Some(resolve_target(&cli_path)) - ); - } - - #[test] - fn test_restore_single_file_is_noop_when_current_target_is_unpatched() { - let tmp = tempfile::NamedTempFile::new().unwrap(); - std::fs::write( - tmp.path(), - br#"return["api.anthropic.com"].includes(x)}catch{return!1}"#, - ) - .unwrap(); - - assert!(restore_single_file(tmp.path()).is_ok()); - } -} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 93701c72..1551b39f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -26,7 +26,7 @@ } ], "security": { - "csp": "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost https: http:", + "csp": "default-src 'self'; img-src 'self' data: https: http:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost https: http:", "assetProtocol": { "enable": true, "scope": [] diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index 697da49c..c248b52c 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -5,7 +5,9 @@ import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { Provider, UsageScript, UsageData } from "@/types"; import { usageApi, settingsApi, type AppId } from "@/lib/api"; +import { copilotGetUsage, copilotGetUsageForAccount } from "@/lib/api/copilot"; import { useSettingsQuery } from "@/lib/query"; +import { resolveManagedAccountId } from "@/lib/authBinding"; import { extractCodexBaseUrl } from "@/utils/providerConfigUtils"; import JsonEditor from "./JsonEditor"; import * as prettier from "prettier/standalone"; @@ -18,6 +20,7 @@ import { Switch } from "@/components/ui/switch"; import { FullScreenPanel } from "@/components/common/FullScreenPanel"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { cn } from "@/lib/utils"; +import { TEMPLATE_TYPES, PROVIDER_TYPES } from "@/config/constants"; interface UsageScriptModalProps { provider: Provider; @@ -27,18 +30,11 @@ interface UsageScriptModalProps { onSave: (script: UsageScript) => void; } -// 预设模板键名(用于国际化) -const TEMPLATE_KEYS = { - CUSTOM: "custom", - GENERAL: "general", - NEW_API: "newapi", -} as const; - // 生成预设模板的函数(支持国际化) const generatePresetTemplates = ( t: (key: string) => string, ): Record => ({ - [TEMPLATE_KEYS.CUSTOM]: `({ + [TEMPLATE_TYPES.CUSTOM]: `({ request: { url: "", method: "GET", @@ -52,7 +48,7 @@ const generatePresetTemplates = ( } })`, - [TEMPLATE_KEYS.GENERAL]: `({ + [TEMPLATE_TYPES.GENERAL]: `({ request: { url: "{{baseUrl}}/user/balance", method: "GET", @@ -70,7 +66,7 @@ const generatePresetTemplates = ( } })`, - [TEMPLATE_KEYS.NEW_API]: `({ + [TEMPLATE_TYPES.NEW_API]: `({ request: { url: "{{baseUrl}}/api/user/self", method: "GET", @@ -96,13 +92,17 @@ const generatePresetTemplates = ( }; }, })`, + + // GitHub Copilot 模板不需要脚本,使用专用 API + [TEMPLATE_TYPES.GITHUB_COPILOT]: "", }); // 模板名称国际化键映射 const TEMPLATE_NAME_KEYS: Record = { - [TEMPLATE_KEYS.CUSTOM]: "usageScript.templateCustom", - [TEMPLATE_KEYS.GENERAL]: "usageScript.templateGeneral", - [TEMPLATE_KEYS.NEW_API]: "usageScript.templateNewAPI", + [TEMPLATE_TYPES.CUSTOM]: "usageScript.templateCustom", + [TEMPLATE_TYPES.GENERAL]: "usageScript.templateGeneral", + [TEMPLATE_TYPES.NEW_API]: "usageScript.templateNewAPI", + [TEMPLATE_TYPES.GITHUB_COPILOT]: "usageScript.templateCopilot", }; const UsageScriptModal: React.FC = ({ @@ -167,7 +167,7 @@ const UsageScriptModal: React.FC = ({ const defaultScript = { enabled: false, language: "javascript" as const, - code: PRESET_TEMPLATES[TEMPLATE_KEYS.GENERAL], + code: PRESET_TEMPLATES[TEMPLATE_TYPES.GENERAL], timeout: 10, }; @@ -230,6 +230,10 @@ const UsageScriptModal: React.FC = ({ const [selectedTemplate, setSelectedTemplate] = useState( () => { const existingScript = provider.meta?.usage_script; + // Copilot 供应商默认使用 Copilot 模板 + if (provider.meta?.providerType === PROVIDER_TYPES.GITHUB_COPILOT) { + return TEMPLATE_TYPES.GITHUB_COPILOT; + } // 优先使用保存的 templateType if (existingScript?.templateType) { return existingScript.templateType; @@ -237,14 +241,14 @@ const UsageScriptModal: React.FC = ({ // 向后兼容:根据字段推断模板类型 // 检测 NEW_API 模板(有 accessToken 或 userId) if (existingScript?.accessToken || existingScript?.userId) { - return TEMPLATE_KEYS.NEW_API; + return TEMPLATE_TYPES.NEW_API; } // 检测 GENERAL 模板(有 apiKey 或 baseUrl) if (existingScript?.apiKey || existingScript?.baseUrl) { - return TEMPLATE_KEYS.GENERAL; + return TEMPLATE_TYPES.GENERAL; } // 新配置或无凭证:默认使用 GENERAL(与默认代码模板一致) - return TEMPLATE_KEYS.GENERAL; + return TEMPLATE_TYPES.GENERAL; }, ); @@ -273,13 +277,16 @@ const UsageScriptModal: React.FC = ({ }; const handleSave = () => { - if (script.enabled && !script.code.trim()) { - toast.error(t("usageScript.scriptEmpty")); - return; - } - if (script.enabled && !script.code.includes("return")) { - toast.error(t("usageScript.mustHaveReturn"), { duration: 5000 }); - return; + // Copilot 模板不需要脚本验证 + if (selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT) { + if (script.enabled && !script.code.trim()) { + toast.error(t("usageScript.scriptEmpty")); + return; + } + if (script.enabled && !script.code.includes("return")) { + toast.error(t("usageScript.mustHaveReturn"), { duration: 5000 }); + return; + } } // 保存时记录当前选择的模板类型 const scriptWithTemplate = { @@ -288,6 +295,7 @@ const UsageScriptModal: React.FC = ({ | "custom" | "general" | "newapi" + | "github_copilot" | undefined, }; onSave(scriptWithTemplate); @@ -297,6 +305,38 @@ const UsageScriptModal: React.FC = ({ const handleTest = async () => { setTesting(true); try { + // Copilot 模板使用专用 API + if (selectedTemplate === TEMPLATE_TYPES.GITHUB_COPILOT) { + const accountId = resolveManagedAccountId( + provider.meta, + PROVIDER_TYPES.GITHUB_COPILOT, + ); + const usage = accountId + ? await copilotGetUsageForAccount(accountId) + : await copilotGetUsage(); + const premium = usage.quota_snapshots.premium_interactions; + const used = premium.entitlement - premium.remaining; + const summary = `[${usage.copilot_plan}] ${t("usage.remaining")} ${premium.remaining}/${premium.entitlement} (${t("usageScript.resetDate")}: ${usage.quota_reset_date})`; + toast.success(`${t("usageScript.testSuccess")}${summary}`, { + duration: 3000, + closeButton: true, + }); + // 更新缓存 + queryClient.setQueryData(["usage", provider.id, appId], { + success: true, + data: [ + { + planName: usage.copilot_plan, + remaining: premium.remaining, + total: premium.entitlement, + used: used, + unit: t("usageScript.premiumRequests"), + }, + ], + }); + return; + } + const result = await usageApi.testScript( provider.id, appId, @@ -370,7 +410,7 @@ const UsageScriptModal: React.FC = ({ const handleUsePreset = (presetName: string) => { const preset = PRESET_TEMPLATES[presetName]; if (preset) { - if (presetName === TEMPLATE_KEYS.CUSTOM) { + if (presetName === TEMPLATE_TYPES.CUSTOM) { // 🔧 自定义模式:用户应该在脚本中直接写完整 URL 和凭证,而不是依赖变量替换 // 这样可以避免同源检查导致的问题 // 如果用户想使用变量,需要手动在配置中设置 baseUrl/apiKey @@ -383,27 +423,37 @@ const UsageScriptModal: React.FC = ({ accessToken: undefined, userId: undefined, }); - } else if (presetName === TEMPLATE_KEYS.GENERAL) { + } else if (presetName === TEMPLATE_TYPES.GENERAL) { setScript({ ...script, code: preset, accessToken: undefined, userId: undefined, }); - } else if (presetName === TEMPLATE_KEYS.NEW_API) { + } else if (presetName === TEMPLATE_TYPES.NEW_API) { setScript({ ...script, code: preset, apiKey: undefined, }); + } else if (presetName === TEMPLATE_TYPES.GITHUB_COPILOT) { + // Copilot 模板不需要脚本和凭证,使用专用 API + setScript({ + ...script, + code: "", + apiKey: undefined, + baseUrl: undefined, + accessToken: undefined, + userId: undefined, + }); } setSelectedTemplate(presetName); } }; const shouldShowCredentialsConfig = - selectedTemplate === TEMPLATE_KEYS.GENERAL || - selectedTemplate === TEMPLATE_KEYS.NEW_API; + selectedTemplate === TEMPLATE_TYPES.GENERAL || + selectedTemplate === TEMPLATE_TYPES.NEW_API; const footer = ( <> @@ -474,30 +524,40 @@ const UsageScriptModal: React.FC = ({ {t("usageScript.presetTemplate")}
- {Object.keys(PRESET_TEMPLATES).map((name) => { - const isSelected = selectedTemplate === name; - return ( - - ); - })} + {Object.keys(PRESET_TEMPLATES) + .filter((name) => { + const isCopilotProvider = + provider.meta?.providerType === "github_copilot"; + // Copilot 供应商只显示 copilot 模板,其他供应商不显示 copilot 模板 + if (isCopilotProvider) { + return name === TEMPLATE_TYPES.GITHUB_COPILOT; + } + return name !== TEMPLATE_TYPES.GITHUB_COPILOT; + }) + .map((name) => { + const isSelected = selectedTemplate === name; + return ( + + ); + })}
{/* 自定义模式:变量提示和具体值 */} - {selectedTemplate === TEMPLATE_KEYS.CUSTOM && ( + {selectedTemplate === TEMPLATE_TYPES.CUSTOM && (

{t("usageScript.supportedVariables")} @@ -564,6 +624,15 @@ const UsageScriptModal: React.FC = ({

)} + {/* Copilot 模式:自动认证提示 */} + {selectedTemplate === TEMPLATE_TYPES.GITHUB_COPILOT && ( +
+

+ {t("usageScript.copilotAutoAuth")} +

+
+ )} + {/* 凭证配置 */} {shouldShowCredentialsConfig && (
@@ -577,7 +646,7 @@ const UsageScriptModal: React.FC = ({
- {selectedTemplate === TEMPLATE_KEYS.GENERAL && ( + {selectedTemplate === TEMPLATE_TYPES.GENERAL && ( <>
- {/* 提取器代码 */} -
-
- -
- {t("usageScript.extractorHint")} + {/* 提取器代码 - Copilot 模板不需要 */} + {selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT && ( +
+
+ +
+ {t("usageScript.extractorHint")} +
+ setScript({ ...script, code: value })} + height={480} + language="javascript" + showMinimap={false} + />
- setScript({ ...script, code: value })} - height={480} - language="javascript" - showMinimap={false} - /> -
+ )} - {/* 帮助信息 */} -
-

{t("usageScript.scriptHelp")}

-
-
- {t("usageScript.configFormat")} -
-                  {`({
+          {/* 帮助信息 - Copilot 模板不需要 */}
+          {selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT && (
+            
+

+ {t("usageScript.scriptHelp")} +

+
+
+ {t("usageScript.configFormat")} +
+                    {`({
   request: {
     url: "{{baseUrl}}/api/usage",
     method: "POST",
@@ -833,38 +907,39 @@ const UsageScriptModal: React.FC = ({
     };
   }
 })`}
-                
-
+
+
-
- {t("usageScript.extractorFormat")} -
    -
  • {t("usageScript.fieldIsValid")}
  • -
  • {t("usageScript.fieldInvalidMessage")}
  • -
  • {t("usageScript.fieldRemaining")}
  • -
  • {t("usageScript.fieldUnit")}
  • -
  • {t("usageScript.fieldPlanName")}
  • -
  • {t("usageScript.fieldTotal")}
  • -
  • {t("usageScript.fieldUsed")}
  • -
  • {t("usageScript.fieldExtra")}
  • -
-
+
+ {t("usageScript.extractorFormat")} +
    +
  • {t("usageScript.fieldIsValid")}
  • +
  • {t("usageScript.fieldInvalidMessage")}
  • +
  • {t("usageScript.fieldRemaining")}
  • +
  • {t("usageScript.fieldUnit")}
  • +
  • {t("usageScript.fieldPlanName")}
  • +
  • {t("usageScript.fieldTotal")}
  • +
  • {t("usageScript.fieldUsed")}
  • +
  • {t("usageScript.fieldExtra")}
  • +
+
-
- {t("usageScript.tips")} -
    -
  • - {t("usageScript.tip1", { - apiKey: "{{apiKey}}", - baseUrl: "{{baseUrl}}", - })} -
  • -
  • {t("usageScript.tip2")}
  • -
  • {t("usageScript.tip3")}
  • -
+
+ {t("usageScript.tips")} +
    +
  • + {t("usageScript.tip1", { + apiKey: "{{apiKey}}", + baseUrl: "{{baseUrl}}", + })} +
  • +
  • {t("usageScript.tip2")}
  • +
  • {t("usageScript.tip3")}
  • +
+
-
+ )}
)} diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index b6e9075a..34a72298 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -14,6 +14,7 @@ import { } from "@/components/providers/forms/ProviderForm"; import { UniversalProviderFormModal } from "@/components/universal/UniversalProviderFormModal"; import { UniversalProviderPanel } from "@/components/universal"; +import { AuthCenterPanel } from "@/components/settings/AuthCenterPanel"; import { providerPresets } from "@/config/claudeProviderPresets"; import { codexProviderPresets } from "@/config/codexProviderPresets"; import { geminiProviderPresets } from "@/config/geminiProviderPresets"; @@ -42,9 +43,9 @@ export function AddProviderDialog({ const { t } = useTranslation(); // OpenCode and OpenClaw don't support universal providers const showUniversalTab = appId !== "opencode" && appId !== "openclaw"; - const [activeTab, setActiveTab] = useState<"app-specific" | "universal">( - "app-specific", - ); + const [activeTab, setActiveTab] = useState< + "app-specific" | "universal" | "oauth" + >("app-specific"); const [universalFormOpen, setUniversalFormOpen] = useState(false); const [selectedUniversalPreset, setSelectedUniversalPreset] = useState(null); @@ -255,6 +256,14 @@ export function AddProviderDialog({ {t("common.add")} + ) : activeTab === "oauth" ? ( + ) : ( <> + + + {vendors.map((vendor, vi) => ( +
+ {vi > 0 && } + {vendor} + {grouped[vendor].map((model) => ( + onModelChange(field, model.id)} + > + {model.id} + + ))} +
+ ))} +
+ +
+ ); + } + + if (isCopilotPreset && modelsLoading) { + return ( +
+ onModelChange(field, e.target.value)} + placeholder={placeholder} + autoComplete="off" + className="flex-1" + /> + +
+ ); + } + + return ( + onModelChange(field, e.target.value)} + placeholder={placeholder} + autoComplete="off" + /> + ); + }; return ( <> - {/* API Key 输入框 */} - {shouldShowApiKey && ( + {/* GitHub Copilot OAuth 认证 */} + {isCopilotPreset && ( + + )} + + {/* API Key 输入框(非 OAuth 预设时显示) */} + {shouldShowApiKey && !usesOAuth && ( )} - {/* API 格式选择(仅非官方、非云服务商显示) */} - {shouldShowModelSelector && category !== "cloud_provider" && ( -
- - {t("providerForm.apiFormat", { defaultValue: "API 格式" })} - - -

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

-
- )} - - {/* 认证字段选择器 */} + {/* 高级选项(API 格式 + 认证字段 + 模型映射) */} {shouldShowModelSelector && ( -
- - {t("providerForm.authField", { defaultValue: "认证字段" })} - - -

- {t("providerForm.authFieldHint", { - defaultValue: "选择写入配置的认证环境变量名", - })} -

-
- )} + + + + + {!advancedExpanded && ( +

+ {t("providerForm.advancedOptionsHint")} +

+ )} + + {/* API 格式选择(仅非云服务商显示) */} + {category !== "cloud_provider" && ( +
+ + {t("providerForm.apiFormat", { defaultValue: "API 格式" })} + + +

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

+
+ )} - {/* 模型选择器 */} - {shouldShowModelSelector && ( -
-
- {/* 主模型 */} + {/* 认证字段选择器 */}
- - {t("providerForm.anthropicModel", { defaultValue: "主模型" })} + + {t("providerForm.authField", { defaultValue: "认证字段" })} - - onModelChange("ANTHROPIC_MODEL", e.target.value) + +

+ {t("providerForm.authFieldHint", { + defaultValue: "选择写入配置的认证环境变量名", })} - autoComplete="off" - /> +

- {/* 推理模型 */} -
- - {t("providerForm.anthropicReasoningModel")} - - - onModelChange("ANTHROPIC_REASONING_MODEL", e.target.value) - } - autoComplete="off" - /> + {/* 模型映射 */} +
+ {t("providerForm.modelMappingLabel")} +

+ {t("providerForm.modelMappingHint")} +

+
+ {/* 主模型 */} +
+ + {t("providerForm.anthropicModel", { + defaultValue: "主模型", + })} + + {renderModelInput( + "claudeModel", + claudeModel, + "ANTHROPIC_MODEL", + t("providerForm.modelPlaceholder", { defaultValue: "" }), + )} +
- {/* 默认 Haiku */} -
- - {t("providerForm.anthropicDefaultHaikuModel", { - defaultValue: "Haiku 默认模型", - })} - - - onModelChange("ANTHROPIC_DEFAULT_HAIKU_MODEL", e.target.value) - } - placeholder={t("providerForm.haikuModelPlaceholder", { - defaultValue: "", - })} - autoComplete="off" - /> -
+ {/* 推理模型 */} +
+ + {t("providerForm.anthropicReasoningModel")} + + {renderModelInput( + "reasoningModel", + reasoningModel, + "ANTHROPIC_REASONING_MODEL", + )} +
- {/* 默认 Sonnet */} -
- - {t("providerForm.anthropicDefaultSonnetModel", { - defaultValue: "Sonnet 默认模型", - })} - - - onModelChange( - "ANTHROPIC_DEFAULT_SONNET_MODEL", - e.target.value, - ) - } - placeholder={t("providerForm.modelPlaceholder", { - defaultValue: "", - })} - autoComplete="off" - /> -
+ {/* 默认 Haiku */} +
+ + {t("providerForm.anthropicDefaultHaikuModel", { + defaultValue: "Haiku 默认模型", + })} + + {renderModelInput( + "claudeDefaultHaikuModel", + defaultHaikuModel, + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + t("providerForm.haikuModelPlaceholder", { defaultValue: "" }), + )} +
- {/* 默认 Opus */} -
- - {t("providerForm.anthropicDefaultOpusModel", { - defaultValue: "Opus 默认模型", - })} - - - onModelChange("ANTHROPIC_DEFAULT_OPUS_MODEL", e.target.value) - } - placeholder={t("providerForm.modelPlaceholder", { - defaultValue: "", - })} - autoComplete="off" - /> + {/* 默认 Sonnet */} +
+ + {t("providerForm.anthropicDefaultSonnetModel", { + defaultValue: "Sonnet 默认模型", + })} + + {renderModelInput( + "claudeDefaultSonnetModel", + defaultSonnetModel, + "ANTHROPIC_DEFAULT_SONNET_MODEL", + t("providerForm.modelPlaceholder", { defaultValue: "" }), + )} +
+ + {/* 默认 Opus */} +
+ + {t("providerForm.anthropicDefaultOpusModel", { + defaultValue: "Opus 默认模型", + })} + + {renderModelInput( + "claudeDefaultOpusModel", + defaultOpusModel, + "ANTHROPIC_DEFAULT_OPUS_MODEL", + t("providerForm.modelPlaceholder", { defaultValue: "" }), + )} +
-
-

- {t("providerForm.modelHelper", { - defaultValue: - "可选:指定默认使用的 Claude 模型,留空则使用系统默认。", - })} -

-
+ + )} ); diff --git a/src/components/providers/forms/CodexConfigSections.tsx b/src/components/providers/forms/CodexConfigSections.tsx index 99ada427..446e4523 100644 --- a/src/components/providers/forms/CodexConfigSections.tsx +++ b/src/components/providers/forms/CodexConfigSections.tsx @@ -1,6 +1,11 @@ -import React, { useEffect, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import JsonEditor from "@/components/JsonEditor"; +import { + extractCodexTopLevelInt, + setCodexTopLevelInt, + removeCodexTopLevelField, +} from "@/utils/providerConfigUtils"; interface CodexAuthSectionProps { value: string; @@ -115,6 +120,95 @@ export const CodexConfigSection: React.FC = ({ return () => observer.disconnect(); }, []); + // Mirror value prop to local state (same pattern as CommonConfigEditor) + const [localValue, setLocalValue] = useState(value); + const localValueRef = useRef(value); + useEffect(() => { + setLocalValue(value); + localValueRef.current = value; + }, [value]); + + const handleLocalChange = useCallback( + (newValue: string) => { + if (newValue === localValueRef.current) return; + localValueRef.current = newValue; + setLocalValue(newValue); + onChange(newValue); + }, + [onChange], + ); + + // Parse toggle states from TOML text + const toggleStates = useMemo(() => { + const contextWindow = extractCodexTopLevelInt( + localValue, + "model_context_window", + ); + const compactLimit = extractCodexTopLevelInt( + localValue, + "model_auto_compact_token_limit", + ); + return { + contextWindow1M: contextWindow === 1000000, + compactLimit: compactLimit ?? 900000, + }; + }, [localValue]); + + // Debounce timer for compact limit input + const compactTimerRef = useRef>(); + + const handleContextWindowToggle = useCallback( + (checked: boolean) => { + let toml = localValueRef.current || ""; + if (checked) { + toml = setCodexTopLevelInt(toml, "model_context_window", 1000000); + // Auto-set compact limit if not already present + if ( + extractCodexTopLevelInt(toml, "model_auto_compact_token_limit") === + undefined + ) { + toml = setCodexTopLevelInt( + toml, + "model_auto_compact_token_limit", + 900000, + ); + } + } else { + toml = removeCodexTopLevelField(toml, "model_context_window"); + toml = removeCodexTopLevelField( + toml, + "model_auto_compact_token_limit", + ); + } + handleLocalChange(toml); + }, + [handleLocalChange], + ); + + const handleCompactLimitChange = useCallback( + (inputValue: string) => { + clearTimeout(compactTimerRef.current); + compactTimerRef.current = setTimeout(() => { + const num = parseInt(inputValue, 10); + if (!Number.isNaN(num) && num > 0) { + handleLocalChange( + setCodexTopLevelInt( + localValueRef.current || "", + "model_auto_compact_token_limit", + num, + ), + ); + } + }, 500); + }, + [handleLocalChange], + ); + + // Cleanup debounce timer + useEffect(() => { + return () => clearTimeout(compactTimerRef.current); + }, []); + return (
@@ -152,9 +246,34 @@ export const CodexConfigSection: React.FC = ({

)} +
+ + +
+ {t("claudeConfig.hideAttribution")} - + +
void; +} + +/** + * Copilot OAuth 认证区块 + * + * 显示 GitHub Copilot 的认证状态,支持多账号管理和选择。 + */ +export const CopilotAuthSection: React.FC = ({ + className, + selectedAccountId, + onAccountSelect, +}) => { + const { t } = useTranslation(); + const [copied, setCopied] = React.useState(false); + + const { + accounts, + defaultAccountId, + migrationError, + hasAnyAccount, + pollingState, + deviceCode, + error, + isPolling, + isAddingAccount, + isRemovingAccount, + isSettingDefaultAccount, + addAccount, + removeAccount, + setDefaultAccount, + cancelAuth, + logout, + } = useCopilotAuth(); + + // 复制用户码 + const copyUserCode = async () => { + if (deviceCode?.user_code) { + await navigator.clipboard.writeText(deviceCode.user_code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + // 处理账号选择 + const handleAccountSelect = (value: string) => { + onAccountSelect?.(value === "none" ? null : value); + }; + + // 处理移除账号 + const handleRemoveAccount = (accountId: string, e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + removeAccount(accountId); + // 如果移除的是当前选中的账号,清除选择 + if (selectedAccountId === accountId) { + onAccountSelect?.(null); + } + }; + + // 渲染账号头像 + const renderAvatar = (account: GitHubAccount) => { + return ; + }; + + return ( +
+ {/* 认证状态标题 */} +
+ + + {hasAnyAccount + ? t("copilot.accountCount", { + count: accounts.length, + defaultValue: `${accounts.length} 个账号`, + }) + : t("copilot.notAuthenticated", "未认证")} + +
+ + {migrationError && ( +

+ {t("copilot.migrationFailed", { + error: migrationError, + defaultValue: `旧认证数据迁移失败:${migrationError}`, + })} +

+ )} + + {/* 账号选择器(有账号时显示) */} + {hasAnyAccount && onAccountSelect && ( +
+ + +
+ )} + + {/* 已登录账号列表 */} + {hasAnyAccount && ( +
+ +
+ {accounts.map((account) => ( +
+
+ {renderAvatar(account)} + {account.login} + {defaultAccountId === account.id && ( + + {t("copilot.defaultAccount", "默认")} + + )} + {selectedAccountId === account.id && ( + + {t("copilot.selected", "已选中")} + + )} +
+
+ {defaultAccountId !== account.id && ( + + )} + +
+
+ ))} +
+
+ )} + + {/* 未认证状态 - 登录按钮 */} + {!hasAnyAccount && pollingState === "idle" && ( + + )} + + {/* 已有账号 - 添加更多账号按钮 */} + {hasAnyAccount && pollingState === "idle" && ( + + )} + + {/* 轮询中状态 */} + {isPolling && deviceCode && ( +
+
+ + {t("copilot.waitingForAuth", "等待授权中...")} +
+ + {/* 用户码 */} +
+

+ {t("copilot.enterCode", "在浏览器中输入以下代码:")} +

+
+ + {deviceCode.user_code} + + +
+
+ + {/* 验证链接 */} + + + {/* 取消按钮 */} +
+ +
+
+ )} + + {/* 错误状态 */} + {pollingState === "error" && error && ( +
+

{error}

+
+ + +
+
+ )} + + {/* 注销所有账号按钮 */} + {hasAnyAccount && accounts.length > 1 && ( + + )} +
+ ); +}; + +const CopilotAccountAvatar: React.FC<{ account: GitHubAccount }> = ({ + account, +}) => { + const [failed, setFailed] = React.useState(false); + + if (!account.avatar_url || failed) { + return ; + } + + return ( + {account.login} setFailed(true)} + /> + ); +}; + +export default CopilotAuthSection; diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 7d7898d0..8ee74daf 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -80,6 +80,7 @@ import { useOpencodeFormState, useOmoDraftState, useOpenclawFormState, + useCopilotAuth, } from "./hooks"; import { CLAUDE_DEFAULT_CONFIG, @@ -89,6 +90,7 @@ import { OPENCLAW_DEFAULT_CONFIG, normalizePricingSource, } from "./helpers/opencodeFormUtils"; +import { resolveManagedAccountId } from "@/lib/authBinding"; type PresetEntry = { id: string; @@ -331,6 +333,14 @@ export function ProviderForm({ [localApiKeyField, form, handleSettingsConfigChange], ); + // Copilot OAuth 认证状态(仅 Claude 应用需要) + const { isAuthenticated: isCopilotAuthenticated } = useCopilotAuth(); + + // 选中的 GitHub 账号 ID(多账号支持) + const [selectedGitHubAccountId, setSelectedGitHubAccountId] = useState< + string | null + >(() => resolveManagedAccountId(initialData?.meta, "github_copilot")); + const { codexAuth, codexConfig, @@ -660,6 +670,21 @@ export function ProviderForm({ // 非官方供应商必填校验:端点和 API Key // cloud_provider(如 Bedrock)通过模板变量处理认证,跳过通用校验 + // GitHub Copilot 使用 OAuth 认证,不需要 API Key + const isCopilotProvider = + templatePreset?.providerType === "github_copilot" || + initialData?.meta?.providerType === "github_copilot" || + baseUrl.includes("githubcopilot.com"); + // GitHub Copilot 必须先登录才能添加 + if (isCopilotProvider && !isCopilotAuthenticated) { + toast.error( + t("copilot.loginRequired", { + defaultValue: "请先登录 GitHub Copilot", + }), + ); + return; + } + if (category !== "official" && category !== "cloud_provider") { if (appId === "claude") { if (!baseUrl.trim()) { @@ -670,7 +695,7 @@ export function ProviderForm({ ); return; } - if (!apiKey.trim()) { + if (!isCopilotProvider && !apiKey.trim()) { toast.error( t("providerForm.apiKeyRequired", { defaultValue: "非官方供应商请填写 API Key", @@ -867,6 +892,11 @@ export function ProviderForm({ const baseMeta: ProviderMeta | undefined = payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined); + + // 确定 providerType(新建时从预设获取,编辑时从现有数据获取) + const providerType = + templatePreset?.providerType || initialData?.meta?.providerType; + payload.meta = { ...(baseMeta ?? {}), commonConfigEnabled: @@ -878,6 +908,20 @@ export function ProviderForm({ ? useGeminiCommonConfigFlag : undefined, endpointAutoSelect, + // 保存 providerType(用于识别 Copilot 等特殊供应商) + providerType, + authBinding: isCopilotProvider + ? { + source: "managed_account", + authProvider: "github_copilot", + accountId: selectedGitHubAccountId ?? undefined, + } + : undefined, + // GitHub Copilot 多账号:保存关联的账号 ID + githubAccountId: + isCopilotProvider && selectedGitHubAccountId + ? selectedGitHubAccountId + : undefined, testConfig: testConfig.enabled ? testConfig : undefined, proxyConfig: proxyConfig.enabled ? proxyConfig : undefined, costMultiplier: pricingConfig.enabled @@ -1318,6 +1362,20 @@ export function ProviderForm({ websiteUrl={claudeWebsiteUrl} isPartner={isClaudePartner} partnerPromotionKey={claudePartnerPromotionKey} + isCopilotPreset={ + templatePreset?.providerType === "github_copilot" || + initialData?.meta?.providerType === "github_copilot" || + baseUrl.includes("githubcopilot.com") + } + usesOAuth={ + templatePreset?.requiresOAuth === true || + templatePreset?.providerType === "github_copilot" || + initialData?.meta?.providerType === "github_copilot" || + baseUrl.includes("githubcopilot.com") + } + isCopilotAuthenticated={isCopilotAuthenticated} + selectedGitHubAccountId={selectedGitHubAccountId} + onGitHubAccountSelect={setSelectedGitHubAccountId} templateValueEntries={templateValueEntries} templateValues={templateValues} templatePresetName={templatePreset?.name || ""} @@ -1607,7 +1665,9 @@ export function ProviderForm({ - +
)} diff --git a/src/components/providers/forms/helpers/opencodeFormUtils.ts b/src/components/providers/forms/helpers/opencodeFormUtils.ts index bcfd74fa..0a91f287 100644 --- a/src/components/providers/forms/helpers/opencodeFormUtils.ts +++ b/src/components/providers/forms/helpers/opencodeFormUtils.ts @@ -28,6 +28,7 @@ export const OPENCODE_DEFAULT_CONFIG = JSON.stringify( options: { baseURL: "", apiKey: "", + setCacheKey: true, }, models: {}, }, diff --git a/src/components/providers/forms/hooks/index.ts b/src/components/providers/forms/hooks/index.ts index 76090433..f39983ae 100644 --- a/src/components/providers/forms/hooks/index.ts +++ b/src/components/providers/forms/hooks/index.ts @@ -11,8 +11,10 @@ export { useCodexCommonConfig } from "./useCodexCommonConfig"; export { useSpeedTestEndpoints } from "./useSpeedTestEndpoints"; export { useCodexTomlValidation } from "./useCodexTomlValidation"; export { useGeminiConfigState } from "./useGeminiConfigState"; +export { useManagedAuth } from "./useManagedAuth"; export { useGeminiCommonConfig } from "./useGeminiCommonConfig"; export { useOmoModelSource } from "./useOmoModelSource"; export { useOpencodeFormState } from "./useOpencodeFormState"; export { useOmoDraftState } from "./useOmoDraftState"; export { useOpenclawFormState } from "./useOpenclawFormState"; +export { useCopilotAuth } from "./useCopilotAuth"; diff --git a/src/components/providers/forms/hooks/useCopilotAuth.ts b/src/components/providers/forms/hooks/useCopilotAuth.ts new file mode 100644 index 00000000..7b2c4501 --- /dev/null +++ b/src/components/providers/forms/hooks/useCopilotAuth.ts @@ -0,0 +1,27 @@ +import type { GitHubAccount } from "@/lib/api"; +import { useManagedAuth } from "./useManagedAuth"; + +export function useCopilotAuth() { + const managedAuth = useManagedAuth("github_copilot"); + const defaultAccount = + managedAuth.accounts.find( + (account) => account.id === managedAuth.defaultAccountId, + ) ?? managedAuth.accounts[0]; + + return { + ...managedAuth, + authStatus: managedAuth.authStatus + ? { + authenticated: managedAuth.authStatus.authenticated, + username: defaultAccount?.login ?? null, + // Managed auth status does not expose a single provider-wide token expiry. + expires_at: null, + default_account_id: managedAuth.defaultAccountId, + migration_error: managedAuth.migrationError, + accounts: managedAuth.accounts as GitHubAccount[], + } + : undefined, + // Managed auth status no longer exposes a single default token expiry. + username: defaultAccount?.login ?? null, + }; +} diff --git a/src/components/providers/forms/hooks/useManagedAuth.ts b/src/components/providers/forms/hooks/useManagedAuth.ts new file mode 100644 index 00000000..787f1094 --- /dev/null +++ b/src/components/providers/forms/hooks/useManagedAuth.ts @@ -0,0 +1,233 @@ +import { useState, useCallback, useRef, useEffect } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { authApi, settingsApi } from "@/lib/api"; +import type { + ManagedAuthProvider, + ManagedAuthStatus, + ManagedAuthDeviceCodeResponse, +} from "@/lib/api"; + +type PollingState = "idle" | "polling" | "success" | "error"; + +export function useManagedAuth(authProvider: ManagedAuthProvider) { + const queryClient = useQueryClient(); + const queryKey = ["managed-auth-status", authProvider]; + + const [pollingState, setPollingState] = useState("idle"); + const [deviceCode, setDeviceCode] = + useState(null); + const [error, setError] = useState(null); + + const pollingIntervalRef = useRef | null>( + null, + ); + const pollingTimeoutRef = useRef | null>(null); + + const { + data: authStatus, + isLoading: isLoadingStatus, + refetch: refetchStatus, + } = useQuery({ + queryKey, + queryFn: () => authApi.authGetStatus(authProvider), + staleTime: 30000, + }); + + const stopPolling = useCallback(() => { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + if (pollingTimeoutRef.current) { + clearTimeout(pollingTimeoutRef.current); + pollingTimeoutRef.current = null; + } + }, []); + + useEffect(() => { + return () => { + stopPolling(); + }; + }, [stopPolling]); + + const startLoginMutation = useMutation({ + mutationFn: () => authApi.authStartLogin(authProvider), + onSuccess: async (response) => { + setDeviceCode(response); + setPollingState("polling"); + setError(null); + + try { + await navigator.clipboard.writeText(response.user_code); + } catch (e) { + console.debug("[ManagedAuth] Failed to copy user code:", e); + } + + try { + await settingsApi.openExternal(response.verification_uri); + } catch (e) { + console.debug("[ManagedAuth] Failed to open browser:", e); + } + + // Add a small buffer on top of GitHub's suggested interval to avoid + // hitting slow_down responses too aggressively during device polling. + const interval = Math.max((response.interval || 5) + 3, 8) * 1000; + const expiresAt = Date.now() + response.expires_in * 1000; + + const pollOnce = async () => { + if (Date.now() > expiresAt) { + stopPolling(); + setPollingState("error"); + setError("Device code expired. Please try again."); + return; + } + + try { + const newAccount = await authApi.authPollForAccount( + authProvider, + response.device_code, + ); + if (newAccount) { + stopPolling(); + setPollingState("success"); + await refetchStatus(); + await queryClient.invalidateQueries({ queryKey }); + setPollingState("idle"); + setDeviceCode(null); + } + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + if ( + !errorMessage.includes("pending") && + !errorMessage.includes("slow_down") + ) { + stopPolling(); + setPollingState("error"); + setError(errorMessage); + } + } + }; + + void pollOnce(); + pollingIntervalRef.current = setInterval(pollOnce, interval); + pollingTimeoutRef.current = setTimeout(() => { + stopPolling(); + setPollingState("error"); + setError("Device code expired. Please try again."); + }, response.expires_in * 1000); + }, + onError: (e) => { + setPollingState("error"); + setError(e instanceof Error ? e.message : String(e)); + }, + }); + + const logoutMutation = useMutation({ + mutationFn: () => authApi.authLogout(authProvider), + onSuccess: async () => { + setPollingState("idle"); + setDeviceCode(null); + setError(null); + queryClient.setQueryData(queryKey, { + provider: authProvider, + authenticated: false, + default_account_id: null, + accounts: [], + }); + await queryClient.invalidateQueries({ queryKey }); + }, + onError: async (e) => { + console.error("[ManagedAuth] Failed to logout:", e); + setError(e instanceof Error ? e.message : String(e)); + await refetchStatus(); + }, + }); + + const removeAccountMutation = useMutation({ + mutationFn: (accountId: string) => + authApi.authRemoveAccount(authProvider, accountId), + onSuccess: async () => { + setPollingState("idle"); + setDeviceCode(null); + setError(null); + await refetchStatus(); + await queryClient.invalidateQueries({ queryKey }); + }, + onError: (e) => { + console.error("[ManagedAuth] Failed to remove account:", e); + setError(e instanceof Error ? e.message : String(e)); + }, + }); + + const setDefaultAccountMutation = useMutation({ + mutationFn: (accountId: string) => + authApi.authSetDefaultAccount(authProvider, accountId), + onSuccess: async () => { + await refetchStatus(); + await queryClient.invalidateQueries({ queryKey }); + }, + onError: (e) => { + console.error("[ManagedAuth] Failed to set default account:", e); + setError(e instanceof Error ? e.message : String(e)); + }, + }); + + const startAuth = useCallback(() => { + setPollingState("idle"); + setDeviceCode(null); + setError(null); + stopPolling(); + startLoginMutation.mutate(); + }, [startLoginMutation, stopPolling]); + + const cancelAuth = useCallback(() => { + stopPolling(); + setPollingState("idle"); + setDeviceCode(null); + setError(null); + }, [stopPolling]); + + const logout = useCallback(() => { + logoutMutation.mutate(); + }, [logoutMutation]); + + const removeAccount = useCallback( + (accountId: string) => { + removeAccountMutation.mutate(accountId); + }, + [removeAccountMutation], + ); + + const setDefaultAccount = useCallback( + (accountId: string) => { + setDefaultAccountMutation.mutate(accountId); + }, + [setDefaultAccountMutation], + ); + + const accounts = authStatus?.accounts ?? []; + + return { + authStatus, + isLoadingStatus, + accounts, + hasAnyAccount: accounts.length > 0, + isAuthenticated: authStatus?.authenticated ?? false, + defaultAccountId: authStatus?.default_account_id ?? null, + migrationError: authStatus?.migration_error ?? null, + pollingState, + deviceCode, + error, + isPolling: pollingState === "polling", + isAddingAccount: startLoginMutation.isPending || pollingState === "polling", + isRemovingAccount: removeAccountMutation.isPending, + isSettingDefaultAccount: setDefaultAccountMutation.isPending, + startAuth, + addAccount: startAuth, + cancelAuth, + logout, + removeAccount, + setDefaultAccount, + refetchStatus, + }; +} diff --git a/src/components/settings/AuthCenterPanel.tsx b/src/components/settings/AuthCenterPanel.tsx new file mode 100644 index 00000000..ee06b0bc --- /dev/null +++ b/src/components/settings/AuthCenterPanel.tsx @@ -0,0 +1,55 @@ +import { Github, ShieldCheck } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Badge } from "@/components/ui/badge"; +import { CopilotAuthSection } from "@/components/providers/forms/CopilotAuthSection"; + +export function AuthCenterPanel() { + const { t } = useTranslation(); + + return ( +
+
+
+
+
+ +

+ {t("settings.authCenter.title", { + defaultValue: "OAuth 认证中心", + })} +

+
+

+ {t("settings.authCenter.description", { + defaultValue: + "集中管理跨应用复用的 OAuth 账号。Provider 只绑定这些认证源,不再重复登录。", + })} +

+
+ + {t("settings.authCenter.beta", { defaultValue: "Beta" })} + +
+
+ +
+
+
+ +
+
+

GitHub Copilot

+

+ {t("settings.authCenter.copilotDescription", { + defaultValue: + "管理 GitHub Copilot 账号、默认账号以及供 Claude / Codex / Gemini 绑定的托管凭据。", + })} +

+
+
+ + +
+
+ ); +} diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 0e6604af..cb536159 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -9,6 +9,7 @@ import { ScrollText, HardDriveDownload, FlaskConical, + KeyRound, } from "lucide-react"; import { toast } from "sonner"; import { @@ -42,6 +43,7 @@ import { ProxyTabContent } from "@/components/settings/ProxyTabContent"; import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel"; import { UsageDashboard } from "@/components/usage/UsageDashboard"; import { LogConfigPanel } from "@/components/settings/LogConfigPanel"; +import { AuthCenterPanel } from "@/components/settings/AuthCenterPanel"; import { useSettings } from "@/hooks/useSettings"; import { useImportExport } from "@/hooks/useImportExport"; import { useTranslation } from "react-i18next"; @@ -189,11 +191,14 @@ export function SettingsPage({ onValueChange={setActiveTab} className="flex flex-col h-full" > - + {t("settings.tabGeneral")} {t("settings.tabProxy")} + + {t("settings.tabAuth", { defaultValue: "认证" })} + {t("settings.tabAdvanced")} @@ -249,6 +254,34 @@ export function SettingsPage({ ) : null} + + +
+ +
+

+ {t("settings.authCenter.heading", { + defaultValue: "认证中心", + })} +

+

+ {t("settings.authCenter.headingDescription", { + defaultValue: + "统一管理可跨应用复用的 OAuth 账号和默认认证来源。", + })} +

+
+
+ + +
+
+ {settings ? ( onChange({ skipClaudeOnboarding: value })} /> - } - title={t("settings.toolSearchBypass")} - description={t("settings.toolSearchBypassDescription")} - checked={!!settings.toolSearchBypass} - onCheckedChange={(value) => onChange({ toolSearchBypass: value })} - /> - } title={t("settings.minimizeToTray")} diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index e3f16d3b..7e9ff23e 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -50,6 +50,13 @@ export interface ProviderPreset { // - "openai_chat": OpenAI Chat Completions 格式,需要格式转换 // - "openai_responses": OpenAI Responses API 格式,需要格式转换 apiFormat?: "anthropic" | "openai_chat" | "openai_responses"; + + // 供应商类型标识(用于特殊供应商检测) + // - "github_copilot": GitHub Copilot 供应商(需要 OAuth 认证) + providerType?: "github_copilot"; + + // 是否需要 OAuth 认证(而非 API Key) + requiresOAuth?: boolean; } export const providerPresets: ProviderPreset[] = [ @@ -679,6 +686,25 @@ export const providerPresets: ProviderPreset[] = [ icon: "novita", iconColor: "#000000", }, + { + name: "GitHub Copilot", + websiteUrl: "https://github.com/features/copilot", + settingsConfig: { + env: { + ANTHROPIC_BASE_URL: "https://api.githubcopilot.com", + ANTHROPIC_MODEL: "claude-opus-4.6", + ANTHROPIC_DEFAULT_HAIKU_MODEL: "claude-haiku-4.5", + ANTHROPIC_DEFAULT_SONNET_MODEL: "claude-sonnet-4.6", + ANTHROPIC_DEFAULT_OPUS_MODEL: "claude-opus-4.6", + }, + }, + category: "third_party", + apiFormat: "openai_chat", + providerType: "github_copilot", + requiresOAuth: true, + icon: "github", + iconColor: "#000000", + }, { name: "Nvidia", websiteUrl: "https://build.nvidia.com", diff --git a/src/config/constants.ts b/src/config/constants.ts new file mode 100644 index 00000000..18ae12de --- /dev/null +++ b/src/config/constants.ts @@ -0,0 +1,15 @@ +// Provider 类型常量 +export const PROVIDER_TYPES = { + GITHUB_COPILOT: "github_copilot", +} as const; + +// 用量脚本模板类型常量 +export const TEMPLATE_TYPES = { + CUSTOM: "custom", + GENERAL: "general", + NEW_API: "newapi", + GITHUB_COPILOT: "github_copilot", +} as const; + +export type TemplateType = + (typeof TEMPLATE_TYPES)[keyof typeof TEMPLATE_TYPES]; diff --git a/src/config/opencodeProviderPresets.ts b/src/config/opencodeProviderPresets.ts index 1e50daf0..18c993d1 100644 --- a/src/config/opencodeProviderPresets.ts +++ b/src/config/opencodeProviderPresets.ts @@ -300,6 +300,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://api.deepseek.com/v1", apiKey: "", + setCacheKey: true, }, models: { "deepseek-chat": { name: "DeepSeek V3.2" }, @@ -327,6 +328,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://open.bigmodel.cn/api/paas/v4", apiKey: "", + setCacheKey: true, }, models: { "glm-5": { name: "GLM-5" }, @@ -359,6 +361,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://api.z.ai/v1", apiKey: "", + setCacheKey: true, }, models: { "glm-5": { name: "GLM-5" }, @@ -391,6 +394,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", apiKey: "", + setCacheKey: true, }, models: {}, }, @@ -421,6 +425,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://api.moonshot.cn/v1", apiKey: "", + setCacheKey: true, }, models: { "kimi-k2.5": { name: "Kimi K2.5" }, @@ -453,6 +458,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://api.kimi.com/v1", apiKey: "", + setCacheKey: true, }, models: { "kimi-for-coding": { name: "Kimi For Coding" }, @@ -485,6 +491,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://api.stepfun.ai/v1", apiKey: "", + setCacheKey: true, }, models: { "step-3.5-flash": { name: "Step 3.5 Flash" }, @@ -517,6 +524,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://api-inference.modelscope.cn/v1", apiKey: "", + setCacheKey: true, }, models: { "ZhipuAI/GLM-5": { name: "GLM-5" }, @@ -550,6 +558,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ baseURL: "https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/openai", apiKey: "", + setCacheKey: true, }, models: { "KAT-Coder-Pro": { name: "KAT-Coder Pro" }, @@ -589,6 +598,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://api.longcat.chat/v1", apiKey: "", + setCacheKey: true, }, models: { "LongCat-Flash-Chat": { name: "LongCat Flash Chat" }, @@ -621,6 +631,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://api.minimaxi.com/v1", apiKey: "", + setCacheKey: true, }, models: { "MiniMax-M2.5": { name: "MiniMax M2.5" }, @@ -653,6 +664,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://api.minimax.io/v1", apiKey: "", + setCacheKey: true, }, models: { "MiniMax-M2.5": { name: "MiniMax M2.5" }, @@ -685,6 +697,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://ark.cn-beijing.volces.com/api/v3", apiKey: "", + setCacheKey: true, }, models: { "doubao-seed-2-0-code-preview-latest": { @@ -712,6 +725,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://api.tbox.cn/v1", apiKey: "", + setCacheKey: true, }, models: { "Ling-2.5-1T": { name: "Ling 2.5-1T" }, @@ -736,6 +750,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://api.xiaomimimo.com/v1", apiKey: "", + setCacheKey: true, }, models: { "mimo-v2-flash": { name: "MiMo V2 Flash" }, @@ -763,6 +778,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://aihubmix.com/v1", apiKey: "", + setCacheKey: true, }, models: { "claude-sonnet-4-6": { name: "Claude Sonnet 4.6" }, @@ -790,6 +806,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://www.dmxapi.cn/v1", apiKey: "", + setCacheKey: true, }, models: { "claude-sonnet-4-6": { name: "Claude Sonnet 4.6" }, @@ -817,6 +834,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://openrouter.ai/api/v1", apiKey: "", + setCacheKey: true, }, models: { "anthropic/claude-sonnet-4.6": { name: "Claude Sonnet 4.6" }, @@ -844,6 +862,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://api.novita.ai/openai", apiKey: "", + setCacheKey: true, }, models: { "zai-org/glm-5": { name: "GLM-5" }, @@ -870,6 +889,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://integrate.api.nvidia.com/v1", apiKey: "", + setCacheKey: true, }, models: { "moonshotai/kimi-k2.5": { name: "Kimi K2.5" }, @@ -897,6 +917,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://www.packyapi.com/v1", apiKey: "", + setCacheKey: true, }, models: { "claude-sonnet-4-6": { name: "Claude Sonnet 4.6" }, @@ -925,6 +946,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://api.cubence.com/v1", apiKey: "", + setCacheKey: true, }, models: { "claude-sonnet-4-6": { name: "Claude Sonnet 4.6" }, @@ -954,6 +976,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://api.aigocode.com", apiKey: "", + setCacheKey: true, }, models: { "claude-sonnet-4-6": { name: "Claude Sonnet 4.6" }, @@ -983,6 +1006,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://right.codes/codex/v1", apiKey: "", + setCacheKey: true, }, models: { "gpt-5.4": { name: "GPT-5.4" }, @@ -1011,6 +1035,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://api.aicodemirror.com/api/claudecode", apiKey: "", + setCacheKey: true, }, models: { "claude-sonnet-4.6": { name: "Claude Sonnet 4.6" }, @@ -1040,6 +1065,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://api.aicoding.sh", apiKey: "", + setCacheKey: true, }, models: { "claude-sonnet-4-6": { name: "Claude Sonnet 4.6" }, @@ -1069,6 +1095,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://crazyrouter.com", apiKey: "", + setCacheKey: true, }, models: { "claude-sonnet-4-6": { name: "Claude Sonnet 4.6" }, @@ -1098,6 +1125,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://node-hk.sssaicode.com/api/v1", apiKey: "", + setCacheKey: true, }, models: { "claude-sonnet-4-6": { name: "Claude Sonnet 4.6" }, @@ -1127,6 +1155,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://www.openclaudecode.cn/v1", apiKey: "", + setCacheKey: true, }, models: { "claude-opus-4-6": { name: "Claude Opus 4.6" }, @@ -1156,6 +1185,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://x-code.cc/v1", apiKey: "", + setCacheKey: true, }, models: { "claude-opus-4-6": { name: "Claude Opus 4.6" }, @@ -1185,6 +1215,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "https://api.ctok.ai/v1", apiKey: "", + setCacheKey: true, }, models: { "claude-opus-4-6": { name: "Claude Opus 4.6" }, @@ -1214,6 +1245,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ region: "${region}", accessKeyId: "${accessKeyId}", secretAccessKey: "${secretAccessKey}", + setCacheKey: true, }, models: { "global.anthropic.claude-opus-4-6-v1": { name: "Claude Opus 4.6" }, @@ -1260,6 +1292,7 @@ export const opencodeProviderPresets: OpenCodeProviderPreset[] = [ options: { baseURL: "", apiKey: "", + setCacheKey: true, }, models: {}, }, diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 37cda881..b24e01e4 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -197,44 +197,6 @@ export function useSettings(): UseSettingsResult { } } - // Tool Search bypass: apply/restore patch when toggled - const nextToolSearchBypass = updates.toolSearchBypass; - if ( - nextToolSearchBypass !== undefined && - nextToolSearchBypass !== (data?.toolSearchBypass ?? false) - ) { - try { - const results = nextToolSearchBypass - ? await settingsApi.applyToolSearchPatch() - : await settingsApi.restoreToolSearchPatch(); - const failed = results.find((r) => !r.success); - if (failed) { - throw new Error(failed.error ?? "Tool Search patch failed"); - } - } catch (error) { - console.warn( - "[useSettings] Failed to sync Tool Search bypass", - error, - ); - // Rollback: revert the setting we already saved - const rolledBack = { - ...payload, - toolSearchBypass: !nextToolSearchBypass, - }; - await saveMutation.mutateAsync(rolledBack); - updateSettings({ toolSearchBypass: !nextToolSearchBypass }); - toast.error( - nextToolSearchBypass - ? t("notifications.toolSearchPatchFailed", { - defaultValue: "Tool Search 补丁操作失败", - }) - : t("notifications.toolSearchRestoreFailed", { - defaultValue: "Tool Search 恢复操作失败", - }), - ); - } - } - // 持久化语言偏好 try { if (typeof window !== "undefined" && updates.language) { @@ -266,7 +228,7 @@ export function useSettings(): UseSettingsResult { throw error; } }, - [data, saveMutation, settings, t, updateSettings], + [data, saveMutation, settings, t], ); // 完整保存设置(用于 Advanced 标签页的手动保存) diff --git a/src/hooks/useSettingsForm.ts b/src/hooks/useSettingsForm.ts index dbbd026e..72530920 100644 --- a/src/hooks/useSettingsForm.ts +++ b/src/hooks/useSettingsForm.ts @@ -85,7 +85,6 @@ export function useSettingsForm(): UseSettingsFormResult { data.enableClaudePluginIntegration ?? false, silentStartup: data.silentStartup ?? false, skipClaudeOnboarding: data.skipClaudeOnboarding ?? false, - toolSearchBypass: data.toolSearchBypass ?? false, claudeConfigDir: sanitizeDir(data.claudeConfigDir), codexConfigDir: sanitizeDir(data.codexConfigDir), geminiConfigDir: sanitizeDir(data.geminiConfigDir), @@ -108,7 +107,6 @@ export function useSettingsForm(): UseSettingsFormResult { minimizeToTrayOnClose: true, enableClaudePluginIntegration: false, skipClaudeOnboarding: false, - toolSearchBypass: false, language: readPersistedLanguage(), } as SettingsFormState); @@ -145,7 +143,6 @@ export function useSettingsForm(): UseSettingsFormResult { serverData.enableClaudePluginIntegration ?? false, silentStartup: serverData.silentStartup ?? false, skipClaudeOnboarding: serverData.skipClaudeOnboarding ?? false, - toolSearchBypass: serverData.toolSearchBypass ?? false, claudeConfigDir: sanitizeDir(serverData.claudeConfigDir), codexConfigDir: sanitizeDir(serverData.codexConfigDir), geminiConfigDir: sanitizeDir(serverData.geminiConfigDir), diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index c96e7fe4..04c42fbb 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -63,8 +63,9 @@ "extractFailed": "Extract failed: {{error}}", "saveFailed": "Save failed: {{error}}", "hideAttribution": "Hide AI Attribution", - "alwaysThinking": "Extended Thinking", - "enableTeammates": "Teammates Mode" + "enableTeammates": "Teammates Mode", + "enableToolSearch": "Enable Tool Search", + "effortHigh": "High Effort Thinking" }, "header": { "viewOnGithub": "View on GitHub", @@ -166,8 +167,6 @@ "syncClaudePluginFailed": "Sync Claude plugin failed", "skipClaudeOnboardingFailed": "Failed to skip Claude Code first-run confirmation", "clearClaudeOnboardingSkipFailed": "Failed to restore Claude Code first-run confirmation", - "toolSearchPatchFailed": "Failed to apply Tool Search patch", - "toolSearchRestoreFailed": "Failed to restore Tool Search patch", "updateSuccess": "Provider updated successfully", "updateFailed": "Failed to update provider: {{error}}", "deleteSuccess": "Provider deleted", @@ -465,8 +464,6 @@ "enableClaudePluginIntegrationDescription": "When enabled, the VS Code Claude Code extension provider will switch with this app", "skipClaudeOnboarding": "Skip Claude Code first-run confirmation", "skipClaudeOnboardingDescription": "When enabled, Claude Code will skip the first-run confirmation", - "toolSearchBypass": "Bypass Tool Search domain restriction", - "toolSearchBypassDescription": "Remove the Tool Search domain whitelist in the active Claude Code installation. Auto-reapplied after that installation updates", "appVisibility": { "title": "Homepage Display", "description": "Choose which apps to show on the homepage", @@ -760,11 +757,45 @@ "smallModelPlaceholder": "", "haikuModelPlaceholder": "", "modelHelper": "Optional: Specify default Claude model to use, leave blank to use system default.", + "modelMappingLabel": "Model Mapping", + "modelMappingHint": "Usually not needed if the provider natively serves Claude models. Only configure when you need to map requests to different model names.", + "advancedOptionsToggle": "Advanced Options", + "advancedOptionsHint": "Includes API format, auth field, and model mapping. Defaults work for most use cases.", "categoryOfficial": "Official", "categoryCnOfficial": "Opensource Official", "categoryAggregation": "Aggregation", "categoryThirdParty": "Third Party" }, + "copilot": { + "authSection": "GitHub Copilot Authentication", + "authStatus": "Authentication Status", + "authenticated": "Authenticated as {{username}}", + "notAuthenticated": "Not authenticated", + "loginWithGitHub": "Login with GitHub", + "loginRequired": "Please login to GitHub Copilot first", + "waitingForAuth": "Waiting for authorization...", + "enterCode": "Please enter the code in your browser:", + "logout": "Logout", + "authSuccess": "GitHub Copilot authentication successful", + "authFailed": "Authentication failed: {{error}}", + "authTimeout": "Authentication timed out, please try again", + "tokenExpired": "Token expired, please re-authenticate", + "accountCount": "{{count}} account(s)", + "selectAccount": "Select Account", + "selectAccountPlaceholder": "Select a GitHub account", + "useDefaultAccount": "Use default account", + "loggedInAccounts": "Logged in accounts", + "defaultAccount": "Default", + "selected": "Selected", + "removeAccount": "Remove account", + "setAsDefault": "Set as default", + "addAnotherAccount": "Add another account", + "logoutAll": "Logout all accounts", + "retry": "Retry", + "copyCode": "Copy code", + "migrationFailed": "Legacy auth migration failed: {{error}}", + "loadModelsFailed": "Failed to load Copilot models" + }, "endpointTest": { "title": "API Endpoint Management", "endpoints": "endpoints", @@ -835,7 +866,10 @@ "saveFailed": "Save failed: {{error}}", "modelNameHint": "Specify the model to use, will be auto-updated in config.toml", "modelName": "Model Name", - "modelNamePlaceholder": "e.g., gpt-5-codex" + "modelNamePlaceholder": "e.g., gpt-5-codex", + "contextWindow1M": "1M Context Window", + "autoCompactLimit": "Auto Compact Limit", + "autoCompactLimitHint": "Auto-compacts history when context reaches this token limit" }, "geminiConfig": { "envFile": "Environment Variables (.env)", @@ -1017,6 +1051,10 @@ "templateCustom": "Custom", "templateGeneral": "General", "templateNewAPI": "NewAPI", + "templateCopilot": "GitHub Copilot", + "copilotAutoAuth": "Auto OAuth authentication, no manual credentials needed", + "resetDate": "Reset date", + "premiumRequests": "Premium Requests", "credentialsConfig": "Credentials", "credentialsHint": "Leave empty to use provider config", "optional": "optional", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 0a4e6b51..d2d62d47 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -63,8 +63,9 @@ "extractFailed": "抽出に失敗しました: {{error}}", "saveFailed": "保存に失敗しました: {{error}}", "hideAttribution": "AI署名を非表示", - "alwaysThinking": "拡張思考", - "enableTeammates": "Teammates モード" + "enableTeammates": "Teammates モード", + "enableToolSearch": "Tool Search を有効化", + "effortHigh": "高強度思考" }, "header": { "viewOnGithub": "GitHub で見る", @@ -166,8 +167,6 @@ "syncClaudePluginFailed": "Claude プラグインとの同期に失敗しました", "skipClaudeOnboardingFailed": "Claude Code の初回確認スキップに失敗しました", "clearClaudeOnboardingSkipFailed": "Claude Code の初回確認の復元に失敗しました", - "toolSearchPatchFailed": "Tool Search パッチの適用に失敗しました", - "toolSearchRestoreFailed": "Tool Search パッチの復元に失敗しました", "updateSuccess": "プロバイダーを更新しました", "updateFailed": "プロバイダーの更新に失敗しました: {{error}}", "deleteSuccess": "プロバイダーを削除しました", @@ -465,8 +464,6 @@ "enableClaudePluginIntegrationDescription": "オンにすると VS Code の Claude Code 拡張のプロバイダーも同期します", "skipClaudeOnboarding": "Claude Code の初回確認をスキップ", "skipClaudeOnboardingDescription": "オンにすると Claude Code の初回インストール確認をスキップします", - "toolSearchBypass": "Tool Search ドメイン制限を解除", - "toolSearchBypassDescription": "現在アクティブな Claude Code インストールの Tool Search ドメインホワイトリスト制限を解除します。そのインストールの更新後は自動で再適用されます", "appVisibility": { "title": "ホームページ表示", "description": "ホームページに表示するアプリを選択", @@ -760,11 +757,45 @@ "smallModelPlaceholder": "", "haikuModelPlaceholder": "", "modelHelper": "任意: 既定で使いたい Claude モデルを指定。空欄ならシステム既定を使用します。", + "modelMappingLabel": "モデルマッピング", + "modelMappingHint": "プロバイダーが Claude モデルをネイティブ提供している場合、通常は設定不要です。リクエストを別のモデル名にマッピングする場合のみ設定してください。", + "advancedOptionsToggle": "高級オプション", + "advancedOptionsHint": "API フォーマット、認証フィールド、モデルマッピングの設定を含みます。通常はデフォルトのままで問題ありません。", "categoryOfficial": "公式", "categoryCnOfficial": "オープンソース公式", "categoryAggregation": "アグリゲーター", "categoryThirdParty": "サードパーティ" }, + "copilot": { + "authSection": "GitHub Copilot 認証", + "authStatus": "認証状態", + "authenticated": "認証済み: {{username}}", + "notAuthenticated": "未認証", + "loginWithGitHub": "GitHub でログイン", + "loginRequired": "先に GitHub Copilot にログインしてください", + "waitingForAuth": "認証を待っています...", + "enterCode": "ブラウザで以下のコードを入力してください:", + "logout": "ログアウト", + "authSuccess": "GitHub Copilot 認証に成功しました", + "authFailed": "認証に失敗しました: {{error}}", + "authTimeout": "認証がタイムアウトしました。もう一度お試しください", + "tokenExpired": "トークンの有効期限が切れました。再認証してください", + "accountCount": "{{count}} アカウント", + "selectAccount": "アカウントを選択", + "selectAccountPlaceholder": "GitHub アカウントを選択", + "useDefaultAccount": "デフォルトアカウントを使用", + "loggedInAccounts": "ログイン済みアカウント", + "defaultAccount": "デフォルト", + "selected": "選択中", + "removeAccount": "アカウントを削除", + "setAsDefault": "デフォルトに設定", + "addAnotherAccount": "別のアカウントを追加", + "logoutAll": "すべてのアカウントをログアウト", + "retry": "再試行", + "copyCode": "コードをコピー", + "migrationFailed": "旧認証データの移行に失敗しました: {{error}}", + "loadModelsFailed": "Copilot モデル一覧の読み込みに失敗しました" + }, "endpointTest": { "title": "API エンドポイント管理", "endpoints": "エンドポイント", @@ -835,7 +866,10 @@ "saveFailed": "保存に失敗しました: {{error}}", "modelNameHint": "使用するモデルを指定します。config.toml に自動更新されます", "modelName": "モデル名", - "modelNamePlaceholder": "例: gpt-5-codex" + "modelNamePlaceholder": "例: gpt-5-codex", + "contextWindow1M": "1M コンテキストウィンドウ", + "autoCompactLimit": "自動圧縮しきい値", + "autoCompactLimitHint": "コンテキストトークン数がこのしきい値に達すると履歴を自動圧縮" }, "geminiConfig": { "envFile": "環境変数 (.env)", @@ -1017,6 +1051,10 @@ "templateCustom": "カスタム", "templateGeneral": "General", "templateNewAPI": "NewAPI", + "templateCopilot": "GitHub Copilot", + "copilotAutoAuth": "OAuth 認証を自動使用、手動設定不要", + "resetDate": "リセット日", + "premiumRequests": "Premium リクエスト", "credentialsConfig": "認証情報", "credentialsHint": "空欄の場合はプロバイダー設定を使用", "optional": "オプション", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 3b2e6df0..15128c7f 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -63,8 +63,9 @@ "extractFailed": "提取失败: {{error}}", "saveFailed": "保存失败: {{error}}", "hideAttribution": "隐藏 AI 署名", - "alwaysThinking": "扩展思考", - "enableTeammates": "Teammates 模式" + "enableTeammates": "Teammates 模式", + "enableToolSearch": "启用 Tool Search", + "effortHigh": "高强度思考" }, "header": { "viewOnGithub": "在 GitHub 上查看", @@ -166,8 +167,6 @@ "syncClaudePluginFailed": "同步 Claude 插件失败", "skipClaudeOnboardingFailed": "跳过 Claude Code 初次安装确认失败", "clearClaudeOnboardingSkipFailed": "恢复 Claude Code 初次安装确认失败", - "toolSearchPatchFailed": "Tool Search 补丁操作失败", - "toolSearchRestoreFailed": "Tool Search 恢复操作失败", "updateSuccess": "供应商更新成功", "updateFailed": "更新供应商失败:{{error}}", "deleteSuccess": "供应商已删除", @@ -465,8 +464,6 @@ "enableClaudePluginIntegrationDescription": "开启后 Vscode Claude Code 插件的供应商将随本软件切换", "skipClaudeOnboarding": "跳过 Claude Code 初次安装确认", "skipClaudeOnboardingDescription": "开启后跳过 Claude Code 初次安装确认", - "toolSearchBypass": "解除 Tool Search 域名限制", - "toolSearchBypassDescription": "解除当前活跃 Claude Code 安装的 Tool Search 域名白名单限制。该安装更新后会自动重新应用", "appVisibility": { "title": "主页面显示", "description": "选择在主页面显示的应用", @@ -760,11 +757,45 @@ "smallModelPlaceholder": "", "haikuModelPlaceholder": "", "modelHelper": "可选:指定默认使用的 Claude 模型,留空则使用系统默认。", + "modelMappingLabel": "模型映射", + "modelMappingHint": "如果供应商原生提供 Claude 系列模型,通常无需配置。仅在需要将请求映射到不同模型名称时填写。", + "advancedOptionsToggle": "高级选项", + "advancedOptionsHint": "包含 API 格式、认证字段、模型映射等配置。大多数场景下保持默认即可。", "categoryOfficial": "官方", "categoryCnOfficial": "开源官方", "categoryAggregation": "聚合服务", "categoryThirdParty": "第三方" }, + "copilot": { + "authSection": "GitHub Copilot 认证", + "authStatus": "认证状态", + "authenticated": "已认证: {{username}}", + "notAuthenticated": "未认证", + "loginWithGitHub": "使用 GitHub 登录", + "loginRequired": "请先登录 GitHub Copilot", + "waitingForAuth": "等待授权中...", + "enterCode": "请在浏览器中输入验证码:", + "logout": "注销", + "authSuccess": "GitHub Copilot 认证成功", + "authFailed": "认证失败: {{error}}", + "authTimeout": "认证超时,请重试", + "tokenExpired": "令牌已过期,请重新认证", + "accountCount": "{{count}} 个账号", + "selectAccount": "选择账号", + "selectAccountPlaceholder": "选择一个 GitHub 账号", + "useDefaultAccount": "使用默认账号", + "loggedInAccounts": "已登录账号", + "defaultAccount": "默认", + "selected": "已选中", + "removeAccount": "移除账号", + "setAsDefault": "设为默认", + "addAnotherAccount": "添加其他账号", + "logoutAll": "注销所有账号", + "retry": "重试", + "copyCode": "复制代码", + "migrationFailed": "旧认证数据迁移失败:{{error}}", + "loadModelsFailed": "加载 Copilot 模型列表失败" + }, "endpointTest": { "title": "请求地址管理", "endpoints": "个端点", @@ -835,7 +866,10 @@ "saveFailed": "保存失败: {{error}}", "modelNameHint": "指定使用的模型,将自动更新到 config.toml 中", "modelName": "模型名称", - "modelNamePlaceholder": "例如: gpt-5-codex" + "modelNamePlaceholder": "例如: gpt-5-codex", + "contextWindow1M": "1M 上下文窗口", + "autoCompactLimit": "压缩阈值", + "autoCompactLimitHint": "上下文 token 数达到此阈值时自动压缩历史" }, "geminiConfig": { "envFile": "环境变量 (.env)", @@ -1017,6 +1051,10 @@ "templateCustom": "自定义", "templateGeneral": "通用模板", "templateNewAPI": "NewAPI", + "templateCopilot": "GitHub Copilot", + "copilotAutoAuth": "自动使用 OAuth 认证,无需手动配置凭证", + "resetDate": "重置日期", + "premiumRequests": "Premium 请求", "credentialsConfig": "凭证配置", "credentialsHint": "留空则自动使用供应商配置", "optional": "可选", diff --git a/src/lib/api/auth.ts b/src/lib/api/auth.ts new file mode 100644 index 00000000..5e6ecde0 --- /dev/null +++ b/src/lib/api/auth.ts @@ -0,0 +1,101 @@ +import { invoke } from "@tauri-apps/api/core"; + +export type ManagedAuthProvider = "github_copilot"; + +export interface ManagedAuthAccount { + id: string; + provider: ManagedAuthProvider; + login: string; + avatar_url: string | null; + authenticated_at: number; + is_default: boolean; +} + +export interface ManagedAuthStatus { + provider: ManagedAuthProvider; + authenticated: boolean; + default_account_id: string | null; + migration_error?: string | null; + accounts: ManagedAuthAccount[]; +} + +export interface ManagedAuthDeviceCodeResponse { + provider: ManagedAuthProvider; + device_code: string; + user_code: string; + verification_uri: string; + expires_in: number; + interval: number; +} + +export async function authStartLogin( + authProvider: ManagedAuthProvider, +): Promise { + return invoke("auth_start_login", { + authProvider, + }); +} + +export async function authPollForAccount( + authProvider: ManagedAuthProvider, + deviceCode: string, +): Promise { + return invoke("auth_poll_for_account", { + authProvider, + deviceCode, + }); +} + +export async function authListAccounts( + authProvider: ManagedAuthProvider, +): Promise { + return invoke("auth_list_accounts", { + authProvider, + }); +} + +export async function authGetStatus( + authProvider: ManagedAuthProvider, +): Promise { + return invoke("auth_get_status", { + authProvider, + }); +} + +export async function authRemoveAccount( + authProvider: ManagedAuthProvider, + accountId: string, +): Promise { + return invoke("auth_remove_account", { + authProvider, + accountId, + }); +} + +export async function authSetDefaultAccount( + authProvider: ManagedAuthProvider, + accountId: string, +): Promise { + return invoke("auth_set_default_account", { + authProvider, + accountId, + }); +} + +export async function authLogout( + authProvider: ManagedAuthProvider, +): Promise { + return invoke("auth_logout", { + authProvider, + }); +} + +export const authApi = { + authStartLogin, + authPollForAccount, + authListAccounts, + authGetStatus, + authRemoveAccount, + authSetDefaultAccount, + authLogout, +}; diff --git a/src/lib/api/copilot.ts b/src/lib/api/copilot.ts new file mode 100644 index 00000000..09eb55b0 --- /dev/null +++ b/src/lib/api/copilot.ts @@ -0,0 +1,256 @@ +/** + * GitHub Copilot OAuth API + * + * 提供 GitHub Copilot OAuth 设备码流程相关的 API 函数。 + * 支持多账号管理。 + */ + +import { invoke } from "@tauri-apps/api/core"; + +/** + * GitHub 设备码响应 + */ +export interface CopilotDeviceCodeResponse { + device_code: string; + user_code: string; + verification_uri: string; + expires_in: number; + interval: number; +} + +/** + * GitHub 账号信息(公开信息) + */ +export interface GitHubAccount { + /** GitHub 用户 ID(唯一标识) */ + id: string; + /** GitHub 用户名 */ + login: string; + /** 头像 URL */ + avatar_url: string | null; + /** 认证时间戳(Unix 秒) */ + authenticated_at: number; +} + +/** + * Copilot 认证状态(多账号版本) + */ +export interface CopilotAuthStatus { + /** 是否已认证(有任意账号)- 向后兼容 */ + authenticated: boolean; + /** 默认账号 ID */ + default_account_id: string | null; + /** 旧认证数据迁移失败时的状态消息 */ + migration_error?: string | null; + /** 第一个账号的用户名 - 向后兼容 */ + username: string | null; + /** Copilot Token 过期时间 - 向后兼容 */ + expires_at: number | null; + /** 所有已认证账号列表 */ + accounts: GitHubAccount[]; +} + +/** + * 启动 GitHub OAuth 设备码流程 + * + * @returns 设备码响应,包含用户码和验证 URL + */ +export async function copilotStartDeviceFlow(): Promise { + return invoke("copilot_start_device_flow"); +} + +/** + * 轮询 OAuth Token + * + * 使用设备码轮询 GitHub,等待用户完成授权。 + * + * @param deviceCode - 设备码 + * @returns true 表示认证成功,false 表示仍在等待用户授权 + */ +export async function copilotPollForAuth(deviceCode: string): Promise { + return invoke("copilot_poll_for_auth", { + deviceCode, + }); +} + +/** + * 获取 Copilot 认证状态 + * + * @returns 认证状态,包含是否已认证、用户名和过期时间 + */ +export async function copilotGetAuthStatus(): Promise { + return invoke("copilot_get_auth_status"); +} + +/** + * 注销 Copilot 认证 + */ +export async function copilotLogout(): Promise { + return invoke("copilot_logout"); +} + +/** + * 检查是否已认证 + * + * @returns true 表示已认证 + */ +export async function copilotIsAuthenticated(): Promise { + return invoke("copilot_is_authenticated"); +} + +/** + * Copilot 可用模型 + */ +export interface CopilotModel { + id: string; + name: string; + vendor: string; + model_picker_enabled: boolean; +} + +/** + * 获取有效的 Copilot Token + * + * 内部使用,用于代理请求。 + * + * @returns Copilot Token + */ +export async function copilotGetToken(): Promise { + return invoke("copilot_get_token"); +} + +/** + * 获取 Copilot 可用模型列表 + * + * @returns 可用模型列表 + */ +export async function copilotGetModels(): Promise { + return invoke("copilot_get_models"); +} + +/** + * 配额详情 + */ +export interface QuotaDetail { + entitlement: number; + remaining: number; + percent_remaining: number; + unlimited: boolean; +} + +/** + * 配额快照 + */ +export interface QuotaSnapshots { + chat: QuotaDetail; + completions: QuotaDetail; + premium_interactions: QuotaDetail; +} + +/** + * Copilot 使用量响应 + */ +export interface CopilotUsageResponse { + copilot_plan: string; + quota_reset_date: string; + quota_snapshots: QuotaSnapshots; +} + +/** + * 获取 Copilot 使用量信息 + * + * @returns 使用量信息,包含计划类型、重置日期和配额快照 + */ +export async function copilotGetUsage(): Promise { + return invoke("copilot_get_usage"); +} + +// ==================== 多账号管理 API ==================== + +/** + * 列出所有已认证的 GitHub 账号 + * + * @returns 账号列表 + */ +export async function copilotListAccounts(): Promise { + return invoke("copilot_list_accounts"); +} + +/** + * 轮询 OAuth Token(多账号版本) + * + * 使用设备码轮询 GitHub,等待用户完成授权。 + * 授权成功后返回新添加的账号信息。 + * + * @param deviceCode - 设备码 + * @returns 新添加的账号信息,如果仍在等待则返回 null + */ +export async function copilotPollForAccount( + deviceCode: string, +): Promise { + return invoke("copilot_poll_for_account", { + deviceCode, + }); +} + +/** + * 移除指定的 GitHub 账号 + * + * @param accountId - GitHub 用户 ID + */ +export async function copilotRemoveAccount(accountId: string): Promise { + return invoke("copilot_remove_account", { accountId }); +} + +/** + * 设置默认 GitHub 账号 + * + * @param accountId - GitHub 用户 ID + */ +export async function copilotSetDefaultAccount( + accountId: string, +): Promise { + return invoke("copilot_set_default_account", { accountId }); +} + +/** + * 获取指定账号的有效 Copilot Token + * + * 内部使用,用于代理请求。 + * + * @param accountId - GitHub 用户 ID + * @returns Copilot Token + */ +export async function copilotGetTokenForAccount( + accountId: string, +): Promise { + return invoke("copilot_get_token_for_account", { accountId }); +} + +/** + * 获取指定账号的 Copilot 可用模型列表 + * + * @param accountId - GitHub 用户 ID + * @returns 可用模型列表 + */ +export async function copilotGetModelsForAccount( + accountId: string, +): Promise { + return invoke("copilot_get_models_for_account", { + accountId, + }); +} + +/** + * 获取指定账号的 Copilot 使用量信息 + * + * @param accountId - GitHub 用户 ID + * @returns 使用量信息 + */ +export async function copilotGetUsageForAccount( + accountId: string, +): Promise { + return invoke("copilot_get_usage_for_account", { + accountId, + }); +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index bf00f161..ea971fba 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -12,5 +12,18 @@ export { openclawApi } from "./openclaw"; export { sessionsApi } from "./sessions"; export { workspaceApi } from "./workspace"; export * as configApi from "./config"; +export * as authApi from "./auth"; +export * as copilotApi from "./copilot"; export type { ProviderSwitchEvent } from "./providers"; export type { Prompt } from "./prompts"; +export type { + CopilotDeviceCodeResponse, + CopilotAuthStatus, + GitHubAccount, +} from "./copilot"; +export type { + ManagedAuthProvider, + ManagedAuthAccount, + ManagedAuthStatus, + ManagedAuthDeviceCodeResponse, +} from "./auth"; diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts index ab0c0d4c..7102e4ba 100644 --- a/src/lib/api/settings.ts +++ b/src/lib/api/settings.ts @@ -86,18 +86,6 @@ export const settingsApi = { return await invoke("clear_claude_onboarding_skip"); }, - async applyToolSearchPatch(): Promise< - Array<{ path: string; success: boolean; error?: string }> - > { - return await invoke("apply_toolsearch_patch"); - }, - - async restoreToolSearchPatch(): Promise< - Array<{ path: string; success: boolean; error?: string }> - > { - return await invoke("restore_toolsearch_patch"); - }, - async saveFileDialog(defaultName: string): Promise { return await invoke("save_file_dialog", { defaultName }); }, diff --git a/src/lib/api/usage.ts b/src/lib/api/usage.ts index 5b94269a..dbb38bc9 100644 --- a/src/lib/api/usage.ts +++ b/src/lib/api/usage.ts @@ -12,6 +12,7 @@ import type { } from "@/types/usage"; import type { UsageResult } from "@/types"; import type { AppId } from "./types"; +import type { TemplateType } from "@/config/constants"; export const usageApi = { // Provider usage script methods @@ -28,7 +29,7 @@ export const usageApi = { baseUrl?: string, accessToken?: string, userId?: string, - templateType?: "custom" | "general" | "newapi", + templateType?: TemplateType, ): Promise => { return invoke("testUsageScript", { providerId, diff --git a/src/lib/authBinding.ts b/src/lib/authBinding.ts new file mode 100644 index 00000000..675c67a0 --- /dev/null +++ b/src/lib/authBinding.ts @@ -0,0 +1,21 @@ +import type { ProviderMeta } from "@/types"; + +export function resolveManagedAccountId( + meta: ProviderMeta | undefined, + authProvider: string, +): string | null { + const binding = meta?.authBinding; + + if ( + binding?.source === "managed_account" && + binding.authProvider === authProvider + ) { + return binding.accountId ?? null; + } + + if (authProvider === "github_copilot") { + return meta?.githubAccountId ?? null; + } + + return null; +} diff --git a/src/types.ts b/src/types.ts index 9c645587..1a54c122 100644 --- a/src/types.ts +++ b/src/types.ts @@ -49,13 +49,15 @@ export interface EndpointCandidate { isCustom?: boolean; } +import type { TemplateType } from "./config/constants"; + // 用量查询脚本配置 export interface UsageScript { enabled: boolean; // 是否启用用量查询 language: "javascript"; // 脚本语言 code: string; // 脚本代码(JSON 格式配置) timeout?: number; // 超时时间(秒,默认 10) - templateType?: "custom" | "general" | "newapi"; // 模板类型(用于后端判断验证规则) + templateType?: TemplateType; // 模板类型(用于后端判断验证规则) apiKey?: string; // 用量查询专用的 API Key(通用模板使用) baseUrl?: string; // 用量查询专用的 Base URL(通用和 NewAPI 模板使用) accessToken?: string; // 访问令牌(NewAPI 模板使用) @@ -122,6 +124,14 @@ export interface ProviderProxyConfig { proxyPassword?: string; } +export type AuthBindingSource = "provider_config" | "managed_account"; + +export interface AuthBinding { + source: AuthBindingSource; + authProvider?: string; + accountId?: string; +} + // 供应商元数据(字段名与后端一致,保持 snake_case) export interface ProviderMeta { // 自定义端点:以 URL 为键,值为端点信息 @@ -149,10 +159,16 @@ export interface ProviderMeta { // - "openai_chat": OpenAI Chat Completions 格式,需要格式转换 // - "openai_responses": OpenAI Responses API 格式,需要格式转换 apiFormat?: "anthropic" | "openai_chat" | "openai_responses"; + // 通用认证绑定 + authBinding?: AuthBinding; // Claude 认证字段名 apiKeyField?: ClaudeApiKeyField; // Prompt cache key for OpenAI-compatible endpoints (improves cache hit rate) promptCacheKey?: string; + // 供应商类型(用于识别 Copilot 等特殊供应商) + providerType?: string; + // GitHub Copilot 关联账号 ID(旧字段,保留兼容读取) + githubAccountId?: string; } // Skill 同步方式 @@ -226,8 +242,6 @@ export interface Settings { enableClaudePluginIntegration?: boolean; // 跳过 Claude Code 初次安装确认(写入 ~/.claude.json 的 hasCompletedOnboarding) skipClaudeOnboarding?: boolean; - // 解除 Tool Search 域名限制 - toolSearchBypass?: boolean; // 是否开机自启 launchOnStartup?: boolean; // 静默启动(程序启动时不显示主窗口) diff --git a/src/utils/providerConfigUtils.ts b/src/utils/providerConfigUtils.ts index 84f5e941..20984a9f 100644 --- a/src/utils/providerConfigUtils.ts +++ b/src/utils/providerConfigUtils.ts @@ -837,3 +837,82 @@ export const setCodexModelName = ( lines.splice(topLevelEndIndex, 0, replacementLine); return finalizeTomlText(lines); }; + +// ========== Codex top-level integer field utils ========== + +const tomlTopLevelIntPattern = (field: string) => + new RegExp(`^\\s*${field}\\s*=\\s*(\\d+)\\s*(?:#.*)?$`); + +const findTopLevelIntMatch = ( + lines: string[], + fieldName: string, + topLevelEndIndex: number, +): { index: number; value: number } | undefined => { + const pattern = tomlTopLevelIntPattern(fieldName); + for (let i = 0; i < topLevelEndIndex; i += 1) { + const m = lines[i].match(pattern); + if (m) { + return { index: i, value: Number(m[1]) }; + } + } + return undefined; +}; + +// 从 Codex TOML 配置中提取顶级整数字段 +export const extractCodexTopLevelInt = ( + configText: string | undefined | null, + fieldName: string, +): number | undefined => { + try { + const raw = typeof configText === "string" ? configText : ""; + const text = normalizeTomlText(raw); + if (!text) return undefined; + const lines = text.split("\n"); + return findTopLevelIntMatch(lines, fieldName, getTopLevelEndIndex(lines)) + ?.value; + } catch { + return undefined; + } +}; + +// 在 Codex TOML 配置中设置或更新顶级整数字段 +export const setCodexTopLevelInt = ( + configText: string, + fieldName: string, + value: number, +): string => { + const normalizedText = normalizeTomlText(configText); + const lines = normalizedText ? normalizedText.split("\n") : []; + const topLevelEndIndex = getTopLevelEndIndex(lines); + const existing = findTopLevelIntMatch(lines, fieldName, topLevelEndIndex); + const replacementLine = `${fieldName} = ${value}`; + + if (existing) { + lines[existing.index] = replacementLine; + return finalizeTomlText(lines); + } + + // 插入位置:顶级区域末尾(section header 之前) + if (lines.length === 0) { + return `${replacementLine}\n`; + } + + lines.splice(topLevelEndIndex, 0, replacementLine); + return finalizeTomlText(lines); +}; + +// 从 Codex TOML 配置中移除顶级字段行 +export const removeCodexTopLevelField = ( + configText: string, + fieldName: string, +): string => { + const normalizedText = normalizeTomlText(configText); + if (!normalizedText) return normalizedText; + const lines = normalizedText.split("\n"); + const topLevelEndIndex = getTopLevelEndIndex(lines); + const existing = findTopLevelIntMatch(lines, fieldName, topLevelEndIndex); + if (existing) { + lines.splice(existing.index, 1); + } + return finalizeTomlText(lines); +};