diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index a73cf246c..a295c9018 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -21,11 +21,11 @@ import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBa import { extractCodexBaseUrl, extractCodexExperimentalBearerToken, - extractCodexWireApi, - isCodexAnthropicWireApi, - isCodexChatWireApi, } from "@/utils/providerConfigUtils"; -import { supportsOfficialProxyTakeover } from "@/utils/providerCapabilities"; +import { + supportsOfficialProxyTakeover, + providerNeedsRouting, +} from "@/utils/providerCapabilities"; import { useProviderHealth } from "@/lib/query/failover"; import { useUsageQuery } from "@/lib/query/queries"; import { resolveProviderIcon } from "@/utils/providerIcon"; @@ -227,25 +227,10 @@ export function ProviderCard({ appId === "hermes" && isHermesReadOnlyProvider(provider.settingsConfig); const isCodexOauth = provider.meta?.providerType === PROVIDER_TYPES.CODEX_OAUTH; - const codexNeedsRouting = useMemo(() => { - if (appId !== "codex" || provider.category === "official") return false; - if ( - provider.meta?.apiFormat === "openai_chat" || - provider.meta?.apiFormat === "anthropic" - ) - return true; - const config = (provider.settingsConfig as Record)?.config; - return ( - typeof config === "string" && - (isCodexChatWireApi(extractCodexWireApi(config)) || - isCodexAnthropicWireApi(extractCodexWireApi(config))) - ); - }, [ - appId, - provider.category, - provider.meta?.apiFormat, - (provider.settingsConfig as Record)?.config, - ]); + // 统一权威谓词(详见 providerNeedsRouting):以 providerType 为准,不受 + // apiFormat 被改动/缺省影响。此 badge 仅在 Codex 视图渲染,故加 appId 守卫。 + const codexNeedsRouting = + appId === "codex" && providerNeedsRouting(appId, provider); // 获取用量数据以判断是否有多套餐 // 累加模式应用(OpenCode/OpenClaw/Hermes):使用 isInConfig 代替 isCurrent const shouldAutoQuery = @@ -381,8 +366,7 @@ export function ProviderCard({ )} {appId === "claude-desktop" && - provider.category !== "official" && - provider.meta?.claudeDesktopMode === "proxy" && ( + providerNeedsRouting(appId, provider) && ( {t("claudeDesktop.modeProxy", { defaultValue: "需要路由", @@ -390,16 +374,13 @@ export function ProviderCard({ )} - {appId === "claude" && - provider.category !== "official" && - provider.meta?.apiFormat && - provider.meta.apiFormat !== "anthropic" && ( - - {t("claudeCode.needsRouting", { - defaultValue: "需要路由", - })} - - )} + {appId === "claude" && providerNeedsRouting(appId, provider) && ( + + {t("claudeCode.needsRouting", { + defaultValue: "需要路由", + })} + + )} {codexNeedsRouting && ( diff --git a/src/components/providers/forms/ClaudeDesktopProviderForm.tsx b/src/components/providers/forms/ClaudeDesktopProviderForm.tsx index c88976164..195a87044 100644 --- a/src/components/providers/forms/ClaudeDesktopProviderForm.tsx +++ b/src/components/providers/forms/ClaudeDesktopProviderForm.tsx @@ -70,6 +70,7 @@ import { } from "@/lib/api/providers"; import { resolveManagedAccountId } from "@/lib/authBinding"; import { useCopilotAuth, useCodexOauth, useXaiOauth } from "./hooks"; +import { isOAuthProviderType } from "@/config/constants"; export type ClaudeDesktopProviderFormValues = ProviderFormData & { presetId?: string; @@ -258,9 +259,10 @@ export function ClaudeDesktopProviderForm({ showButtons = true, }: ClaudeDesktopProviderFormProps) { const { t } = useTranslation(); - const initialMode = initialData?.meta?.claudeDesktopMode ?? "direct"; + const initialMode = isOAuthProviderType(initialData?.meta?.providerType) + ? "proxy" + : (initialData?.meta?.claudeDesktopMode ?? "direct"); const [mode, setMode] = useState<"direct" | "proxy">(initialMode); - const needsModelMapping = mode === "proxy"; const [apiFormat, setApiFormat] = useState( initialData?.meta?.apiFormat ?? "anthropic", ); @@ -397,9 +399,9 @@ export function ClaudeDesktopProviderForm({ activePreset?.category === "official"; const usesManagedOAuth = activePreset?.requiresOAuth === true || - activeProviderType === "github_copilot" || - activeProviderType === "codex_oauth" || - activeProviderType === "xai_oauth"; + isOAuthProviderType(activeProviderType); + const effectiveMode: "direct" | "proxy" = usesManagedOAuth ? "proxy" : mode; + const needsModelMapping = effectiveMode === "proxy"; // API Key 获取/邀请链接(与 Claude Code 表单同款,见 ClaudeFormFields) const apiKeyLinkCategory = activePreset?.category ?? initialData?.category; @@ -428,9 +430,13 @@ export function ClaudeDesktopProviderForm({ setApiKeyField(preset.apiKeyField ?? "ANTHROPIC_AUTH_TOKEN"); setApiFormat(preset.apiFormat ?? "anthropic"); + const presetMode = + preset.requiresOAuth === true || isOAuthProviderType(preset.providerType) + ? "proxy" + : preset.mode; didSeedDefaultRoutes.current = true; - setMode(preset.mode); - if (preset.mode === "proxy" && preset.modelRoutes) { + setMode(presetMode); + if (presetMode === "proxy" && preset.modelRoutes) { setRoutes( normalizeProxyRows( preset.modelRoutes.map((r) => @@ -485,6 +491,7 @@ export function ClaudeDesktopProviderForm({ }; const handleModelMappingChange = (checked: boolean) => { + if (usesManagedOAuth) return; setMode(checked ? "proxy" : "direct"); if (checked) { // 切到 proxy:归一化成固定 Sonnet / Opus / Haiku 三档; @@ -511,7 +518,7 @@ export function ClaudeDesktopProviderForm({ useEffect(() => { if ( didSeedDefaultRoutes.current || - mode !== "proxy" || + effectiveMode !== "proxy" || routes.length > 0 || defaultProxyRouteRows.length === 0 ) { @@ -520,7 +527,7 @@ export function ClaudeDesktopProviderForm({ didSeedDefaultRoutes.current = true; setRoutes(normalizeProxyRows(defaultProxyRouteRows)); - }, [defaultProxyRouteRows, mode, routes.length]); + }, [defaultProxyRouteRows, effectiveMode, routes.length]); const handleFetchModels = async () => { if (!baseUrl.trim() || !apiKey.trim()) { @@ -665,7 +672,6 @@ export function ClaudeDesktopProviderForm({ })) .filter((route) => route.route || route.model); - const effectiveMode = activeProviderType === "xai_oauth" ? "proxy" : mode; if (effectiveMode === "proxy") { // 固定四档(Sonnet / Opus / Fable / Haiku),route_id 由 UI 生成、恒合法, // 因此只要求至少填一个实际请求模型;留空档继承第一个已填档(Sonnet 优先), @@ -925,7 +931,7 @@ export function ClaudeDesktopProviderForm({ = {}): Provider { + return { id: "p1", name: "Test", settingsConfig: {}, ...overrides }; +} + +// wire_api 取自 config.toml;chat_completions 需转换(需路由),responses 直连。 +const codexConfig = (wireApi: "chat_completions" | "responses") => + `model_provider = "custom"\n\n[model_providers.custom]\nname = "X"\nbase_url = "https://x.example/v1"\nwire_api = "${wireApi}"\n`; + +describe("providerNeedsRouting", () => { + it("官方供应商一律不需要路由(即便 providerType 是 OAuth)", () => { + const apps: AppId[] = ["claude", "codex", "claude-desktop"]; + for (const app of apps) { + expect( + providerNeedsRouting( + app, + mkProvider({ + category: "official", + meta: { providerType: "xai_oauth" }, + }), + ), + ).toBe(false); + } + }); + + describe("托管 OAuth:providerType 权威,与 apiFormat 无关(P2)", () => { + it("Claude 下 xai_oauth 需要路由", () => { + expect( + providerNeedsRouting( + "claude", + mkProvider({ + meta: { providerType: "xai_oauth", apiFormat: "openai_responses" }, + }), + ), + ).toBe(true); + }); + + it("Claude 下 codex_oauth 即便 apiFormat 被改成 anthropic 仍需路由", () => { + expect( + providerNeedsRouting( + "claude", + mkProvider({ + meta: { providerType: "codex_oauth", apiFormat: "anthropic" }, + }), + ), + ).toBe(true); + }); + + it("Claude 下 codex_oauth 即便 apiFormat 缺省(旧数据)仍需路由", () => { + expect( + providerNeedsRouting( + "claude", + mkProvider({ meta: { providerType: "codex_oauth" } }), + ), + ).toBe(true); + }); + + it("Claude 下 github_copilot 需要路由", () => { + expect( + providerNeedsRouting( + "claude", + mkProvider({ + meta: { providerType: "github_copilot", apiFormat: "openai_chat" }, + }), + ), + ).toBe(true); + }); + + it("Codex 下 xai_oauth 需要路由(原生 Responses 也要注入 token)", () => { + expect( + providerNeedsRouting( + "codex", + mkProvider({ + meta: { providerType: "xai_oauth", apiFormat: "openai_responses" }, + }), + ), + ).toBe(true); + }); + + it("grokbuild 下 xai_oauth 需要路由", () => { + expect( + providerNeedsRouting( + "grokbuild", + mkProvider({ + meta: { providerType: "xai_oauth", apiFormat: "openai_responses" }, + }), + ), + ).toBe(true); + }); + }); + + describe("Claude 非 OAuth 按格式判定", () => { + it("anthropic 原生直连不需要路由", () => { + expect( + providerNeedsRouting( + "claude", + mkProvider({ meta: { apiFormat: "anthropic" } }), + ), + ).toBe(false); + }); + + it("apiFormat 缺省视为原生直连,不需要路由", () => { + expect(providerNeedsRouting("claude", mkProvider({ meta: {} }))).toBe( + false, + ); + }); + + it("openai_chat 需要路由", () => { + expect( + providerNeedsRouting( + "claude", + mkProvider({ meta: { apiFormat: "openai_chat" } }), + ), + ).toBe(true); + }); + + it("openai_responses 需要路由", () => { + expect( + providerNeedsRouting( + "claude", + mkProvider({ meta: { apiFormat: "openai_responses" } }), + ), + ).toBe(true); + }); + }); + + describe("Codex 非 OAuth 按格式判定(Responses 直连)", () => { + it("原生 Responses 不需要路由", () => { + expect( + providerNeedsRouting( + "codex", + mkProvider({ meta: { apiFormat: "openai_responses" } }), + ), + ).toBe(false); + }); + + it("Chat 格式需要路由", () => { + expect( + providerNeedsRouting( + "codex", + mkProvider({ meta: { apiFormat: "openai_chat" } }), + ), + ).toBe(true); + }); + + it("Anthropic 格式需要路由", () => { + expect( + providerNeedsRouting( + "codex", + mkProvider({ meta: { apiFormat: "anthropic" } }), + ), + ).toBe(true); + }); + + it("config 里 wire_api=chat_completions 需要路由", () => { + expect( + providerNeedsRouting( + "codex", + mkProvider({ + settingsConfig: { config: codexConfig("chat_completions") }, + }), + ), + ).toBe(true); + }); + + it("config 里 wire_api=responses 不需要路由", () => { + expect( + providerNeedsRouting( + "codex", + mkProvider({ settingsConfig: { config: codexConfig("responses") } }), + ), + ).toBe(false); + }); + }); + + describe("Claude Desktop 路由判定", () => { + it("proxy 模式需要路由", () => { + expect( + providerNeedsRouting( + "claude-desktop", + mkProvider({ meta: { claudeDesktopMode: "proxy" } }), + ), + ).toBe(true); + }); + + it.each(["github_copilot", "codex_oauth", "xai_oauth"])( + "direct 模式的托管 OAuth %s 仍需要路由", + (providerType) => { + expect( + providerNeedsRouting( + "claude-desktop", + mkProvider({ + meta: { + providerType, + claudeDesktopMode: "direct", + }, + }), + ), + ).toBe(true); + }, + ); + }); +}); diff --git a/src/utils/providerCapabilities.ts b/src/utils/providerCapabilities.ts index 6e86176e8..6addf530b 100644 --- a/src/utils/providerCapabilities.ts +++ b/src/utils/providerCapabilities.ts @@ -1,5 +1,11 @@ import type { AppId } from "@/lib/api"; import type { Provider } from "@/types"; +import { isOAuthProviderType } from "@/config/constants"; +import { + extractCodexWireApi, + isCodexAnthropicWireApi, + isCodexChatWireApi, +} from "@/utils/providerConfigUtils"; export const CODEX_OFFICIAL_PROVIDER_ID = "codex-official"; @@ -14,3 +20,62 @@ export function supportsOfficialProxyTakeover( provider.category === "official" ); } + +/** + * 供应商在指定应用下是否必须开启路由接管才能正常工作(badge 与切换警告共用的权威谓词)。 + * + * 权威信号是 `providerType`:托管 OAuth 供应商的凭据由本地代理按请求注入 + * (见 `forwarder.rs`,注入发生在转发路径上,请求必须经过代理 = 接管当前应用), + * 且后端按 providerType 强制托管认证/格式而**无视 apiFormat**。因此 apiFormat + * 只是可能被用户改动或旧数据缺省的次要信号,OAuth 供应商一律以 providerType 判定。 + * + * - Claude Desktop 的普通供应商按 direct/proxy 模式判定;托管 OAuth 没有 + * direct 逃生口(后端同样拒绝),始终需要本地路由。 + * - claude / codex / grokbuild 的托管 OAuth 同样恒需路由;非 OAuth 则按 + * 各自原生格式及完整 URL 模式判断是否需要本地处理。 + */ +export function providerNeedsRouting( + appId: AppId, + provider: Provider, +): boolean { + if (provider.category === "official") return false; + + const isManagedOAuth = isOAuthProviderType(provider.meta?.providerType); + + // Desktop 普通供应商由表单模式决定;托管 OAuth 的 token 只能由代理注入。 + if (appId === "claude-desktop") { + return isManagedOAuth || provider.meta?.claudeDesktopMode === "proxy"; + } + + if (appId !== "claude" && appId !== "codex" && appId !== "grokbuild") { + return false; + } + + // 托管 OAuth:凭据由代理注入,与 apiFormat 无关,必须接管。 + if (isManagedOAuth) return true; + + if (appId === "claude") { + const fmt = provider.meta?.apiFormat; + // Claude 原生是 Anthropic 格式,任何非 anthropic 格式都需要代理转换。 + return provider.meta?.isFullUrl === true || (!!fmt && fmt !== "anthropic"); + } + + if (appId === "codex" || appId === "grokbuild") { + const fmt = provider.meta?.apiFormat; + // Codex 原生是 Responses,仅 Chat / Anthropic 需要转换(Responses 直连)。 + if ( + provider.meta?.isFullUrl === true || + fmt === "openai_chat" || + fmt === "anthropic" + ) + return true; + const config = (provider.settingsConfig as Record)?.config; + return ( + typeof config === "string" && + (isCodexChatWireApi(extractCodexWireApi(config)) || + isCodexAnthropicWireApi(extractCodexWireApi(config))) + ); + } + + return false; +} diff --git a/tests/components/ClaudeDesktopProviderForm.test.tsx b/tests/components/ClaudeDesktopProviderForm.test.tsx index 944efa06c..dac9a745e 100644 --- a/tests/components/ClaudeDesktopProviderForm.test.tsx +++ b/tests/components/ClaudeDesktopProviderForm.test.tsx @@ -30,6 +30,35 @@ function renderForm( } describe("ClaudeDesktopProviderForm", () => { + it.each(["github_copilot", "codex_oauth", "xai_oauth"])( + "托管 OAuth %s 即使旧数据是 direct 也强制开启模型映射", + (providerType) => { + renderForm({ + name: "Managed OAuth Provider", + category: "third_party", + settingsConfig: { + env: { + ANTHROPIC_BASE_URL: "https://api.example.com", + }, + }, + meta: { + providerType, + claudeDesktopMode: "direct", + apiFormat: "anthropic", + claudeDesktopModelRoutes: { + "claude-sonnet-5": { model: "upstream-model" }, + }, + }, + }); + + const modelMappingToggle = screen.getByRole("switch", { + name: "需要模型映射", + }); + expect(modelMappingToggle).toBeChecked(); + expect(modelMappingToggle).toBeDisabled(); + }, + ); + it("编辑模型映射的菜单显示名时保持输入框焦点", () => { renderForm({ name: "Proxy Provider", diff --git a/tests/hooks/useProviderActions.test.tsx b/tests/hooks/useProviderActions.test.tsx index f16ccff27..252d06fac 100644 --- a/tests/hooks/useProviderActions.test.tsx +++ b/tests/hooks/useProviderActions.test.tsx @@ -300,6 +300,86 @@ describe("useProviderActions", () => { expect(switchProviderMutateAsync).toHaveBeenCalledTimes(3); }); + it("warns for managed OAuth until the current Code app is taken over", async () => { + switchProviderMutateAsync.mockResolvedValueOnce(undefined); + const { wrapper } = createWrapper(); + const provider = createProvider({ + category: "custom", + meta: { + providerType: "codex_oauth", + apiFormat: "openai_responses", + }, + }); + + const { result } = renderHook( + () => useProviderActions("codex", true, false), + { wrapper }, + ); + + await act(async () => { + await result.current.switchProvider(provider); + }); + + expect(toastWarningMock).toHaveBeenCalledWith( + expect.stringContaining("托管 OAuth"), + ); + expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id); + }); + + it("does not warn for managed OAuth after the current Code app is taken over", async () => { + switchProviderMutateAsync.mockResolvedValueOnce(undefined); + const { wrapper } = createWrapper(); + const provider = createProvider({ + category: "custom", + meta: { + providerType: "codex_oauth", + apiFormat: "openai_responses", + }, + }); + + const { result } = renderHook( + () => useProviderActions("codex", true, true), + { wrapper }, + ); + + await act(async () => { + await result.current.switchProvider(provider); + }); + + expect(toastWarningMock).not.toHaveBeenCalled(); + expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id); + }); + + it("uses proxy process readiness for Claude Desktop routing", async () => { + switchProviderMutateAsync.mockResolvedValue(undefined); + const { wrapper } = createWrapper(); + const provider = createProvider({ + category: "custom", + meta: { claudeDesktopMode: "proxy" }, + }); + + const { result, rerender } = renderHook( + ({ isProxyRunning }) => + useProviderActions("claude-desktop", isProxyRunning, false), + { initialProps: { isProxyRunning: true }, wrapper }, + ); + + await act(async () => { + await result.current.switchProvider(provider); + }); + expect(toastWarningMock).not.toHaveBeenCalled(); + + rerender({ isProxyRunning: false }); + await act(async () => { + await result.current.switchProvider(provider); + }); + + expect(toastWarningMock).toHaveBeenCalledTimes(1); + expect(toastWarningMock).toHaveBeenCalledWith( + expect.stringContaining("Claude Desktop 本地路由模式"), + ); + }); + it("allows the built-in Codex official provider during takeover", async () => { switchProviderMutateAsync.mockResolvedValueOnce(undefined); const { wrapper } = createWrapper();