- 支持 Claude Desktop 使用 Copilot/Codex OAuth 供应商

- 放开本地路由托管 OAuth 供应商校验,允许动态 Token
- 新增 Claude Desktop Copilot/Codex 预设与账号选择
- 添加 OAuth proxy 回归测试
This commit is contained in:
Jason
2026-05-12 11:30:11 +08:00
parent 953b7cdcf9
commit 6a3c2fe0ba
5 changed files with 209 additions and 60 deletions
+62 -11
View File
@@ -370,17 +370,6 @@ pub fn validate_proxy_provider(provider: &Provider) -> Result<(), AppError> {
} }
if let Some(meta) = provider.meta.as_ref() { 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 let Some(api_format) = meta.api_format.as_deref() {
if !matches!( if !matches!(
api_format, api_format,
@@ -419,6 +408,10 @@ fn has_proxy_base_url_and_key(provider: &Provider) -> bool {
.map(str::trim) .map(str::trim)
.is_some_and(|value| !value.is_empty()); .is_some_and(|value| !value.is_empty());
if is_managed_oauth_proxy_provider(provider) {
return has_base_url;
}
let has_key = env let has_key = env
.and_then(|value| { .and_then(|value| {
[ [
@@ -440,6 +433,14 @@ fn has_proxy_base_url_and_key(provider: &Provider) -> bool {
has_base_url && has_key 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> { pub fn validate_provider(provider: &Provider) -> Result<(), AppError> {
if is_official_provider(provider) { if is_official_provider(provider) {
return Ok(()); return Ok(());
@@ -1066,6 +1067,33 @@ mod tests {
provider 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 { fn direct_provider_with_models(id: &str) -> Provider {
let mut provider = direct_provider(id); let mut provider = direct_provider(id);
provider.meta = Some(ProviderMeta { provider.meta = Some(ProviderMeta {
@@ -1163,6 +1191,29 @@ mod tests {
assert!(!profile.to_string().contains("kimi-k2")); 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] #[test]
fn claude_desktop_proxy_maps_known_route_and_rejects_unknown_route() { fn claude_desktop_proxy_maps_known_route_and_rejects_unknown_route() {
let provider = proxy_provider("proxy"); let provider = proxy_provider("proxy");
+17 -13
View File
@@ -184,16 +184,6 @@ pub fn import_claude_desktop_providers_from_claude(
continue; 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(); let mut desktop_provider = provider.clone();
desktop_provider.in_failover_queue = false; desktop_provider.in_failover_queue = false;
let meta = desktop_provider.meta.get_or_insert_with(Default::default); let meta = desktop_provider.meta.get_or_insert_with(Default::default);
@@ -249,12 +239,20 @@ fn suggested_claude_desktop_routes(
.get("env") .get("env")
.and_then(|value| value.as_object())?; .and_then(|value| value.as_object())?;
let mut routes = std::collections::HashMap::new(); 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( fn add_route(
routes: &mut std::collections::HashMap<String, crate::provider::ClaudeDesktopModelRoute>, routes: &mut std::collections::HashMap<String, crate::provider::ClaudeDesktopModelRoute>,
env: &serde_json::Map<String, serde_json::Value>, env: &serde_json::Map<String, serde_json::Value>,
route_id: &str, route_id: &str,
env_key: &str, env_key: &str,
supports_1m: bool,
) { ) {
if let Some(model) = env if let Some(model) = env
.get(env_key) .get(env_key)
@@ -266,19 +264,25 @@ fn suggested_claude_desktop_routes(
route_id.to_string(), route_id.to_string(),
crate::provider::ClaudeDesktopModelRoute { crate::provider::ClaudeDesktopModelRoute {
model: model.to_string(), model: model.to_string(),
supports_1m: Some(true), supports_1m: Some(supports_1m),
}, },
); );
} }
} }
for spec in crate::claude_desktop_config::DEFAULT_PROXY_ROUTES { 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]; let primary_route = crate::claude_desktop_config::DEFAULT_PROXY_ROUTES[0];
if !routes.contains_key(primary_route.route_id) { 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) (!routes.is_empty()).then_some(routes)
@@ -37,6 +37,8 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { BasicFormFields } from "./BasicFormFields"; import { BasicFormFields } from "./BasicFormFields";
import { CodexOAuthSection } from "./CodexOAuthSection";
import { CopilotAuthSection } from "./CopilotAuthSection";
import { EndpointField } from "./shared/EndpointField"; import { EndpointField } from "./shared/EndpointField";
import { ModelDropdown } from "./shared/ModelDropdown"; import { ModelDropdown } from "./shared/ModelDropdown";
import { ProviderPresetSelector } from "./ProviderPresetSelector"; import { ProviderPresetSelector } from "./ProviderPresetSelector";
@@ -61,6 +63,7 @@ import {
providersApi, providersApi,
type ClaudeDesktopDefaultRoute, type ClaudeDesktopDefaultRoute,
} from "@/lib/api/providers"; } from "@/lib/api/providers";
import { resolveManagedAccountId } from "@/lib/authBinding";
export type ClaudeDesktopProviderFormValues = ProviderFormData & { export type ClaudeDesktopProviderFormValues = ProviderFormData & {
presetId?: string; presetId?: string;
@@ -231,6 +234,15 @@ export function ClaudeDesktopProviderForm({
? "ANTHROPIC_API_KEY" ? "ANTHROPIC_API_KEY"
: "ANTHROPIC_AUTH_TOKEN", : "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<boolean>(
() => initialData?.meta?.codexFastMode ?? false,
);
const [selectedPresetId, setSelectedPresetId] = useState<string | null>( const [selectedPresetId, setSelectedPresetId] = useState<string | null>(
"custom", "custom",
); );
@@ -239,6 +251,8 @@ export function ClaudeDesktopProviderForm({
category?: ProviderCategory; category?: ProviderCategory;
isPartner?: boolean; isPartner?: boolean;
partnerPromotionKey?: string; partnerPromotionKey?: string;
providerType?: string;
requiresOAuth?: boolean;
} | null>(null); } | null>(null);
const [routes, setRoutes] = useState<RouteRow[]>(() => const [routes, setRoutes] = useState<RouteRow[]>(() =>
initialRouteRows(initialData?.meta?.claudeDesktopModelRoutes), initialRouteRows(initialData?.meta?.claudeDesktopModelRoutes),
@@ -334,6 +348,12 @@ export function ClaudeDesktopProviderForm({
}), }),
[t], [t],
); );
const activeProviderType =
activePreset?.providerType ?? initialData?.meta?.providerType;
const usesManagedOAuth =
activePreset?.requiresOAuth === true ||
activeProviderType === "github_copilot" ||
activeProviderType === "codex_oauth";
const applyDesktopPreset = (preset: ClaudeDesktopProviderPreset) => { const applyDesktopPreset = (preset: ClaudeDesktopProviderPreset) => {
form.setValue("name", preset.nameKey ? t(preset.nameKey) : preset.name); form.setValue("name", preset.nameKey ? t(preset.nameKey) : preset.name);
@@ -386,6 +406,8 @@ export function ClaudeDesktopProviderForm({
category: entry.preset.category, category: entry.preset.category,
isPartner: entry.preset.isPartner, isPartner: entry.preset.isPartner,
partnerPromotionKey: entry.preset.partnerPromotionKey, partnerPromotionKey: entry.preset.partnerPromotionKey,
providerType: entry.preset.providerType,
requiresOAuth: entry.preset.requiresOAuth,
}); });
applyDesktopPreset(entry.preset); applyDesktopPreset(entry.preset);
}; };
@@ -469,7 +491,7 @@ export function ClaudeDesktopProviderForm({
); );
return; return;
} }
if (!apiKey.trim()) { if (!usesManagedOAuth && !apiKey.trim()) {
toast.error( toast.error(
t("providerForm.fetchModelsNeedApiKey", { t("providerForm.fetchModelsNeedApiKey", {
defaultValue: "请先填写 API Key", defaultValue: "请先填写 API Key",
@@ -524,16 +546,18 @@ export function ClaudeDesktopProviderForm({
const settingsConfig = clonePlainRecord(initialData?.settingsConfig); const settingsConfig = clonePlainRecord(initialData?.settingsConfig);
const env = clonePlainRecord(settingsConfig.env); const env = clonePlainRecord(settingsConfig.env);
const otherKey: ApiKeyField = delete env.ANTHROPIC_AUTH_TOKEN;
apiKeyField === "ANTHROPIC_AUTH_TOKEN" delete env.ANTHROPIC_API_KEY;
? "ANTHROPIC_API_KEY" settingsConfig.env = usesManagedOAuth
: "ANTHROPIC_AUTH_TOKEN"; ? {
delete env[otherKey]; ...env,
settingsConfig.env = { ANTHROPIC_BASE_URL: baseUrl.trim().replace(/\/+$/, ""),
...env, }
ANTHROPIC_BASE_URL: baseUrl.trim().replace(/\/+$/, ""), : {
[apiKeyField]: apiKey.trim(), ...env,
}; ANTHROPIC_BASE_URL: baseUrl.trim().replace(/\/+$/, ""),
[apiKeyField]: apiKey.trim(),
};
const routeMap = routeEntries.reduce< const routeMap = routeEntries.reduce<
Record<string, ClaudeDesktopModelRoute> Record<string, ClaudeDesktopModelRoute>
@@ -552,9 +576,25 @@ export function ClaudeDesktopProviderForm({
}; };
meta.claudeDesktopModelRoutes = routeMap; 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.endpointAutoSelect;
delete meta.providerType;
delete meta.isFullUrl; delete meta.isFullUrl;
await onSubmit({ await onSubmit({
@@ -573,21 +613,23 @@ export function ClaudeDesktopProviderForm({
const renderActionButtons = (onAdd: () => void, addLabel: string) => ( const renderActionButtons = (onAdd: () => void, addLabel: string) => (
<div className="flex gap-1"> <div className="flex gap-1">
<Button {!usesManagedOAuth && (
type="button" <Button
variant="outline" type="button"
size="sm" variant="outline"
onClick={handleFetchModels} size="sm"
disabled={isFetchingModels} onClick={handleFetchModels}
className="h-7 gap-1" disabled={isFetchingModels}
> className="h-7 gap-1"
{isFetchingModels ? ( >
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {isFetchingModels ? (
) : ( <Loader2 className="h-3.5 w-3.5 animate-spin" />
<Download className="h-3.5 w-3.5" /> ) : (
)} <Download className="h-3.5 w-3.5" />
{t("providerForm.fetchModels", { defaultValue: "获取模型" })} )}
</Button> {t("providerForm.fetchModels", { defaultValue: "获取模型" })}
</Button>
)}
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -621,15 +663,33 @@ export function ClaudeDesktopProviderForm({
<BasicFormFields form={form} /> <BasicFormFields form={form} />
<div className="space-y-1"> {usesManagedOAuth ? (
<Label>{"API Key"}</Label> <div className="rounded-lg border border-border-default bg-muted/20 p-3">
<Input {activeProviderType === "github_copilot" ? (
value={apiKey} <CopilotAuthSection
onChange={(event) => setApiKey(event.target.value)} selectedAccountId={selectedGitHubAccountId}
type="password" onAccountSelect={setSelectedGitHubAccountId}
placeholder="sk-..." />
/> ) : (
</div> <CodexOAuthSection
selectedAccountId={selectedCodexAccountId}
onAccountSelect={setSelectedCodexAccountId}
fastModeEnabled={codexFastMode}
onFastModeChange={setCodexFastMode}
/>
)}
</div>
) : (
<div className="space-y-1">
<Label>{"API Key"}</Label>
<Input
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
type="password"
placeholder="sk-..."
/>
</div>
)}
<EndpointField <EndpointField
id="baseUrl" id="baseUrl"
@@ -38,6 +38,8 @@ export interface ClaudeDesktopProviderPreset {
mode: "direct" | "proxy"; mode: "direct" | "proxy";
apiFormat?: ClaudeDesktopApiFormat; apiFormat?: ClaudeDesktopApiFormat;
modelRoutes?: ClaudeDesktopRoutePreset[]; modelRoutes?: ClaudeDesktopRoutePreset[];
providerType?: "github_copilot" | "codex_oauth";
requiresOAuth?: boolean;
endpointCandidates?: string[]; endpointCandidates?: string[];
theme?: PresetTheme; theme?: PresetTheme;
@@ -155,6 +157,36 @@ export const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [
icon: "gemini", icon: "gemini",
iconColor: "#4285F4", iconColor: "#4285F4",
}, },
{
name: "GitHub Copilot",
websiteUrl: "https://github.com/features/copilot",
category: "third_party",
baseUrl: "https://api.githubcopilot.com",
mode: "proxy",
apiFormat: "openai_chat",
providerType: "github_copilot",
requiresOAuth: true,
modelRoutes: brandedRoutes(
"claude-sonnet-4.6",
"claude-sonnet-4.6",
"claude-haiku-4.5",
),
icon: "github",
iconColor: "#000000",
},
{
name: "Codex",
websiteUrl: "https://openai.com/chatgpt/pricing",
category: "third_party",
baseUrl: "https://chatgpt.com/backend-api/codex",
mode: "proxy",
apiFormat: "openai_responses",
providerType: "codex_oauth",
requiresOAuth: true,
modelRoutes: brandedRoutes("gpt-5.4", "gpt-5.4", "gpt-5.4-mini"),
icon: "openai",
iconColor: "#000000",
},
{ {
name: "DeepSeek", name: "DeepSeek",
websiteUrl: "https://platform.deepseek.com", websiteUrl: "https://platform.deepseek.com",
+2
View File
@@ -133,6 +133,8 @@ export interface AuthBinding {
export interface ClaudeDesktopModelRoute { export interface ClaudeDesktopModelRoute {
model: string; model: string;
/** @deprecated Claude Desktop ignores this in the model menu; kept only to read old configs. */
displayName?: string;
supports1m?: boolean; supports1m?: boolean;
} }