From b12f66f5d0d9ac4f825922e6846a42252a990762 Mon Sep 17 00:00:00 2001 From: backryun Date: Thu, 9 Jul 2026 08:32:53 +0900 Subject: [PATCH 1/3] fix(lmarena): modernize Arena web provider + static Direct-chat catalog Update the lmarena provider for arena.ai (product rebranded from LMArena): - Route chat via arena.ai create-evaluation with Chrome TLS impersonation (tls-client-node) and optional browser-minted recaptchaV3Token. - Seed Text+Search (48) into the chat registry; seed Image (27) only into IMAGE_PROVIDERS. Disable live HTML model discovery; resolve public names to Arena UUIDs from the static TypeScript allowlist (no scrape JSON in-repo). - Soft-exclude 404/502 model ids; slow/stop bulk test-all probes for this provider. - Do not fold IMAGE_PROVIDERS/video specialty into the chat provider catalog when a chat registry already exists (lmarena/openai/xai). - Display name Arena (Free); keep wire id `lmarena` / alias `lma` for back-compat. - Theme-aware provider icons: arena-light.svg / arena-dark.svg. - Preserve split Supabase SSR cookie reconstruction for arena-auth-prod-v1.*. --- config/quality/eslint-suppressions.json | 5 - docs/reference/PROVIDER_REFERENCE.md | 534 ++++++------- open-sse/config/imageRegistry.ts | 22 +- open-sse/config/providers/index.ts | 2 + .../registry/lmarena/directModels.ts | 737 ++++++++++++++++++ .../providers/registry/lmarena/index.ts | 18 + open-sse/executors/lmarena.ts | 596 ++++---------- open-sse/executors/lmarena/cookie.ts | 103 +++ open-sse/executors/lmarena/models.ts | 307 ++++++++ open-sse/executors/lmarena/response.ts | 305 ++++++++ open-sse/executors/lmarena/stream.ts | 132 ++++ open-sse/services/lmarenaTlsClient.ts | 605 ++++++++++++++ public/providers/arena-dark.svg | 1 + public/providers/arena-light.svg | 1 + .../[id]/components/modals/AddApiKeyModal.tsx | 36 +- .../components/modals/EditConnectionModal.tsx | 24 +- .../providers/[id]/providerPageHelpers.ts | 19 +- src/app/api/models/test-all/route.ts | 44 +- src/app/api/providers/[id]/models/route.ts | 7 + src/i18n/messages/en.json | 3 +- src/lib/providers/staticModels.ts | 31 +- src/shared/components/ProviderIcon.tsx | 62 +- src/shared/constants/providers/web-cookie.ts | 12 +- src/shared/providers/webSessionCredentials.ts | 10 +- tests/snapshots/provider/translate-path.json | 23 + .../unit/bailian-coding-plan-provider.test.ts | 25 +- tests/unit/lmarena-provider.test.ts | 460 +++++++++-- tests/unit/lmarena-split-cookie-4271.test.ts | 27 +- tests/unit/provider-icon-arena-themed.test.ts | 30 + tests/unit/t28-model-catalog-updates.test.ts | 25 + .../unit/t3chat-web-cookie-hint-5465.test.ts | 21 + ...i-key-modal-unsupported-save-5565.test.tsx | 31 +- .../validation-badge-unsupported-5442.test.ts | 24 +- tests/unit/web-session-credentials.test.ts | 37 +- 34 files changed, 3448 insertions(+), 871 deletions(-) create mode 100644 open-sse/config/providers/registry/lmarena/directModels.ts create mode 100644 open-sse/config/providers/registry/lmarena/index.ts create mode 100644 open-sse/executors/lmarena/cookie.ts create mode 100644 open-sse/executors/lmarena/models.ts create mode 100644 open-sse/executors/lmarena/response.ts create mode 100644 open-sse/executors/lmarena/stream.ts create mode 100644 open-sse/services/lmarenaTlsClient.ts create mode 100644 public/providers/arena-dark.svg create mode 100644 public/providers/arena-light.svg create mode 100644 tests/unit/provider-icon-arena-themed.test.ts diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index a3006bcdf392..9065f5db4835 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -1502,11 +1502,6 @@ "count": 1 } }, - "tests/unit/lmarena-provider.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, "tests/unit/lmarena-split-cookie-4271.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index e26558e11bc0..d691719dd2d8 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -1,14 +1,14 @@ --- title: "Provider Reference" version: 3.8.47 -lastUpdated: 2026-07-08 +lastUpdated: 2026-07-09 --- # Provider Reference > **Auto-generated** from `src/shared/constants/providers.ts` — do not edit by hand. > Regenerate with: `npm run gen:provider-reference` -> **Last generated:** 2026-07-08 +> **Last generated:** 2026-07-09 Total providers: **248**. See category breakdown below. @@ -33,296 +33,296 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each ## OAuth Providers (21) -| ID | Alias | Name | Tags | Website | Notes | -| -------------- | ------------ | -------------------- | ----- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `agy` | `agy` | Antigravity CLI | OAuth | [link](https://antigravity.google) | Import your Antigravity CLI (`agy`) login (paste/upload its token file), auto-detect a local CLI login, or sign in with Google. Shares the Antigravity backend (incl. Claude models). | -| `amazon-q` | `aq` | Amazon Q | OAuth | [link](https://aws.amazon.com/q/developer/) | Uses the same AWS Builder ID or imported refresh-token flow as Kiro, but keeps Amazon Q connections separate. | -| `antigravity` | — | Antigravity | OAuth | — | — | -| `claude` | `cc` | Claude Code | OAuth | — | — | -| `cline` | `cl` | Cline | OAuth | — | — | -| `codebuddy-cn` | `cbcn` | CodeBuddy CN | OAuth | [link](https://copilot.tencent.com) | Tencent CodeBuddy CN (copilot.tencent.com). Sign in via the official CLI device-code flow, or paste a direct API key (sent as Authorization: Bearer). Catalog: GLM / Kimi / MiniMax / DeepSeek / Hunyuan. | -| `codex` | `cx` | OpenAI Codex | OAuth | — | — | -| `cursor` | `cu` | Cursor IDE | OAuth | — | — | -| `devin-cli` | `dv` | Devin CLI (Official) | OAuth | [link](https://cli.devin.ai) | Requires the Devin CLI binary. Run `devin auth login` to authenticate, or provide your WINDSURF_API_KEY. Install: https://cli.devin.ai | -| `github` | `gh` | GitHub Copilot | OAuth | — | — | -| `gitlab-duo` | `gitlab-duo` | GitLab Duo | OAuth | [link](https://docs.gitlab.com/user/duo_agent_platform/code_suggestions/) | OAuth application with ai_features + read_user scopes. Configure GITLAB_DUO_OAUTH_CLIENT_ID and optionally GITLAB_DUO_OAUTH_CLIENT_SECRET on this OmniRoute instance. | -| `grok-cli` | `gc` | Grok Build | OAuth | — | Paste your ~/.grok/auth.json (or the JWT access token) from the Grok Build CLI; refresh_token is rotated automatically. | -| `kilocode` | `kc` | Kilo Code | OAuth | — | — | -| `kimi-coding` | `kmc` | Kimi Coding | OAuth | — | — | -| `kiro` | `kr` | Kiro AI | OAuth | — | Free tier: 50 credits/month (~25K–100K tokens). ⚠️ Kiro ToS prohibits third-party proxy/harness use. | -| `qoder` | `if` | Qoder | OAuth | — | — | -| `qwen` | `qw` | Qwen Code | OAuth | — | ⚠️ **DEPRECATED.** Qwen OAuth free tier was discontinued on 2026-04-15. Use 'bailian-coding-plan', 'alibaba', 'alibaba-cn', or 'openrouter' provider with API key instead. | -| `trae` | `tr` | Trae | OAuth | [link](https://trae.ai) | Trae is an AI-native IDE by ByteDance (SOLO remote agent). Authorize via trae.ai in the popup, or sign in at solo.trae.ai and paste the Cloud-IDE-JWT (sent as 'Authorization: Cloud-IDE-JWT ', ~14-day lifetime) as the access token; web_id/biz_user_id/user_unique_id/scope/tenant/region propagate via providerSpecificData. No headless refresh for pasted tokens — re-paste on expiry. | -| `windsurf` | `ws` | Windsurf (Devin CLI) | OAuth | [link](https://windsurf.com) | In the Windsurf / VS Code IDE, open the command palette and run `Windsurf: Provide Auth Token` (or click the Jupyter "Get Windsurf Authentication Token" button), then copy the shown token and paste it here. Note: opening windsurf.com/show-auth-token directly only renders a "Redirecting" page — the IDE must initiate the flow (it adds a `?state=...` param) for the token to appear. | -| `zed` | `zd` | Zed IDE | OAuth | [link](https://zed.dev) | Zed stores LLM provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) in the OS keychain. Use the Import button below to discover and import them automatically. | -| `zed-hosted` | — | Zed Hosted Models | OAuth | [link](https://zed.dev) | Sign in with your Zed account (native-app sign-in). OmniRoute generates a one-time RSA keypair and opens zed.dev to authorize it — on a remote/headless install, copy the resulting 127.0.0.1 callback URL from your browser's address bar and paste it back here. Distinct from the 'Zed IDE' credential-import entry above: this proxies chat completions through Zed's own hosted model aggregator (cloud.zed.dev), fronting Anthropic/OpenAI/Google/xAI models under your Zed plan. | +| ID | Alias | Name | Tags | Website | Notes | +|----|-------|------|------|---------|-------| +| `agy` | `agy` | Antigravity CLI | OAuth | [link](https://antigravity.google) | Import your Antigravity CLI (`agy`) login (paste/upload its token file), auto-detect a local CLI login, or sign in with Google. Shares the Antigravity backend (incl. Claude models). | +| `amazon-q` | `aq` | Amazon Q | OAuth | [link](https://aws.amazon.com/q/developer/) | Uses the same AWS Builder ID or imported refresh-token flow as Kiro, but keeps Amazon Q connections separate. | +| `antigravity` | — | Antigravity | OAuth | — | — | +| `claude` | `cc` | Claude Code | OAuth | — | — | +| `cline` | `cl` | Cline | OAuth | — | — | +| `codebuddy-cn` | `cbcn` | CodeBuddy CN | OAuth | [link](https://copilot.tencent.com) | Tencent CodeBuddy CN (copilot.tencent.com). Sign in via the official CLI device-code flow, or paste a direct API key (sent as Authorization: Bearer). Catalog: GLM / Kimi / MiniMax / DeepSeek / Hunyuan. | +| `codex` | `cx` | OpenAI Codex | OAuth | — | — | +| `cursor` | `cu` | Cursor IDE | OAuth | — | — | +| `devin-cli` | `dv` | Devin CLI (Official) | OAuth | [link](https://cli.devin.ai) | Requires the Devin CLI binary. Run `devin auth login` to authenticate, or provide your WINDSURF_API_KEY. Install: https://cli.devin.ai | +| `github` | `gh` | GitHub Copilot | OAuth | — | — | +| `gitlab-duo` | `gitlab-duo` | GitLab Duo | OAuth | [link](https://docs.gitlab.com/user/duo_agent_platform/code_suggestions/) | OAuth application with ai_features + read_user scopes. Configure GITLAB_DUO_OAUTH_CLIENT_ID and optionally GITLAB_DUO_OAUTH_CLIENT_SECRET on this OmniRoute instance. | +| `grok-cli` | `gc` | Grok Build | OAuth | — | Paste your ~/.grok/auth.json (or the JWT access token) from the Grok Build CLI; refresh_token is rotated automatically. | +| `kilocode` | `kc` | Kilo Code | OAuth | — | — | +| `kimi-coding` | `kmc` | Kimi Coding | OAuth | — | — | +| `kiro` | `kr` | Kiro AI | OAuth | — | Free tier: 50 credits/month (~25K–100K tokens). ⚠️ Kiro ToS prohibits third-party proxy/harness use. | +| `qoder` | `if` | Qoder | OAuth | — | — | +| `qwen` | `qw` | Qwen Code | OAuth | — | ⚠️ **DEPRECATED.** Qwen OAuth free tier was discontinued on 2026-04-15. Use 'bailian-coding-plan', 'alibaba', 'alibaba-cn', or 'openrouter' provider with API key instead. | +| `trae` | `tr` | Trae | OAuth | [link](https://trae.ai) | Trae is an AI-native IDE by ByteDance (SOLO remote agent). Authorize via trae.ai in the popup, or sign in at solo.trae.ai and paste the Cloud-IDE-JWT (sent as 'Authorization: Cloud-IDE-JWT ', ~14-day lifetime) as the access token; web_id/biz_user_id/user_unique_id/scope/tenant/region propagate via providerSpecificData. No headless refresh for pasted tokens — re-paste on expiry. | +| `windsurf` | `ws` | Windsurf (Devin CLI) | OAuth | [link](https://windsurf.com) | In the Windsurf / VS Code IDE, open the command palette and run `Windsurf: Provide Auth Token` (or click the Jupyter "Get Windsurf Authentication Token" button), then copy the shown token and paste it here. Note: opening windsurf.com/show-auth-token directly only renders a "Redirecting" page — the IDE must initiate the flow (it adds a `?state=...` param) for the token to appear. | +| `zed` | `zd` | Zed IDE | OAuth | [link](https://zed.dev) | Zed stores LLM provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) in the OS keychain. Use the Import button below to discover and import them automatically. | +| `zed-hosted` | — | Zed Hosted Models | OAuth | [link](https://zed.dev) | Sign in with your Zed account (native-app sign-in). OmniRoute generates a one-time RSA keypair and opens zed.dev to authorize it — on a remote/headless install, copy the resulting 127.0.0.1 callback URL from your browser's address bar and paste it back here. Distinct from the 'Zed IDE' credential-import entry above: this proxies chat completions through Zed's own hosted model aggregator (cloud.zed.dev), fronting Anthropic/OpenAI/Google/xAI models under your Zed plan. | ## Web Cookie Providers (24) -| ID | Alias | Name | Tags | Website | Notes | -| ------------------ | ------------- | ------------------------------- | ---------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `adapta-web` | `adp-web` | Adapta.org (Adapta One Web) | Web cookie | [link](https://agent.adapta.one) | Paste your __client cookie value from .clerk.agent.adapta.one (DevTools → Application → Cookies) | -| `blackbox-web` | `bb-web` | Blackbox Web (Subscription) | Web cookie | [link](https://app.blackbox.ai) | Paste your __Secure-authjs.session-token value or full cookie header from app.blackbox.ai | -| `chatgpt-web` | `cgpt-web` | ChatGPT Web (Plus/Pro) | Web cookie | [link](https://chatgpt.com) | Paste your __Secure-next-auth.session-token cookie value from chatgpt.com | -| `claude-web` | `cw` | Claude Web | Web cookie | [link](https://claude.ai) | Paste your session cookie from claude.ai | +| ID | Alias | Name | Tags | Website | Notes | +|----|-------|------|------|---------|-------| +| `adapta-web` | `adp-web` | Adapta.org (Adapta One Web) | Web cookie | [link](https://agent.adapta.one) | Paste your __client cookie value from .clerk.agent.adapta.one (DevTools → Application → Cookies) | +| `blackbox-web` | `bb-web` | Blackbox Web (Subscription) | Web cookie | [link](https://app.blackbox.ai) | Paste your __Secure-authjs.session-token value or full cookie header from app.blackbox.ai | +| `chatgpt-web` | `cgpt-web` | ChatGPT Web (Plus/Pro) | Web cookie | [link](https://chatgpt.com) | Paste your __Secure-next-auth.session-token cookie value from chatgpt.com | +| `claude-web` | `cw` | Claude Web | Web cookie | [link](https://claude.ai) | Paste your session cookie from claude.ai | | `copilot-m365-web` | `m365copilot` | Microsoft 365 Copilot (BizChat) | Web cookie | [link](https://m365.cloud.microsoft/chat) | Sign in at m365.cloud.microsoft/chat, then open DevTools → Network → filter 'WS' → click the Chathub WebSocket connection. Copy both the access_token query parameter AND the account-specific Chathub path segment from its request URL (wss://…/Chathub/?…&access_token=…). It is NOT an Authorization: Bearer header on an XHR/Fetch request. The token is short-lived; this is an unofficial integration. | -| `copilot-web` | `copilot` | Microsoft Copilot Web | Web cookie | [link](https://copilot.microsoft.com) | Paste your access_token from copilot.microsoft.com (or export a .har file from DevTools while logged in) | -| `deepseek-web` | `ds-web` | DeepSeek Web | Web cookie | [link](https://chat.deepseek.com) | Paste your userToken from chat.deepseek.com — DevTools → Application → Local Storage → userToken | -| `doubao-web` | `db` | Dola Web (ByteDance) | Web cookie | [link](https://www.dola.com) | Paste the full Cookie header from www.dola.com. It should include sessionid, ttwid, and s_v_web_id. If s_v_web_id is unavailable, fp=verify_... from a chat/completion request URL can be used as a fallback. | -| `gemini-business` | `gembiz` | Gemini Business (Enterprise) | Web cookie | [link](https://business.gemini.google) | From your enterprise account: open business.gemini.google/home/cid/{your-cid}, then copy __Secure-1PSID and __Secure-1PSIDTS cookies from DevTools → Application → Cookies. Paste as a cookie header below. | -| `gemini-web` | `gweb` | Gemini Web (Free) | Web cookie | [link](https://gemini.google.com) | Paste your __Secure-1PSID cookie value from gemini.google.com. Optionally add __Secure-1PSIDTS separated by semicolon. | -| `grok-web` | `gw` | Grok Web (Subscription) | Web cookie | [link](https://grok.com) | Paste the full grok.com cookie line from DevTools → Application → Cookies. Include both `sso` and `sso-rw` (e.g. `sso=...; sso-rw=...`) — Grok's anti-bot rejects `sso` on its own. | -| `huggingchat` | `huggingchat` | HuggingChat (Free) | Web cookie | [link](https://huggingface.co/chat) | Paste the full Cookie header from huggingface.co/chat (DevTools → Network → /chat/conversation → Request Headers → Cookie). It should include hf-chat and may also include token / aws-waf-token. | -| `inner-ai` | `in-ai` | Inner.ai (Subscription) | Web cookie | [link](https://app.innerai.com) | Paste your token cookie and email separated by a space: open DevTools → Application → Cookies → .innerai.com, copy the token value, then append a space and your Inner.ai login email. Example: eyJhbG... user@example.com | -| `kimi-web` | `kimi-web` | Kimi Web (Moonshot AI) | Web cookie | [link](https://www.kimi.com) | Paste your Cookie header from www.kimi.com (must contain kimi-auth=...). Find it via DevTools → Network → request → Cookie. | -| `lmarena` | `lma` | LMArena (Free) | Web cookie | [link](https://lmarena.ai) | Paste the full Cookie header from lmarena.ai (DevTools → Network → request → Cookie). The session is now split across arena-auth-prod-v1.0, .1, … — copy the whole header. Optional — works with free tier for basic comparisons. | -| `muse-spark-web` | `ms-web` | Muse Spark Web (Meta AI) | Web cookie | [link](https://www.meta.ai) | Paste your ecto_1_sess value or full cookie header from meta.ai | -| `perplexity-web` | `pplx-web` | Perplexity Web (Pro/Max) | Web cookie | [link](https://www.perplexity.ai) | Paste your __Secure-next-auth.session-token cookie value from perplexity.ai | -| `poe-web` | `poe` | Poe Web (Subscription) | Web cookie | [link](https://poe.com) | Paste your p-b cookie value from poe.com (DevTools → Application → Cookies → p-b) | -| `qwen-web` | `qwen-web` | Qwen Web (Free) | Web cookie | [link](https://chat.qwen.ai) | Open chat.qwen.ai, log in, then open DevTools → Application → Local Storage → copy the "token" value (or use tongyi_sso_ticket cookie as Bearer token). | -| `t3-web` | `t3chat` | t3.chat (Pro/Free) | Web cookie | [link](https://t3.chat) | Open t3.chat in your browser, log in, then open DevTools → Application → Local Storage → https://t3.chat. Copy the value of 'convex-session-id'. Also open DevTools → Network, copy the Cookie header from any request. Paste both values here. See provider setup docs for a step-by-step guide. | -| `v0-vercel-web` | `v0` | v0 Vercel Web (Code Gen) | Web cookie | [link](https://v0.dev) | Paste your session cookie from v0.dev (DevTools → Application → Cookies) | -| `venice-web` | `ven` | Venice Web (Privacy) | Web cookie | [link](https://venice.ai) | Paste your session cookie from venice.ai (DevTools → Application → Cookies) | -| `yuanbao-web` | `ybw` | Tencent Yuanbao (Free) | Web cookie | [link](https://yuanbao.tencent.com) | Log in to yuanbao.tencent.com, then paste the full Cookie header (DevTools → Network → any /api request → Request Headers → Cookie). It must contain hy_user and hy_token. | -| `zenmux-free` | `zmf` | ZenMux Free (Web) | Web cookie | [link](https://zenmux.ai) | Login at zenmux.ai, then export all cookies using EditThisCookie or Cookie-Editor and paste the full Cookie header string here. Refresh every ~30 days. | +| `copilot-web` | `copilot` | Microsoft Copilot Web | Web cookie | [link](https://copilot.microsoft.com) | Paste your access_token from copilot.microsoft.com (or export a .har file from DevTools while logged in) | +| `deepseek-web` | `ds-web` | DeepSeek Web | Web cookie | [link](https://chat.deepseek.com) | Paste your userToken from chat.deepseek.com — DevTools → Application → Local Storage → userToken | +| `doubao-web` | `db` | Dola Web (ByteDance) | Web cookie | [link](https://www.dola.com) | Paste the full Cookie header from www.dola.com. It should include sessionid, ttwid, and s_v_web_id. If s_v_web_id is unavailable, fp=verify_... from a chat/completion request URL can be used as a fallback. | +| `gemini-business` | `gembiz` | Gemini Business (Enterprise) | Web cookie | [link](https://business.gemini.google) | From your enterprise account: open business.gemini.google/home/cid/{your-cid}, then copy __Secure-1PSID and __Secure-1PSIDTS cookies from DevTools → Application → Cookies. Paste as a cookie header below. | +| `gemini-web` | `gweb` | Gemini Web (Free) | Web cookie | [link](https://gemini.google.com) | Paste your __Secure-1PSID cookie value from gemini.google.com. Optionally add __Secure-1PSIDTS separated by semicolon. | +| `grok-web` | `gw` | Grok Web (Subscription) | Web cookie | [link](https://grok.com) | Paste the full grok.com cookie line from DevTools → Application → Cookies. Include both `sso` and `sso-rw` (e.g. `sso=...; sso-rw=...`) — Grok's anti-bot rejects `sso` on its own. | +| `huggingchat` | `huggingchat` | HuggingChat (Free) | Web cookie | [link](https://huggingface.co/chat) | Paste the full Cookie header from huggingface.co/chat (DevTools → Network → /chat/conversation → Request Headers → Cookie). It should include hf-chat and may also include token / aws-waf-token. | +| `inner-ai` | `in-ai` | Inner.ai (Subscription) | Web cookie | [link](https://app.innerai.com) | Paste your token cookie and email separated by a space: open DevTools → Application → Cookies → .innerai.com, copy the token value, then append a space and your Inner.ai login email. Example: eyJhbG... user@example.com | +| `kimi-web` | `kimi-web` | Kimi Web (Moonshot AI) | Web cookie | [link](https://www.kimi.com) | Paste your Cookie header from www.kimi.com (must contain kimi-auth=...). Find it via DevTools → Network → request → Cookie. | +| `lmarena` | `lma` | Arena (Free) | Web cookie | [link](https://arena.ai) | Paste the full Cookie header from arena.ai (DevTools → Network → request → Cookie). Include arena-auth-prod-v1.0/.1… and cf_clearance/__cf_bm when present. OmniRoute uses Chrome TLS impersonation; if Arena still 403s, set providerSpecificData.recaptchaV3Token from a live browser session. | +| `muse-spark-web` | `ms-web` | Muse Spark Web (Meta AI) | Web cookie | [link](https://www.meta.ai) | Paste your ecto_1_sess value or full cookie header from meta.ai | +| `perplexity-web` | `pplx-web` | Perplexity Web (Pro/Max) | Web cookie | [link](https://www.perplexity.ai) | Paste your __Secure-next-auth.session-token cookie value from perplexity.ai | +| `poe-web` | `poe` | Poe Web (Subscription) | Web cookie | [link](https://poe.com) | Paste your p-b cookie value from poe.com (DevTools → Application → Cookies → p-b) | +| `qwen-web` | `qwen-web` | Qwen Web (Free) | Web cookie | [link](https://chat.qwen.ai) | Open chat.qwen.ai, log in, then open DevTools → Application → Local Storage → copy the "token" value (or use tongyi_sso_ticket cookie as Bearer token). | +| `t3-web` | `t3chat` | t3.chat (Pro/Free) | Web cookie | [link](https://t3.chat) | Open t3.chat in your browser, log in, then open DevTools → Application → Local Storage → https://t3.chat. Copy the value of 'convex-session-id'. Also open DevTools → Network, copy the Cookie header from any request. Paste both values here. See provider setup docs for a step-by-step guide. | +| `v0-vercel-web` | `v0` | v0 Vercel Web (Code Gen) | Web cookie | [link](https://v0.dev) | Paste your session cookie from v0.dev (DevTools → Application → Cookies) | +| `venice-web` | `ven` | Venice Web (Privacy) | Web cookie | [link](https://venice.ai) | Paste your session cookie from venice.ai (DevTools → Application → Cookies) | +| `yuanbao-web` | `ybw` | Tencent Yuanbao (Free) | Web cookie | [link](https://yuanbao.tencent.com) | Log in to yuanbao.tencent.com, then paste the full Cookie header (DevTools → Network → any /api request → Request Headers → Cookie). It must contain hy_user and hy_token. | +| `zenmux-free` | `zmf` | ZenMux Free (Web) | Web cookie | [link](https://zenmux.ai) | Login at zenmux.ai, then export all cookies using EditThisCookie or Cookie-Editor and paste the full Cookie header string here. Refresh every ~30 days. | ## API Key Providers (paid / paid-with-free-credits) (167) -| ID | Alias | Name | Tags | Website | Notes | -| --------------------- | -------------- | ------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `360ai` | `360ai` | 360 AI | API key | [link](https://ai.360.cn) | Get API key at ai.360.cn | -| `agentrouter` | `agentrouter` | AgentRouter | API key, aggregator | [link](https://agentrouter.org) | $200 free credits on signup - multi-model routing gateway | -| `ai21` | `ai21` | AI21 Labs | API key | [link](https://www.ai21.com) | $10 trial credits on signup (valid 3 months), no credit card required | -| `aimlapi` | `aiml` | AI/ML API | API key, aggregator | [link](https://aimlapi.com) | Free tier paused (2026) — AI/ML API is now pay-as-you-go only (min $20 top-up); no recurring free credits. | -| `alibaba` | `ali` | Alibaba | API key | [link](https://bailian.console.alibabacloud.com/) | — | -| `alibaba-cn` | `ali-cn` | Alibaba (China) | API key | [link](https://dashscope.console.aliyun.com/) | — | -| `anthropic` | `anthropic` | Anthropic | API key | [link](https://platform.claude.com) | — | -| `api-airforce` | `af` | Api.airforce | API key | [link](https://api.airforce) | 55 free tier models including Grok-3, Claude 3.7, Qwen3, Kimi-K2, Gemini 2.5 Flash, DeepSeek-V3 | -| `arcee-ai` | `arcee` | Arcee AI | API key | [link](https://arcee.ai) | Get API key at arcee.ai | -| `azure-ai` | `azure-ai` | Azure AI Foundry | API key, enterprise | [link](https://learn.microsoft.com/azure/ai-foundry) | Use your Azure AI Foundry key. Base URL can be https://.services.ai.azure.com/openai/v1/ or https://.openai.azure.com/openai/v1/. | -| `azure-openai` | `azure` | Azure OpenAI | API key, enterprise | [link](https://azure.microsoft.com/products/ai-services/openai-service) | Use your Azure OpenAI API key. Base URL should be your resource endpoint, for example https://my-resource.openai.azure.com. | -| `bai` | `bai` | b.ai | API key | [link](https://b.ai) | Bearer API key for the b.ai OpenAI-compatible LLM gateway (distinct from TheB.AI). Create a key at https://docs.b.ai, then use https://api.b.ai/v1 as the OpenAI-compatible base URL. | -| `baichuan` | `baichuan` | Baichuan | API key | [link](https://baichuan.com) | Get API key at platform.baichuan-ai.com | -| `baidu` | `baidu` | Baidu (ERNIE) | API key | [link](https://yiyan.baidu.com) | Get API key at console.bce.baidu.com | -| `bailian-coding-plan` | `bcp` | Alibaba Coding Plan | API key | [link](https://www.alibabacloud.com/help/en/model-studio/coding-plan) | — | -| `baseten` | `baseten` | Baseten | API key | [link](https://baseten.co) | $30 free trial credits for GPU inference | -| `bazaarlink` | `bzl` | BazaarLink | API key | [link](https://bazaarlink.ai) | Use your BazaarLink API key (starts with sk-bl-) in Authorization: Bearer . OpenAI SDK works with base URL https://bazaarlink.ai/api/v1. Models use provider/model-name format. | -| `bedrock` | `bedrock` | Amazon Bedrock | API key, enterprise | [link](https://aws.amazon.com/bedrock) | Use your Amazon Bedrock API key and configure the AWS region where your models are enabled (for example eu-west-2). OmniRoute calls Bedrock's native Converse API directly. | -| `black-forest-labs` | `bfl` | Black Forest Labs | API key, image | [link](https://blackforestlabs.ai) | — | -| `blackbox` | `bb` | Blackbox AI | API key | [link](https://blackbox.ai) | Free tier: unlimited basic chat plus Minimax-M2.5, no credit card required | -| `bluesminds` | `bm` | BluesMinds | API key | [link](https://www.bluesminds.com) | Free daily pi credits — supports 200+ models including GPT-4o, GPT-4.1, Claude Sonnet 4.5, Gemini 2.0 Flash, DeepSeek V4, Qwen, Kimi K2 | -| `byteplus` | `bpm` | BytePlus ModelArk | API key | [link](https://console.byteplus.com/ark) | — | -| `bytez` | `bytez` | Bytez | API key | [link](https://bytez.com) | $1 free credits, refreshes every 4 weeks | -| `cerebras` | `cerebras` | Cerebras | API key | [link](https://inference.cerebras.ai) | Free Trial: 1M tokens/day, 30K TPM, 5 RPM — no credit card. | -| `charm-hyper` | `charm-hyper` | Charm Hyper | API key | [link](https://hyper.charm.land) | 100 free monthly Hypercredits on signup | -| `chutes` | `chutes` | Chutes.ai | API key, aggregator | [link](https://chutes.ai) | Bearer API key for the Chutes OpenAI-compatible gateway. | -| `clarifai` | `clarifai` | Clarifai | API key, enterprise | [link](https://docs.clarifai.com) | Use your Clarifai PAT or app-specific API key. OmniRoute targets the OpenAI-compatible endpoint at https://api.clarifai.com/v2/ext/openai/v1 and authenticates with Authorization: Key . | -| `clinepass` | `clinepass` | ClinePass | API key | [link](https://cline.bot) | — | -| `cloudflare-ai` | `cf` | Cloudflare Workers AI | API key | [link](https://developers.cloudflare.com/workers-ai) | Requires API Token AND Account ID (found at dash.cloudflare.com) | -| `codestral` | `codestral` | Codestral | API key | [link](https://mistral.ai) | — | -| `cohere` | `cohere` | Cohere | API key | [link](https://cohere.com) | Free Trial: 1,000 API calls/month for testing, no credit card required | -| `command-code` | `cmd` | Command Code | API key | [link](https://commandcode.ai/) | Use a Command Code API key. Requests are sent to Command Code's /alpha/generate endpoint. | -| `coze` | `coze` | Coze | API key | [link](https://coze.com) | Get API key at coze.com/open/api | -| `crof` | `crof` | CrofAI | API key | [link](https://crof.ai) | — | -| `databricks` | `databricks` | Databricks | API key, enterprise | [link](https://www.databricks.com) | — | -| `datarobot` | `datarobot` | DataRobot | API key, enterprise | [link](https://docs.datarobot.com) | Use your DataRobot API token. Optional Base URL can be the account root (for LLM Gateway) or a deployment URL under /api/v2/deployments/. | -| `deepinfra` | `deepinfra` | DeepInfra | API key | [link](https://deepinfra.com) | Free signup credits for API testing and model exploration | -| `deepseek` | `ds` | DeepSeek | API key | [link](https://platform.deepseek.com) | 5M free tokens on signup - no credit card required | -| `dgrid` | `dgrid` | DGrid | API key | [link](https://dgrid.ai) | DGrid Free Models Router: 10 requests/minute and 100 requests/day. A $5 lifetime top-up unlocks up to 20 requests/minute and 1,000 requests/day. | -| `dify` | `dify` | Dify | API key | [link](https://dify.ai) | Get API key from your Dify instance. | -| `digitalocean` | `digitalocean` | DigitalOcean | API key | [link](https://docs.digitalocean.com/products/ai-platform/) | — | -| `dit` | `dai` | DIT.ai | API key | [link](https://dit.ai) | Use your dit.ai API key in Authorization: Bearer . Fully OpenAI-compatible — a drop-in replacement, just change the base URL to https://api.dit.ai/v1. | -| `doubao` | `doubao` | Doubao | API key | [link](https://doubao.com) | Get API key at console.volcengine.com | -| `empower` | `empower` | Empower | API key, aggregator | [link](https://docs.empower.dev) | Bearer API key for the Empower OpenAI-compatible endpoint. | -| `factory` | `factory` | Factory | API key | [link](https://factory.ai) | Bearer API key for the Factory OpenAI-compatible gateway. | -| `fal-ai` | `fal` | Fal.ai | API key, image | [link](https://fal.ai) | — | -| `featherless-ai` | `featherless` | Featherless AI | API key | [link](https://featherless.ai) | Free tier available — no credit card required | -| `fenayai` | `fenayai` | FenayAI | API key, aggregator | [link](https://fenayai.com) | Bearer API key for the FenayAI OpenAI-compatible gateway. | -| `firecrawl` | `fc` | Firecrawl | API key | [link](https://firecrawl.dev) | — | -| `fireworks` | `fireworks` | Fireworks AI | API key | [link](https://fireworks.ai) | $1 free starter credits on signup for API testing | -| `freeaiapikey` | `faik` | FreeAIAPIKey | API key | [link](https://freeaiapikey.com) | — | -| `freemodel-dev` | `fmd` | FreeModel.dev | API key | [link](https://freemodel.dev) | $300 free credits on signup — no credit card required. Access GPT-5.4 and GPT-5.5 (OpenAI's latest flagship models) through an OpenAI-compatible API. | -| `friendliai` | `friendli` | FriendliAI | API key | [link](https://friendli.ai) | Free tier for serverless inference — no credit card required | -| `galadriel` | `galadriel` | Galadriel | API key | [link](https://galadriel.com) | ⚠️ **DEPRECATED.** api.galadriel.ai no longer resolves (sweep 2026-06-19); the inference API appears discontinued. | -| `gemini` | `gemini` | Gemini (Google AI Studio) | API key | [link](https://aistudio.google.com) | Free forever: 1,500 req/day for Gemini 2.5 Flash — no credit card, get key at aistudio.google.com | -| `getgoapi` | `ggo` | GoAPI | API key, aggregator | [link](https://api.getgoapi.com) | — | -| `gigachat` | `gigachat` | GigaChat (Sber) | API key | [link](https://developers.sber.ru) | — | -| `github-models` | `ghm` | GitHub Models | API key | [link](https://github.com/marketplace/models) | Create a GitHub PAT with 'models: read' scope at github.com/settings/tokens | -| `gitlab` | `gitlab` | GitLab Duo PAT | API key | [link](https://docs.gitlab.com/user/duo_agent_platform/code_suggestions/) | GitLab personal access token for the public Code Suggestions API. Configure a self-hosted base URL when not using gitlab.com. | -| `gitlawb` | `glb` | Gitlawb Opengateway (MiMo) | API key | [link](https://opengateway.gitlawb.com) | Free MiMo (xiaomi/mimo-v2.5) revoked 2026-05 — Opengateway is now a pay-as-you-go credit gateway; no recurring free model. | -| `gitlawb-gmi` | `glb-gmi` | Gitlawb Opengateway (GMI Cloud) | API key | [link](https://opengateway.gitlawb.com) | Free Nemotron promo ended 2026-06 — the GMI Cloud route is now pay-as-you-go credit only. | -| `glm` | `glm` | GLM Coding | API key | [link](https://z.ai/subscribe) | — | -| `glm-cn` | `glmcn` | GLM Coding (China) | API key | [link](https://open.bigmodel.cn) | — | -| `glmt` | `glmt` | GLM Thinking | API key | [link](https://open.bigmodel.cn) | — | -| `groq` | `groq` | Groq | API key | [link](https://groq.com) | Free tier: 30 RPM / 14.4K RPD — no credit card | -| `hackclub` | `hc` | Hackclub AI | API key, aggregator | [link](https://ai.hackclub.com) | Sign in with your Hack Club account at ai.hackclub.com. | -| `haiper` | `hp` | Haiper | API key, video | [link](https://haiper.ai) | Get API key at haiper.ai/haiper-api | -| `hcnsec` | `hcnsec` | Huancheng Public API | API key | [link](https://api.hcnsec.cn) | Get API key at api.hcnsec.cn | -| `heroku` | `heroku` | Heroku AI | API key, enterprise | [link](https://www.heroku.com) | — | -| `huggingface` | `hf` | HuggingFace | API key | [link](https://huggingface.co) | Free Inference API for thousands of models (Whisper, VITS, SDXL…) | -| `hyperbolic` | `hyp` | Hyperbolic | API key | [link](https://hyperbolic.xyz) | $1-5 trial credits on signup for serverless inference | -| `ideogram` | `ideo` | Ideogram | API key | [link](https://ideogram.ai) | Get API key at ideogram.ai/docs/api | -| `iflytek` | `iflytek` | iFlytek Spark | API key | [link](https://xinghuo.xfyun.cn) | Get API key at console.xfyun.cn | -| `inference-net` | `inet` | Inference.net | API key | [link](https://inference.net) | $25 free credits on signup plus research grants available | -| `jina-ai` | `jina` | Jina AI | API key, embed/rerank | [link](https://jina.ai) | Bearer API key for the Jina AI rerank API. | -| `jina-reader` | `jr` | Jina Reader | API key | [link](https://jina.ai/reader) | — | -| `kenari` | `kenari` | Kenari | API key | [link](https://kenari.id) | Use your Kenari API key (kn-...) in Authorization: Bearer . Fully OpenAI-compatible. API base URL: https://kenari.id/v1. | -| `kie` | `kie` | KIE.AI | API key | [link](https://kie.ai) | — | -| `kilo-gateway` | `kg` | Kilo Gateway | API key, aggregator | [link](https://kilo.ai) | — | -| `kimi` | `kimi` | Kimi | API key | [link](https://platform.moonshot.ai) | — | -| `kimi-coding-apikey` | `kmca` | Kimi Coding (API Key) | API key | [link](https://www.kimi.com/code) | — | -| `lambda-ai` | `lambda` | Lambda AI | API key | [link](https://lambda.ai) | — | -| `laozhang` | `lz` | LaoZhang AI | API key, aggregator | [link](https://api.laozhang.ai) | — | -| `leonardo` | `leo` | Leonardo AI | API key, video | [link](https://leonardo.ai) | Get API key at leonardo.ai/developer | -| `liquid` | `liquid` | Liquid AI | API key | [link](https://liquid.ai) | Get API key at liquid.ai | -| `llamagate` | `llamagate` | LlamaGate | API key | [link](https://llamagate.ai) | — | -| `llm7` | `llm7` | LLM7.io | API key | [link](https://llm7.io) | No signup required - 2 req/s, 20 RPM, 100 req/hr free tier | -| `longcat` | `lc` | LongCat AI | API key | [link](https://longcat.chat/platform/docs) | Free: one-time 10M-token grant after account signup + KYC verification (LongCat-2.0). One-time only — not a recurring daily/monthly allowance. | -| `maritalk` | `maritalk` | Maritalk | API key | [link](https://www.maritaca.ai) | — | -| `meta-llama` | `meta` | Meta Llama API | API key | [link](https://llama.developer.meta.com) | — | -| `minimax` | `minimax` | Minimax Coding | API key, video | [link](https://www.minimax.io) | — | -| `minimax-cn` | `minimax-cn` | Minimax (China) | API key | [link](https://www.minimaxi.com) | — | -| `mistral` | `mistral` | Mistral | API key | [link](https://mistral.ai) | Free Experiment tier: rate-limited access to all models, no credit card required | -| `modal` | `mdl` | Modal | API key, enterprise | [link](https://modal.com/docs) | Use the bearer token that protects your Modal deployment, if enabled. Base URL should point to your OpenAI-compatible Modal app, for example https://--.modal.run/v1. | -| `modelscope` | `ms` | ModelScope | API key | [link](https://modelscope.cn) | Free tier via ModelScope API-Inference — Alibaba account required. | -| `monsterapi` | `monster` | MonsterAPI | API key | [link](https://monsterapi.ai) | Get API key at monsterapi.ai | -| `moonshot` | `moonshot` | Moonshot AI | API key | [link](https://platform.moonshot.ai) | — | -| `morph` | `morph` | Morph | API key | [link](https://morphllm.com) | Free tier: 250K credits/month, $0 | -| `nanogpt` | `nanogpt` | NanoGPT | API key | [link](https://nano-gpt.com) | — | -| `nebius` | `nebius` | Nebius AI | API key | [link](https://nebius.com) | ~$1 trial credits on signup for API testing | -| `nlpcloud` | `nlpc` | NLP Cloud | API key | [link](https://docs.nlpcloud.com) | Use your NLP Cloud API key in Authorization: Token . OmniRoute targets the chatbot endpoint on https://api.nlpcloud.io/v1/gpu//chatbot by default. | -| `nomic` | `nomic` | Nomic | API key | [link](https://nomic.ai) | Get API key at atlas.nomic.ai | -| `nous-research` | `nous` | Nous Research | API key | [link](https://portal.nousresearch.com/help) | Use your Nous Portal API key. OmniRoute targets the official OpenAI-compatible inference endpoint at https://inference-api.nousresearch.com/v1. | -| `novita` | `novita` | Novita AI | API key, aggregator | [link](https://novita.ai) | $0.50 trial credits on signup (valid about 1 year) | -| `nscale` | `nscale` | nScale | API key | [link](https://nscale.com) | $5 free credits on signup for inference testing | -| `nube` | `nube` | Nube.sh | API key | [link](https://nube.sh) | — | -| `nvidia` | `nvidia` | NVIDIA NIM | API key | [link](https://build.nvidia.com) | Free dev access: ~40 RPM, 70+ models (Kimi K2.5, GLM 4.7, DeepSeek V3.2...) | -| `oci` | `oci` | OCI Generative AI | API key, enterprise | [link](https://www.oracle.com/artificial-intelligence/generative-ai) | Use your OCI Generative AI API key or IAM bearer token. Base URL can be https://inference.generativeai..oci.oraclecloud.com/openai/v1/. | -| `ollama-cloud` | `ollamacloud` | Ollama Cloud | API key | [link](https://ollama.com/settings/keys) | — | -| `openadapter` | `oad` | OpenAdapter | API key | [link](https://openadapter.dev) | Use your OpenAdapter API key in Authorization: Bearer sk-cv-. Fully OpenAI-compatible. API base URL: https://api.openadapter.in/v1. | -| `openai` | `openai` | OpenAI | API key | [link](https://platform.openai.com) | — | -| `opencode-go` | `opencode-go` | OpenCode Go | API key | [link](https://opencode.ai/go) | — | -| `opencode-zen` | `opencode-zen` | OpenCode Zen | API key | [link](https://opencode.ai/zen) | — | -| `openrouter` | `openrouter` | OpenRouter | API key, aggregator | [link](https://openrouter.ai) | Free models at $0/token with :free suffix - 20 RPM / 200 RPD | -| `orcarouter` | `orcarouter` | OrcaRouter | API key | [link](https://www.orcarouter.ai) | — | -| `ovhcloud` | `ovh` | OVHcloud AI | API key | [link](https://www.ovhcloud.com) | — | -| `perplexity` | `pplx` | Perplexity | API key | [link](https://www.perplexity.ai) | — | -| `piapi` | `pi` | PiAPI | API key, aggregator | [link](https://piapi.ai) | — | -| `pioneer` | `pn` | Pioneer AI | API key | [link](https://pioneer.ai) | $75 free usage credits — no credit card required | -| `poe` | `poe` | Poe | API key, aggregator | [link](https://creator.poe.com/api-reference) | Bearer API key for the Poe OpenAI-compatible API. | -| `pollinations` | `pol` | Pollinations AI | API key, video | [link](https://pollinations.ai) | Free keyless tier: openai, openai-fast, openai-large, qwen-coder, mistral, deepseek, grok, gemini-flash-lite-3.1, perplexity-fast, perplexity-reasoning. Premium models (claude, gemini, midijourney) require a Pollinations API key from enter.pollinations.ai. | -| `predibase` | `predibase` | Predibase | API key | [link](https://predibase.com) | ⚠️ **DEPRECATED.** serving.app.predibase.com no longer resolves (sweep 2026-06-19); the managed serving API appears discontinued. | -| `publicai` | `publicai` | PublicAI | API key | [link](https://publicai.co) | Requires an API key — one-time signup credit, then paid | -| `puter` | `pu` | Puter AI | API key | [link](https://puter.com) | Get token at puter.com/dashboard → Copy Auth Token | -| `qianfan` | `qianfan` | Baidu Qianfan | API key | [link](https://cloud.baidu.com/product/wenxinworkshop) | — | -| `qiniu` | `qiniu` | Qiniu | API key | [link](https://www.qiniu.com) | — | -| `recraft` | `recraft` | Recraft | API key, image | [link](https://recraft.ai) | — | -| `reka` | `reka` | Reka | API key | [link](https://docs.reka.ai/chat/overview) | Use your Reka API key. OmniRoute supports the OpenAI-compatible base URL https://api.reka.ai/v1 and sends both Authorization and X-Api-Key headers for compatibility. | -| `requesty` | `requesty` | Requesty | API key | [link](https://requesty.ai) | Free tier ~200 requests/day - multi-model routing gateway (300+ models) | -| `runwayml` | `runway` | Runway | API key, video | [link](https://docs.dev.runwayml.com) | Use your Runway API key in Authorization: Bearer . OmniRoute targets the current Runway API at https://api.dev.runwayml.com/v1 and sends the required X-Runway-Version header automatically. | -| `sambanova` | `samba` | SambaNova | API key | [link](https://sambanova.ai) | $5 free credits on signup (30-day validity), no credit card required | -| `sap` | `sap` | SAP Generative AI Hub | API key, enterprise | [link](https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/generative-ai-hub-in-sap-ai-core) | Use your SAP AI Core bearer token. Base URL can be your AI_API_URL root or a deploymentUrl from Generative AI Hub. | -| `scaleway` | `scw` | Scaleway AI | API key | [link](https://www.scaleway.com/en/docs/ai-data/generative-apis/) | 1M free tokens for new accounts — EU/GDPR compliant (Paris), Qwen3 235B & Llama 70B | -| `sensenova` | `sensenova` | SenseNova | API key | [link](https://platform.sensenova.cn) | Get API key at platform.sensenova.cn | -| `siliconflow` | `siliconflow` | SiliconFlow | API key | [link](https://cloud.siliconflow.com) | $1 free credits plus permanently free models after identity verification | -| `snowflake` | `snowflake` | Snowflake Cortex | API key, enterprise | [link](https://www.snowflake.com) | — | -| `sparkdesk` | `sparkdesk` | SparkDesk | API key | [link](https://xinghuo.xfyun.cn) | Get API key at console.xfyun.cn | -| `stability-ai` | `stability` | Stability AI | API key, image | [link](https://stability.ai) | — | -| `stepfun` | `stepfun` | StepFun | API key | [link](https://stepfun.com) | Get API key at platform.stepfun.com | -| `sumopod` | `sumopod` | SumoPod | API key | [link](https://ai.sumopod.com) | Use your SumoPod API key (sk-...) in Authorization: Bearer . Fully OpenAI-compatible. API base URL: https://ai.sumopod.com/v1. | -| `suno` | `suno` | Suno | API key | [link](https://suno.ai) | Paste session cookie from suno.ai (Clerk auth) | -| `synthetic` | `synthetic` | Synthetic | API key, aggregator | [link](https://synthetic.new) | — | -| `tencent` | `tencent` | Tencent Hunyuan | API key | [link](https://hunyuan.tencent.com) | Get API key at console.cloud.tencent.com | -| `thebai` | `thebai` | TheB.AI | API key, aggregator | [link](https://theb.ai) | Bearer API key for the TheB.AI OpenAI-compatible gateway. | -| `tinyfish` | `tf` | TinyFish Fetch | API key | [link](https://docs.tinyfish.ai/fetch-api) | X-API-Key from agent.tinyfish.ai/api-keys | -| `together` | `together` | Together AI | API key, video | [link](https://www.together.ai) | — | -| `tokenrouter` | `trk` | TokenRouter | API key | [link](https://tokenrouter.com) | Use your TokenRouter API key in Authorization: Bearer . Fully OpenAI-compatible. API base URL: https://api.tokenrouter.com/v1. | -| `topaz` | `topaz` | Topaz | API key, image | [link](https://topazlabs.com) | — | -| `udio` | `udio` | Udio | API key | [link](https://udio.com) | Paste session cookie from udio.com (Supabase auth) | -| `uncloseai` | `unc` | UncloseAI | API key | [link](https://uncloseai.com) | No auth required. API accepts any non-empty string as key for identification. | -| `upstage` | `upstage` | Upstage | API key | [link](https://www.upstage.ai) | — | -| `v0-vercel` | `v0` | v0 (Vercel) | API key | [link](https://v0.dev) | — | -| `venice` | `venice` | Venice.ai | API key | [link](https://venice.ai) | — | -| `vercel-ai-gateway` | `vag` | Vercel AI Gateway | API key, aggregator | [link](https://vercel.com/docs/ai-gateway) | — | -| `vertex` | `vertex` | Vertex AI | API key, enterprise | [link](https://cloud.google.com/vertex-ai) | Provide Service Account JSON or OAuth access_token | -| `vertex-partner` | `vp` | Vertex AI Partners | API key, enterprise | [link](https://cloud.google.com/vertex-ai) | Provide the same Service Account JSON used for Vertex AI partner models. | -| `volcengine` | `volcengine` | Volcengine | API key | [link](https://www.volcengine.com) | — | -| `voyage-ai` | `voyage` | Voyage AI | API key, embed/rerank | [link](https://www.voyageai.com) | Bearer API key for Voyage AI embeddings and rerank APIs. | -| `wafer` | `wafer` | Wafer AI | API key | [link](https://wafer.ai) | — | -| `wandb` | `wandb` | Weights & Biases Inference | API key | [link](https://wandb.ai) | — | -| `watsonx` | `watsonx` | IBM watsonx.ai Gateway | API key, enterprise | [link](https://www.ibm.com/products/watsonx-ai) | Use your watsonx bearer token. Base URL can be https://.ml.cloud.ibm.com/ml/gateway/v1/ or a self-managed /ml/gateway/v1 endpoint. | -| `x5lab` | `x5lab` | X5Lab | API key | [link](https://x5lab.dev) | Use your X5Lab API key (x5-...) in Authorization: Bearer . Fully OpenAI-compatible. API base URL: https://api.x5lab.dev/v1. | -| `xai` | `xai` | xAI (Grok) | API key | [link](https://x.ai) | — | -| `xiaomi-mimo` | `mimo` | Xiaomi MiMo | API key | [link](https://mimo.mi.com) | — | -| `yi` | `yi` | Yi (01.AI) | API key | [link](https://01.ai) | Get API key at platform.lingyiwanwu.com | -| `zai` | `zai` | Z.AI | API key | [link](https://open.bigmodel.cn) | — | -| `zenmux` | `zm` | ZenMux | API key | [link](https://zenmux.ai) | Use your ZenMux API key in Authorization: Bearer . ZenMux is fully OpenAI-compatible. Base URL: https://zenmux.ai/api/v1. | +| ID | Alias | Name | Tags | Website | Notes | +|----|-------|------|------|---------|-------| +| `360ai` | `360ai` | 360 AI | API key | [link](https://ai.360.cn) | Get API key at ai.360.cn | +| `agentrouter` | `agentrouter` | AgentRouter | API key, aggregator | [link](https://agentrouter.org) | $200 free credits on signup - multi-model routing gateway | +| `ai21` | `ai21` | AI21 Labs | API key | [link](https://www.ai21.com) | $10 trial credits on signup (valid 3 months), no credit card required | +| `aimlapi` | `aiml` | AI/ML API | API key, aggregator | [link](https://aimlapi.com) | Free tier paused (2026) — AI/ML API is now pay-as-you-go only (min $20 top-up); no recurring free credits. | +| `alibaba` | `ali` | Alibaba | API key | [link](https://bailian.console.alibabacloud.com/) | — | +| `alibaba-cn` | `ali-cn` | Alibaba (China) | API key | [link](https://dashscope.console.aliyun.com/) | — | +| `anthropic` | `anthropic` | Anthropic | API key | [link](https://platform.claude.com) | — | +| `api-airforce` | `af` | Api.airforce | API key | [link](https://api.airforce) | 55 free tier models including Grok-3, Claude 3.7, Qwen3, Kimi-K2, Gemini 2.5 Flash, DeepSeek-V3 | +| `arcee-ai` | `arcee` | Arcee AI | API key | [link](https://arcee.ai) | Get API key at arcee.ai | +| `azure-ai` | `azure-ai` | Azure AI Foundry | API key, enterprise | [link](https://learn.microsoft.com/azure/ai-foundry) | Use your Azure AI Foundry key. Base URL can be https://.services.ai.azure.com/openai/v1/ or https://.openai.azure.com/openai/v1/. | +| `azure-openai` | `azure` | Azure OpenAI | API key, enterprise | [link](https://azure.microsoft.com/products/ai-services/openai-service) | Use your Azure OpenAI API key. Base URL should be your resource endpoint, for example https://my-resource.openai.azure.com. | +| `bai` | `bai` | b.ai | API key | [link](https://b.ai) | Bearer API key for the b.ai OpenAI-compatible LLM gateway (distinct from TheB.AI). Create a key at https://docs.b.ai, then use https://api.b.ai/v1 as the OpenAI-compatible base URL. | +| `baichuan` | `baichuan` | Baichuan | API key | [link](https://baichuan.com) | Get API key at platform.baichuan-ai.com | +| `baidu` | `baidu` | Baidu (ERNIE) | API key | [link](https://yiyan.baidu.com) | Get API key at console.bce.baidu.com | +| `bailian-coding-plan` | `bcp` | Alibaba Coding Plan | API key | [link](https://www.alibabacloud.com/help/en/model-studio/coding-plan) | — | +| `baseten` | `baseten` | Baseten | API key | [link](https://baseten.co) | $30 free trial credits for GPU inference | +| `bazaarlink` | `bzl` | BazaarLink | API key | [link](https://bazaarlink.ai) | Use your BazaarLink API key (starts with sk-bl-) in Authorization: Bearer . OpenAI SDK works with base URL https://bazaarlink.ai/api/v1. Models use provider/model-name format. | +| `bedrock` | `bedrock` | Amazon Bedrock | API key, enterprise | [link](https://aws.amazon.com/bedrock) | Use your Amazon Bedrock API key and configure the AWS region where your models are enabled (for example eu-west-2). OmniRoute calls Bedrock's native Converse API directly. | +| `black-forest-labs` | `bfl` | Black Forest Labs | API key, image | [link](https://blackforestlabs.ai) | — | +| `blackbox` | `bb` | Blackbox AI | API key | [link](https://blackbox.ai) | Free tier: unlimited basic chat plus Minimax-M2.5, no credit card required | +| `bluesminds` | `bm` | BluesMinds | API key | [link](https://www.bluesminds.com) | Free daily pi credits — supports 200+ models including GPT-4o, GPT-4.1, Claude Sonnet 4.5, Gemini 2.0 Flash, DeepSeek V4, Qwen, Kimi K2 | +| `byteplus` | `bpm` | BytePlus ModelArk | API key | [link](https://console.byteplus.com/ark) | — | +| `bytez` | `bytez` | Bytez | API key | [link](https://bytez.com) | $1 free credits, refreshes every 4 weeks | +| `cerebras` | `cerebras` | Cerebras | API key | [link](https://inference.cerebras.ai) | Free Trial: 1M tokens/day, 30K TPM, 5 RPM — no credit card. | +| `charm-hyper` | `charm-hyper` | Charm Hyper | API key | [link](https://hyper.charm.land) | 100 free monthly Hypercredits on signup | +| `chutes` | `chutes` | Chutes.ai | API key, aggregator | [link](https://chutes.ai) | Bearer API key for the Chutes OpenAI-compatible gateway. | +| `clarifai` | `clarifai` | Clarifai | API key, enterprise | [link](https://docs.clarifai.com) | Use your Clarifai PAT or app-specific API key. OmniRoute targets the OpenAI-compatible endpoint at https://api.clarifai.com/v2/ext/openai/v1 and authenticates with Authorization: Key . | +| `clinepass` | `clinepass` | ClinePass | API key | [link](https://cline.bot) | — | +| `cloudflare-ai` | `cf` | Cloudflare Workers AI | API key | [link](https://developers.cloudflare.com/workers-ai) | Requires API Token AND Account ID (found at dash.cloudflare.com) | +| `codestral` | `codestral` | Codestral | API key | [link](https://mistral.ai) | — | +| `cohere` | `cohere` | Cohere | API key | [link](https://cohere.com) | Free Trial: 1,000 API calls/month for testing, no credit card required | +| `command-code` | `cmd` | Command Code | API key | [link](https://commandcode.ai/) | Use a Command Code API key. Requests are sent to Command Code's /alpha/generate endpoint. | +| `coze` | `coze` | Coze | API key | [link](https://coze.com) | Get API key at coze.com/open/api | +| `crof` | `crof` | CrofAI | API key | [link](https://crof.ai) | — | +| `databricks` | `databricks` | Databricks | API key, enterprise | [link](https://www.databricks.com) | — | +| `datarobot` | `datarobot` | DataRobot | API key, enterprise | [link](https://docs.datarobot.com) | Use your DataRobot API token. Optional Base URL can be the account root (for LLM Gateway) or a deployment URL under /api/v2/deployments/. | +| `deepinfra` | `deepinfra` | DeepInfra | API key | [link](https://deepinfra.com) | Free signup credits for API testing and model exploration | +| `deepseek` | `ds` | DeepSeek | API key | [link](https://platform.deepseek.com) | 5M free tokens on signup - no credit card required | +| `dgrid` | `dgrid` | DGrid | API key | [link](https://dgrid.ai) | DGrid Free Models Router: 10 requests/minute and 100 requests/day. A $5 lifetime top-up unlocks up to 20 requests/minute and 1,000 requests/day. | +| `dify` | `dify` | Dify | API key | [link](https://dify.ai) | Get API key from your Dify instance. | +| `digitalocean` | `digitalocean` | DigitalOcean | API key | [link](https://docs.digitalocean.com/products/ai-platform/) | — | +| `dit` | `dai` | DIT.ai | API key | [link](https://dit.ai) | Use your dit.ai API key in Authorization: Bearer . Fully OpenAI-compatible — a drop-in replacement, just change the base URL to https://api.dit.ai/v1. | +| `doubao` | `doubao` | Doubao | API key | [link](https://doubao.com) | Get API key at console.volcengine.com | +| `empower` | `empower` | Empower | API key, aggregator | [link](https://docs.empower.dev) | Bearer API key for the Empower OpenAI-compatible endpoint. | +| `factory` | `factory` | Factory | API key | [link](https://factory.ai) | Bearer API key for the Factory OpenAI-compatible gateway. | +| `fal-ai` | `fal` | Fal.ai | API key, image | [link](https://fal.ai) | — | +| `featherless-ai` | `featherless` | Featherless AI | API key | [link](https://featherless.ai) | Free tier available — no credit card required | +| `fenayai` | `fenayai` | FenayAI | API key, aggregator | [link](https://fenayai.com) | Bearer API key for the FenayAI OpenAI-compatible gateway. | +| `firecrawl` | `fc` | Firecrawl | API key | [link](https://firecrawl.dev) | — | +| `fireworks` | `fireworks` | Fireworks AI | API key | [link](https://fireworks.ai) | $1 free starter credits on signup for API testing | +| `freeaiapikey` | `faik` | FreeAIAPIKey | API key | [link](https://freeaiapikey.com) | — | +| `freemodel-dev` | `fmd` | FreeModel.dev | API key | [link](https://freemodel.dev) | $300 free credits on signup — no credit card required. Access GPT-5.4 and GPT-5.5 (OpenAI's latest flagship models) through an OpenAI-compatible API. | +| `friendliai` | `friendli` | FriendliAI | API key | [link](https://friendli.ai) | Free tier for serverless inference — no credit card required | +| `galadriel` | `galadriel` | Galadriel | API key | [link](https://galadriel.com) | ⚠️ **DEPRECATED.** api.galadriel.ai no longer resolves (sweep 2026-06-19); the inference API appears discontinued. | +| `gemini` | `gemini` | Gemini (Google AI Studio) | API key | [link](https://aistudio.google.com) | Free forever: 1,500 req/day for Gemini 2.5 Flash — no credit card, get key at aistudio.google.com | +| `getgoapi` | `ggo` | GoAPI | API key, aggregator | [link](https://api.getgoapi.com) | — | +| `gigachat` | `gigachat` | GigaChat (Sber) | API key | [link](https://developers.sber.ru) | — | +| `github-models` | `ghm` | GitHub Models | API key | [link](https://github.com/marketplace/models) | Create a GitHub PAT with 'models: read' scope at github.com/settings/tokens | +| `gitlab` | `gitlab` | GitLab Duo PAT | API key | [link](https://docs.gitlab.com/user/duo_agent_platform/code_suggestions/) | GitLab personal access token for the public Code Suggestions API. Configure a self-hosted base URL when not using gitlab.com. | +| `gitlawb` | `glb` | Gitlawb Opengateway (MiMo) | API key | [link](https://opengateway.gitlawb.com) | Free MiMo (xiaomi/mimo-v2.5) revoked 2026-05 — Opengateway is now a pay-as-you-go credit gateway; no recurring free model. | +| `gitlawb-gmi` | `glb-gmi` | Gitlawb Opengateway (GMI Cloud) | API key | [link](https://opengateway.gitlawb.com) | Free Nemotron promo ended 2026-06 — the GMI Cloud route is now pay-as-you-go credit only. | +| `glm` | `glm` | GLM Coding | API key | [link](https://z.ai/subscribe) | — | +| `glm-cn` | `glmcn` | GLM Coding (China) | API key | [link](https://open.bigmodel.cn) | — | +| `glmt` | `glmt` | GLM Thinking | API key | [link](https://open.bigmodel.cn) | — | +| `groq` | `groq` | Groq | API key | [link](https://groq.com) | Free tier: 30 RPM / 14.4K RPD — no credit card | +| `hackclub` | `hc` | Hackclub AI | API key, aggregator | [link](https://ai.hackclub.com) | Sign in with your Hack Club account at ai.hackclub.com. | +| `haiper` | `hp` | Haiper | API key, video | [link](https://haiper.ai) | Get API key at haiper.ai/haiper-api | +| `hcnsec` | `hcnsec` | Huancheng Public API | API key | [link](https://api.hcnsec.cn) | Get API key at api.hcnsec.cn | +| `heroku` | `heroku` | Heroku AI | API key, enterprise | [link](https://www.heroku.com) | — | +| `huggingface` | `hf` | HuggingFace | API key | [link](https://huggingface.co) | Free Inference API for thousands of models (Whisper, VITS, SDXL…) | +| `hyperbolic` | `hyp` | Hyperbolic | API key | [link](https://hyperbolic.xyz) | $1-5 trial credits on signup for serverless inference | +| `ideogram` | `ideo` | Ideogram | API key | [link](https://ideogram.ai) | Get API key at ideogram.ai/docs/api | +| `iflytek` | `iflytek` | iFlytek Spark | API key | [link](https://xinghuo.xfyun.cn) | Get API key at console.xfyun.cn | +| `inference-net` | `inet` | Inference.net | API key | [link](https://inference.net) | $25 free credits on signup plus research grants available | +| `jina-ai` | `jina` | Jina AI | API key, embed/rerank | [link](https://jina.ai) | Bearer API key for the Jina AI rerank API. | +| `jina-reader` | `jr` | Jina Reader | API key | [link](https://jina.ai/reader) | — | +| `kenari` | `kenari` | Kenari | API key | [link](https://kenari.id) | Use your Kenari API key (kn-...) in Authorization: Bearer . Fully OpenAI-compatible. API base URL: https://kenari.id/v1. | +| `kie` | `kie` | KIE.AI | API key | [link](https://kie.ai) | — | +| `kilo-gateway` | `kg` | Kilo Gateway | API key, aggregator | [link](https://kilo.ai) | — | +| `kimi` | `kimi` | Kimi | API key | [link](https://platform.moonshot.ai) | — | +| `kimi-coding-apikey` | `kmca` | Kimi Coding (API Key) | API key | [link](https://www.kimi.com/code) | — | +| `lambda-ai` | `lambda` | Lambda AI | API key | [link](https://lambda.ai) | — | +| `laozhang` | `lz` | LaoZhang AI | API key, aggregator | [link](https://api.laozhang.ai) | — | +| `leonardo` | `leo` | Leonardo AI | API key, video | [link](https://leonardo.ai) | Get API key at leonardo.ai/developer | +| `liquid` | `liquid` | Liquid AI | API key | [link](https://liquid.ai) | Get API key at liquid.ai | +| `llamagate` | `llamagate` | LlamaGate | API key | [link](https://llamagate.ai) | — | +| `llm7` | `llm7` | LLM7.io | API key | [link](https://llm7.io) | No signup required - 2 req/s, 20 RPM, 100 req/hr free tier | +| `longcat` | `lc` | LongCat AI | API key | [link](https://longcat.chat/platform/docs) | Free: one-time 10M-token grant after account signup + KYC verification (LongCat-2.0). One-time only — not a recurring daily/monthly allowance. | +| `maritalk` | `maritalk` | Maritalk | API key | [link](https://www.maritaca.ai) | — | +| `meta-llama` | `meta` | Meta Llama API | API key | [link](https://llama.developer.meta.com) | — | +| `minimax` | `minimax` | Minimax Coding | API key, video | [link](https://www.minimax.io) | — | +| `minimax-cn` | `minimax-cn` | Minimax (China) | API key | [link](https://www.minimaxi.com) | — | +| `mistral` | `mistral` | Mistral | API key | [link](https://mistral.ai) | Free Experiment tier: rate-limited access to all models, no credit card required | +| `modal` | `mdl` | Modal | API key, enterprise | [link](https://modal.com/docs) | Use the bearer token that protects your Modal deployment, if enabled. Base URL should point to your OpenAI-compatible Modal app, for example https://--.modal.run/v1. | +| `modelscope` | `ms` | ModelScope | API key | [link](https://modelscope.cn) | Free tier via ModelScope API-Inference — Alibaba account required. | +| `monsterapi` | `monster` | MonsterAPI | API key | [link](https://monsterapi.ai) | Get API key at monsterapi.ai | +| `moonshot` | `moonshot` | Moonshot AI | API key | [link](https://platform.moonshot.ai) | — | +| `morph` | `morph` | Morph | API key | [link](https://morphllm.com) | Free tier: 250K credits/month, $0 | +| `nanogpt` | `nanogpt` | NanoGPT | API key | [link](https://nano-gpt.com) | — | +| `nebius` | `nebius` | Nebius AI | API key | [link](https://nebius.com) | ~$1 trial credits on signup for API testing | +| `nlpcloud` | `nlpc` | NLP Cloud | API key | [link](https://docs.nlpcloud.com) | Use your NLP Cloud API key in Authorization: Token . OmniRoute targets the chatbot endpoint on https://api.nlpcloud.io/v1/gpu//chatbot by default. | +| `nomic` | `nomic` | Nomic | API key | [link](https://nomic.ai) | Get API key at atlas.nomic.ai | +| `nous-research` | `nous` | Nous Research | API key | [link](https://portal.nousresearch.com/help) | Use your Nous Portal API key. OmniRoute targets the official OpenAI-compatible inference endpoint at https://inference-api.nousresearch.com/v1. | +| `novita` | `novita` | Novita AI | API key, aggregator | [link](https://novita.ai) | $0.50 trial credits on signup (valid about 1 year) | +| `nscale` | `nscale` | nScale | API key | [link](https://nscale.com) | $5 free credits on signup for inference testing | +| `nube` | `nube` | Nube.sh | API key | [link](https://nube.sh) | — | +| `nvidia` | `nvidia` | NVIDIA NIM | API key | [link](https://build.nvidia.com) | Free dev access: ~40 RPM, 70+ models (Kimi K2.5, GLM 4.7, DeepSeek V3.2...) | +| `oci` | `oci` | OCI Generative AI | API key, enterprise | [link](https://www.oracle.com/artificial-intelligence/generative-ai) | Use your OCI Generative AI API key or IAM bearer token. Base URL can be https://inference.generativeai..oci.oraclecloud.com/openai/v1/. | +| `ollama-cloud` | `ollamacloud` | Ollama Cloud | API key | [link](https://ollama.com/settings/keys) | — | +| `openadapter` | `oad` | OpenAdapter | API key | [link](https://openadapter.dev) | Use your OpenAdapter API key in Authorization: Bearer sk-cv-. Fully OpenAI-compatible. API base URL: https://api.openadapter.in/v1. | +| `openai` | `openai` | OpenAI | API key | [link](https://platform.openai.com) | — | +| `opencode-go` | `opencode-go` | OpenCode Go | API key | [link](https://opencode.ai/go) | — | +| `opencode-zen` | `opencode-zen` | OpenCode Zen | API key | [link](https://opencode.ai/zen) | — | +| `openrouter` | `openrouter` | OpenRouter | API key, aggregator | [link](https://openrouter.ai) | Free models at $0/token with :free suffix - 20 RPM / 200 RPD | +| `orcarouter` | `orcarouter` | OrcaRouter | API key | [link](https://www.orcarouter.ai) | — | +| `ovhcloud` | `ovh` | OVHcloud AI | API key | [link](https://www.ovhcloud.com) | — | +| `perplexity` | `pplx` | Perplexity | API key | [link](https://www.perplexity.ai) | — | +| `piapi` | `pi` | PiAPI | API key, aggregator | [link](https://piapi.ai) | — | +| `pioneer` | `pn` | Pioneer AI | API key | [link](https://pioneer.ai) | $75 free usage credits — no credit card required | +| `poe` | `poe` | Poe | API key, aggregator | [link](https://creator.poe.com/api-reference) | Bearer API key for the Poe OpenAI-compatible API. | +| `pollinations` | `pol` | Pollinations AI | API key, video | [link](https://pollinations.ai) | Free keyless tier: openai, openai-fast, openai-large, qwen-coder, mistral, deepseek, grok, gemini-flash-lite-3.1, perplexity-fast, perplexity-reasoning. Premium models (claude, gemini, midijourney) require a Pollinations API key from enter.pollinations.ai. | +| `predibase` | `predibase` | Predibase | API key | [link](https://predibase.com) | ⚠️ **DEPRECATED.** serving.app.predibase.com no longer resolves (sweep 2026-06-19); the managed serving API appears discontinued. | +| `publicai` | `publicai` | PublicAI | API key | [link](https://publicai.co) | Requires an API key — one-time signup credit, then paid | +| `puter` | `pu` | Puter AI | API key | [link](https://puter.com) | Get token at puter.com/dashboard → Copy Auth Token | +| `qianfan` | `qianfan` | Baidu Qianfan | API key | [link](https://cloud.baidu.com/product/wenxinworkshop) | — | +| `qiniu` | `qiniu` | Qiniu | API key | [link](https://www.qiniu.com) | — | +| `recraft` | `recraft` | Recraft | API key, image | [link](https://recraft.ai) | — | +| `reka` | `reka` | Reka | API key | [link](https://docs.reka.ai/chat/overview) | Use your Reka API key. OmniRoute supports the OpenAI-compatible base URL https://api.reka.ai/v1 and sends both Authorization and X-Api-Key headers for compatibility. | +| `requesty` | `requesty` | Requesty | API key | [link](https://requesty.ai) | Free tier ~200 requests/day - multi-model routing gateway (300+ models) | +| `runwayml` | `runway` | Runway | API key, video | [link](https://docs.dev.runwayml.com) | Use your Runway API key in Authorization: Bearer . OmniRoute targets the current Runway API at https://api.dev.runwayml.com/v1 and sends the required X-Runway-Version header automatically. | +| `sambanova` | `samba` | SambaNova | API key | [link](https://sambanova.ai) | $5 free credits on signup (30-day validity), no credit card required | +| `sap` | `sap` | SAP Generative AI Hub | API key, enterprise | [link](https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/generative-ai-hub-in-sap-ai-core) | Use your SAP AI Core bearer token. Base URL can be your AI_API_URL root or a deploymentUrl from Generative AI Hub. | +| `scaleway` | `scw` | Scaleway AI | API key | [link](https://www.scaleway.com/en/docs/ai-data/generative-apis/) | 1M free tokens for new accounts — EU/GDPR compliant (Paris), Qwen3 235B & Llama 70B | +| `sensenova` | `sensenova` | SenseNova | API key | [link](https://platform.sensenova.cn) | Get API key at platform.sensenova.cn | +| `siliconflow` | `siliconflow` | SiliconFlow | API key | [link](https://cloud.siliconflow.com) | $1 free credits plus permanently free models after identity verification | +| `snowflake` | `snowflake` | Snowflake Cortex | API key, enterprise | [link](https://www.snowflake.com) | — | +| `sparkdesk` | `sparkdesk` | SparkDesk | API key | [link](https://xinghuo.xfyun.cn) | Get API key at console.xfyun.cn | +| `stability-ai` | `stability` | Stability AI | API key, image | [link](https://stability.ai) | — | +| `stepfun` | `stepfun` | StepFun | API key | [link](https://stepfun.com) | Get API key at platform.stepfun.com | +| `sumopod` | `sumopod` | SumoPod | API key | [link](https://ai.sumopod.com) | Use your SumoPod API key (sk-...) in Authorization: Bearer . Fully OpenAI-compatible. API base URL: https://ai.sumopod.com/v1. | +| `suno` | `suno` | Suno | API key | [link](https://suno.ai) | Paste session cookie from suno.ai (Clerk auth) | +| `synthetic` | `synthetic` | Synthetic | API key, aggregator | [link](https://synthetic.new) | — | +| `tencent` | `tencent` | Tencent Hunyuan | API key | [link](https://hunyuan.tencent.com) | Get API key at console.cloud.tencent.com | +| `thebai` | `thebai` | TheB.AI | API key, aggregator | [link](https://theb.ai) | Bearer API key for the TheB.AI OpenAI-compatible gateway. | +| `tinyfish` | `tf` | TinyFish Fetch | API key | [link](https://docs.tinyfish.ai/fetch-api) | X-API-Key from agent.tinyfish.ai/api-keys | +| `together` | `together` | Together AI | API key, video | [link](https://www.together.ai) | — | +| `tokenrouter` | `trk` | TokenRouter | API key | [link](https://tokenrouter.com) | Use your TokenRouter API key in Authorization: Bearer . Fully OpenAI-compatible. API base URL: https://api.tokenrouter.com/v1. | +| `topaz` | `topaz` | Topaz | API key, image | [link](https://topazlabs.com) | — | +| `udio` | `udio` | Udio | API key | [link](https://udio.com) | Paste session cookie from udio.com (Supabase auth) | +| `uncloseai` | `unc` | UncloseAI | API key | [link](https://uncloseai.com) | No auth required. API accepts any non-empty string as key for identification. | +| `upstage` | `upstage` | Upstage | API key | [link](https://www.upstage.ai) | — | +| `v0-vercel` | `v0` | v0 (Vercel) | API key | [link](https://v0.dev) | — | +| `venice` | `venice` | Venice.ai | API key | [link](https://venice.ai) | — | +| `vercel-ai-gateway` | `vag` | Vercel AI Gateway | API key, aggregator | [link](https://vercel.com/docs/ai-gateway) | — | +| `vertex` | `vertex` | Vertex AI | API key, enterprise | [link](https://cloud.google.com/vertex-ai) | Provide Service Account JSON or OAuth access_token | +| `vertex-partner` | `vp` | Vertex AI Partners | API key, enterprise | [link](https://cloud.google.com/vertex-ai) | Provide the same Service Account JSON used for Vertex AI partner models. | +| `volcengine` | `volcengine` | Volcengine | API key | [link](https://www.volcengine.com) | — | +| `voyage-ai` | `voyage` | Voyage AI | API key, embed/rerank | [link](https://www.voyageai.com) | Bearer API key for Voyage AI embeddings and rerank APIs. | +| `wafer` | `wafer` | Wafer AI | API key | [link](https://wafer.ai) | — | +| `wandb` | `wandb` | Weights & Biases Inference | API key | [link](https://wandb.ai) | — | +| `watsonx` | `watsonx` | IBM watsonx.ai Gateway | API key, enterprise | [link](https://www.ibm.com/products/watsonx-ai) | Use your watsonx bearer token. Base URL can be https://.ml.cloud.ibm.com/ml/gateway/v1/ or a self-managed /ml/gateway/v1 endpoint. | +| `x5lab` | `x5lab` | X5Lab | API key | [link](https://x5lab.dev) | Use your X5Lab API key (x5-...) in Authorization: Bearer . Fully OpenAI-compatible. API base URL: https://api.x5lab.dev/v1. | +| `xai` | `xai` | xAI (Grok) | API key | [link](https://x.ai) | — | +| `xiaomi-mimo` | `mimo` | Xiaomi MiMo | API key | [link](https://mimo.mi.com) | — | +| `yi` | `yi` | Yi (01.AI) | API key | [link](https://01.ai) | Get API key at platform.lingyiwanwu.com | +| `zai` | `zai` | Z.AI | API key | [link](https://open.bigmodel.cn) | — | +| `zenmux` | `zm` | ZenMux | API key | [link](https://zenmux.ai) | Use your ZenMux API key in Authorization: Bearer . ZenMux is fully OpenAI-compatible. Base URL: https://zenmux.ai/api/v1. | ## Local Providers (12) -| ID | Alias | Name | Tags | Website | Notes | -| --------------------- | ------------ | ------------------- | ------------------ | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `comfyui` | `comfyui` | ComfyUI | Local | [link](https://github.com/comfyanonymous/ComfyUI) | No API key required. Configure the local ComfyUI base URL (default: http://localhost:8188). | -| `docker-model-runner` | `dmr` | Docker Model Runner | Local, self-hosted | [link](https://docs.docker.com/ai/model-runner/) | API key optional. Configure the local Docker Model Runner OpenAI-compatible base URL (default: http://localhost:12434/v1). | -| `lemonade` | `lemonade` | Lemonade Server | Local, self-hosted | [link](https://lemonade-server.ai) | API key optional. Configure the local Lemonade OpenAI-compatible base URL (default: http://localhost:13305/api/v1). | -| `llama-cpp` | `llamacpp` | llama.cpp | Local, self-hosted | [link](https://github.com/ggml-org/llama.cpp) | API key optional (use any value, e.g. sk-no-key-required). Configure the llama-server OpenAI-compatible base URL (default: http://127.0.0.1:8080/v1). Note: if Llamafile is also installed, both default to port 8080 — run only one at a time or override the port. | -| `llamafile` | `llamafile` | Llamafile | Local, self-hosted | [link](https://github.com/Mozilla-Ocho/llamafile) | API key optional. Configure the local Llamafile OpenAI-compatible base URL (default: http://127.0.0.1:8080/v1). | -| `lm-studio` | `lmstudio` | LM Studio | Local, self-hosted | [link](https://lmstudio.ai) | API key optional. Configure the local LM Studio OpenAI-compatible base URL (default: http://localhost:1234/v1). | -| `ollama-local` | `ollama` | Ollama | Local, self-hosted | [link](https://ollama.com) | No API key required. Ollama runs locally — configure its OpenAI-compatible base URL (default: http://localhost:11434/v1) and make sure Ollama is running before connecting. | -| `oobabooga` | `ooba` | oobabooga | Local, self-hosted | [link](https://github.com/oobabooga/text-generation-webui) | API key optional. Configure the local oobabooga OpenAI-compatible base URL (default: http://localhost:5000/v1). | -| `sdwebui` | `sdwebui` | SD WebUI | Local | [link](https://github.com/AUTOMATIC1111/stable-diffusion-webui) | No API key required. Configure the local WebUI base URL (default: http://localhost:7860). | -| `triton` | `triton` | NVIDIA Triton | Local, self-hosted | [link](https://developer.nvidia.com/triton-inference-server) | API key optional. Configure the Triton OpenAI-compatible base URL (default: http://localhost:8000/v1). | -| `vllm` | `vllm` | vLLM | Local, self-hosted | [link](https://github.com/vllm-project/vllm) | API key optional. Configure the local vLLM OpenAI-compatible base URL (default: http://localhost:8000/v1). | -| `xinference` | `xinference` | XInference | Local, self-hosted | [link](https://inference.readthedocs.io) | API key optional. Configure the local XInference OpenAI-compatible base URL (default: http://localhost:9997/v1). | +| ID | Alias | Name | Tags | Website | Notes | +|----|-------|------|------|---------|-------| +| `comfyui` | `comfyui` | ComfyUI | Local | [link](https://github.com/comfyanonymous/ComfyUI) | No API key required. Configure the local ComfyUI base URL (default: http://localhost:8188). | +| `docker-model-runner` | `dmr` | Docker Model Runner | Local, self-hosted | [link](https://docs.docker.com/ai/model-runner/) | API key optional. Configure the local Docker Model Runner OpenAI-compatible base URL (default: http://localhost:12434/v1). | +| `lemonade` | `lemonade` | Lemonade Server | Local, self-hosted | [link](https://lemonade-server.ai) | API key optional. Configure the local Lemonade OpenAI-compatible base URL (default: http://localhost:13305/api/v1). | +| `llama-cpp` | `llamacpp` | llama.cpp | Local, self-hosted | [link](https://github.com/ggml-org/llama.cpp) | API key optional (use any value, e.g. sk-no-key-required). Configure the llama-server OpenAI-compatible base URL (default: http://127.0.0.1:8080/v1). Note: if Llamafile is also installed, both default to port 8080 — run only one at a time or override the port. | +| `llamafile` | `llamafile` | Llamafile | Local, self-hosted | [link](https://github.com/Mozilla-Ocho/llamafile) | API key optional. Configure the local Llamafile OpenAI-compatible base URL (default: http://127.0.0.1:8080/v1). | +| `lm-studio` | `lmstudio` | LM Studio | Local, self-hosted | [link](https://lmstudio.ai) | API key optional. Configure the local LM Studio OpenAI-compatible base URL (default: http://localhost:1234/v1). | +| `ollama-local` | `ollama` | Ollama | Local, self-hosted | [link](https://ollama.com) | No API key required. Ollama runs locally — configure its OpenAI-compatible base URL (default: http://localhost:11434/v1) and make sure Ollama is running before connecting. | +| `oobabooga` | `ooba` | oobabooga | Local, self-hosted | [link](https://github.com/oobabooga/text-generation-webui) | API key optional. Configure the local oobabooga OpenAI-compatible base URL (default: http://localhost:5000/v1). | +| `sdwebui` | `sdwebui` | SD WebUI | Local | [link](https://github.com/AUTOMATIC1111/stable-diffusion-webui) | No API key required. Configure the local WebUI base URL (default: http://localhost:7860). | +| `triton` | `triton` | NVIDIA Triton | Local, self-hosted | [link](https://developer.nvidia.com/triton-inference-server) | API key optional. Configure the Triton OpenAI-compatible base URL (default: http://localhost:8000/v1). | +| `vllm` | `vllm` | vLLM | Local, self-hosted | [link](https://github.com/vllm-project/vllm) | API key optional. Configure the local vLLM OpenAI-compatible base URL (default: http://localhost:8000/v1). | +| `xinference` | `xinference` | XInference | Local, self-hosted | [link](https://inference.readthedocs.io) | API key optional. Configure the local XInference OpenAI-compatible base URL (default: http://localhost:9997/v1). | ## Search Providers (11) -| ID | Alias | Name | Tags | Website | Notes | -| ------------------- | --------------- | -------------------------- | ------ | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| `brave-search` | `brave-search` | Brave Search | Search | [link](https://brave.com/search/api) | Subscription token from Brave Search API dashboard | -| `exa-search` | `exa-search` | Exa Search | Search | [link](https://exa.ai) | API key from dashboard.exa.ai | -| `google-pse-search` | `google-pse` | Google Programmable Search | Search | [link](https://developers.google.com/custom-search/v1/overview) | Requires a Google API key and your Programmable Search Engine ID (cx) | -| `linkup-search` | `linkup` | Linkup Search | Search | [link](https://docs.linkup.so) | Bearer API key from the Linkup dashboard | -| `ollama-search` | `ollama-search` | Ollama Search | Search | [link](https://ollama.com/settings/keys) | Same API key as Ollama Cloud (from ollama.com/settings/keys) | -| `perplexity-search` | `pplx-search` | Perplexity Search | Search | [link](https://docs.perplexity.ai/guides/search-quickstart) | Same API key as Perplexity (pplx-...) | -| `searchapi-search` | `searchapi` | SearchAPI | Search | [link](https://www.searchapi.io/docs/google) | API key from SearchAPI (query param or Bearer auth) | -| `searxng-search` | `searxng` | SearXNG Search | Search | [link](https://docs.searxng.org) | API key is optional. Set your SearXNG base URL. Some instances may require a bearer token for access. | -| `serper-search` | `serper-search` | Serper Search | Search | [link](https://serper.dev) | API key from serper.dev dashboard | -| `tavily-search` | `tavily-search` | Tavily Search | Search | [link](https://tavily.com) | API key from app.tavily.com (format: tvly-...) | -| `youcom-search` | `youcom-search` | You.com Search | Search | [link](https://you.com/business/api/) | X-API-Key from the You.com platform dashboard | +| ID | Alias | Name | Tags | Website | Notes | +|----|-------|------|------|---------|-------| +| `brave-search` | `brave-search` | Brave Search | Search | [link](https://brave.com/search/api) | Subscription token from Brave Search API dashboard | +| `exa-search` | `exa-search` | Exa Search | Search | [link](https://exa.ai) | API key from dashboard.exa.ai | +| `google-pse-search` | `google-pse` | Google Programmable Search | Search | [link](https://developers.google.com/custom-search/v1/overview) | Requires a Google API key and your Programmable Search Engine ID (cx) | +| `linkup-search` | `linkup` | Linkup Search | Search | [link](https://docs.linkup.so) | Bearer API key from the Linkup dashboard | +| `ollama-search` | `ollama-search` | Ollama Search | Search | [link](https://ollama.com/settings/keys) | Same API key as Ollama Cloud (from ollama.com/settings/keys) | +| `perplexity-search` | `pplx-search` | Perplexity Search | Search | [link](https://docs.perplexity.ai/guides/search-quickstart) | Same API key as Perplexity (pplx-...) | +| `searchapi-search` | `searchapi` | SearchAPI | Search | [link](https://www.searchapi.io/docs/google) | API key from SearchAPI (query param or Bearer auth) | +| `searxng-search` | `searxng` | SearXNG Search | Search | [link](https://docs.searxng.org) | API key is optional. Set your SearXNG base URL. Some instances may require a bearer token for access. | +| `serper-search` | `serper-search` | Serper Search | Search | [link](https://serper.dev) | API key from serper.dev dashboard | +| `tavily-search` | `tavily-search` | Tavily Search | Search | [link](https://tavily.com) | API key from app.tavily.com (format: tvly-...) | +| `youcom-search` | `youcom-search` | You.com Search | Search | [link](https://you.com/business/api/) | X-API-Key from the You.com platform dashboard | ## Audio-only Providers (7) -| ID | Alias | Name | Tags | Website | Notes | -| ------------ | ---------- | ---------- | ----- | ------------------------------------- | ----------------------------------------------------------------------------------------------- | -| `assemblyai` | `aai` | AssemblyAI | Audio | [link](https://assemblyai.com) | — | -| `aws-polly` | `polly` | AWS Polly | Audio | [link](https://aws.amazon.com/polly/) | Use AWS Secret Access Key as API key; set providerSpecificData.accessKeyId and optional region. | -| `cartesia` | `cartesia` | Cartesia | Audio | [link](https://cartesia.ai) | — | -| `deepgram` | `dg` | Deepgram | Audio | [link](https://deepgram.com) | — | -| `elevenlabs` | `el` | ElevenLabs | Audio | [link](https://elevenlabs.io) | — | -| `inworld` | `inworld` | Inworld | Audio | [link](https://inworld.ai) | — | -| `playht` | `playht` | PlayHT | Audio | [link](https://play.ht) | — | +| ID | Alias | Name | Tags | Website | Notes | +|----|-------|------|------|---------|-------| +| `assemblyai` | `aai` | AssemblyAI | Audio | [link](https://assemblyai.com) | — | +| `aws-polly` | `polly` | AWS Polly | Audio | [link](https://aws.amazon.com/polly/) | Use AWS Secret Access Key as API key; set providerSpecificData.accessKeyId and optional region. | +| `cartesia` | `cartesia` | Cartesia | Audio | [link](https://cartesia.ai) | — | +| `deepgram` | `dg` | Deepgram | Audio | [link](https://deepgram.com) | — | +| `elevenlabs` | `el` | ElevenLabs | Audio | [link](https://elevenlabs.io) | — | +| `inworld` | `inworld` | Inworld | Audio | [link](https://inworld.ai) | — | +| `playht` | `playht` | PlayHT | Audio | [link](https://play.ht) | — | ## Upstream Proxy Providers (2) -| ID | Alias | Name | Tags | Website | Notes | -| ------------- | ----- | ----------- | -------------- | ---------------------------------------------------- | ----- | -| `9router` | `nr` | 9router | Upstream proxy | [link](https://www.npmjs.com/package/9router) | — | -| `cliproxyapi` | `cpa` | CLIProxyAPI | Upstream proxy | [link](https://github.com/router-for-me/CLIProxyAPI) | — | +| ID | Alias | Name | Tags | Website | Notes | +|----|-------|------|------|---------|-------| +| `9router` | `nr` | 9router | Upstream proxy | [link](https://www.npmjs.com/package/9router) | — | +| `cliproxyapi` | `cpa` | CLIProxyAPI | Upstream proxy | [link](https://github.com/router-for-me/CLIProxyAPI) | — | ## Cloud Agent Providers (3) -| ID | Alias | Name | Tags | Website | Notes | -| ------------- | ------------- | ------------ | ----------- | -------------------------------- | ----------------------------------------------------------- | -| `codex-cloud` | `codex-cloud` | Codex Cloud | Cloud agent | [link](https://openai.com/codex) | OpenAI API key with Codex Cloud task access. | -| `devin` | `devin` | Devin | Cloud agent | [link](https://devin.ai) | Devin API key for cloud agent sessions. | -| `jules` | `jules` | Google Jules | Cloud agent | [link](https://jules.google) | Jules API key for creating and managing cloud coding tasks. | +| ID | Alias | Name | Tags | Website | Notes | +|----|-------|------|------|---------|-------| +| `codex-cloud` | `codex-cloud` | Codex Cloud | Cloud agent | [link](https://openai.com/codex) | OpenAI API key with Codex Cloud task access. | +| `devin` | `devin` | Devin | Cloud agent | [link](https://devin.ai) | Devin API key for cloud agent sessions. | +| `jules` | `jules` | Google Jules | Cloud agent | [link](https://jules.google) | Jules API key for creating and managing cloud coding tasks. | ## System Providers (1) -| ID | Alias | Name | Tags | Website | Notes | -| ------ | ------ | ------------------ | ------ | ------- | ----- | -| `auto` | `auto` | Auto (Zero-Config) | System | — | — | +| ID | Alias | Name | Tags | Website | Notes | +|----|-------|------|------|---------|-------| +| `auto` | `auto` | Auto (Zero-Config) | System | — | — | ## Sources of truth diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index 97f1dc60ce31..0c0acf4e8d8d 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -5,6 +5,8 @@ * Each provider has its own request format and endpoint. */ +import { LMARENA_DIRECT_IMAGE_MODELS } from "./providers/registry/lmarena/directModels.ts"; + interface ImageModelEntry { id: string; name: string; @@ -576,7 +578,11 @@ export const IMAGE_PROVIDERS: Record = { authHeader: "bearer", format: "nvidia-nim", models: [ - { id: "black-forest-labs/flux.1-dev", name: "FLUX.1 Dev", inputModalities: ["text", "image"] }, + { + id: "black-forest-labs/flux.1-dev", + name: "FLUX.1 Dev", + inputModalities: ["text", "image"], + }, { id: "black-forest-labs/flux.1-schnell", name: "FLUX.1 Schnell" }, { id: "black-forest-labs/flux.1-kontext-dev", @@ -625,6 +631,20 @@ export const IMAGE_PROVIDERS: Record = { ], supportedSizes: ["1024x1024"], }, + + // Arena (formerly LMArena) Direct-chat Image category (static scrape 2026-07-09). + // Not listed in the chat registry — image catalog only. Generation path still + // uses cookie session auth via the lmarena provider connection (stable wire id). + lmarena: { + id: "lmarena", + alias: "lma", + baseUrl: "https://arena.ai/nextjs-api/stream/create-evaluation", + authType: "apikey", + authHeader: "cookie", + format: "openai", + models: LMARENA_DIRECT_IMAGE_MODELS, + supportedSizes: ["1024x1024", "1024x1792", "1792x1024"], + }, }; /** diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index a7e0fa51eb95..110091d04aab 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -21,6 +21,7 @@ import { glmtProvider } from "./registry/glm/t/index.ts"; import { glm_cnProvider } from "./registry/glm/cn/index.ts"; import { traeProvider } from "./registry/trae/index.ts"; import { muse_spark_webProvider } from "./registry/muse-spark-web/index.ts"; +import { lmarenaProvider } from "./registry/lmarena/index.ts"; import { kilocodeProvider } from "./registry/kilocode/index.ts"; import { github_modelsProvider } from "./registry/github/models/index.ts"; import { githubProvider } from "./registry/github/index.ts"; @@ -204,6 +205,7 @@ export const REGISTRY: Record = { "glm-cn": glm_cnProvider, trae: traeProvider, "muse-spark-web": muse_spark_webProvider, + lmarena: lmarenaProvider, kilocode: kilocodeProvider, "github-models": github_modelsProvider, github: githubProvider, diff --git a/open-sse/config/providers/registry/lmarena/directModels.ts b/open-sse/config/providers/registry/lmarena/directModels.ts new file mode 100644 index 000000000000..3e75bc7597ef --- /dev/null +++ b/open-sse/config/providers/registry/lmarena/directModels.ts @@ -0,0 +1,737 @@ +/** + * Arena (formerly LMArena) Direct-chat model allowlist (scraped 2026-07-09). + * - Text + Search → chat registry (providers/registry/lmarena, wire id unchanged) + * - Image → IMAGE_PROVIDERS in imageRegistry.ts (not chat catalog) + * Live HTML discovery is disabled. Scrape JSON stays local/desktop only — not shipped. + */ +import type { RegistryModel } from "../../shared.ts"; + +export interface LmarenaDirectModelEntry { + catalogId: string; + arenaId: string; + publicName: string; + displayName: string; + organization: string; + vision: boolean; + category: string; +} + +export const LMARENA_DIRECT_MODEL_ENTRIES: readonly LmarenaDirectModelEntry[] = Object.freeze([ + { + catalogId: "amazon.nova-pro-v1:0", + arenaId: "a14546b5-d78d-4cf6-bb61-ab5b8510a9d6", + publicName: "amazon.nova-pro-v1:0", + displayName: "amazon.nova-pro-v1:0", + organization: "amazon", + vision: true, + category: "Text", + }, + { + catalogId: "claude-haiku-4-5-20251001", + arenaId: "0199e8e9-01ed-73e0-96ba-cf43b286bf10", + publicName: "claude-haiku-4-5-20251001", + displayName: "claude-haiku-4-5-20251001", + organization: "anthropic", + vision: false, + category: "Text", + }, + { + catalogId: "claude-sonnet-5", + arenaId: "019f19f2-41f1-7c6d-9891-48d02fd9952c", + publicName: "claude-sonnet-5", + displayName: "claude-sonnet-5-high", + organization: "anthropic", + vision: true, + category: "Text", + }, + { + catalogId: "deepseek-v4-pro-thinking", + arenaId: "019dc1c1-c62d-7b70-85a1-e0565e29fce1", + publicName: "deepseek-v4-pro-thinking", + displayName: "deepseek-v4-pro-thinking", + organization: "deepseek", + vision: false, + category: "Text", + }, + { + catalogId: "dola-seed-2.0-preview-vision", + arenaId: "019c6453-641a-74f8-a689-a8a67175a359", + publicName: "dola-seed-2.0-preview-vision", + displayName: "dola-seed-2.0-preview-vision", + organization: "bytedance", + vision: true, + category: "Text", + }, + { + catalogId: "ernie-5.0-preview-1220", + arenaId: "019d44f1-26da-729f-a4ea-ddddfe7b4eae", + publicName: "ernie-5.0-preview-1220", + displayName: "ernie-5.0-preview-1220", + organization: "baidu", + vision: true, + category: "Text", + }, + { + catalogId: "gemini-3.1-flash-lite", + arenaId: "019f408a-186c-7f3a-9595-4e079e42a613", + publicName: "gemini-3.1-flash-lite", + displayName: "gemini-3.1-flash-lite", + organization: "google", + vision: true, + category: "Text", + }, + { + catalogId: "gemini-3.1-pro-preview", + arenaId: "019c7820-5480-78b6-9fef-04c0d7004054", + publicName: "gemini-3.1-pro-preview", + displayName: "gemini-3.1-pro-preview", + organization: "google", + vision: true, + category: "Text", + }, + { + catalogId: "gemini-3.5-flash-high", + arenaId: "019f406f-fc33-7b9d-9571-7b8443bc7ca0", + publicName: "gemini-3.5-flash-high", + displayName: "gemini-3.5-flash-high", + organization: "google", + vision: true, + category: "Text", + }, + { + catalogId: "significant-otter", + arenaId: "019d2cd3-2641-7628-94bd-67ecb0a7134e", + publicName: "significant-otter", + displayName: "gemma-4-26b-a4b", + organization: "google", + vision: true, + category: "Text", + }, + { + catalogId: "pteronura", + arenaId: "019d2cd2-dd83-75ab-a421-d0ba2e22b1e3", + publicName: "pteronura", + displayName: "gemma-4-31b", + organization: "google", + vision: true, + category: "Text", + }, + { + catalogId: "glm-5.1", + arenaId: "019ebf6a-94d4-7649-b704-1dbbd5eb0942", + publicName: "glm-5.1", + displayName: "glm-5.2 (max)", + organization: "zai", + vision: false, + category: "Text", + }, + { + catalogId: "glm-5v-turbo", + arenaId: "019d4a09-9651-78cb-86ea-bb0fa5ec77f4", + publicName: "glm-5v-turbo", + displayName: "glm-5v-turbo", + organization: "zai", + vision: true, + category: "Text", + }, + { + catalogId: "gpt-oss-120b", + arenaId: "6ee9f901-17b5-4fbe-9cc2-13c16497c23b", + publicName: "gpt-oss-120b", + displayName: "gpt-oss-120b", + organization: "openai", + vision: false, + category: "Text", + }, + { + catalogId: "gpt-5.2-high", + arenaId: "019b1449-0313-7911-b836-419e2ed79b2e", + publicName: "gpt-5.2-high", + displayName: "gpt-5.2-high", + organization: "openai", + vision: true, + category: "Text", + }, + { + catalogId: "gpt-5.4-mini-high", + arenaId: "019cfcdd-5426-777e-8314-04619cb92cc4", + publicName: "gpt-5.4-mini-high", + displayName: "gpt-5.4-mini-high", + organization: "openai", + vision: true, + category: "Text", + }, + { + catalogId: "gpt-5.4-nano-high", + arenaId: "019cfcdd-0bca-706f-92b5-a4c4cbd022d8", + publicName: "gpt-5.4-nano-high", + displayName: "gpt-5.4-nano-high", + organization: "openai", + vision: true, + category: "Text", + }, + { + catalogId: "gpt-5.5-instant", + arenaId: "019e71ea-1e1d-740f-9c2d-dab5869ff108", + publicName: "gpt-5.5-instant", + displayName: "gpt-5.5-instant", + organization: "openai", + vision: true, + category: "Text", + }, + { + catalogId: "grok-4.3/text", + arenaId: "019f42aa-9c3b-76d1-8bdf-2e883b1ca227", + publicName: "grok-4.3", + displayName: "grok-4.5", + organization: "xai", + vision: true, + category: "Text", + }, + { + catalogId: "hunyuan-vision-1.5-thinking", + arenaId: "6a3a1e04-050e-4cb4-9052-b9ac4bec0c38", + publicName: "hunyuan-vision-1.5-thinking", + displayName: "hunyuan-vision-1.5-thinking", + organization: "tencent", + vision: true, + category: "Text", + }, + { + catalogId: "hy3", + arenaId: "019f3911-ba1c-7e36-b631-01893e557290", + publicName: "hy3", + displayName: "hy3", + organization: "tencent", + vision: false, + category: "Text", + }, + { + catalogId: "kimi-k2.6", + arenaId: "019dac54-e8a4-7c54-904a-ff0ecd82af42", + publicName: "kimi-k2.6", + displayName: "kimi-k2.6", + organization: "moonshot", + vision: true, + category: "Text", + }, + { + catalogId: "ling-2.5-1t", + arenaId: "019c6e76-fbbc-7e92-b0ba-784c7ef3ad8b", + publicName: "ling-2.5-1t", + displayName: "ling-2.5-1t", + organization: "ant-group", + vision: false, + category: "Text", + }, + { + catalogId: "longcat-2.0", + arenaId: "019f3a0a-bd19-7b19-9eed-a98453759b48", + publicName: "longcat-2.0", + displayName: "longcat-2.0", + organization: "meituan", + vision: false, + category: "Text", + }, + { + catalogId: "mercury-2", + arenaId: "019cc65f-c1e3-7574-b332-898ab71c8211", + publicName: "mercury-2", + displayName: "mercury-2", + organization: "inception-ai", + vision: false, + category: "Text", + }, + { + catalogId: "mimo-v2.5", + arenaId: "019db651-bd2f-7d80-ab12-d69c6bb623df", + publicName: "mimo-v2.5", + displayName: "mimo-v2.5", + organization: "xiaomi", + vision: true, + category: "Text", + }, + { + catalogId: "mimo-v2.5-pro", + arenaId: "019db650-909d-7dec-8711-1907d7233cd4", + publicName: "mimo-v2.5-pro", + displayName: "mimo-v2.5-pro", + organization: "xiaomi", + vision: false, + category: "Text", + }, + { + catalogId: "minimax-m3", + arenaId: "019e809d-f62d-7192-bb7f-1657e066b5f2", + publicName: "minimax-m3", + displayName: "minimax-m3", + organization: "minimax", + vision: true, + category: "Text", + }, + { + catalogId: "mistral-large-3", + arenaId: "019acbac-df7c-73dc-9716-ebe040daaa4e", + publicName: "mistral-large-3", + displayName: "mistral-large-3", + organization: "mistral", + vision: true, + category: "Text", + }, + { + catalogId: "mistral-medium-3.5", + arenaId: "019f30a4-044d-7a14-9d3b-2e7299159e36", + publicName: "mistral-medium-3.5", + displayName: "mistral-medium-3.5", + organization: "mistral", + vision: true, + category: "Text", + }, + { + catalogId: "mistral-small-2603", + arenaId: "019cf983-532b-73fa-a057-7658e1e1c5ee", + publicName: "mistral-small-2603", + displayName: "mistral-small-2603", + organization: "mistral", + vision: false, + category: "Text", + }, + { + catalogId: "nova-2-lite", + arenaId: "019ae300-83b7-7717-a1e0-31accd1ff6fa", + publicName: "nova-2-lite", + displayName: "nova-2-lite", + organization: "amazon", + vision: false, + category: "Text", + }, + { + catalogId: "nvidia-nemotron-3-nano-30b-a3b-bf16", + arenaId: "019b0aa7-334a-78e8-b2a8-885f31f4fc0c", + publicName: "nvidia-nemotron-3-nano-30b-a3b-bf16", + displayName: "nvidia-nemotron-3-nano-30b-a3b-bf16", + organization: "nvidia", + vision: false, + category: "Text", + }, + { + catalogId: "march26-chatbot1-public", + arenaId: "019cd9e3-c3ff-7225-92f2-c392259b1fbe", + publicName: "march26-chatbot1-public", + displayName: "nvidia-nemotron-3-super-120b-a12b", + organization: "nvidia", + vision: false, + category: "Text", + }, + { + catalogId: "may26-chatbot4-public", + arenaId: "019e8ea8-2052-7f2e-b1b6-59bd94be5203", + publicName: "may26-chatbot4-public", + displayName: "nvidia-nemotron-3-ultra-550b-a55b-nvfp4", + organization: "nvidia", + vision: false, + category: "Text", + }, + { + catalogId: "o3-2025-04-16", + arenaId: "cb0f1e24-e8e9-4745-aabc-b926ffde7475", + publicName: "o3-2025-04-16", + displayName: "o3-2025-04-16", + organization: "openai", + vision: true, + category: "Text", + }, + { + catalogId: "qwen3.5-397b-a17b", + arenaId: "019c6918-1d2a-7e3f-88ec-ada000b6ab16", + publicName: "qwen3.5-397b-a17b", + displayName: "qwen3.5-397b-a17b", + organization: "alibaba", + vision: true, + category: "Text", + }, + { + catalogId: "qwen3.7-max", + arenaId: "019e6530-f140-77b1-b6b8-5c859829d992", + publicName: "qwen3.7-max", + displayName: "qwen3.7-max", + organization: "alibaba", + vision: false, + category: "Text", + }, + { + catalogId: "qwen3.7-plus", + arenaId: "019e86fe-167d-77bd-94a8-df7aee4f4551", + publicName: "qwen3.7-plus", + displayName: "qwen3.7-plus", + organization: "alibaba", + vision: true, + category: "Text", + }, + { + catalogId: "ring-2.5-1t", + arenaId: "019c6e77-1b9f-7649-9136-43d07566c6c5", + publicName: "ring-2.5-1t", + displayName: "ring-2.5-1t", + organization: "ant-group", + vision: false, + category: "Text", + }, + { + catalogId: "step-3.5-flash", + arenaId: "019d22bb-fcf5-7866-9c07-de74fe05c9cc", + publicName: "step-3.5-flash", + displayName: "step-3.5-flash", + organization: "stepfun", + vision: false, + category: "Text", + }, + { + catalogId: "trinity-large-thinking", + arenaId: "019d50aa-447d-74d6-8661-405b4b6de5de", + publicName: "trinity-large-thinking", + displayName: "trinity-large-thinking", + organization: "arcee-ai", + vision: false, + category: "Text", + }, + { + catalogId: "cosmos3-super", + arenaId: "019f4270-5d10-723c-8a7e-dfd5dd2214e5", + publicName: "cosmos3-super", + displayName: "cosmos3-super", + organization: "nvidia", + vision: false, + category: "Image", + }, + { + catalogId: "cosmos3-super-agentic", + arenaId: "019f4270-9eea-79c3-b18f-71303ee41ff8", + publicName: "cosmos3-super-agentic", + displayName: "cosmos3-super-agentic", + organization: "nvidia", + vision: false, + category: "Image", + }, + { + catalogId: "flux-2-dev", + arenaId: "019b478d-74ae-7d19-9a8f-6cfde89ab4ca", + publicName: "flux-2-dev", + displayName: "flux-2-dev", + organization: "bfl", + vision: true, + category: "Image", + }, + { + catalogId: "flux-2-pro", + arenaId: "019b7541-5e4b-7ff7-a34b-b0255b6ca9aa", + publicName: "flux-2-pro", + displayName: "flux-2-pro", + organization: "bfl", + vision: true, + category: "Image", + }, + { + catalogId: "gemini-2.5-flash-image-preview (nano-banana)", + arenaId: "0199ef2a-583f-7088-b704-b75fd169401d", + publicName: "gemini-2.5-flash-image-preview (nano-banana)", + displayName: "gemini-2.5-flash-image-preview (nano-banana)", + organization: "google", + vision: true, + category: "Image", + }, + { + catalogId: "instant-ramen", + arenaId: "019ed3b3-c4f8-7e67-b72d-385d740096d0", + publicName: "instant-ramen", + displayName: "gemini-3.1-flash-lite-image (nano-banana-2-lite)", + organization: "google", + vision: true, + category: "Image", + }, + { + catalogId: "gpt-image-1", + arenaId: "6e855f13-55d7-4127-8656-9168a9f4dcc0", + publicName: "gpt-image-1", + displayName: "gpt-image-1", + organization: "openai", + vision: true, + category: "Image", + }, + { + catalogId: "blue-crab", + arenaId: "019e4271-4717-7dd9-a0e2-90783fbabd25", + publicName: "blue-crab", + displayName: "grok-imagine-image-quality (20260519)", + organization: "xai", + vision: true, + category: "Image", + }, + { + catalogId: "hidream-o1-image", + arenaId: "019e1cd7-5cc5-75d2-8b3e-275616dec624", + publicName: "hidream-o1-image", + displayName: "hidream-o1-image", + organization: "hidream", + vision: false, + category: "Image", + }, + { + catalogId: "sungod", + arenaId: "019bec2d-e92c-745d-ae46-c7166590237a", + publicName: "sungod", + displayName: "hunyuan-image-3.0-instruct", + organization: "tencent", + vision: true, + category: "Image", + }, + { + catalogId: "ideogram-v3-quality", + arenaId: "73378be5-cdba-49e7-b3d0-027949871aa6", + publicName: "ideogram-v3-quality", + displayName: "ideogram-v3-quality", + organization: "Ideogram", + vision: false, + category: "Image", + }, + { + catalogId: "krea-2-large", + arenaId: "019e8ebd-6cfb-7492-949c-a2c4a00301aa", + publicName: "krea-2-large", + displayName: "krea-2-large", + organization: "krea", + vision: false, + category: "Image", + }, + { + catalogId: "krea-2-turbo", + arenaId: "019f049d-7de4-7fae-8237-1c2103b9e730", + publicName: "krea-2-turbo", + displayName: "krea-2-turbo", + organization: "krea", + vision: false, + category: "Image", + }, + { + catalogId: "lucid-origin", + arenaId: "5a3b3520-c87d-481f-953c-1364687b6e8f", + publicName: "lucid-origin", + displayName: "lucid-origin", + organization: "leonardo-ai", + vision: false, + category: "Image", + }, + { + catalogId: "kakarot-v2", + arenaId: "019e80aa-37bf-7e89-8a28-41e4ab72ed9f", + publicName: "kakarot-v2", + displayName: "mai-image-2.5 (image-edit)", + organization: "microsoft-ai", + vision: true, + category: "Image", + }, + { + catalogId: "baryonyx", + arenaId: "019e530d-2a50-75e3-95d1-a5ef41d4c24c", + publicName: "baryonyx", + displayName: "mai-image-2.5 (text-to-image)", + organization: "microsoft-ai", + vision: false, + category: "Image", + }, + { + catalogId: "iron-bloom", + arenaId: "019ef780-25ef-7878-8b91-307f8f879d42", + publicName: "iron-bloom", + displayName: "muse-image", + organization: "meta", + vision: true, + category: "Image", + }, + { + catalogId: "photon", + arenaId: "e7c9fa2d-6f5d-40eb-8305-0980b11c7cab", + publicName: "photon", + displayName: "photon", + organization: "luma-ai", + vision: false, + category: "Image", + }, + { + catalogId: "qwen-image-2.0", + arenaId: "019d287c-4906-7f9c-8b78-8a2a86cf00a5", + publicName: "qwen-image-2.0", + displayName: "qwen-image-2.0", + organization: "alibaba", + vision: true, + category: "Image", + }, + { + catalogId: "qwen-image-2.0-pro", + arenaId: "019d287b-b718-7daa-ad65-502596d0813d", + publicName: "qwen-image-2.0-pro", + displayName: "qwen-image-2.0-pro", + organization: "alibaba", + vision: true, + category: "Image", + }, + { + catalogId: "recraft-v4", + arenaId: "019c6e76-a7c0-7b05-8dce-bbe3d52c8f4e", + publicName: "recraft-v4", + displayName: "recraft-v4", + organization: "Recraft", + vision: false, + category: "Image", + }, + { + catalogId: "avalon", + arenaId: "019e7091-f73f-7338-b5e2-4ab5fba37dc2", + publicName: "avalon", + displayName: "reve-2.0 (image-edit)", + organization: "reve", + vision: true, + category: "Image", + }, + { + catalogId: "babylon", + arenaId: "019e86c6-a3dc-73ac-9adc-8c5f304dc2fb", + publicName: "babylon", + displayName: "reve-2.0 (text-to-image)", + organization: "reve", + vision: false, + category: "Image", + }, + { + catalogId: "seedream-5.0-pro", + arenaId: "019f42b5-8c52-7793-9be8-de35eecf7ea9", + publicName: "seedream-5.0-pro", + displayName: "seedream-5.0-pro", + organization: "bytedance", + vision: true, + category: "Image", + }, + { + catalogId: "uni-1.1-max", + arenaId: "019ed208-69ca-7f3f-85ee-182d5f0ea08b", + publicName: "uni-1.1-max", + displayName: "uni-1.1-max", + organization: "luma-ai", + vision: true, + category: "Image", + }, + { + catalogId: "wan2.7-image-pro", + arenaId: "019db3f0-b024-7478-bd4d-55ea1ec1d421", + publicName: "wan2.7-image-pro", + displayName: "wan2.7-image-pro", + organization: "wan", + vision: true, + category: "Image", + }, + { + catalogId: "zen-bear-v3", + arenaId: "019f38c2-002d-7f0f-a391-db6df024b734", + publicName: "zen-bear-v3", + displayName: "zen-bear-v3", + organization: "alibaba", + vision: false, + category: "Image", + }, + { + catalogId: "claude-sonnet-5-search", + arenaId: "019f1a07-de72-7fbe-8d82-56dbd7348360", + publicName: "claude-sonnet-5-search", + displayName: "claude-sonnet-5-search", + organization: "anthropic", + vision: false, + category: "Search", + }, + { + catalogId: "gemini-2.5-pro-grounding", + arenaId: "b222be23-bd55-4b20-930b-a30cc84d3afd", + publicName: "gemini-2.5-pro-grounding", + displayName: "gemini-2.5-pro-grounding", + organization: "google", + vision: false, + category: "Search", + }, + { + catalogId: "gemini-3-flash-grounding", + arenaId: "019bda1f-3abc-783f-aac0-1ee102b247ba", + publicName: "gemini-3-flash-grounding", + displayName: "gemini-3-flash-grounding", + organization: "google", + vision: false, + category: "Search", + }, + { + catalogId: "gpt-5.2-search", + arenaId: "019b1448-f74a-72de-b25d-8666618f8c5a", + publicName: "gpt-5.2-search", + displayName: "gpt-5.2-search", + organization: "openai", + vision: false, + category: "Search", + }, + { + catalogId: "grok-4.3/search", + arenaId: "019de22d-1445-7296-9c88-a5877bc66ef8", + publicName: "grok-4.3", + displayName: "grok-4.3", + organization: "xai", + vision: false, + category: "Search", + }, + { + catalogId: "o3-search", + arenaId: "fbe08e9a-3805-4f9f-a085-7bc38e4b51d1", + publicName: "o3-search", + displayName: "o3-search", + organization: "openai", + vision: false, + category: "Search", + }, +] as LmarenaDirectModelEntry[]); + +/** Chat-completions catalog (Text + Search). Image rows are excluded. */ +export const LMARENA_DIRECT_CHAT_ENTRIES: readonly LmarenaDirectModelEntry[] = + LMARENA_DIRECT_MODEL_ENTRIES.filter((m) => m.category === "Text" || m.category === "Search"); + +/** Image-generation catalog rows (IMAGE_PROVIDERS). */ +export const LMARENA_DIRECT_IMAGE_ENTRIES: readonly LmarenaDirectModelEntry[] = + LMARENA_DIRECT_MODEL_ENTRIES.filter((m) => m.category === "Image"); + +export const LMARENA_DIRECT_MODELS: RegistryModel[] = LMARENA_DIRECT_CHAT_ENTRIES.map((m) => ({ + id: m.catalogId, + name: m.displayName, + ...(m.vision ? { supportsVision: true as const } : {}), +})); + +export const LMARENA_DIRECT_IMAGE_MODELS: Array<{ + id: string; + name: string; + inputModalities?: string[]; +}> = LMARENA_DIRECT_IMAGE_ENTRIES.map((m) => ({ + id: m.catalogId, + name: m.displayName, + inputModalities: m.vision ? ["text", "image"] : ["text"], +})); + +export function resolveLmarenaArenaId(catalogOrArenaId: string): string | null { + const raw = catalogOrArenaId.replace(/^(?:lmarena|lma|arena)\//i, "").trim(); + if (!raw) return null; + const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (uuidRe.test(raw)) return raw; + const lower = raw.toLowerCase(); + const hit = LMARENA_DIRECT_MODEL_ENTRIES.find( + (m) => + m.catalogId === raw || + m.publicName === raw || + m.displayName === raw || + m.catalogId.toLowerCase() === lower || + m.publicName.toLowerCase() === lower || + m.displayName.toLowerCase() === lower + ); + return hit?.arenaId ?? null; +} diff --git a/open-sse/config/providers/registry/lmarena/index.ts b/open-sse/config/providers/registry/lmarena/index.ts new file mode 100644 index 000000000000..ab91a6a9e5f8 --- /dev/null +++ b/open-sse/config/providers/registry/lmarena/index.ts @@ -0,0 +1,18 @@ +import type { RegistryEntry } from "../../shared.ts"; +import { LMARENA_DIRECT_MODELS } from "./directModels.ts"; + +/** + * Arena (formerly LMArena) web-session provider — arena.ai. + * Wire id remains `lmarena`. Model list is a static Direct-chat allowlist + * (no live arena.ai HTML scrape). + */ +export const lmarenaProvider: RegistryEntry = { + id: "lmarena", + alias: "lma", + format: "openai", + executor: "lmarena", + baseUrl: "https://arena.ai/nextjs-api/stream/create-evaluation", + authType: "apikey", + authHeader: "cookie", + models: LMARENA_DIRECT_MODELS, +}; diff --git a/open-sse/executors/lmarena.ts b/open-sse/executors/lmarena.ts index 2f78d278db1d..3f28acf84865 100644 --- a/open-sse/executors/lmarena.ts +++ b/open-sse/executors/lmarena.ts @@ -1,177 +1,71 @@ /** - * LMArenaExecutor — LMArena Web Session Provider + * LMArenaExecutor — Arena (formerly LMArena) web-session provider. * - * Routes requests through LMArena's web API using session credentials. - * LMArena is a model comparison platform with 100+ models (GPT, Claude, Gemini, Llama). + * Routes requests through arena.ai create-evaluation with session cookies. + * Upstream sits behind Cloudflare; traffic goes through tls-client-node Chrome + * impersonation (see services/lmarenaTlsClient.ts). * - * API Structure: - * Endpoint: https://arena.ai/nextjs-api/stream - * Method: POST - * Content-Type: application/json - * Accept: text/event-stream - * - * Auth pipeline (per request): - * 1. Extract session cookie from credentials - * 2. Build request with model and messages - * 3. Make authenticated POST request to LMArena API - * 4. Handle SSE response stream with custom prefixes (a0:, ag:, a3:, ae:, ad:) - * - * SSE Format: - * a0: - Text content (concatenate) - * ag: - Thinking/reasoning content - * a2: - Heartbeat (ignore) - * a3: - Model error - * ae: - Platform error - * ad: - Done marker + * Helpers: open-sse/executors/lmarena/{cookie,models,stream,response}.ts */ +import { v7 as uuidv7 } from "uuid"; import { BaseExecutor, type ExecuteInput } from "./base.ts"; -import { sanitizeErrorMessage } from "../utils/error.ts"; - -const LMARENA_API_BASE = "https://arena.ai"; -const LMARENA_STREAM_URL = `${LMARENA_API_BASE}/nextjs-api/stream`; - -const LMARENA_USER_AGENT = - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; - -const LMARENA_AUTH_COOKIE = "arena-auth-prod-v1"; - -interface ParsedCookie { - name: string; - value: string; +import { tlsFetchLMArena, TlsClientUnavailableError } from "../services/lmarenaTlsClient.ts"; +import { readLMArenaCookie, reconstructLMArenaCookie } from "./lmarena/cookie.ts"; +import { + LMARENA_STREAM_URL, + LMARENA_USER_AGENT, + buildLmarenaBrowserHeaders, + markLMArenaCatalogModelDead, + normalizeLMArenaModelsForCatalog, + parseLMArenaInitialModels, + pickLMArenaModelId, + resolveLMArenaModelId, + type LMArenaModelMetadata, +} from "./lmarena/models.ts"; +import { formatArenaPrompt, parseArenaSSE } from "./lmarena/stream.ts"; +import { + buildArenaUpstreamHttpResponse, + createOpenAIArenaStream, + handleNonStreamingArenaResponse, + mapFailedTlsResult, + mapNetworkError, + mapTlsUnavailable, + missingCookieResult, +} from "./lmarena/response.ts"; + +export { + reconstructLMArenaCookie, + normalizeLMArenaModelsForCatalog, + parseLMArenaInitialModels, + pickLMArenaModelId, + parseArenaSSE, + markLMArenaCatalogModelDead, + LMARENA_USER_AGENT, +}; +export { clearLMArenaDeadCatalogModels } from "./lmarena/models.ts"; +export type { LMArenaModelMetadata }; + +interface OpenAIMessage { + role?: string; + content?: unknown; } -/** - * Parse a raw `Cookie:`-style blob (`name=value; name2=value2; …`) into an - * ordered list of name/value pairs. Whitespace around names is trimmed; values - * are kept verbatim (they may legitimately contain `=`, e.g. base64 padding). - */ -function parseCookieBlob(blob: string): ParsedCookie[] { - const pairs: ParsedCookie[] = []; - for (const part of blob.split(";")) { - const eq = part.indexOf("="); - if (eq < 0) continue; - const name = part.slice(0, eq).trim(); - if (!name) continue; - const value = part.slice(eq + 1).trim(); - pairs.push({ name, value }); - } - return pairs; -} - -/** - * Reconstruct LMArena's single `arena-auth-prod-v1` auth cookie from the - * Supabase SSR chunked form. - * - * LMArena migrated to `@supabase/ssr`, which splits a large auth cookie across - * `arena-auth-prod-v1.0`, `arena-auth-prod-v1.1`, … (ascending). The single - * `arena-auth-prod-v1` cookie is then left empty. Following `@supabase/ssr`'s - * `combineChunks`, we read chunks in ascending numeric order until one is - * missing and `join("")` their raw values — NO base64-decode, NO JSON-parse. - * The joined value typically starts with the literal `base64-` prefix; we keep - * it verbatim (the upstream expects it). - * - * - If the blob already carries a non-empty `arena-auth-prod-v1=`, it is - * returned unchanged (back-compat with the pre-migration single cookie). - * - Otherwise the reconstructed `arena-auth-prod-v1=` is injected while - * every other cookie in the pasted jar is preserved. - * - If neither the single cookie nor any `.N` chunk has a value, the blob is - * returned as-is so the existing missing-cookie path still fires. - */ -export function reconstructLMArenaCookie(rawCookie: string): string { - if (!rawCookie || !rawCookie.trim()) return rawCookie; - - const pairs = parseCookieBlob(rawCookie); - - // Back-compat: a non-empty single cookie is already usable — forward verbatim. - const existing = pairs.find((p) => p.name === LMARENA_AUTH_COOKIE); - if (existing && existing.value) return rawCookie; - - // Collect chunk values keyed by their numeric index (`arena-auth-prod-v1.`). - const chunkPrefix = `${LMARENA_AUTH_COOKIE}.`; - const chunks = new Map(); - for (const { name, value } of pairs) { - if (!name.startsWith(chunkPrefix)) continue; - const idxRaw = name.slice(chunkPrefix.length); - if (!/^\d+$/.test(idxRaw)) continue; - chunks.set(Number(idxRaw), value); - } - - // Join in ascending order until a chunk is missing (combineChunks semantics). - const joinedParts: string[] = []; - for (let i = 0; chunks.has(i); i++) { - joinedParts.push(chunks.get(i) ?? ""); - } - const joined = joinedParts.join(""); - - // No usable session anywhere → return as-is so the missing-cookie path fires. - if (!joined) return rawCookie; - - // Inject the reconstructed single cookie while preserving the rest of the jar - // (drop the empty base cookie and the now-redundant chunks). - const preserved = pairs.filter( - (p) => p.name !== LMARENA_AUTH_COOKIE && !p.name.startsWith(chunkPrefix) - ); - const rebuilt = [ - `${LMARENA_AUTH_COOKIE}=${joined}`, - ...preserved.map((p) => `${p.name}=${p.value}`), - ]; - return rebuilt.join("; "); -} - -function readLMArenaCookie(credentials: unknown): string { - if (!credentials || typeof credentials !== "object") return ""; - const c = credentials as Record; - const direct = typeof c.cookie === "string" ? c.cookie : ""; - if (direct.trim()) return reconstructLMArenaCookie(direct); - const apiKey = typeof c.apiKey === "string" ? c.apiKey : ""; - if (apiKey.trim()) return reconstructLMArenaCookie(apiKey); - const psd = c.providerSpecificData; - if (psd && typeof psd === "object") { - const nested = (psd as Record).cookie; - if (typeof nested === "string" && nested.trim()) return reconstructLMArenaCookie(nested); - } - return ""; -} - -interface ArenaSSEEvent { - type: "text" | "thinking" | "error" | "done" | "heartbeat"; - content?: string; -} - -export function parseArenaSSE(line: string): ArenaSSEEvent | null { - if (line.startsWith("a0:")) { - try { - const content = JSON.parse(line.substring(3)); - return { type: "text", content: typeof content === "string" ? content : content.text || "" }; - } catch { - return null; - } - } else if (line.startsWith("ag:")) { - try { - const content = JSON.parse(line.substring(3)); - return { - type: "thinking", - content: typeof content === "string" ? content : content.thinking || "", - }; - } catch { - return null; +/** Optional browser-issued reCAPTCHA v3 token (operator-supplied). */ +function readRecaptchaToken(credentials: unknown, body: unknown): string | null { + const fromObj = (v: unknown): string | null => { + if (!v || typeof v !== "object") return null; + const rec = v as Record; + const direct = rec.recaptchaV3Token ?? rec.recaptchaToken; + if (typeof direct === "string" && direct.trim()) return direct.trim(); + const psd = rec.providerSpecificData; + if (psd && typeof psd === "object") { + const nested = psd as Record; + const t = nested.recaptchaV3Token ?? nested.recaptchaToken; + if (typeof t === "string" && t.trim()) return t.trim(); } - } else if (line.startsWith("a3:") || line.startsWith("ae:")) { - try { - const content = JSON.parse(line.substring(3)); - return { - type: "error", - content: typeof content === "string" ? content : content.error || JSON.stringify(content), - }; - } catch { - return { type: "error", content: line.substring(3) }; - } - } else if (line.startsWith("ad:")) { - return { type: "done" }; - } else if (line.startsWith("a2:")) { - return { type: "heartbeat" }; - } - return null; + return null; + }; + return fromObj(credentials) ?? fromObj(body); } export class LMArenaExecutor extends BaseExecutor { @@ -189,242 +83,135 @@ export class LMArenaExecutor extends BaseExecutor { _body: unknown ): Record { const cookie = readLMArenaCookie(credentials); - const headers: Record = { + const headers = buildLmarenaBrowserHeaders({ "Content-Type": "application/json", Accept: "text/event-stream", - "User-Agent": LMARENA_USER_AGENT, - Origin: LMARENA_API_BASE, - Referer: `${LMARENA_API_BASE}/`, - }; - - if (cookie) { - headers.Cookie = cookie; - } - + }); + if (cookie) headers.Cookie = cookie; return headers; } - protected transformRequest(body: unknown, model: string): unknown { - const openaiBody = body as Record; - const messages = openaiBody.messages as Array<{ role: string; content: string }>; - + protected transformRequest(body: unknown, model: string, credentials?: unknown): unknown { + const openaiBody = body && typeof body === "object" ? (body as Record) : {}; + const messages = Array.isArray(openaiBody.messages) + ? (openaiBody.messages as OpenAIMessage[]) + : []; return { - messages: messages.map((m) => ({ - role: m.role, - content: m.content, - })), - model, - stream: openaiBody.stream || false, + id: uuidv7(), + mode: "direct-battle", + modelAId: model, + userMessageId: uuidv7(), + modelAMessageId: uuidv7(), + userMessage: { + content: formatArenaPrompt(messages), + experimental_attachments: [], + metadata: {}, + }, + modality: "chat", + recaptchaV3Token: readRecaptchaToken(credentials, body), }; } async execute(input: ExecuteInput) { const { model, body, stream, credentials, signal, log } = input; - const url = this.buildUrl(model, credentials); const headers = this.buildHeaders(model, credentials, body); - const transformedBody = this.transformRequest(body, model); - const cookie = readLMArenaCookie(credentials); + if (!cookie) { - return { - response: new Response( - JSON.stringify({ - error: { - message: "LMArena requires a session cookie. Please provide cookie in credentials.", - type: "authentication_error", - code: "missing_cookie", - }, - }), - { status: 401, headers: { "Content-Type": "application/json" } } - ), - url, - headers, - transformedBody, - }; + return missingCookieResult(url, headers, this.transformRequest(body, model, credentials)); } - log?.info?.("LMArenaExecutor", `Executing request for model: ${model}`); + const arenaModelId = await resolveLMArenaModelId(model, log); + const transformedBody = this.transformRequest(body, arenaModelId, credentials) as Record< + string, + unknown + >; + + log?.info?.( + "LMArenaExecutor", + arenaModelId === model + ? `Executing request for model: ${model}` + : `Executing request for model: ${model} (${arenaModelId})` + ); try { - const response = await fetch(url, { - method: "POST", - headers, - body: JSON.stringify(transformedBody), + return await this.dispatchTls(url, headers, transformedBody, { + model, + arenaModelId, + stream: !!stream, signal, + log, }); - - if (!response.ok) { - const errorText = await response.text(); - let errorMessage = `LMArena API error: ${response.status}`; - try { - const errorJson = JSON.parse(errorText); - errorMessage = errorJson.error?.message || errorJson.message || errorMessage; - } catch { - errorMessage = errorText || errorMessage; - } - - return { - response: new Response( - JSON.stringify({ - error: { - message: sanitizeErrorMessage(errorMessage), - type: "api_error", - code: String(response.status), - }, - }), - { status: response.status, headers: { "Content-Type": "application/json" } } - ), - url, - headers, - transformedBody, - }; - } - - const upstreamResponse = stream - ? await this.handleStreamingResponse(response, model, log) - : await this.handleNonStreamingResponse(response, model, log); - - return { response: upstreamResponse, url, headers, transformedBody }; } catch (error) { + if (error instanceof TlsClientUnavailableError) { + log?.error?.("LMArenaExecutor", `TLS client unavailable: ${error.message}`); + return mapTlsUnavailable(error, url, headers, transformedBody); + } const message = error instanceof Error ? error.message : String(error); log?.error?.("LMArenaExecutor", `Request failed: ${message}`); + return mapNetworkError(message, url, headers, transformedBody); + } + } - return { - response: new Response( - JSON.stringify({ - error: { - message: sanitizeErrorMessage(message), - type: "network_error", - code: "request_failed", - }, - }), - { status: 502, headers: { "Content-Type": "application/json" } } - ), - url, - headers, - transformedBody, - }; + private async dispatchTls( + url: string, + headers: Record, + transformedBody: Record, + ctx: { + model: string; + arenaModelId: string; + stream: boolean; + signal?: AbortSignal; + log?: ExecuteInput["log"]; } + ) { + const tlsResult = await tlsFetchLMArena(url, { + method: "POST", + headers, + body: JSON.stringify(transformedBody), + signal: ctx.signal, + stream: ctx.stream, + streamEofSymbol: "__OMNIROUTE_LMARENA_EOF_NEVER__", + }); + + const failed = mapFailedTlsResult({ + status: tlsResult.status, + text: tlsResult.text, + hasRecaptcha: transformedBody.recaptchaV3Token != null, + model: ctx.model, + arenaModelId: ctx.arenaModelId, + url, + headers, + transformedBody, + }); + if (failed) return failed; + + const upstream = buildArenaUpstreamHttpResponse({ + stream: ctx.stream, + status: tlsResult.status, + text: tlsResult.text, + body: tlsResult.body, + }); + + const response = ctx.stream + ? await this.handleStreamingResponse(upstream, ctx.model, ctx.signal, ctx.log) + : await handleNonStreamingArenaResponse(upstream, ctx.model); + + return { response, url, headers, transformedBody }; } private async handleStreamingResponse( response: Response, model: string, + signal?: AbortSignal, log?: ExecuteInput["log"] ): Promise { const reader = response.body?.getReader(); - if (!reader) { - throw new Error("No response body for streaming"); - } - - const decoder = new TextDecoder(); - let buffer = ""; - let fullText = ""; - let fullThinking = ""; - - const stream = new ReadableStream({ - async start(controller) { - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - if (!line.trim()) continue; - - const sseLine = line.startsWith("data: ") ? line.substring(6) : line; - const event = parseArenaSSE(sseLine); - - if (!event) continue; - - if (event.type === "text" && event.content) { - fullText += event.content; - const chunk = { - id: `chatcmpl-${Date.now()}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model, - choices: [ - { - index: 0, - delta: { content: event.content }, - finish_reason: null, - }, - ], - }; - controller.enqueue(`data: ${JSON.stringify(chunk)}\n\n`); - } else if (event.type === "thinking" && event.content) { - fullThinking += event.content; - } else if (event.type === "error") { - const errorChunk = { - id: `chatcmpl-${Date.now()}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model, - choices: [ - { - index: 0, - delta: {}, - finish_reason: "stop", - }, - ], - error: { message: event.content }, - }; - controller.enqueue(`data: ${JSON.stringify(errorChunk)}\n\n`); - controller.close(); - return; - } else if (event.type === "done") { - const finalChunk = { - id: `chatcmpl-${Date.now()}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model, - choices: [ - { - index: 0, - delta: {}, - finish_reason: "stop", - }, - ], - }; - controller.enqueue(`data: ${JSON.stringify(finalChunk)}\n\n`); - controller.enqueue("data: [DONE]\n\n"); - controller.close(); - return; - } - } - } + if (!reader) throw new Error("No response body for streaming"); - const finalChunk = { - id: `chatcmpl-${Date.now()}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model, - choices: [ - { - index: 0, - delta: {}, - finish_reason: "stop", - }, - ], - }; - controller.enqueue(`data: ${JSON.stringify(finalChunk)}\n\n`); - controller.enqueue("data: [DONE]\n\n"); - controller.close(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - log?.error?.("LMArenaExecutor", `Streaming error: ${message}`); - controller.error(error); - } - }, - }); - - return new Response(stream, { + const out = createOpenAIArenaStream({ reader, model, signal, log }); + return new Response(out, { status: 200, headers: { "Content-Type": "text/event-stream", @@ -433,79 +220,4 @@ export class LMArenaExecutor extends BaseExecutor { }, }); } - - private async handleNonStreamingResponse( - response: Response, - model: string, - log?: ExecuteInput["log"] - ): Promise { - const text = await response.text(); - const lines = text.split("\n"); - let fullText = ""; - let fullThinking = ""; - let error: string | null = null; - - for (const line of lines) { - if (!line.trim()) continue; - - const sseLine = line.startsWith("data: ") ? line.substring(6) : line; - const event = parseArenaSSE(sseLine); - - if (!event) continue; - - if (event.type === "text" && event.content) { - fullText += event.content; - } else if (event.type === "thinking" && event.content) { - fullThinking += event.content; - } else if (event.type === "error") { - error = event.content || "Unknown error"; - break; - } else if (event.type === "done") { - break; - } - } - - if (error) { - return new Response( - JSON.stringify({ - error: { - message: sanitizeErrorMessage(error), - type: "api_error", - code: "lmarena_error", - }, - }), - { - status: 502, - headers: { "Content-Type": "application/json" }, - } - ); - } - - const result = { - id: `chatcmpl-${Date.now()}`, - object: "chat.completion", - created: Math.floor(Date.now() / 1000), - model, - choices: [ - { - index: 0, - message: { - role: "assistant", - content: fullText, - }, - finish_reason: "stop", - }, - ], - usage: { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - }, - }; - - return new Response(JSON.stringify(result), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } } diff --git a/open-sse/executors/lmarena/cookie.ts b/open-sse/executors/lmarena/cookie.ts new file mode 100644 index 000000000000..c1e62529a369 --- /dev/null +++ b/open-sse/executors/lmarena/cookie.ts @@ -0,0 +1,103 @@ +/** + * LMArena / arena.ai session cookie reconstruction. + * Supabase SSR splits `arena-auth-prod-v1` across `.0`, `.1`, … chunks. + */ + +export const LMARENA_AUTH_COOKIE = "arena-auth-prod-v1"; + +interface ParsedCookie { + name: string; + value: string; +} + +/** + * Parse a raw `Cookie:`-style blob (`name=value; name2=value2; …`) into an + * ordered list of name/value pairs. Whitespace around names is trimmed; values + * are kept verbatim (they may legitimately contain `=`, e.g. base64 padding). + */ +function parseCookieBlob(blob: string): ParsedCookie[] { + const pairs: ParsedCookie[] = []; + for (const part of blob.split(";")) { + const eq = part.indexOf("="); + if (eq < 0) continue; + const name = part.slice(0, eq).trim(); + if (!name) continue; + const value = part.slice(eq + 1).trim(); + pairs.push({ name, value }); + } + return pairs; +} + +/** + * Reconstruct LMArena's single `arena-auth-prod-v1` auth cookie from the + * Supabase SSR chunked form. + * + * - Non-empty single cookie → returned unchanged (pre-migration back-compat). + * - Otherwise join ascending `.N` chunks (no base64-decode / no JSON-parse). + * - Neither single nor chunks → raw blob returned for the missing-cookie path. + */ +export function reconstructLMArenaCookie(rawCookie: string): string { + if (!rawCookie || !rawCookie.trim()) return rawCookie; + + const pairs = parseCookieBlob(rawCookie); + + const existing = pairs.find((p) => p.name === LMARENA_AUTH_COOKIE); + if (existing && existing.value) return rawCookie; + + const chunkPrefix = `${LMARENA_AUTH_COOKIE}.`; + const chunks = new Map(); + for (const { name, value } of pairs) { + if (!name.startsWith(chunkPrefix)) continue; + const idxRaw = name.slice(chunkPrefix.length); + if (!/^\d+$/.test(idxRaw)) continue; + chunks.set(Number(idxRaw), value); + } + + const joinedParts: string[] = []; + for (let i = 0; chunks.has(i); i++) { + joinedParts.push(chunks.get(i) ?? ""); + } + const joined = joinedParts.join(""); + if (!joined) return rawCookie; + + const preserved = pairs.filter( + (p) => p.name !== LMARENA_AUTH_COOKIE && !p.name.startsWith(chunkPrefix) + ); + return [`${LMARENA_AUTH_COOKIE}=${joined}`, ...preserved.map((p) => `${p.name}=${p.value}`)].join( + "; " + ); +} + +function buildLMArenaCookieFromStoredFields(data: Record): string { + const pairs: string[] = []; + for (const [name, value] of Object.entries(data)) { + if (name !== LMARENA_AUTH_COOKIE && !name.startsWith(`${LMARENA_AUTH_COOKIE}.`)) { + continue; + } + if (typeof value !== "string" || !value.trim()) continue; + pairs.push(`${name}=${value.trim()}`); + } + + if (pairs.length === 0) return ""; + return reconstructLMArenaCookie(pairs.join("; ")); +} + +export function readLMArenaCookie(credentials: unknown): string { + if (!credentials || typeof credentials !== "object") return ""; + const c = credentials as Record; + const direct = typeof c.cookie === "string" ? c.cookie : ""; + if (direct.trim()) return reconstructLMArenaCookie(direct); + const apiKey = typeof c.apiKey === "string" ? c.apiKey : ""; + if (apiKey.trim()) return reconstructLMArenaCookie(apiKey); + const topLevelChunks = buildLMArenaCookieFromStoredFields(c); + if (topLevelChunks) return topLevelChunks; + const psd = c.providerSpecificData; + if (psd && typeof psd === "object") { + const nestedData = psd as Record; + const nested = nestedData.cookie; + if (typeof nested === "string" && nested.trim()) return reconstructLMArenaCookie(nested); + const nestedChunks = buildLMArenaCookieFromStoredFields(nestedData); + if (nestedChunks) return nestedChunks; + } + return ""; +} diff --git a/open-sse/executors/lmarena/models.ts b/open-sse/executors/lmarena/models.ts new file mode 100644 index 000000000000..fbfe2778095b --- /dev/null +++ b/open-sse/executors/lmarena/models.ts @@ -0,0 +1,307 @@ +/** + * LMArena live model list parsing, catalog normalization, and name→UUID resolution. + */ + +export const LMARENA_API_BASE = "https://arena.ai"; +export const LMARENA_STREAM_URL = `${LMARENA_API_BASE}/nextjs-api/stream/create-evaluation`; +/** + * Current Chrome stable UA (header surface). + * TLS JA3 profile is separate: tls-client-node tops out at chrome_146 — see + * LMARENA_PROFILE in lmarenaTlsClient.ts. Headers track the live browser string; + * fingerprint stays at the newest native profile we can actually impersonate. + */ +export const LMARENA_USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"; +export const LMARENA_MODEL_ID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** Browser-like CORS headers for arena.ai same-origin API calls. */ +export function buildLmarenaBrowserHeaders(extra?: Record): Record { + return { + Accept: "text/event-stream, application/json, text/plain, */*", + "Accept-Language": "en-US,en;q=0.9", + "Cache-Control": "no-cache", + Pragma: "no-cache", + Origin: LMARENA_API_BASE, + Referer: `${LMARENA_API_BASE}/`, + "Sec-Ch-Ua": '"Chromium";v="150", "Google Chrome";v="150", "Not-A.Brand";v="24"', + "Sec-Ch-Ua-Mobile": "?0", + "Sec-Ch-Ua-Platform": '"Windows"', + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + "User-Agent": LMARENA_USER_AGENT, + ...extra, + }; +} + +export interface LMArenaModelMetadata { + id?: string; + publicName?: string; + name?: string; + displayName?: string; + organization?: string; + provider?: string; + userSelectable?: boolean; + rank?: number; + rankByModality?: Record; + capabilities?: { + inputCapabilities?: Record; + outputCapabilities?: Record; + }; +} + +// Live arena.ai HTML discovery is intentionally disabled. Catalog + UUID map +// come from the Direct-chat scrape seed (registry/lmarena/directModels.ts). + +function stripLMArenaModelPrefix(model: string): string { + return model.replace(/^(?:lmarena|lma|arena)\//i, "").trim(); +} + +function normalizeModelName(model: string): string { + return model.trim().toLowerCase(); +} + +function hasLMArenaCapability( + entry: LMArenaModelMetadata, + direction: "input" | "output", + key: string +): boolean { + const capabilities = + direction === "input" + ? entry.capabilities?.inputCapabilities + : entry.capabilities?.outputCapabilities; + return capabilities?.[key] === true; +} + +/** + * Arena ships hundreds of initialModels rows; many are webdev-only, hidden, + * unranked sentinels (chat rank = MAX_SAFE_INTEGER), or UUID twins that 404 on + * create-evaluation. Keep the catalog to chat-usable, ranked, selectable rows. + */ +const LMARENA_MAX_REASONABLE_CHAT_RANK = 100_000; +/** Soft cap after dedupe — Arena UI only surfaces ~100–130 chat models. */ +export const LMARENA_CATALOG_SOFT_CAP = 120; + +const deadCatalogKeys = new Map(); +const DEAD_CATALOG_TTL_MS = 6 * 60 * 60 * 1000; + +function deadKey(value: string): string { + return value.trim().toLowerCase(); +} + +/** Remember a model id/publicName that 404/502'd so the next catalog import drops it. */ +export function markLMArenaCatalogModelDead(idOrPublicName: string): void { + if (!idOrPublicName?.trim()) return; + deadCatalogKeys.set(deadKey(idOrPublicName), Date.now() + DEAD_CATALOG_TTL_MS); +} + +export function clearLMArenaDeadCatalogModels(): void { + deadCatalogKeys.clear(); +} + +function isMarkedDead(entry: LMArenaModelMetadata, publicId: string): boolean { + const now = Date.now(); + for (const key of [publicId, entry.id, entry.publicName, entry.name, entry.displayName]) { + if (!key) continue; + const exp = deadCatalogKeys.get(deadKey(key)); + if (exp === undefined) continue; + if (exp <= now) { + deadCatalogKeys.delete(deadKey(key)); + continue; + } + return true; + } + return false; +} + +function isLMArenaChatCatalogModel(entry: LMArenaModelMetadata): boolean { + if (entry.userSelectable === false) return false; + // Must resolve to a real Arena UUID for create-evaluation. + if (typeof entry.id !== "string" || !LMARENA_MODEL_ID_RE.test(entry.id)) return false; + + const chatRank = entry.rankByModality?.chat; + if (typeof chatRank !== "number" || !Number.isFinite(chatRank)) return false; + // Unranked / placeholder rows use huge sentinels and commonly 404 when probed. + if (chatRank >= LMARENA_MAX_REASONABLE_CHAT_RANK) return false; + + if (!hasLMArenaCapability(entry, "input", "text")) return false; + if (!hasLMArenaCapability(entry, "output", "text")) return false; + + // Prefer rows with a stable human slug (not bare UUID as the only label). + const publicId = getLMArenaPublicModelId(entry).trim(); + if (!publicId) return false; + if (LMARENA_MODEL_ID_RE.test(publicId) && !entry.publicName && !entry.name) return false; + + return true; +} + +function lmarenaModelResolutionScore(entry: LMArenaModelMetadata): number { + let score = 0; + if (entry.userSelectable === false) score += 1_000_000; + if (!hasLMArenaCapability(entry, "input", "text")) score += 100_000; + if (!hasLMArenaCapability(entry, "output", "text")) score += 50_000; + + const chatRank = entry.rankByModality?.chat; + if (typeof chatRank === "number" && Number.isFinite(chatRank)) { + score += chatRank; + } else if (typeof entry.rank === "number" && Number.isFinite(entry.rank)) { + score += 10_000 + entry.rank; + } else { + score += 20_000; + } + + if (!entry.name) score += 500; + if (!entry.organization && !entry.provider) score += 100; + + return score; +} + +function getLMArenaPublicModelId(entry: LMArenaModelMetadata): string { + return entry.publicName || entry.displayName || entry.name || entry.id || ""; +} + +export function normalizeLMArenaModelsForCatalog(models: LMArenaModelMetadata[]): Array<{ + id: string; + name: string; + owned_by: string; + supportsVision?: boolean; + apiFormat: "chat-completions"; + supportedEndpoints: ["chat"]; +}> { + const bestByPublicId = new Map(); + + models.forEach((entry, index) => { + if (!isLMArenaChatCatalogModel(entry)) return; + const publicId = getLMArenaPublicModelId(entry).trim(); + if (!publicId) return; + if (isMarkedDead(entry, publicId)) return; + + const previous = bestByPublicId.get(publicId); + if ( + !previous || + lmarenaModelResolutionScore(entry) < lmarenaModelResolutionScore(previous.entry) + ) { + bestByPublicId.set(publicId, { entry, index }); + } + }); + + return Array.from(bestByPublicId.entries()) + .sort( + ([, a], [, b]) => + lmarenaModelResolutionScore(a.entry) - lmarenaModelResolutionScore(b.entry) || + a.index - b.index + ) + .slice(0, LMARENA_CATALOG_SOFT_CAP) + .map(([id, { entry }]) => ({ + id, + name: entry.displayName || entry.publicName || entry.name || id, + owned_by: entry.organization || entry.provider || "lmarena", + ...(hasLMArenaCapability(entry, "input", "image") ? { supportsVision: true } : {}), + apiFormat: "chat-completions" as const, + supportedEndpoints: ["chat"] as const, + })); +} + +export function pickLMArenaModelId(model: string, models: LMArenaModelMetadata[]): string { + const requested = stripLMArenaModelPrefix(model); + if (LMARENA_MODEL_ID_RE.test(requested)) return requested; + + const normalized = normalizeModelName(requested); + const matches = models + .map((entry, index) => ({ entry, index })) + // Only map onto chat-catalog-quality rows — avoids binding a public name to a + // webdev-only / unranked twin UUID that 404s on create-evaluation. + .filter(({ entry }) => isLMArenaChatCatalogModel(entry)) + .filter(({ entry }) => + [entry.id, entry.publicName, entry.name, entry.displayName].some( + (candidate) => typeof candidate === "string" && normalizeModelName(candidate) === normalized + ) + ); + const match = matches.sort( + (a, b) => + lmarenaModelResolutionScore(a.entry) - lmarenaModelResolutionScore(b.entry) || + a.index - b.index + )[0]?.entry; + + return match?.id || requested; +} + +export function parseLMArenaInitialModels(html: string): LMArenaModelMetadata[] { + const escapedMarker = '\\"initialModels\\":['; + const plainMarker = '"initialModels":['; + const marker = html.includes(escapedMarker) ? escapedMarker : plainMarker; + const markerIndex = html.indexOf(marker); + if (markerIndex < 0) return []; + + const arrayStart = markerIndex + marker.length - 1; + const escapedEnd = '],\\"initialModelAId\\"'; + const plainEnd = '],"initialModelAId"'; + const arrayEnd = html.indexOf(escapedEnd, arrayStart); + const fallbackEnd = html.indexOf(plainEnd, arrayStart); + const endIndex = arrayEnd >= 0 ? arrayEnd : fallbackEnd; + if (endIndex < 0 || endIndex < arrayStart) return []; + + const rawArray = html.slice(arrayStart, endIndex + 1).replace(/\\"/g, '"'); + try { + const parsed = JSON.parse(rawArray); + return Array.isArray(parsed) ? (parsed as LMArenaModelMetadata[]) : []; + } catch { + return []; + } +} + +type LogFn = { + debug?: (scope: string, msg: string) => void; + warn?: (scope: string, msg: string) => void; +}; + +/** Static Direct-chat allowlist only — no arena.ai network call. */ +export async function getLMArenaModels(log?: LogFn): Promise { + const { LMARENA_DIRECT_MODEL_ENTRIES } = + await import("../../config/providers/registry/lmarena/directModels.ts"); + // Chat path only — Image rows live in IMAGE_PROVIDERS (imageRegistry). + const models: LMArenaModelMetadata[] = LMARENA_DIRECT_MODEL_ENTRIES.filter( + (m) => m.category === "Text" || m.category === "Search" + ).map((m) => ({ + id: m.arenaId, + publicName: m.catalogId, + name: m.publicName, + displayName: m.displayName, + organization: m.organization, + userSelectable: true, + capabilities: { + inputCapabilities: { text: true, ...(m.vision ? { image: true } : {}) }, + outputCapabilities: { + text: true, + ...(m.category === "Search" ? { web: true } : {}), + }, + }, + rankByModality: { chat: 1 }, + })); + log?.debug?.( + "LMArenaExecutor", + `Using static Direct-chat catalog (${models.length} Text/Search models; Image in imageRegistry)` + ); + return models; +} + +export async function resolveLMArenaModelId(model: string, log?: LogFn): Promise { + const requested = stripLMArenaModelPrefix(model); + if (LMARENA_MODEL_ID_RE.test(requested)) return requested; + + try { + const { resolveLmarenaArenaId } = + await import("../../config/providers/registry/lmarena/directModels.ts"); + const fromSeed = resolveLmarenaArenaId(requested); + if (fromSeed) return fromSeed; + return pickLMArenaModelId(requested, await getLMArenaModels(log)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log?.warn?.( + "LMArenaExecutor", + `Using raw model id after static catalog lookup failed: ${message}` + ); + return requested; + } +} diff --git a/open-sse/executors/lmarena/response.ts b/open-sse/executors/lmarena/response.ts new file mode 100644 index 000000000000..64aef907c9ec --- /dev/null +++ b/open-sse/executors/lmarena/response.ts @@ -0,0 +1,305 @@ +/** + * Response mapping helpers for the Arena (lmarena) executor — kept small so + * the executor methods stay under complexity / max-lines gates. + */ +import { sanitizeErrorMessage } from "../../utils/error.ts"; +import { isCloudflareChallenge } from "../../services/lmarenaTlsClient.ts"; +import { markLMArenaCatalogModelDead } from "./models.ts"; +import { parseArenaSSE } from "./stream.ts"; + +export function errorResponse( + status: number, + message: string, + type: string, + code: string +): Response { + return new Response( + JSON.stringify({ + error: { message: sanitizeErrorMessage(message), type, code }, + }), + { status, headers: { "Content-Type": "application/json" } } + ); +} + +export function missingCookieResult( + url: string, + headers: Record, + transformedBody: unknown +) { + return { + response: errorResponse( + 401, + "Arena requires a session cookie. Paste the full Cookie header from arena.ai (include arena-auth-prod-v1.* chunks and ideally cf_clearance).", + "authentication_error", + "missing_cookie" + ), + url, + headers, + transformedBody, + }; +} + +function parseArenaErrorBody(text: string | null | undefined, status: number): string { + const fallback = `Arena API error: ${status}`; + if (!text) return fallback; + try { + const errorJson = JSON.parse(text) as { error?: { message?: string }; message?: string }; + return errorJson.error?.message || errorJson.message || fallback; + } catch { + return text.slice(0, 500) || fallback; + } +} + +function isBotOrChallenge(status: number, text: string | null | undefined): boolean { + if (status === 403) return true; + if (isCloudflareChallenge(text)) return true; + return Boolean(text && text.trimStart().startsWith("; + transformedBody: unknown; +}) { + const { status, text, hasRecaptcha, model, arenaModelId, url, headers, transformedBody } = opts; + if (isBotOrChallenge(status, text)) { + return { + response: errorResponse( + status || 403, + botBlockMessage(text, hasRecaptcha, status), + "api_error", + "cloudflare_or_bot" + ), + url, + headers, + transformedBody, + }; + } + if (status >= 200 && status < 300) return null; + + if (status === 404 || status === 410 || status === 502) { + markLMArenaCatalogModelDead(model); + markLMArenaCatalogModelDead(arenaModelId); + } + return { + response: errorResponse(status, parseArenaErrorBody(text, status), "api_error", String(status)), + url, + headers, + transformedBody, + }; +} + +export function mapTlsUnavailable( + error: Error, + url: string, + headers: Record, + transformedBody: unknown +) { + return { + response: errorResponse( + 502, + `Arena TLS impersonation unavailable: ${error.message}. Install/repair tls-client-node native binary.`, + "upstream_error", + "TLS_CLIENT_UNAVAILABLE" + ), + url, + headers, + transformedBody, + }; +} + +export function mapNetworkError( + message: string, + url: string, + headers: Record, + transformedBody: unknown +) { + return { + response: errorResponse(502, message, "network_error", "request_failed"), + url, + headers, + transformedBody, + }; +} + +export function buildArenaUpstreamHttpResponse(opts: { + stream: boolean; + status: number; + text: string | null; + body: ReadableStream | null; +}): Response { + const { stream, status, text, body } = opts; + if (stream && body) { + return new Response(body, { + status, + headers: { "Content-Type": "text/event-stream" }, + }); + } + return new Response(text ?? "", { + status, + headers: { "Content-Type": "text/event-stream" }, + }); +} + +function baseChunk(model: string) { + return { + id: `chatcmpl-${Date.now()}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + }; +} + +function enqueueSse(controller: ReadableStreamDefaultController, chunk: Record) { + controller.enqueue(`data: ${JSON.stringify(chunk)}\n\n`); +} + +function emitStopAndDone(controller: ReadableStreamDefaultController, model: string) { + enqueueSse(controller, { + ...baseChunk(model), + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }); + controller.enqueue("data: [DONE]\n\n"); + controller.close(); +} + +/** Process one Arena SSE line into OpenAI chunk writes. Returns true if stream should end. */ +function handleArenaEventLine( + sseLine: string, + model: string, + controller: ReadableStreamDefaultController +): boolean { + const event = parseArenaSSE(sseLine); + if (!event) return false; + if (event.type === "text" && event.content) { + enqueueSse(controller, { + ...baseChunk(model), + choices: [{ index: 0, delta: { content: event.content }, finish_reason: null }], + }); + return false; + } + if (event.type === "error") { + enqueueSse(controller, { + ...baseChunk(model), + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + error: { message: sanitizeErrorMessage(event.content || "Unknown error") }, + }); + controller.close(); + return true; + } + if (event.type === "done") { + emitStopAndDone(controller, model); + return true; + } + return false; +} + +export function createOpenAIArenaStream(opts: { + reader: ReadableStreamDefaultReader; + model: string; + signal?: AbortSignal; + log?: { error?: (scope: string, msg: string) => void }; +}): ReadableStream { + const { reader, model, signal, log } = opts; + const decoder = new TextDecoder(); + let buffer = ""; + + const onAbort = () => { + void reader.cancel().catch(() => undefined); + }; + if (signal) { + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + } + + return new ReadableStream({ + async start(controller) { + try { + while (true) { + if (signal?.aborted) { + await reader.cancel().catch(() => undefined); + controller.close(); + return; + } + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + if (!line.trim()) continue; + const sseLine = line.startsWith("data: ") ? line.substring(6) : line; + if (handleArenaEventLine(sseLine, model, controller)) return; + } + } + emitStopAndDone(controller, model); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log?.error?.("LMArenaExecutor", `Streaming error: ${message}`); + controller.error(error); + } finally { + if (signal) signal.removeEventListener("abort", onAbort); + } + }, + cancel() { + void reader.cancel().catch(() => undefined); + if (signal) signal.removeEventListener("abort", onAbort); + }, + }); +} + +export async function handleNonStreamingArenaResponse( + response: Response, + model: string +): Promise { + const text = await response.text(); + let fullText = ""; + let error: string | null = null; + + for (const line of text.split("\n")) { + if (!line.trim()) continue; + const sseLine = line.startsWith("data: ") ? line.substring(6) : line; + const event = parseArenaSSE(sseLine); + if (!event) continue; + if (event.type === "text" && event.content) fullText += event.content; + else if (event.type === "error") { + error = event.content || "Unknown error"; + break; + } else if (event.type === "done") break; + } + + if (error) return errorResponse(502, error, "api_error", "lmarena_error"); + + return new Response( + JSON.stringify({ + id: `chatcmpl-${Date.now()}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + message: { role: "assistant", content: fullText }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); +} diff --git a/open-sse/executors/lmarena/stream.ts b/open-sse/executors/lmarena/stream.ts new file mode 100644 index 000000000000..0f30482e4e33 --- /dev/null +++ b/open-sse/executors/lmarena/stream.ts @@ -0,0 +1,132 @@ +/** + * Arena/AI-SDK SSE line parsing and OpenAI message → Arena prompt formatting. + */ + +export interface ArenaSSEEvent { + type: "text" | "thinking" | "error" | "done" | "heartbeat"; + content?: string; +} + +function parseJsonValue(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return raw; + } +} + +function pickString(value: unknown, keys: string[]): string { + if (typeof value === "string") return value; + if (!value || typeof value !== "object") return ""; + const data = value as Record; + for (const key of keys) { + const candidate = data[key]; + if (typeof candidate === "string") return candidate; + } + return JSON.stringify(value); +} + +function normalizeArenaSSELine(payload: string): string { + const participantPrefixed = payload.match(/^[ab]([023dfg]):(.*)$/); + if (!participantPrefixed) return payload; + return `${participantPrefixed[1]}:${participantPrefixed[2]}`; +} + +export function parseArenaSSE(line: string): ArenaSSEEvent | null { + const trimmed = line.trim(); + const payload = trimmed.startsWith("data: ") ? trimmed.substring(6).trim() : trimmed; + if (!payload) return null; + + // Historical Arena platform errors used `ae:`. Current AI SDK `e:` is + // finish_step and not terminal, so only treat it as an error when it carries + // an obvious error payload. + const legacyError = payload.match(/^[ab]e:(.*)$/); + if (legacyError) { + const value = parseJsonValue(legacyError[1] ?? ""); + const content = pickString(value, ["error", "message"]); + return content ? { type: "error", content } : null; + } + + const normalized = normalizeArenaSSELine(payload); + const separator = normalized.indexOf(":"); + if (separator < 0) return null; + + const code = normalized.slice(0, separator); + const rawValue = normalized.slice(separator + 1); + const value = parseJsonValue(rawValue); + + switch (code) { + case "0": + return { type: "text", content: pickString(value, ["text", "textDelta"]) }; + case "g": + return { type: "thinking", content: pickString(value, ["thinking", "text", "textDelta"]) }; + case "2": + return { type: "heartbeat" }; + case "3": + return { type: "error", content: pickString(value, ["error", "message"]) }; + case "d": { + if ( + value && + typeof value === "object" && + (value as Record).finishReason === "error" + ) { + return { type: "error", content: "Arena stream finished with an error" }; + } + return { type: "done" }; + } + default: + return null; + } +} + +interface OpenAIMessage { + role?: string; + content?: unknown; +} + +function contentToText(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((part) => { + if (typeof part === "string") return part; + if (!part || typeof part !== "object") return ""; + const data = part as Record; + if (typeof data.text === "string") return data.text; + if (data.type === "image_url") return "[image]"; + return ""; + }) + .filter(Boolean) + .join("\n"); + } + if (content && typeof content === "object") { + const data = content as Record; + if (typeof data.text === "string") return data.text; + } + return content == null ? "" : String(content); +} + +export function formatArenaPrompt(messages: OpenAIMessage[]): string { + const rendered = messages + .map((message) => { + const text = contentToText(message.content).trim(); + if (!text) return ""; + const role = typeof message.role === "string" ? message.role : "user"; + const label = + role === "system" + ? "System" + : role === "assistant" + ? "Assistant" + : role === "developer" + ? "Developer" + : "User"; + return `${label}: ${text}`; + }) + .filter(Boolean); + + if (rendered.length === 1 && messages[0]?.role === "user") { + return contentToText(messages[0].content).trim(); + } + + return rendered.join("\n\n"); +} diff --git a/open-sse/services/lmarenaTlsClient.ts b/open-sse/services/lmarenaTlsClient.ts new file mode 100644 index 000000000000..896a6e74627e --- /dev/null +++ b/open-sse/services/lmarenaTlsClient.ts @@ -0,0 +1,605 @@ +/** + * Browser-TLS-impersonating HTTP client for arena.ai. + * + * Why this exists: LMArena sits behind Cloudflare Enterprise which pins + * `cf_clearance` to the client's TLS fingerprint (JA3/JA4) + HTTP/2 SETTINGS + * frame ordering. Node's Undici fetch presents an obvious "not a browser" + * handshake and gets challenged with a 403 even with a valid arena session + * cookie (and often a browser-minted `cf_clearance`). This module wraps + * `tls-client-node` (bogdanfinn/tls-client) to send a Chrome handshake instead. + * + * Mirrors `grokTlsClient.ts` / `perplexityTlsClient.ts` as an independent + * module so changes here cannot regress those production paths. + * + * Note: Arena may still require a browser-issued reCAPTCHA v3 token on + * create-evaluation; TLS alone is necessary but not always sufficient. + */ + +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { mkdtemp, open, unlink, rmdir, stat } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; + +let clientPromise: Promise | null = null; +let exitHookInstalled = false; + +// Newest Chrome JA3 profile shipped by tls-client-node (no chrome_147+ yet). +// HTTP User-Agent / Sec-Ch-Ua track Chrome 150 separately in models.ts. +const LMARENA_PROFILE = "chrome_146"; +// Fixed timeouts (same defaults as other TLS sidecars). No extra env knobs — +// env-doc-sync must not grow for provider-local constants. +const DEFAULT_TIMEOUT_MS = 60_000; +// Grace period added to the binding's wire-level timeout before our JS-level +// hard timeout fires. Under healthy operation `tls-client-node` honors +// `timeoutMilliseconds` and rejects on its own; the JS-level race only wins +// when the koffi-loaded native library is wedged (which the binding's own +// timer can't escape). +const HARD_TIMEOUT_GRACE_MS = 10_000; + +function installExitHook(): void { + if (exitHookInstalled) return; + exitHookInstalled = true; + const stop = async () => { + if (clientPromise === null) return; + try { + const c = (await clientPromise) as { stop?: () => Promise }; + await c.stop?.(); + } catch { + // ignore + } + }; + process.once("beforeExit", stop); + process.once("SIGINT", () => { + void stop(); + }); + process.once("SIGTERM", () => { + void stop(); + }); +} + +/** + * Drop the cached client so the next `getClient()` call respawns it. Called + * when a request observes the native binding has wedged — releasing the + * reference lets a fresh TLSClient (and a fresh koffi load) take over without + * a process restart. + */ +function resetClientCache(): void { + clientPromise = null; +} + +export class TlsClientHangError extends Error { + constructor(message: string) { + super(message); + this.name = "TlsClientHangError"; + } +} + +/** + * Race a `client.request()` promise against (a) a JS-level hard timeout and + * (b) the caller's abort signal. The native binding's `timeoutMilliseconds` + * already covers the wire path; this guards the case where the koffi binding + * itself deadlocks (observed after sustained load), where neither the + * binding's own timer nor a post-call `signal.aborted` re-check can recover. + */ +async function raceWithTimeout( + promise: Promise, + timeoutMs: number, + signal: AbortSignal | null | undefined +): Promise { + let timer: ReturnType | null = null; + let abortListener: (() => void) | null = null; + try { + const racers: Promise[] = [ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => { + reject( + new TlsClientHangError( + `tls-client-node call exceeded ${timeoutMs}ms — native binding likely deadlocked` + ) + ); + }, timeoutMs); + }), + ]; + if (signal) { + racers.push( + new Promise((_, reject) => { + if (signal.aborted) { + reject(makeAbortError(signal)); + return; + } + abortListener = () => reject(makeAbortError(signal)); + signal.addEventListener("abort", abortListener, { once: true }); + }) + ); + } + return await Promise.race(racers); + } finally { + if (timer) clearTimeout(timer); + if (signal && abortListener) signal.removeEventListener("abort", abortListener); + } +} + +async function getClient(): Promise<{ + request: (url: string, opts: Record) => Promise; +}> { + if (!clientPromise) { + clientPromise = (async () => { + try { + const mod = await import("tls-client-node"); + const TLSClient = (mod as { TLSClient: new (opts?: Record) => unknown }) + .TLSClient; + // Native mode loads the shared library directly via koffi, avoiding the + // managed sidecar's localhost HTTP calls that OmniRoute's global fetch + // proxy patch interferes with. + const client = new TLSClient({ runtimeMode: "native" }) as { + start: () => Promise; + request: (url: string, opts: Record) => Promise; + }; + await client.start(); + + installExitHook(); + return client; + } catch (err) { + clientPromise = null; + const msg = err instanceof Error ? err.message : String(err); + throw new TlsClientUnavailableError( + `TLS impersonation client failed to start: ${msg}. ` + + `Verify tls-client-node is installed and its native binary downloaded.` + ); + } + })(); + } + return clientPromise as Promise<{ + request: (url: string, opts: Record) => Promise; + }>; +} + +interface TlsResponseLike { + status: number; + headers: Record; + body: string; // for non-streaming requests, the full response body + cookies?: Record; + text: () => Promise; + bytes: () => Promise; + json: () => Promise; +} + +export class TlsClientUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = "TlsClientUnavailableError"; + } +} + +export interface TlsFetchOptions { + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + headers?: Record; + body?: string; + timeoutMs?: number; + signal?: AbortSignal | null; + /** + * If true, the response body is streamed to a temp file and exposed as a + * ReadableStream. Use for NDJSON streaming responses (the + * LMArena conversation endpoint). Otherwise, the full body is read into memory. + */ + stream?: boolean; + /** EOF marker the upstream sends to signal end of stream (default: "[DONE]"). */ + streamEofSymbol?: string; + /** + * Optional upstream proxy URL (`http://user:pass@host:port` or + * `socks5://...`). When set, the request is tunneled through this proxy + * before reaching arena.ai. + * + * Resolution order: + * 1. `options.proxyUrl` (per-call override from caller) + * 2. `process.env.OMNIROUTE_TLS_PROXY_URL` (single-flag opt-in) + * 3. `process.env.HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` (POSIX-standard fallback) + * + * The native `tls-client-node` binding does **not** consult Go's + * `http.ProxyFromEnvironment`, so the env vars need to be plumbed in here at + * the JS layer. + */ + proxyUrl?: string; +} + +import { resolveProxyForRequest } from "../utils/proxyFetch.ts"; +import { resolveTlsClientProxyUrl } from "./tlsClientProxy.ts"; + +/** + * Resolve the proxy URL for a tls-client request. Per-call value wins; + * otherwise we use the standard proxy fetch resolution which reads from + * the dashboard AsyncLocalStorage context or falls back to env vars. + * + * Fail-closed: if resolution throws (e.g. a configured socks5 proxy with + * ENABLE_SOCKS5_PROXY=false), this rethrows rather than returning undefined — + * undefined would let the native binding connect directly and leak the real IP. + */ +function resolveProxyUrl(perCall: string | undefined): string | undefined { + return resolveTlsClientProxyUrl("https://arena.ai", perCall, resolveProxyForRequest); +} + +export interface TlsFetchResult { + status: number; + headers: Headers; + /** Full response body as text — only populated for non-streaming requests. */ + text: string | null; + /** Streaming body — only populated when options.stream === true. */ + body: ReadableStream | null; +} + +// Test-only injection point. Tests call __setTlsFetchOverrideForTesting() +// to replace the real TLS client with a mock; production never touches this. +let testOverride: ((url: string, options: TlsFetchOptions) => Promise) | null = + null; + +export function __setTlsFetchOverrideForTesting(fn: typeof testOverride): void { + testOverride = fn; +} + +function throwIfAborted(signal: AbortSignal | null | undefined): void { + if (signal?.aborted) throw makeAbortError(signal); +} + +function buildTlsRequestOptions(options: TlsFetchOptions): Record { + return { + method: options.method || "GET", + headers: options.headers || {}, + body: options.body, + tlsClientIdentifier: LMARENA_PROFILE, + timeoutMilliseconds: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + followRedirects: true, + withRandomTLSExtensionOrder: true, + // Plumb proxy via options — tls-client-node does not read HTTP_PROXY env. + proxyUrl: resolveProxyUrl(options.proxyUrl), + }; +} + +function hardTimeoutMs(options: TlsFetchOptions): number { + return (options.timeoutMs ?? DEFAULT_TIMEOUT_MS) + HARD_TIMEOUT_GRACE_MS; +} + +async function tlsFetchNonStreaming( + client: { request: (url: string, opts: Record) => Promise }, + url: string, + requestOptions: Record, + options: TlsFetchOptions +): Promise { + let tlsResponse: TlsResponseLike; + try { + tlsResponse = await raceWithTimeout( + client.request(url, requestOptions), + hardTimeoutMs(options), + options.signal ?? null + ); + } catch (err) { + if (err instanceof TlsClientHangError) resetClientCache(); + throw err; + } + throwIfAborted(options.signal); + return { + status: tlsResponse.status, + headers: toHeaders(tlsResponse.headers), + text: tlsResponse.body, + body: null, + }; +} + +/** + * Make a single HTTP request to arena.ai with a Chrome-like TLS fingerprint. + * Throws TlsClientUnavailableError if the native binary failed to load. + */ +export async function tlsFetchLMArena( + url: string, + options: TlsFetchOptions = {} +): Promise { + if (testOverride) return testOverride(url, options); + throwIfAborted(options.signal); + const client = await getClient(); + throwIfAborted(options.signal); + + const requestOptions = buildTlsRequestOptions(options); + if (options.stream) { + return tlsFetchStreaming( + client, + url, + requestOptions, + options.streamEofSymbol, + options.signal ?? null, + hardTimeoutMs(options) + ); + } + return tlsFetchNonStreaming(client, url, requestOptions, options); +} + +function makeAbortError(signal: AbortSignal): Error { + const reason = signal.reason; + if (reason instanceof Error) return reason; + const err = new Error(typeof reason === "string" ? reason : "The operation was aborted"); + err.name = "AbortError"; + return err; +} + +function toHeaders(raw: Record): Headers { + const h = new Headers(); + for (const [k, vs] of Object.entries(raw || {})) { + for (const v of vs) h.append(k, v); + } + return h; +} + +/** + * Returns true if the response body is a Cloudflare challenge/interstitial page + * rather than a real LMArena response. From VPS/datacenter IPs a valid cookie + * still gets a 403 "Request rejected by anti-bot rules." JSON; distinguishing + * it from a genuine auth failure lets the caller surface an actionable error + * (issue #3180). + * + * Exported so the executor and the connection validator share one detector. + */ +export function isCloudflareChallenge(text: string | null | undefined): boolean { + if (!text) return false; + return /just a moment|window\._cf_chl_opt|challenges\.cloudflare\.com|attention required|cf-chl/i.test( + text + ); +} + +// ─── Streaming via temp file ──────────────────────────────────────────────── +// tls-client-node's streaming primitive writes the response body chunk-by-chunk +// to a file path, terminating when the upstream sends `streamOutputEOFSymbol`. +// We tail the file from a worker and surface the bytes as a ReadableStream. + +async function tlsFetchStreaming( + client: { request: (url: string, opts: Record) => Promise }, + url: string, + requestOptions: Record, + eofSymbol = "[DONE]", + signal: AbortSignal | null = null, + hardTimeoutMs: number = DEFAULT_TIMEOUT_MS + HARD_TIMEOUT_GRACE_MS +): Promise { + const dir = await mkdtemp(join(tmpdir(), "LMArena-stream-")); + const path = join(dir, `${randomUUID()}.ndjson`); + + const streamOpts = { + ...requestOptions, + streamOutputPath: path, + streamOutputBlockSize: 1024, + streamOutputEOFSymbol: eofSymbol, + }; + + // Kick off the request without awaiting — tls-client writes the body to + // `path` chunk-by-chunk while the call runs. The Promise resolves when the + // request fully completes (full body written). Wrapping in raceWithTimeout + // guarantees this promise eventually settles even if the koffi binding + // wedges; on hang we reset the singleton so the next request respawns. + let resetOnHang = true; + const requestPromise = raceWithTimeout( + client.request(url, streamOpts), + hardTimeoutMs, + signal + ).catch((err: unknown) => { + if (resetOnHang && err instanceof TlsClientHangError) { + resetClientCache(); + resetOnHang = false; + } + // Re-throw so downstream consumers (waitForContent, tailFile) observe + // the rejection and surface it instead of treating the stream as having + // ended cleanly. + throw err; + }); + + // Wait for the file to exist AND have at least one byte. + const ready = await waitForContent(path, 5_000, requestPromise); + if (!ready) { + const r = await requestPromise.catch( + (e) => ({ status: 502, headers: {}, body: String(e) }) as TlsResponseLike + ); + await cleanupTempPath(path); + return { + status: r.status, + headers: toHeaders(r.headers), + text: r.body, + body: null, + }; + } + + // Peek at the first bytes to distinguish a genuine NDJSON stream from a + // Cloudflare challenge page or an HTML error response that tls-client-node + // streamed to the temp file with a 200 status. + const peek = await readFirstBytes(path, 256); + if (isCloudflareChallenge(peek)) { + await cleanupTempPath(path); + return { + status: 403, + headers: new Headers({ "Content-Type": "text/html" }), + text: peek, + body: null, + }; + } + if (peek.trimStart().startsWith("<")) { + // HTML error page (not a challenge) — surface as a non-2xx so the executor + // can emit a proper SSE error chunk instead of feeding HTML to the NDJSON + // parser. + await cleanupTempPath(path); + return { + status: 502, + headers: new Headers({ "Content-Type": "text/html" }), + text: peek, + body: null, + }; + } + + // Looks like NDJSON — start tailing. The requestPromise will eventually + // resolve with the real upstream status; tailFile propagates non-2xx errors + // into the stream so the consumer sees them instead of a truncated success. + const stream = tailFile(path, eofSymbol, requestPromise, signal); + const headers = new Headers({ + "Content-Type": "application/x-ndjson", + "Cache-Control": "no-cache", + }); + return { status: 200, headers, text: null, body: stream }; +} + +async function cleanupTempPath(path: string): Promise { + await unlink(path).catch(() => {}); + await rmdir(dirname(path)).catch(() => {}); +} + +async function readFirstBytes(path: string, n: number): Promise { + const fd = await open(path, "r"); + try { + const buf = Buffer.alloc(n); + const { bytesRead } = await fd.read(buf, 0, n, 0); + return buf.subarray(0, bytesRead).toString("utf8"); + } finally { + await fd.close().catch(() => {}); + } +} + +/** + * Wait for the streaming output file to exist AND contain at least one byte. + * Returns false if the request settles before any bytes arrive (so the caller + * can drain `requestPromise` and surface the real upstream status). Returns + * true as soon as the file has data — even one byte is enough for the NDJSON + * heuristic to give a useful answer. + */ +async function waitForContent( + path: string, + timeoutMs: number, + requestPromise: Promise +): Promise { + let requestSettled = false; + requestPromise.then( + () => { + requestSettled = true; + }, + () => { + requestSettled = true; + } + ); + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const s = await stat(path); + if (s.size > 0) return true; + } catch { + // file doesn't exist yet + } + // If the request finished without producing any bytes, no point waiting + // out the rest of the timeout — let the caller drain it. + if (requestSettled) return false; + await sleep(25); + } + return false; +} + +/** Enqueue chunk bytes, splitting off an EOF symbol when present. Returns true if closed. */ +function enqueueChunkMaybeEof( + controller: ReadableStreamDefaultController, + chunk: Buffer, + eofSymbol: string +): boolean { + const text = chunk.toString("utf8"); + if (!text.includes(eofSymbol)) { + controller.enqueue(Buffer.from(chunk)); + return false; + } + const beforeEof = text.substring(0, text.indexOf(eofSymbol)); + if (beforeEof) controller.enqueue(Buffer.from(beforeEof, "utf8")); + controller.close(); + return true; +} + +type FileHandle = Awaited>; + +async function drainRemaining( + fd: FileHandle, + buf: Buffer, + offsetRef: { offset: number }, + controller: ReadableStreamDefaultController, + eofSymbol: string +): Promise<"closed" | "drained"> { + while (true) { + const { bytesRead } = await fd.read(buf, 0, buf.length, offsetRef.offset); + if (bytesRead === 0) return "drained"; + const chunk = buf.subarray(0, bytesRead); + offsetRef.offset += bytesRead; + if (enqueueChunkMaybeEof(controller, chunk, eofSymbol)) return "closed"; + } +} + +function tailFile( + path: string, + eofSymbol: string, + done: Promise, + signal: AbortSignal | null = null +): ReadableStream { + return new ReadableStream({ + async start(controller) { + const fd = await open(path, "r"); + const buf = Buffer.alloc(64 * 1024); + const offsetRef = { offset: 0 }; + let finished = false; + let aborted = false; + let upstreamError: Error | null = null; + let errored = false; + + done.then( + () => { + finished = true; + }, + (err) => { + upstreamError = err instanceof Error ? err : new Error(String(err)); + finished = true; + } + ); + + const onAbort = () => { + aborted = true; + }; + if (signal) { + if (signal.aborted) aborted = true; + else signal.addEventListener("abort", onAbort, { once: true }); + } + + try { + while (!aborted) { + const { bytesRead } = await fd.read(buf, 0, buf.length, offsetRef.offset); + if (bytesRead > 0) { + const chunk = buf.subarray(0, bytesRead); + offsetRef.offset += bytesRead; + if (enqueueChunkMaybeEof(controller, chunk, eofSymbol)) return; + } + + if (!finished) { + await sleep(25); + continue; + } + + const drained = await drainRemaining(fd, buf, offsetRef, controller, eofSymbol); + if (drained === "closed") return; + if (upstreamError && !errored) { + errored = true; + controller.error(upstreamError); + return; + } + controller.close(); + return; + } + } catch (err) { + if (!errored) { + errored = true; + controller.error(err instanceof Error ? err : new Error(String(err))); + } + } finally { + await fd.close().catch(() => {}); + await cleanupTempPath(path); + if (signal) signal.removeEventListener("abort", onAbort); + } + }, + }); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/public/providers/arena-dark.svg b/public/providers/arena-dark.svg new file mode 100644 index 000000000000..0435f9ee2429 --- /dev/null +++ b/public/providers/arena-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/arena-light.svg b/public/providers/arena-light.svg new file mode 100644 index 000000000000..6ae050e8f894 --- /dev/null +++ b/public/providers/arena-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx index 43dc76981c6d..03da3e479782 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx @@ -215,27 +215,26 @@ export default function AddApiKeyModal({ "Session credential validation failed. Sign in again, copy a fresh credential, and try again." ) : t("apiKeyValidationFailed"); - - // Normalize the raw credential field(s) into the single value stored as `apiKey`. - // command-code providers extract a key from a pasted blob (#5088); Modal joins its - // Token ID + Token Secret into `id:secret` (#5446); everyone else uses the field verbatim. - const resolveCredentialInput = () => { - if (isCommandCode) return extractCommandCodeCredentialInput(formData.apiKey); - if (isModal) return combineModalCredential(formData.apiKey, formData.tokenSecret); - return formData.apiKey; - }; + const validationBadge = validationResult ? validationBadgeProps(validationResult) : null; + // Normalize raw credential field(s) into the single value stored as `apiKey` + // (#5088 command-code extract; #5446 Modal id:secret join; else verbatim). + const resolveCredentialInput = () => + isCommandCode + ? extractCommandCodeCredentialInput(formData.apiKey) + : isModal + ? combineModalCredential(formData.apiKey, formData.tokenSecret) + : formData.apiKey; const handleValidate = async () => { setValidating(true); setSaveError(null); try { - const credentialInput = resolveCredentialInput(); const res = await fetch("/api/providers/validate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider, - apiKey: credentialInput, + apiKey: resolveCredentialInput(), validationModelId: formData.validationModelId || undefined, customUserAgent: formData.customUserAgent.trim() || undefined, baseUrl: formData.baseUrl.trim() || undefined, @@ -245,11 +244,10 @@ export default function AddApiKeyModal({ }); const data = await res.json(); const ok = !!data.valid; - setValidationResult(ok ? "success" : data.unsupported ? "unsupported" : "failed"); - // #5088: surface the detailed reason the backend returns (e.g. a TLS/EACCES - // environment error for claude-web/chatgpt-web) instead of only a bare - // "invalid" badge — otherwise the real cause is hidden and users are stuck. - if (!ok && typeof data.error === "string" && data.error) { + const unsupported = !!data.unsupported; + setValidationResult(ok ? "success" : unsupported ? "unsupported" : "failed"); + // #5088: surface backend reason (e.g. TLS/EACCES) instead of bare "invalid". + if (!ok && !unsupported && typeof data.error === "string" && data.error) { setSaveError(data.error); } } catch { @@ -759,9 +757,9 @@ export default function AddApiKeyModal({ hint={t("searchEngineIdHint")} /> )} - {validationResult && ( - - {t(validationBadgeProps(validationResult).labelKey)} + {validationBadge && ( + + {providerText(t, validationBadge.labelKey, validationBadge.fallback)} )} {saveError && ( diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx index cb7987922630..bbbbc99ba63b 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx @@ -51,11 +51,7 @@ import { useOpenRouterPresetControl } from "../OpenRouterPresetInput"; import WebSessionCredentialGuide from "../WebSessionCredentialGuide"; import CcCompatibleRequestDefaultsFields from "./CcCompatibleRequestDefaultsFields"; import { assignEditApiKeyProviderSpecificData } from "./connectionProviderSpecificData"; -import { - isM365TierCapableProvider, - normalizeM365TierValue, - type M365TierValue, -} from "./m365Tier"; +import { isM365TierCapableProvider, normalizeM365TierValue, type M365TierValue } from "./m365Tier"; import QuotaScrapingFields, { EMPTY_QUOTA_SCRAPING_FIELDS } from "./QuotaScrapingFields"; import GlmTeamQuotaFields, { EMPTY_GLM_TEAM_QUOTA_FIELDS } from "./GlmTeamQuotaFields"; @@ -99,6 +95,8 @@ export default function EditConnectionModal({ const t = useTranslations("providers"); const notify = useNotificationStore(); const provider = connection?.provider || providerId; + const connectionAuthType = connection?.authType; + const connectionProviderSpecificData = connection?.providerSpecificData; const showFreeModelsToggle = providerHasFreeModels(provider); const [formData, setFormData] = useState({ name: "", @@ -134,12 +132,12 @@ export default function EditConnectionModal({ antigravityClientProfile: "ide", blockExtraUsage: provider === "claude" - ? isClaudeExtraUsageBlockEnabled(provider, connection?.providerSpecificData) + ? isClaudeExtraUsageBlockEnabled(provider, connectionProviderSpecificData) : false, - passthroughModels: connection?.providerSpecificData?.passthroughModels === true, - disableCooling: connection?.providerSpecificData?.disableCooling === true, - importFreeModelsOnly: connection?.providerSpecificData?.importFreeModelsOnly === true, - m365Tier: normalizeM365TierValue(connection?.providerSpecificData?.tier) as M365TierValue, + passthroughModels: connectionProviderSpecificData?.passthroughModels === true, + disableCooling: connectionProviderSpecificData?.disableCooling === true, + importFreeModelsOnly: connectionProviderSpecificData?.importFreeModelsOnly === true, + m365Tier: normalizeM365TierValue(connectionProviderSpecificData?.tier) as M365TierValue, }); const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState(null); @@ -169,11 +167,11 @@ export default function EditConnectionModal({ // providerSpecificData.baseUrl. const isConfigurableBaseUrl = isBaseUrlConfigurableProvider(provider); const isBaseUrlOverrideEligible = - connection?.authType !== "oauth" && isBaseUrlOverrideEligibleProvider(provider); + !!connection && connectionAuthType !== "oauth" && isBaseUrlOverrideEligibleProvider(provider); const [showBaseUrlOverride, setShowBaseUrlOverride] = useState( () => - typeof connection?.providerSpecificData?.baseUrl === "string" && - connection.providerSpecificData.baseUrl.trim().length > 0 + typeof connectionProviderSpecificData?.baseUrl === "string" && + connectionProviderSpecificData.baseUrl.trim().length > 0 ); const usesBaseUrl = isConfigurableBaseUrl || (isBaseUrlOverrideEligible && showBaseUrlOverride); const defaultBaseUrl = getProviderBaseUrlDefault(provider); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts index e35cae394af4..2bd0b2d4a62e 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts @@ -119,20 +119,16 @@ export function providerText( return fallback; } -/** - * #5442 — Badge variant + i18n label key for an add-credential validation result. - * A provider with no live validator returns `unsupported` (Save still succeeds); - * previously the modal only had success/failed states, so it rendered a red - * "Invalid" badge for those providers even though saving worked (LMArena, PiAPI…). - * "unsupported" now maps to a neutral `info` badge ("N/A"), not "Invalid". - */ +/** #5442 — badge for add-credential validation; unsupported → neutral N/A (not red Invalid). */ export function validationBadgeProps(result: string): { variant: "success" | "error" | "info"; labelKey: string; + fallback: string; } { - if (result === "success") return { variant: "success", labelKey: "valid" }; - if (result === "unsupported") return { variant: "info", labelKey: "notApplicable" }; - return { variant: "error", labelKey: "invalid" }; + if (result === "success") return { variant: "success", labelKey: "valid", fallback: "Valid" }; + if (result === "unsupported") + return { variant: "info", labelKey: "notApplicable", fallback: "N/A" }; + return { variant: "error", labelKey: "invalid", fallback: "Invalid" }; } /** A single model's outcome from a `/api/models/test-all` response. */ @@ -440,7 +436,8 @@ export function getWebSessionCredentialHint( return providerText( t, requirement.hintKey, - "Open the provider's web session in DevTools, copy the required credential(s), and paste them in the fields below.", + requirement.hintFallback ?? + "Open the provider's web session in DevTools, copy the required credential(s), and paste them in the fields below.", values ); } diff --git a/src/app/api/models/test-all/route.ts b/src/app/api/models/test-all/route.ts index 67a410145d2a..28a418dd48a3 100644 --- a/src/app/api/models/test-all/route.ts +++ b/src/app/api/models/test-all/route.ts @@ -22,6 +22,15 @@ import * as log from "@/sse/utils/logger"; const PER_MODEL_TIMEOUT_MS = 20_000; const CONSECUTIVE_RATE_LIMIT_STOP_THRESHOLD = 3; +/** Web-session providers (esp. Arena/CF) ban burst probes — pause between models. */ +const SLOW_PROBE_PROVIDERS = new Set(["lmarena", "lma"]); +/** Fixed inter-model delay for SLOW_PROBE_PROVIDERS (no env — avoids doc-sync drift). */ +const SLOW_PROBE_DELAY_MS = 3500; +const CONSECUTIVE_BOT_STOP_THRESHOLD = 2; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} const testAllSchema = z.object({ providerId: z.string().min(1), @@ -106,8 +115,11 @@ export async function POST(request: Request) { const results: Record = {}; let consecutiveRateLimits = 0; + let consecutiveBotBlocks = 0; let stoppedEarly = false; - let stopReason: "consecutive_rate_limits" | undefined; + let stopReason: "consecutive_rate_limits" | "consecutive_bot_blocks" | undefined; + const slowProbe = SLOW_PROBE_PROVIDERS.has(providerId); + let testedUpstream = 0; for (const modelId of modelIds) { // #6328: skip paid ids without dispatching; do not increment @@ -126,6 +138,10 @@ export async function POST(request: Request) { let entry: BatchTestResultEntry; try { + // Space out Arena/CF probes — sequential alone still looks like a burst. + if (slowProbe && testedUpstream > 0 && SLOW_PROBE_DELAY_MS > 0) { + await sleep(SLOW_PROBE_DELAY_MS); + } // `runSingleModelTest` only engages the Bottleneck rate limiter when // `connectionId` is provided. To honor `respectRateLimit=false`, we // omit the connectionId so the runner bypasses `withRateLimit`. @@ -138,6 +154,7 @@ export async function POST(request: Request) { timeoutMs: PER_MODEL_TIMEOUT_MS, }); entry = toBatchEntry(result); + testedUpstream += 1; } catch (error: unknown) { log.error("MODEL_TEST_ALL", `Unexpected error testing model ${modelId}`, { providerId, @@ -157,6 +174,16 @@ export async function POST(request: Request) { consecutiveRateLimits = 0; } + const botBlocked = + entry.statusCode === 403 || + (typeof entry.error === "string" && + /cloudflare|bot management|recaptcha|cf-chl|just a moment/i.test(entry.error)); + if (botBlocked) { + consecutiveBotBlocks += 1; + } else if (entry.status === "ok") { + consecutiveBotBlocks = 0; + } + if (autoHideFailed && entry.status === "error" && !entry.rateLimited && !entry.isTimeout) { try { await setModelIsHidden(providerId, modelId, true); @@ -202,6 +229,21 @@ export async function POST(request: Request) { ); break; } + + if (slowProbe && consecutiveBotBlocks >= CONSECUTIVE_BOT_STOP_THRESHOLD) { + stoppedEarly = true; + stopReason = "consecutive_bot_blocks"; + log.warn( + "MODEL_TEST_ALL", + `Stopping batch early after ${consecutiveBotBlocks} consecutive bot/Cloudflare blocks (avoid session ban)`, + { + providerId, + testedCount: Object.keys(results).length, + totalCount: modelIds.length, + } + ); + break; + } } log.info( diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 153f2bc23245..9d4c92075e3f 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -458,6 +458,13 @@ export async function GET( if (localCatalog) return localCatalog; } + if (provider === "lmarena") { + // Direct-chat allowlist is the intended source — no arena.ai HTML scrape + // (avoids CF bot burn and thrashy initialModels rows). + const localCatalog = buildLocalCatalogResponse(undefined, true); + if (localCatalog) return localCatalog; + } + if (provider === "bedrock") { const cachedResponse = maybeReturnCachedDiscovery(); if (cachedResponse) return cachedResponse; diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 16857596066e..9b8cfd9eb163 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4886,7 +4886,8 @@ "doubaoWebDesc": "ByteDance AI chat via dola.com", "overrideBaseUrlAdvanced": "Advanced: override base URL", "overrideBaseUrlHint": "Advanced: point this built-in provider at a custom endpoint. Leave blank to use the default.", - "bulkAddFormatHintCloudflare": "One key per line. Format: name|accountId|apiKey (Cloudflare account ID + API token)." + "bulkAddFormatHintCloudflare": "One key per line. Format: name|accountId|apiKey (Cloudflare account ID + API token).", + "lmarenaWebCookieHint": "Open arena.ai, sign in, then copy the full Cookie header from a Network request. Include arena-auth-prod-v1.0 and arena-auth-prod-v1.1 (and further chunks if present), preferably with cf_clearance. Do not paste only the empty arena-auth-prod-v1 cookie. Optional: providerSpecificData.recaptchaV3Token if create-evaluation still returns 403." }, "settings": { "title": "Settings", diff --git a/src/lib/providers/staticModels.ts b/src/lib/providers/staticModels.ts index 86bb2f590404..271590670ba2 100644 --- a/src/lib/providers/staticModels.ts +++ b/src/lib/providers/staticModels.ts @@ -146,24 +146,43 @@ export function getStaticModelsForProvider(provider: string): LocalCatalogModel[ }); } + // Image / video: only fold into the provider specialty list for *media-only* + // providers (no chat registry models). Chat+image providers (openai, lmarena, + // xai, …) keep image rows exclusively in IMAGE_PROVIDERS so the provider page + // chat catalog is not polluted with flux-* / dalle ids. + const chatRegistry = getModelsByProviderId(provider); + const hasChatRegistry = Array.isArray(chatRegistry) && chatRegistry.length > 0; + const imageProvider = getImageProvider(provider); - if (imageProvider) { - appendModels(imageProvider.models); + if (imageProvider && !hasChatRegistry) { + appendModels(imageProvider.models, { + apiFormat: "images", + supportedEndpoints: ["images"], + }); } const videoProvider = getVideoProvider(provider); - if (videoProvider) { - appendModels(videoProvider.models); + if (videoProvider && !hasChatRegistry) { + appendModels(videoProvider.models, { + apiFormat: "video", + supportedEndpoints: ["videos"], + }); } const speechProvider = getSpeechProvider(provider); if (speechProvider) { - appendModels(speechProvider.models); + appendModels(speechProvider.models, { + apiFormat: "audio", + supportedEndpoints: ["audio"], + }); } const transcriptionProvider = getTranscriptionProvider(provider); if (transcriptionProvider) { - appendModels(transcriptionProvider.models); + appendModels(transcriptionProvider.models, { + apiFormat: "audio", + supportedEndpoints: ["audio"], + }); } return specialtyModels.length > 0 ? specialtyModels : undefined; diff --git a/src/shared/components/ProviderIcon.tsx b/src/shared/components/ProviderIcon.tsx index 19aad7ce1a04..3f1ef2d09e8e 100644 --- a/src/shared/components/ProviderIcon.tsx +++ b/src/shared/components/ProviderIcon.tsx @@ -7,12 +7,13 @@ * 0. If `src` is set (operator-supplied remote icon URL, #2166), render it — this always * wins over the resolution below. On load error, falls back to * `fallbackText`/`fallbackColor` (a colored text badge) if provided, otherwise falls - * through to steps 1-5. - * 1. Try /providers/{id}.svg (local SVG assets — fastest, cached separately from JS bundle) - * 2. Try @lobehub/icons direct React components (no @lobehub/ui peer runtime) - * 3. Fall back to thesvg.org CDN (external SVG) - * 4. Fall back to /providers/{id}.png (legacy static assets) - * 5. Fall back to a generic AI icon + * through to steps 1-6. + * 1. Theme-aware static SVGs (`THEMED_SVGS`, e.g. arena-light/dark for lmarena) + * 2. Try /providers/{id}.svg (local SVG assets — fastest, cached separately from JS bundle) + * 3. Try @lobehub/icons direct React components (no @lobehub/ui peer runtime) + * 4. Fall back to thesvg.org CDN (external SVG) + * 5. Fall back to /providers/{id}.png (legacy static assets) + * 6. Fall back to a generic AI icon * * Usage: * @@ -23,6 +24,8 @@ import { createElement, memo, useState } from "react"; import Image from "next/image"; +import { useTheme } from "@/shared/hooks/useTheme"; + import { getLobeProviderIcon } from "./lobeProviderIcons"; interface ProviderIconProps { @@ -216,6 +219,18 @@ const KNOWN_PNGS = new Set([ "zeroclaw", ]); +const THEMED_SVGS: Record = { + // Arena (formerly LMArena) — wire id stays `lmarena`; alias `lma` also accepted. + lmarena: { + light: "/providers/arena-light.svg", + dark: "/providers/arena-dark.svg", + }, + lma: { + light: "/providers/arena-light.svg", + dark: "/providers/arena-dark.svg", + }, +}; + const ProviderIcon = memo(function ProviderIcon({ providerId, size = 24, @@ -227,18 +242,22 @@ const ProviderIcon = memo(function ProviderIcon({ fallbackText, fallbackColor, }: ProviderIconProps) { + const { isDark } = useTheme(); const normalizedId = providerId.toLowerCase(); const lobeIcon = getLobeProviderIcon(normalizedId, type); + const themedSvg = THEMED_SVGS[normalizedId]; const hasSvg = KNOWN_SVGS.has(normalizedId); const hasPng = KNOWN_PNGS.has(normalizedId); const [failedAssets, setFailedAssets] = useState>({}); const [remoteSrcFailed, setRemoteSrcFailed] = useState(false); + const themedKey = `${normalizedId}:themed`; const svgKey = `${normalizedId}:svg`; const pngKey = `${normalizedId}:png`; const theSvgKey = `${normalizedId}:thesvg`; const trimmedSrc = typeof src === "string" ? src.trim() : ""; + const themedFailed = failedAssets[themedKey]; const svgFailed = failedAssets[svgKey]; const theSvgFailed = failedAssets[theSvgKey]; const pngFailed = failedAssets[pngKey]; @@ -287,7 +306,28 @@ const ProviderIcon = memo(function ProviderIcon({ ); } - // Tier 1: Local SVG — fastest, cached separately from the JS bundle + // Tier 1: Theme-aware local SVGs (e.g. Arena light/dark) + if (themedSvg && !themedFailed) { + const themedSrc = isDark ? themedSvg.dark : themedSvg.light; + return ( + + {providerId} setFailedAssets((current) => ({ ...current, [themedKey]: true }))} + unoptimized + /> + + ); + } + + // Tier 2: Local SVG — fastest, cached separately from the JS bundle if (hasSvg && !svgFailed) { return ( diff --git a/src/shared/constants/providers/web-cookie.ts b/src/shared/constants/providers/web-cookie.ts index 1d8e7671898b..b06e1f767945 100644 --- a/src/shared/constants/providers/web-cookie.ts +++ b/src/shared/constants/providers/web-cookie.ts @@ -171,18 +171,20 @@ export const WEB_COOKIE_PROVIDERS = { "Paste your __client cookie value from .clerk.agent.adapta.one (DevTools → Application → Cookies)", }, lmarena: { + // Wire id stays `lmarena` for DB/combo/model-prefix back-compat. + // Product rebranded LMArena → Arena (arena.ai) in Jan 2026. id: "lmarena", alias: "lma", - name: "LMArena (Free)", + name: "Arena (Free)", icon: "auto_awesome", color: "#FF6B6B", - textIcon: "LMA", - website: "https://lmarena.ai", + textIcon: "AR", + website: "https://arena.ai", hasFree: true, freeNote: - "Free model comparison platform — 40+ models (GPT, Claude, Gemini, Llama). No subscription required.", + "Free model comparison platform (formerly LMArena) at arena.ai — Direct-chat catalog of chat models (GPT, Claude, Gemini, Llama, …). No subscription required.", authHint: - "Paste the full Cookie header from lmarena.ai (DevTools → Network → request → Cookie). The session is now split across arena-auth-prod-v1.0, .1, … — copy the whole header. Optional — works with free tier for basic comparisons.", + "Paste the full Cookie header from arena.ai (DevTools → Network → request → Cookie). Include arena-auth-prod-v1.0/.1… and cf_clearance/__cf_bm when present. OmniRoute uses Chrome TLS impersonation; if Arena still 403s, set providerSpecificData.recaptchaV3Token from a live browser session.", riskNoticeVariant: "webCookie", }, "yuanbao-web": { diff --git a/src/shared/providers/webSessionCredentials.ts b/src/shared/providers/webSessionCredentials.ts index 77d3d3ca5e39..22b422f79ed5 100644 --- a/src/shared/providers/webSessionCredentials.ts +++ b/src/shared/providers/webSessionCredentials.ts @@ -14,6 +14,7 @@ export type WebSessionCredentialRequirement = * AND the Cookie header, so the one-line cookie hint reads circular). */ hintKey?: string; + hintFallback?: string; } | { kind: "none"; @@ -229,16 +230,16 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = { }, lmarena: { kind: "cookie", - // lmarena.ai's auth cookie is `arena-auth-prod-v1` (the legacy hint said `session`, + // arena.ai's auth cookie is `arena-auth-prod-v1` (the legacy hint said `session`, // which never matched the real cookie name and confused users). #3810 // // #4271: LMArena migrated to Supabase SSR chunked cookies — the single // `arena-auth-prod-v1` cookie is now empty and the session is split across // `arena-auth-prod-v1.0`, `arena-auth-prod-v1.1`, … Users must paste the FULL // Cookie header so the executor can reconstruct the single cookie from chunks. - credentialName: "arena-auth-prod-v1", + credentialName: "full Cookie header (arena-auth-prod-v1.0 + arena-auth-prod-v1.1)", placeholder: - "Paste the full Cookie header from lmarena.ai (the session is now split across arena-auth-prod-v1.0, .1, …)", + "arena-auth-prod-v1.0=...; arena-auth-prod-v1.1=...; other=value (full Cookie header from arena.ai)", acceptsFullCookieHeader: true, storageKeys: [ "cookie", @@ -247,6 +248,9 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = { "arena-auth-prod-v1.1", "session", ], + hintKey: "lmarenaWebCookieHint", + hintFallback: + "Open arena.ai, sign in, then copy the full Cookie header from a Network request. Include arena-auth-prod-v1.0 and arena-auth-prod-v1.1 (and further chunks if present), preferably with cf_clearance. Do not paste only the empty arena-auth-prod-v1 cookie. Optional: providerSpecificData.recaptchaV3Token if create-evaluation still returns 403.", }, } satisfies Record; diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 5c5ec76a899f..6a1bf66eb660 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -2566,6 +2566,29 @@ "stream": "https://api.llm7.io/v1/chat/completions" } }, + "lmarena": { + "format": "openai", + "headers": { + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + } + }, + "url": { + "nonStream": "https://arena.ai/nextjs-api/stream/create-evaluation", + "stream": "https://arena.ai/nextjs-api/stream/create-evaluation" + } + }, "longcat": { "format": "openai", "headers": { diff --git a/tests/unit/bailian-coding-plan-provider.test.ts b/tests/unit/bailian-coding-plan-provider.test.ts index 2492071a8643..2dcbe63e558f 100644 --- a/tests/unit/bailian-coding-plan-provider.test.ts +++ b/tests/unit/bailian-coding-plan-provider.test.ts @@ -293,13 +293,26 @@ test("getStaticModelsForProvider returns undefined for non-static providers", () }); test("getStaticModelsForProvider returns local image catalogs for image-only providers", () => { - const models = getStaticModelsForProvider("xai"); + // nanobanana has IMAGE_PROVIDERS rows but no chat registry models — specialty + // must still surface them. Chat+image providers (xai/lmarena/openai) keep + // image models exclusively in IMAGE_PROVIDERS (not the chat specialty list). + const models = getStaticModelsForProvider("nanobanana"); - assert.ok(models, "xAI should expose local image models"); - assert.deepEqual( - models.map((model) => model.id), - ["grok-imagine-image-quality", "grok-imagine-image"] - ); + assert.ok(models, "nanobanana should expose local image models"); + assert.ok(models.length >= 1); + assert.ok(models.every((m) => m.supportedEndpoints?.includes("images"))); +}); + +test("getStaticModelsForProvider does not dump IMAGE_PROVIDERS into chat specialty", () => { + for (const provider of ["lmarena", "openai", "xai"]) { + const models = getStaticModelsForProvider(provider) || []; + assert.ok( + !models.some((m) => m.supportedEndpoints?.includes("images")), + `${provider} chat specialty must not include image-only models` + ); + } + const lmarena = getStaticModelsForProvider("lmarena") || []; + assert.ok(!lmarena.some((m) => String(m.id).includes("flux"))); }); test("getStaticModelsForProvider returns models for other static providers", () => { diff --git a/tests/unit/lmarena-provider.test.ts b/tests/unit/lmarena-provider.test.ts index 96cae4d18ee0..3fb8081b3e80 100644 --- a/tests/unit/lmarena-provider.test.ts +++ b/tests/unit/lmarena-provider.test.ts @@ -12,15 +12,59 @@ import { requiresWebSessionCredential, hasUsableWebSessionCredential, } from "../../src/shared/providers/webSessionCredentials.ts"; -import { LMArenaExecutor, parseArenaSSE } from "../../open-sse/executors/lmarena.ts"; +import { + LMArenaExecutor, + markLMArenaCatalogModelDead, + normalizeLMArenaModelsForCatalog, + parseArenaSSE, + parseLMArenaInitialModels, + pickLMArenaModelId, +} from "../../open-sse/executors/lmarena.ts"; +import { clearLMArenaDeadCatalogModels } from "../../open-sse/executors/lmarena/models.ts"; +import { __setTlsFetchOverrideForTesting } from "../../open-sse/services/lmarenaTlsClient.ts"; + +const TEST_ARENA_MODEL_ID = "019e080d-c29d-7d9a-aa54-faed41da0763"; +const UUID_V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +/** Protected BaseExecutor methods exercised by unit tests without `any`. */ +type LMArenaExecutorTestAccess = { + provider: string; + buildUrl: (model: string, credentials: unknown) => string; + buildHeaders: (model: string, credentials: unknown, body: unknown) => Record; + transformRequest: ( + body: unknown, + model: string, + credentials?: unknown + ) => { + id: string; + mode: string; + modality: string; + modelAId: string; + modelBId?: string; + userMessageId: string; + modelAMessageId: string; + modelBMessageId?: string; + recaptchaV3Token: string | null; + userMessage: { + content: string; + experimental_attachments: unknown[]; + metadata: Record; + }; + }; +}; + +function access(executor: LMArenaExecutor): LMArenaExecutorTestAccess { + return executor as unknown as LMArenaExecutorTestAccess; +} describe("LMArena Provider Definition", () => { it("is registered in WEB_COOKIE_PROVIDERS", () => { assert.ok(WEB_COOKIE_PROVIDERS.lmarena, "lmarena should be in WEB_COOKIE_PROVIDERS"); assert.equal(WEB_COOKIE_PROVIDERS.lmarena.id, "lmarena"); assert.equal(WEB_COOKIE_PROVIDERS.lmarena.alias, "lma"); - assert.equal(WEB_COOKIE_PROVIDERS.lmarena.name, "LMArena (Free)"); - assert.equal(WEB_COOKIE_PROVIDERS.lmarena.website, "https://lmarena.ai"); + assert.equal(WEB_COOKIE_PROVIDERS.lmarena.name, "Arena (Free)"); + assert.equal(WEB_COOKIE_PROVIDERS.lmarena.textIcon, "AR"); + assert.equal(WEB_COOKIE_PROVIDERS.lmarena.website, "https://arena.ai"); assert.equal(WEB_COOKIE_PROVIDERS.lmarena.hasFree, true); assert.equal(WEB_COOKIE_PROVIDERS.lmarena.riskNoticeVariant, "webCookie"); }); @@ -28,6 +72,7 @@ describe("LMArena Provider Definition", () => { it("has correct metadata", () => { const provider = WEB_COOKIE_PROVIDERS.lmarena; assert.ok(provider.freeNote, "Should have freeNote"); + assert.ok(provider.freeNote.includes("formerly LMArena"), "Should note rebrand"); assert.ok(provider.authHint, "Should have authHint"); assert.ok(provider.icon, "Should have icon"); assert.ok(provider.color, "Should have color"); @@ -44,10 +89,12 @@ describe("LMArena Credential Requirements", () => { const req = getWebSessionCredentialRequirement("lmarena"); assert.ok(req, "Should have credential requirement"); assert.equal(req.kind, "cookie"); - // #3810: lmarena.ai's real auth cookie is `arena-auth-prod-v1`, not `session` - assert.equal(req.credentialName, "arena-auth-prod-v1"); + // #3810: arena.ai's real auth cookie is `arena-auth-prod-v1`, not `session`; + // #4271: it is now split into Supabase SSR chunks. + assert.ok(req.credentialName.includes("arena-auth-prod-v1.0")); + assert.ok(req.credentialName.includes("arena-auth-prod-v1.1")); assert.ok(req.placeholder.includes("arena-auth-prod-v1")); - assert.ok(req.placeholder.includes("lmarena.ai")); + assert.ok(req.placeholder.includes("arena.ai")); assert.equal(req.acceptsFullCookieHeader, true); assert.ok(req.storageKeys.includes("cookie")); assert.ok(req.storageKeys.includes("arena-auth-prod-v1")); @@ -71,19 +118,22 @@ describe("LMArena Executor", () => { it("has correct provider ID", () => { const executor = new LMArenaExecutor(); - assert.equal((executor as any).provider, "lmarena"); + assert.equal(access(executor).provider, "lmarena"); }); - it("builds correct URL (arena.ai/nextjs-api/stream)", () => { + it("builds correct URL (arena.ai/nextjs-api/stream/create-evaluation)", () => { const executor = new LMArenaExecutor(); - const url = (executor as any).buildUrl("gpt-4", {}); + const url = access(executor).buildUrl("gpt-4", {}); assert.ok(url.includes("arena.ai"), "URL should include arena.ai"); - assert.ok(url.includes("/nextjs-api/stream"), "URL should include /nextjs-api/stream"); + assert.ok( + url.includes("/nextjs-api/stream/create-evaluation"), + "URL should include /nextjs-api/stream/create-evaluation" + ); }); it("builds headers with cookie", () => { const executor = new LMArenaExecutor(); - const headers = (executor as any).buildHeaders("gpt-4", { cookie: "session=abc123" }, {}); + const headers = access(executor).buildHeaders("gpt-4", { cookie: "session=abc123" }, {}); assert.ok(headers.Cookie, "Should have Cookie header"); assert.equal(headers.Cookie, "session=abc123"); assert.equal(headers["Content-Type"], "application/json"); @@ -92,35 +142,28 @@ describe("LMArena Executor", () => { it("builds headers without cookie when not provided", () => { const executor = new LMArenaExecutor(); - const headers = (executor as any).buildHeaders("gpt-4", {}, {}); + const headers = access(executor).buildHeaders("gpt-4", {}, {}); assert.ok(!headers.Cookie, "Should not have Cookie header when no cookie provided"); }); it("reads cookie from credentials correctly", () => { const executor = new LMArenaExecutor(); + const ex = access(executor); // Direct cookie field - let headers = (executor as any).buildHeaders("gpt-4", { cookie: "session=abc" }, {}); + let headers = ex.buildHeaders("gpt-4", { cookie: "session=abc" }, {}); assert.equal(headers.Cookie, "session=abc"); // apiKey field (dashboard form) - headers = (executor as any).buildHeaders("gpt-4", { apiKey: "session=def" }, {}); + headers = ex.buildHeaders("gpt-4", { apiKey: "session=def" }, {}); assert.equal(headers.Cookie, "session=def"); // providerSpecificData.cookie - headers = (executor as any).buildHeaders( - "gpt-4", - { providerSpecificData: { cookie: "session=ghi" } }, - {} - ); + headers = ex.buildHeaders("gpt-4", { providerSpecificData: { cookie: "session=ghi" } }, {}); assert.equal(headers.Cookie, "session=ghi"); // Priority: direct > apiKey > providerSpecificData - headers = (executor as any).buildHeaders( - "gpt-4", - { cookie: "session=abc", apiKey: "session=def" }, - {} - ); + headers = ex.buildHeaders("gpt-4", { cookie: "session=abc", apiKey: "session=def" }, {}); assert.equal(headers.Cookie, "session=abc"); }); @@ -133,6 +176,15 @@ describe("LMArena Executor", () => { assert.equal(result.content, "Hello, world!"); }); + it("parses bare AI SDK text events (0: prefix)", () => { + const textEvent = '0:"Hello, world!"'; + const result = parseArenaSSE(textEvent); + + assert.ok(result, "Should parse text event"); + assert.equal(result.type, "text"); + assert.equal(result.content, "Hello, world!"); + }); + it("parses LMArena SSE thinking events (ag: prefix)", () => { const thinkingEvent = 'ag:{"thinking":"Let me analyze this..."}'; const result = parseArenaSSE(thinkingEvent); @@ -142,6 +194,15 @@ describe("LMArena Executor", () => { assert.equal(result.content, "Let me analyze this..."); }); + it("parses bare AI SDK reasoning events (g: prefix)", () => { + const thinkingEvent = 'g:"Let me analyze this..."'; + const result = parseArenaSSE(thinkingEvent); + + assert.ok(result, "Should parse reasoning event"); + assert.equal(result.type, "thinking"); + assert.equal(result.content, "Let me analyze this..."); + }); + it("parses LMArena SSE error events (a3: and ae: prefixes)", () => { const errorEvent1 = 'a3:{"error":"Rate limit exceeded"}'; const result1 = parseArenaSSE(errorEvent1); @@ -156,6 +217,15 @@ describe("LMArena Executor", () => { assert.equal(result2.content, "Invalid session"); }); + it("parses bare AI SDK error events (3: prefix)", () => { + const errorEvent = '3:"Rate limit exceeded"'; + const result = parseArenaSSE(errorEvent); + + assert.ok(result, "Should parse error event"); + assert.equal(result.type, "error"); + assert.equal(result.content, "Rate limit exceeded"); + }); + it("parses LMArena SSE done event (ad: prefix)", () => { const doneEvent = "ad:{}"; const result = parseArenaSSE(doneEvent); @@ -164,6 +234,14 @@ describe("LMArena Executor", () => { assert.equal(result.type, "done"); }); + it("parses bare AI SDK finish events (d: prefix)", () => { + const doneEvent = 'd:{"finishReason":"stop"}'; + const result = parseArenaSSE(doneEvent); + + assert.ok(result, "Should parse done event"); + assert.equal(result.type, "done"); + }); + it("handles malformed SSE events gracefully", () => { const malformedEvent = "invalid:data"; const result = parseArenaSSE(malformedEvent); @@ -171,9 +249,9 @@ describe("LMArena Executor", () => { assert.equal(result, null, "Should return null for malformed events"); }); - it("transforms OpenAI messages to LMArena format", () => { + it("transforms OpenAI messages to LMArena create-evaluation format", () => { const executor = new LMArenaExecutor(); - const transformRequest = (executor as any).transformRequest.bind(executor); + const transformRequest = access(executor).transformRequest.bind(access(executor)); const openaiBody = { messages: [ @@ -189,9 +267,253 @@ describe("LMArena Executor", () => { const arenaBody = transformRequest(openaiBody, "gpt-4"); assert.ok(arenaBody, "Should transform request body"); - assert.ok(arenaBody.messages, "Should have messages array"); - assert.equal(arenaBody.model, "gpt-4", "Should preserve model"); - assert.equal(arenaBody.stream, true, "Should preserve stream flag"); + assert.match(arenaBody.id, UUID_V7_RE, "Should have UUIDv7 evaluation session id"); + assert.match(arenaBody.userMessageId, UUID_V7_RE, "Should have UUIDv7 user message id"); + assert.match(arenaBody.modelAMessageId, UUID_V7_RE, "Should have UUIDv7 model message id"); + assert.equal(arenaBody.mode, "direct-battle"); + assert.equal(arenaBody.modality, "chat"); + assert.equal(arenaBody.modelAId, "gpt-4", "Should set modelAId"); + assert.equal(arenaBody.modelBId, undefined, "Should not set modelBId for direct mode"); + assert.equal(arenaBody.modelBMessageId, undefined, "Should not set modelBMessageId"); + assert.equal(arenaBody.recaptchaV3Token, null); + assert.deepEqual(arenaBody.userMessage.experimental_attachments, []); + assert.deepEqual(arenaBody.userMessage.metadata, {}); + assert.ok( + arenaBody.userMessage.content.includes("You are a helpful assistant."), + "Should preserve system context in first prompt" + ); + assert.ok( + arenaBody.userMessage.content.includes("How are you?"), + "Should preserve latest user prompt" + ); + }); + + it("handles null request bodies when transforming requests", () => { + const executor = new LMArenaExecutor(); + const arenaBody = access(executor).transformRequest(null, "gpt-4"); + + assert.equal(arenaBody.modelAId, "gpt-4"); + assert.equal(arenaBody.userMessage.content, ""); + }); + + it("maps display model names to Arena internal model ids", () => { + const models = [ + { + id: "019e080d-c29d-7d9a-aa54-faed41da0763", + publicName: "gemini-3.1-pro-preview", + name: "gemini-3.1-pro-preview", + displayName: "Gemini 3.1 Pro Preview", + userSelectable: true, + capabilities: { + inputCapabilities: { text: true }, + outputCapabilities: { text: true }, + }, + rankByModality: { chat: 18 }, + }, + ]; + + assert.equal( + pickLMArenaModelId("gemini-3.1-pro-preview", models), + "019e080d-c29d-7d9a-aa54-faed41da0763" + ); + assert.equal( + pickLMArenaModelId("lmarena/Gemini 3.1 Pro Preview", models), + "019e080d-c29d-7d9a-aa54-faed41da0763" + ); + }); + + it("prefers chat-capable ranked variants when public names are duplicated", () => { + const models = [ + { + id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + publicName: "gemini-3.1-pro-preview", + displayName: "gemini-3.1-pro-preview", + userSelectable: true, + capabilities: { + inputCapabilities: { text: true }, + outputCapabilities: { web: true }, + }, + rankByModality: { webdev: 29 }, + }, + { + id: "019e080d-c29d-7d9a-aa54-faed41da0763", + publicName: "gemini-3.1-pro-preview", + displayName: "gemini-3.1-pro-preview", + userSelectable: true, + capabilities: { + inputCapabilities: { text: true, image: true }, + outputCapabilities: { text: true, web: true }, + }, + rankByModality: { chat: 18, webdev: 29 }, + }, + ]; + + assert.equal( + pickLMArenaModelId("gemini-3.1-pro-preview", models), + "019e080d-c29d-7d9a-aa54-faed41da0763" + ); + }); + + it("drops unranked sentinel rows (huge chat rank) that usually 404 on probe", () => { + const models = [ + { + id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + publicName: "mimo-v2.5-pro", + displayName: "mimo-v2.5-pro", + userSelectable: true, + capabilities: { + inputCapabilities: { text: true }, + outputCapabilities: { text: true, web: true }, + }, + rankByModality: { chat: Number.MAX_SAFE_INTEGER }, + }, + { + id: "11111111-2222-3333-4444-555555555555", + name: "mimo-v2.5-pro", + publicName: "mimo-v2.5-pro", + displayName: "mimo-v2.5-pro", + organization: "xiaomi", + provider: "xiaomiV1", + userSelectable: true, + capabilities: { + inputCapabilities: { text: true }, + outputCapabilities: { text: true, web: true }, + }, + rankByModality: { chat: 42 }, + }, + ]; + + assert.equal( + pickLMArenaModelId("mimo-v2.5-pro", models), + "11111111-2222-3333-4444-555555555555" + ); + assert.deepEqual(normalizeLMArenaModelsForCatalog(models), [ + { + id: "mimo-v2.5-pro", + name: "mimo-v2.5-pro", + owned_by: "xiaomi", + apiFormat: "chat-completions", + supportedEndpoints: ["chat"], + }, + ]); + }); + + it("normalizes live initialModels into unique chat catalog ids", () => { + const models = [ + { + id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + publicName: "gemini-3.1-pro-preview", + displayName: "gemini-3.1-pro-preview", + userSelectable: true, + capabilities: { + inputCapabilities: { text: true }, + outputCapabilities: { web: true }, + }, + rankByModality: { webdev: 29 }, + }, + { + id: "019e080d-c29d-7d9a-aa54-faed41da0763", + publicName: "gemini-3.1-pro-preview", + displayName: "gemini-3.1-pro-preview", + organization: "google", + userSelectable: true, + capabilities: { + inputCapabilities: { text: true, image: true }, + outputCapabilities: { text: true, web: true }, + }, + rankByModality: { chat: 18, webdev: 29 }, + }, + { + id: "99999999-9999-9999-9999-999999999999", + publicName: "hidden-model", + displayName: "Hidden Model", + userSelectable: false, + capabilities: { + inputCapabilities: { text: true }, + outputCapabilities: { text: true }, + }, + rankByModality: { chat: 1 }, + }, + ]; + + assert.deepEqual(normalizeLMArenaModelsForCatalog(models), [ + { + id: "gemini-3.1-pro-preview", + name: "gemini-3.1-pro-preview", + owned_by: "google", + supportsVision: true, + apiFormat: "chat-completions", + supportedEndpoints: ["chat"], + }, + ]); + }); + + it("soft-excludes models marked dead after 404/502 probes", () => { + clearLMArenaDeadCatalogModels(); + const models = [ + { + id: "019e080d-c29d-7d9a-aa54-faed41da0763", + publicName: "gemini-3.1-pro-preview", + displayName: "gemini-3.1-pro-preview", + organization: "google", + userSelectable: true, + capabilities: { + inputCapabilities: { text: true, image: true }, + outputCapabilities: { text: true }, + }, + rankByModality: { chat: 18 }, + }, + ]; + assert.equal(normalizeLMArenaModelsForCatalog(models).length, 1); + markLMArenaCatalogModelDead("gemini-3.1-pro-preview"); + assert.equal(normalizeLMArenaModelsForCatalog(models).length, 0); + clearLMArenaDeadCatalogModels(); + }); + + it("keeps raw Arena ids unchanged when no model mapping is needed", () => { + assert.equal(pickLMArenaModelId(TEST_ARENA_MODEL_ID, []), TEST_ARENA_MODEL_ID); + }); + + it("resolves catalog public names via static Direct-chat allowlist (no arena.ai fetch)", async () => { + const executor = new LMArenaExecutor(); + let arenaHomeFetches = 0; + __setTlsFetchOverrideForTesting(async (url) => { + if (url === "https://arena.ai/" || /arena\.ai\/?$/.test(url)) { + arenaHomeFetches++; + return { status: 200, headers: new Headers(), text: "", body: null }; + } + return { + status: 200, + headers: new Headers({ "Content-Type": "text/event-stream" }), + text: '0:"ok"\nd:{"finishReason":"stop"}\n', + body: null, + }; + }); + + try { + const result = await executor.execute({ + model: "gemini-3.1-pro-preview", + body: { messages: [{ role: "user", content: "Hello" }] }, + credentials: { cookie: "session=test" }, + signal: new AbortController().signal, + log: console, + }); + assert.equal(result.response.status, 200); + // Model resolution must not scrape arena.ai home for initialModels. + assert.equal(arenaHomeFetches, 0); + // create-evaluation should receive the scraped Arena UUID, not the public name. + const body = result.transformedBody as { modelAId?: string }; + assert.match(String(body.modelAId || ""), /^[0-9a-f-]{36}$/i); + } finally { + __setTlsFetchOverrideForTesting(null); + } + }); + + it("returns an empty model list when initialModels end marker is before the array", () => { + assert.deepEqual( + parseLMArenaInitialModels('"initialModelAId"],"initialModels":[{"id":"bad"}]'), + [] + ); }); it("returns 401 when cookie is missing", async () => { @@ -213,23 +535,27 @@ describe("LMArena Executor", () => { it("handles streaming response correctly", async () => { const executor = new LMArenaExecutor(); - const mockSSE = [ 'data: a0:{"text":"Hello"}\n\n', 'data: a0:{"text":", world!"}\n\n', "data: ad:{}\n\n", ].join(""); - const originalFetch = global.fetch; - global.fetch = async () => - new Response(mockSSE, { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }); + __setTlsFetchOverrideForTesting(async () => ({ + status: 200, + headers: new Headers({ "Content-Type": "text/event-stream" }), + text: null, + body: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(mockSSE)); + controller.close(); + }, + }), + })); try { const result = await executor.execute({ - model: "gpt-4", + model: TEST_ARENA_MODEL_ID, body: { messages: [{ role: "user", content: "Hello" }], stream: true }, credentials: { cookie: "session=test" }, signal: new AbortController().signal, @@ -239,28 +565,22 @@ describe("LMArena Executor", () => { assert.equal(result.response.status, 200, "Should return 200 for successful streaming"); assert.ok(result.response.body, "Should have response body for streaming"); } finally { - global.fetch = originalFetch; + __setTlsFetchOverrideForTesting(null); } }); it("handles error response from LMArena API", async () => { const executor = new LMArenaExecutor(); - - const originalFetch = global.fetch; - global.fetch = async () => - new Response( - JSON.stringify({ - error: { message: "Rate limit exceeded" }, - }), - { - status: 429, - headers: { "Content-Type": "application/json" }, - } - ); + __setTlsFetchOverrideForTesting(async () => ({ + status: 429, + headers: new Headers({ "Content-Type": "application/json" }), + text: JSON.stringify({ error: { message: "Rate limit exceeded" } }), + body: null, + })); try { const result = await executor.execute({ - model: "gpt-4", + model: TEST_ARENA_MODEL_ID, body: { messages: [{ role: "user", content: "Hello" }] }, credentials: { cookie: "session=test" }, signal: new AbortController().signal, @@ -271,7 +591,43 @@ describe("LMArena Executor", () => { const errorBody = await result.response.json(); assert.ok(errorBody.error, "Should have error object"); } finally { - global.fetch = originalFetch; + __setTlsFetchOverrideForTesting(null); + } + }); + + it("forwards optional browser reCAPTCHA token from credentials", () => { + const executor = new LMArenaExecutor(); + const body = access(executor).transformRequest( + { messages: [{ role: "user", content: "Hi" }] }, + "gpt-4", + { cookie: "x=1", providerSpecificData: { recaptchaV3Token: "tok_abc" } } + ); + assert.equal(body.recaptchaV3Token, "tok_abc"); + }); + + it("surfaces Cloudflare challenge as bot-management error", async () => { + const executor = new LMArenaExecutor(); + __setTlsFetchOverrideForTesting(async () => ({ + status: 403, + headers: new Headers({ "Content-Type": "text/html" }), + text: "Just a moment... challenges.cloudflare.com", + body: null, + })); + + try { + const result = await executor.execute({ + model: TEST_ARENA_MODEL_ID, + body: { messages: [{ role: "user", content: "Hello" }] }, + credentials: { cookie: "session=test" }, + signal: new AbortController().signal, + log: console, + }); + assert.equal(result.response.status, 403); + const err = await result.response.json(); + assert.match(err.error.message, /Cloudflare|bot|recaptcha/i); + assert.equal(err.error.code, "cloudflare_or_bot"); + } finally { + __setTlsFetchOverrideForTesting(null); } }); }); diff --git a/tests/unit/lmarena-split-cookie-4271.test.ts b/tests/unit/lmarena-split-cookie-4271.test.ts index 80b304928231..85c4350f50db 100644 --- a/tests/unit/lmarena-split-cookie-4271.test.ts +++ b/tests/unit/lmarena-split-cookie-4271.test.ts @@ -17,10 +17,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { - LMArenaExecutor, - reconstructLMArenaCookie, -} from "../../open-sse/executors/lmarena.ts"; +import { LMArenaExecutor, reconstructLMArenaCookie } from "../../open-sse/executors/lmarena.ts"; import { getWebSessionCredentialRequirement } from "../../src/shared/providers/webSessionCredentials.ts"; function cookieHeaderFor(credentials: unknown): string | undefined { @@ -85,6 +82,28 @@ describe("LMArena split Supabase SSR cookie (#4271)", () => { assert.ok(reconstructed.includes("sidebar=open"), "should keep sidebar"); }); + it("reconstructs from separately stored providerSpecificData chunk keys", () => { + const header = cookieHeaderFor({ + providerSpecificData: { + "arena-auth-prod-v1.0": "base64-eyJABC", + "arena-auth-prod-v1.1": "DEF.ghi", + }, + }); + + assert.ok(header, "should set a Cookie header"); + assert.equal(header, "arena-auth-prod-v1=base64-eyJABCDEF.ghi"); + }); + + it("reconstructs from separately stored top-level chunk keys", () => { + const header = cookieHeaderFor({ + "arena-auth-prod-v1.0": "base64-eyJABC", + "arena-auth-prod-v1.1": "DEF.ghi", + }); + + assert.ok(header, "should set a Cookie header"); + assert.equal(header, "arena-auth-prod-v1=base64-eyJABCDEF.ghi"); + }); + it("treats an empty base with no chunks as no usable session (returned as-is)", () => { const raw = "arena-auth-prod-v1="; const reconstructed = reconstructLMArenaCookie(raw); diff --git a/tests/unit/provider-icon-arena-themed.test.ts b/tests/unit/provider-icon-arena-themed.test.ts new file mode 100644 index 000000000000..8ccd0546a445 --- /dev/null +++ b/tests/unit/provider-icon-arena-themed.test.ts @@ -0,0 +1,30 @@ +/** + * Arena (lmarena) theme-aware provider icons must stay wired to the static SVGs + * under public/providers/arena-{light,dark}.svg. + */ +import assert from "node:assert/strict"; +import { readFileSync, existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; + +const root = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const providerIconSrc = readFileSync(join(root, "src/shared/components/ProviderIcon.tsx"), "utf8"); + +test("ProviderIcon maps lmarena (and lma) to arena light/dark SVGs", () => { + assert.ok( + providerIconSrc.includes('light: "/providers/arena-light.svg"'), + "must reference arena-light.svg" + ); + assert.ok( + providerIconSrc.includes('dark: "/providers/arena-dark.svg"'), + "must reference arena-dark.svg" + ); + assert.ok(providerIconSrc.includes("lmarena:"), "must register wire id lmarena"); + assert.ok(providerIconSrc.includes("lma:"), "must register alias lma"); +}); + +test("arena theme SVG assets exist under public/providers/", () => { + assert.ok(existsSync(join(root, "public/providers/arena-light.svg"))); + assert.ok(existsSync(join(root, "public/providers/arena-dark.svg"))); +}); diff --git a/tests/unit/t28-model-catalog-updates.test.ts b/tests/unit/t28-model-catalog-updates.test.ts index 355c00d799e3..3455bafcca75 100644 --- a/tests/unit/t28-model-catalog-updates.test.ts +++ b/tests/unit/t28-model-catalog-updates.test.ts @@ -58,6 +58,31 @@ test("T28: qwen registry uses native chat.qwen.ai base URL", () => { ); }); +test("T28: lmarena registry seeds Direct-chat Text/search; image models in IMAGE_PROVIDERS", async () => { + const { IMAGE_PROVIDERS } = await import("../../open-sse/config/imageRegistry.ts"); + const lmarenaIds = REGISTRY.lmarena.models.map((m) => m.id); + const imageIds = (IMAGE_PROVIDERS.lmarena?.models || []).map((m: { id: string }) => m.id); + + // Chat registry: Text + Search only (not Image thrash) + assert.ok(lmarenaIds.length >= 40 && lmarenaIds.length < 60); + assert.ok(lmarenaIds.includes("gemini-3.1-pro-preview")); + assert.ok(lmarenaIds.includes("gemini-3.5-flash-high")); + assert.ok(lmarenaIds.includes("claude-sonnet-5")); + assert.ok(!lmarenaIds.includes("flux-2-pro"), "image models must not live in chat registry"); + + // Image registry: Direct-chat Image category + assert.ok(imageIds.length >= 20); + assert.ok(imageIds.includes("flux-2-pro") || imageIds.includes("flux-2-dev")); + assert.ok(IMAGE_PROVIDERS.lmarena, "lmarena must be an IMAGE_PROVIDERS key"); + + // publicName collision → category-suffixed catalog id (chat side) + assert.ok(lmarenaIds.includes("grok-4.3/text") || lmarenaIds.includes("grok-4.3/search")); + + const resolved = await getModelInfoCore("lma/gemini-3.1-pro-preview", {}); + assert.equal(resolved.provider, "lmarena"); + assert.equal(resolved.model, "gemini-3.1-pro-preview"); +}); + test("T28: vertex catalog includes partner models when vertex executor is available", () => { const vertexIds = REGISTRY.vertex.models.map((m) => m.id); diff --git a/tests/unit/t3chat-web-cookie-hint-5465.test.ts b/tests/unit/t3chat-web-cookie-hint-5465.test.ts index e938b87947cd..0cac7c6e92f6 100644 --- a/tests/unit/t3chat-web-cookie-hint-5465.test.ts +++ b/tests/unit/t3chat-web-cookie-hint-5465.test.ts @@ -57,6 +57,27 @@ test("t3-web requirement now carries the hintKey override (#5465)", () => { assert.equal(req.hintKey, "t3ChatWebCookieHint"); }); +test("lmarena add-credential hint uses dedicated copy (not generic single-cookie), full header intent", () => { + // Intent-only: product copy may mention CF/reCAPTCHA/chunk shorthand; do not freeze wording. + const t = makeTranslator({ + webCookieCredentialHint: + "Required cookie: {credential}. Paste the Cookie header value from your own signed-in {provider} web session. Do not include the Cookie: prefix.", + }); + + const requirement = WEB_SESSION_CREDENTIAL_REQUIREMENTS["lmarena"]; + const hint = getWebSessionCredentialHint(t, requirement, "Arena", false); + + assert.equal(requirement.hintKey, "lmarenaWebCookieHint"); + assert.ok(requirement.credentialName && /arena-auth-prod-v1/i.test(requirement.credentialName)); + assert.ok(hint && hint.length > 0, "dedicated hintFallback/i18n must resolve"); + assert.ok(/full cookie header/i.test(hint), "must ask for the full Cookie header"); + assert.ok(/arena-auth-prod-v1/i.test(hint), "must reference Arena auth cookies"); + assert.ok( + !hint.startsWith("Required cookie:"), + "must not fall back to the generic cookie template" + ); +}); + test("cookie providers without a hintKey still use the generic hint (#5465 regression guard)", () => { const t = makeTranslator({ webCookieCredentialHint: diff --git a/tests/unit/ui/add-api-key-modal-unsupported-save-5565.test.tsx b/tests/unit/ui/add-api-key-modal-unsupported-save-5565.test.tsx index e2afb05ca58e..5d123321ef71 100644 --- a/tests/unit/ui/add-api-key-modal-unsupported-save-5565.test.tsx +++ b/tests/unit/ui/add-api-key-modal-unsupported-save-5565.test.tsx @@ -25,7 +25,12 @@ function render(props: Record) { const root = createRoot(el); act(() => { root.render( - undefined} onClose={() => {}} {...(props as any)} /> + undefined} + onClose={() => {}} + {...(props as any)} + /> ); }); containers.push({ root, el }); @@ -62,7 +67,10 @@ beforeEach(() => { Promise.resolve({ error: "Provider validation not supported", unsupported: true }), } as Response); } - return Promise.resolve({ ok: true, json: () => Promise.resolve({ valid: true }) } as Response); + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ valid: true }), + } as Response); }) ); }); @@ -76,6 +84,25 @@ afterEach(() => { }); describe("AddApiKeyModal — 'validation not supported' is a non-blocking warning (#5565/#5567)", () => { + it("does not show unsupported validator responses as red save errors after Check cookie", async () => { + const el = render({ provider: "lmarena", providerName: "LMArena" }); + + const apiKeyInput = el.querySelector('input[type="password"]')!; + expect(apiKeyInput).toBeTruthy(); + setInputValue(apiKeyInput, "arena-auth-prod-v1.0=abc; arena-auth-prod-v1.1=def"); + + const checkBtn = Array.from(el.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "Check cookie" + )!; + expect(checkBtn).toBeTruthy(); + act(() => { + checkBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitFor(() => el.textContent?.includes("N/A") ?? false); + expect(el.textContent).not.toContain("Provider validation not supported"); + }); + it("still calls onSave when /validate returns unsupported=true", async () => { const onSave = vi.fn().mockResolvedValue(undefined); const el = render({ provider: "piapi", providerName: "PiAPI", onSave }); diff --git a/tests/unit/validation-badge-unsupported-5442.test.ts b/tests/unit/validation-badge-unsupported-5442.test.ts index d10f024b860f..74385b32cc38 100644 --- a/tests/unit/validation-badge-unsupported-5442.test.ts +++ b/tests/unit/validation-badge-unsupported-5442.test.ts @@ -6,20 +6,32 @@ import assert from "node:assert/strict"; // The Add-API-Key modal only had success/failed states, so it rendered a red // "Invalid" badge for those providers even though the key was saved fine. The // "unsupported" result now maps to a neutral info "N/A" badge, not "Invalid". -const { validationBadgeProps } = await import( - "../../src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts" -); +const { validationBadgeProps } = + await import("../../src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts"); test("#5442 unsupported validation → neutral N/A badge, not red Invalid", () => { assert.deepEqual(validationBadgeProps("unsupported"), { variant: "info", labelKey: "notApplicable", + fallback: "N/A", }); }); test("#5442 success and failed badges are unchanged", () => { - assert.deepEqual(validationBadgeProps("success"), { variant: "success", labelKey: "valid" }); - assert.deepEqual(validationBadgeProps("failed"), { variant: "error", labelKey: "invalid" }); + assert.deepEqual(validationBadgeProps("success"), { + variant: "success", + labelKey: "valid", + fallback: "Valid", + }); + assert.deepEqual(validationBadgeProps("failed"), { + variant: "error", + labelKey: "invalid", + fallback: "Invalid", + }); // Any other/unknown result defaults to the error badge (fail-safe). - assert.deepEqual(validationBadgeProps("whatever"), { variant: "error", labelKey: "invalid" }); + assert.deepEqual(validationBadgeProps("whatever"), { + variant: "error", + labelKey: "invalid", + fallback: "Invalid", + }); }); diff --git a/tests/unit/web-session-credentials.test.ts b/tests/unit/web-session-credentials.test.ts index 366b50296030..250ac5ea399c 100644 --- a/tests/unit/web-session-credentials.test.ts +++ b/tests/unit/web-session-credentials.test.ts @@ -37,22 +37,27 @@ test("web session credential metadata identifies cookie, token, and no-auth prov acceptsFullCookieHeader: false, storageKeys: ["token", "userToken"], }); - // lmarena.ai's real auth cookie is `arena-auth-prod-v1`, not `session` (#3810). - // The session is now split across `arena-auth-prod-v1.0`, `.1`, … (#4271). - assert.deepEqual(webSessionCredentials.getWebSessionCredentialRequirement("lmarena"), { - kind: "cookie", - credentialName: "arena-auth-prod-v1", - placeholder: - "Paste the full Cookie header from lmarena.ai (the session is now split across arena-auth-prod-v1.0, .1, …)", - acceptsFullCookieHeader: true, - storageKeys: [ - "cookie", - "arena-auth-prod-v1", - "arena-auth-prod-v1.0", - "arena-auth-prod-v1.1", - "session", - ], - }); + // Arena (lmarena): assert contract/intent only — do not freeze UX copy. + // #3810 chunk name, #4271 split SSR cookies, full Cookie header paste. + { + const req = webSessionCredentials.getWebSessionCredentialRequirement("lmarena"); + assert.ok(req && req.kind === "cookie"); + assert.equal(req.acceptsFullCookieHeader, true); + assert.equal(req.hintKey, "lmarenaWebCookieHint"); + assert.ok(req.storageKeys.includes("cookie")); + assert.ok(req.storageKeys.includes("arena-auth-prod-v1.0")); + assert.ok(req.storageKeys.includes("arena-auth-prod-v1.1")); + // legacy key retained for already-saved credentials + assert.ok(req.storageKeys.includes("session")); + assert.ok(/full cookie header/i.test(req.credentialName)); + assert.ok(/arena-auth-prod-v1/i.test(req.placeholder)); + // hintFallback is operator copy — may change with CF/reCAPTCHA notes; must still + // steer users to a full header and away from the empty single base cookie. + assert.ok(typeof req.hintFallback === "string" && req.hintFallback.length > 0); + assert.ok(/full cookie header/i.test(req.hintFallback)); + assert.ok(/arena-auth-prod-v1/i.test(req.hintFallback)); + assert.ok(/empty/i.test(req.hintFallback)); + } assert.deepEqual(webSessionCredentials.getWebSessionCredentialRequirement("huggingchat"), { kind: "cookie", credentialName: "full Cookie header (hf-chat + token)", From 9512dbabf295c354dd276137e12ccc6f13a3ef1e Mon Sep 17 00:00:00 2001 From: backryun Date: Fri, 10 Jul 2026 20:27:16 -0300 Subject: [PATCH 2/3] fix(providers): align provider-models-route test fixture + regen provider reference Fold the topaz image-only catalog entry's apiFormat/supportedEndpoints into the local-catalog test fixture (route now tags media-only providers per the lmarena PR's staticModels.ts change), regenerate PROVIDER_REFERENCE.md against the merged release providers.ts, and add the changelog fragment for #6280. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- changelog.d/fixes/6280-lmarena-arena-modernize.md | 1 + docs/reference/PROVIDER_REFERENCE.md | 10 +++++----- tests/unit/provider-models-route.test.ts | 9 ++++++++- 3 files changed, 14 insertions(+), 6 deletions(-) create mode 100644 changelog.d/fixes/6280-lmarena-arena-modernize.md diff --git a/changelog.d/fixes/6280-lmarena-arena-modernize.md b/changelog.d/fixes/6280-lmarena-arena-modernize.md new file mode 100644 index 000000000000..4ba53979bc7e --- /dev/null +++ b/changelog.d/fixes/6280-lmarena-arena-modernize.md @@ -0,0 +1 @@ +- **fix(providers):** modernize the `lmarena` provider for the Arena.ai rebrand — route chat through `arena.ai` create-evaluation with Chrome TLS impersonation, seed a static Direct-chat Text/Search + Image catalog, and keep the `lmarena`/`lma` wire id for back-compat ([#6280](https://github.com/diegosouzapw/OmniRoute/pull/6280)) — thanks @backryun diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index d691719dd2d8..361f69eca20a 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -1,14 +1,14 @@ --- title: "Provider Reference" version: 3.8.47 -lastUpdated: 2026-07-09 +lastUpdated: 2026-07-10 --- # Provider Reference > **Auto-generated** from `src/shared/constants/providers.ts` — do not edit by hand. > Regenerate with: `npm run gen:provider-reference` -> **Last generated:** 2026-07-09 +> **Last generated:** 2026-07-10 Total providers: **248**. See category breakdown below. @@ -31,7 +31,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each --- -## OAuth Providers (21) +## OAuth Providers (22) | ID | Alias | Name | Tags | Website | Notes | |----|-------|------|------|---------|-------| @@ -40,6 +40,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `antigravity` | — | Antigravity | OAuth | — | — | | `claude` | `cc` | Claude Code | OAuth | — | — | | `cline` | `cl` | Cline | OAuth | — | — | +| `clinepass` | `cp` | ClinePass | OAuth | [link](https://cline.bot/clinepass) | ClinePass is Cline's $9.99/mo subscription bundling 10 open coding models. Sign in with your Cline account (same login as the Cline CLI/IDE), or paste a direct ClinePass API key (app.cline.bot → Settings → API Keys). A ClinePass subscription unlocks the cline-pass/* models. Reuses the Cline WorkOS OAuth flow. | | `codebuddy-cn` | `cbcn` | CodeBuddy CN | OAuth | [link](https://copilot.tencent.com) | Tencent CodeBuddy CN (copilot.tencent.com). Sign in via the official CLI device-code flow, or paste a direct API key (sent as Authorization: Bearer). Catalog: GLM / Kimi / MiniMax / DeepSeek / Hunyuan. | | `codex` | `cx` | OpenAI Codex | OAuth | — | — | | `cursor` | `cu` | Cursor IDE | OAuth | — | — | @@ -86,7 +87,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `yuanbao-web` | `ybw` | Tencent Yuanbao (Free) | Web cookie | [link](https://yuanbao.tencent.com) | Log in to yuanbao.tencent.com, then paste the full Cookie header (DevTools → Network → any /api request → Request Headers → Cookie). It must contain hy_user and hy_token. | | `zenmux-free` | `zmf` | ZenMux Free (Web) | Web cookie | [link](https://zenmux.ai) | Login at zenmux.ai, then export all cookies using EditThisCookie or Cookie-Editor and paste the full Cookie header string here. Refresh every ~30 days. | -## API Key Providers (paid / paid-with-free-credits) (167) +## API Key Providers (paid / paid-with-free-credits) (166) | ID | Alias | Name | Tags | Website | Notes | |----|-------|------|------|---------|-------| @@ -117,7 +118,6 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `charm-hyper` | `charm-hyper` | Charm Hyper | API key | [link](https://hyper.charm.land) | 100 free monthly Hypercredits on signup | | `chutes` | `chutes` | Chutes.ai | API key, aggregator | [link](https://chutes.ai) | Bearer API key for the Chutes OpenAI-compatible gateway. | | `clarifai` | `clarifai` | Clarifai | API key, enterprise | [link](https://docs.clarifai.com) | Use your Clarifai PAT or app-specific API key. OmniRoute targets the OpenAI-compatible endpoint at https://api.clarifai.com/v2/ext/openai/v1 and authenticates with Authorization: Key . | -| `clinepass` | `clinepass` | ClinePass | API key | [link](https://cline.bot) | — | | `cloudflare-ai` | `cf` | Cloudflare Workers AI | API key | [link](https://developers.cloudflare.com/workers-ai) | Requires API Token AND Account ID (found at dash.cloudflare.com) | | `codestral` | `codestral` | Codestral | API key | [link](https://mistral.ai) | — | | `cohere` | `cohere` | Cohere | API key | [link](https://cohere.com) | Free Trial: 1,000 API calls/month for testing, no credit card required | diff --git a/tests/unit/provider-models-route.test.ts b/tests/unit/provider-models-route.test.ts index ef507dc21427..5975cb0b99f1 100644 --- a/tests/unit/provider-models-route.test.ts +++ b/tests/unit/provider-models-route.test.ts @@ -540,7 +540,14 @@ test("provider models route returns the local catalog for built-in image provide assert.equal(response.status, 200); assert.equal(body.provider, "topaz"); assert.ok(Array.isArray(body.models)); - assert.deepEqual(body.models, [{ id: "topaz-enhance", name: "topaz-enhance" }]); + assert.deepEqual(body.models, [ + { + id: "topaz-enhance", + name: "topaz-enhance", + apiFormat: "images", + supportedEndpoints: ["images"], + }, + ]); }); test("provider models route prefers the remote OpenRouter /models API over static image models", async () => { From 31db0e6964e256288c09fb0ce545a2df22fe524d Mon Sep 17 00:00:00 2001 From: backryun Date: Sat, 11 Jul 2026 01:04:06 -0300 Subject: [PATCH 3/3] =?UTF-8?q?test:=20align=20web-cookie=20fallback=20sui?= =?UTF-8?q?te=20=E2=80=94=20lmarena=20now=20has=20a=20registry=20entry=20(?= =?UTF-8?q?probe=20path)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- .../web-cookie-validation-fallback.test.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/unit/web-cookie-validation-fallback.test.ts b/tests/unit/web-cookie-validation-fallback.test.ts index 6e1a1d75f750..c2d43736647f 100644 --- a/tests/unit/web-cookie-validation-fallback.test.ts +++ b/tests/unit/web-cookie-validation-fallback.test.ts @@ -45,16 +45,19 @@ test.afterEach(() => { globalThis.fetch = originalFetch; }); -// ── lmarena (no registry entry, falls back to WEB_COOKIE_PROVIDERS) ── +// ── lmarena (#6280: gained a REAL providerRegistry entry — left the fallback class) ── +// Before #6280 lmarena had no registry entry, so this suite asserted the unsupported +// fallback. The Arena modernization added a real registry entry (registry/lmarena/), +// so validateWebCookieProvider now takes the probe path (directHttpsRequest against the +// registry baseUrl — which deliberately bypasses this suite's fetch mock, so the probe +// itself cannot be unit-asserted here). What this suite still guards for lmarena: its +// classification out of the no-registry fallback. -test("lmarena validation is unsupported (no verified auth probe) and makes no network call", async () => { - const result = await validateProviderApiKey({ - provider: "lmarena", - apiKey: "test-lmarena-cookie", - }); - assert.strictEqual(result.valid, false); - assert.equal(result.unsupported, true); - assert.equal(fetchCalls.length, 0, "must not probe the marketing website"); +test("lmarena has a registry entry — no longer the no-registry unsupported fallback", async () => { + const { getRegistryEntry } = await import("@omniroute/open-sse/config/providerRegistry.ts"); + const entry = getRegistryEntry("lmarena"); + assert.ok(entry, "lmarena must have a providerRegistry entry (#6280 Arena modernization)"); + assert.ok(entry.baseUrl, "the probe path requires a real baseUrl on the registry entry"); }); test("lmarena validation rejects empty cookie before checking support", async () => {