From fe1b6886a837fa245344f79f236cb4358a28136f Mon Sep 17 00:00:00 2001 From: ankaifeng <2895443235@qq.com> Date: Mon, 7 Sep 2026 21:18:49 +0800 Subject: [PATCH 1/5] feat: add OrcaRouter PKCE and API key authentication --- .../src/content/docs/guides/providers.md | 33 ++- .../docs/reference/cli/providers-accounts.md | 7 +- .../docs/reference/configuration/providers.md | 7 + .../content/docs/zh-cn/guides/providers.md | 24 ++ gui/public/provider-icons/README.md | 11 +- gui/public/provider-icons/orcarouter.png | Bin 0 -> 74215 bytes gui/public/provider-icons/orcarouter.svg | 175 -------------- gui/src/components/AddProviderModal.tsx | 13 +- .../components/add-provider-oauth-pane.tsx | 7 + gui/src/components/use-add-provider-oauth.ts | 101 ++++++-- gui/src/pages/use-providers-oauth.ts | 50 ++-- gui/src/provider-icons.ts | 5 +- .../add-provider-oauth-url-leak.test.tsx | 163 ++++++++++++- scripts/test-layout/layout.json | 1 + src/codex/catalog/provider-fetch.ts | 3 +- src/oauth/index.ts | 42 +++- src/oauth/orcarouter.ts | 200 ++++++++++++++++ src/providers/registry.ts | 87 ++++--- tests/fixtures/test-layout-expected.json | 1 + tests/providers/orcarouter-provider.test.ts | 216 ++++++++++++++++++ .../provider-registry-parity.test.ts | 4 +- 21 files changed, 894 insertions(+), 256 deletions(-) create mode 100644 gui/public/provider-icons/orcarouter.png delete mode 100644 gui/public/provider-icons/orcarouter.svg create mode 100644 src/oauth/orcarouter.ts create mode 100644 tests/providers/orcarouter-provider.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 7ac2e4502b..f292b55120 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -95,9 +95,10 @@ The ChatGPT passthrough catalog also layers in the bare GPT-5.6 Sol/Terra/Luna s ## 2. Account login (OAuth) -Eight provider presets use OAuth login — plus GitHub Copilot via an experimental unofficial +Provider presets can use account login — including GitHub Copilot via an experimental unofficial device-flow bridge. opencodex stores their credentials in -`~/.opencodex/auth.json` and refreshes them automatically. `chatgpt` is also accepted by the login +`~/.opencodex/auth.json`; refreshable tokens are refreshed automatically, while durable keys are +reused until the provider revokes them. `chatgpt` is also accepted by the login CLI; it acquires a ChatGPT credential while creating a `forward`-mode provider entry. ```bash @@ -109,6 +110,7 @@ ocx login kiro # import kiro-cli credentials (or token fallback) ocx login google-antigravity ocx login cursor # standalone Cursor PKCE login ocx login command-code # Command Code browser OAuth (or import ~/.commandcode/auth.json) +ocx login orcarouter-oauth # OrcaRouter browser consent + PKCE ocx login github-copilot # GitHub device flow → Copilot token (Copilot Pro/Business) ocx login chatgpt # standalone ChatGPT OAuth login ocx logout @@ -123,6 +125,7 @@ ocx logout | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Initial login imports the installed, signed-in `kiro-cli` session (on Unix, install with `curl -fsSL https://cli.kiro.dev/install` | `bash`; on Windows PowerShell, use `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; then run `kiro-cli login`). **Add account** logs `kiro-cli` out, starts a fresh browser login that switches the account used by `kiro-cli`, and stores account-scoped profile metadata. Existing OpenCodex accounts are preserved, and cancellation or failure restores the previous `kiro-cli` session. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport with an opt-in HTTP/1.1 compatibility path, and account-filtered model discovery. | +| `orcarouter-oauth` | `openai-chat` | `https://api.orcarouter.ai/v1` | Browser consent and key exchange use `https://www.orcarouter.ai` with S256 PKCE. The returned user-owned `sk-orca-…` API key is stored in the existing credential store and reused until revoked. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. | Google Antigravity account and provider quota probes use fixed Google accounting endpoints, including the models fallback. They support transparent Fake-IP DNS for those destinations while retaining TLS verification, redirect rejection and private-address checks. A custom provider base URL changes model requests, not quota destinations; `NO_PROXY` continues to select the direct-route policy. @@ -352,6 +355,7 @@ free-experimentation model. | Vultr Serverless Inference | `https://api.vultrinference.com/v1` | | Baseten Model APIs | `https://inference.baseten.co/v1` | | Command Code | `https://api.commandcode.ai/provider/v1` | +| OrcaRouter | `https://api.orcarouter.ai/v1` | | Meta Model API | `https://api.meta.ai/v1` | | Meta Muse Code (CLI credential) | `https://api.meta.ai/v1` | | SambaNova Cloud | `https://api.sambanova.ai/v1` | @@ -465,6 +469,31 @@ preset (`commandcode`) uses the active configured Bearer key for chat requests; (`command-code`) uses the stored account bearer for authenticated discovery and chat. Create Provider-API keys at [Command Code Studio](https://commandcode.ai/studio/). +**OrcaRouter authentication and discovery.** Choose either `ocx login orcarouter-oauth` for +one-click browser authorization or `ocx login orcarouter` to paste an existing API key. The PKCE +flow starts a loopback listener first, sends a fresh S256 challenge and state to +`https://www.orcarouter.ai/auth`, exchanges the single-use code at +`https://www.orcarouter.ai/api/v1/auth/keys`, and stores the returned user-owned key in +`~/.opencodex/auth.json`. The manual-key preset continues to use the normal provider key store. +Both modes route to `https://api.orcarouter.ai/v1` and discover the public live catalog with +`capability=chat`; non-chat media/rerank rows are excluded, and reported input modalities control +whether Codex offers image attachments. Because the catalog itself is public, manual key setup +reports validation as unknown instead of accepting that response as proof that the key works. + +For a one-origin self-hosted deployment, set the shared origin before the first PKCE login; the saved +inference URL is derived from the same origin: + +```bash +ORCAROUTER_BASE_URL=https://router.example ocx login orcarouter-oauth +``` + +For a split self-hosted deployment, set `ORCAROUTER_API_BASE_URL` and +`ORCAROUTER_AUTH_BASE_URL` separately. + +The value must be an HTTPS origin (or HTTP loopback for local development) with no credentials, +query, or fragment. Re-run the login after a relay `401`; OrcaRouter keys are durable and do not +have a refresh-token grant. + **Meta Model API (`meta-model`).** Muse Spark on Meta's own OpenAI-compatible endpoint, served over `/v1/responses`. Create a key in [the Meta developer console](https://dev.meta.ai/docs/authentication) — Meta calls this diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 3d602799bb..b937606915 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -88,8 +88,9 @@ files or a raw network capture. ### `ocx login ` -Start the provider's registered login flow. OAuth providers open a browser and store auto-refreshed -credentials under `~/.opencodex/`; API-key login providers open their key dashboard, prompt for the +Start the provider's registered login flow. OAuth-style account providers open a browser and store +credentials under `~/.opencodex/` (refreshable tokens rotate automatically; durable key grants such +as OrcaRouter are reused); API-key login providers open their key dashboard, prompt for the key, validate it when possible, and save the resulting provider config. The command prints the currently accepted OAuth and API-key provider ids when the name is missing or unknown. @@ -101,6 +102,8 @@ account pool (Reauthenticate) or the headless `ocx account reauth` flow instead. ```bash ocx login xai ocx login anthropic +ocx login orcarouter-oauth # browser consent + S256 PKCE +ocx login orcarouter # paste an existing API key ``` OAuth reauthentication preserves operator settings such as model selections, pricing overrides, diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index b0792b7367..c00be9e19e 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -380,6 +380,13 @@ API-key providers may hold a literal key or an environment reference. OAuth prov credential store populated by `ocx login`; subscription-backed Claude Code launch behavior is configured under [`claudeCode.authMode`](/reference/configuration/server/#claude-code). +OrcaRouter exposes both forms explicitly: `orcarouter` is the manual API-key provider and +`orcarouter-oauth` runs browser consent with S256 PKCE, then stores the returned durable API key as +an account credential. The public defaults intentionally split authentication +(`https://www.orcarouter.ai`) from inference (`https://api.orcarouter.ai/v1`). Set +`ORCAROUTER_BASE_URL` before the first account login for a one-origin self-hosted deployment, or use +`ORCAROUTER_AUTH_BASE_URL` and `ORCAROUTER_API_BASE_URL` for separate origins. + ## Provider diagnostic outbound safety Dashboard connection tests and live model discovery use a bounded GET-only transport. Without an diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 314894c3d1..34d52429b2 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -88,6 +88,7 @@ ocx login kiro # 导入 kiro-cli 凭据(支持令牌回退) ocx login google-antigravity ocx login cursor # 独立的 Cursor PKCE 登录 ocx login command-code # Command Code 浏览器 OAuth(或导入 ~/.commandcode/auth.json) +ocx login orcarouter-oauth # OrcaRouter 浏览器授权 + PKCE ocx login github-copilot # GitHub 设备流 → Copilot 令牌(Copilot Pro/Business) ocx login chatgpt # 独立的 ChatGPT OAuth 登录 ocx logout @@ -102,6 +103,7 @@ ocx logout | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 首次登录会导入已安装并已登录的 Kiro CLI 会话(Unix 使用 `curl -fsSL https://cli.kiro.dev/install` | `bash`;Windows PowerShell 使用 `irm 'https://cli.kiro.dev/install.ps1'` | `iex`;然后运行 `kiro-cli login`)。**添加账户**会先退出 `kiro-cli`,再启动新的浏览器登录,从而切换 `kiro-cli` 自身使用的账户,并保存账户范围的配置文件元数据。现有 OpenCodex 账户会保留;如果取消或失败,则恢复之前的 `kiro-cli` 会话。 | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。实时发现调用已认证的 CCA `v1internal:fetchAvailableModels` 端点,并仅发布当前登录账户可用的 agent 模型;维护中的目录仍作为回退。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 实验性 PKCE 登录、带可选 HTTP/1.1 兼容路径的 HTTP/2 传输,以及按账号筛选的模型发现。 | +| `orcarouter-oauth` | `openai-chat` | `https://api.orcarouter.ai/v1` | 浏览器授权与密钥交换走 `https://www.orcarouter.ai` + S256 PKCE。交换结果是用户自己的普通 `sk-orca-…` API key,保存在现有凭据库中并持续复用,直到被撤销。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 实验性。GitHub 设备流 + `copilot_internal` 交换(VS Code OAuth 客户端)。需要有效的 Copilot 订阅;不是官方第三方 API。 | Google Antigravity 账户和提供方的配额查询(包括模型列表回退)使用固定的 Google 计量端点。这些目标支持透明 Fake-IP DNS,同时保留 TLS 验证、重定向拒绝和私有地址检查。自定义 base URL 仅改变模型请求,不改变配额目标;`NO_PROXY` 仍使用直连策略。 @@ -263,6 +265,28 @@ inference key 可从 [Vultr Console](https://my.vultr.com) 的订阅概览复制 `~/.commandcode/auth.json` 导入本地 CLI 凭据);模型目录按账户隔离,并在登录后从经过认证的发现 端点获取。聊天请求使用已配置的 bearer 密钥。密钥可在 [Command Code Studio](https://commandcode.ai/studio/) 创建。 +**OrcaRouter 认证与模型发现:**可用 `ocx login orcarouter-oauth` 走浏览器一键授权, +也可用 `ocx login orcarouter` 粘贴已有 API key。PKCE 流程会先监听本机回环端口,为每次登录 +生成新的 S256 challenge 和 state;授权页使用 `https://www.orcarouter.ai/auth`,并通过 +`https://www.orcarouter.ai/api/v1/auth/keys` 交换一次性 code,再把返回的 +用户自有 key 保存到 `~/.opencodex/auth.json`;手填 key 仍使用项目原有的 provider key 存储。 +两种模式都访问 `https://api.orcarouter.ai/v1`,并使用 `capability=chat` 实时发现模型;图片生成、 +视频和 rerank 条目会被排除,模型返回的 input modalities 决定 Codex 是否允许图片附件。 +由于模型目录本身是公开的,手填 key 时会诚实显示“无法验证”,不会把公开目录的 200 响应误当成 +密钥有效证明。 + +单域名自托管环境可在第一次 PKCE 登录前设置统一 origin;推理地址会从同一个 origin 派生: + +```bash +ORCAROUTER_BASE_URL=https://router.example ocx login orcarouter-oauth +``` + +若自托管环境也分离登录域名与 API 域名,可分别设置 `ORCAROUTER_AUTH_BASE_URL` 和 +`ORCAROUTER_API_BASE_URL`。 + +该值必须是 HTTPS origin(本地开发可使用 HTTP loopback),且不能包含用户名密码、query 或 fragment。 +若 relay 返回 `401`,重新运行登录即可;OrcaRouter 签发的是长期 API key,不存在 refresh-token grant。 + **Command Code 配额:**仪表盘和 `ocx account refresh` 会在规范主机 `https://api.commandcode.ai` 上探测 `/alpha/billing/credits` 窗口(5 小时和每周)。OAuth 预设 (`command-code`) 使用已保存的账户 bearer;Provider-API 密钥预设 (`commandcode`) 使用当前配置的有效密钥。用户改写后的仿冒 base URL 不会被探测。当 Command Code 同时返回周期消耗时,剩余的 monthly / purchased / free credits 会显示为 USD 窗口。 **SambaNova Cloud 发现:**该预设从固定 API 主机读取 SambaNova Cloud 的公开 `/v1/models` 列表,保留提供商原生 diff --git a/gui/public/provider-icons/README.md b/gui/public/provider-icons/README.md index f5e64b568b..f68818b7e8 100644 --- a/gui/public/provider-icons/README.md +++ b/gui/public/provider-icons/README.md @@ -183,11 +183,18 @@ follow every upscaled pixel edge. - `cerebras.svg`, `novita.svg`, `siliconflow.svg`, `deepinfra.svg` — single-ink. - `baseten.svg`, `hyperbolic.svg`, `sambanova.svg`, `umans.svg`, `venice.svg`, - `vultr.svg`, `bizrouter.svg`, `orcarouter.svg` — colour-layered. + `vultr.svg`, `bizrouter.svg` — colour-layered. - `nous.svg` — traced from `nousresearch.com/apple-touch-icon.png` (180x180). This is the Nous Research company mark, distinct from `hermes-agent.svg`, which is the Hermes product's own icon. Attributing one to the other would be wrong even - though the same organization ships both. +though the same organization ships both. + +Published as a square raster mark and committed unmodified: + +- `orcarouter.png` — fetched 2026-09-07 from + `https://www.orcarouter.ai/orca-logo-classic.png`, OrcaRouter's official classic + logo (535x533 indexed PNG; SHA-256 + `96ed7062ed766e27bb6002f2831d6b54ec87f2371d4c4ab3bab27823a0b0fb07`). Found on a docs subdomain after the vendor's marketing site offered only a wordmark: diff --git a/gui/public/provider-icons/orcarouter.png b/gui/public/provider-icons/orcarouter.png new file mode 100644 index 0000000000000000000000000000000000000000..fcf66b698de9613492ecdcb06be88a7a8dacf6d6 GIT binary patch literal 74215 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 p_UcHjK? \ No newline at end of file diff --git a/gui/src/components/AddProviderModal.tsx b/gui/src/components/AddProviderModal.tsx index 835a84b22b..09f4fcb1d6 100644 --- a/gui/src/components/AddProviderModal.tsx +++ b/gui/src/components/AddProviderModal.tsx @@ -197,7 +197,11 @@ export default function AddProviderModal({ } }; - const { loginOAuth, submitManualCode: submitManualCodeApi } = useAddProviderOAuth({ apiBase, t, aliveRef, onAdded }); + const { + cancelLoginOAuth, + loginOAuth, + submitManualCode: submitManualCodeApi, + } = useAddProviderOAuth({ apiBase, t, aliveRef, onAdded }); const oauthSetters = { setOauthBusy: (busy: boolean) => dispatch({ type: "set-oauth-busy", busy }), @@ -287,12 +291,17 @@ export default function AddProviderModal({ manualCodeMsg={manualCodeMsg} manualCodeOk={manualCodeOk} onRequestLogin={requestLoginOAuth} + onCancelLogin={providerId => { void cancelLoginOAuth(providerId, oauthSetters, preset.label); }} onUseApiKeyInstead={() => { + if (oauthBusy && preset.oauthProvider) void cancelLoginOAuth(preset.oauthProvider, oauthSetters, preset.label); dispatch({ type: "use-api-key-instead", form: { ...form, authMode: "key" } }); }} onManualCodeChange={code => dispatch({ type: "set-manual-code", code })} onSubmitManualCode={providerId => { void submitManualCode(providerId); }} - onBack={() => dispatch({ type: "back" })} + onBack={() => { + if (oauthBusy && preset.oauthProvider) void cancelLoginOAuth(preset.oauthProvider, oauthSetters, preset.label); + dispatch({ type: "back" }); + }} /> ) : ( void; + onCancelLogin: (providerId: string) => void; onUseApiKeyInstead: () => void; onManualCodeChange: (value: string) => void; onSubmitManualCode: (providerId: string) => void; @@ -83,6 +85,11 @@ export function AddProviderOAuthPane({ {t("modal.useApiKeyInstead")}

diff --git a/gui/src/components/use-add-provider-oauth.ts b/gui/src/components/use-add-provider-oauth.ts index 5b93ad3f7e..bb7166ba00 100644 --- a/gui/src/components/use-add-provider-oauth.ts +++ b/gui/src/components/use-add-provider-oauth.ts @@ -1,10 +1,20 @@ -import { useCallback } from "react"; +import { useCallback, useEffect, useRef } from "react"; import type { TFn } from "../i18n/shared"; import { readJsonIfOk } from "../fetch-json"; import { openBrowserRequestField } from "../oauth-open-browser-pref"; export const OAUTH_LOGIN_POLL_INTERVAL_MS = 2_000; +type OAuthLoginSetters = { + setOauthBusy: (v: boolean) => void; + setOauthMsg: (v: string) => void; + setOauthMsgTone: (v: "ok" | "warn") => void; + setOauthUrl: (url: string, providerId: string, deviceCode?: string, instructions?: string) => void; + setManualCode: (v: string) => void; + setManualCodeMsg: (v: string) => void; + setManualCodeOk: (v: boolean) => void; +}; + export function useAddProviderOAuth({ apiBase, t, @@ -16,19 +26,63 @@ export function useAddProviderOAuth({ aliveRef: React.MutableRefObject; onAdded: (name: string) => void; }) { + const loginGenerationRef = useRef(new Map()); + const activeProvidersRef = useRef(new Set()); + + const bumpLoginGeneration = useCallback((providerId: string) => { + const generation = (loginGenerationRef.current.get(providerId) ?? 0) + 1; + loginGenerationRef.current.set(providerId, generation); + return generation; + }, []); + + const cancelServerLogin = useCallback(async (providerId: string) => { + await fetch(`${apiBase}/api/oauth/login/cancel`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: providerId }), + keepalive: true, + }).catch(() => undefined); + }, [apiBase]); + + useEffect(() => { + const cancelActiveLogins = () => { + const providers = [...activeProvidersRef.current]; + activeProvidersRef.current.clear(); + for (const providerId of providers) { + bumpLoginGeneration(providerId); + void cancelServerLogin(providerId); + } + }; + window.addEventListener("pagehide", cancelActiveLogins); + return () => { + window.removeEventListener("pagehide", cancelActiveLogins); + cancelActiveLogins(); + }; + }, [bumpLoginGeneration, cancelServerLogin]); + + const cancelLoginOAuth = useCallback(async ( + providerId: string, + setters: OAuthLoginSetters, + providerLabel = providerId, + ) => { + const generation = bumpLoginGeneration(providerId); + activeProvidersRef.current.delete(providerId); + await cancelServerLogin(providerId); + if (!aliveRef.current || loginGenerationRef.current.get(providerId) !== generation) return; + setters.setOauthBusy(false); + setters.setOauthUrl("", providerId); + setters.setOauthMsgTone("warn"); + setters.setOauthMsg(t("prov.loginCancelled", { provider: providerLabel })); + }, [aliveRef, bumpLoginGeneration, cancelServerLogin, t]); + const loginOAuth = useCallback(async ( providerId: string, - setters: { - setOauthBusy: (v: boolean) => void; - setOauthMsg: (v: string) => void; - setOauthMsgTone: (v: "ok" | "warn") => void; - setOauthUrl: (url: string, providerId: string, deviceCode?: string, instructions?: string) => void; - setManualCode: (v: string) => void; - setManualCodeMsg: (v: string) => void; - setManualCodeOk: (v: boolean) => void; - }, + setters: OAuthLoginSetters, ) => { const { setOauthBusy, setOauthMsg, setOauthMsgTone, setOauthUrl, setManualCode, setManualCodeMsg, setManualCodeOk } = setters; + const generation = bumpLoginGeneration(providerId); + const isCurrent = () => loginGenerationRef.current.get(providerId) === generation; + activeProvidersRef.current.add(providerId); setOauthBusy(true); setOauthMsg(""); setOauthMsgTone("ok"); @@ -42,8 +96,9 @@ export function useAddProviderOAuth({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider: providerId, ...openBrowserRequestField() }), }); - if (!aliveRef.current) return; + if (!aliveRef.current || !isCurrent()) return; if (!res.ok) { + activeProvidersRef.current.delete(providerId); const data = await res.json().catch(() => ({})) as { error?: string }; setOauthMsgTone("warn"); setOauthMsg(data.error === "unknown oauth provider" @@ -60,28 +115,38 @@ export function useAddProviderOAuth({ else setOauthMsg(data.instructions || t("modal.loggingIn")); for (let i = 0; i < 100; i++) { await new Promise(r => setTimeout(r, OAUTH_LOGIN_POLL_INTERVAL_MS)); - if (!aliveRef.current) return; + if (!aliveRef.current || !isCurrent()) return; const sRes = await fetch(`${apiBase}/api/oauth/status?provider=${providerId}`).catch(() => null); const s = sRes ? await readJsonIfOk<{ loggedIn?: boolean; error?: string }>(sRes) : null; - if (!aliveRef.current) return; + if (!aliveRef.current || !isCurrent()) return; if (s?.error) { + activeProvidersRef.current.delete(providerId); setOauthMsgTone("warn"); setOauthMsg(t("modal.loginError", { error: s.error })); return; } - if (s?.loggedIn) { onAdded(providerId); return; } + if (s?.loggedIn) { + activeProvidersRef.current.delete(providerId); + onAdded(providerId); + return; + } } + await cancelServerLogin(providerId); + if (!aliveRef.current || !isCurrent()) return; + activeProvidersRef.current.delete(providerId); setOauthMsgTone("warn"); setOauthMsg(t("modal.loginTimeout")); } catch { - if (aliveRef.current) { + if (isCurrent()) await cancelServerLogin(providerId); + activeProvidersRef.current.delete(providerId); + if (aliveRef.current && isCurrent()) { setOauthMsgTone("warn"); setOauthMsg(t("modal.networkError")); } } finally { - if (aliveRef.current) setOauthBusy(false); + if (aliveRef.current && isCurrent()) setOauthBusy(false); } - }, [aliveRef, apiBase, onAdded, t]); + }, [aliveRef, apiBase, bumpLoginGeneration, cancelServerLogin, onAdded, t]); const submitManualCode = useCallback(async ( providerId: string, @@ -125,5 +190,5 @@ export function useAddProviderOAuth({ } }, [aliveRef, apiBase, t]); - return { loginOAuth, submitManualCode }; + return { cancelLoginOAuth, loginOAuth, submitManualCode }; } diff --git a/gui/src/pages/use-providers-oauth.ts b/gui/src/pages/use-providers-oauth.ts index 3440939ef1..700f5e54ad 100644 --- a/gui/src/pages/use-providers-oauth.ts +++ b/gui/src/pages/use-providers-oauth.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import type { TFn } from "../i18n/shared"; import { readJsonIfOk } from "../fetch-json"; import { openBrowserRequestField } from "../oauth-open-browser-pref"; @@ -45,6 +45,7 @@ export function useProvidersOAuth({ }) { const oauthLoginGenerationRef = useRef | null>(null); if (oauthLoginGenerationRef.current === null) oauthLoginGenerationRef.current = new Map(); + const activeLoginGenerationsRef = useRef(new Map()); const bumpLoginGeneration = useCallback((provider: string) => { const gen = (oauthLoginGenerationRef.current!.get(provider) ?? 0) + 1; @@ -52,25 +53,46 @@ export function useProvidersOAuth({ return gen; }, []); + const cancelServerLogin = useCallback(async (provider: string) => { + await fetch(`${apiBase}/api/oauth/login/cancel`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider }), + keepalive: true, + }).catch(() => undefined); + }, [apiBase]); + + useEffect(() => { + const cancelActiveLogins = () => { + const active = [...activeLoginGenerationsRef.current]; + activeLoginGenerationsRef.current.clear(); + for (const [provider, generation] of active) { + if (oauthLoginGenerationRef.current!.get(provider) === generation) bumpLoginGeneration(provider); + void cancelServerLogin(provider); + } + }; + window.addEventListener("pagehide", cancelActiveLogins); + return () => { + window.removeEventListener("pagehide", cancelActiveLogins); + cancelActiveLogins(); + }; + }, [bumpLoginGeneration, cancelServerLogin]); + const cancelLoginOAuth = useCallback(async (provider: string) => { const gen = bumpLoginGeneration(provider); - try { - await fetch(`${apiBase}/api/oauth/login/cancel`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider }), - }); - } catch { /* ignore */ } + activeLoginGenerationsRef.current.delete(provider); + await cancelServerLogin(provider); if (!aliveRef.current) return; if (oauthLoginGenerationRef.current!.get(provider) === gen) { setBusy(current => current === provider ? null : current); setLoginInfo(current => current?.provider === provider ? null : current); } notify(t("prov.loginCancelled", { provider: oauthLabel(provider) }), false); - }, [aliveRef, apiBase, bumpLoginGeneration, notify, setBusy, setLoginInfo, t]); + }, [aliveRef, bumpLoginGeneration, cancelServerLogin, notify, setBusy, setLoginInfo, t]); const loginOAuth = async (provider: string, addAccount = false, accountId?: string) => { const generation = bumpLoginGeneration(provider); + activeLoginGenerationsRef.current.set(provider, generation); const reauthTargetId = accountId?.trim() || undefined; setBusy(provider); setStatus(""); @@ -175,19 +197,19 @@ export function useProvidersOAuth({ } } if (!finished && oauthLoginGenerationRef.current!.get(provider) === generation && aliveRef.current) { - await fetch(`${apiBase}/api/oauth/login/cancel`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider }), - }).catch(() => {}); + await cancelServerLogin(provider); notify(t("prov.loginTimeout", { provider: oauthLabel(provider) }), false); setLoginInfo(null); } } catch { if (oauthLoginGenerationRef.current!.get(provider) === generation) { + await cancelServerLogin(provider); notify(t("prov.loginRequestFail", { provider: oauthLabel(provider) }), false); } } finally { + if (activeLoginGenerationsRef.current.get(provider) === generation) { + activeLoginGenerationsRef.current.delete(provider); + } if (aliveRef.current && oauthLoginGenerationRef.current!.get(provider) === generation) setBusy(null); } }; diff --git a/gui/src/provider-icons.ts b/gui/src/provider-icons.ts index b99cbacd8c..11d28b7347 100644 --- a/gui/src/provider-icons.ts +++ b/gui/src/provider-icons.ts @@ -59,7 +59,8 @@ const PROVIDER_ICON_ALIASES: Record = { neuralwatt: "neuralwatt.svg", nous: "nous.svg", novita: "novita.svg", - orcarouter: "orcarouter.svg", + orcarouter: "orcarouter.png", + "orcarouter-oauth": "orcarouter.png", parallel: "parallel.svg", sambanova: "sambanova.svg", scaleway: "scaleway.svg", @@ -121,6 +122,8 @@ const PROVIDER_DISPLAY_NAMES: Record = { "opencode-go": "OpenCode Go", "opencode-free": "OpenCode Free", "opencode-zen": "OpenCode Zen", + orcarouter: "OrcaRouter - API", + "orcarouter-oauth": "OrcaRouter - Auth", mistral: "Mistral", groq: "Groq", "meta-model": "Meta Model API", diff --git a/gui/tests/add-provider-oauth-url-leak.test.tsx b/gui/tests/add-provider-oauth-url-leak.test.tsx index 8265f64071..46e9f63843 100644 --- a/gui/tests/add-provider-oauth-url-leak.test.tsx +++ b/gui/tests/add-provider-oauth-url-leak.test.tsx @@ -1,10 +1,13 @@ import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; import { Window } from "happy-dom"; -import { act } from "react"; +import { act, useEffect, useRef, useState } from "react"; import type { Root } from "react-dom/client"; import { LanguageProvider } from "../src/i18n/provider"; +import { useT } from "../src/i18n/shared"; import AddProviderModal from "../src/components/AddProviderModal"; import { OAUTH_LOGIN_POLL_INTERVAL_MS } from "../src/components/use-add-provider-oauth"; +import { useProvidersOAuth } from "../src/pages/use-providers-oauth"; +import type { OAuthAccount, OAuthStatus } from "../src/pages/providers-shared"; /** * The add-provider OAuth pane renders the authorization URL so a user whose @@ -25,6 +28,7 @@ let root: Root | null = null; let originalFetch: typeof globalThis.fetch; let pendingLogins: Array<(url: string) => void> = []; let oauthStatus: { loggedIn: boolean; error?: string } = { loggedIn: false }; +let cancelledProviders: string[] = []; const PRESETS = [ { id: "claude", label: "Claude", adapter: "anthropic", baseUrl: "https://api.anthropic.com", auth: "oauth", oauthProvider: "claude" }, @@ -46,6 +50,7 @@ beforeEach(() => { pendingLogins = []; oauthStatus = { loggedIn: false }; + cancelledProviders = []; Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, init?: RequestInit) => { @@ -59,6 +64,11 @@ beforeEach(() => { pendingLogins.push((authUrl: string) => resolve(Response.json({ url: authUrl }))); }); } + if (url.pathname === "/api/oauth/login/cancel" && (init?.method ?? "GET") === "POST") { + const body = JSON.parse(String(init?.body ?? "{}")) as { provider?: string }; + if (body.provider) cancelledProviders.push(body.provider); + return Response.json({ ok: true, cancelled: true }); + } if (url.pathname === "/api/oauth/status") return Response.json(oauthStatus); return Response.json({}); }, @@ -94,6 +104,56 @@ async function mountModal(onAdded: (name: string) => void = () => {}) { await act(async () => { await new Promise((r) => setTimeout(r, 40)); }); } +function ProvidersOAuthHarness() { + const t = useT(); + const aliveRef = useRef(true); + const startedRef = useRef(false); + const [accountSets, setAccountSets] = useState>({}); + const [, setBusy] = useState(null); + const [, setStatus] = useState(""); + const [, setLoginInfo] = useState<{ provider: string; url?: string; instructions?: string; deviceCode?: string } | null>(null); + const [, setOauthStatus] = useState>({}); + + useEffect(() => () => { aliveRef.current = false; }, []); + const { loginOAuth } = useProvidersOAuth({ + apiBase: "", + t, + aliveRef, + accountSets, + setAccountSets, + setBusy, + setStatus, + setLoginInfo, + setOauthStatus, + notify: () => {}, + fetchConfig: async () => {}, + fetchOauth: async () => {}, + fetchAccountSets: async () => undefined, + fetchProviderQuotas: async () => {}, + bumpModelsRefresh: () => {}, + }); + + useEffect(() => { + if (startedRef.current) return; + startedRef.current = true; + void loginOAuth("orcarouter-oauth"); + }, [loginOAuth]); + return null; +} + +async function mountProvidersOAuthHarness() { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( + + + , + ); + await new Promise((r) => setTimeout(r, 20)); + }); +} + function clickByText(fragment: string) { const el = Array.from(host.querySelectorAll("button, [role='button']")).find((node) => (node.textContent ?? "").includes(fragment), @@ -141,6 +201,107 @@ test("the in-flight provider's own authorization URL does render", async () => { expect(host.querySelector(".login-url-block-text")?.textContent).toBe(A_URL); }); +test("unmounting the add-provider modal cancels its in-flight OAuth login", async () => { + await mountModal(); + + clickByText("Claude"); + await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); + clickByText("Log in with Claude"); + await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); + await act(async () => { + pendingLogins.shift()?.(A_URL); + await new Promise((r) => setTimeout(r, 20)); + }); + + const current = root!; + await act(async () => { current.unmount(); }); + root = null; + await new Promise((r) => setTimeout(r, 20)); + + expect(cancelledProviders).toEqual(["claude"]); +}); + +test("leaving the providers page cancels its in-flight account login", async () => { + await mountProvidersOAuthHarness(); + await act(async () => { + pendingLogins.shift()?.(A_URL); + await new Promise((r) => setTimeout(r, 20)); + }); + + const current = root!; + await act(async () => { current.unmount(); }); + root = null; + await new Promise((r) => setTimeout(r, 20)); + + expect(cancelledProviders).toEqual(["orcarouter-oauth"]); +}); + +test("closing or reloading the management page cancels its in-flight account login", async () => { + await mountProvidersOAuthHarness(); + await act(async () => { + pendingLogins.shift()?.(A_URL); + await new Promise((r) => setTimeout(r, 20)); + }); + + win.dispatchEvent(new win.Event("pagehide")); + await new Promise((r) => setTimeout(r, 20)); + + expect(cancelledProviders).toEqual(["orcarouter-oauth"]); +}); + +test("the add-provider OAuth pane can cancel an in-flight login", async () => { + await mountModal(); + + clickByText("Claude"); + await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); + clickByText("Log in with Claude"); + await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); + await act(async () => { + pendingLogins.shift()?.(A_URL); + await new Promise((r) => setTimeout(r, 20)); + }); + + await act(async () => { + clickByText("Cancel"); + await new Promise((r) => setTimeout(r, 20)); + }); + + expect(cancelledProviders).toEqual(["claude"]); + expect(host.textContent).toContain("Claude login cancelled"); +}); + +test("timing out an add-provider OAuth login releases the server login", async () => { + const realSetTimeout = globalThis.setTimeout; + const timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation((( + callback: (...args: unknown[]) => void, + delay?: number, + ...args: unknown[] + ) => { + if (delay === OAUTH_LOGIN_POLL_INTERVAL_MS) { + queueMicrotask(() => callback(...args)); + return 0 as unknown as ReturnType; + } + return realSetTimeout(callback, delay, ...args); + }) as typeof setTimeout); + + try { + await mountModal(); + clickByText("Claude"); + await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); + clickByText("Log in with Claude"); + await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); + await act(async () => { + pendingLogins.shift()?.(A_URL); + await new Promise((r) => setTimeout(r, 40)); + }); + + expect(cancelledProviders).toEqual(["claude"]); + expect(host.textContent).toContain("timed out"); + } finally { + timeoutSpy.mockRestore(); + } +}); + test("a late URL for an abandoned provider cannot overwrite the one already shown", async () => { await mountModal(); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 60e0f22a0d..679d3ae9bd 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -924,6 +924,7 @@ "opencode-go-session-header.test.ts": "providers", "opencode-zen-deepseek-reasoning.test.ts": "providers", "opencode-zen-rate-limit.test.ts": "providers", + "orcarouter-provider.test.ts": "providers", "openrouter-provider-routing.test.ts": "providers", "optional-shutdown-hooks.test.ts": "lib", "outbound-body-guard.test.ts": "server", diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 58e21cf82c..b90e0b12cf 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1335,7 +1335,8 @@ function modelInputModalities( item.input_modalities ?? item.modalities ?? metadata?.input_modalities - ?? capabilityRecord?.input_modalities, + ?? capabilityRecord?.input_modalities + ?? plainRecord(item.architecture)?.input_modalities, 8, 24, )?.filter(value => ( diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 4edd6375c8..b31960d3e6 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -41,6 +41,7 @@ import { loginCursor, refreshCursorToken } from "./cursor"; import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot"; import { loginCommandCode, refreshCommandCodeToken } from "./command-code"; import { loginMetaMuse, refreshMetaMuseToken } from "./meta-muse"; +import { loginOrcaRouter, orcaRouterInferenceBaseUrl, refreshOrcaRouterKey } from "./orcarouter"; import { ANTIGRAVITY_REQUEST_UA } from "../adapters/google-antigravity-wire"; import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive"; import { apiKeyPoolEntryId, sanitizeApiKeyValue } from "../providers/api-keys"; @@ -180,7 +181,7 @@ export interface LoginFlowLifecycle { } interface OAuthProviderDef { - login(ctrl: OAuthController, opts?: LoginOpts): Promise; + login(ctrl: OAuthController, opts?: LoginOpts, providerConfig?: OcxProviderConfig): Promise; refresh( refreshToken: string, signal?: AbortSignal, @@ -188,6 +189,8 @@ interface OAuthProviderDef { ): Promise; /** provider entry written into config.json on first login. */ providerConfig: OcxProviderConfig; + /** Resolve login-owned config from the latest disk state (for configurable OAuth origins). */ + resolveProviderConfig?: (config: OcxConfig) => OcxProviderConfig; defaultModel: string; /** * Built-in proactive-refresh policy, risk-tiered by the provider's ToS exposure (devlog @@ -218,6 +221,27 @@ export const OAUTH_PROVIDERS: Record = { defaultModel: oauthDefaultModel("command-code"), defaultRefreshPolicy: "disabled", }, + "orcarouter-oauth": { + login: (ctrl, _opts, providerConfig) => loginOrcaRouter(ctrl, { + baseUrl: process.env.ORCAROUTER_API_BASE_URL + ?? process.env.ORCAROUTER_BASE_URL + ?? providerConfig?.baseUrl, + authBaseUrl: process.env.ORCAROUTER_AUTH_BASE_URL, + }), + refresh: refreshOrcaRouterKey, + providerConfig: oauthConfig("orcarouter-oauth"), + resolveProviderConfig: config => ({ + ...oauthConfig("orcarouter-oauth"), + baseUrl: orcaRouterInferenceBaseUrl( + process.env.ORCAROUTER_API_BASE_URL + ?? process.env.ORCAROUTER_BASE_URL + ?? config.providers["orcarouter-oauth"]?.baseUrl, + ), + }), + defaultModel: oauthDefaultModel("orcarouter-oauth"), + // The credential is a durable API key. There is no refresh endpoint. + defaultRefreshPolicy: "disabled", + }, xai: { // forceLogin skips the local grok-cli import so a SECOND account can be chosen in the browser. login: (ctrl, opts) => loginXai(ctrl, { importLocal: opts?.forceLogin ? "off" : "fallback" }), @@ -1442,10 +1466,11 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void { const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider); if (namespaceCollision) throw new Error(namespaceCollision); const existing = config.providers[provider]; + const providerConfig = def.resolveProviderConfig?.(config) ?? def.providerConfig; // Clone operator state, including xAI wire choices and their migration version. - const next: OcxProviderConfig = structuredClone(existing ?? def.providerConfig); + const next: OcxProviderConfig = structuredClone(existing ?? providerConfig); for (const field of OAUTH_LOGIN_OWNED_PROVIDER_FIELDS) { - const value = def.providerConfig[field]; + const value = providerConfig[field]; if (value === undefined) delete next[field]; else next[field] = structuredClone(value) as never; } @@ -1454,7 +1479,7 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void { if (next.googleMode === "cloud-code-assist") delete next.project; // Login used to rebuild the whole row from the preset, so catalog data refreshed // immediately. Keep that timing without overwriting unrelated operator-owned fields. - applyOAuthPresetCatalog(next, def.providerConfig); + applyOAuthPresetCatalog(next, providerConfig); // The original Command Code seed was an implementation-owned static catalog, not an // operator opt-out. Promote that exact legacy shape when OAuth login refreshes the row. if (provider === "command-code" && existing && isLegacyCommandCodeStaticCatalog(existing)) { @@ -1532,8 +1557,8 @@ export async function runLogin( if (!def) throw new UnsupportedOAuthProviderError(provider); const loadLatestConfig = deps.loadConfig ?? loadConfig; const saveLatestConfig = deps.saveConfig ?? saveConfig; - if (provider !== "chatgpt") { - const preflightConfig = loadLatestConfig(); + const preflightConfig = provider !== "chatgpt" ? loadLatestConfig() : undefined; + if (preflightConfig) { const namespaceCollision = codexAccountNamespaceProviderCollisionError( preflightConfig.codexAccountNamespaces, provider, @@ -1546,7 +1571,10 @@ export async function runLogin( const previousKiroAccounts = shouldRollbackKiroAccounts ? getAccountSet(provider) : undefined; const previousKiroActiveId = previousKiroAccounts?.activeAccountId; const previousKiroAccountIds = new Set(previousKiroAccounts?.accounts.map(account => account.id) ?? []); - const rawCred = await def.login(ctrl, opts); + const loginProviderConfig = preflightConfig + ? (def.resolveProviderConfig?.(preflightConfig) ?? preflightConfig.providers[provider] ?? def.providerConfig) + : def.providerConfig; + const rawCred = await def.login(ctrl, opts, loginProviderConfig); const cred: OAuthCredentials = rawCred.source ? rawCred : { ...rawCred, source: "oauth" }; const settleKiroTransaction = deps.settleKiroLoginTransaction ?? settleKiroLoginTransaction; try { diff --git a/src/oauth/orcarouter.ts b/src/oauth/orcarouter.ts new file mode 100644 index 0000000000..7663df2c30 --- /dev/null +++ b/src/oauth/orcarouter.ts @@ -0,0 +1,200 @@ +/** OrcaRouter browser authorization: OAuth-style consent + PKCE, yielding a durable API key. */ +import { OAuthCallbackFlow, type OAuthCallbackFlowOptions } from "./callback-server"; +import { generatePKCE } from "./pkce"; +import type { OAuthController, OAuthCredentials } from "./types"; + +export const ORCAROUTER_DEFAULT_API_BASE_URL = "https://api.orcarouter.ai"; +export const ORCAROUTER_DEFAULT_AUTH_BASE_URL = "https://www.orcarouter.ai"; +/** Backwards-compatible name for the inference/API origin. */ +export const ORCAROUTER_DEFAULT_BASE_URL = ORCAROUTER_DEFAULT_API_BASE_URL; +const ORCAROUTER_CALLBACK_PORT = 51733; +const ORCAROUTER_CALLBACK_PATH = "/callback"; +const ORCAROUTER_KEY_PREFIX = "sk-orca-"; +const TOKEN_REQUEST_TIMEOUT_MS = 30_000; + +export interface OrcaRouterLoginOptions { + /** Inference base URL. A non-public value also acts as the auth origin for one-origin self-hosting. */ + baseUrl?: string; + /** Optional dedicated auth origin; the public service defaults to www.orcarouter.ai. */ + authBaseUrl?: string; +} + +interface OrcaRouterKeyPayload { + key?: unknown; + user_id?: unknown; + scope?: unknown; +} + +function requestSignal(signal: AbortSignal | undefined): AbortSignal { + const timeout = AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS); + return signal ? AbortSignal.any([signal, timeout]) : timeout; +} + +/** + * Resolve the one configurable OrcaRouter origin used by both auth and inference. + * Plain HTTP is accepted only on loopback so a long-lived key is never sent over a + * clear-text remote connection by a typo in `ORCAROUTER_BASE_URL`. + */ +export function normalizeOrcaRouterBaseUrl(raw = ORCAROUTER_DEFAULT_BASE_URL): string { + let parsed: URL; + try { + parsed = new URL(raw.trim()); + } catch { + // Do not echo malformed input: it may contain credentials pasted into the URL. + throw new Error("OrcaRouter base URL is invalid"); + } + const hostname = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase(); + const loopback = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; + if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && loopback)) { + throw new Error("OrcaRouter base URL must use HTTPS (HTTP is allowed only on loopback)"); + } + if (parsed.username || parsed.password || parsed.search || parsed.hash) { + throw new Error("OrcaRouter base URL must not contain credentials, a query, or a fragment"); + } + const path = parsed.pathname.replace(/\/+$/, ""); + if (path && path !== "/v1") { + throw new Error("OrcaRouter base URL path must be empty or /v1"); + } + return parsed.origin; +} + +export function orcaRouterInferenceBaseUrl(raw?: string): string { + return `${normalizeOrcaRouterBaseUrl(raw)}/v1`; +} + +export function orcaRouterAuthBaseUrl(apiBaseUrl?: string, authBaseUrl?: string): string { + if (authBaseUrl) return normalizeOrcaRouterBaseUrl(authBaseUrl); + const apiOrigin = normalizeOrcaRouterBaseUrl(apiBaseUrl); + return apiOrigin === ORCAROUTER_DEFAULT_API_BASE_URL + ? ORCAROUTER_DEFAULT_AUTH_BASE_URL + : apiOrigin; +} + +function parseKeyPayload(value: unknown): OAuthCredentials { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("OrcaRouter key exchange returned an invalid response"); + } + const payload = value as OrcaRouterKeyPayload; + const key = typeof payload.key === "string" ? payload.key.trim() : ""; + if (!key.startsWith(ORCAROUTER_KEY_PREFIX) || key.length > 4096 || /[\r\n]/.test(key)) { + throw new Error("OrcaRouter key exchange did not return a valid API key"); + } + if (payload.scope !== "api") { + throw new Error("OrcaRouter key exchange did not grant the required api scope"); + } + const accountId = typeof payload.user_id === "string" + ? payload.user_id.trim() + : typeof payload.user_id === "number" && Number.isSafeInteger(payload.user_id) + ? String(payload.user_id) + : ""; + if (!accountId || accountId.length > 256 || /[\x00-\x1f\x7f]/.test(accountId)) { + throw new Error("OrcaRouter key exchange did not return a valid user id"); + } + // OrcaRouter issues a normal long-lived API key, not a refresh token. The OAuth + // store requires both fields, so mirror the established Command Code key-grant + // representation. `expires` prevents background refresh; an upstream 401 asks the + // user to reconnect and mint a replacement key. + return { + access: key, + refresh: key, + expires: Number.MAX_SAFE_INTEGER, + accountId, + source: "oauth", + }; +} + +function durableKeyCredential(apiKey: string): OAuthCredentials { + const key = apiKey.trim(); + if (!key.startsWith(ORCAROUTER_KEY_PREFIX) || key.length > 4096 || /[\r\n]/.test(key)) { + throw new Error("OrcaRouter API key is invalid; reconnect with ocx login orcarouter-oauth"); + } + return { + access: key, + refresh: key, + expires: Number.MAX_SAFE_INTEGER, + source: "oauth", + }; +} + +export class OrcaRouterOAuthFlow extends OAuthCallbackFlow { + readonly #authBaseUrl: string; + #verifier = ""; + + constructor(ctrl: OAuthController, options: OrcaRouterLoginOptions = {}) { + super(ctrl, { + preferredPort: ORCAROUTER_CALLBACK_PORT, + callbackPath: ORCAROUTER_CALLBACK_PATH, + callbackHostname: "127.0.0.1", + callbackBindHostname: "127.0.0.1", + } satisfies OAuthCallbackFlowOptions); + this.#authBaseUrl = orcaRouterAuthBaseUrl(options.baseUrl, options.authBaseUrl); + } + + async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions: string }> { + const pkce = await generatePKCE(); + this.#verifier = pkce.verifier; + const url = new URL("/auth", this.#authBaseUrl); + url.search = new URLSearchParams({ + callback_url: redirectUri, + code_challenge: pkce.challenge, + code_challenge_method: "S256", + state, + app_name: "OpenCodex", + scope: "api", + }).toString(); + return { + url: url.toString(), + instructions: + "Approve access in your browser. If the browser cannot reach this machine, choose the displayed-code option and paste the code here.", + }; + } + + async exchangeToken(code: string, _state: string, _redirectUri: string): Promise { + if (!this.#verifier) throw new Error("OrcaRouter PKCE verifier was not initialized"); + let response: Response; + try { + response = await fetch(new URL("/api/v1/auth/keys", this.#authBaseUrl), { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ + code, + code_verifier: this.#verifier, + code_challenge_method: "S256", + }), + redirect: "error", + signal: requestSignal(this.ctrl.signal), + }); + } catch (error) { + if (this.ctrl.signal?.aborted) { + throw this.ctrl.signal.reason ?? new DOMException("OrcaRouter login aborted", "AbortError"); + } + throw new Error("OrcaRouter key exchange failed: network error", { cause: error }); + } + if (!response.ok) { + // The body is deliberately not reflected: authentication error payloads must + // never turn a code, verifier, or accidentally returned key into console output. + throw new Error(`OrcaRouter key exchange failed with HTTP ${response.status}`); + } + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new Error("OrcaRouter key exchange returned invalid JSON"); + } + return parseKeyPayload(payload); + } +} + +export async function loginOrcaRouter( + ctrl: OAuthController, + options: OrcaRouterLoginOptions = {}, +): Promise { + if (ctrl.signal?.aborted) { + throw ctrl.signal.reason ?? new DOMException("OrcaRouter login aborted", "AbortError"); + } + return new OrcaRouterOAuthFlow(ctrl, options).login(); +} + +export async function refreshOrcaRouterKey(apiKey: string): Promise { + return durableKeyCredential(apiKey); +} diff --git a/src/providers/registry.ts b/src/providers/registry.ts index b1f689a8bd..687d150f99 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1122,6 +1122,32 @@ const CLINE_PASS_MODELS = [ "cline-pass/qwen3.7-max", "cline-pass/qwen3.7-plus", ]; + +const ORCAROUTER_MODEL_DISCOVERY: ProviderModelDiscoverySpec = { + path: "models", + query: { capability: "chat" }, + maxResponseBytes: 512 * 1024, + maxModels: 512, + filter: { + anyOf: [{ + path: ["supported_endpoint_types"], + containsAny: ["openai", "openai-response", "anthropic", "gemini"], + caseInsensitive: true, + }], + noneOf: [{ + path: ["supported_endpoint_types"], + containsAny: ["image-generation", "openai-video", "jina-rerank"], + caseInsensitive: true, + }], + }, +}; +const ORCAROUTER_TEXT_ONLY_MODELS = ["deepseek/deepseek-v4-pro"]; +const ORCAROUTER_MODEL_REASONING_EFFORTS = { + "deepseek/deepseek-v4-pro": deepseekThinkingEffortsFor("deepseek/deepseek-v4-pro"), +}; +const ORCAROUTER_MODEL_REASONING_EFFORT_MAP = { + "deepseek/deepseek-v4-pro": deepseekReasoningMapFor("deepseek/deepseek-v4-pro"), +}; const CLINE_PASS_MODEL_CONTEXT_WINDOWS: Record = { "cline-pass/glm-5.3": 1_048_576, "cline-pass/glm-5.3-flash": 1_048_576, @@ -1360,6 +1386,24 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // The proprietary generate wire has no verified per-request serialization flag. parallelToolCalls: false, }, + { + id: "orcarouter-oauth", + label: "OrcaRouter - Auth", + adapter: "openai-chat", + baseUrl: "https://api.orcarouter.ai/v1", + authKind: "oauth", + oauthId: "orcarouter-oauth", + featured: true, + allowBaseUrlOverride: true, + defaultModel: "openai/gpt-5.5", + liveModels: true, + modelDiscovery: ORCAROUTER_MODEL_DISCOVERY, + noVisionModels: ORCAROUTER_TEXT_ONLY_MODELS, + modelReasoningEfforts: ORCAROUTER_MODEL_REASONING_EFFORTS, + modelReasoningEffortMap: ORCAROUTER_MODEL_REASONING_EFFORT_MAP, + preserveReasoningContentModels: ORCAROUTER_TEXT_ONLY_MODELS, + note: "Connect your OrcaRouter account with OAuth 2.0 + PKCE; the issued API key is stored in OpenCodex's existing credential store.", + }, { id: "anthropic", label: "Anthropic Claude", @@ -1834,37 +1878,22 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ note: "Cline usage-billing API: one key, 100+ models, OpenRouter-style ids. Promotional free models are IDE/CLI-only per Cline docs; minimax/minimax-m2.5 is the documented API free experimentation model.", }, { - // OrcaRouter: OpenAI-compatible adaptive router (api.orcarouter.ai). Model ids are - // vendor-namespaced (`/`) and pass through to the upstream as-is. - // The default pins a tool-capable model; the adaptive `orcarouter/auto` router is also - // selectable. Live-verified 2026-07-20: /v1/chat/completions accepts the `tools` field - // and routes to a function-calling-capable upstream. - id: "orcarouter", label: "OrcaRouter", adapter: "openai-chat", baseUrl: "https://api.orcarouter.ai/v1", + // OrcaRouter: OpenAI-compatible adaptive router (api.orcarouter.ai). The public live + // catalog is authoritative; model ids and input modalities are never maintained here. + id: "orcarouter", label: "OrcaRouter - API", adapter: "openai-chat", baseUrl: "https://api.orcarouter.ai/v1", authKind: "key", dashboardUrl: "https://www.orcarouter.ai/console", + // The catalog is public, so a successful /models probe cannot validate a submitted key. + apiKeyValidation: "unknown", defaultModel: "openai/gpt-5.5", - models: [ - "openai/gpt-5.5", - "anthropic/claude-opus-4.8", - "google/gemini-3.5-flash", - "deepseek/deepseek-v4-pro", - "orcarouter/auto", - ], - // Text-only models → the vision sidecar describes images instead. - noVisionModels: ["deepseek/deepseek-v4-pro"], - // Reasoning/temperature behavior verified live 2026-07-20 against api.orcarouter.ai: - // - openai/gpt-5.5 accepts reasoning_effort none|low|medium|high|xhigh but rejects `max` (400), - // so advertise up to xhigh and let mapReasoningEffort clamp a `max`/`ultra` request to xhigh. - // - deepseek/deepseek-v4-pro mirrors the direct-DeepSeek wiring (thinking-effort map + - // reasoning_content history replay) so the namespaced selection behaves identically. - // - temperature is accepted by every seeded model (gpt-5.5, claude-opus-4.8, deepseek-v4-pro all - // returned 200), so no noTemperatureModels entry is warranted here. - modelReasoningEfforts: { - "openai/gpt-5.5": ["low", "medium", "high", "xhigh"], - "deepseek/deepseek-v4-pro": deepseekThinkingEffortsFor("deepseek/deepseek-v4-pro"), - }, - modelReasoningEffortMap: { "deepseek/deepseek-v4-pro": deepseekReasoningMapFor("deepseek/deepseek-v4-pro") }, - preserveReasoningContentModels: ["deepseek/deepseek-v4-pro"], - note: "OpenAI-compatible adaptive router. Default is a tool-capable model; orcarouter/auto (adaptive routing) is also selectable. Full catalog: https://www.orcarouter.ai/models", + liveModels: true, + modelDiscovery: ORCAROUTER_MODEL_DISCOVERY, + // Catalog discovery owns WHICH models exist. These entries only retain verified + // request-shaping facts that the upstream catalog does not currently publish. + noVisionModels: ORCAROUTER_TEXT_ONLY_MODELS, + modelReasoningEfforts: ORCAROUTER_MODEL_REASONING_EFFORTS, + modelReasoningEffortMap: ORCAROUTER_MODEL_REASONING_EFFORT_MAP, + preserveReasoningContentModels: ORCAROUTER_TEXT_ONLY_MODELS, + note: "OpenAI-compatible adaptive router. Models and multimodal capabilities are discovered live from the public chat catalog. Use the OrcaRouter account entry for PKCE login.", }, { // BizRouter: Korean enterprise LLM gateway (api.bizrouter.ai). Model ids are diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index fe613ac71f..da47a1bd22 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -759,6 +759,7 @@ "opencode-go-session-header.test.ts": "providers", "opencode-zen-deepseek-reasoning.test.ts": "providers", "opencode-zen-rate-limit.test.ts": "providers", + "orcarouter-provider.test.ts": "providers", "openrouter-provider-routing.test.ts": "providers", "optional-shutdown-hooks.test.ts": "lib", "outbound-body-guard.test.ts": "server", diff --git a/tests/providers/orcarouter-provider.test.ts b/tests/providers/orcarouter-provider.test.ts new file mode 100644 index 0000000000..45f2eab05f --- /dev/null +++ b/tests/providers/orcarouter-provider.test.ts @@ -0,0 +1,216 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { catalogHintsFromModelsApiItem } from "../../src/codex/catalog/provider-fetch"; +import { OAUTH_PROVIDERS, upsertOAuthProvider } from "../../src/oauth"; +import { KEY_LOGIN_PROVIDERS } from "../../src/oauth/key-providers"; +import { + normalizeOrcaRouterBaseUrl, + OrcaRouterOAuthFlow, + orcaRouterAuthBaseUrl, + orcaRouterInferenceBaseUrl, + refreshOrcaRouterKey, +} from "../../src/oauth/orcarouter"; +import { deriveProviderPresets, providerConfigSeed } from "../../src/providers/derive"; +import { + extractProviderModelItems, + providerModelDiscoverySpecError, + resolveProviderModelDiscovery, + resolveProviderModelDiscoveryUrl, +} from "../../src/providers/model-discovery"; +import { PROVIDER_REGISTRY } from "../../src/providers/registry"; +import type { OcxConfig } from "../../src/types"; +import { formatProviderDisplayName, providerIconSrc } from "../../gui/src/provider-icons"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function registryEntry(id: "orcarouter" | "orcarouter-oauth") { + const entry = PROVIDER_REGISTRY.find(row => row.id === id); + if (!entry) throw new Error(`missing ${id} registry entry`); + return entry; +} + +describe("OrcaRouter dual authentication", () => { + test("keeps API-key and PKCE account login as explicit first-class choices", () => { + const key = registryEntry("orcarouter"); + const oauth = registryEntry("orcarouter-oauth"); + expect(key).toMatchObject({ + authKind: "key", + adapter: "openai-chat", + baseUrl: "https://api.orcarouter.ai/v1", + liveModels: true, + apiKeyValidation: "unknown", + }); + expect(oauth).toMatchObject({ + authKind: "oauth", + adapter: "openai-chat", + baseUrl: "https://api.orcarouter.ai/v1", + liveModels: true, + allowBaseUrlOverride: true, + }); + expect(KEY_LOGIN_PROVIDERS.orcarouter).toBeDefined(); + expect(OAUTH_PROVIDERS["orcarouter-oauth"]).toBeDefined(); + expect(deriveProviderPresets().find(row => row.id === "orcarouter")).toMatchObject({ auth: "key" }); + expect(deriveProviderPresets().find(row => row.id === "orcarouter-oauth")).toMatchObject({ auth: "oauth" }); + expect(formatProviderDisplayName("orcarouter")).toBe("OrcaRouter - API"); + expect(formatProviderDisplayName("orcarouter-oauth")).toBe("OrcaRouter - Auth"); + expect(providerIconSrc("orcarouter")).toBe("/provider-icons/orcarouter.png"); + expect(providerIconSrc("orcarouter-oauth")).toBe("/provider-icons/orcarouter.png"); + }); + + test("discovers the live chat catalog with bounded declarative filtering", () => { + const entry = registryEntry("orcarouter"); + expect(providerModelDiscoverySpecError(entry.modelDiscovery!)).toBeNull(); + expect(entry.models).toBeUndefined(); + const seed = providerConfigSeed(entry); + const discovery = resolveProviderModelDiscovery("orcarouter", seed); + expect(resolveProviderModelDiscoveryUrl( + "orcarouter", + seed, + seed.baseUrl, + `${seed.baseUrl}/models`, + )).toBe("https://api.orcarouter.ai/v1/models?capability=chat"); + + const result = extractProviderModelItems({ + data: [ + { id: "vendor/text", supported_endpoint_types: ["openai"], architecture: { input_modalities: ["text"] } }, + { id: "vendor/vision", supported_endpoint_types: ["openai-response"], architecture: { input_modalities: ["text", "image"] } }, + { id: "vendor/image", supported_endpoint_types: ["image-generation"] }, + { id: "vendor/rerank", supported_endpoint_types: ["jina-rerank", "openai"] }, + { id: "vendor/unknown", supported_endpoint_types: null }, + ], + }, discovery); + expect(result).toMatchObject({ + ok: true, + rawCount: 5, + items: [ + { id: "vendor/text" }, + { id: "vendor/vision" }, + ], + }); + }); + + test("maps OrcaRouter architecture.input_modalities into Codex-safe attachment metadata", () => { + expect(catalogHintsFromModelsApiItem("orcarouter", { + id: "vendor/vision", + architecture: { input_modalities: ["file", "image", "text", "video"] }, + })).toEqual({ inputModalities: ["image", "text"] }); + expect(catalogHintsFromModelsApiItem("orcarouter", { + id: "vendor/text", + architecture: { input_modalities: ["text"] }, + })).toEqual({ inputModalities: ["text"] }); + }); + + test("builds S256 authorization and exchanges at /api/v1/auth/keys without leaking secrets", async () => { + let requestUrl = ""; + let requestBody: Record = {}; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + requestUrl = String(input); + requestBody = JSON.parse(String(init?.body)) as Record; + return Response.json({ key: "sk-orca-local-test", user_id: "user-42", scope: "api" }); + }) as typeof fetch; + + const flow = new OrcaRouterOAuthFlow({}); + const authorization = await flow.generateAuthUrl("state-42", "http://127.0.0.1:51733/callback"); + const url = new URL(authorization.url); + expect(url.origin + url.pathname).toBe("https://www.orcarouter.ai/auth"); + expect(url.searchParams.get("callback_url")).toBe("http://127.0.0.1:51733/callback"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("state")).toBe("state-42"); + expect(url.searchParams.get("app_name")).toBe("OpenCodex"); + expect(url.searchParams.get("scope")).toBe("api"); + + const credential = await flow.exchangeToken("single-use-code", "state-42", "ignored"); + expect(requestUrl).toBe("https://www.orcarouter.ai/api/v1/auth/keys"); + expect(requestBody).toMatchObject({ + code: "single-use-code", + code_challenge_method: "S256", + }); + const verifier = String(requestBody.code_verifier); + expect(createHash("sha256").update(verifier).digest("base64url")) + .toBe(url.searchParams.get("code_challenge")); + expect(authorization.url).not.toContain(verifier); + expect(credential).toEqual({ + access: "sk-orca-local-test", + refresh: "sk-orca-local-test", + expires: Number.MAX_SAFE_INTEGER, + accountId: "user-42", + source: "oauth", + }); + + const secretErrorBody = ["sk", "orca", "should-not-leak", verifier].join("-"); + globalThis.fetch = (async () => new Response(secretErrorBody, { status: 403 })) as typeof fetch; + let message = ""; + try { + await flow.exchangeToken("used-code", "state-42", "ignored"); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + expect(message).toBe("OrcaRouter key exchange failed with HTTP 403"); + expect(message).not.toContain(secretErrorBody); + expect(message).not.toContain(verifier); + }); + + test("splits the public auth and inference origins while preserving one-origin self-hosting", async () => { + expect(orcaRouterAuthBaseUrl()).toBe("https://www.orcarouter.ai"); + expect(orcaRouterInferenceBaseUrl()).toBe("https://api.orcarouter.ai/v1"); + expect(normalizeOrcaRouterBaseUrl("https://router.example/v1/")).toBe("https://router.example"); + expect(orcaRouterInferenceBaseUrl("http://127.0.0.1:9999")).toBe("http://127.0.0.1:9999/v1"); + expect(() => normalizeOrcaRouterBaseUrl("http://router.example")).toThrow("must use HTTPS"); + expect(() => normalizeOrcaRouterBaseUrl("https://router.example/prefix")).toThrow("empty or /v1"); + const secret = "do-not-echo-this-password"; + let malformedMessage = ""; + try { + normalizeOrcaRouterBaseUrl(`https://user:${secret}@`); + } catch (error) { + malformedMessage = error instanceof Error ? error.message : String(error); + } + expect(malformedMessage).toBe("OrcaRouter base URL is invalid"); + expect(malformedMessage).not.toContain(secret); + + const flow = new OrcaRouterOAuthFlow({}, { baseUrl: "https://router.example/v1" }); + const authorization = await flow.generateAuthUrl("state", "http://127.0.0.1:1/callback"); + expect(new URL(authorization.url).origin).toBe("https://router.example"); + + const splitFlow = new OrcaRouterOAuthFlow({}, { + baseUrl: "https://api.router.example/v1", + authBaseUrl: "https://login.router.example", + }); + const splitAuthorization = await splitFlow.generateAuthUrl("state", "http://127.0.0.1:1/callback"); + expect(new URL(splitAuthorization.url).origin).toBe("https://login.router.example"); + }); + + test("preserves a configured self-hosted origin when account login publishes the provider", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "orcarouter-oauth", + providers: { + "orcarouter-oauth": { + adapter: "openai-chat", + baseUrl: "https://router.example/v1/", + authMode: "oauth", + }, + }, + }; + upsertOAuthProvider(config, "orcarouter-oauth"); + expect(config.providers["orcarouter-oauth"]).toMatchObject({ + adapter: "openai-chat", + baseUrl: "https://router.example/v1", + authMode: "oauth", + liveModels: true, + }); + }); + + test("treats the exchanged key as durable instead of inventing a refresh grant", async () => { + await expect(refreshOrcaRouterKey("bad-key")).rejects.toThrow("reconnect"); + await expect(refreshOrcaRouterKey("sk-orca-existing-key")).resolves.toEqual({ + access: "sk-orca-existing-key", + refresh: "sk-orca-existing-key", + expires: Number.MAX_SAFE_INTEGER, + source: "oauth", + }); + }); +}); diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index 238a5808ea..9aeff8e1b2 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -737,7 +737,7 @@ describe("provider registry parity", () => { // Registry order. Both OAuth entries (anthropic, google-antigravity) are gated by // providerSecureTransportConfigError; the rest are key/local providers that never send a // subscription bearer to the override. - expect(optedIn.map(entry => entry.id)).toEqual(["anthropic", "google-antigravity", "ollama", "vllm", "lm-studio", "moonshot", "qwen-cloud", "alibaba", "alibaba-token-plan-intl", "litellm"]); + expect(optedIn.map(entry => entry.id)).toEqual(["orcarouter-oauth", "anthropic", "google-antigravity", "ollama", "vllm", "lm-studio", "moonshot", "qwen-cloud", "alibaba", "alibaba-token-plan-intl", "litellm"]); for (const entry of optedIn) { expect(providerConfigSeed(entry)).not.toHaveProperty("allowBaseUrlOverride"); } @@ -962,7 +962,7 @@ describe("provider registry parity", () => { test("GUI preset projection preserves current featured set plus key catalog and custom", () => { const featured = deriveFeaturedProviderIds(); expect(featured).toEqual([ - "openai", "xai", "command-code", "anthropic", "anthropic-apikey", "kimi", "nous", "openai-apikey", "umans", "opencode-go", "openrouter", + "openai", "xai", "command-code", "orcarouter-oauth", "anthropic", "anthropic-apikey", "kimi", "nous", "openai-apikey", "umans", "opencode-go", "openrouter", "groq", "google", "azure-openai", "ollama", "vllm", "lm-studio", "opencode-free", "mimo-free", ]); From b70c59409d729efe83fead973a265c1efdee95ec Mon Sep 17 00:00:00 2001 From: ankaifeng <2895443235@qq.com> Date: Mon, 7 Sep 2026 21:37:36 +0800 Subject: [PATCH 2/5] fix: preserve OrcaRouter model fallbacks --- src/providers/registry.ts | 15 +++++++++++++++ tests/providers/orcarouter-provider.test.ts | 10 +++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 687d150f99..5e46f27d60 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1141,8 +1141,21 @@ const ORCAROUTER_MODEL_DISCOVERY: ProviderModelDiscoverySpec = { }], }, }; +// Preserve the previously verified cold-start catalog. Live discovery remains authoritative +// when it succeeds, but a temporary catalog outage must not erase the provider's known-good +// selectors from the picker. `orcarouter/auto` is intentionally retained here even though the +// public catalog did not enumerate it at the latest verification (2026-09-07). +const ORCAROUTER_MODELS = [ + "openai/gpt-5.5", + "anthropic/claude-opus-4.8", + "google/gemini-3.5-flash", + "deepseek/deepseek-v4-pro", + "orcarouter/auto", +]; const ORCAROUTER_TEXT_ONLY_MODELS = ["deepseek/deepseek-v4-pro"]; const ORCAROUTER_MODEL_REASONING_EFFORTS = { + // Live /models currently exposes ids and modalities, not the accepted reasoning ladder. + "openai/gpt-5.5": ["low", "medium", "high", "xhigh"], "deepseek/deepseek-v4-pro": deepseekThinkingEffortsFor("deepseek/deepseek-v4-pro"), }; const ORCAROUTER_MODEL_REASONING_EFFORT_MAP = { @@ -1396,6 +1409,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ featured: true, allowBaseUrlOverride: true, defaultModel: "openai/gpt-5.5", + models: ORCAROUTER_MODELS, liveModels: true, modelDiscovery: ORCAROUTER_MODEL_DISCOVERY, noVisionModels: ORCAROUTER_TEXT_ONLY_MODELS, @@ -1885,6 +1899,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // The catalog is public, so a successful /models probe cannot validate a submitted key. apiKeyValidation: "unknown", defaultModel: "openai/gpt-5.5", + models: ORCAROUTER_MODELS, liveModels: true, modelDiscovery: ORCAROUTER_MODEL_DISCOVERY, // Catalog discovery owns WHICH models exist. These entries only retain verified diff --git a/tests/providers/orcarouter-provider.test.ts b/tests/providers/orcarouter-provider.test.ts index 45f2eab05f..8cca844ade 100644 --- a/tests/providers/orcarouter-provider.test.ts +++ b/tests/providers/orcarouter-provider.test.ts @@ -51,6 +51,13 @@ describe("OrcaRouter dual authentication", () => { liveModels: true, allowBaseUrlOverride: true, }); + for (const entry of [key, oauth]) { + expect(entry.models).toContain("openai/gpt-5.5"); + expect(entry.models).toContain("orcarouter/auto"); + expect(entry.modelReasoningEfforts?.["openai/gpt-5.5"]) + .toEqual(["low", "medium", "high", "xhigh"]); + expect(entry.modelReasoningEfforts?.["deepseek/deepseek-v4-pro"]).toBeArray(); + } expect(KEY_LOGIN_PROVIDERS.orcarouter).toBeDefined(); expect(OAUTH_PROVIDERS["orcarouter-oauth"]).toBeDefined(); expect(deriveProviderPresets().find(row => row.id === "orcarouter")).toMatchObject({ auth: "key" }); @@ -64,7 +71,8 @@ describe("OrcaRouter dual authentication", () => { test("discovers the live chat catalog with bounded declarative filtering", () => { const entry = registryEntry("orcarouter"); expect(providerModelDiscoverySpecError(entry.modelDiscovery!)).toBeNull(); - expect(entry.models).toBeUndefined(); + expect(entry.models).toContain("openai/gpt-5.5"); + expect(entry.models).toContain("orcarouter/auto"); const seed = providerConfigSeed(entry); const discovery = resolveProviderModelDiscovery("orcarouter", seed); expect(resolveProviderModelDiscoveryUrl( From 8fc6de357a2e7dbe71945a035e63230b17bed120 Mon Sep 17 00:00:00 2001 From: ankaifeng <2895443235@qq.com> Date: Mon, 7 Sep 2026 21:44:55 +0800 Subject: [PATCH 3/5] fix: require reauthentication for revoked OrcaRouter keys --- src/oauth/index.ts | 8 +++- src/oauth/orcarouter.ts | 16 +++---- src/server/responses/core.ts | 1 + tests/providers/orcarouter-provider.test.ts | 48 +++++++++++++++++---- 4 files changed, 55 insertions(+), 18 deletions(-) diff --git a/src/oauth/index.ts b/src/oauth/index.ts index b31960d3e6..904674716d 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -572,7 +572,13 @@ export async function getValidAccessTokenSnapshot(provider: string): Promise 4096 || /[\r\n]/.test(key)) { throw new Error("OrcaRouter API key is invalid; reconnect with ocx login orcarouter-oauth"); } - return { - access: key, - refresh: key, - expires: Number.MAX_SAFE_INTEGER, - source: "oauth", - }; } export class OrcaRouterOAuthFlow extends OAuthCallbackFlow { @@ -195,6 +189,10 @@ export async function loginOrcaRouter( return new OrcaRouterOAuthFlow(ctrl, options).login(); } -export async function refreshOrcaRouterKey(apiKey: string): Promise { - return durableKeyCredential(apiKey); +export async function refreshOrcaRouterKey(apiKey: string): Promise { + assertDurableApiKey(apiKey); + // This hook is reached only after upstream rejected the durable key. There is no refresh + // grant to replay, so classify the credential as terminal and let the shared generation-safe + // refresh path mark this exact account as needing a new browser login. + throw new Error("invalid_grant: OrcaRouter API keys cannot be refreshed; reconnect with ocx login orcarouter-oauth"); } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 594b444c8b..6f0cc0dc1a 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -3693,6 +3693,7 @@ async function handleResponsesInner( || route.providerName === "github-copilot" || route.providerName === "kiro" || route.providerName === "google-antigravity" + || route.providerName === "orcarouter-oauth" ) && route.provider.authMode === "oauth"; let sentOAuthSnapshot: OAuthAccessSnapshot | undefined; let replayOAuthCredentialSnapshot: Pick | undefined; diff --git a/tests/providers/orcarouter-provider.test.ts b/tests/providers/orcarouter-provider.test.ts index 8cca844ade..c48768b505 100644 --- a/tests/providers/orcarouter-provider.test.ts +++ b/tests/providers/orcarouter-provider.test.ts @@ -1,7 +1,15 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createHash } from "node:crypto"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { catalogHintsFromModelsApiItem } from "../../src/codex/catalog/provider-fetch"; -import { OAUTH_PROVIDERS, upsertOAuthProvider } from "../../src/oauth"; +import { + forceRefreshOAuthAccessSnapshot, + getValidAccessTokenSnapshot, + OAUTH_PROVIDERS, + upsertOAuthProvider, +} from "../../src/oauth"; import { KEY_LOGIN_PROVIDERS } from "../../src/oauth/key-providers"; import { normalizeOrcaRouterBaseUrl, @@ -10,6 +18,7 @@ import { orcaRouterInferenceBaseUrl, refreshOrcaRouterKey, } from "../../src/oauth/orcarouter"; +import { getAccountSet, saveCredential } from "../../src/oauth/store"; import { deriveProviderPresets, providerConfigSeed } from "../../src/providers/derive"; import { extractProviderModelItems, @@ -20,6 +29,7 @@ import { import { PROVIDER_REGISTRY } from "../../src/providers/registry"; import type { OcxConfig } from "../../src/types"; import { formatProviderDisplayName, providerIconSrc } from "../../gui/src/provider-icons"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; const originalFetch = globalThis.fetch; @@ -212,13 +222,35 @@ describe("OrcaRouter dual authentication", () => { }); }); - test("treats the exchanged key as durable instead of inventing a refresh grant", async () => { + test("treats an upstream-rejected durable key as terminal instead of inventing a refresh grant", async () => { await expect(refreshOrcaRouterKey("bad-key")).rejects.toThrow("reconnect"); - await expect(refreshOrcaRouterKey("sk-orca-existing-key")).resolves.toEqual({ - access: "sk-orca-existing-key", - refresh: "sk-orca-existing-key", - expires: Number.MAX_SAFE_INTEGER, - source: "oauth", - }); + await expect(refreshOrcaRouterKey("sk-orca-existing-key")) + .rejects.toThrow("invalid_grant"); + }); + + test("generation-safely marks a rejected durable key as requiring a new login", async () => { + const previousHome = process.env.OPENCODEX_HOME; + const testHome = mkdtempSync(join(tmpdir(), "ocx-orcarouter-401-")); + process.env.OPENCODEX_HOME = testHome; + try { + await saveCredential("orcarouter-oauth", { + access: "sk-orca-revoked-key", + refresh: "sk-orca-revoked-key", + expires: Number.MAX_SAFE_INTEGER, + accountId: "user-42", + source: "oauth", + }); + const rejected = await getValidAccessTokenSnapshot("orcarouter-oauth"); + + await expect(forceRefreshOAuthAccessSnapshot(rejected)).rejects.toThrow("Not logged in"); + const account = getAccountSet("orcarouter-oauth")?.accounts + .find(candidate => candidate.id === rejected.accountId); + expect(account?.needsReauth).toBe(true); + expect(account?.credential.access).toBe("sk-orca-revoked-key"); + } finally { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(testHome); + } }); }); From 9e90d8271e4b8dc9b229b478196270c0c4eae7bb Mon Sep 17 00:00:00 2001 From: ankaifeng <2895443235@qq.com> Date: Mon, 7 Sep 2026 21:57:22 +0800 Subject: [PATCH 4/5] fix(orcarouter): address OAuth review feedback --- .../docs/reference/cli/providers-accounts.md | 2 +- .../content/docs/zh-cn/guides/providers.md | 5 +- gui/src/components/use-add-provider-oauth.ts | 20 ++++-- gui/src/i18n/de.ts | 2 + gui/src/i18n/en.ts | 2 + gui/src/i18n/fr.ts | 2 + gui/src/i18n/ja.ts | 2 + gui/src/i18n/ko.ts | 2 + gui/src/i18n/ru.ts | 2 + gui/src/i18n/tr.ts | 2 + gui/src/i18n/zh-TW.ts | 2 + gui/src/i18n/zh.ts | 2 + gui/src/pages/use-providers-oauth.ts | 15 +++-- gui/src/provider-icons.ts | 2 + .../add-provider-oauth-url-leak.test.tsx | 65 +++++++++++++++++-- gui/tests/fr-localization.test.ts | 1 + gui/tests/locale-parity.test.ts | 1 + tests/providers/orcarouter-provider.test.ts | 7 +- 18 files changed, 113 insertions(+), 23 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index b937606915..f5631aceaf 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -90,7 +90,7 @@ files or a raw network capture. Start the provider's registered login flow. OAuth-style account providers open a browser and store credentials under `~/.opencodex/` (refreshable tokens rotate automatically; durable key grants such -as OrcaRouter are reused); API-key login providers open their key dashboard, prompt for the +as OrcaRouter are reused until the provider revokes them); API-key login providers open their key dashboard, prompt for the key, validate it when possible, and save the resulting provider config. The command prints the currently accepted OAuth and API-key provider ids when the name is missing or unknown. diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 34d52429b2..54c3c0d614 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -75,8 +75,9 @@ ChatGPT 透传目录也会加入 GPT-5.6 Sol/Terra/Luna 的裸 slug(`gpt-5.6-s ## 2. 账号登录(OAuth) -有八个提供商预设使用 OAuth 登录,另加通过实验性非官方设备流桥接的 GitHub Copilot。 -opencodex 会把凭据存入 `~/.opencodex/auth.json` 并自动刷新。登录 CLI 也接受 `chatgpt`: +有九个提供商预设使用 OAuth 登录,另加通过实验性非官方设备流桥接的 GitHub Copilot。 +opencodex 会把凭据存入 `~/.opencodex/auth.json`:可刷新的令牌会自动轮换;OrcaRouter +这类持久密钥会复用到提供商撤销为止。登录 CLI 也接受 `chatgpt`: 它会获取一份 ChatGPT 凭据,并创建一个 `forward` 模式的提供商条目。 ```bash diff --git a/gui/src/components/use-add-provider-oauth.ts b/gui/src/components/use-add-provider-oauth.ts index bb7166ba00..87ec2146a8 100644 --- a/gui/src/components/use-add-provider-oauth.ts +++ b/gui/src/components/use-add-provider-oauth.ts @@ -27,7 +27,7 @@ export function useAddProviderOAuth({ onAdded: (name: string) => void; }) { const loginGenerationRef = useRef(new Map()); - const activeProvidersRef = useRef(new Set()); + const activeProvidersRef = useRef(new Map()); const bumpLoginGeneration = useCallback((providerId: string) => { const generation = (loginGenerationRef.current.get(providerId) ?? 0) + 1; @@ -45,18 +45,24 @@ export function useAddProviderOAuth({ }, [apiBase]); useEffect(() => { - const cancelActiveLogins = () => { + const cancelActiveLogins = (clearUi: boolean) => { const providers = [...activeProvidersRef.current]; activeProvidersRef.current.clear(); - for (const providerId of providers) { + for (const [providerId, setters] of providers) { bumpLoginGeneration(providerId); + if (clearUi) { + setters.setOauthBusy(false); + setters.setOauthUrl("", providerId); + setters.setOauthMsg(""); + } void cancelServerLogin(providerId); } }; - window.addEventListener("pagehide", cancelActiveLogins); + const onPageHide = () => cancelActiveLogins(true); + window.addEventListener("pagehide", onPageHide); return () => { - window.removeEventListener("pagehide", cancelActiveLogins); - cancelActiveLogins(); + window.removeEventListener("pagehide", onPageHide); + cancelActiveLogins(false); }; }, [bumpLoginGeneration, cancelServerLogin]); @@ -82,7 +88,7 @@ export function useAddProviderOAuth({ const { setOauthBusy, setOauthMsg, setOauthMsgTone, setOauthUrl, setManualCode, setManualCodeMsg, setManualCodeOk } = setters; const generation = bumpLoginGeneration(providerId); const isCurrent = () => loginGenerationRef.current.get(providerId) === generation; - activeProvidersRef.current.add(providerId); + activeProvidersRef.current.set(providerId, setters); setOauthBusy(true); setOauthMsg(""); setOauthMsgTone("ok"); diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 4145cb8032..a3c881b989 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -135,6 +135,8 @@ export const de: Record = { "lang.nativeName": "Deutsch", "provider.name.commandCodeAuth": "Command Code - Auth", "provider.name.commandCodeApi": "Command Code - API", + "provider.name.orcaRouterApi": "OrcaRouter - API", + "provider.name.orcaRouterAuth": "OrcaRouter - Authentifizierung", "provider.name.volcengine": "Volcengine Ark", "provider.name.volcengineCodingPlan": "Volcengine Ark Coding-Tarif", "provider.name.volcengineAgentPlan": "Volcengine Ark Agent-Tarif", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 2a31ec7947..7d9be02b2f 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -73,6 +73,8 @@ export const en = { "lang.nativeName": "English", "provider.name.commandCodeAuth": "Command Code - Auth", "provider.name.commandCodeApi": "Command Code - API", + "provider.name.orcaRouterApi": "OrcaRouter - API", + "provider.name.orcaRouterAuth": "OrcaRouter - Auth", "provider.name.volcengine": "Volcengine Ark", "provider.name.volcengineCodingPlan": "Volcengine Ark Coding Plan", "provider.name.volcengineAgentPlan": "Volcengine Ark Agent Plan", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 753982c1bf..89a941f8ef 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -70,6 +70,8 @@ export const fr: Record = { "lang.nativeName": "Français", "provider.name.commandCodeAuth": "Command Code - Auth", "provider.name.commandCodeApi": "Command Code - API", + "provider.name.orcaRouterApi": "OrcaRouter - API", + "provider.name.orcaRouterAuth": "OrcaRouter - Authentification", "provider.name.volcengine": "Volcengine Ark", "provider.name.volcengineCodingPlan": "Volcengine Ark Coding Plan", "provider.name.volcengineAgentPlan": "Volcengine Ark Agent Plan", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 300d1f8c51..2ed03a08e9 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -140,6 +140,8 @@ export const ja: Record = { "lang.nativeName": "日本語", "provider.name.commandCodeAuth": "Command Code - Auth", "provider.name.commandCodeApi": "Command Code - API", + "provider.name.orcaRouterApi": "OrcaRouter - API", + "provider.name.orcaRouterAuth": "OrcaRouter - 認証", "provider.name.volcengine": "Volcengine Ark", "provider.name.volcengineCodingPlan": "Volcengine Ark コーディングプラン", "provider.name.volcengineAgentPlan": "Volcengine Ark エージェントプラン", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 42c000c6bb..bd0c17d88b 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -135,6 +135,8 @@ export const ko: Record = { "lang.nativeName": "한국어", "provider.name.commandCodeAuth": "Command Code - Auth", "provider.name.commandCodeApi": "Command Code - API", + "provider.name.orcaRouterApi": "OrcaRouter - API", + "provider.name.orcaRouterAuth": "OrcaRouter - 인증", "provider.name.volcengine": "Volcengine Ark", "provider.name.volcengineCodingPlan": "Volcengine Ark 코딩 플랜", "provider.name.volcengineAgentPlan": "Volcengine Ark 에이전트 플랜", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 51fcaef210..43e43d207e 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -140,6 +140,8 @@ export const ru: Record = { "lang.nativeName": "Русский", "provider.name.commandCodeAuth": "Command Code - Auth", "provider.name.commandCodeApi": "Command Code - API", + "provider.name.orcaRouterApi": "OrcaRouter — API", + "provider.name.orcaRouterAuth": "OrcaRouter — авторизация", "provider.name.volcengine": "Volcengine Ark", "provider.name.volcengineCodingPlan": "Volcengine Ark — тариф Coding", "provider.name.volcengineAgentPlan": "Volcengine Ark — тариф Agent", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index c764bf11ca..7b9d90b4de 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -72,6 +72,8 @@ export const tr: Record = { "lang.nativeName": "Türkçe", "provider.name.commandCodeAuth": "Command Code - Auth", "provider.name.commandCodeApi": "Command Code - API", + "provider.name.orcaRouterApi": "OrcaRouter - API", + "provider.name.orcaRouterAuth": "OrcaRouter - Kimlik Doğrulama", "provider.name.volcengine": "Volcengine Ark", "provider.name.volcengineCodingPlan": "Volcengine Ark Coding Plan", "provider.name.volcengineAgentPlan": "Volcengine Ark Agent Plan", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index ab6aedd67a..09d17d1eac 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2044,6 +2044,8 @@ export const zhTW: Record = { "lang.nativeName": "繁體中文", "provider.name.commandCodeAuth": "Command Code - Auth", "provider.name.commandCodeApi": "Command Code - API", + "provider.name.orcaRouterApi": "OrcaRouter - API", + "provider.name.orcaRouterAuth": "OrcaRouter - 授權", "routing.title": "路由智能 (beta)", "routing.subtitle": "策略設定檔、試運行評估,以及有來源依據的路由分析。", "routing.loadFailed": "無法載入路由資料", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 052f449855..1acf31f5bf 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -135,6 +135,8 @@ export const zh: Record = { "lang.nativeName": "中文", "provider.name.commandCodeAuth": "Command Code - Auth", "provider.name.commandCodeApi": "Command Code - API", + "provider.name.orcaRouterApi": "OrcaRouter - API", + "provider.name.orcaRouterAuth": "OrcaRouter - 授权", "provider.name.volcengine": "火山方舟", "provider.name.volcengineCodingPlan": "火山方舟编程套餐", "provider.name.volcengineAgentPlan": "火山方舟智能体套餐", diff --git a/gui/src/pages/use-providers-oauth.ts b/gui/src/pages/use-providers-oauth.ts index 700f5e54ad..9de0a7537d 100644 --- a/gui/src/pages/use-providers-oauth.ts +++ b/gui/src/pages/use-providers-oauth.ts @@ -63,20 +63,25 @@ export function useProvidersOAuth({ }, [apiBase]); useEffect(() => { - const cancelActiveLogins = () => { + const cancelActiveLogins = (clearUi: boolean) => { const active = [...activeLoginGenerationsRef.current]; activeLoginGenerationsRef.current.clear(); for (const [provider, generation] of active) { if (oauthLoginGenerationRef.current!.get(provider) === generation) bumpLoginGeneration(provider); + if (clearUi) { + setBusy(current => current === provider ? null : current); + setLoginInfo(current => current?.provider === provider ? null : current); + } void cancelServerLogin(provider); } }; - window.addEventListener("pagehide", cancelActiveLogins); + const onPageHide = () => cancelActiveLogins(true); + window.addEventListener("pagehide", onPageHide); return () => { - window.removeEventListener("pagehide", cancelActiveLogins); - cancelActiveLogins(); + window.removeEventListener("pagehide", onPageHide); + cancelActiveLogins(false); }; - }, [bumpLoginGeneration, cancelServerLogin]); + }, [bumpLoginGeneration, cancelServerLogin, setBusy, setLoginInfo]); const cancelLoginOAuth = useCallback(async (provider: string) => { const gen = bumpLoginGeneration(provider); diff --git a/gui/src/provider-icons.ts b/gui/src/provider-icons.ts index 11d28b7347..cdaa5b809a 100644 --- a/gui/src/provider-icons.ts +++ b/gui/src/provider-icons.ts @@ -149,6 +149,8 @@ const PROVIDER_DISPLAY_NAMES: Record = { const PROVIDER_DISPLAY_NAME_KEYS: Record = { "command-code": "provider.name.commandCodeAuth", commandcode: "provider.name.commandCodeApi", + orcarouter: "provider.name.orcaRouterApi", + "orcarouter-oauth": "provider.name.orcaRouterAuth", volcengine: "provider.name.volcengine", "volcengine-coding-plan": "provider.name.volcengineCodingPlan", "volcengine-agent-plan": "provider.name.volcengineAgentPlan", diff --git a/gui/tests/add-provider-oauth-url-leak.test.tsx b/gui/tests/add-provider-oauth-url-leak.test.tsx index 46e9f63843..c21cf372b5 100644 --- a/gui/tests/add-provider-oauth-url-leak.test.tsx +++ b/gui/tests/add-provider-oauth-url-leak.test.tsx @@ -109,9 +109,9 @@ function ProvidersOAuthHarness() { const aliveRef = useRef(true); const startedRef = useRef(false); const [accountSets, setAccountSets] = useState>({}); - const [, setBusy] = useState(null); + const [busy, setBusy] = useState(null); const [, setStatus] = useState(""); - const [, setLoginInfo] = useState<{ provider: string; url?: string; instructions?: string; deviceCode?: string } | null>(null); + const [loginInfo, setLoginInfo] = useState<{ provider: string; url?: string; instructions?: string; deviceCode?: string } | null>(null); const [, setOauthStatus] = useState>({}); useEffect(() => () => { aliveRef.current = false; }, []); @@ -138,7 +138,19 @@ function ProvidersOAuthHarness() { startedRef.current = true; void loginOAuth("orcarouter-oauth"); }, [loginOAuth]); - return null; + return ( + <> + {busy ?? "idle"} + {loginInfo?.url ?? "no-login-info"} + + + ); } async function mountProvidersOAuthHarness() { @@ -236,17 +248,58 @@ test("leaving the providers page cancels its in-flight account login", async () expect(cancelledProviders).toEqual(["orcarouter-oauth"]); }); -test("closing or reloading the management page cancels its in-flight account login", async () => { +test("pagehide cancels an account login and allows another login after bfcache restore", async () => { await mountProvidersOAuthHarness(); await act(async () => { pendingLogins.shift()?.(A_URL); await new Promise((r) => setTimeout(r, 20)); }); - win.dispatchEvent(new win.Event("pagehide")); - await new Promise((r) => setTimeout(r, 20)); + expect(host.querySelector('[data-testid="oauth-busy"]')?.textContent).toBe("orcarouter-oauth"); + expect(host.querySelector('[data-testid="oauth-login-info"]')?.textContent).toBe(A_URL); + await act(async () => { + win.dispatchEvent(new win.Event("pagehide")); + await new Promise((r) => setTimeout(r, 20)); + }); expect(cancelledProviders).toEqual(["orcarouter-oauth"]); + expect(host.querySelector('[data-testid="oauth-busy"]')?.textContent).toBe("idle"); + expect(host.querySelector('[data-testid="oauth-login-info"]')?.textContent).toBe("no-login-info"); + const loginAgain = Array.from(host.querySelectorAll("button")).find(button => button.textContent?.includes("Log in again")); + expect(loginAgain?.disabled).toBe(false); + await act(async () => { + loginAgain?.dispatchEvent(new win.MouseEvent("click", { bubbles: true })); + await new Promise((r) => setTimeout(r, 20)); + }); + expect(pendingLogins).toHaveLength(1); +}); + +test("pagehide clears the add-provider OAuth hint and allows another login", async () => { + await mountModal(); + clickByText("Claude"); + await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); + clickByText("Log in with Claude"); + await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); + await act(async () => { + pendingLogins.shift()?.(A_URL); + await new Promise((r) => setTimeout(r, 20)); + }); + + expect(host.querySelector(".login-url-block-text")?.textContent).toBe(A_URL); + await act(async () => { + win.dispatchEvent(new win.Event("pagehide")); + await new Promise((r) => setTimeout(r, 20)); + }); + + expect(cancelledProviders).toEqual(["claude"]); + expect(host.querySelector(".login-url-block-text")).toBeNull(); + const loginAgain = Array.from(host.querySelectorAll("button")).find(button => button.textContent?.includes("Log in with Claude")); + expect(loginAgain?.disabled).toBe(false); + await act(async () => { + loginAgain?.dispatchEvent(new win.MouseEvent("click", { bubbles: true })); + await new Promise((r) => setTimeout(r, 20)); + }); + expect(pendingLogins).toHaveLength(1); }); test("the add-provider OAuth pane can cancel an in-flight login", async () => { diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts index 8c1ca29ada..87250bb74b 100644 --- a/gui/tests/fr-localization.test.ts +++ b/gui/tests/fr-localization.test.ts @@ -51,6 +51,7 @@ const INTENTIONAL_ENGLISH = new Set([ "api.protocolMessages", "provider.name.commandCodeAuth", "provider.name.commandCodeApi", + "provider.name.orcaRouterApi", "provider.name.volcengine", "provider.name.volcengineCodingPlan", "provider.name.volcengineAgentPlan", diff --git a/gui/tests/locale-parity.test.ts b/gui/tests/locale-parity.test.ts index 5ea6785493..9754b98051 100644 --- a/gui/tests/locale-parity.test.ts +++ b/gui/tests/locale-parity.test.ts @@ -136,6 +136,7 @@ const ZH_TW_KEEP_ENGLISH: ReadonlySet = new Set([ // Provider proper nouns kept in English "provider.name.commandCodeAuth", "provider.name.commandCodeApi", + "provider.name.orcaRouterApi", // Routing analytics identifiers and short labels "routing.revision", "routing.unavailable", diff --git a/tests/providers/orcarouter-provider.test.ts b/tests/providers/orcarouter-provider.test.ts index c48768b505..7a5e2c36a0 100644 --- a/tests/providers/orcarouter-provider.test.ts +++ b/tests/providers/orcarouter-provider.test.ts @@ -28,10 +28,13 @@ import { } from "../../src/providers/model-discovery"; import { PROVIDER_REGISTRY } from "../../src/providers/registry"; import type { OcxConfig } from "../../src/types"; +import { en } from "../../gui/src/i18n/en"; +import { interpolate, type TFn } from "../../gui/src/i18n/shared"; import { formatProviderDisplayName, providerIconSrc } from "../../gui/src/provider-icons"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const originalFetch = globalThis.fetch; +const englishT: TFn = (key, vars) => interpolate(en[key], vars); afterEach(() => { globalThis.fetch = originalFetch; @@ -72,8 +75,8 @@ describe("OrcaRouter dual authentication", () => { expect(OAUTH_PROVIDERS["orcarouter-oauth"]).toBeDefined(); expect(deriveProviderPresets().find(row => row.id === "orcarouter")).toMatchObject({ auth: "key" }); expect(deriveProviderPresets().find(row => row.id === "orcarouter-oauth")).toMatchObject({ auth: "oauth" }); - expect(formatProviderDisplayName("orcarouter")).toBe("OrcaRouter - API"); - expect(formatProviderDisplayName("orcarouter-oauth")).toBe("OrcaRouter - Auth"); + expect(formatProviderDisplayName("orcarouter", englishT)).toBe("OrcaRouter - API"); + expect(formatProviderDisplayName("orcarouter-oauth", englishT)).toBe("OrcaRouter - Auth"); expect(providerIconSrc("orcarouter")).toBe("/provider-icons/orcarouter.png"); expect(providerIconSrc("orcarouter-oauth")).toBe("/provider-icons/orcarouter.png"); }); From 2e6b349c154362e77e89046a85507b4bf74ebedd Mon Sep 17 00:00:00 2001 From: t Date: Tue, 8 Sep 2026 00:16:26 +0900 Subject: [PATCH 5/5] test(orcarouter): expect the SVG mark both provider ids alias --- tests/providers/orcarouter-provider.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/providers/orcarouter-provider.test.ts b/tests/providers/orcarouter-provider.test.ts index 7a5e2c36a0..0c7d002a15 100644 --- a/tests/providers/orcarouter-provider.test.ts +++ b/tests/providers/orcarouter-provider.test.ts @@ -77,8 +77,8 @@ describe("OrcaRouter dual authentication", () => { expect(deriveProviderPresets().find(row => row.id === "orcarouter-oauth")).toMatchObject({ auth: "oauth" }); expect(formatProviderDisplayName("orcarouter", englishT)).toBe("OrcaRouter - API"); expect(formatProviderDisplayName("orcarouter-oauth", englishT)).toBe("OrcaRouter - Auth"); - expect(providerIconSrc("orcarouter")).toBe("/provider-icons/orcarouter.png"); - expect(providerIconSrc("orcarouter-oauth")).toBe("/provider-icons/orcarouter.png"); + expect(providerIconSrc("orcarouter")).toBe("/provider-icons/orcarouter.svg"); + expect(providerIconSrc("orcarouter-oauth")).toBe("/provider-icons/orcarouter.svg"); }); test("discovers the live chat catalog with bounded declarative filtering", () => {