mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat(proxy): flag managed-OAuth providers as routing-required
Managed-OAuth providers (github_copilot / codex_oauth / xai_oauth) need proxy takeover to inject credentials, but the "needs routing" badge and the switch-time warning only keyed off apiFormat — missing OAuth providers whose upstream format is native (e.g. the new Codex xAI Grok OAuth preset on openai_responses). - Add isOAuthProviderType / OAUTH_PROVIDER_TYPES as the SSOT for OAuth types - Extract providerNeedsRouting() as the authoritative predicate: managed OAuth always needs routing (the backend rejects these accounts' direct connection) regardless of apiFormat or Claude Desktop mode - ProviderCard badges (claude / codex / claude-desktop) and the switch warning consume the shared predicate instead of raw apiFormat - Gate the warning on per-app routing readiness: claude-desktop uses isProxyRunning (backend takeover status has no such field), other apps use isProxyTakeover — fixes false warnings on Desktop and missing warnings when the proxy runs but the app isn't taken over - Force proxy mode for all managed-OAuth providers in the Claude Desktop form (lock the mode toggle), not only xai_oauth - Add unit tests for providerNeedsRouting, the switch routing gate, and Desktop OAuth preset enforcement - i18n(zh/en/ja/zh-TW): add notifications.proxyReasonManagedOAuth
This commit is contained in:
@@ -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<string, any>)?.config;
|
||||
return (
|
||||
typeof config === "string" &&
|
||||
(isCodexChatWireApi(extractCodexWireApi(config)) ||
|
||||
isCodexAnthropicWireApi(extractCodexWireApi(config)))
|
||||
);
|
||||
}, [
|
||||
appId,
|
||||
provider.category,
|
||||
provider.meta?.apiFormat,
|
||||
(provider.settingsConfig as Record<string, any>)?.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) && (
|
||||
<span className="inline-flex items-center rounded-md bg-sky-100 px-1.5 py-0.5 text-[10px] font-semibold text-sky-700 dark:bg-sky-900/40 dark:text-sky-300">
|
||||
{t("claudeDesktop.modeProxy", {
|
||||
defaultValue: "需要路由",
|
||||
@@ -390,10 +374,7 @@ export function ProviderCard({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{appId === "claude" &&
|
||||
provider.category !== "official" &&
|
||||
provider.meta?.apiFormat &&
|
||||
provider.meta.apiFormat !== "anthropic" && (
|
||||
{appId === "claude" && providerNeedsRouting(appId, provider) && (
|
||||
<span className="inline-flex items-center rounded-md bg-sky-100 px-1.5 py-0.5 text-[10px] font-semibold text-sky-700 dark:bg-sky-900/40 dark:text-sky-300">
|
||||
{t("claudeCode.needsRouting", {
|
||||
defaultValue: "需要路由",
|
||||
|
||||
@@ -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<ClaudeApiFormat>(
|
||||
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({
|
||||
<Switch
|
||||
checked={needsModelMapping}
|
||||
onCheckedChange={handleModelMappingChange}
|
||||
disabled={activeProviderType === "xai_oauth"}
|
||||
disabled={usesManagedOAuth}
|
||||
aria-label={t("claudeDesktop.modelMappingToggle", {
|
||||
defaultValue: "需要模型映射",
|
||||
})}
|
||||
|
||||
@@ -5,6 +5,22 @@ export const PROVIDER_TYPES = {
|
||||
XAI_OAUTH: "xai_oauth",
|
||||
} as const;
|
||||
|
||||
// 托管 OAuth 供应商类型:真实凭据由本地代理按请求注入,因此无论上游是否
|
||||
// 需要格式转换,都必须开启路由接管才能通过认证。新增此类预设时只需把
|
||||
// providerType 加进本数组,needsRouting 判定即自动覆盖,无需逐个特判。
|
||||
export const OAUTH_PROVIDER_TYPES: readonly string[] = [
|
||||
PROVIDER_TYPES.GITHUB_COPILOT,
|
||||
PROVIDER_TYPES.CODEX_OAUTH,
|
||||
PROVIDER_TYPES.XAI_OAUTH,
|
||||
];
|
||||
|
||||
/** 判断某 providerType 是否为托管 OAuth(凭据由代理注入、必须开启路由)。 */
|
||||
export function isOAuthProviderType(
|
||||
providerType: string | null | undefined,
|
||||
): boolean {
|
||||
return providerType != null && OAUTH_PROVIDER_TYPES.includes(providerType);
|
||||
}
|
||||
|
||||
// 用量脚本模板类型常量
|
||||
export const TEMPLATE_TYPES = {
|
||||
CUSTOM: "custom",
|
||||
|
||||
@@ -25,7 +25,11 @@ import {
|
||||
isCodexAnthropicWireApi,
|
||||
isCodexChatWireApi,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
import { supportsOfficialProxyTakeover } from "@/utils/providerCapabilities";
|
||||
import {
|
||||
providerNeedsRouting,
|
||||
supportsOfficialProxyTakeover,
|
||||
} from "@/utils/providerCapabilities";
|
||||
import { isOAuthProviderType } from "@/config/constants";
|
||||
|
||||
/**
|
||||
* Hook for managing provider actions (add, update, delete, switch)
|
||||
@@ -179,13 +183,29 @@ export function useProviderActions(
|
||||
),
|
||||
)));
|
||||
|
||||
// Determine why this provider requires the proxy
|
||||
// Claude Desktop 的路由开关就是代理进程本身;其余应用还必须开启当前
|
||||
// 应用的 takeover。不能只看全局进程,否则其它应用已接管时会漏判;也
|
||||
// 不能只看 takeover,否则 Desktop 在路由已运行时会持续误报。
|
||||
const routingReady =
|
||||
activeApp === "claude-desktop"
|
||||
? isProxyRunning === true
|
||||
: isProxyTakeover === true;
|
||||
|
||||
// Determine why this provider requires the proxy.
|
||||
let proxyRequiredReason: string | null = null;
|
||||
if (!isProxyRunning && provider.category !== "official") {
|
||||
if (!routingReady && providerNeedsRouting(activeApp, provider)) {
|
||||
if (isCopilotProvider) {
|
||||
proxyRequiredReason = t("notifications.proxyReasonCopilot", {
|
||||
defaultValue: "使用 GitHub Copilot 作为 Claude 供应商",
|
||||
});
|
||||
} else if (isOAuthProviderType(provider.meta?.providerType)) {
|
||||
// 托管 OAuth(codex_oauth / xai_oauth 等):凭据由本地代理注入,
|
||||
// 是否需路由由 providerType 权威决定,不看 apiFormat(后端亦无视,
|
||||
// 见 forwarder.rs)——避免 codex_oauth 被改成 anthropic / 旧数据缺省
|
||||
// apiFormat 时漏判。Claude 下的 Copilot 保留上面的专属文案。
|
||||
proxyRequiredReason = t("notifications.proxyReasonManagedOAuth", {
|
||||
defaultValue: "使用托管 OAuth 登录(令牌由本地路由注入)",
|
||||
});
|
||||
} else if (
|
||||
provider.meta?.apiFormat === "openai_chat" &&
|
||||
activeApp === "claude"
|
||||
@@ -227,6 +247,10 @@ export function useProviderActions(
|
||||
proxyRequiredReason = t("notifications.proxyReasonFullUrl", {
|
||||
defaultValue: "开启了完整 URL 连接模式",
|
||||
});
|
||||
} else {
|
||||
proxyRequiredReason = t("notifications.proxyReasonRoutingRequired", {
|
||||
defaultValue: "需要本地路由处理请求",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -259,6 +259,8 @@
|
||||
"proxyReasonOpenAIResponses": "uses OpenAI Responses API format",
|
||||
"proxyReasonAnthropicMessages": "uses Anthropic Messages API format",
|
||||
"proxyReasonFullUrl": "has full URL connection mode enabled",
|
||||
"proxyReasonManagedOAuth": "uses managed OAuth login (token injected by local routing)",
|
||||
"proxyReasonRoutingRequired": "requires local routing to process requests",
|
||||
"openAIFormatHint": "This provider uses OpenAI-compatible format and requires the routing service to be enabled",
|
||||
"copilotProxyHint": "GitHub Copilot as a Claude provider always requires local routing; routing automatically selects Chat Completions or Responses based on the current model.",
|
||||
"openLinkFailed": "Failed to open link",
|
||||
|
||||
@@ -259,6 +259,8 @@
|
||||
"proxyReasonOpenAIResponses": "OpenAI Responses API フォーマットを使用しており",
|
||||
"proxyReasonAnthropicMessages": "Anthropic Messages API フォーマットを使用しており",
|
||||
"proxyReasonFullUrl": "完全 URL 接続モードが有効になっており",
|
||||
"proxyReasonManagedOAuth": "マネージド OAuth ログイン(トークンはローカルルーティングが注入)を使用しており",
|
||||
"proxyReasonRoutingRequired": "リクエスト処理にローカルルーティングが必要であり",
|
||||
"openAIFormatHint": "このプロバイダーは OpenAI 互換フォーマットを使用しており、ルーティングサービスの有効化が必要です",
|
||||
"copilotProxyHint": "GitHub Copilot を Claude プロバイダーとして使用する場合、ローカルルーティングが常に必要です。ルーティングは現在のモデルに応じて Chat Completions または Responses を自動的に選択します。",
|
||||
"openLinkFailed": "リンクを開けませんでした",
|
||||
|
||||
@@ -259,6 +259,8 @@
|
||||
"proxyReasonOpenAIResponses": "使用 OpenAI Responses API 格式",
|
||||
"proxyReasonAnthropicMessages": "使用 Anthropic Messages API 格式",
|
||||
"proxyReasonFullUrl": "開啟了完整 URL 連線模式",
|
||||
"proxyReasonManagedOAuth": "使用託管 OAuth 登入(權杖由本機路由注入)",
|
||||
"proxyReasonRoutingRequired": "需要本機路由處理請求",
|
||||
"openAIFormatHint": "此供應商使用 OpenAI 相容格式,需要開啟路由服務才能正常使用",
|
||||
"copilotProxyHint": "GitHub Copilot 作為 Claude 供應商時始終需要本地路由;路由會根據目前模型自動選擇 Chat Completions 或 Responses。",
|
||||
"openLinkFailed": "連結開啟失敗",
|
||||
|
||||
@@ -259,6 +259,8 @@
|
||||
"proxyReasonOpenAIResponses": "使用 OpenAI Responses 接口格式",
|
||||
"proxyReasonAnthropicMessages": "使用 Anthropic Messages 接口格式",
|
||||
"proxyReasonFullUrl": "开启了完整 URL 连接模式",
|
||||
"proxyReasonManagedOAuth": "使用托管 OAuth 登录(令牌由本地路由注入)",
|
||||
"proxyReasonRoutingRequired": "需要本地路由处理请求",
|
||||
"openAIFormatHint": "此供应商使用 OpenAI 兼容格式,需要开启路由服务才能正常使用",
|
||||
"copilotProxyHint": "GitHub Copilot 作为 Claude 供应商时始终需要本地路由;路由会根据当前模型自动选择 Chat Completions 或 Responses。",
|
||||
"openLinkFailed": "链接打开失败",
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { Provider } from "@/types";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import { providerNeedsRouting } from "@/utils/providerCapabilities";
|
||||
|
||||
function mkProvider(overrides: Partial<Provider> = {}): 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>)?.config;
|
||||
return (
|
||||
typeof config === "string" &&
|
||||
(isCodexChatWireApi(extractCodexWireApi(config)) ||
|
||||
isCodexAnthropicWireApi(extractCodexWireApi(config)))
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user