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
@@ -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();