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 e92bf67874..82c30a2c30 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 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. @@ -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 3c72c4b302..d8ae722afe 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -409,6 +409,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..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 @@ -88,6 +89,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 +104,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 +266,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/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")}
+ {oauthBusy && preset.oauthProvider && ( + + )}
diff --git a/gui/src/components/use-add-provider-oauth.ts b/gui/src/components/use-add-provider-oauth.ts index 5b93ad3f7e..87ec2146a8 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,69 @@ export function useAddProviderOAuth({ aliveRef: React.MutableRefObject; onAdded: (name: string) => void; }) { + const loginGenerationRef = useRef(new Map()); + const activeProvidersRef = useRef(new Map()); + + 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 = (clearUi: boolean) => { + const providers = [...activeProvidersRef.current]; + activeProvidersRef.current.clear(); + for (const [providerId, setters] of providers) { + bumpLoginGeneration(providerId); + if (clearUi) { + setters.setOauthBusy(false); + setters.setOauthUrl("", providerId); + setters.setOauthMsg(""); + } + void cancelServerLogin(providerId); + } + }; + const onPageHide = () => cancelActiveLogins(true); + window.addEventListener("pagehide", onPageHide); + return () => { + window.removeEventListener("pagehide", onPageHide); + cancelActiveLogins(false); + }; + }, [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.set(providerId, setters); setOauthBusy(true); setOauthMsg(""); setOauthMsgTone("ok"); @@ -42,8 +102,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 +121,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 +196,5 @@ export function useAddProviderOAuth({ } }, [aliveRef, apiBase, t]); - return { loginOAuth, submitManualCode }; + return { cancelLoginOAuth, loginOAuth, submitManualCode }; } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index d413e1277c..b7e62e0ea8 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 b842f4618c..fdb91fad32 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 d07b7f7a16..4354e6fd56 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 1db94902c5..dfbab83390 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 b31d6e4e1a..19855bdd33 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 a8370e7f5f..194d7aa72a 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 6a7c02c936..aa97b22ff2 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 d59fa6d6bc..db9829821d 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 d20318ce42..a13ff07973 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 3440939ef1..9de0a7537d 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,51 @@ 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 = (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); + } + }; + const onPageHide = () => cancelActiveLogins(true); + window.addEventListener("pagehide", onPageHide); + return () => { + window.removeEventListener("pagehide", onPageHide); + cancelActiveLogins(false); + }; + }, [bumpLoginGeneration, cancelServerLogin, setBusy, setLoginInfo]); + 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 +202,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..7f7996a085 100644 --- a/gui/src/provider-icons.ts +++ b/gui/src/provider-icons.ts @@ -60,6 +60,7 @@ const PROVIDER_ICON_ALIASES: Record = { nous: "nous.svg", novita: "novita.svg", orcarouter: "orcarouter.svg", + "orcarouter-oauth": "orcarouter.svg", 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", @@ -146,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 8265f64071..c21cf372b5 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,68 @@ 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 [busy, setBusy] = useState(null); + const [, setStatus] = useState(""); + const [loginInfo, 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 ( + <> + {busy ?? "idle"} + {loginInfo?.url ?? "no-login-info"} + + + ); +} + +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 +213,148 @@ 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("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)); + }); + + 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 () => { + 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/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/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index a1b6e1d46e..e1b29d490b 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..904674716d 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" }), @@ -548,7 +572,13 @@ export async function getValidAccessTokenSnapshot(provider: string): Promise 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..afbf6066f4 --- /dev/null +++ b/src/oauth/orcarouter.ts @@ -0,0 +1,198 @@ +/** 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 assertDurableApiKey(apiKey: string): void { + 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"); + } +} + +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 { + 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/providers/registry.ts b/src/providers/registry.ts index b1f689a8bd..5e46f27d60 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1122,6 +1122,45 @@ 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, + }], + }, +}; +// 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 = { + "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 +1399,25 @@ 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", + models: ORCAROUTER_MODELS, + 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 +1892,23 @@ 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", + models: ORCAROUTER_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/src/server/responses/core.ts b/src/server/responses/core.ts index 741b93d74b..7281bcd305 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -3705,6 +3705,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/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index fbd8836dea..02c2062062 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..0c7d002a15 --- /dev/null +++ b/tests/providers/orcarouter-provider.test.ts @@ -0,0 +1,259 @@ +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 { + forceRefreshOAuthAccessSnapshot, + getValidAccessTokenSnapshot, + 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 { getAccountSet, saveCredential } from "../../src/oauth/store"; +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 { 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; +}); + +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, + }); + 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" }); + 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.svg"); + expect(providerIconSrc("orcarouter-oauth")).toBe("/provider-icons/orcarouter.svg"); + }); + + test("discovers the live chat catalog with bounded declarative filtering", () => { + const entry = registryEntry("orcarouter"); + expect(providerModelDiscoverySpecError(entry.modelDiscovery!)).toBeNull(); + 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( + "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 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")) + .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); + } + }); +}); 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", ]);