feat(copilot): add GitHub Copilot reverse proxy support (#930)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <farion1231@gmail.com>
Co-authored-by: 周梦泽 <mengze.zhou@dafeng-tech.com>
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Zhou Mengze
2026-03-17 23:57:58 +08:00
committed by GitHub
parent 36bbdc36f5
commit 8ccfbd36d6
50 changed files with 4555 additions and 1062 deletions
+4
View File
@@ -25,3 +25,7 @@ flatpak-build/
flatpak-repo/
.worktrees/
.spec-workflow/
copilot-api
.history
CODEBUDDY.md
.github
+182
View File
@@ -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<String>,
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<String>,
pub migration_error: Option<String>,
pub accounts: Vec<ManagedAuthAccount>,
}
#[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<ManagedAuthDeviceCodeResponse, String> {
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<Option<ManagedAuthAccount>, 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<Vec<ManagedAuthAccount>, 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<ManagedAuthStatus, 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(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())
}
+212
View File
@@ -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<RwLock<CopilotAuthManager>>);
// ==================== 设备码流程 ====================
/// 启动设备码流程
///
/// 返回设备码和用户码,用于 OAuth 认证
#[tauri::command]
pub async fn copilot_start_device_flow(
state: State<'_, CopilotAuthState>,
) -> Result<GitHubDeviceCodeResponse, String> {
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<bool, String> {
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<Option<GitHubAccount>, 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<Vec<GitHubAccount>, 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<CopilotAuthStatus, String> {
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<bool, String> {
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<String, String> {
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<String, String> {
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<Vec<CopilotModel>, 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<Vec<CopilotModel>, 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<CopilotUsageResponse, String> {
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<CopilotUsageResponse, String> {
let auth_manager = state.0.read().await;
auth_manager
.fetch_usage_for_account(&account_id)
.await
.map_err(|e| e.to_string())
}
+6 -2
View File
@@ -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::*;
+61
View File
@@ -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<crate::provider::UsageResult, String> {
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())
+52 -2
View File
@@ -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<StreamCheckResult, AppError> {
@@ -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<Vec<(String, StreamCheckResult)>, AppError> {
@@ -67,7 +72,9 @@ pub async fn stream_check_all_providers(
}
}
let result = StreamCheckService::check_with_retry(&app_type, &provider, &config)
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,
@@ -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<Option<crate::proxy::providers::AuthInfo>, 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,
)))
}
-21
View File
@@ -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::ToolSearchStatus, String>
{
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<Vec<crate::toolsearch_patch::PatchResult>, 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<Vec<crate::toolsearch_patch::PatchResult>, String>
{
crate::toolsearch_patch::restore_toolsearch_patch().map_err(|e| e.to_string())
}
+53 -4
View File
@@ -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::<serde_json::Value>(&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(())
}
+38 -25
View File
@@ -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::<AppState>().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,
+60
View File
@@ -191,6 +191,31 @@ pub struct ProviderProxyConfig {
pub proxy_password: Option<String>,
}
/// 认证绑定来源
#[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<String>,
/// 托管账号 ID;为空表示跟随该认证供应商的默认账号
#[serde(rename = "accountId", skip_serializing_if = "Option::is_none")]
pub account_id: Option<String>,
}
/// 供应商元数据
#[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<String>,
/// 通用认证绑定(provider_config / managed_account
///
/// 新代码应只写入该字段;githubAccountId 仅保留兼容读取。
#[serde(rename = "authBinding", skip_serializing_if = "Option::is_none")]
pub auth_binding: Option<AuthBinding>,
/// Claude 认证字段名("ANTHROPIC_AUTH_TOKEN" 或 "ANTHROPIC_API_KEY"
#[serde(rename = "apiKeyField", skip_serializing_if = "Option::is_none")]
pub api_key_field: Option<String>,
/// 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<String>,
/// 供应商类型标识(用于特殊供应商检测)
/// - "github_copilot": GitHub Copilot 供应商
#[serde(rename = "providerType", skip_serializing_if = "Option::is_none")]
pub provider_type: Option<String>,
/// GitHub Copilot 关联账号 ID(仅 github_copilot 供应商使用)
/// 用于多账号支持,关联到特定的 GitHub 账号
#[serde(rename = "githubAccountId", skip_serializing_if = "Option::is_none")]
pub github_account_id: Option<String>,
}
impl ProviderMeta {
/// 解析指定托管认证供应商绑定的账号 ID。
///
/// 新版优先读取 authBinding,旧版继续兼容 githubAccountId。
pub fn managed_account_id_for(&self, auth_provider: &str) -> Option<String> {
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 {
+68 -2
View File
@@ -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,8 +795,20 @@ 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" {
if is_copilot {
// GitHub Copilot uses /chat/completions without /v1 prefix
"/chat/completions"
} else {
// 根据 api_format 选择目标端点
let api_format = super::providers::get_claude_api_format(provider);
if api_format == "openai_responses" {
@@ -801,6 +816,7 @@ impl RequestForwarder {
} 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::<CopilotAuthState>();
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);
}
+8
View File
@@ -112,6 +112,13 @@ pub enum AuthStrategy {
///
/// 用于 Gemini CLI 等需要 OAuth 的场景
GoogleOAuth,
/// GitHub Copilot 认证方式
///
/// - Header: `Authorization: Bearer <copilot_token>`
///
/// 使用动态获取的 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() {
+116
View File
@@ -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<AuthInfo> {
let provider_type = self.provider_type(provider);
// GitHub Copilot 使用特殊的认证策略
// 实际的 token 会在代理请求时动态获取
if provider_type == ProviderType::GitHubCopilot {
// 返回一个占位符,实际 token 由 CopilotAuthManager 动态提供
return Some(AuthInfo::new(
"copilot_placeholder".to_string(),
AuthStrategy::GitHubCopilot,
));
}
let strategy = match provider_type {
ProviderType::OpenRouter => AuthStrategy::Bearer,
ProviderType::ClaudeAuth => AuthStrategy::ClaudeAuth,
@@ -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));
}
}
File diff suppressed because it is too large Load Diff
+47 -4
View File
@@ -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<dyn ProviderAdapter> {
#[allow(dead_code)]
pub fn get_adapter_for_provider_type(provider_type: &ProviderType) -> Box<dyn ProviderAdapter> {
match provider_type {
ProviderType::Claude | ProviderType::ClaudeAuth | ProviderType::OpenRouter => {
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::<ProviderType>().unwrap(),
ProviderType::OpenRouter
);
assert_eq!(
"github_copilot".parse::<ProviderType>().unwrap(),
ProviderType::GitHubCopilot
);
assert_eq!(
"github-copilot".parse::<ProviderType>().unwrap(),
ProviderType::GitHubCopilot
);
assert_eq!(
"githubcopilot".parse::<ProviderType>().unwrap(),
ProviderType::GitHubCopilot
);
assert!("invalid".parse::<ProviderType>().is_err());
}
@@ -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");
+95 -10
View File
@@ -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<AuthInfo>,
) -> Result<StreamCheckResult, AppError> {
// 合并供应商单独配置和全局配置
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<AuthInfo>,
) -> Result<StreamCheckResult, AppError> {
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");
}
}
-4
View File
@@ -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,
-569
View File
@@ -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<String>,
}
/// Overall Tool Search patch status
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolSearchStatus {
pub installations: Vec<ClaudeInstallation>,
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<Vec<u8>, 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<u8>, usize), AppError> {
let re = Regex::new(PATCH_TARGET_PATTERN).unwrap();
let mut count = 0usize;
let result = re.replace_all(data, |caps: &regex::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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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<PathBuf>,
seen: &mut std::collections::HashSet<PathBuf>,
path: PathBuf,
) {
if path.is_dir() && seen.insert(path.clone()) {
candidates.push(path);
}
}
fn resolve_active_patch_target(command_path: &Path) -> Option<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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<PathBuf, AppError> {
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<PathBuf, AppError> {
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<PathBuf, AppError> {
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<ToolSearchStatus, AppError> {
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::<Vec<_>>();
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<Vec<PatchResult>, 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<Vec<PatchResult>, 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());
}
}
+1 -1
View File
@@ -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": []
+104 -29
View File
@@ -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<string, string> => ({
[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<string, string> = {
[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<UsageScriptModalProps> = ({
@@ -167,7 +167,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
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<UsageScriptModalProps> = ({
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(
() => {
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<UsageScriptModalProps> = ({
// 向后兼容:根据字段推断模板类型
// 检测 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,6 +277,8 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
};
const handleSave = () => {
// Copilot 模板不需要脚本验证
if (selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT) {
if (script.enabled && !script.code.trim()) {
toast.error(t("usageScript.scriptEmpty"));
return;
@@ -281,6 +287,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
toast.error(t("usageScript.mustHaveReturn"), { duration: 5000 });
return;
}
}
// 保存时记录当前选择的模板类型
const scriptWithTemplate = {
...script,
@@ -288,6 +295,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
| "custom"
| "general"
| "newapi"
| "github_copilot"
| undefined,
};
onSave(scriptWithTemplate);
@@ -297,6 +305,38 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
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<UsageScriptModalProps> = ({
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<UsageScriptModalProps> = ({
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,7 +524,17 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
{t("usageScript.presetTemplate")}
</Label>
<div className="flex gap-2 flex-wrap">
{Object.keys(PRESET_TEMPLATES).map((name) => {
{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 (
<Button
@@ -497,7 +557,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
</div>
{/* 自定义模式:变量提示和具体值 */}
{selectedTemplate === TEMPLATE_KEYS.CUSTOM && (
{selectedTemplate === TEMPLATE_TYPES.CUSTOM && (
<div className="space-y-2 border-t border-white/10 pt-3">
<h4 className="text-sm font-medium text-foreground">
{t("usageScript.supportedVariables")}
@@ -564,6 +624,15 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
</div>
)}
{/* Copilot 模式:自动认证提示 */}
{selectedTemplate === TEMPLATE_TYPES.GITHUB_COPILOT && (
<div className="space-y-2 border-t border-white/10 pt-3">
<p className="text-sm text-muted-foreground">
{t("usageScript.copilotAutoAuth")}
</p>
</div>
)}
{/* 凭证配置 */}
{shouldShowCredentialsConfig && (
<div className="space-y-4">
@@ -577,7 +646,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
</div>
<div className="grid gap-4 md:grid-cols-2">
{selectedTemplate === TEMPLATE_KEYS.GENERAL && (
{selectedTemplate === TEMPLATE_TYPES.GENERAL && (
<>
<div className="space-y-2">
<Label htmlFor="usage-api-key">
@@ -641,7 +710,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
</>
)}
{selectedTemplate === TEMPLATE_KEYS.NEW_API && (
{selectedTemplate === TEMPLATE_TYPES.NEW_API && (
<>
<div className="space-y-2">
<Label htmlFor="usage-newapi-base-url">
@@ -789,7 +858,8 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
</div>
</div>
{/* 提取器代码 */}
{/* 提取器代码 - Copilot 模板不需要 */}
{selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT && (
<div className="space-y-4 glass rounded-xl border border-white/10 p-6">
<div className="flex items-center justify-between">
<Label className="text-base font-medium">
@@ -808,10 +878,14 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
showMinimap={false}
/>
</div>
)}
{/* 帮助信息 */}
{/* 帮助信息 - Copilot 模板不需要 */}
{selectedTemplate !== TEMPLATE_TYPES.GITHUB_COPILOT && (
<div className="glass rounded-xl border border-white/10 p-6 text-sm text-foreground/90">
<h4 className="font-medium mb-2">{t("usageScript.scriptHelp")}</h4>
<h4 className="font-medium mb-2">
{t("usageScript.scriptHelp")}
</h4>
<div className="space-y-3 text-xs">
<div>
<strong>{t("usageScript.configFormat")}</strong>
@@ -865,6 +939,7 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
</div>
</div>
</div>
)}
</div>
)}
+23 -5
View File
@@ -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<UniversalProviderPreset | null>(null);
@@ -255,6 +256,14 @@ export function AddProviderDialog({
{t("common.add")}
</Button>
</>
) : activeTab === "oauth" ? (
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="border-border/20 hover:bg-accent hover:text-accent-foreground"
>
{t("common.close", { defaultValue: "关闭" })}
</Button>
) : (
<>
<Button
@@ -284,15 +293,20 @@ export function AddProviderDialog({
{showUniversalTab ? (
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "app-specific" | "universal")}
onValueChange={(v) =>
setActiveTab(v as "app-specific" | "universal" | "oauth")
}
>
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsList className="grid w-full grid-cols-3 mb-6">
<TabsTrigger value="app-specific">
{t(`apps.${appId}`)} {t("provider.tabProvider")}
</TabsTrigger>
<TabsTrigger value="universal">
{t("provider.tabUniversal")}
</TabsTrigger>
<TabsTrigger value="oauth">
{t("provider.tabOAuth", { defaultValue: "OAuth 认证源" })}
</TabsTrigger>
</TabsList>
<TabsContent value="app-specific" className="mt-0">
@@ -309,6 +323,10 @@ export function AddProviderDialog({
<TabsContent value="universal" className="mt-0">
<UniversalProviderPanel />
</TabsContent>
<TabsContent value="oauth" className="mt-0">
<AuthCenterPanel />
</TabsContent>
</Tabs>
) : (
// OpenCode/OpenClaw: directly show form without tabs
@@ -1,4 +1,12 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { toast } from "sonner";
import { FormLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
@@ -8,8 +16,23 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ChevronDown, ChevronRight, Loader2 } from "lucide-react";
import EndpointSpeedTest from "./EndpointSpeedTest";
import { ApiKeySection, EndpointField } from "./shared";
import { CopilotAuthSection } from "./CopilotAuthSection";
import {
copilotGetModels,
copilotGetModelsForAccount,
} from "@/lib/api/copilot";
import type { CopilotModel } from "@/lib/api/copilot";
import type {
ProviderCategory,
ClaudeApiFormat,
@@ -33,6 +56,15 @@ interface ClaudeFormFieldsProps {
isPartner?: boolean;
partnerPromotionKey?: string;
// GitHub Copilot OAuth
isCopilotPreset?: boolean;
usesOAuth?: boolean;
isCopilotAuthenticated?: boolean;
/** 当前选中的 GitHub 账号 ID(多账号支持) */
selectedGitHubAccountId?: string | null;
/** GitHub 账号选择回调(多账号支持) */
onGitHubAccountSelect?: (accountId: string | null) => void;
// Template Values
templateValueEntries: Array<[string, TemplateValueConfig]>;
templateValues: Record<string, TemplateValueConfig>;
@@ -88,6 +120,11 @@ export function ClaudeFormFields({
websiteUrl,
isPartner,
partnerPromotionKey,
isCopilotPreset,
usesOAuth,
isCopilotAuthenticated,
selectedGitHubAccountId,
onGitHubAccountSelect,
templateValueEntries,
templateValues,
templatePresetName,
@@ -114,11 +151,172 @@ export function ClaudeFormFields({
onApiKeyFieldChange,
}: ClaudeFormFieldsProps) {
const { t } = useTranslation();
const hasAnyAdvancedValue = !!(
claudeModel ||
reasoningModel ||
defaultHaikuModel ||
defaultSonnetModel ||
defaultOpusModel ||
apiFormat !== "anthropic" ||
apiKeyField !== "ANTHROPIC_AUTH_TOKEN"
);
const [advancedExpanded, setAdvancedExpanded] =
useState(hasAnyAdvancedValue);
// 预设填充高级值后自动展开(仅从折叠→展开,不会自动折叠)
useEffect(() => {
if (hasAnyAdvancedValue) {
setAdvancedExpanded(true);
}
}, [hasAnyAdvancedValue]);
// Copilot 可用模型列表
const [copilotModels, setCopilotModels] = useState<CopilotModel[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
// 当 Copilot 预设且已认证时,加载可用模型
useEffect(() => {
// 如果不是 Copilot 预设或未认证,清空模型列表
if (!isCopilotPreset || !isCopilotAuthenticated) {
setCopilotModels([]);
setModelsLoading(false);
return;
}
let cancelled = false;
setModelsLoading(true);
const fetchModels = selectedGitHubAccountId
? copilotGetModelsForAccount(selectedGitHubAccountId)
: copilotGetModels();
fetchModels
.then((models) => {
if (!cancelled) setCopilotModels(models);
})
.catch((err) => {
console.warn("[Copilot] Failed to fetch models:", err);
if (!cancelled) {
toast.error(
t("copilot.loadModelsFailed", {
defaultValue: "加载 Copilot 模型列表失败",
}),
);
}
})
.finally(() => {
if (!cancelled) setModelsLoading(false);
});
return () => {
cancelled = true;
};
}, [isCopilotPreset, isCopilotAuthenticated, selectedGitHubAccountId]);
// 模型输入框:支持手动输入 + 下拉选择
const renderModelInput = (
id: string,
value: string,
field: ClaudeFormFieldsProps["onModelChange"] extends (
f: infer F,
v: string,
) => void
? F
: never,
placeholder?: string,
) => {
if (isCopilotPreset && copilotModels.length > 0) {
// 按 vendor 分组
const grouped: Record<string, CopilotModel[]> = {};
for (const model of copilotModels) {
const vendor = model.vendor || "Other";
if (!grouped[vendor]) grouped[vendor] = [];
grouped[vendor].push(model);
}
const vendors = Object.keys(grouped).sort();
return (
<div className="flex gap-1">
<Input
id={id}
type="text"
value={value}
onChange={(e) => onModelChange(field, e.target.value)}
placeholder={placeholder}
autoComplete="off"
className="flex-1"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="shrink-0">
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="max-h-64 overflow-y-auto z-[200]"
>
{vendors.map((vendor, vi) => (
<div key={vendor}>
{vi > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel>{vendor}</DropdownMenuLabel>
{grouped[vendor].map((model) => (
<DropdownMenuItem
key={model.id}
onSelect={() => onModelChange(field, model.id)}
>
{model.id}
</DropdownMenuItem>
))}
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
if (isCopilotPreset && modelsLoading) {
return (
<div className="flex gap-1">
<Input
id={id}
type="text"
value={value}
onChange={(e) => onModelChange(field, e.target.value)}
placeholder={placeholder}
autoComplete="off"
className="flex-1"
/>
<Button variant="outline" size="icon" className="shrink-0" disabled>
<Loader2 className="h-4 w-4 animate-spin" />
</Button>
</div>
);
}
return (
<Input
id={id}
type="text"
value={value}
onChange={(e) => onModelChange(field, e.target.value)}
placeholder={placeholder}
autoComplete="off"
/>
);
};
return (
<>
{/* API Key 输入框 */}
{shouldShowApiKey && (
{/* GitHub Copilot OAuth 认证 */}
{isCopilotPreset && (
<CopilotAuthSection
selectedAccountId={selectedGitHubAccountId}
onAccountSelect={onGitHubAccountSelect}
/>
)}
{/* API Key 输入框(非 OAuth 预设时显示) */}
{shouldShowApiKey && !usesOAuth && (
<ApiKeySection
value={apiKey}
onChange={onApiKeyChange}
@@ -200,8 +398,35 @@ export function ClaudeFormFields({
/>
)}
{/* API 格式选择(仅非官方、非云服务商显示 */}
{shouldShowModelSelector && category !== "cloud_provider" && (
{/* 高级选项(API 格式 + 认证字段 + 模型映射 */}
{shouldShowModelSelector && (
<Collapsible
open={advancedExpanded}
onOpenChange={setAdvancedExpanded}
>
<CollapsibleTrigger asChild>
<Button
type="button"
variant={null}
size="sm"
className="h-8 gap-1.5 px-0 text-sm font-medium text-foreground hover:opacity-70"
>
{advancedExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
{t("providerForm.advancedOptionsToggle")}
</Button>
</CollapsibleTrigger>
{!advancedExpanded && (
<p className="text-xs text-muted-foreground mt-1 ml-1">
{t("providerForm.advancedOptionsHint")}
</p>
)}
<CollapsibleContent className="space-y-4 pt-2">
{/* API 格式选择(仅非云服务商显示) */}
{category !== "cloud_provider" && (
<div className="space-y-2">
<FormLabel htmlFor="apiFormat">
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
@@ -237,14 +462,15 @@ export function ClaudeFormFields({
)}
{/* 认证字段选择器 */}
{shouldShowModelSelector && (
<div className="space-y-2">
<FormLabel>
{t("providerForm.authField", { defaultValue: "认证字段" })}
</FormLabel>
<Select
value={apiKeyField}
onValueChange={(v) => onApiKeyFieldChange(v as ClaudeApiKeyField)}
onValueChange={(v) =>
onApiKeyFieldChange(v as ClaudeApiKeyField)
}
>
<SelectTrigger>
<SelectValue />
@@ -268,29 +494,28 @@ export function ClaudeFormFields({
})}
</p>
</div>
)}
{/* 模型选择器 */}
{shouldShowModelSelector && (
<div className="space-y-3">
{/* 模型映射 */}
<div className="space-y-1 pt-2 border-t">
<FormLabel>{t("providerForm.modelMappingLabel")}</FormLabel>
<p className="text-xs text-muted-foreground">
{t("providerForm.modelMappingHint")}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 主模型 */}
<div className="space-y-2">
<FormLabel htmlFor="claudeModel">
{t("providerForm.anthropicModel", { defaultValue: "主模型" })}
</FormLabel>
<Input
id="claudeModel"
type="text"
value={claudeModel}
onChange={(e) =>
onModelChange("ANTHROPIC_MODEL", e.target.value)
}
placeholder={t("providerForm.modelPlaceholder", {
defaultValue: "",
{t("providerForm.anthropicModel", {
defaultValue: "主模型",
})}
autoComplete="off"
/>
</FormLabel>
{renderModelInput(
"claudeModel",
claudeModel,
"ANTHROPIC_MODEL",
t("providerForm.modelPlaceholder", { defaultValue: "" }),
)}
</div>
{/* 推理模型 */}
@@ -298,15 +523,11 @@ export function ClaudeFormFields({
<FormLabel htmlFor="reasoningModel">
{t("providerForm.anthropicReasoningModel")}
</FormLabel>
<Input
id="reasoningModel"
type="text"
value={reasoningModel}
onChange={(e) =>
onModelChange("ANTHROPIC_REASONING_MODEL", e.target.value)
}
autoComplete="off"
/>
{renderModelInput(
"reasoningModel",
reasoningModel,
"ANTHROPIC_REASONING_MODEL",
)}
</div>
{/* 默认 Haiku */}
@@ -316,18 +537,12 @@ export function ClaudeFormFields({
defaultValue: "Haiku 默认模型",
})}
</FormLabel>
<Input
id="claudeDefaultHaikuModel"
type="text"
value={defaultHaikuModel}
onChange={(e) =>
onModelChange("ANTHROPIC_DEFAULT_HAIKU_MODEL", e.target.value)
}
placeholder={t("providerForm.haikuModelPlaceholder", {
defaultValue: "",
})}
autoComplete="off"
/>
{renderModelInput(
"claudeDefaultHaikuModel",
defaultHaikuModel,
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
t("providerForm.haikuModelPlaceholder", { defaultValue: "" }),
)}
</div>
{/* 默认 Sonnet */}
@@ -337,21 +552,12 @@ export function ClaudeFormFields({
defaultValue: "Sonnet 默认模型",
})}
</FormLabel>
<Input
id="claudeDefaultSonnetModel"
type="text"
value={defaultSonnetModel}
onChange={(e) =>
onModelChange(
{renderModelInput(
"claudeDefaultSonnetModel",
defaultSonnetModel,
"ANTHROPIC_DEFAULT_SONNET_MODEL",
e.target.value,
)
}
placeholder={t("providerForm.modelPlaceholder", {
defaultValue: "",
})}
autoComplete="off"
/>
t("providerForm.modelPlaceholder", { defaultValue: "" }),
)}
</div>
{/* 默认 Opus */}
@@ -361,27 +567,16 @@ export function ClaudeFormFields({
defaultValue: "Opus 默认模型",
})}
</FormLabel>
<Input
id="claudeDefaultOpusModel"
type="text"
value={defaultOpusModel}
onChange={(e) =>
onModelChange("ANTHROPIC_DEFAULT_OPUS_MODEL", e.target.value)
}
placeholder={t("providerForm.modelPlaceholder", {
defaultValue: "",
})}
autoComplete="off"
/>
{renderModelInput(
"claudeDefaultOpusModel",
defaultOpusModel,
"ANTHROPIC_DEFAULT_OPUS_MODEL",
t("providerForm.modelPlaceholder", { defaultValue: "" }),
)}
</div>
</div>
<p className="text-xs text-muted-foreground">
{t("providerForm.modelHelper", {
defaultValue:
"可选:指定默认使用的 Claude 模型,留空则使用系统默认。",
})}
</p>
</div>
</CollapsibleContent>
</Collapsible>
)}
</>
);
@@ -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<CodexConfigSectionProps> = ({
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<ReturnType<typeof setTimeout>>();
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 (
<div className="space-y-2">
<div className="flex items-center justify-between">
@@ -152,9 +246,34 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
</p>
)}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={toggleStates.contextWindow1M}
onChange={(e) => handleContextWindowToggle(e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
<span>{t("codexConfig.contextWindow1M")}</span>
</label>
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground">
<span>{t("codexConfig.autoCompactLimit")}:</span>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
key={toggleStates.compactLimit}
defaultValue={toggleStates.compactLimit}
disabled={!toggleStates.contextWindow1M}
onChange={(e) => handleCompactLimitChange(e.target.value)}
className="w-28 h-7 px-2 text-sm rounded border border-border bg-background text-foreground disabled:opacity-50 disabled:cursor-not-allowed"
/>
</label>
</div>
<JsonEditor
value={value}
onChange={onChange}
value={localValue}
onChange={handleLocalChange}
placeholder=""
darkMode={isDarkMode}
rows={8}
@@ -75,16 +75,20 @@ export function CommonConfigEditor({
return {
hideAttribution:
config?.attribution?.commit === "" && config?.attribution?.pr === "",
alwaysThinking: config?.alwaysThinkingEnabled === true,
teammates:
config?.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === "1" ||
config?.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === 1,
enableToolSearch:
config?.env?.ENABLE_TOOL_SEARCH === "true" ||
config?.env?.ENABLE_TOOL_SEARCH === "1",
effortHigh: config?.effortLevel === "high",
};
} catch {
return {
hideAttribution: false,
alwaysThinking: false,
teammates: false,
enableToolSearch: false,
effortHigh: false,
};
}
}, [localValue]);
@@ -102,13 +106,6 @@ export function CommonConfigEditor({
delete config.attribution;
}
break;
case "alwaysThinking":
if (checked) {
config.alwaysThinkingEnabled = true;
} else {
delete config.alwaysThinkingEnabled;
}
break;
case "teammates":
if (!config.env) config.env = {};
if (checked) {
@@ -118,6 +115,22 @@ export function CommonConfigEditor({
if (Object.keys(config.env).length === 0) delete config.env;
}
break;
case "enableToolSearch":
if (!config.env) config.env = {};
if (checked) {
config.env.ENABLE_TOOL_SEARCH = "true";
} else {
delete config.env.ENABLE_TOOL_SEARCH;
if (Object.keys(config.env).length === 0) delete config.env;
}
break;
case "effortHigh":
if (checked) {
config.effortLevel = "high";
} else {
delete config.effortLevel;
}
break;
}
handleLocalChange(JSON.stringify(config, null, 2));
@@ -178,15 +191,6 @@ export function CommonConfigEditor({
/>
<span>{t("claudeConfig.hideAttribution")}</span>
</label>
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={toggleStates.alwaysThinking}
onChange={(e) => handleToggle("alwaysThinking", e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
<span>{t("claudeConfig.alwaysThinking")}</span>
</label>
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
@@ -196,6 +200,26 @@ export function CommonConfigEditor({
/>
<span>{t("claudeConfig.enableTeammates")}</span>
</label>
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={toggleStates.enableToolSearch}
onChange={(e) =>
handleToggle("enableToolSearch", e.target.checked)
}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
<span>{t("claudeConfig.enableToolSearch")}</span>
</label>
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={toggleStates.effortHigh}
onChange={(e) => handleToggle("effortHigh", e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
<span>{t("claudeConfig.effortHigh")}</span>
</label>
</div>
<JsonEditor
value={localValue}
@@ -0,0 +1,367 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Loader2,
Github,
LogOut,
Copy,
Check,
ExternalLink,
Plus,
X,
User,
} from "lucide-react";
import { useCopilotAuth } from "./hooks/useCopilotAuth";
import type { GitHubAccount } from "@/lib/api";
interface CopilotAuthSectionProps {
className?: string;
/** 当前选中的 GitHub 账号 ID */
selectedAccountId?: string | null;
/** 账号选择回调 */
onAccountSelect?: (accountId: string | null) => void;
}
/**
* Copilot OAuth 认证区块
*
* 显示 GitHub Copilot 的认证状态,支持多账号管理和选择。
*/
export const CopilotAuthSection: React.FC<CopilotAuthSectionProps> = ({
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 <CopilotAccountAvatar account={account} />;
};
return (
<div className={`space-y-4 ${className || ""}`}>
{/* 认证状态标题 */}
<div className="flex items-center justify-between">
<Label>{t("copilot.authStatus", "GitHub Copilot 认证")}</Label>
<Badge
variant={hasAnyAccount ? "default" : "secondary"}
className={hasAnyAccount ? "bg-green-500 hover:bg-green-600" : ""}
>
{hasAnyAccount
? t("copilot.accountCount", {
count: accounts.length,
defaultValue: `${accounts.length} 个账号`,
})
: t("copilot.notAuthenticated", "未认证")}
</Badge>
</div>
{migrationError && (
<p className="text-sm text-amber-600 dark:text-amber-400">
{t("copilot.migrationFailed", {
error: migrationError,
defaultValue: `旧认证数据迁移失败:${migrationError}`,
})}
</p>
)}
{/* 账号选择器(有账号时显示) */}
{hasAnyAccount && onAccountSelect && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("copilot.selectAccount", "选择账号")}
</Label>
<Select
value={selectedAccountId || "none"}
onValueChange={handleAccountSelect}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"copilot.selectAccountPlaceholder",
"选择一个 GitHub 账号",
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
<span className="text-muted-foreground">
{t("copilot.useDefaultAccount", "使用默认账号")}
</span>
</SelectItem>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
<div className="flex items-center gap-2">
{renderAvatar(account)}
<span>{account.login}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 已登录账号列表 */}
{hasAnyAccount && (
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("copilot.loggedInAccounts", "已登录账号")}
</Label>
<div className="space-y-1">
{accounts.map((account) => (
<div
key={account.id}
className="flex items-center justify-between p-2 rounded-md border bg-muted/30"
>
<div className="flex items-center gap-2">
{renderAvatar(account)}
<span className="text-sm font-medium">{account.login}</span>
{defaultAccountId === account.id && (
<Badge variant="secondary" className="text-xs">
{t("copilot.defaultAccount", "默认")}
</Badge>
)}
{selectedAccountId === account.id && (
<Badge variant="outline" className="text-xs">
{t("copilot.selected", "已选中")}
</Badge>
)}
</div>
<div className="flex items-center gap-1">
{defaultAccountId !== account.id && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground"
onClick={() => setDefaultAccount(account.id)}
disabled={isSettingDefaultAccount}
>
{t("copilot.setAsDefault", "设为默认")}
</Button>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-red-500"
onClick={(e) => handleRemoveAccount(account.id, e)}
disabled={isRemovingAccount}
title={t("copilot.removeAccount", "移除账号")}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</div>
)}
{/* 未认证状态 - 登录按钮 */}
{!hasAnyAccount && pollingState === "idle" && (
<Button
type="button"
onClick={addAccount}
className="w-full"
variant="outline"
>
<Github className="mr-2 h-4 w-4" />
{t("copilot.loginWithGitHub", "使用 GitHub 登录")}
</Button>
)}
{/* 已有账号 - 添加更多账号按钮 */}
{hasAnyAccount && pollingState === "idle" && (
<Button
type="button"
onClick={addAccount}
className="w-full"
variant="outline"
disabled={isAddingAccount}
>
<Plus className="mr-2 h-4 w-4" />
{t("copilot.addAnotherAccount", "添加其他账号")}
</Button>
)}
{/* 轮询中状态 */}
{isPolling && deviceCode && (
<div className="space-y-3 p-4 rounded-lg border border-border bg-muted/50">
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("copilot.waitingForAuth", "等待授权中...")}
</div>
{/* 用户码 */}
<div className="text-center">
<p className="text-xs text-muted-foreground mb-1">
{t("copilot.enterCode", "在浏览器中输入以下代码:")}
</p>
<div className="flex items-center justify-center gap-2">
<code className="text-2xl font-mono font-bold tracking-wider bg-background px-4 py-2 rounded border">
{deviceCode.user_code}
</code>
<Button
type="button"
size="icon"
variant="ghost"
onClick={copyUserCode}
title={t("copilot.copyCode", "复制代码")}
>
{copied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
{/* 验证链接 */}
<div className="text-center">
<a
href={deviceCode.verification_uri}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-blue-500 hover:underline"
>
{deviceCode.verification_uri}
<ExternalLink className="h-3 w-3" />
</a>
</div>
{/* 取消按钮 */}
<div className="text-center">
<Button
type="button"
variant="ghost"
size="sm"
onClick={cancelAuth}
>
{t("common.cancel", "取消")}
</Button>
</div>
</div>
)}
{/* 错误状态 */}
{pollingState === "error" && error && (
<div className="space-y-2">
<p className="text-sm text-red-500">{error}</p>
<div className="flex gap-2">
<Button
type="button"
onClick={addAccount}
variant="outline"
size="sm"
>
{t("copilot.retry", "重试")}
</Button>
<Button
type="button"
onClick={cancelAuth}
variant="ghost"
size="sm"
>
{t("common.cancel", "取消")}
</Button>
</div>
</div>
)}
{/* 注销所有账号按钮 */}
{hasAnyAccount && accounts.length > 1 && (
<Button
type="button"
variant="outline"
onClick={logout}
className="w-full text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950"
>
<LogOut className="mr-2 h-4 w-4" />
{t("copilot.logoutAll", "注销所有账号")}
</Button>
)}
</div>
);
};
const CopilotAccountAvatar: React.FC<{ account: GitHubAccount }> = ({
account,
}) => {
const [failed, setFailed] = React.useState(false);
if (!account.avatar_url || failed) {
return <User className="h-5 w-5 text-muted-foreground" />;
}
return (
<img
src={account.avatar_url}
alt={account.login}
className="h-5 w-5 rounded-full"
loading="lazy"
referrerPolicy="no-referrer"
onError={() => setFailed(true)}
/>
);
};
export default CopilotAuthSection;
@@ -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({
<Button variant="outline" type="button" onClick={onCancel}>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={isSubmitting}>{submitLabel}</Button>
<Button type="submit" disabled={isSubmitting}>
{submitLabel}
</Button>
</div>
)}
</form>
@@ -28,6 +28,7 @@ export const OPENCODE_DEFAULT_CONFIG = JSON.stringify(
options: {
baseURL: "",
apiKey: "",
setCacheKey: true,
},
models: {},
},
@@ -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";
@@ -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,
};
}
@@ -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<PollingState>("idle");
const [deviceCode, setDeviceCode] =
useState<ManagedAuthDeviceCodeResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(
null,
);
const pollingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const {
data: authStatus,
isLoading: isLoadingStatus,
refetch: refetchStatus,
} = useQuery<ManagedAuthStatus>({
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,
};
}
@@ -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 (
<div className="space-y-6">
<section className="rounded-xl border border-border/60 bg-card/60 p-6">
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-primary" />
<h3 className="text-base font-semibold">
{t("settings.authCenter.title", {
defaultValue: "OAuth 认证中心",
})}
</h3>
</div>
<p className="text-sm text-muted-foreground">
{t("settings.authCenter.description", {
defaultValue:
"集中管理跨应用复用的 OAuth 账号。Provider 只绑定这些认证源,不再重复登录。",
})}
</p>
</div>
<Badge variant="secondary">
{t("settings.authCenter.beta", { defaultValue: "Beta" })}
</Badge>
</div>
</section>
<section className="rounded-xl border border-border/60 bg-card/60 p-6">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-muted">
<Github className="h-5 w-5" />
</div>
<div>
<h4 className="font-medium">GitHub Copilot</h4>
<p className="text-sm text-muted-foreground">
{t("settings.authCenter.copilotDescription", {
defaultValue:
"管理 GitHub Copilot 账号、默认账号以及供 Claude / Codex / Gemini 绑定的托管凭据。",
})}
</p>
</div>
</div>
<CopilotAuthSection />
</section>
</div>
);
}
+34 -1
View File
@@ -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"
>
<TabsList className="grid w-full grid-cols-5 mb-6 glass rounded-lg">
<TabsList className="grid w-full grid-cols-6 mb-6 glass rounded-lg">
<TabsTrigger value="general">
{t("settings.tabGeneral")}
</TabsTrigger>
<TabsTrigger value="proxy">{t("settings.tabProxy")}</TabsTrigger>
<TabsTrigger value="auth">
{t("settings.tabAuth", { defaultValue: "认证" })}
</TabsTrigger>
<TabsTrigger value="advanced">
{t("settings.tabAdvanced")}
</TabsTrigger>
@@ -249,6 +254,34 @@ export function SettingsPage({
) : null}
</TabsContent>
<TabsContent value="auth" className="space-y-6 mt-0 pb-4">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
<div className="flex items-center gap-3 px-1">
<KeyRound className="h-5 w-5 text-primary" />
<div>
<h2 className="text-base font-semibold">
{t("settings.authCenter.heading", {
defaultValue: "认证中心",
})}
</h2>
<p className="text-sm text-muted-foreground">
{t("settings.authCenter.headingDescription", {
defaultValue:
"统一管理可跨应用复用的 OAuth 账号和默认认证来源。",
})}
</p>
</div>
</div>
<AuthCenterPanel />
</motion.div>
</TabsContent>
<TabsContent value="advanced" className="space-y-6 mt-0 pb-4">
{settings ? (
<motion.div
+1 -9
View File
@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import type { SettingsFormState } from "@/hooks/useSettings";
import { AppWindow, MonitorUp, Power, EyeOff, Search } from "lucide-react";
import { AppWindow, MonitorUp, Power, EyeOff } from "lucide-react";
import { ToggleRow } from "@/components/ui/toggle-row";
import { AnimatePresence, motion } from "framer-motion";
@@ -66,14 +66,6 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) {
onCheckedChange={(value) => onChange({ skipClaudeOnboarding: value })}
/>
<ToggleRow
icon={<Search className="h-4 w-4 text-amber-500" />}
title={t("settings.toolSearchBypass")}
description={t("settings.toolSearchBypassDescription")}
checked={!!settings.toolSearchBypass}
onCheckedChange={(value) => onChange({ toolSearchBypass: value })}
/>
<ToggleRow
icon={<AppWindow className="h-4 w-4 text-blue-500" />}
title={t("settings.minimizeToTray")}
+26
View File
@@ -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",
+15
View File
@@ -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];
+33
View File
@@ -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: {},
},
+1 -39
View File
@@ -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 标签页的手动保存)
-3
View File
@@ -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),
+45 -7
View File
@@ -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",
+45 -7
View File
@@ -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": "オプション",
+45 -7
View File
@@ -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": "可选",
+101
View File
@@ -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<ManagedAuthDeviceCodeResponse> {
return invoke<ManagedAuthDeviceCodeResponse>("auth_start_login", {
authProvider,
});
}
export async function authPollForAccount(
authProvider: ManagedAuthProvider,
deviceCode: string,
): Promise<ManagedAuthAccount | null> {
return invoke<ManagedAuthAccount | null>("auth_poll_for_account", {
authProvider,
deviceCode,
});
}
export async function authListAccounts(
authProvider: ManagedAuthProvider,
): Promise<ManagedAuthAccount[]> {
return invoke<ManagedAuthAccount[]>("auth_list_accounts", {
authProvider,
});
}
export async function authGetStatus(
authProvider: ManagedAuthProvider,
): Promise<ManagedAuthStatus> {
return invoke<ManagedAuthStatus>("auth_get_status", {
authProvider,
});
}
export async function authRemoveAccount(
authProvider: ManagedAuthProvider,
accountId: string,
): Promise<void> {
return invoke("auth_remove_account", {
authProvider,
accountId,
});
}
export async function authSetDefaultAccount(
authProvider: ManagedAuthProvider,
accountId: string,
): Promise<void> {
return invoke("auth_set_default_account", {
authProvider,
accountId,
});
}
export async function authLogout(
authProvider: ManagedAuthProvider,
): Promise<void> {
return invoke("auth_logout", {
authProvider,
});
}
export const authApi = {
authStartLogin,
authPollForAccount,
authListAccounts,
authGetStatus,
authRemoveAccount,
authSetDefaultAccount,
authLogout,
};
+256
View File
@@ -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<CopilotDeviceCodeResponse> {
return invoke<CopilotDeviceCodeResponse>("copilot_start_device_flow");
}
/**
* 轮询 OAuth Token
*
* 使用设备码轮询 GitHub,等待用户完成授权。
*
* @param deviceCode - 设备码
* @returns true 表示认证成功,false 表示仍在等待用户授权
*/
export async function copilotPollForAuth(deviceCode: string): Promise<boolean> {
return invoke<boolean>("copilot_poll_for_auth", {
deviceCode,
});
}
/**
* 获取 Copilot 认证状态
*
* @returns 认证状态,包含是否已认证、用户名和过期时间
*/
export async function copilotGetAuthStatus(): Promise<CopilotAuthStatus> {
return invoke<CopilotAuthStatus>("copilot_get_auth_status");
}
/**
* 注销 Copilot 认证
*/
export async function copilotLogout(): Promise<void> {
return invoke("copilot_logout");
}
/**
* 检查是否已认证
*
* @returns true 表示已认证
*/
export async function copilotIsAuthenticated(): Promise<boolean> {
return invoke<boolean>("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<string> {
return invoke<string>("copilot_get_token");
}
/**
* 获取 Copilot 可用模型列表
*
* @returns 可用模型列表
*/
export async function copilotGetModels(): Promise<CopilotModel[]> {
return invoke<CopilotModel[]>("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<CopilotUsageResponse> {
return invoke<CopilotUsageResponse>("copilot_get_usage");
}
// ==================== 多账号管理 API ====================
/**
* 列出所有已认证的 GitHub 账号
*
* @returns 账号列表
*/
export async function copilotListAccounts(): Promise<GitHubAccount[]> {
return invoke<GitHubAccount[]>("copilot_list_accounts");
}
/**
* 轮询 OAuth Token(多账号版本)
*
* 使用设备码轮询 GitHub,等待用户完成授权。
* 授权成功后返回新添加的账号信息。
*
* @param deviceCode - 设备码
* @returns 新添加的账号信息,如果仍在等待则返回 null
*/
export async function copilotPollForAccount(
deviceCode: string,
): Promise<GitHubAccount | null> {
return invoke<GitHubAccount | null>("copilot_poll_for_account", {
deviceCode,
});
}
/**
* 移除指定的 GitHub 账号
*
* @param accountId - GitHub 用户 ID
*/
export async function copilotRemoveAccount(accountId: string): Promise<void> {
return invoke("copilot_remove_account", { accountId });
}
/**
* 设置默认 GitHub 账号
*
* @param accountId - GitHub 用户 ID
*/
export async function copilotSetDefaultAccount(
accountId: string,
): Promise<void> {
return invoke("copilot_set_default_account", { accountId });
}
/**
* 获取指定账号的有效 Copilot Token
*
* 内部使用,用于代理请求。
*
* @param accountId - GitHub 用户 ID
* @returns Copilot Token
*/
export async function copilotGetTokenForAccount(
accountId: string,
): Promise<string> {
return invoke<string>("copilot_get_token_for_account", { accountId });
}
/**
* 获取指定账号的 Copilot 可用模型列表
*
* @param accountId - GitHub 用户 ID
* @returns 可用模型列表
*/
export async function copilotGetModelsForAccount(
accountId: string,
): Promise<CopilotModel[]> {
return invoke<CopilotModel[]>("copilot_get_models_for_account", {
accountId,
});
}
/**
* 获取指定账号的 Copilot 使用量信息
*
* @param accountId - GitHub 用户 ID
* @returns 使用量信息
*/
export async function copilotGetUsageForAccount(
accountId: string,
): Promise<CopilotUsageResponse> {
return invoke<CopilotUsageResponse>("copilot_get_usage_for_account", {
accountId,
});
}
+13
View File
@@ -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";
-12
View File
@@ -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<string | null> {
return await invoke("save_file_dialog", { defaultName });
},
+2 -1
View File
@@ -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<UsageResult> => {
return invoke("testUsageScript", {
providerId,
+21
View File
@@ -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;
}
+17 -3
View File
@@ -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;
// 静默启动(程序启动时不显示主窗口)
+79
View File
@@ -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);
};