diff --git a/src-tauri/src/claude_desktop_config.rs b/src-tauri/src/claude_desktop_config.rs index 8bb53701f..3d67c8945 100644 --- a/src-tauri/src/claude_desktop_config.rs +++ b/src-tauri/src/claude_desktop_config.rs @@ -370,17 +370,6 @@ pub fn validate_proxy_provider(provider: &Provider) -> Result<(), AppError> { } if let Some(meta) = provider.meta.as_ref() { - if matches!( - meta.provider_type.as_deref(), - Some("github_copilot") | Some("codex_oauth") - ) { - return Err(AppError::localized( - "claude_desktop.provider.type_unsupported", - "Claude Desktop 本地路由模式暂不支持 Copilot 或 Codex OAuth 供应商", - "Claude Desktop proxy mode does not support Copilot or Codex OAuth providers yet", - )); - } - if let Some(api_format) = meta.api_format.as_deref() { if !matches!( api_format, @@ -419,6 +408,10 @@ fn has_proxy_base_url_and_key(provider: &Provider) -> bool { .map(str::trim) .is_some_and(|value| !value.is_empty()); + if is_managed_oauth_proxy_provider(provider) { + return has_base_url; + } + let has_key = env .and_then(|value| { [ @@ -440,6 +433,14 @@ fn has_proxy_base_url_and_key(provider: &Provider) -> bool { has_base_url && has_key } +fn is_managed_oauth_proxy_provider(provider: &Provider) -> bool { + provider + .meta + .as_ref() + .and_then(|meta| meta.provider_type.as_deref()) + .is_some_and(|provider_type| matches!(provider_type, "github_copilot" | "codex_oauth")) +} + pub fn validate_provider(provider: &Provider) -> Result<(), AppError> { if is_official_provider(provider) { return Ok(()); @@ -1066,6 +1067,33 @@ mod tests { provider } + fn oauth_proxy_provider(id: &str, provider_type: &str, api_format: &str) -> Provider { + let mut provider = Provider::with_id( + id.to_string(), + "OAuth Proxy".to_string(), + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://oauth-upstream.example.com" + } + }), + Some("https://example.com".to_string()), + ); + provider.meta = Some(ProviderMeta { + claude_desktop_mode: Some(ClaudeDesktopMode::Proxy), + api_format: Some(api_format.to_string()), + provider_type: Some(provider_type.to_string()), + claude_desktop_model_routes: std::collections::HashMap::from([( + "claude-gpt-5-4".to_string(), + ClaudeDesktopModelRoute { + model: "gpt-5.4".to_string(), + supports_1m: Some(false), + }, + )]), + ..Default::default() + }); + provider + } + fn direct_provider_with_models(id: &str) -> Provider { let mut provider = direct_provider(id); provider.meta = Some(ProviderMeta { @@ -1163,6 +1191,29 @@ mod tests { assert!(!profile.to_string().contains("kimi-k2")); } + #[test] + fn claude_desktop_proxy_accepts_managed_oauth_providers_without_static_key() { + for (provider_type, api_format) in [ + ("github_copilot", "openai_chat"), + ("codex_oauth", "openai_responses"), + ] { + let provider = oauth_proxy_provider(provider_type, provider_type, api_format); + validate_proxy_provider(&provider).expect("oauth proxy provider should validate"); + + let temp = TempDir::new().expect("tempdir"); + let paths = test_paths(temp.path()); + let db = test_db(); + apply_provider_to_paths(&db, &provider, &paths).expect("apply oauth proxy provider"); + + let profile: Value = read_json_file(&paths.profile_path).expect("read profile"); + assert_eq!( + profile["inferenceGatewayBaseUrl"], + json!("http://127.0.0.1:15721/claude-desktop") + ); + assert_eq!(profile["inferenceModels"], json!(["claude-gpt-5-4"])); + } + } + #[test] fn claude_desktop_proxy_maps_known_route_and_rejects_unknown_route() { let provider = proxy_provider("proxy"); diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index fb6dc5ba3..fbba2560c 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -184,16 +184,6 @@ pub fn import_claude_desktop_providers_from_claude( continue; } - if matches!( - provider - .meta - .as_ref() - .and_then(|meta| meta.provider_type.as_deref()), - Some("github_copilot") | Some("codex_oauth") - ) { - continue; - } - let mut desktop_provider = provider.clone(); desktop_provider.in_failover_queue = false; let meta = desktop_provider.meta.get_or_insert_with(Default::default); @@ -249,12 +239,20 @@ fn suggested_claude_desktop_routes( .get("env") .and_then(|value| value.as_object())?; let mut routes = std::collections::HashMap::new(); + let supports_1m = !matches!( + provider + .meta + .as_ref() + .and_then(|meta| meta.provider_type.as_deref()), + Some("github_copilot") | Some("codex_oauth") + ); fn add_route( routes: &mut std::collections::HashMap, env: &serde_json::Map, route_id: &str, env_key: &str, + supports_1m: bool, ) { if let Some(model) = env .get(env_key) @@ -266,19 +264,25 @@ fn suggested_claude_desktop_routes( route_id.to_string(), crate::provider::ClaudeDesktopModelRoute { model: model.to_string(), - supports_1m: Some(true), + supports_1m: Some(supports_1m), }, ); } } for spec in crate::claude_desktop_config::DEFAULT_PROXY_ROUTES { - add_route(&mut routes, env, spec.route_id, spec.env_key); + add_route(&mut routes, env, spec.route_id, spec.env_key, supports_1m); } let primary_route = crate::claude_desktop_config::DEFAULT_PROXY_ROUTES[0]; if !routes.contains_key(primary_route.route_id) { - add_route(&mut routes, env, primary_route.route_id, "ANTHROPIC_MODEL"); + add_route( + &mut routes, + env, + primary_route.route_id, + "ANTHROPIC_MODEL", + supports_1m, + ); } (!routes.is_empty()).then_some(routes) diff --git a/src/components/providers/forms/ClaudeDesktopProviderForm.tsx b/src/components/providers/forms/ClaudeDesktopProviderForm.tsx index 140a4b196..b8543052c 100644 --- a/src/components/providers/forms/ClaudeDesktopProviderForm.tsx +++ b/src/components/providers/forms/ClaudeDesktopProviderForm.tsx @@ -37,6 +37,8 @@ import { } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { BasicFormFields } from "./BasicFormFields"; +import { CodexOAuthSection } from "./CodexOAuthSection"; +import { CopilotAuthSection } from "./CopilotAuthSection"; import { EndpointField } from "./shared/EndpointField"; import { ModelDropdown } from "./shared/ModelDropdown"; import { ProviderPresetSelector } from "./ProviderPresetSelector"; @@ -61,6 +63,7 @@ import { providersApi, type ClaudeDesktopDefaultRoute, } from "@/lib/api/providers"; +import { resolveManagedAccountId } from "@/lib/authBinding"; export type ClaudeDesktopProviderFormValues = ProviderFormData & { presetId?: string; @@ -231,6 +234,15 @@ export function ClaudeDesktopProviderForm({ ? "ANTHROPIC_API_KEY" : "ANTHROPIC_AUTH_TOKEN", ); + const [selectedGitHubAccountId, setSelectedGitHubAccountId] = useState< + string | null + >(() => resolveManagedAccountId(initialData?.meta, "github_copilot")); + const [selectedCodexAccountId, setSelectedCodexAccountId] = useState< + string | null + >(() => resolveManagedAccountId(initialData?.meta, "codex_oauth")); + const [codexFastMode, setCodexFastMode] = useState( + () => initialData?.meta?.codexFastMode ?? false, + ); const [selectedPresetId, setSelectedPresetId] = useState( "custom", ); @@ -239,6 +251,8 @@ export function ClaudeDesktopProviderForm({ category?: ProviderCategory; isPartner?: boolean; partnerPromotionKey?: string; + providerType?: string; + requiresOAuth?: boolean; } | null>(null); const [routes, setRoutes] = useState(() => initialRouteRows(initialData?.meta?.claudeDesktopModelRoutes), @@ -334,6 +348,12 @@ export function ClaudeDesktopProviderForm({ }), [t], ); + const activeProviderType = + activePreset?.providerType ?? initialData?.meta?.providerType; + const usesManagedOAuth = + activePreset?.requiresOAuth === true || + activeProviderType === "github_copilot" || + activeProviderType === "codex_oauth"; const applyDesktopPreset = (preset: ClaudeDesktopProviderPreset) => { form.setValue("name", preset.nameKey ? t(preset.nameKey) : preset.name); @@ -386,6 +406,8 @@ export function ClaudeDesktopProviderForm({ category: entry.preset.category, isPartner: entry.preset.isPartner, partnerPromotionKey: entry.preset.partnerPromotionKey, + providerType: entry.preset.providerType, + requiresOAuth: entry.preset.requiresOAuth, }); applyDesktopPreset(entry.preset); }; @@ -469,7 +491,7 @@ export function ClaudeDesktopProviderForm({ ); return; } - if (!apiKey.trim()) { + if (!usesManagedOAuth && !apiKey.trim()) { toast.error( t("providerForm.fetchModelsNeedApiKey", { defaultValue: "请先填写 API Key", @@ -524,16 +546,18 @@ export function ClaudeDesktopProviderForm({ const settingsConfig = clonePlainRecord(initialData?.settingsConfig); const env = clonePlainRecord(settingsConfig.env); - const otherKey: ApiKeyField = - apiKeyField === "ANTHROPIC_AUTH_TOKEN" - ? "ANTHROPIC_API_KEY" - : "ANTHROPIC_AUTH_TOKEN"; - delete env[otherKey]; - settingsConfig.env = { - ...env, - ANTHROPIC_BASE_URL: baseUrl.trim().replace(/\/+$/, ""), - [apiKeyField]: apiKey.trim(), - }; + delete env.ANTHROPIC_AUTH_TOKEN; + delete env.ANTHROPIC_API_KEY; + settingsConfig.env = usesManagedOAuth + ? { + ...env, + ANTHROPIC_BASE_URL: baseUrl.trim().replace(/\/+$/, ""), + } + : { + ...env, + ANTHROPIC_BASE_URL: baseUrl.trim().replace(/\/+$/, ""), + [apiKeyField]: apiKey.trim(), + }; const routeMap = routeEntries.reduce< Record @@ -552,9 +576,25 @@ export function ClaudeDesktopProviderForm({ }; meta.claudeDesktopModelRoutes = routeMap; + meta.providerType = activeProviderType; + meta.authBinding = + activeProviderType === "github_copilot" + ? { + source: "managed_account", + authProvider: "github_copilot", + accountId: selectedGitHubAccountId ?? undefined, + } + : activeProviderType === "codex_oauth" + ? { + source: "managed_account", + authProvider: "codex_oauth", + accountId: selectedCodexAccountId ?? undefined, + } + : undefined; + meta.codexFastMode = + activeProviderType === "codex_oauth" ? codexFastMode : undefined; delete meta.endpointAutoSelect; - delete meta.providerType; delete meta.isFullUrl; await onSubmit({ @@ -573,21 +613,23 @@ export function ClaudeDesktopProviderForm({ const renderActionButtons = (onAdd: () => void, addLabel: string) => (
- + {!usesManagedOAuth && ( + + )}