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:
Jason
2026-07-21 10:04:55 +08:00
parent dbb5bd1537
commit 6428e993a3
12 changed files with 465 additions and 49 deletions
+16 -35
View File
@@ -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,16 +374,13 @@ export function ProviderCard({
</span>
)}
{appId === "claude" &&
provider.category !== "official" &&
provider.meta?.apiFormat &&
provider.meta.apiFormat !== "anthropic" && (
<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: "需要路由",
})}
</span>
)}
{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: "需要路由",
})}
</span>
)}
{codexNeedsRouting && (
<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">
@@ -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: "需要模型映射",
})}
+16
View File
@@ -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",
+27 -3
View File
@@ -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)) {
// 托管 OAuthcodex_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: "需要本地路由处理请求",
});
}
}
+2
View File
@@ -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",
+2
View File
@@ -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": "リンクを開けませんでした",
+2
View File
@@ -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": "連結開啟失敗",
+2
View File
@@ -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": "链接打开失败",
+207
View File
@@ -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.tomlchat_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("托管 OAuthproviderType 权威,与 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);
},
);
});
});
+65
View File
@@ -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",
+80
View File
@@ -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();