From cabbbe410ac79a0b2e9fd86c300c337db67a9a32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Armin=20Anton=E2=80=9D=20=E2=88=B4?= Date: Tue, 1 Sep 2026 21:55:42 -0700 Subject: [PATCH] =?UTF-8?q?feat(providers):=20add=20MaxAI=20=E2=80=94=20si?= =?UTF-8?q?gned=20OpenAI-compatible=20provider=20(chat,=20tools,=20vision,?= =?UTF-8?q?=20image-gen,=20doc-RAG)=20(#11461)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MaxAI joins as a first-class signed provider: 13 chat models discovered live from /models/get_config plus 6 image models, routed through the standard /v1 endpoints with per-request X-Authorization signing, browserless onboarding, prompted tool-calling, vision input, image generation and document RAG. Reconciled on merge — worth reading, because the branch forked 227 commits back and 77 files conflicted. Only five carried MaxAI content; the rest was drift from the older release line and took the tip's side, taking the diff from 113 files to 37 (then 93 as counted against the current base). - executors/index.ts: the tip has since refactored the executor map to lazy dynamic imports, so MaxAI is registered in that shape rather than the branch's static import. - imageRegistry.ts: kept only the maxai block. The branch still carried microsoft-designer-web, which #11754 retired. - models/route.ts: the conflicting hunk was an unrelated Vertex/Anthropic URL change, not MaxAI — tip's side. - volcengine agent-plan/coding-plan registries: git auto-merged both sides and produced a duplicated supportsVision key, which TypeScript rejects (TS1117). Removed. One real integration break that only the combined state shows: the MaxAI entry declared no serviceKinds, which #11392 made required a few hours ago. Provider validation threw at load time and check:provider-consistency crashed outright. Declared ["llm"] — the image kinds derive from imageRegistry, per the convention in that PR's backfill. Every count was measured rather than taken from the branch, and each would have been wrong: reserved prefixes are 402, not the 397 the branch computed from its stale 395 base; providers are 353, not 354. PROVIDER_REFERENCE.md regenerated, the count updated across README/AGENTS.md/llm.txt and its 42 mirrors, package.json and 6 SVGs — every changed line in those files is a digit substitution and nothing else, verified by masking digits and comparing the removed and added sets (90 lines, identical). The executor-map golden snapshot was regenerated: keyCount 133 -> 134. The branch's file-size-baseline.json predates #12411's ratchet re-tightening, so it was discarded rather than merged — taking it would have silently undone that. The three files this PR grows (proxyFetch.ts +20 for the Windows/firefox_150 TLS profile, imageGeneration.ts +12, models/route.ts +48) were entered against the current baseline under one _rebaseline annotation; no other cap moves. Verified: typecheck:core clean, check:provider-consistency OK (269 REGISTRY entries, 353 canonical providers), check:docs-counts exit 0, check-file-size OK, check:cycles OK, and 79/79 across the MaxAI suites plus 21/21 reserved-prefix and 2/2 executor-map-golden. Thanks @arminanton — the provider work itself is thorough; it was the 227 commits of base that needed the attention. --- AGENTS.md | 2 +- README.md | 6 +- changelog.d/features/maxai-provider.md | 6 + config/quality/eslint-suppressions.json | 2 +- config/quality/file-size-baseline.json | 7 +- docs/diagrams/cli-terminal.svg | 2 +- docs/diagrams/comparison-table.svg | 2 +- docs/diagrams/promise-pillars.svg | 6 +- docs/diagrams/readme-hero.svg | 4 +- docs/i18n/ar/llm.txt | 4 +- docs/i18n/az/llm.txt | 4 +- docs/i18n/bg/llm.txt | 4 +- docs/i18n/bn/llm.txt | 4 +- docs/i18n/cs/llm.txt | 4 +- docs/i18n/da/llm.txt | 4 +- docs/i18n/de/llm.txt | 4 +- docs/i18n/es/llm.txt | 4 +- docs/i18n/fa/llm.txt | 4 +- docs/i18n/fi/llm.txt | 4 +- docs/i18n/fr/llm.txt | 4 +- docs/i18n/gu/llm.txt | 4 +- docs/i18n/he/llm.txt | 4 +- docs/i18n/hi/llm.txt | 4 +- docs/i18n/hu/llm.txt | 4 +- docs/i18n/id/llm.txt | 4 +- docs/i18n/in/llm.txt | 4 +- docs/i18n/it/llm.txt | 4 +- docs/i18n/ja/llm.txt | 4 +- docs/i18n/ko/llm.txt | 4 +- docs/i18n/mr/llm.txt | 4 +- docs/i18n/ms/llm.txt | 4 +- docs/i18n/nl/llm.txt | 4 +- docs/i18n/no/llm.txt | 4 +- docs/i18n/phi/llm.txt | 4 +- docs/i18n/pl/llm.txt | 4 +- docs/i18n/pt-BR/llm.txt | 4 +- docs/i18n/pt/llm.txt | 4 +- docs/i18n/ro/llm.txt | 4 +- docs/i18n/ru/llm.txt | 4 +- docs/i18n/sk/llm.txt | 4 +- docs/i18n/sv/llm.txt | 4 +- docs/i18n/sw/llm.txt | 4 +- docs/i18n/ta/llm.txt | 4 +- docs/i18n/te/llm.txt | 4 +- docs/i18n/th/llm.txt | 4 +- docs/i18n/tr/llm.txt | 4 +- docs/i18n/uk-UA/llm.txt | 4 +- docs/i18n/ur/llm.txt | 4 +- docs/i18n/vi/llm.txt | 4 +- docs/i18n/zh-CN/llm.txt | 4 +- docs/i18n/zh-TW/llm.txt | 4 +- docs/reference/PROVIDER_REFERENCE.md | 11 +- llm.txt | 4 +- open-sse/config/imageRegistry.ts | 20 + open-sse/config/providers/index.ts | 2 + .../config/providers/registry/maxai/index.ts | 26 + .../registry/volcengine/agent-plan/index.ts | 2 +- .../registry/volcengine/coding-plan/index.ts | 2 +- open-sse/executors/index.ts | 1 + open-sse/executors/maxai.ts | 620 ++++++++++ open-sse/executors/maxai/catalog.ts | 76 ++ open-sse/executors/maxai/constants.ts | 427 +++++++ open-sse/executors/maxai/constantsStore.ts | 156 +++ open-sse/executors/maxai/credentials.ts | 96 ++ open-sse/executors/maxai/documents.ts | 266 ++++ open-sse/executors/maxai/emailLogin.ts | 234 ++++ open-sse/executors/maxai/protocol.ts | 266 ++++ open-sse/executors/maxai/refresh.ts | 149 +++ open-sse/executors/maxai/signing.ts | 151 +++ open-sse/executors/maxai/stream.ts | 101 ++ open-sse/handlers/imageGeneration.ts | 12 + .../imageGeneration/providers/maxaiImage.ts | 230 ++++ open-sse/services/maxaiModels.ts | 172 +++ open-sse/services/rateLimitManager.ts | 20 +- open-sse/utils/proxyFetch.ts | 20 + package.json | 2 +- public/images/tier-flow-dark.svg | 6 +- public/images/tier-flow-light.svg | 6 +- scripts/build/pack-artifact-policy.ts | 6 + scripts/check/check-provider-assets.mjs | 2 +- src/app/api/providers/[id]/login/route.ts | 145 +++ src/app/api/providers/[id]/models/route.ts | 48 + src/shared/constants/providers/web-cookie.ts | 21 + src/shared/providers/webSessionCredentials.ts | 16 + stryker.conf.json | 2 + tests/snapshots/executors/executor-map.json | 7 +- tests/snapshots/provider/translate-path.json | 23 + tests/unit/helpers/maxaiMockConstants.ts | 122 ++ tests/unit/maxai-documents.test.ts | 218 ++++ tests/unit/maxai-image.test.ts | 169 +++ tests/unit/maxai.test.ts | 1088 +++++++++++++++++ .../provider-node-reserved-prefix.test.ts | 2 +- .../ratelimit-admission-control-6593.test.ts | 12 + 93 files changed, 5042 insertions(+), 122 deletions(-) create mode 100644 changelog.d/features/maxai-provider.md create mode 100644 open-sse/config/providers/registry/maxai/index.ts create mode 100644 open-sse/executors/maxai.ts create mode 100644 open-sse/executors/maxai/catalog.ts create mode 100644 open-sse/executors/maxai/constants.ts create mode 100644 open-sse/executors/maxai/constantsStore.ts create mode 100644 open-sse/executors/maxai/credentials.ts create mode 100644 open-sse/executors/maxai/documents.ts create mode 100644 open-sse/executors/maxai/emailLogin.ts create mode 100644 open-sse/executors/maxai/protocol.ts create mode 100644 open-sse/executors/maxai/refresh.ts create mode 100644 open-sse/executors/maxai/signing.ts create mode 100644 open-sse/executors/maxai/stream.ts create mode 100644 open-sse/handlers/imageGeneration/providers/maxaiImage.ts create mode 100644 open-sse/services/maxaiModels.ts create mode 100644 tests/unit/helpers/maxaiMockConstants.ts create mode 100644 tests/unit/maxai-documents.test.ts create mode 100644 tests/unit/maxai-image.test.ts create mode 100644 tests/unit/maxai.test.ts diff --git a/AGENTS.md b/AGENTS.md index 08adf6d8d3..30c2f8b299 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ Repository map and Reference Documentation sections below. ## Project at a Glance -**OmniRoute** — unified AI proxy/router. One endpoint, 352 LLM providers, auto-fallback. +**OmniRoute** — unified AI proxy/router. One endpoint, 353 LLM providers, auto-fallback. | Layer | Location | Purpose | | ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/README.md b/README.md index 5431c2459f..5d35b64c08 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ # 🚀 OmniRoute — The Free AI Gateway -OmniRoute — Never stop coding. Every AI tool → 352 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 15–95% tokens (~89% avg) — never hit limits. 352 AI providers · 150+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start. +OmniRoute — Never stop coding. Every AI tool → 353 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 15–95% tokens (~89% avg) — never hit limits. 353 AI providers · 150+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start. @@ -210,7 +210,7 @@ curl http://localhost:20128/v1/chat/completions \ -The Promise — One endpoint and 352 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 352 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 53 recurring/keyless free-forever providers · 36 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files. +The Promise — One endpoint and 353 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 353 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 53 recurring/keyless free-forever providers · 36 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files.

@@ -463,7 +463,7 @@ All **19** strategies — mix & match per combo step: -What sets OmniRoute apart — a dated feature snapshot vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 352 providers, 150+ free tiers built in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 43 i18n UI locales. OmniRoute is MIT-licensed and self-hostable. Competitor capabilities and counts may change; see the linked methodology. +What sets OmniRoute apart — a dated feature snapshot vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 353 providers, 150+ free tiers built in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 43 i18n UI locales. OmniRoute is MIT-licensed and self-hostable. Competitor capabilities and counts may change; see the linked methodology. 📊 Full methodology & per-feature detail vs 9router, OpenRouter, CLIProxyAPI & LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md) diff --git a/changelog.d/features/maxai-provider.md b/changelog.d/features/maxai-provider.md new file mode 100644 index 0000000000..4e74854b27 --- /dev/null +++ b/changelog.d/features/maxai-provider.md @@ -0,0 +1,6 @@ +- **feat(providers):** add MaxAI as a signed, OpenAI-compatible provider serving its 13 paid chat models (GPT-5.6 / Luna / Thinking, Claude 5 Sonnet, Claude Haiku 4.5, Gemini 3.1 Pro / Flash-Lite, Grok 4.1-fast / 4.5, DeepSeek V3.2 / R1, Llama 3.3 70B) through OmniRoute's `/v1` endpoint, with per-request HMAC-SHA1→SM3→AES request signing, live model + context-window discovery from `/models/get_config`, and prompted tool-calling translated to OpenAI `tool_calls` +- **feat(providers):** MaxAI vision input — image_url content parts are forwarded inline in `message_content` to the 6 vision-capable models (GPT-5.6 / Luna / Thinking, Claude Haiku 4.5, Gemini 3.1 Pro / Flash-Lite) +- **feat(providers):** MaxAI image generation — 6 image models (gpt-image-1, dall-e-3, flux-1-schnell/dev/pro, sd3-medium) exposed through `POST /v1/images/generations` +- **feat(providers):** MaxAI document RAG — inline base64 file/document attachments are uploaded to MaxAI (content-addressed `doc_id`) and attached to the chat via `doc_list` +- **feat(providers):** browserless MaxAI onboarding — email device-pair login (`/api/providers/[id]/login`) and signed access-token refresh, so a connection can be created and kept fresh without a real browser or Google OAuth +- **feat(providers):** per-provider TLS impersonation profile (MaxAI presents a Windows Firefox-150 client fingerprint) so its bot-sensitive endpoints accept OmniRoute traffic diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 8250ba6936..c7fd7e1b9a 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -3373,7 +3373,7 @@ }, "tests/unit/combo-routing-engine.test.ts": { "@typescript-eslint/no-explicit-any": { - "count": 267 + "count": 268 } }, "tests/unit/combo-same-provider-cascade.test.ts": { diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 3a75185a68..04342aff2d 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,5 @@ { + "_rebaseline_2026_09_02_11461_maxai_tls_profile": "PR #11461 (arminanton, feat/maxai-provider) own growth, three files at existing per-provider chokepoints: open-sse/utils/proxyFetch.ts 1241->1261 (+20, the TLS_PROVIDER_PROFILE map giving MaxAI a Windows/firefox_150 impersonation profile instead of the tlsClient chrome_124/macos default); open-sse/handlers/imageGeneration.ts 3231->3243 (+12, the maxai-image format branch); src/app/api/providers/[id]/models/route.ts 2381->2429 (+48, live model listing via maxaiModels). Additive data, same no-split rationale as _rebaseline_2026_08_20_10531_freebuff_provider.", "_rebaseline_2026_09_02_11460_flat_rate_estimates": "PR #11460 (xiaoyaner0201, fix/11459-cc-cost-estimates) own growth: src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx 1283->1319 (+36) — the flat-rate estimate labelling and the includeFlatRateEstimates opt-in on the Costs dashboard. #11460 merged first so this ratchet re-tightening measures the real post-merge LOC; the cap still drops 2002->1319 (-683) versus the 2026-08-10 +30% loosening this PR reverses. Same own-growth rationale as _rebaseline_2026_08_20_10531_freebuff_provider.", "_rebaseline_2026_08_31_chatgpt_web_v4_vendor": "Pinned MIT vendor refresh from codex-chatgpt-web 0.1.16 to v4.0.6 (commit 09877fa21ffdbf20979623ef501046fc02a750d7). browser-worker.ts is preserved as the reviewed upstream browser protocol implementation; splitting the vendored file would destroy source parity and make future security/liveness updates unauditable. OmniRoute-specific DATA_DIR, Docker CDP, credential-marker, and XML decoding adaptations are covered by the ChatGPT Web Codex focused suite.", "_rebaseline_2026_08_20_10531_freebuff_provider": "PR #10531 (adrianaryaputra, feat/freebuff-provider-support, closes #6793) own growth: src/shared/constants/providers/apikey/gateways.ts 1283->1298 (+15, the freebuff APIKEY_PROVIDERS_GATEWAYS catalog entry, additive data at the existing registry chokepoint, same god-file no-split rationale as prior gateways.ts rebaselines) and src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx 1062->1067 (+5, freebuff credential placeholder/hint at the existing per-provider switch chokepoint). Covered by tests/unit/freebuff-provider.test.ts (9/9 passing).", @@ -406,7 +407,7 @@ "open-sse/executors/cursor.ts": 1759, "open-sse/executors/muse-spark-web.ts": 1405, "open-sse/handlers/chatCore.ts": 5946, - "open-sse/handlers/imageGeneration.ts": 3231, + "open-sse/handlers/imageGeneration.ts": 3243, "open-sse/handlers/search.ts": 1789, "open-sse/mcp-server/schemas/tools.ts": 1621, "open-sse/mcp-server/server.ts": 1572, @@ -415,7 +416,7 @@ "open-sse/services/combo.ts": 4023, "open-sse/translator/response/openai-responses.ts": 1466, "open-sse/utils/cursorAgentProtobuf.ts": 1547, - "open-sse/utils/proxyFetch.ts": 1241, + "open-sse/utils/proxyFetch.ts": 1261, "open-sse/utils/stream.ts": 3072, "open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/browser-worker.ts": 4398, "open-sse/vendor/codex-chatgpt-web/bridge.ts": 1322, @@ -432,7 +433,7 @@ "src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 1606, "src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1597, "src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2152, - "src/app/api/providers/[id]/models/route.ts": 2381, + "src/app/api/providers/[id]/models/route.ts": 2429, "src/app/api/providers/[id]/test/route.ts": 1252, "src/app/api/v1/models/catalog.ts": 2066, "src/app/docs/lib/openapi.generated.ts": 1347, diff --git a/docs/diagrams/cli-terminal.svg b/docs/diagrams/cli-terminal.svg index 41023d868d..92cb473457 100644 --- a/docs/diagrams/cli-terminal.svg +++ b/docs/diagrams/cli-terminal.svg @@ -1,4 +1,4 @@ - + Compact animated terminal cycling three real OmniRoute CLI commands with a typewriter effect and a scrolling subcommand ticker; the first frame shows the completed providers-list screen. diff --git a/docs/diagrams/comparison-table.svg b/docs/diagrams/comparison-table.svg index bfdc3ea240..71992cc04a 100644 --- a/docs/diagrams/comparison-table.svg +++ b/docs/diagrams/comparison-table.svg @@ -1,4 +1,4 @@ - + Static-header comparison table where each capability row fades in top to bottom; the OmniRoute column is highlighted and shows a check or a leading value in every row, while competitors show a mix of checks, partials and crosses. diff --git a/docs/diagrams/promise-pillars.svg b/docs/diagrams/promise-pillars.svg index 6e037e5098..9466b0c859 100644 --- a/docs/diagrams/promise-pillars.svg +++ b/docs/diagrams/promise-pillars.svg @@ -1,4 +1,4 @@ - + Animated promise card: six pillar tiles fade in in reading order, then a soft colored border highlight sweeps from tile to tile in a continuous cycle. @@ -21,7 +21,7 @@ - One endpoint. 352 providers. Never stop building — OmniRoute picks the cheapest one that works. + One endpoint. 353 providers. Never stop building — OmniRoute picks the cheapest one that works. @@ -38,7 +38,7 @@ Never hit limits - Auto-fallback across 352 providers in + Auto-fallback across 353 providers in milliseconds. Quota out? The next provider takes over while a healthy target remains. diff --git a/docs/diagrams/readme-hero.svg b/docs/diagrams/readme-hero.svg index 1c49b21b2e..6d4c7ba9bf 100644 --- a/docs/diagrams/readme-hero.svg +++ b/docs/diagrams/readme-hero.svg @@ -1,4 +1,4 @@ - + Animated hero card: a pulse travels the divider line and a compression bar demo repeatedly shrinks a prompt by up to 95 percent; all headline content is static and readable on the first frame. @@ -28,7 +28,7 @@ Never stop coding. - Every AI tool → 352 providers150+ free — through one endpoint. + Every AI tool → 353 providers150+ free — through one endpoint. Claude Code · Codex · Cursor · Cline · Copilot · Antigravity  →  FREE Claude / GPT / Gemini · auto-fallback diff --git a/docs/i18n/ar/llm.txt b/docs/i18n/ar/llm.txt index 548eefbf3b..c1e7abe59c 100644 --- a/docs/i18n/ar/llm.txt +++ b/docs/i18n/ar/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/az/llm.txt b/docs/i18n/az/llm.txt index 6bc2a099db..5ef9e5f2fe 100644 --- a/docs/i18n/az/llm.txt +++ b/docs/i18n/az/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/bg/llm.txt b/docs/i18n/bg/llm.txt index 6bc2a099db..5ef9e5f2fe 100644 --- a/docs/i18n/bg/llm.txt +++ b/docs/i18n/bg/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/bn/llm.txt b/docs/i18n/bn/llm.txt index 90e06508b6..ccdb9b8013 100644 --- a/docs/i18n/bn/llm.txt +++ b/docs/i18n/bn/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/cs/llm.txt b/docs/i18n/cs/llm.txt index d954459b13..ac38750608 100644 --- a/docs/i18n/cs/llm.txt +++ b/docs/i18n/cs/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/da/llm.txt b/docs/i18n/da/llm.txt index 9230d437d3..b4653489b6 100644 --- a/docs/i18n/da/llm.txt +++ b/docs/i18n/da/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/de/llm.txt b/docs/i18n/de/llm.txt index 3c7b5b303a..88b776ad66 100644 --- a/docs/i18n/de/llm.txt +++ b/docs/i18n/de/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/es/llm.txt b/docs/i18n/es/llm.txt index a16e64035c..cef74db964 100644 --- a/docs/i18n/es/llm.txt +++ b/docs/i18n/es/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/fa/llm.txt b/docs/i18n/fa/llm.txt index 00d95daeae..651c65dcd0 100644 --- a/docs/i18n/fa/llm.txt +++ b/docs/i18n/fa/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/fi/llm.txt b/docs/i18n/fi/llm.txt index 5c59495a6a..fa001b3f5b 100644 --- a/docs/i18n/fi/llm.txt +++ b/docs/i18n/fi/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/fr/llm.txt b/docs/i18n/fr/llm.txt index a285637e33..98bbf3cffe 100644 --- a/docs/i18n/fr/llm.txt +++ b/docs/i18n/fr/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/gu/llm.txt b/docs/i18n/gu/llm.txt index 34d83e829b..d6b23b4a6f 100644 --- a/docs/i18n/gu/llm.txt +++ b/docs/i18n/gu/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/he/llm.txt b/docs/i18n/he/llm.txt index 416da4c84b..34fdbd6c37 100644 --- a/docs/i18n/he/llm.txt +++ b/docs/i18n/he/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/hi/llm.txt b/docs/i18n/hi/llm.txt index 9393dd87eb..e9330bcbc1 100644 --- a/docs/i18n/hi/llm.txt +++ b/docs/i18n/hi/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/hu/llm.txt b/docs/i18n/hu/llm.txt index 20f32976c5..c2e73a6d51 100644 --- a/docs/i18n/hu/llm.txt +++ b/docs/i18n/hu/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/id/llm.txt b/docs/i18n/id/llm.txt index ea80bb4578..339089ffa0 100644 --- a/docs/i18n/id/llm.txt +++ b/docs/i18n/id/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/in/llm.txt b/docs/i18n/in/llm.txt index 8033e3fa82..9d403264be 100644 --- a/docs/i18n/in/llm.txt +++ b/docs/i18n/in/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/it/llm.txt b/docs/i18n/it/llm.txt index e2df9cc115..2b99808639 100644 --- a/docs/i18n/it/llm.txt +++ b/docs/i18n/it/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ja/llm.txt b/docs/i18n/ja/llm.txt index ab95c8bc0c..cd750e07fd 100644 --- a/docs/i18n/ja/llm.txt +++ b/docs/i18n/ja/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ko/llm.txt b/docs/i18n/ko/llm.txt index 9658b42bb0..fa37857775 100644 --- a/docs/i18n/ko/llm.txt +++ b/docs/i18n/ko/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/mr/llm.txt b/docs/i18n/mr/llm.txt index 48a94b897c..15c7b22545 100644 --- a/docs/i18n/mr/llm.txt +++ b/docs/i18n/mr/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ms/llm.txt b/docs/i18n/ms/llm.txt index c7dc286a2f..0671482309 100644 --- a/docs/i18n/ms/llm.txt +++ b/docs/i18n/ms/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/nl/llm.txt b/docs/i18n/nl/llm.txt index 8cab222517..7a2b769983 100644 --- a/docs/i18n/nl/llm.txt +++ b/docs/i18n/nl/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/no/llm.txt b/docs/i18n/no/llm.txt index b209c8c81e..b4a5eb0f44 100644 --- a/docs/i18n/no/llm.txt +++ b/docs/i18n/no/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/phi/llm.txt b/docs/i18n/phi/llm.txt index cda4f3ec19..6286537b20 100644 --- a/docs/i18n/phi/llm.txt +++ b/docs/i18n/phi/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/pl/llm.txt b/docs/i18n/pl/llm.txt index f2c24807ba..6d4bd07b83 100644 --- a/docs/i18n/pl/llm.txt +++ b/docs/i18n/pl/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/pt-BR/llm.txt b/docs/i18n/pt-BR/llm.txt index 1d0e7c7572..d64cc39343 100644 --- a/docs/i18n/pt-BR/llm.txt +++ b/docs/i18n/pt-BR/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/pt/llm.txt b/docs/i18n/pt/llm.txt index ed8d0f33b5..3d6d434382 100644 --- a/docs/i18n/pt/llm.txt +++ b/docs/i18n/pt/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ro/llm.txt b/docs/i18n/ro/llm.txt index 945d07ef04..b99a4536cb 100644 --- a/docs/i18n/ro/llm.txt +++ b/docs/i18n/ro/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ru/llm.txt b/docs/i18n/ru/llm.txt index 795d39530a..6d3e7c07c9 100644 --- a/docs/i18n/ru/llm.txt +++ b/docs/i18n/ru/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/sk/llm.txt b/docs/i18n/sk/llm.txt index a96f49dbc5..722056455f 100644 --- a/docs/i18n/sk/llm.txt +++ b/docs/i18n/sk/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/sv/llm.txt b/docs/i18n/sv/llm.txt index 5270a3acf4..56f8047049 100644 --- a/docs/i18n/sv/llm.txt +++ b/docs/i18n/sv/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/sw/llm.txt b/docs/i18n/sw/llm.txt index 166ecff735..c72f695bde 100644 --- a/docs/i18n/sw/llm.txt +++ b/docs/i18n/sw/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ta/llm.txt b/docs/i18n/ta/llm.txt index 3996aa166c..47fb44c802 100644 --- a/docs/i18n/ta/llm.txt +++ b/docs/i18n/ta/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/te/llm.txt b/docs/i18n/te/llm.txt index c0a319e444..86c6e44622 100644 --- a/docs/i18n/te/llm.txt +++ b/docs/i18n/te/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/th/llm.txt b/docs/i18n/th/llm.txt index 068975ff6a..3ee4254f1c 100644 --- a/docs/i18n/th/llm.txt +++ b/docs/i18n/th/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/tr/llm.txt b/docs/i18n/tr/llm.txt index 0ff720ab5b..538a1d9cc3 100644 --- a/docs/i18n/tr/llm.txt +++ b/docs/i18n/tr/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/uk-UA/llm.txt b/docs/i18n/uk-UA/llm.txt index 7030db01be..21b67bfc86 100644 --- a/docs/i18n/uk-UA/llm.txt +++ b/docs/i18n/uk-UA/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/ur/llm.txt b/docs/i18n/ur/llm.txt index ee39faa930..0f881c7b4b 100644 --- a/docs/i18n/ur/llm.txt +++ b/docs/i18n/ur/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/vi/llm.txt b/docs/i18n/vi/llm.txt index cc75542ed9..6a0ec2cdf1 100644 --- a/docs/i18n/vi/llm.txt +++ b/docs/i18n/vi/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/zh-CN/llm.txt b/docs/i18n/zh-CN/llm.txt index 969e3af8b1..6953a90999 100644 --- a/docs/i18n/zh-CN/llm.txt +++ b/docs/i18n/zh-CN/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/i18n/zh-TW/llm.txt b/docs/i18n/zh-TW/llm.txt index c1275ba043..e081e5c730 100644 --- a/docs/i18n/zh-TW/llm.txt +++ b/docs/i18n/zh-TW/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index 99d2a4f39d..92b0e82373 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -1,16 +1,16 @@ --- title: "Provider Reference" version: 3.8.51 -lastUpdated: 2026-08-30 +lastUpdated: 2026-09-02 --- # 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-08-30 +> **Last generated:** 2026-09-02 -Total providers: **352**. See category breakdown below. +Total providers: **353**. See category breakdown below. ## Categories @@ -80,7 +80,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `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 (31) +## Web Cookie Providers (32) | ID | Alias | Name | Tags | Website | Notes | Tool calling | |----|-------|------|------|---------|-------|--------------| @@ -102,6 +102,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `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 | emulated | | `kimi-web` | `kimi-web` | Kimi Web | Web cookie | [link](https://www.kimi.ai) | Paste access_token from www.kimi.ai DevTools → Application → Local Storage. A legacy kimi-auth cookie is also accepted. | — | | `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. | — | +| `maxai` | `mx` | MaxAI | Web cookie | [link](https://www.maxai.co) | Sign in once (email code or browser) to mint a MaxAI access token. OmniRoute signs each request, routes it through residential egress, and refreshes the token browserlessly, so a connection stays valid for about a year without re-login. | emulated | | `muse-spark-web` | `ms-web` | Muse Spark Web (Meta AI) | Web cookie | [link](https://www.meta.ai) | Paste your ecto_1_sess cookie AND the ecto1:... WS auth token from meta.ai. Capture the ecto1: token in DevTools → Network → WS → the clippy request's Authorization query param. Example: ecto_1_sess=4240a308...NVDg0; ecto1:ABCD... | emulated | | `notion-web` | `nw` | Notion AI Web (Unofficial/Experimental) | Web cookie | [link](https://www.notion.so) | Paste only the token_v2 cookie VALUE from app.notion.com (DevTools → Application → Cookies → token_v2). Do not paste token_v2= or the full Cookie header. Workspace is auto-detected; space_id / notion_user_id are optional. | — | | `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 | emulated | @@ -440,7 +441,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each - Catalog: [`src/shared/constants/providers.ts`](../../src/shared/constants/providers.ts) - Registry (per-model details): [`open-sse/config/providerRegistry.ts`](../../open-sse/config/providerRegistry.ts) -- Executors: [`open-sse/executors/`](../../open-sse/executors/) (104 implementations) +- Executors: [`open-sse/executors/`](../../open-sse/executors/) (107 implementations) - Translators: [`open-sse/translator/`](../../open-sse/translator/) ## See Also diff --git a/llm.txt b/llm.txt index 9c60de9919..907184c88a 100644 --- a/llm.txt +++ b/llm.txt @@ -1,6 +1,6 @@ # OmniRoute -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 352 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 353 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -277,7 +277,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **352 AI providers** with automatic format translation +- **353 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index 85531b1f84..8af67947ce 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -256,6 +256,26 @@ export const IMAGE_PROVIDERS: Record = { supportedSizes: ["1024x1024", "1024x1792", "1792x1024", "1024x1536", "1536x1024"], }, + maxai: { + id: "maxai", + alias: "mx", + baseUrl: "https://api.maxai.me/gpt/get_image_generate_response", + authType: "apikey", + authHeader: "bearer", + format: "maxai-image", + models: [ + { id: "gpt-image-1", name: "GPT Image 1 (MaxAI)" }, + { id: "dall-e-3", name: "DALL-E 3 (MaxAI)" }, + { id: "flux-1-schnell", name: "FLUX.1 [schnell] (MaxAI)" }, + { id: "flux-1-dev", name: "FLUX.1 [dev] (MaxAI)" }, + { id: "flux-1-pro", name: "FLUX.1 [pro] (MaxAI)" }, + { id: "sd3-medium", name: "Stable Diffusion 3 Medium (MaxAI)" }, + ], + // gpt-image-1/dall-e-3 are size-snapped to 1024x1024 by the handler; flux + // models pass any size through. + supportedSizes: ["1024x1024", "1024x1536", "1536x1024", "1024x1792", "1792x1024"], + }, + xai: { id: "xai", baseUrl: "https://api.x.ai/v1/images/generations", diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index cc8ec3a703..b3cf60b463 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -210,6 +210,7 @@ import { pollinationsProvider } from "./registry/pollinations/index.ts"; import { veoaifree_webProvider } from "./registry/veoaifree-web/index.ts"; import { codexProvider } from "./registry/codex/index.ts"; import { codexAppServerProvider } from "./registry/codex-app-server/index.ts"; +import { maxaiProvider } from "./registry/maxai/index.ts"; import { veniceProvider } from "./registry/venice/index.ts"; import { kiroProvider } from "./registry/kiro/index.ts"; import { openadapterProvider } from "./registry/openadapter/index.ts"; @@ -477,6 +478,7 @@ export const REGISTRY: Record = { "veoaifree-web": veoaifree_webProvider, codex: codexProvider, "codex-app-server": codexAppServerProvider, + maxai: maxaiProvider, venice: veniceProvider, kiro: kiroProvider, byteplus: byteplusProvider, diff --git a/open-sse/config/providers/registry/maxai/index.ts b/open-sse/config/providers/registry/maxai/index.ts new file mode 100644 index 0000000000..d35a023e49 --- /dev/null +++ b/open-sse/config/providers/registry/maxai/index.ts @@ -0,0 +1,26 @@ +import type { RegistryEntry } from "../../shared.ts"; +import { MAXAI_REGISTRY_MODELS } from "../../../../executors/maxai/catalog.ts"; + +/** + * MaxAI — the MaxAI web app (chat.maxai.co / api.maxai.me) as an OpenAI-compatible + * provider. A signed web-app port (like zai-web): each request carries a + * per-request `X-Authorization` signature + a Bearer access token minted by the + * browser-mint flow. Runs over residential egress with a Firefox TLS fingerprint. + * + * authType `apikey`/authHeader `bearer`: the OpenAI-style access token is stored + * on the connection and replayed as `Authorization: Bearer`; the device id + + * user id ride in providerSpecificData and are folded into the signature. The + * token is refreshed out-of-band by the browser-mint (the `/oauth` refresh + * endpoint is deep-TLS-gated), so there is no central token-refresh case. + */ +export const maxaiProvider: RegistryEntry = { + id: "maxai", + alias: "mx", + format: "openai", + executor: "maxai", + baseUrl: "https://api.maxai.me", + authType: "apikey", + authHeader: "bearer", + defaultContextLength: 128000, + models: MAXAI_REGISTRY_MODELS, +}; diff --git a/open-sse/config/providers/registry/volcengine/agent-plan/index.ts b/open-sse/config/providers/registry/volcengine/agent-plan/index.ts index 3ecae6aa08..6604844efd 100644 --- a/open-sse/config/providers/registry/volcengine/agent-plan/index.ts +++ b/open-sse/config/providers/registry/volcengine/agent-plan/index.ts @@ -78,8 +78,8 @@ export const VOLCENGINE_AGENT_PLAN_MODELS: RegistryModel[] = [ name: "MiniMax M3 (Agent Plan)", contextLength: 1048576, toolCalling: true, - supportsReasoning: true, supportsVision: true, + supportsReasoning: true, }, { id: "deepseek-v4-pro-260425", diff --git a/open-sse/config/providers/registry/volcengine/coding-plan/index.ts b/open-sse/config/providers/registry/volcengine/coding-plan/index.ts index f4864c9b75..49c2788b7d 100644 --- a/open-sse/config/providers/registry/volcengine/coding-plan/index.ts +++ b/open-sse/config/providers/registry/volcengine/coding-plan/index.ts @@ -54,8 +54,8 @@ export const VOLCENGINE_CODING_PLAN_MODELS: RegistryModel[] = [ name: "MiniMax M3 (Coding Plan)", contextLength: 1048576, toolCalling: true, - supportsReasoning: true, supportsVision: true, + supportsReasoning: true, }, { id: "deepseek-v4-pro", diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index fbe9940d80..c33ca48839 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -44,6 +44,7 @@ const lazyExecutors: Record Promise> = { import("./codex-app-server.ts").then( (m) => new m.CodexAppServerExecutor({}, "codex-app-server") ), + maxai: () => import("./maxai.ts").then((m) => new m.MaxAiExecutor()), "chatgpt-web-codex": () => import("./chatgpt-web-codex.ts").then((m) => new m.ChatGptWebCodexExecutor()), "cgpt-codex": () => diff --git a/open-sse/executors/maxai.ts b/open-sse/executors/maxai.ts new file mode 100644 index 0000000000..5a9ac60197 --- /dev/null +++ b/open-sse/executors/maxai.ts @@ -0,0 +1,620 @@ +/** + * MaxAiExecutor — MaxAI web-app chat as an OpenAI-compatible OmniRoute provider. + * + * MaxAI (chat.maxai.co / api.maxai.me) is a consumer web app with no public API. + * This executor reproduces the web app's own signed request to `/gpt/cwc/chat`: + * • per-request `X-Authorization` signature (see ./signing.ts), + * • Firefox-150 identity headers + Bearer access token, + * • the full OpenAI transcript flattened into one `message_content` block + * (stateless-full-history; see ./protocol.ts), + * • SSE response parsed for text deltas, with inline `` reasoning split + * out into `reasoning_content` (see ./stream.ts). + * + * Egress + TLS: the request MUST exit a residential IP (MaxAI bot-bans datacenter + * IPs). OmniRoute routes the executor's `fetch()` through the per-connection proxy + * (a residential HTTP proxy) transparently, and applies the wreq-js Firefox TLS + * fingerprint when enabled. This executor does not open its own socket; it uses + * the ambient patched `fetch`, so the proxy + TLS overlay apply automatically. + * + * Auth refresh: MaxAI's `/oauth/refresh_access_token` is deep-TLS-gated and cannot + * be called by any HTTP client (only a real browser passes). The access token is + * therefore minted/refreshed out-of-band by OmniRoute's own browser-mint flow + * (see maxaiBrowserLogin); this executor only consumes the stored credential. + */ +import { BaseExecutor, type ExecuteInput, type ExecutorExecuteResult } from "./base.ts"; +import { PROVIDERS } from "../config/constants.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; +import { resolveMaxaiCredential, type MaxaiCredential } from "./maxai/credentials.ts"; +import { buildMaxaiSignedHeaders } from "./maxai/signing.ts"; +import { ensureMaxaiConstants } from "./maxai/constantsStore.ts"; +import { maxaiAccessTokenNeedsRefresh, maxaiRefreshAccessToken } from "./maxai/refresh.ts"; +import { + assembleMaxaiContext, + buildMaxaiChatBody, + extractCurrentTurnImages, + MAXAI_BASE_URL, + MAXAI_CHAT_PATH, + maxaiStaticHeaders, + newConversationId, +} from "./maxai/protocol.ts"; +import { resolveMaxaiDocList, type MaxaiDocListEntry } from "./maxai/documents.ts"; +import { estimateMaxaiTokens, isMaxaiTextFrame, ThinkSplitter } from "./maxai/stream.ts"; +import { prepareToolMessages, parseToolCallsFromText } from "../translator/webTools.ts"; +import { buildToolModeResponse } from "./chatgptWebTools.ts"; + +const JSON_HEADERS = { "Content-Type": "application/json" }; +const SSE_HEADERS = { + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "Content-Type": "text/event-stream; charset=utf-8", +}; + +interface OpenAiChatBody { + messages?: Array<{ + role?: string; + content?: unknown; + tool_calls?: unknown; + tool_call_id?: string; + }>; + model?: string; +} + +function errorResponse(status: number, message: string, code: string): Response { + return new Response( + JSON.stringify({ + error: { + code, + message: sanitizeErrorMessage(message), + type: status >= 500 ? "provider_error" : "invalid_request_error", + }, + }), + { status, headers: JSON_HEADERS } + ); +} + +/** + * Wrap a Response into the executor wrapper contract shape + * `{response, url, headers, transformedBody}` that `chatCore.ts` and the + * web-cookie/noauth sweep (tests/unit/executor-web-cookie-sweep.test.ts) + * require. `headers` and `transformedBody` are the ACTUAL upstream request + * headers and body — chatCore surfaces them as the provider-request-capture + * ("what we actually sent") in the dashboard and uses the body for service-tier + * and prompt-cache metadata (chatCore.ts:3680-3688), mirroring the shape returned + * by every web-cookie sibling (venice-web.ts:92-94, poe-web.ts:121-123). Error + * paths that fail BEFORE a request is assembled pass no capture — honestly empty, + * because nothing was sent upstream. + */ +function wrap( + response: Response, + url: string, + capture?: { headers?: Record; transformedBody?: unknown } +): { response: Response; url: string; headers: Record; transformedBody: unknown } { + return { + response, + url, + headers: capture?.headers ?? {}, + transformedBody: capture?.transformedBody ?? null, + }; +} + +/** + * Detect a tool "narration miss": the model produced no parseable block + * but its text shows it was ABOUT to call a tool (talks about the block + * or names a requested tool). This is the occasional reasoning-model failure + * mode (e.g. deepseek-r1) where it reasons about the call instead of emitting + * it. A true refusal or a normal answer returns false, so we never retry those. + */ +function isToolNarrationMiss(text: string, requestedTools: unknown): boolean { + if (!text) return false; + if (/) + .map((t) => (typeof t?.function?.name === "string" ? t.function.name : "")) + .filter(Boolean) + : []; + // Names it a tool AND signals intent to use it (not merely mentioning it). + const intent = /\b(I('| wi)ll|let me|I can|going to|need to)\b/i.test(text); + return intent && names.some((n) => text.includes(n)); +} + +/** A short, soft nudge appended to the transcript for the single retry turn. */ +function toolNudge(originalText: string): string { + return ( + originalText + + "\n\n[A quick note: if a client tool would help answer this, please go ahead " + + "and emit the block directly rather than describing it — just the block " + + "on its own line. If no tool is needed, a normal answer is perfectly fine.]" + ); +} + +/** Emit one OpenAI `chat.completion.chunk`. */ +function chunk( + controller: ReadableStreamDefaultController, + id: string, + created: number, + model: string, + delta: Record, + finish: string | null = null +): void { + const payload = { + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta, finish_reason: finish }], + }; + controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n\n`)); +} + +export class MaxAiExecutor extends BaseExecutor { + constructor() { + super("maxai", PROVIDERS.maxai ?? { id: "maxai", baseUrl: MAXAI_BASE_URL }); + } + + override async execute(input: ExecuteInput): Promise { + // The MaxAI chat endpoint URL is the wrapper's `url` for every return path + // (error and success alike), so define it once up front. + const url = MAXAI_BASE_URL + MAXAI_CHAT_PATH; + + const cred = resolveMaxaiCredential( + input.credentials?.providerSpecificData, + input.credentials?.accessToken + ); + if (!cred) { + return wrap( + errorResponse( + 401, + "MaxAI connection is not configured (missing access token, device id, or user id). Sign in to mint a token.", + "maxai_unconfigured" + ), + url + ); + } + + // Proactively refresh a near-expiry access token (browserless; see ./maxai/refresh.ts). + // Failures here are non-fatal: we fall through with the existing token, and a + // genuinely-dead token surfaces as a 401/418 below (prompting a re-mint). + const accessToken = await this.ensureFreshAccess(cred, input); + + const body = (input.body ?? {}) as OpenAiChatBody; + + // Tool-calling (prompted protocol): when the request carries tools[], inject + // the contract into the messages so the model learns the client tools + // and how to invoke them (see translator/webTools.ts). MaxAI has no native + // function-calling; this is the same prompted-tool shim the web-cookie + // providers use. The response side parses blocks back into tool_calls. + const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages( + body as Record, + (body.messages ?? []) as Array<{ role: string; content: unknown }> + ); + + let text: string; + try { + text = assembleMaxaiContext(effectiveMessages); + } catch { + return wrap( + errorResponse(400, "No user message to send to MaxAI.", "maxai_empty_request"), + url + ); + } + + // Vision input: attach the CURRENT user turn's images (data: / http(s):) to + // message_content so vision-capable MaxAI models actually see them. Extract + // from the original messages (pre-tool-munging); text stays flattened. + const originalMessages = (body.messages ?? []) as Array<{ role?: string; content?: unknown }>; + const imageUrls = extractCurrentTurnImages(originalMessages); + + // Doc-RAG: upload any inline documents (base64 file/input_file/document + // parts) on the current turn to /app/upload_document and attach the + // resulting doc_list to the chat body. Best-effort: upload failures are + // skipped and the chat proceeds without the doc. + let docList: MaxaiDocListEntry[] = []; + try { + docList = await resolveMaxaiDocList( + originalMessages, + { accessToken, userId: cred.userId, deviceId: cred.deviceId }, + { signal: input.signal ?? undefined } + ); + } catch { + docList = []; + } + + const constants = await ensureMaxaiConstants({ signal: input.signal }); + if (!constants) { + return wrap( + errorResponse( + 401, + "MaxAI signing constants unavailable (extraction failed); cannot sign the request.", + "maxai_auth_error" + ), + url + ); + } + + const conversationId = newConversationId(); + const chatBody = buildMaxaiChatBody({ + conversationId, + text, + modelName: input.model, + appVersion: constants.appVersion, + imageUrls, + docList: docList.length ? docList : undefined, + }); + + const signedHeaders = buildMaxaiSignedHeaders( + { + path: MAXAI_CHAT_PATH, + userId: cred.userId, + deviceId: cred.deviceId, + }, + constants + ); + const headers: Record = { + ...maxaiStaticHeaders(), + ...signedHeaders, + Authorization: `Bearer ${accessToken}`, + ...(input.upstreamExtraHeaders ?? {}), + }; + + let upstream: Response; + try { + upstream = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(chatBody), + signal: input.signal ?? undefined, + }); + } catch (err) { + return wrap( + errorResponse( + 502, + `MaxAI request failed: ${sanitizeErrorMessage(err instanceof Error ? err.message : err)}`, + "maxai_transport_error" + ), + url + ); + } + + if (upstream.status !== 200 || !upstream.body) { + const detail = await upstream.text().catch(() => ""); + // 401/418 = auth expired/masked-reject; surface so the caller can prompt a re-mint. + // A body-too-large rejection (MaxAI answers 422 "...message you submitted being + // too long...") is INPUT-bound: classify it as context_length_exceeded so + // OmniRoute's compression/overflow pipeline can shrink and retry instead of + // treating it as an opaque provider error. + const tooLong = /too\s+long|exceeds?\b.*\bcontext|context.*(?:exceeded|too long|limit)/i.test( + detail + ); + if (tooLong) { + return wrap( + errorResponse( + 400, + `MaxAI request exceeds the context limit: ${sanitizeErrorMessage(detail.slice(0, 200))}`, + "context_length_exceeded" + ), + url + ); + } + const status = upstream.status === 418 ? 401 : upstream.status || 502; + return wrap( + errorResponse( + status, + `MaxAI upstream ${upstream.status}: ${sanitizeErrorMessage(detail.slice(0, 300))}`, + upstream.status === 401 || upstream.status === 418 + ? "maxai_auth_error" + : "maxai_upstream_error" + ), + url + ); + } + + const id = `chatcmpl-${conversationId}`; + const created = Math.floor(Date.now() / 1000); + const promptTokens = estimateMaxaiTokens(text); + + // Tool mode: MaxAI streams plain text, and the protocol is only + // parseable once the full reply is in hand. So when tools are active we + // buffer the whole body, build a chat.completion, and let the shared shim + // parse blocks into tool_calls (emitting a terminal SSE replay for + // streaming callers). This mirrors every web-cookie provider's tool path. + if (hasTools) { + const raw = await upstream.text(); + let { reasoning, answer } = collectNonStream(raw); + + // Reliability: if the model narrated about the tool but emitted no + // parseable block (occasional reasoning-model miss), do ONE gentle + // nudged retry and keep it only if it actually produces a tool call. + const firstHasToolCall = !!parseToolCallsFromText(answer, "probe", requestedTools).toolCalls; + if (!firstHasToolCall && isToolNarrationMiss(reasoning + "\n" + answer, requestedTools)) { + const retry = await this.retryToolTurn(cred, accessToken, input, toolNudge(text)); + if (retry && parseToolCallsFromText(retry.answer, "probe", requestedTools).toolCalls) { + reasoning = retry.reasoning; + answer = retry.answer; + input.log?.debug?.("maxai", "tool narration-miss recovered via one nudged retry"); + } + } + + const completionTokens = estimateMaxaiTokens(reasoning + answer); + const buffered = new Response( + JSON.stringify({ + id, + object: "chat.completion", + created, + model: input.model, + choices: [ + { + index: 0, + message: { + role: "assistant", + content: answer, + ...(reasoning ? { reasoning_content: reasoning } : {}), + }, + finish_reason: "stop", + }, + ], + usage: { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + }, + }), + { status: 200, headers: JSON_HEADERS } + ); + const response = await buildToolModeResponse(buffered, requestedTools, input.stream, { + cid: id, + created, + model: input.model, + idSeed: "maxai", + }); + return wrap(response, url, { headers, transformedBody: chatBody }); + } + + if (input.stream) { + const stream = this.buildStream(upstream.body, id, created, input.model, promptTokens); + return wrap(new Response(stream, { status: 200, headers: SSE_HEADERS }), url, { + headers, + transformedBody: chatBody, + }); + } + + // Non-streaming: collect the whole SSE body, split think, build a chat.completion. + const raw = await upstream.text(); + const { reasoning, answer } = collectNonStream(raw); + const completionTokens = estimateMaxaiTokens(reasoning + answer); + const response = { + id, + object: "chat.completion", + created, + model: input.model, + choices: [ + { + index: 0, + message: { + role: "assistant", + content: answer, + ...(reasoning ? { reasoning_content: reasoning } : {}), + }, + finish_reason: "stop", + }, + ], + usage: { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + }, + }; + return wrap( + new Response(JSON.stringify(response), { status: 200, headers: JSON_HEADERS }), + url, + { headers, transformedBody: chatBody } + ); + } + + /** + * Return a non-expired access token, refreshing browserlessly when the stored + * one is missing or within the expiry margin and a refresh token is available. + * Persists a freshly-minted token via `onCredentialsRefreshed`. Never throws — + * on any refresh failure it returns the original token so the request still + * proceeds (a truly-dead token then surfaces as an upstream 401/418). + */ + private async ensureFreshAccess(cred: MaxaiCredential, input: ExecuteInput): Promise { + if (!cred.refreshToken) return cred.accessToken; + if (!maxaiAccessTokenNeedsRefresh(cred.accessToken)) return cred.accessToken; + + const result = await maxaiRefreshAccessToken({ + refreshToken: cred.refreshToken, + deviceId: cred.deviceId, + userId: cred.userId, + signal: input.signal ?? undefined, + }); + if (!result.ok || !result.accessToken) { + input.log?.warn?.( + "maxai", + `access-token refresh failed (${result.status}); using existing token` + ); + return cred.accessToken; + } + + // Persist the new access token (merged into providerSpecificData) so the next + // request starts fresh. The refresh token and device id are unchanged. + try { + await input.onCredentialsRefreshed?.({ + accessToken: result.accessToken, + providerSpecificData: { + ...(input.credentials?.providerSpecificData ?? {}), + maxaiAccessToken: result.accessToken, + }, + }); + } catch (err) { + input.log?.warn?.( + "maxai", + `refreshed token persist failed: ${sanitizeErrorMessage(err instanceof Error ? err.message : err)}` + ); + } + return result.accessToken; + } + + /** + * Run a single follow-up MaxAI turn with a gentle nudge appended, used to + * recover a reasoning-model "narration miss" (the model talked ABOUT the + * block instead of emitting it). Bounded to one extra call; returns the + * split { reasoning, answer } or null on any failure (caller keeps the original). + */ + private async retryToolTurn( + cred: MaxaiCredential, + accessToken: string, + input: ExecuteInput, + nudgedText: string + ): Promise<{ reasoning: string; answer: string } | null> { + try { + const constants = await ensureMaxaiConstants({ signal: input.signal }); + if (!constants) return null; + const retryBody = buildMaxaiChatBody({ + conversationId: newConversationId(), + text: nudgedText, + modelName: input.model, + appVersion: constants.appVersion, + }); + const headers: Record = { + ...maxaiStaticHeaders(), + ...buildMaxaiSignedHeaders( + { + path: MAXAI_CHAT_PATH, + userId: cred.userId, + deviceId: cred.deviceId, + }, + constants + ), + Authorization: `Bearer ${accessToken}`, + ...(input.upstreamExtraHeaders ?? {}), + }; + const res = await fetch(MAXAI_BASE_URL + MAXAI_CHAT_PATH, { + method: "POST", + headers, + body: JSON.stringify(retryBody), + signal: input.signal ?? undefined, + }); + if (res.status !== 200 || !res.body) return null; + return collectNonStream(await res.text()); + } catch { + return null; + } + } + + /** Bridge the MaxAI SSE body into an OpenAI chat.completion.chunk stream. */ + private buildStream( + source: ReadableStream, + id: string, + created: number, + model: string, + promptTokens: number + ): ReadableStream { + const splitter = new ThinkSplitter(); + const decoder = new TextDecoder(); + let sseBuf = ""; + let sentRole = false; + let completionChars = 0; + + const emitDelta = (controller: ReadableStreamDefaultController, r: string, a: string) => { + if (!sentRole && (r || a)) { + chunk(controller, id, created, model, { role: "assistant" }); + sentRole = true; + } + if (r) { + chunk(controller, id, created, model, { reasoning_content: r }); + completionChars += r.length; + } + if (a) { + chunk(controller, id, created, model, { content: a }); + completionChars += a.length; + } + }; + + const processFrame = (controller: ReadableStreamDefaultController, jsonStr: string) => { + if (!jsonStr || jsonStr === "[DONE]") return; + let frame: unknown; + try { + frame = JSON.parse(jsonStr); + } catch { + return; + } + if (isMaxaiTextFrame(frame)) { + const { reasoning, answer } = splitter.feed(frame.text); + emitDelta(controller, reasoning, answer); + } + }; + + return new ReadableStream({ + async start(controller) { + const reader = source.getReader(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + sseBuf += decoder.decode(value, { stream: true }); + let nl: number; + while ((nl = sseBuf.indexOf("\n")) !== -1) { + const line = sseBuf.slice(0, nl).trim(); + sseBuf = sseBuf.slice(nl + 1); + if (line.startsWith("data:")) processFrame(controller, line.slice(5).trim()); + } + } + // flush held tail from the think splitter + const tail = splitter.flush(); + emitDelta(controller, tail.reasoning, tail.answer); + // final chunk with usage + finish + const completionTokens = estimateMaxaiTokens("x".repeat(completionChars)); + const finalChunk = { + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + }, + }; + controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(finalChunk)}\n\n`)); + controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")); + controller.close(); + } catch (err) { + try { + controller.error(err); + } catch { + /* already errored */ + } + } finally { + reader.releaseLock(); + } + }, + }); + } +} + +/** Collect a full MaxAI SSE body into split { reasoning, answer } (non-stream). */ +function collectNonStream(raw: string): { reasoning: string; answer: string } { + const splitter = new ThinkSplitter(); + let reasoning = ""; + let answer = ""; + for (const line of raw.split("\n")) { + const s = line.trim(); + if (!s.startsWith("data:")) continue; + const js = s.slice(5).trim(); + if (!js || js === "[DONE]") continue; + let frame: unknown; + try { + frame = JSON.parse(js); + } catch { + continue; + } + if (isMaxaiTextFrame(frame)) { + const out = splitter.feed(frame.text); + reasoning += out.reasoning; + answer += out.answer; + } + } + const tail = splitter.flush(); + return { reasoning: reasoning + tail.reasoning, answer: answer + tail.answer }; +} diff --git a/open-sse/executors/maxai/catalog.ts b/open-sse/executors/maxai/catalog.ts new file mode 100644 index 0000000000..363b3cca9e --- /dev/null +++ b/open-sse/executors/maxai/catalog.ts @@ -0,0 +1,76 @@ +/** + * MaxAI model catalog + provider-enum mapping. Ported from the MaxAI v3 client + * (catalog/context_windows.py, tools/provider_enum.py). All 13 chat models are + * PAID (the free `mistral-7b-instruct-free` is a window-lookup fallback only and + * is not offered). Context windows are the MaxAI-reported values. + */ +import type { RegistryModel } from "../../config/providers/shared.ts"; + +interface MaxaiModelSpec { + id: string; + name: string; + contextLength: number; + supportsReasoning?: boolean; + /** + * Vision-capable (accepts image_url input). Sourced from MaxAI's live + * `/models/get_config` `capabilities.vision` (verified 2026-08); the executor + * forwards image parts inline in message_content for these. Live discovery + * (services/maxaiModels.ts) overrides this from the catalog at runtime; this + * static flag keeps the offline registry in agreement. + */ + supportsVision?: boolean; +} + +/** The 13 offered paid chat models (group order: FAST, SMART, REASONING). */ +export const MAXAI_MODELS: MaxaiModelSpec[] = [ + // FAST + { id: "gpt-5.6-luna", name: "GPT-5.6 Luna", contextLength: 1_050_000, supportsVision: true }, + { id: "claude-haiku-4-5", name: "Claude Haiku 4.5", contextLength: 200_000, supportsVision: true }, + { id: "gemini-3-1-flash-lite", name: "Gemini 3.1 Flash Lite", contextLength: 1_000_000, supportsVision: true }, + { id: "grok-4-1-fast-non-reasoning", name: "Grok 4.1 Fast", contextLength: 2_000_000 }, + { id: "llama-3.3-70b", name: "Llama 3.3 70B", contextLength: 128_000 }, + { id: "deepseek-v3.2", name: "DeepSeek V3.2", contextLength: 128_000 }, + // SMART + { id: "gpt-5.6", name: "GPT-5.6", contextLength: 1_050_000, supportsVision: true }, + { id: "claude-5-sonnet", name: "Claude 5 Sonnet", contextLength: 1_000_000 }, + { + id: "grok-4-1-fast-reasoning", + name: "Grok 4.1 Fast (Reasoning)", + contextLength: 2_000_000, + supportsReasoning: true, + }, + // REASONING + { + id: "gpt-5.6-thinking", + name: "GPT-5.6 Thinking", + contextLength: 1_050_000, + supportsReasoning: true, + supportsVision: true, + }, + { + id: "gemini-3.1-pro-preview", + name: "Gemini 3.1 Pro Preview", + contextLength: 1_000_000, + supportsReasoning: true, + supportsVision: true, + }, + { id: "grok-4.5", name: "Grok 4.5", contextLength: 500_000, supportsReasoning: true }, + { id: "deepseek-r1", name: "DeepSeek R1", contextLength: 128_000, supportsReasoning: true }, +]; + +/** RegistryModel[] form for the provider registry entry. */ +export const MAXAI_REGISTRY_MODELS: RegistryModel[] = MAXAI_MODELS.map((m) => ({ + id: m.id, + name: m.name, + contextLength: m.contextLength, + toolCalling: true, // prompted tool-calling (no native API, but supported via the tool protocol) + ...(m.supportsReasoning ? { supportsReasoning: true } : {}), + ...(m.supportsVision ? { supportsVision: true } : {}), +})); + +/** Default context window for an unknown model. */ +export const MAXAI_DEFAULT_CONTEXT = 128_000; + +export function maxaiContextWindow(modelId: string): number { + return MAXAI_MODELS.find((m) => m.id === modelId)?.contextLength ?? MAXAI_DEFAULT_CONTEXT; +} diff --git a/open-sse/executors/maxai/constants.ts b/open-sse/executors/maxai/constants.ts new file mode 100644 index 0000000000..08b5bfd3e4 --- /dev/null +++ b/open-sse/executors/maxai/constants.ts @@ -0,0 +1,427 @@ +/** + * MaxAI web-app signing constants — extracted live from the public JS bundle. + * + * MaxAI's request signer needs a small set of CLIENT-SIDE constants that its own + * front-end ships VERBATIM in the public `www.maxai.co` JavaScript bundle + * (identical for every visitor, no per-user or server secret). OmniRoute EXTRACTS + * them from the live bundle and persists them, so if MaxAI ever rotates a value — + * or a Next.js rebuild renumbers its chunks — the provider self-heals on the next + * login or daily refresh instead of hard-failing every signed call. + * + * NOTHING id/key/version-shaped is hardcoded anywhere (source OR tests). Every + * such value (hmacKey, aesKey, docIdKey, ctxKey, appVersion) is discovered at + * runtime and validated; the repo carries no scannable secret and no build- + * specific chunk number. + * + * WHAT is extracted, and from WHERE (all are plain, public static assets): + * pages/_app-*.js — the Next.js app-entry chunk (framework-STABLE name, not a + * MaxAI chunk number). Webpack module 69319 inside it defines the constants as + * export getters we follow to their string literals: + * - hmacKey export `Mn` → a hex string (HMAC-SHA1 → SM3 keying) + * - aesKey export `Rl` → a hex string (CryptoJS AES passphrase) + * - docIdKey export `U0` → a UUID (doc-upload HMAC key) + * - appVersion the sole `webpage_x.y.z` literal (folded into the sign_str) + * the SIGNER chunk — a NUMBERED chunk whose id changes across builds, so it is + * located by CONTENT FINGERPRINT (never by number): the chunk that assembles + * the signed payload, recognised by the ctx-slot pattern `"<40hex>":{a:…}` next + * to the `(0,r.nj)("")` header-name decoders. From it we read: + * - ctxKey the 40-hex payload content-slot label + * - headerNames the `nj("")` calls = hex→ASCII header/slot names + * + * The extracted set is SHAPE-validated (hex/UUID/version regexes) before it is + * trusted; the ULTIMATE validation is the first live signed call (a wrong value + * is rejected by MaxAI, which triggers a re-extract). Only the plain, non-secret + * HTTP header NAMES (e.g. "X-Authorization") keep in-code defaults, so a transient + * miss on the signer chunk can't break a signer that already has valid keys; + * extraction still overrides them when present. + */ +import { createHmac, createHash } from "node:crypto"; + +/** The public bundle base. `/app/` is the SPA entry that references the chunks. */ +export const MAXAI_WEBAPP_ORIGIN = "https://www.maxai.co"; +export const MAXAI_WEBAPP_APP_PATH = "/app/"; + +/** Settings key under which the extracted constants bundle is persisted. */ +export const MAXAI_CONSTANTS_SETTINGS_KEY = "maxaiSigningConstants"; + +/** Firefox-150 UA used for the (unauthenticated) static-asset fetches. */ +const FETCH_UA = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0"; + +/** + * The header/slot NAMES the signer emits. These are standard HTTP header names + * (not secrets, not id/key/version-shaped), so in-code defaults are appropriate; + * extraction overrides any that the signer chunk exposes. + */ +export interface MaxaiHeaderNames { + authorization: string; // "X-Authorization" + clientDomain: string; // "X-Client-Domain" + clientPath: string; // "X-Client-Path" + random: string; // "X-Random" + browserName: string; // "X-Browser-Name" + browserVersion: string; // "X-Browser-Version" + browserMajor: string; // "X-Browser-Major" + appVersionHeader: string; // "X-App-Version" + appEnvHeader: string; // "X-App-Env" + appEnvValue: string; // "MaxAI-Browser-Extension" + tSlot: string; // "t" + pSlot: string; // "p" + dSlot: string; // "d" +} + +/** The full set of signing constants the MaxAI signer depends on. */ +export interface MaxaiSigningConstants { + /** HMAC-SHA1 → SM3 keying material (extracted; no in-code default). */ + hmacKey: string; + /** CryptoJS AES passphrase (extracted; no in-code default). */ + aesKey: string; + /** Version string folded into the signature `sign_str` (extracted). */ + appVersion: string; + /** Payload content-slot label, 40-hex (extracted; no in-code default). */ + ctxKey: string; + /** Doc-upload HMAC key, UUID (extracted; no in-code default). */ + docIdKey: string; + /** Header/slot names emitted by the signer. */ + headerNames: MaxaiHeaderNames; + /** Provenance for the persisted record. */ + source?: "extracted"; + extractedAt?: number; +} + +/** + * Default HTTP header NAMES (standard, non-secret labels). Extraction overrides + * any the signer chunk exposes; these keep a signer with valid keys working even + * if the signer chunk momentarily can't be located. + */ +export const MAXAI_DEFAULT_HEADER_NAMES: MaxaiHeaderNames = { + authorization: "X-Authorization", + clientDomain: "X-Client-Domain", + clientPath: "X-Client-Path", + random: "X-Random", + browserName: "X-Browser-Name", + browserVersion: "X-Browser-Version", + browserMajor: "X-Browser-Major", + appVersionHeader: "X-App-Version", + appEnvHeader: "X-App-Env", + appEnvValue: "MaxAI-Browser-Extension", + tSlot: "t", + pSlot: "p", + dSlot: "d", +}; + +/** Raw pieces the parser can pull from the two chunks (any may be absent). */ +export interface MaxaiParsedConstants { + hmacKey: string | null; + aesKey: string | null; + appVersion: string | null; + ctxKey: string | null; + docIdKey: string | null; + headerNames: Partial; +} + +/** Resolve a webpack export getter `Name:function(){return VAR}` → the `VAR="…"` literal. */ +export function resolveWebpackGetter(src: string, exportName: string): string | null { + const getter = new RegExp( + `${exportName}\\s*:\\s*function\\s*\\(\\)\\s*\\{\\s*return\\s+([A-Za-z_$][\\w$]*)\\s*\\}` + ); + let m = src.match(getter); + if (!m) { + const arrow = new RegExp(`${exportName}\\s*:\\s*\\(\\)\\s*=>\\s*([A-Za-z_$][\\w$]*)`); + m = src.match(arrow); + } + if (!m) return null; + const varName = m[1]; + const assign = new RegExp(`\\b${varName}\\s*=\\s*"([^"]+)"`); + const am = src.match(assign); + return am ? am[1] : null; +} + +/** Decode the `(0,r.nj)("")` header-name calls (nj = hex→ASCII). */ +export function decodeNjHeaderNames(signerChunk: string): string[] { + const out = new Set(); + for (const m of signerChunk.matchAll(/nj\)\("([0-9a-f]+)"\)/g)) { + try { + const decoded = Buffer.from(m[1], "hex").toString("utf8"); + // Keep only printable ASCII header-ish tokens (drop numeric ja3 codes etc). + if (/^[\x20-\x7e]+$/.test(decoded)) out.add(decoded); + } catch { + // skip malformed hex + } + } + return [...out]; +} + +/** Map the decoded header-name list onto the structured MaxaiHeaderNames slots. */ +function mapHeaderNames(decoded: string[]): Partial { + const has = (v: string) => decoded.includes(v); + const out: Partial = {}; + if (has("X-Authorization")) out.authorization = "X-Authorization"; + if (has("X-Client-Domain")) out.clientDomain = "X-Client-Domain"; + if (has("X-Client-Path")) out.clientPath = "X-Client-Path"; + if (has("X-Random")) out.random = "X-Random"; + if (has("X-Browser-Name")) out.browserName = "X-Browser-Name"; + if (has("X-Browser-Version")) out.browserVersion = "X-Browser-Version"; + if (has("X-Browser-Major")) out.browserMajor = "X-Browser-Major"; + if (has("X-App-Version")) out.appVersionHeader = "X-App-Version"; + if (has("X-App-Env")) out.appEnvHeader = "X-App-Env"; + if (has("MaxAI-Browser-Extension")) out.appEnvValue = "MaxAI-Browser-Extension"; + return out; +} + +/** Extract the 40-hex payload content-slot label from the signer chunk. */ +export function extractCtxKey(signerChunk: string): string | null { + return (signerChunk.match(/"([0-9a-f]{40})"\s*:\s*\{\s*a\s*:/) || [])[1] ?? null; +} + +/** + * Content fingerprint for the SIGNER chunk (build-independent). The signer chunk + * is the one that both (a) carries the ctx payload slot `"<40hex>":{a:…}` and + * (b) decodes header names via `(0,r.nj)("")`. Matching BOTH avoids a false + * positive on any unrelated chunk that merely contains a 40-hex string. + */ +export function looksLikeSignerChunk(js: string): boolean { + return extractCtxKey(js) !== null && /nj\)\("[0-9a-f]+"\)/.test(js); +} + +/** + * Parse the two bundle chunks into raw constants. Pure (no network) so it is + * unit-tested directly against synthetic fixtures. + */ +export function parseMaxaiConstants( + appChunk: string, + signerChunk: string +): MaxaiParsedConstants { + const decoded = decodeNjHeaderNames(signerChunk); + return { + hmacKey: resolveWebpackGetter(appChunk, "Mn"), + aesKey: resolveWebpackGetter(appChunk, "Rl"), + docIdKey: resolveWebpackGetter(appChunk, "U0"), + appVersion: (appChunk.match(/"(webpage_\d+\.\d+\.\d+)"/) || [])[1] ?? null, + ctxKey: extractCtxKey(signerChunk), + headerNames: mapHeaderNames(decoded), + }; +} + +/** A MaxAI signing key is a 40+ char lowercase hex string. */ +function isHexKey(v: string | null | undefined): boolean { + return typeof v === "string" && /^[0-9a-f]{40,}$/.test(v); +} + +/** A doc-id key is a UUID (v4-shaped). */ +function isUuidKey(v: string | null | undefined): boolean { + return typeof v === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(v); +} + +/** A MaxAI app_version tag looks like `webpage_x.y.z`. */ +function isAppVersion(v: string | null | undefined): boolean { + return typeof v === "string" && /^webpage_\d+\.\d+\.\d+$/.test(v); +} + +/** + * Fold parsed pieces into a full constants object. The five extracted values + * (hmacKey, aesKey, ctxKey, docIdKey, appVersion) are ALL required and must be + * well-formed — return null otherwise, so we never persist a half-configured + * signer. Only the plain HTTP header names fall back to the standard defaults. + */ +export function assembleMaxaiConstants( + parsed: MaxaiParsedConstants +): MaxaiSigningConstants | null { + if (!isHexKey(parsed.hmacKey) || !isHexKey(parsed.aesKey)) return null; + if (!isHexKey(parsed.ctxKey)) return null; + if (!isUuidKey(parsed.docIdKey)) return null; + if (!isAppVersion(parsed.appVersion)) return null; + return { + hmacKey: parsed.hmacKey as string, + aesKey: parsed.aesKey as string, + appVersion: parsed.appVersion as string, + ctxKey: parsed.ctxKey as string, + docIdKey: parsed.docIdKey as string, + headerNames: { ...MAXAI_DEFAULT_HEADER_NAMES, ...parsed.headerNames }, + source: "extracted", + extractedAt: Date.now(), + }; +} + +/** True when a constants object is structurally well-formed (all 5 values valid). */ +export function isValidConstantsShape(c: MaxaiSigningConstants | null | undefined): boolean { + if (!c) return false; + return ( + isHexKey(c.hmacKey) && + isHexKey(c.aesKey) && + isHexKey(c.ctxKey) && + isUuidKey(c.docIdKey) && + isAppVersion(c.appVersion) && + !!c.headerNames + ); +} + +/** + * A signature vector: a (path, reqTime, userId, appVersion) tuple and the SM3 + * proof it should produce. Used to prove the signing ALGORITHM in unit tests with + * mock keys — the runtime does NOT embed any real vector (its trust anchor is the + * live signed probe). `reproduceProof` is a pure helper over the same math. + */ +export interface MaxaiSignatureVector { + path: string; + reqTime: number; + userId: string; + appVersion: string; + expectedProof: string; +} + +/** Reproduce the SM3 proof `p` for a (path, reqTime, userId, appVersion) under a key. */ +export function reproduceProof( + hmacKey: string, + vector: Omit +): string { + const signStr = `${vector.appVersion}:${vector.reqTime}:${vector.path}:${vector.userId}`; + const sha1 = createHmac("sha1", Buffer.from(`${vector.reqTime}:${hmacKey}`, "utf8")) + .update(Buffer.from(signStr, "utf8")) + .digest("hex"); + return createHash("sm3") + .update(Buffer.from(`${vector.reqTime}:${sha1}:${hmacKey}`, "utf8")) + .digest("hex"); +} + +/** + * Runtime validation of an extracted/stored constants set. SHAPE-based on purpose: + * we carry no real signature vector in source, so the definitive check is the + * first live signed call (a wrong value is rejected by MaxAI → re-extract). An + * optional `vector` enables proof-based checking in tests with mock keys. + */ +export function validateMaxaiConstants( + constants: MaxaiSigningConstants, + vector?: MaxaiSignatureVector +): boolean { + if (!isValidConstantsShape(constants)) return false; + if (!vector) return true; + try { + return reproduceProof(constants.hmacKey, vector) === vector.expectedProof; + } catch { + return false; + } +} + +/** + * Fetch a text asset with the Firefox UA through the ambient (residential) fetch. + * Injectable for tests. Returns "" on any failure (caller treats empty as miss). + */ +async function fetchText( + url: string, + fetchImpl: typeof fetch, + signal?: AbortSignal | null +): Promise { + try { + const res = await fetchImpl(url, { + headers: { "User-Agent": FETCH_UA, Accept: "*/*" }, + signal: signal ?? undefined, + }); + if (!res.ok) return ""; + return await res.text(); + } catch { + return ""; + } +} + +/** All `/_next/static/chunks/...js` URLs referenced by the app HTML, in order. */ +export function allChunkUrls(html: string): string[] { + const seen = new Set(); + const out: string[] = []; + for (const m of html.matchAll(/\/_next\/static\/chunks\/[A-Za-z0-9/_-]+\.js/g)) { + if (!seen.has(m[0])) { + seen.add(m[0]); + out.push(m[0]); + } + } + return out; +} + +/** + * From the `/app/` HTML, resolve the app-entry chunk (by its stable Next.js + * `pages/_app-*.js` name) and the list of candidate numbered chunks to scan for + * the signer chunk BY CONTENT. No specific chunk number is ever assumed. + */ +export function findChunkUrls(html: string): { + appChunk: string | null; + candidateChunks: string[]; +} { + const urls = allChunkUrls(html); + let appChunk: string | null = null; + const candidateChunks: string[] = []; + for (const p of urls) { + if (/\/pages\/_app-[a-z0-9]+\.js$/i.test(p)) { + appChunk = p; + } else if (/\/chunks\/[A-Za-z0-9]+-[a-z0-9]+\.js$/i.test(p)) { + // Any hashed vendor/number chunk is a signer-chunk candidate; we identify + // the real one by content, not by its (build-specific) name. + candidateChunks.push(p); + } + } + return { appChunk, candidateChunks }; +} + +export interface FetchConstantsOptions { + fetchImpl?: typeof fetch; + signal?: AbortSignal | null; + /** Override the origin (tests). */ + origin?: string; + /** Cap on how many candidate chunks to scan for the signer chunk (default 80). */ + maxScanChunks?: number; +} + +/** + * Locate + fetch the signer chunk text by CONTENT (never by number): scan the + * candidate chunks referenced in the app HTML and return the first whose content + * matches the signer fingerprint (ctx slot + nj header decoders). A MaxAI-side + * chunk renumber is therefore self-healing, not a break. + */ +async function fetchSignerChunk( + origin: string, + candidates: string[], + fetchImpl: typeof fetch, + signal: AbortSignal | null | undefined, + maxScan: number +): Promise { + for (const c of candidates.slice(0, maxScan)) { + const js = await fetchText(origin + c, fetchImpl, signal); + if (js && looksLikeSignerChunk(js)) return js; + } + return ""; +} + +/** + * Fetch + parse the live constants from MaxAI's public bundle. Returns a fully + * assembled, SHAPE-validated constants object, or null on any failure (network, + * missing chunk, unparseable, malformed values). Never throws. The definitive + * key validation is the caller's first live signed call. + */ +export async function fetchMaxaiConstants( + opts: FetchConstantsOptions = {} +): Promise { + const fetchImpl = opts.fetchImpl ?? fetch; + const origin = opts.origin ?? MAXAI_WEBAPP_ORIGIN; + const maxScan = opts.maxScanChunks ?? 80; + + const html = await fetchText(origin + MAXAI_WEBAPP_APP_PATH, fetchImpl, opts.signal); + if (!html) return null; + + const { appChunk, candidateChunks } = findChunkUrls(html); + if (!appChunk) return null; + + const appJs = await fetchText(origin + appChunk, fetchImpl, opts.signal); + if (!appJs) return null; + + const signerJs = await fetchSignerChunk( + origin, + candidateChunks, + fetchImpl, + opts.signal, + maxScan + ); + + const parsed = parseMaxaiConstants(appJs, signerJs); + const assembled = assembleMaxaiConstants(parsed); + if (!assembled) return null; + if (!validateMaxaiConstants(assembled)) return null; + return assembled; +} diff --git a/open-sse/executors/maxai/constantsStore.ts b/open-sse/executors/maxai/constantsStore.ts new file mode 100644 index 0000000000..08ece802c0 --- /dev/null +++ b/open-sse/executors/maxai/constantsStore.ts @@ -0,0 +1,156 @@ +/** + * MaxAI signing-constants store + `ensure` gate. + * + * This is the persistence + freshness layer around ./constants.ts: + * - `getStoredMaxaiConstants()` reads the last-extracted, validated constants + * from OmniRoute settings (the sole source of the two secret-shaped keys). + * - `persistMaxaiConstants()` writes a freshly-extracted+validated set. + * - `ensureMaxaiConstants()` is the gate every signed path calls: it returns a + * usable constants object, extracting + persisting on a cold store, and is + * cheap (in-process memo) on the hot path. + * - `refreshMaxaiConstants()` force re-extracts (used by the daily token + * refresh) so a MaxAI-side rotation is picked up within a day. + * + * Design (William's Option 2): there is NO hardcoded fallback for the secret + * keys. If the store is empty AND a live extraction cannot be validated, the + * signer has no keys and MaxAI is simply unconfigured (callers surface a clear + * auth error) — we never sign with a guessed/stale secret. + */ +import type { MaxaiSigningConstants, FetchConstantsOptions } from "./constants.ts"; +import { + MAXAI_CONSTANTS_SETTINGS_KEY, + fetchMaxaiConstants, + validateMaxaiConstants, + MAXAI_DEFAULT_HEADER_NAMES, +} from "./constants.ts"; + +/** In-process memo so the hot signing path never touches the DB or network. */ +let memo: MaxaiSigningConstants | null = null; +let inflight: Promise | null = null; + +/** Reset the in-process memo (tests + after a forced refresh). */ +export function resetMaxaiConstantsMemo(): void { + memo = null; + inflight = null; +} + +/** + * Test seam: directly seed the in-process memo so unit tests that exercise the + * signed network functions don't need to also mock the bundle fetch. Not used in + * production paths (production goes through ensure/refresh → store → extraction). + */ +export function __setMaxaiConstantsForTest(constants: MaxaiSigningConstants | null): void { + memo = constants; + inflight = null; +} + +/** Shape-guard a persisted record before trusting it. */ +function isUsableConstants(v: unknown): v is MaxaiSigningConstants { + if (!v || typeof v !== "object") return false; + const c = v as Partial; + return ( + typeof c.hmacKey === "string" && + typeof c.aesKey === "string" && + typeof c.appVersion === "string" && + typeof c.ctxKey === "string" && + typeof c.docIdKey === "string" && + !!c.headerNames && + typeof c.headerNames === "object" + ); +} + +/** Read the persisted constants from settings (validated). Null when absent/invalid. */ +export async function getStoredMaxaiConstants(): Promise { + try { + const { getSettings } = await import("@/lib/db/settings"); + const settings = await getSettings(); + const raw = (settings as Record)[MAXAI_CONSTANTS_SETTINGS_KEY]; + if (!isUsableConstants(raw)) return null; + // Re-validate on read: a persisted record must still reproduce the vector. + const withDefaults: MaxaiSigningConstants = { + ...raw, + headerNames: { ...MAXAI_DEFAULT_HEADER_NAMES, ...raw.headerNames }, + }; + return validateMaxaiConstants(withDefaults) ? withDefaults : null; + } catch { + return null; + } +} + +/** Persist a freshly-extracted+validated constants set to settings. */ +export async function persistMaxaiConstants( + constants: MaxaiSigningConstants +): Promise { + try { + const { updateSettings } = await import("@/lib/db/settings"); + await updateSettings({ [MAXAI_CONSTANTS_SETTINGS_KEY]: constants }); + } catch { + // Non-fatal: a persist failure just means the next process re-extracts. + } +} + +/** + * Return usable MaxAI signing constants, extracting + persisting on a cold store. + * Order: in-process memo → persisted store → live extraction (validated) → null. + * Concurrent callers share a single in-flight extraction. Never throws. + */ +export async function ensureMaxaiConstants( + opts: FetchConstantsOptions = {} +): Promise { + if (memo) return memo; + + const stored = await getStoredMaxaiConstants(); + if (stored) { + memo = stored; + return memo; + } + + if (inflight) return inflight; + inflight = (async () => { + try { + const fresh = await fetchMaxaiConstants(opts); + if (fresh) { + memo = fresh; + await persistMaxaiConstants(fresh); + return fresh; + } + return null; + } finally { + inflight = null; + } + })(); + return inflight; +} + +/** + * Force a live re-extraction (used by the daily token refresh). If the fetched + * set validates AND differs from what's stored, it is persisted + memoized so a + * MaxAI-side rotation is picked up. Returns the current-best constants (the fresh + * set on success, else whatever was already stored/memoized). Never throws. + */ +export async function refreshMaxaiConstants( + opts: FetchConstantsOptions = {} +): Promise { + let fresh: MaxaiSigningConstants | null = null; + try { + fresh = await fetchMaxaiConstants(opts); + } catch { + fresh = null; + } + + if (fresh) { + const changed = + !memo || + memo.hmacKey !== fresh.hmacKey || + memo.aesKey !== fresh.aesKey || + memo.appVersion !== fresh.appVersion || + memo.ctxKey !== fresh.ctxKey || + memo.docIdKey !== fresh.docIdKey; + memo = fresh; + if (changed) await persistMaxaiConstants(fresh); + return fresh; + } + + // Fetch failed — keep serving whatever we already have (memo or store). + return memo ?? (await getStoredMaxaiConstants()); +} diff --git a/open-sse/executors/maxai/credentials.ts b/open-sse/executors/maxai/credentials.ts new file mode 100644 index 0000000000..3f12d55999 --- /dev/null +++ b/open-sse/executors/maxai/credentials.ts @@ -0,0 +1,96 @@ +/** + * MaxAI connection credential resolution. + * + * MaxAI's request signer needs three things bound together: the OpenAI-style + * `access_token` (Bearer, ~24h), the `device_id` that minted it (embedded in the + * signed `X-Authorization` — a mismatch is rejected), and the `user_id` (folded + * into the signature proof). OmniRoute stores these in the connection's + * `providerSpecificData` (minted by OmniRoute's own browser-mint flow — see + * maxaiBrowserLogin), so the router is self-contained and never reads any + * external (Hermes) token file. + * + * The access token is refreshed out-of-band by the browser-mint (the + * `/oauth/refresh_access_token` endpoint is deep-TLS-gated and cannot be called + * by any HTTP client — only a real browser passes), so this module only READS + * the stored credential; it does not attempt an HTTP refresh. + */ + +export interface MaxaiCredential { + accessToken: string; + deviceId: string; + userId: string; + /** ~1-year refresh token used for browserless access-token refresh (optional). */ + refreshToken?: string; +} + +type ProviderSpecificData = Record | null | undefined; + +function firstString(...values: unknown[]): string | null { + for (const v of values) { + if (typeof v === "string") { + // Raw browser LocalStorage sometimes wraps the device id in quotes. + const trimmed = v.trim().replace(/^"|"$/g, ""); + if (trimmed.length > 0) return trimmed; + } + } + return null; +} + +/** Decode the `user_id` from a MaxAI access JWT (subject.user_id or sub). No verify. */ +export function userIdFromJwt(accessToken: string): string | null { + try { + const seg = accessToken.split(".")[1]; + if (!seg) return null; + const b64 = seg.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (seg.length % 4)) % 4); + const claims = JSON.parse(Buffer.from(b64, "base64").toString("utf8")); + const subject = claims?.subject as { user_id?: unknown } | undefined; + if (typeof subject?.user_id === "string") return subject.user_id; + if (typeof claims?.sub === "string") return claims.sub; + return null; + } catch { + return null; + } +} + +/** Epoch seconds of the access-JWT `exp`, or 0 when undecodable. */ +export function accessTokenExpiry(accessToken: string): number { + try { + const seg = accessToken.split(".")[1]; + if (!seg) return 0; + const b64 = seg.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (seg.length % 4)) % 4); + const claims = JSON.parse(Buffer.from(b64, "base64").toString("utf8")); + return typeof claims?.exp === "number" ? claims.exp : 0; + } catch { + return 0; + } +} + +/** + * Resolve the MaxAI credential from a connection's providerSpecificData (with the + * OpenAI-style `access_token` optionally supplied separately by the caller, which + * is how OmniRoute threads the stored connection token). Returns null when not + * fully configured (all three of accessToken/deviceId/userId required). + */ +export function resolveMaxaiCredential( + psd: ProviderSpecificData, + accessTokenFromConnection?: string | null +): MaxaiCredential | null { + const accessToken = firstString( + accessTokenFromConnection, + psd?.maxaiAccessToken, + psd?.accessToken + ); + if (!accessToken) return null; + + const deviceId = firstString(psd?.maxaiDeviceId, psd?.deviceId); + if (!deviceId) return null; + + const userId = + firstString(psd?.maxaiUserId, psd?.userId) ?? userIdFromJwt(accessToken); + if (!userId) return null; + + const refreshToken = + firstString(psd?.maxaiRefreshToken, psd?.refreshToken) ?? undefined; + + return { accessToken, deviceId, userId, refreshToken }; +} diff --git a/open-sse/executors/maxai/documents.ts b/open-sse/executors/maxai/documents.ts new file mode 100644 index 0000000000..ce41d9490a --- /dev/null +++ b/open-sse/executors/maxai/documents.ts @@ -0,0 +1,266 @@ +/** + * MaxAI doc-RAG — inline document parts → /app/upload_document → doc_list. + * + * OmniRoute delivers attached documents INLINE in the chat request as base64 + * `file_data` content parts (OpenAI `{type:"file",file:{filename,file_data}}` / + * Responses `{type:"input_file",file_data}` / Claude `{type:"document",source}`). + * MaxAI's `/gpt/cwc/chat` cannot take binary docs inline; instead it references + * uploaded documents by a content-addressed `doc_id`. This module bridges the + * two: it detects inline base64 doc parts on the current turn, uploads each via + * the multipart `/app/upload_document` endpoint (signed like every MaxAI call), + * and returns the `doc_list` entries to attach to the chat body. + * + * doc_id is NOT random — MaxAI requires `doc_id = HMAC-SHA1(file_bytes, IT)` hex + * (createDocId/qM in the extension). A random id is rejected with a 400 + * "Inconsistency between server doc_id and request doc_id". The IT key is a + * public web-app constant (ships in the bundle), same class as the signing + * constants; kept here as a named constant (not a secret). + * + * The doc_list item shape is exactly what the live web app sends + * (site chunk 41068): `{ doc_id, doc_type, file_name }`. + */ +import { createHmac } from "node:crypto"; +import { buildMaxaiSignedHeaders } from "./signing.ts"; +import { ensureMaxaiConstants } from "./constantsStore.ts"; +import { maxaiStaticHeaders, MAXAI_BASE_URL } from "./protocol.ts"; + +export const MAXAI_UPLOAD_PATH = "/app/upload_document"; + +export interface MaxaiDocListEntry { + doc_id: string; + doc_type: string; + file_name: string; +} + +/** An inline document extracted from an OpenAI/Responses/Claude content part. */ +export interface InlineDoc { + filename: string; + mimeType: string; + bytes: Buffer; +} + +/** doc_id = HMAC-SHA1(file_bytes, docIdKey) hex. Content-addressed; MaxAI verifies it. */ +export function computeMaxaiDocId(bytes: Buffer, key: string): string { + if (!key) throw new Error("computeMaxaiDocId: missing docIdKey"); + return createHmac("sha1", key).update(bytes).digest("hex"); +} + +const TEXTUAL_EXT = /\.(txt|md|markdown|csv|json|log|xml|yaml|yml|tsv)$/i; +const CODE_EXT = + /\.(py|ipynb|js|jsx|ts|tsx|html?|css|java|cs|php|c|cpp|cxx|h|hpp|go|rs|rb|swift|kt|sh|sql)$/i; + +/** Classify the MaxAI doc_type from the filename/mime (extension taxonomy). */ +export function maxaiDocType(filename: string, mimeType: string): string { + const f = filename.toLowerCase(); + if (/\.pdf$/i.test(f) || mimeType === "application/pdf") return "page_content__pdf"; + if (CODE_EXT.test(f)) return "chat_file_code"; + return "chat_file"; // text / generic +} + +/** Whether a doc_type requires the pure_text field (text-extractable docs). */ +function requiresPureText(docType: string): boolean { + return docType === "chat_file" || docType === "chat_file_code"; +} + +/** + * Parse an OpenAI/Responses/Claude data-URL into raw bytes + mime. Returns null + * for anything that isn't an inline base64 payload (e.g. a remote URL or an + * already-uploaded file_id reference, which this bridge does not handle). + */ +export function parseInlineDataUrl(dataUrl: unknown): { mimeType: string; bytes: Buffer } | null { + if (typeof dataUrl !== "string") return null; + const m = /^data:([^;,]*)(;base64)?,(.*)$/s.exec(dataUrl); + if (!m) return null; + const mimeType = m[1] || "application/octet-stream"; + const isBase64 = !!m[2]; + try { + const bytes = isBase64 + ? Buffer.from(m[3], "base64") + : Buffer.from(decodeURIComponent(m[3]), "utf8"); + if (bytes.length === 0) return null; + return { mimeType, bytes }; + } catch { + return null; + } +} + +/** + * Extract inline documents from the CURRENT (last user) turn of an OpenAI + * messages[] array. Recognizes the three OmniRoute-delivered shapes: + * OpenAI Chat: {type:"file", file:{filename, file_data|data}} + * Responses: {type:"input_file", filename, file_data} + * Claude: {type:"document", source:{type:"base64", media_type, data}} + * Only base64/data-URL payloads are handled (a bridge upload needs the bytes). + */ +export function extractCurrentTurnDocs( + messages: Array<{ role?: string; content?: unknown }> +): InlineDoc[] { + let content: unknown; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i]?.role === "user") { + content = messages[i]?.content; + break; + } + } + if (!Array.isArray(content)) return []; + const docs: InlineDoc[] = []; + for (const part of content) { + if (!part || typeof part !== "object") continue; + const p = part as Record; + const type = p.type; + + if (type === "file" && p.file && typeof p.file === "object") { + const file = p.file as Record; + const filename = typeof file.filename === "string" ? file.filename : "upload.bin"; + const raw = (file.file_data ?? file.data) as unknown; + const parsed = parseInlineDataUrl(raw); + if (parsed) docs.push({ filename, mimeType: parsed.mimeType, bytes: parsed.bytes }); + } else if (type === "input_file") { + const filename = typeof p.filename === "string" ? p.filename : "upload.bin"; + const parsed = parseInlineDataUrl(p.file_data); + if (parsed) docs.push({ filename, mimeType: parsed.mimeType, bytes: parsed.bytes }); + } else if (type === "document" && p.source && typeof p.source === "object") { + const source = p.source as Record; + if (source.type === "base64" && typeof source.data === "string") { + const mimeType = + typeof source.media_type === "string" ? source.media_type : "application/octet-stream"; + try { + const bytes = Buffer.from(source.data, "base64"); + if (bytes.length > 0) { + const filename = + typeof p.title === "string" && p.title ? p.title : `document.${mimeExt(mimeType)}`; + docs.push({ filename, mimeType, bytes }); + } + } catch { + /* skip malformed base64 */ + } + } + } + } + return docs; +} + +function mimeExt(mime: string): string { + if (mime === "application/pdf") return "pdf"; + if (mime.startsWith("text/")) return "txt"; + return "bin"; +} + +/** Rough ~4-chars/token estimate; ceil, never 0 for non-empty text. */ +function estimateTokens(text: string): number { + return text ? Math.max(1, Math.ceil(text.length / 4)) : 0; +} + +/** Build the multipart/form-data body for /app/upload_document (fixed boundary). */ +export function buildUploadMultipart( + doc: InlineDoc, + docId: string, + docType: string, + boundary: string +): Buffer { + const isTextual = + requiresPureText(docType) && + (TEXTUAL_EXT.test(doc.filename) || + CODE_EXT.test(doc.filename) || + doc.mimeType.startsWith("text/")); + const pureText = isTextual ? doc.bytes.toString("utf8") : ""; + const tokens = String(estimateTokens(pureText)); + + const parts: Buffer[] = []; + const dash = `--${boundary}\r\n`; + const field = (name: string, value: string): void => { + parts.push( + Buffer.from(`${dash}Content-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`) + ); + }; + field("doc_id", docId); + field("doc_type", docType); + field("pure_text", pureText); + field("tokens", tokens); + field("doc_type_dependent_data", "{}"); + // The file part carries the raw bytes with a content-type. + parts.push( + Buffer.from( + `${dash}Content-Disposition: form-data; name="file"; filename="${doc.filename.replace(/"/g, "")}"\r\n` + + `Content-Type: ${doc.mimeType}\r\n\r\n` + ) + ); + parts.push(doc.bytes); + parts.push(Buffer.from(`\r\n--${boundary}--\r\n`)); + return Buffer.concat(parts); +} + +/** True if any SSE frame in the response is the terminal upload_done event. */ +export function sawUploadDone(text: string): boolean { + return /"event"\s*:\s*"upload_done"/.test(text) || text.includes("upload_done"); +} + +/** + * Upload one inline document to MaxAI and return its doc_list entry, or null on + * failure (upload failures are non-fatal: the chat proceeds without the doc). + */ +export async function uploadMaxaiDocument( + doc: InlineDoc, + auth: { accessToken: string; userId: string; deviceId: string }, + opts?: { fetchImpl?: typeof fetch; signal?: AbortSignal } +): Promise { + const fetchImpl = opts?.fetchImpl ?? fetch; + const constants = await ensureMaxaiConstants({ fetchImpl, signal: opts?.signal }); + if (!constants) return null; + const docId = computeMaxaiDocId(doc.bytes, constants.docIdKey); + const docType = maxaiDocType(doc.filename, doc.mimeType); + const boundary = `----maxai${Date.now().toString(16)}${Math.random().toString(16).slice(2)}`; + const bodyBuf = buildUploadMultipart(doc, docId, docType, boundary); + + // Sign like any request, but DROP the JSON content-type so we can set the + // multipart boundary content-type ourselves (v3h.signed_headers pattern). + const { "Content-Type": _drop, ...staticHeaders } = maxaiStaticHeaders(); + const headers: Record = { + ...staticHeaders, + ...buildMaxaiSignedHeaders( + { path: MAXAI_UPLOAD_PATH, userId: auth.userId, deviceId: auth.deviceId }, + constants + ), + Authorization: `Bearer ${auth.accessToken}`, + "Content-Type": `multipart/form-data; boundary=${boundary}`, + }; + + // Copy the multipart bytes into a fresh Uint8Array backed by a plain + // (non-shared) ArrayBuffer. `Buffer.buffer` is typed ArrayBufferLike + // (ArrayBuffer | SharedArrayBuffer) which isn't assignable to fetch's + // BodyInit; a freshly-allocated Uint8Array is the BodyInit shape the rest of + // the codebase uses for binary bodies (kimi-web.ts:397, conol-web.ts:529). + const bodyBytes = new Uint8Array(bodyBuf.byteLength); + bodyBytes.set(bodyBuf); + + try { + const resp = await fetchImpl(MAXAI_BASE_URL + MAXAI_UPLOAD_PATH, { + method: "POST", + headers, + body: bodyBytes, + signal: opts?.signal, + }); + if (!resp.ok) return null; + const text = await resp.text().catch(() => ""); + if (!sawUploadDone(text)) return null; + return { doc_id: docId, doc_type: docType, file_name: doc.filename }; + } catch { + return null; + } +} + +/** + * Upload every inline document on the current turn and return the doc_list to + * attach to the chat body. Failures are skipped (best-effort); the chat still + * proceeds. Empty array when there are no inline docs. + */ +export async function resolveMaxaiDocList( + messages: Array<{ role?: string; content?: unknown }>, + auth: { accessToken: string; userId: string; deviceId: string }, + opts?: { fetchImpl?: typeof fetch; signal?: AbortSignal } +): Promise { + const docs = extractCurrentTurnDocs(messages); + if (docs.length === 0) return []; + const results = await Promise.all(docs.map((d) => uploadMaxaiDocument(d, auth, opts))); + return results.filter((r): r is MaxaiDocListEntry => r !== null); +} diff --git a/open-sse/executors/maxai/emailLogin.ts b/open-sse/executors/maxai/emailLogin.ts new file mode 100644 index 0000000000..676b5c1fae --- /dev/null +++ b/open-sse/executors/maxai/emailLogin.ts @@ -0,0 +1,234 @@ +/** + * MaxAI email login — browserless, two signed HTTP calls (a codex-style + * device-pair flow, no browser / camoufox / Google navigation). + * + * MaxAI's web app offers email-code sign-in as an alternative to Google OAuth. + * Both steps are plain signed POSTs carrying the same per-request X-Authorization + * signature as every other MaxAI call (see ./signing.ts); both paths are in the + * signer's BLANK_USER_ROUTES (they sign with a blank user_id, correct — there is + * no user id yet before login). Ported byte-faithfully from the MaxAI web-app + * bundle (chunk 86042: signInWithEmail line ~5623, verifySecretCode line ~5665). + * + * Step 1 — request a code (POST /oauth/signin_with_email): + * body { email, app: "maxai_webapp" } -> { status: "OK" } (code emailed) + * + * Step 2 — verify the code (POST /oauth/verify_secret_code): + * body { email, secret_code, app: "maxai_webapp", env: "prod_co", + * client_user_id, ...nullable attribution fields } + * -> { auth_user: { accessToken, refreshToken, userId, email, clientUserId } } + * + * The `device_id` folded into the signature is a CLIENT-GENERATED UUID (the web + * app's getAPIFetchDeviceID = "return stored, else generate + persist"), so the + * caller mints one with randomUUID() and reuses it across BOTH steps and for all + * subsequent chat / refresh calls (the minted token is bound to that device id). + * `client_user_id` is likewise a client UUID. + */ +import { buildMaxaiSignedHeaders } from "./signing.ts"; +import { maxaiStaticHeaders, MAXAI_BASE_URL } from "./protocol.ts"; +import { ensureMaxaiConstants } from "./constantsStore.ts"; +import type { MaxaiSigningConstants } from "./constants.ts"; + +export const MAXAI_SIGNIN_EMAIL_PATH = "/oauth/signin_with_email"; +export const MAXAI_VERIFY_CODE_PATH = "/oauth/verify_secret_code"; + +/** The web app's env tag for production email verification. */ +const MAXAI_VERIFY_ENV = "prod_co"; + +export interface MaxaiEmailRequestInput { + email: string; + /** Client device UUID (mint once, reuse for verify + all later calls). */ + deviceId: string; + signal?: AbortSignal | null; + fetchImpl?: typeof fetch; +} + +export interface MaxaiEmailVerifyInput { + email: string; + /** The 6-digit code the user received by email. */ + code: string; + /** Same device UUID used in the request step. */ + deviceId: string; + /** Client-user UUID (mint once alongside deviceId). */ + clientUserId: string; + signal?: AbortSignal | null; + fetchImpl?: typeof fetch; +} + +export interface MaxaiEmailRequestResult { + ok: boolean; + status: number; + error?: string; +} + +/** The full credential set returned by a successful verify. */ +export interface MaxaiLoginCredential { + accessToken: string; + refreshToken: string; + userId: string; + email: string; + deviceId: string; + clientUserId: string; +} + +export interface MaxaiEmailVerifyResult { + ok: boolean; + status: number; + credential?: MaxaiLoginCredential; + error?: string; +} + +/** Build signed headers for a blank-user OAuth route (user id is blanked in the proof). */ +function signedOauthHeaders( + path: string, + deviceId: string, + constants: MaxaiSigningConstants +): Record { + return { + ...maxaiStaticHeaders(), + // userId is blanked inside computeMaxaiProof for BLANK_USER_ROUTES; pass "". + ...buildMaxaiSignedHeaders({ path, userId: "", deviceId }, constants), + }; +} + +/** Pull a nested-or-top-level field from a MaxAI response body ({data:{...}} | {...}). */ +function pick(body: Record, key: string): T | undefined { + const data = body?.data as Record | undefined; + const nested = data?.[key]; + if (nested !== undefined) return nested as T; + return body?.[key] as T | undefined; +} + +/** + * Step 1: ask MaxAI to email a sign-in code. Never throws. + * Returns ok=true when the server acknowledges (status "OK"). + */ +export async function requestMaxaiEmailCode( + input: MaxaiEmailRequestInput +): Promise { + const doFetch = input.fetchImpl ?? fetch; + if (!input.email || !input.deviceId) { + return { ok: false, status: 0, error: "missing email or deviceId" }; + } + + // Initial login is the FIRST signed call — ensure we have live signing constants + // (extracted from MaxAI's public bundle) before signing. No keys = cannot sign. + const constants = await ensureMaxaiConstants({ fetchImpl: doFetch, signal: input.signal }); + if (!constants) { + return { ok: false, status: 0, error: "MaxAI signing constants unavailable (extraction failed)" }; + } + + let res: Response; + try { + res = await doFetch(MAXAI_BASE_URL + MAXAI_SIGNIN_EMAIL_PATH, { + method: "POST", + headers: signedOauthHeaders(MAXAI_SIGNIN_EMAIL_PATH, input.deviceId, constants), + body: JSON.stringify({ email: input.email, app: "maxai_webapp" }), + signal: input.signal ?? undefined, + }); + } catch (err) { + return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) }; + } + + const raw = await res.text().catch(() => ""); + if (res.status !== 200) { + return { ok: false, status: res.status, error: raw.slice(0, 200) }; + } + let body: Record = {}; + try { + body = JSON.parse(raw) as Record; + } catch { + return { ok: false, status: res.status, error: "unparseable signin response" }; + } + if (pick(body, "status") === "OK") return { ok: true, status: 200 }; + const detail = pick(body, "detail") || pick(body, "msg") || "sign-in request failed"; + return { ok: false, status: res.status, error: String(detail).slice(0, 200) }; +} + +/** + * Step 2: verify the emailed code and return the full credential. Never throws. + * On success the caller persists the credential to the connection's + * providerSpecificData (accessToken/refreshToken/deviceId/userId). + */ +export async function verifyMaxaiEmailCode( + input: MaxaiEmailVerifyInput +): Promise { + const doFetch = input.fetchImpl ?? fetch; + if (!input.email || !input.code || !input.deviceId) { + return { ok: false, status: 0, error: "missing email, code, or deviceId" }; + } + + const constants = await ensureMaxaiConstants({ fetchImpl: doFetch, signal: input.signal }); + if (!constants) { + return { ok: false, status: 0, error: "MaxAI signing constants unavailable (extraction failed)" }; + } + + const requestBody = { + email: input.email, + secret_code: input.code, + app: "maxai_webapp", + env: MAXAI_VERIFY_ENV, + invitation_code: null, + ref: "", + client_reference_id: null, + client_user_id: input.clientUserId, + client_price_version: null, + client_onboarding_version: null, + user_acquisition_channel: "", + gclid: null, + }; + + let res: Response; + try { + res = await doFetch(MAXAI_BASE_URL + MAXAI_VERIFY_CODE_PATH, { + method: "POST", + headers: signedOauthHeaders(MAXAI_VERIFY_CODE_PATH, input.deviceId, constants), + body: JSON.stringify(requestBody), + signal: input.signal ?? undefined, + }); + } catch (err) { + return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) }; + } + + const raw = await res.text().catch(() => ""); + if (res.status !== 200) { + return { ok: false, status: res.status, error: raw.slice(0, 200) }; + } + let body: Record = {}; + try { + body = JSON.parse(raw) as Record; + } catch { + return { ok: false, status: res.status, error: "unparseable verify response" }; + } + + const authUser = pick>(body, "auth_user"); + const status = pick(body, "status"); + if (status === "OK" && authUser && typeof authUser === "object") { + const accessToken = String(authUser.accessToken ?? authUser.access_token ?? ""); + const refreshToken = String(authUser.refreshToken ?? authUser.refresh_token ?? ""); + const userId = String(authUser.userId ?? authUser.user_id ?? ""); + if (!accessToken || !refreshToken) { + return { ok: false, status: 200, error: "verify OK but token fields missing" }; + } + return { + ok: true, + status: 200, + credential: { + accessToken, + refreshToken, + userId, + email: String(authUser.email ?? input.email), + deviceId: input.deviceId, + clientUserId: String(authUser.clientUserId ?? authUser.client_user_id ?? input.clientUserId), + }, + }; + } + + // 10119 is MaxAI's "code expired / too many attempts" signal; surface it. + const code = pick(body, "code"); + const detail = pick(body, "detail") || pick(body, "msg"); + const error = + code === 10119 + ? "Code expired or too many attempts — request a new code." + : String(detail || "Invalid code. Check the code and try again.").slice(0, 200); + return { ok: false, status: res.status, error }; +} diff --git a/open-sse/executors/maxai/protocol.ts b/open-sse/executors/maxai/protocol.ts new file mode 100644 index 0000000000..d3bb117d8f --- /dev/null +++ b/open-sse/executors/maxai/protocol.ts @@ -0,0 +1,266 @@ +/** + * MaxAI web-app protocol — request bodies, header assembly, and OpenAI→MaxAI + * context flattening. Ported from the MaxAI v3 Python client (chat/request.py, + * translation/openai_in.py, translation/turn_render.py) and live-verified against + * the real `/gpt/cwc/chat` endpoint. + * + * MaxAI is a stateless-full-history provider on the OmniRoute side: we send the + * ENTIRE flattened transcript in `message_content[0].text` every turn, always + * with `chat_history: []`, and mint a fresh `conversation_id` per request. The + * live probe proved a bare `/gpt/cwc/chat` (no upsert/add_messages bookkeeping) + * honors `model_name` and serves the real paid model, so no bookkeeping is sent. + */ +import { randomUUID } from "node:crypto"; + +export const MAXAI_BASE_URL = "https://api.maxai.me"; +export const MAXAI_CHAT_PATH = "/gpt/cwc/chat"; +export const MAXAI_MODELS_CONFIG_PATH = "/models/get_config"; + +/** Static Firefox-150 identity headers sent on every MaxAI request. */ +export function maxaiStaticHeaders(): Record { + return { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0", + Accept: "*/*", + "Accept-Language": "en-CA,en;q=0.9", + Origin: "https://www.maxai.co", + Referer: "https://www.maxai.co/", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "cross-site", + "Content-Type": "application/json", + }; +} + +// ── Chat body ─────────────────────────────────────────────────────────────── +// Field ORDER is pinned (it is part of the HTTP/2 request fingerprint). +const CHAT_FIELD_ORDER = [ + "chat_mode", + "conversation_id", + "chat_history", + "message_content", + "chrome_extension_version", + "model_name", + "prompt_id", + "prompt_name", + "prompt_inputs", + "doc_list", + "event_source", + "streaming", + "prompt_type", + "feature_name", + "source_type", + "platform_feature", +] as const; + +export function newConversationId(): string { + return randomUUID(); +} + +export function buildMaxaiChatBody(opts: { + conversationId: string; + text: string; + modelName: string; + language?: string; + relatedQuestionCnt?: string; + /** Extracted app_version for chrome_extension_version (from the signing constants). */ + appVersion: string; + /** + * Vision input: current-turn image URLs (data: or http(s):) to attach to the + * request. MaxAI's `/gpt/cwc/chat` accepts inline OpenAI-shaped image parts in + * `message_content` alongside the text part. Empty/omitted = text-only (the + * default, byte-identical to the pre-vision body). + */ + imageUrls?: string[]; + /** + * Doc-RAG: uploaded-document references (from /app/upload_document). Each entry + * carries at least `{ doc_id, doc_type, file_name }`. Typed as a loose object + * array so callers can pass their concrete `MaxaiDocListEntry[]` without an + * index-signature cast; the body only serializes it into `doc_list`. + * Empty/omitted = no docs (the default `doc_list: []`). + */ + docList?: ReadonlyArray; +}): Record { + // message_content is a typed-parts array: the text part ALWAYS leads (so the + // flattened transcript stays first and the no-image path is unchanged), then + // any image_url parts ride alongside. Mirrors the OpenAI multimodal shape, + // which MaxAI passes through (openai-to-cursor.ts vision pattern). + const messageContent: Array> = [{ type: "text", text: opts.text }]; + for (const url of opts.imageUrls ?? []) { + if (typeof url === "string" && url) { + messageContent.push({ type: "image_url", image_url: { url } }); + } + } + const values: Record = { + chat_mode: "pro_chat", + conversation_id: opts.conversationId, + chat_history: [], + message_content: messageContent, + chrome_extension_version: opts.appVersion, + model_name: opts.modelName, + prompt_id: "chat", + prompt_name: "chat", + prompt_inputs: { + RELATED_QUESTION_CNT: opts.relatedQuestionCnt ?? "5", + AI_RESPONSE_LANGUAGE: opts.language ?? "English", + }, + doc_list: opts.docList ?? [], + event_source: "web", + streaming: true, + prompt_type: "freestyle", + feature_name: "immersive_chat", + source_type: "NA", + platform_feature: "web_app", + }; + const ordered: Record = {}; + for (const k of CHAT_FIELD_ORDER) ordered[k] = values[k]; + return ordered; +} + +// ── OpenAI messages[] → MaxAI single text block ────────────────────────────── +interface OpenAiMessage { + role?: string; + content?: unknown; + tool_calls?: unknown; + tool_call_id?: string; +} + +const ROLE_LABEL: Record = { + system: "System", + user: "User", + assistant: "Assistant", +}; +const HISTORY_HEADER = "=== Conversation so far (for context) ==="; +const CURRENT_HEADER = "=== Current request (respond to THIS) ==="; + +/** Flatten OpenAI `content` (string or multipart array) to text. */ +export function contentToText(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((part) => + part && typeof part === "object" && (part as { type?: string }).type === "text" + ? String((part as { text?: unknown }).text ?? "") + : "" + ) + .filter(Boolean) + .join("\n"); + } + return ""; +} + +/** + * Extract image_url URLs from the CURRENT (last user) turn of an OpenAI + * messages[] array. MaxAI is stateless-full-history, so we attach only the + * current turn's images (history images would be re-sent every request and + * bloat the body). Returns raw url strings (data: or http(s):) in order. + */ +export function extractCurrentTurnImages(messages: OpenAiMessage[]): string[] { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i]?.role === "user") { + const content = messages[i]?.content; + if (!Array.isArray(content)) return []; + const urls: string[] = []; + for (const part of content) { + if (part && typeof part === "object" && (part as { type?: unknown }).type === "image_url") { + const imageUrl = (part as { image_url?: unknown }).image_url; + if (typeof imageUrl === "string" && imageUrl) { + urls.push(imageUrl); + } else if ( + imageUrl && + typeof imageUrl === "object" && + typeof (imageUrl as { url?: unknown }).url === "string" && + (imageUrl as { url: string }).url + ) { + urls.push((imageUrl as { url: string }).url); + } + } + } + return urls; + } + } + return []; +} + +/** Render OpenAI tool_calls[] as the prompted `` text MaxAI understands. */ +function toolCallsToText(toolCalls: unknown): string { + if (!Array.isArray(toolCalls)) return ""; + const blocks: string[] = []; + for (const call of toolCalls) { + const fn = (call as { function?: { name?: unknown; arguments?: unknown } })?.function; + if (!fn) continue; + const name = typeof fn.name === "string" ? fn.name : ""; + let args = fn.arguments; + if (typeof args !== "string") { + try { + args = JSON.stringify(args ?? {}); + } catch { + args = "{}"; + } + } + blocks.push(`${JSON.stringify({ name, arguments: args })}`); + } + return blocks.join("\n"); +} + +/** Render one non-system turn as a labeled block, or null to skip. */ +function renderTurn(message: OpenAiMessage): string | null { + const role = message.role; + const text = contentToText(message.content).trim(); + if (role === "tool") { + const id = message.tool_call_id ? ` tool_call_id="${message.tool_call_id}"` : ""; + return `\n${text}\n`; + } + if (role === "assistant" && message.tool_calls) { + const calls = toolCallsToText(message.tool_calls); + const body = text ? `${text}\n${calls}`.trim() : calls; + return `Assistant: ${body}`; + } + if (!text) return null; + const label = ROLE_LABEL[role ?? "user"] ?? "User"; + return `${label}: ${text}`; +} + +/** + * Assemble the full structured context into one text block: system text leads, + * prior turns render as a labeled transcript, and the LAST user turn is fenced + * under a CURRENT header so a weak model answers THIS turn. Mirrors MaxAI v3 + * translation/openai_in.py::assemble_context. + */ +export function assembleMaxaiContext(messages: OpenAiMessage[]): string { + // Find the last user turn (the current request). + let curIdx = -1; + let current = ""; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i]?.role === "user") { + curIdx = i; + current = contentToText(messages[i].content).trim(); + break; + } + } + const systemParts: string[] = []; + const historyParts: string[] = []; + for (let i = 0; i < messages.length; i++) { + if (i === curIdx) continue; + const m = messages[i]; + if (m?.role === "system") { + const t = contentToText(m.content).trim(); + if (t) systemParts.push(t); + continue; + } + const block = renderTurn(m); + if (block) historyParts.push(block); + } + const out: string[] = [...systemParts]; + if (historyParts.length && current) { + out.push(HISTORY_HEADER + "\n\n" + historyParts.join("\n\n")); + } else { + out.push(...historyParts); + } + if (current) { + const head = historyParts.length ? `${CURRENT_HEADER}\n\n` : ""; + out.push(head + current); + } + if (out.length === 0) throw new Error("no content to send to MaxAI"); + return out.join("\n\n"); +} diff --git a/open-sse/executors/maxai/refresh.ts b/open-sse/executors/maxai/refresh.ts new file mode 100644 index 0000000000..79bf3ba873 --- /dev/null +++ b/open-sse/executors/maxai/refresh.ts @@ -0,0 +1,149 @@ +/** + * MaxAI access-token refresh — browserless, via one signed HTTP call. + * + * MaxAI issues two tokens: a ~24h `accessToken` and a ~1-year `refreshToken`. + * The web app refreshes the access token by POSTing the refresh token to + * `/oauth/refresh_access_token` (web-app chunk 86042, `refreshAccessToken`). That + * endpoint carries the SAME per-request `X-Authorization` signature as every other + * MaxAI call (see ./signing.ts) — it is NOT a browser-only OAuth hop. A residential + * Firefox-TLS client (wreq-js firefox_150, the OmniRoute egress overlay) passes the + * TLS gate, so OmniRoute mints fresh access tokens itself with no browser. + * + * The refresh token is minted out-of-band, once, by the browser Google-OAuth flow + * (see maxaiBrowserLogin) and only needs re-minting when it itself expires (~yearly). + * This module handles the routine daily refresh. + * + * Request shape (byte-faithful to the web app): + * POST https://api.maxai.me/oauth/refresh_access_token + * Authorization: Bearer // the REFRESH token, not access + * noAuthLogout: true + * X-Authorization + X-App/X-Browser headers // standard signing + * body: {"app":"maxai_webapp"} // the app's `params` -> JSON body + * -> 200 { data: { access_token } } // a fresh ~24h access JWT + * + * The signed path is the BARE pathname (no query string); the `app` field travels + * in the body. `user_id` folds into the signature and is read from the refresh + * token's own JWT subject (per the web app), falling back to a provided userId. + */ +import { buildMaxaiSignedHeaders } from "./signing.ts"; +import { maxaiStaticHeaders, MAXAI_BASE_URL } from "./protocol.ts"; +import { userIdFromJwt, accessTokenExpiry } from "./credentials.ts"; +import { refreshMaxaiConstants } from "./constantsStore.ts"; + +export const MAXAI_REFRESH_PATH = "/oauth/refresh_access_token"; + +/** How close to expiry (seconds) an access token may be before we refresh it. */ +export const MAXAI_REFRESH_MARGIN_SECONDS = 60 * 60; // 1h + +export interface MaxaiRefreshInput { + refreshToken: string; + deviceId: string; + /** Optional explicit user id; defaults to the refresh token's JWT subject. */ + userId?: string; + signal?: AbortSignal | null; + /** Injectable fetch for tests (defaults to the ambient patched fetch). */ + fetchImpl?: typeof fetch; +} + +export interface MaxaiRefreshResult { + ok: boolean; + accessToken?: string; + /** access token expiry (epoch seconds), when a token was minted. */ + expiresAt?: number; + status: number; + error?: string; +} + +/** True when an access token is missing, unparseable, or within the margin of expiry. */ +export function maxaiAccessTokenNeedsRefresh( + accessToken: string | null | undefined, + marginSeconds: number = MAXAI_REFRESH_MARGIN_SECONDS, + now: () => number = Date.now +): boolean { + if (!accessToken) return true; + const exp = accessTokenExpiry(accessToken); + if (!exp) return true; + return exp - now() / 1000 <= marginSeconds; +} + +/** + * Mint a fresh access token from a refresh token via one signed HTTP POST. + * Never throws; returns a structured result the caller can branch on. + */ +export async function maxaiRefreshAccessToken( + input: MaxaiRefreshInput +): Promise { + const doFetch = input.fetchImpl ?? fetch; + const userId = input.userId || userIdFromJwt(input.refreshToken) || ""; + if (!input.refreshToken || !input.deviceId || !userId) { + return { ok: false, status: 0, error: "missing refreshToken, deviceId, or userId" }; + } + + // Daily refresh is our freshness checkpoint for the signing constants: re-extract + // from MaxAI's public bundle so a MaxAI-side key/app-version rotation is picked up + // within a day (self-heal). refreshMaxaiConstants persists a changed set and + // returns the current-best; on a fetch miss it returns whatever's already stored. + const constants = await refreshMaxaiConstants({ fetchImpl: doFetch, signal: input.signal }); + if (!constants) { + return { ok: false, status: 0, error: "MaxAI signing constants unavailable (extraction failed)" }; + } + + const signed = buildMaxaiSignedHeaders( + { + path: MAXAI_REFRESH_PATH, + userId, + deviceId: input.deviceId, + }, + constants + ); + const headers: Record = { + ...maxaiStaticHeaders(), + ...signed, + Authorization: `Bearer ${input.refreshToken}`, + noAuthLogout: "true", + "Content-Type": "application/json", + }; + + let res: Response; + try { + res = await doFetch(MAXAI_BASE_URL + MAXAI_REFRESH_PATH, { + method: "POST", + headers, + body: JSON.stringify({ app: "maxai_webapp" }), + signal: input.signal ?? undefined, + }); + } catch (err) { + return { + ok: false, + status: 0, + error: err instanceof Error ? err.message : String(err), + }; + } + + const raw = await res.text().catch(() => ""); + if (res.status !== 200) { + return { ok: false, status: res.status, error: raw.slice(0, 200) }; + } + + let accessToken = ""; + try { + const parsed = JSON.parse(raw) as { + data?: { access_token?: unknown }; + access_token?: unknown; + }; + const candidate = parsed?.data?.access_token ?? parsed?.access_token; + if (typeof candidate === "string") accessToken = candidate; + } catch { + return { ok: false, status: res.status, error: "unparseable refresh response" }; + } + if (!accessToken) { + return { ok: false, status: res.status, error: "refresh response had no access_token" }; + } + + return { + ok: true, + status: 200, + accessToken, + expiresAt: accessTokenExpiry(accessToken) || undefined, + }; +} diff --git a/open-sse/executors/maxai/signing.ts b/open-sse/executors/maxai/signing.ts new file mode 100644 index 0000000000..629d967f32 --- /dev/null +++ b/open-sse/executors/maxai/signing.ts @@ -0,0 +1,151 @@ +/** + * MaxAI web-app signing — the `X-Authorization` per-request signature. + * + * The scheme (validated byte-exact against real captured `X-Authorization` blobs): + * + * sign_str = `${appVersion}:${req_time}:${path}:${uid}` + * sha1 = HMAC_SHA1_hex(sign_str, key=`${req_time}:${hmacKey}`) + * p = SM3_hex(`${req_time}:${sha1}:${hmacKey}`) + * payload = { X-Client-Domain, X-Client-Path(page url), X-Random(6-digit), + * t(ms), p, d(device_id), :{ a: context } } + * X-Authorization = base64( "Salted__" + salt8 + AES-256-CBC(payloadJSON) ) + * with key/iv from OpenSSL EVP_BytesToKey(MD5, aesKey, salt) + * + * All primitives are in `node:crypto` (HMAC-SHA1, SM3 via OpenSSL 3, MD5, + * AES-256-CBC); no external dependency. + * + * KEYING MATERIAL IS NOT HARDCODED. The `hmacKey` and `aesKey` are the CLIENT-SIDE + * constants MaxAI's own web app ships verbatim in its public JS bundle. Rather + * than pin them here, OmniRoute extracts them live (see ./constants.ts) and passes + * a `MaxaiSigningConstants` object into every signing call. There is deliberately + * NO in-code default for the two keys: a signer with no extracted keys cannot sign + * (the caller surfaces a clear auth error) — we never sign with a guessed secret. + * The non-secret STRUCTURAL fields (appVersion, ctxKey, header names) carry safe + * defaults so a transient parse miss can't break an otherwise-working signer. + */ +import { createHmac, createHash, createCipheriv, randomBytes } from "node:crypto"; +import type { MaxaiSigningConstants, MaxaiHeaderNames } from "./constants.ts"; +import { MAXAI_DEFAULT_HEADER_NAMES } from "./constants.ts"; + +const CLIENT_DOMAIN = "maxai.co"; +/** Default browser page URL recorded verbatim as X-Client-Path (NOT the API path). */ +export const MAXAI_DEFAULT_PAGE = "https://www.maxai.co/app/"; +/** Only /oauth/* routes blank the user_id inside the signature. */ +const BLANK_USER_ROUTES = new Set([ + "/oauth/signin_with_email", + "/oauth/signin_with_google", + "/oauth/verify_secret_code", +]); + +const MAGIC = Buffer.from("Salted__", "ascii"); + +function hmacSha1Hex(message: string, key: string): string { + return createHmac("sha1", Buffer.from(key, "utf8")).update(Buffer.from(message, "utf8")).digest("hex"); +} + +function sm3Hex(message: string): string { + return createHash("sm3").update(Buffer.from(message, "utf8")).digest("hex"); +} + +/** OpenSSL EVP_BytesToKey with MD5 (CryptoJS default for a string passphrase). */ +function evpBytesToKey( + passphrase: string, + salt: Buffer, + keyLen = 32, + ivLen = 16 +): { key: Buffer; iv: Buffer } { + let derived = Buffer.alloc(0); + let block = Buffer.alloc(0); + const pass = Buffer.from(passphrase, "utf8"); + while (derived.length < keyLen + ivLen) { + block = createHash("md5").update(Buffer.concat([block, pass, salt])).digest(); + derived = Buffer.concat([derived, block]); + } + return { key: derived.subarray(0, keyLen), iv: derived.subarray(keyLen, keyLen + ivLen) }; +} + +/** + * Reproduce CryptoJS.AES.encrypt(text, passphrase).toString() (OpenSSL Salted__ + * envelope). `passphrase` (the extracted aesKey) is REQUIRED — there is no default. + */ +export function maxaiAesEncrypt(plaintext: string, passphrase: string, salt?: Buffer): string { + if (!passphrase) throw new Error("maxaiAesEncrypt: missing aesKey"); + const s = salt ?? randomBytes(8); + const { key, iv } = evpBytesToKey(passphrase, s); + const cipher = createCipheriv("aes-256-cbc", key, iv); // PKCS7 padding is the default + const body = Buffer.concat([cipher.update(Buffer.from(plaintext, "utf8")), cipher.final()]); + return Buffer.concat([MAGIC, s, body]).toString("base64"); +} + +/** + * Compute the SM3 `p` proof for an API `path` at `reqTime` ms. `hmacKey` and + * `appVersion` (both extracted) are REQUIRED — there is no in-code default. + */ +export function computeMaxaiProof( + path: string, + reqTime: number, + userId: string, + hmacKey: string, + appVersion: string +): string { + if (!hmacKey) throw new Error("computeMaxaiProof: missing hmacKey"); + if (!appVersion) throw new Error("computeMaxaiProof: missing appVersion"); + const p = path.endsWith("?") ? path.slice(0, -1) : path; + const uid = BLANK_USER_ROUTES.has(p) ? "" : userId; + const signStr = `${appVersion}:${reqTime}:${p}:${uid}`; + const sha1 = hmacSha1Hex(signStr, `${reqTime}:${hmacKey}`); + return sm3Hex(`${reqTime}:${sha1}:${hmacKey}`); +} + +export interface MaxaiSignInput { + /** API path being signed, e.g. "/gpt/cwc/chat". */ + path: string; + userId: string; + deviceId: string; + /** Browser page URL for X-Client-Path (defaults to the app page). */ + pageUrl?: string; + /** Context slot value (defaults to "" — the wire default). */ + context?: string; + /** Injectable clock/random for deterministic tests. */ + now?: () => number; + random?: () => string; +} + +/** + * Build the signing headers (X-Authorization plus the X-App and X-Browser + * companions) for one request. `device_id` MUST match the device that minted the + * token, or the server rejects the signature. + * + * `constants` carries the extracted keying material + structural labels. It is + * REQUIRED: callers resolve it via `ensureMaxaiConstants()` before signing. + */ +export function buildMaxaiSignedHeaders( + input: MaxaiSignInput, + constants: MaxaiSigningConstants +): Record { + const reqTime = (input.now ?? (() => Date.now()))(); + const random = + input.random?.() ?? String((randomBytes(4).readUInt32BE(0) % 900000) + 100000); + const h: MaxaiHeaderNames = { ...MAXAI_DEFAULT_HEADER_NAMES, ...constants.headerNames }; + const ctxKey = constants.ctxKey; + const appVersion = constants.appVersion; + // Key ORDER matters — it is signed as a compact JSON string. + const payload: Record = { + [h.clientDomain]: CLIENT_DOMAIN, + [h.clientPath]: input.pageUrl ?? MAXAI_DEFAULT_PAGE, + [h.random]: random, + [h.tSlot]: reqTime, + [h.pSlot]: computeMaxaiProof(input.path, reqTime, input.userId, constants.hmacKey, appVersion), + [h.dSlot]: input.deviceId, + [ctxKey]: { a: input.context ?? "" }, + }; + const blob = maxaiAesEncrypt(JSON.stringify(payload), constants.aesKey); + return { + [h.browserName]: "Firefox", + [h.browserVersion]: "150.0", + [h.browserMajor]: "150", + [h.appVersionHeader]: appVersion, + [h.appEnvHeader]: h.appEnvValue, + [h.authorization]: blob, + }; +} diff --git a/open-sse/executors/maxai/stream.ts b/open-sse/executors/maxai/stream.ts new file mode 100644 index 0000000000..4d865d6a7d --- /dev/null +++ b/open-sse/executors/maxai/stream.ts @@ -0,0 +1,101 @@ +/** + * MaxAI SSE stream handling — frame parsing, incremental `` split, and + * token estimation. Ported from the MaxAI v3 Python client (translation/sse.py, + * translation/stream.py, translation/think_split.py, translation/token_usage.py). + * + * MaxAI's `/gpt/cwc/chat` response is `text/event-stream`: `data: {json}` frames + * separated by blank lines. A text delta is a frame with + * `data_key === "text" && need_merge` truthy; its content is `frame.text`. + * Reasoning is emitted inline wrapped in ``; everything inside is + * reasoning, everything after the close tag is the visible answer. MaxAI returns + * no usage frame, so tokens are estimated (~4 chars/token). + */ + +/** Parse the text deltas out of a raw SSE body (batch). */ +export function parseMaxaiSseText(raw: string): string { + let out = ""; + for (const line of raw.split("\n")) { + const s = line.trim(); + if (!s.startsWith("data:")) continue; + const js = s.slice(5).trim(); + if (!js || js === "[DONE]") continue; + try { + const frame = JSON.parse(js) as { data_key?: unknown; need_merge?: unknown; text?: unknown }; + if (frame.data_key === "text" && frame.need_merge) { + out += typeof frame.text === "string" ? frame.text : ""; + } + } catch { + /* ignore non-JSON keepalive frames */ + } + } + return out; +} + +/** True when a decoded SSE frame is a mergeable text delta. */ +export function isMaxaiTextFrame( + frame: unknown +): frame is { data_key: "text"; need_merge: true; text: string } { + const f = frame as { data_key?: unknown; need_merge?: unknown; text?: unknown }; + return f?.data_key === "text" && Boolean(f?.need_merge) && typeof f?.text === "string"; +} + +const OPEN = ""; +const CLOSE = ""; +const HOLD = Math.max(OPEN.length, CLOSE.length) - 1; + +/** + * Stateful streaming classifier of text into (reasoning, answer). Handles a tag + * split across frames by holding a short tail. Before `` opens, text is + * answer; if no `` ever appears the whole stream is answer. + */ +export class ThinkSplitter { + private buf = ""; + private inThink = false; + + feed(delta: string): { reasoning: string; answer: string } { + this.buf += delta; + let reasoning = ""; + let answer = ""; + for (;;) { + const tag = this.inThink ? CLOSE : OPEN; + const idx = this.buf.indexOf(tag); + if (idx === -1) break; + const before = this.buf.slice(0, idx); + if (this.inThink) reasoning += before; + else answer += before; + this.buf = this.buf.slice(idx + tag.length); + this.inThink = !this.inThink; + } + // Emit everything except a short tail that might begin a tag. + const safe = this.buf.length > HOLD ? this.buf.slice(0, this.buf.length - HOLD) : ""; + if (safe) { + this.buf = this.buf.slice(safe.length); + if (this.inThink) reasoning += safe; + else answer += safe; + } + return { reasoning, answer }; + } + + flush(): { reasoning: string; answer: string } { + const tail = this.buf; + this.buf = ""; + if (!tail) return { reasoning: "", answer: "" }; + return this.inThink ? { reasoning: tail, answer: "" } : { reasoning: "", answer: tail }; + } +} + +/** Split a fully-collected answer into { reasoning, answer } (batch/non-stream). */ +export function splitThink(full: string): { reasoning: string; answer: string } { + const splitter = new ThinkSplitter(); + const a = splitter.feed(full); + const b = splitter.flush(); + return { + reasoning: a.reasoning + b.reasoning, + answer: a.answer + b.answer, + }; +} + +/** MaxAI returns no token counts; estimate ~4 chars/token. */ +export function estimateMaxaiTokens(text: string): number { + return Math.max(0, Math.ceil((text?.length ?? 0) / 4)); +} diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index 2eb758fd41..28cf667ae6 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -56,6 +56,7 @@ import { handleNvidiaNimImageGeneration } from "./imageGeneration/providers/nvid import { handleSegmindImageGeneration } from "./imageGeneration/providers/segmind.ts"; import { handleCursorAgentImageGeneration } from "./imageGeneration/providers/cursorAgentImage.ts"; import { handleMinimaxImageGeneration } from "./imageGeneration/providers/minimax.ts"; +import { handleMaxaiImageGeneration } from "./imageGeneration/providers/maxaiImage.ts"; import { handleAdobeFireflyImageGeneration } from "./imageGeneration/providers/adobeFirefly.ts"; import { handleAlibabaImageGeneration } from "./imageGeneration/providers/alibabaImage.ts"; import { handleAiHordeImageGeneration } from "./imageGeneration/providers/aihorde.ts"; @@ -616,6 +617,17 @@ export async function handleImageGeneration({ }); } + if (providerConfig.format === "maxai-image") { + return handleMaxaiImageGeneration({ + model, + provider, + body, + credentials, + log, + signal, + }); + } + if (providerConfig.format === "adobe-firefly-image") { return handleAdobeFireflyImageGeneration({ model, diff --git a/open-sse/handlers/imageGeneration/providers/maxaiImage.ts b/open-sse/handlers/imageGeneration/providers/maxaiImage.ts new file mode 100644 index 0000000000..2a102e43a0 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/maxaiImage.ts @@ -0,0 +1,230 @@ +// MaxAI (web-app) image-generation handler. +// Family: maxai-image | Provider: maxai +// +// MaxAI exposes 6 image models (gpt-image-1, dall-e-3, flux-1-schnell/dev/pro, +// sd3-medium) behind a SINGLE synchronous endpoint: +// POST https://api.maxai.me/gpt/get_image_generate_response +// body {prompt, style, size, n, model_name} +// -> {status:"OK", data:[{webp_url, png_url}]} +// No submit-then-poll (unlike Microsoft Designer) — one request returns the +// image URLs. Auth reuses the EXISTING signed-executor pieces (the same +// X-Authorization signer + Firefox-150 identity the chat path uses); the signer +// signs whatever `path` it is given, so image and chat share one auth module. +// +// Residential egress + Firefox-150 TLS are applied transparently at the infra +// layer (in-container TUN + TLS_FINGERPRINT_PROVIDERS), so nothing egress- +// specific lives here. + +import { resolveMaxaiCredential } from "../../../executors/maxai/credentials.ts"; +import { buildMaxaiSignedHeaders } from "../../../executors/maxai/signing.ts"; +import { ensureMaxaiConstants } from "../../../executors/maxai/constantsStore.ts"; +import { MAXAI_BASE_URL, maxaiStaticHeaders } from "../../../executors/maxai/protocol.ts"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; +import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGeneration.ts"; + +export const MAXAI_IMAGE_PATH = "/gpt/get_image_generate_response"; +const MAXAI_IMAGE_DEFAULT_SIZE = "1024x1024"; +const MAXAI_IMAGE_N_MAX = 4; + +// Models whose upstream REJECTS non-1024 sizes (verified: gpt-image-1/dall-e-3 +// 500 on 256x256/512x512). The flux family + sd3-medium have no size constraint +// and pass the requested WxH through unchanged. +const MAXAI_STRICT_SIZE_MODELS: Record> = { + "gpt-image-1": new Set(["1024x1024", "1024x1536", "1536x1024", "auto"]), + "dall-e-3": new Set(["1024x1024", "1024x1792", "1792x1024"]), +}; + +const MAXAI_IMAGE_ALIASES: Record = { + "stable-diffusion-v3": "sd3-medium", + "stable-diffusion-3-medium": "sd3-medium", + "flux-1-schneil": "flux-1-schnell", // tolerate a common typo +}; + +/** Strip a `maxai/` prefix and resolve size-name aliases to the canonical model id. */ +export function resolveMaxaiImageModel(model: unknown): string { + let m = typeof model === "string" ? model.trim() : ""; + if (m.startsWith("maxai/")) m = m.slice("maxai/".length); + return MAXAI_IMAGE_ALIASES[m] ?? m; +} + +/** + * Snap an OpenAI-style "WxH" size to something MaxAI accepts. gpt-image-1 / + * dall-e-3 reject anything outside their bucket (→ upstream 500), so an + * unsupported size (e.g. 512x512 from a standard OpenAI client) is snapped to + * the model default. Models with no constraint pass the size through. + */ +export function snapMaxaiImageSize(model: string, size: unknown): string { + const requested = typeof size === "string" && size.trim() ? size.trim() : MAXAI_IMAGE_DEFAULT_SIZE; + const allowed = MAXAI_STRICT_SIZE_MODELS[model]; + if (!allowed) return requested; // flux / sd3: no constraint + return allowed.has(requested) ? requested : MAXAI_IMAGE_DEFAULT_SIZE; +} + +/** Pull image URLs out of MaxAI's response into OpenAI data[] items (prefer png_url). */ +export function extractMaxaiImageUrls(json: unknown): string[] { + // Accept either the raw items array or a { data: [...] } wrapper. MaxAI's real + // response is { status:"OK", data:[{webp_url, png_url}] }, so both shapes occur + // depending on how far the caller unwrapped. + let items: unknown[] = []; + if (Array.isArray(json)) { + items = json; + } else if (json && typeof json === "object" && Array.isArray((json as Record).data)) { + items = (json as Record).data as unknown[]; + } + const urls: string[] = []; + for (const it of items) { + if (it && typeof it === "object") { + const rec = it as Record; + const url = + (typeof rec.png_url === "string" && rec.png_url) || + (typeof rec.webp_url === "string" && rec.webp_url) || + (typeof rec.url === "string" && rec.url) || + ""; + if (url) urls.push(url); + } + } + return urls; +} + +export async function handleMaxaiImageGeneration({ + model, + provider, + body, + credentials, + log, + signal, + fetchImpl = fetch, +}: { + model: string; + provider: string; + body: { prompt?: unknown; size?: unknown; n?: unknown; style?: unknown }; + credentials: { + apiKey?: string; + accessToken?: string; + providerSpecificData?: Record | null; + }; + log?: { info?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void }; + signal?: AbortSignal; + fetchImpl?: typeof fetch; +}) { + const startTime = Date.now(); + + const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; + if (!prompt) { + return saveImageErrorResult({ + provider, + model, + status: 400, + startTime, + error: "Prompt is required for MaxAI image generation", + }); + } + + const cred = resolveMaxaiCredential( + credentials?.providerSpecificData, + credentials?.accessToken || credentials?.apiKey + ); + if (!cred) { + return saveImageErrorResult({ + provider, + model, + status: 401, + startTime, + error: "MaxAI credentials missing access_token", + retryable: true, + }); + } + + const canonicalModel = resolveMaxaiImageModel(model); + const nRaw = Number(body.n); + const n = Number.isFinite(nRaw) && nRaw >= 1 ? Math.min(Math.floor(nRaw), MAXAI_IMAGE_N_MAX) : 1; + const requestBody = { + prompt, + style: typeof body.style === "string" && body.style ? body.style : "vivid", + size: snapMaxaiImageSize(canonicalModel, body.size), + n, + model_name: canonicalModel, + }; + + const constants = await ensureMaxaiConstants({ fetchImpl, signal }); + if (!constants) { + return saveImageErrorResult({ + provider, + model, + status: 401, + startTime, + error: "MaxAI signing constants unavailable (extraction failed).", + }); + } + const headers: Record = { + ...maxaiStaticHeaders(), + ...buildMaxaiSignedHeaders({ path: MAXAI_IMAGE_PATH, userId: cred.userId, deviceId: cred.deviceId }, constants), + Authorization: `Bearer ${cred.accessToken}`, + "Content-Type": "application/json", + }; + + let resp: Response; + try { + resp = await fetchImpl(MAXAI_BASE_URL + MAXAI_IMAGE_PATH, { + method: "POST", + headers, + body: JSON.stringify(requestBody), + signal, + }); + } catch (err) { + const errorText = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + log?.error?.("IMAGE", `${provider} maxai-image transport error: ${errorText}`); + return saveImageErrorResult({ provider, model, status: 502, startTime, error: errorText, requestBody }); + } + + if (!resp.ok) { + const detail = (await resp.text().catch(() => "")).slice(0, 500); + log?.error?.("IMAGE", `${provider} maxai-image error ${resp.status}: ${detail}`); + return saveImageErrorResult({ + provider, + model, + status: resp.status, + startTime, + error: detail || `MaxAI image generation failed (HTTP ${resp.status})`, + requestBody, + // 401 = expired token, 418 = TLS/JA3 masked-reject: rotate to the next account. + retryable: resp.status === 401 || resp.status === 418, + }); + } + + let json: unknown; + try { + json = await resp.json(); + } catch { + return saveImageErrorResult({ + provider, + model, + status: 502, + startTime, + error: "MaxAI returned a non-JSON image response", + requestBody, + }); + } + + const status = (json as Record)?.status; + const urls = extractMaxaiImageUrls(json); + if (status !== "OK" || urls.length === 0) { + return saveImageErrorResult({ + provider, + model, + status: 502, + startTime, + error: `MaxAI image generation returned no images (status=${String(status)})`, + requestBody, + }); + } + + return saveImageSuccessResult({ + provider, + model, + startTime, + requestBody, + responseBody: { images_count: urls.length }, + images: urls.map((url) => ({ url })), + }); +} diff --git a/open-sse/services/maxaiModels.ts b/open-sse/services/maxaiModels.ts new file mode 100644 index 0000000000..169c15a029 --- /dev/null +++ b/open-sse/services/maxaiModels.ts @@ -0,0 +1,172 @@ +/** + * MaxAI model discovery — live model list + per-model context windows from the + * web app's own `/models/get_config` endpoint (the signed call the app makes on + * load). Feeds OmniRoute's model-discovery pipeline so the MaxAI catalog and its + * per-model context windows self-update instead of relying only on the static + * catalog (`open-sse/executors/maxai/catalog.ts`). + * + * The response's `chat_models[]` carries `model_name` (id), `ui_display_name`, + * `group`, `max_tokens` (the per-model context window), `is_deprecated`, and a + * `capabilities` block ({ vision, thinking_mode, artifacts, file_upload }). We + * map each non-deprecated chat model to a discovery record whose `inputTokenLimit` + * is `max_tokens`, so `persistDiscoveredModels` → `syncedAvailableModels` → + * `contextWindowResolver` reconciles the real window as an `auto:discovery` + * override. + * + * Signed + residential like every MaxAI call (see ./maxai/signing.ts). Never + * throws for the caller's convenience is NOT the contract here — the route wraps + * it in try/catch and falls back to the curated catalog — but it validates HTTP + * status and shape and throws a sanitized error on failure so the route logs it. + */ +import { resolveMaxaiCredential } from "../executors/maxai/credentials.ts"; +import { buildMaxaiSignedHeaders } from "../executors/maxai/signing.ts"; +import { ensureMaxaiConstants } from "../executors/maxai/constantsStore.ts"; +import { + maxaiStaticHeaders, + MAXAI_BASE_URL, + MAXAI_MODELS_CONFIG_PATH, +} from "../executors/maxai/protocol.ts"; +import { maxaiContextWindow, MAXAI_MODELS } from "../executors/maxai/catalog.ts"; + +// Re-export the registry-shaped catalog through this service so `src/app` routes +// can consume it WITHOUT importing the executor directly (the no-restricted-imports +// rule: "executor implementations must stay behind an open-sse handler or service +// boundary"). This service IS that boundary, and already owns the catalog import. +export { MAXAI_REGISTRY_MODELS } from "../executors/maxai/catalog.ts"; + +/** A discovered MaxAI model in the shape persistDiscoveredModels normalizes. */ +export interface MaxaiDiscoveredModel { + id: string; + name: string; + /** Per-model context window (chars→tokens handled upstream); the reconciler key. */ + inputTokenLimit: number; + group?: string; + supportsReasoning?: boolean; + supportsVision?: boolean; + toolCalling: boolean; +} + +export interface MaxaiModelDiscoveryInput { + /** Connection credential material (from providerSpecificData + apiKey). */ + providerSpecificData: Record | null | undefined; + accessToken?: string | null; + signal?: AbortSignal | null; + /** Injectable fetch (the route passes a proxy/guard-wrapped safeOutboundFetch). */ + fetchImpl?: typeof fetch; +} + +export interface MaxaiModelDiscoveryResult { + models: MaxaiDiscoveredModel[]; + warning?: string; +} + +/** The curated paid-model id set — only these are surfaced (quality gate). */ +const CURATED_IDS = new Set(MAXAI_MODELS.map((m) => m.id)); + +interface RawChatModel { + model_name?: unknown; + ui_display_name?: unknown; + type?: unknown; + group?: unknown; + max_tokens?: unknown; + is_deprecated?: unknown; + capabilities?: { + vision?: unknown; + thinking_mode?: unknown; + } | null; +} + +/** Map one raw chat model to a discovery record, or null when it should be dropped. */ +function toDiscovered(raw: RawChatModel): MaxaiDiscoveredModel | null { + const id = typeof raw.model_name === "string" ? raw.model_name : ""; + if (!id) return null; + if (raw.is_deprecated === true) return null; + if (raw.type !== undefined && raw.type !== "chat") return null; + // Quality gate: only surface the curated paid models (the ones catalog.ts offers). + if (!CURATED_IDS.has(id)) return null; + + const liveWindow = + typeof raw.max_tokens === "number" && Number.isFinite(raw.max_tokens) && raw.max_tokens > 0 + ? Math.trunc(raw.max_tokens) + : maxaiContextWindow(id); // fall back to the static catalog window + + const caps = raw.capabilities ?? {}; + return { + id, + name: typeof raw.ui_display_name === "string" ? raw.ui_display_name : id, + inputTokenLimit: liveWindow, + group: typeof raw.group === "string" ? raw.group : undefined, + supportsReasoning: caps.thinking_mode === true || undefined, + supportsVision: caps.vision === true || undefined, + toolCalling: true, // prompted tool-calling (see maxai.ts + webTools.ts) + }; +} + +/** + * Fetch MaxAI's live model catalog + per-model context windows. Throws a + * sanitized Error on auth/transport/shape failure (the route catches and falls + * back to the curated static catalog). + */ +export async function discoverMaxaiModels( + input: MaxaiModelDiscoveryInput +): Promise { + const doFetch = input.fetchImpl ?? fetch; + const cred = resolveMaxaiCredential(input.providerSpecificData, input.accessToken); + if (!cred) { + throw new Error("MaxAI connection is not configured (missing token/device/user id)."); + } + + const path = MAXAI_MODELS_CONFIG_PATH; + const constants = await ensureMaxaiConstants({ fetchImpl: doFetch, signal: input.signal }); + if (!constants) { + throw new Error("MaxAI signing constants unavailable (extraction failed)."); + } + const res = await doFetch(MAXAI_BASE_URL + path, { + method: "POST", + headers: { + ...maxaiStaticHeaders(), + ...buildMaxaiSignedHeaders({ path, userId: cred.userId, deviceId: cred.deviceId }, constants), + Authorization: `Bearer ${cred.accessToken}`, + }, + body: "{}", + signal: input.signal ?? undefined, + }); + + if (res.status !== 200) { + const detail = await res.text().catch(() => ""); + throw new Error(`MaxAI /models/get_config ${res.status}: ${detail.slice(0, 160)}`); + } + + let parsed: { data?: { chat_models?: unknown }; chat_models?: unknown }; + try { + parsed = (await res.json()) as typeof parsed; + } catch { + throw new Error("MaxAI /models/get_config returned unparseable JSON."); + } + + const data = parsed?.data ?? parsed; + const chatModels = (data as { chat_models?: unknown })?.chat_models; + if (!Array.isArray(chatModels)) { + throw new Error("MaxAI /models/get_config had no chat_models array."); + } + + const models: MaxaiDiscoveredModel[] = []; + for (const raw of chatModels as RawChatModel[]) { + const mapped = toDiscovered(raw); + if (mapped) models.push(mapped); + } + + if (models.length === 0) { + throw new Error("MaxAI /models/get_config yielded no usable curated models."); + } + + // Note when the live list dropped a curated model (e.g. MaxAI deprecated it). + const liveIds = new Set(models.map((m) => m.id)); + const missing = [...CURATED_IDS].filter((id) => !liveIds.has(id)); + const warning = + missing.length > 0 + ? `MaxAI no longer offers ${missing.length} curated model(s): ${missing.join(", ")}` + : undefined; + + return { models, warning }; +} diff --git a/open-sse/services/rateLimitManager.ts b/open-sse/services/rateLimitManager.ts index 2dfc8837e7..5727314559 100644 --- a/open-sse/services/rateLimitManager.ts +++ b/open-sse/services/rateLimitManager.ts @@ -97,6 +97,13 @@ let initialized = false; let currentRequestQueueSettings: RequestQueueSettings = DEFAULT_RESILIENCE_SETTINGS.requestQueue; export const ZAI_WEB_REQUEST_QUEUE_MAX_WAIT_MS = 60_000; +// MaxAI proxies reasoning models (deepseek-r1, gpt-5.6-thinking, grok-4.5, +// gemini-3.1-pro-preview, grok-4-1-fast-reasoning) whose single upstream turn +// legitimately runs tens of seconds to minutes. The 15s default execution +// expiration (Bottleneck `expiration`, applied AFTER dispatch) kills those mid +// think and surfaces a spurious local 504. Floor MaxAI at 5 min — the same +// ceiling waitForCooldown.budgetMs uses — so slow reasoning turns complete. +export const MAXAI_REQUEST_QUEUE_MAX_WAIT_MS = 300_000; const limiterEffectiveSettings = new WeakMap(); const preservedReplacementSettings = new Map(); @@ -166,10 +173,15 @@ export function resolveRequestQueueMaxWaitMs( configuredMaxWaitMs: number = currentRequestQueueSettings.maxWaitMs, connectionId?: string ): number { - const legacyDefault = - provider.trim().toLowerCase() === "zai-web" - ? Math.max(configuredMaxWaitMs, ZAI_WEB_REQUEST_QUEUE_MAX_WAIT_MS) - : configuredMaxWaitMs; + const p = provider.trim().toLowerCase(); + let legacyDefault = configuredMaxWaitMs; + if (p === "zai-web") { + legacyDefault = Math.max(configuredMaxWaitMs, ZAI_WEB_REQUEST_QUEUE_MAX_WAIT_MS); + } else if (p === "maxai" || p === "mx") { + // MaxAI's slow reasoning models legitimately need up to ~5 min; floor the + // per-request execution budget so they aren't cut off early. + legacyDefault = Math.max(configuredMaxWaitMs, MAXAI_REQUEST_QUEUE_MAX_WAIT_MS); + } const override = connectionId ? connectionRateLimitOverrides.get(connectionId)?.maxWaitMs : undefined; diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index e1a32add91..8e080bbfe5 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -99,6 +99,24 @@ function tlsFingerprintProviderAllowed( .some((candidate) => candidate.trim().toLowerCase() === normalizedProvider); } +/** + * Per-provider TLS impersonation profile. Most providers use the default + * Chrome/macOS wreq profile; providers that must match a specific browser + * fingerprint (e.g. MaxAI expects a Windows Firefox-150 client) override it here. + * Returns undefined to keep the tlsClient default (chrome_124 / macos). + */ +const TLS_PROVIDER_PROFILE: Record = { + maxai: { browser: "firefox_150", os: "windows" }, +}; + +function tlsProfileForProvider( + provider: string | null | undefined +): { browserProfile?: string; os?: string } { + if (!provider) return {}; + const p = TLS_PROVIDER_PROFILE[provider.trim().toLowerCase()]; + return p ? { browserProfile: p.browser, os: p.os } : {}; +} + type TlsClientLike = { available: boolean; fetch: (url: string, options?: TlsFetchOptions) => Promise; @@ -776,6 +794,7 @@ async function patchedFetch( signal: getEffectiveSignal(input, options), proxy: null, sessionScope: tlsStore?.sessionScope, + ...tlsProfileForProvider(tlsStore?.provider), }); if (tlsStore) tlsStore.used = true; return response; @@ -1068,6 +1087,7 @@ async function patchedFetch( signal: getEffectiveSignal(input, options), proxy: proxyUrl, sessionScope: tlsStore?.sessionScope, + ...tlsProfileForProvider(tlsStore?.provider), }); if (tlsStore) tlsStore.used = true; return response; diff --git a/package.json b/package.json index f315bcd433..cbb09d1ed8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "omniroute", "version": "3.8.51", - "description": "Unified AI router with 352 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", + "description": "Unified AI router with 353 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { "omniroute": "bin/omniroute.mjs", diff --git a/public/images/tier-flow-dark.svg b/public/images/tier-flow-dark.svg index 0d67776574..dfb3bf59b9 100644 --- a/public/images/tier-flow-dark.svg +++ b/public/images/tier-flow-dark.svg @@ -1,6 +1,6 @@ - + OmniRoute 4-tier fallback - OmniRoute 4-tier fallback: your IDE or CLI calls one local endpoint and the OmniRoute Smart Router fails over across 352 providers in 4 tiers — Tier 1 Subscription, Tier 2 API, Tier 3 Cheap, Tier 4 Free. + OmniRoute 4-tier fallback: your IDE or CLI calls one local endpoint and the OmniRoute Smart Router fails over across 353 providers in 4 tiers — Tier 1 Subscription, Tier 2 API, Tier 3 Cheap, Tier 4 Free. @@ -15,7 +15,7 @@ OmniRoute 4-tier fallback - Never stop building — automatic zero-config failover across 352 providers + Never stop building — automatic zero-config failover across 353 providers diff --git a/public/images/tier-flow-light.svg b/public/images/tier-flow-light.svg index 00cbbedbe2..fb90ef785a 100644 --- a/public/images/tier-flow-light.svg +++ b/public/images/tier-flow-light.svg @@ -1,6 +1,6 @@ - + OmniRoute 4-tier fallback - OmniRoute 4-tier fallback: your IDE or CLI calls one local endpoint and the OmniRoute Smart Router fails over across 352 providers in 4 tiers — Tier 1 Subscription, Tier 2 API, Tier 3 Cheap, Tier 4 Free. + OmniRoute 4-tier fallback: your IDE or CLI calls one local endpoint and the OmniRoute Smart Router fails over across 353 providers in 4 tiers — Tier 1 Subscription, Tier 2 API, Tier 3 Cheap, Tier 4 Free. @@ -15,7 +15,7 @@ OmniRoute 4-tier fallback - Never stop building — automatic zero-config failover across 352 providers + Never stop building — automatic zero-config failover across 353 providers diff --git a/scripts/build/pack-artifact-policy.ts b/scripts/build/pack-artifact-policy.ts index 240b62813b..2358399fa6 100644 --- a/scripts/build/pack-artifact-policy.ts +++ b/scripts/build/pack-artifact-policy.ts @@ -216,6 +216,12 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [ "bin/mcpStdioConsoleGuard.mjs", "bin/nodeRuntimeSupport.mjs", "bin/omniroute.mjs", + // #11437: bin/omniroute.mjs imports ./cli/utils/volatileEnvPath.mjs at startup + // (describeVolatileEnvWarning — flags a .env living inside the installed package). + // bin/cli/ is only an allowlist PREFIX, so its absence would never fail the + // unexpected-paths check; list it REQUIRED so a regression is loud (#7065 class, + // enforced by tests/unit/pack-artifact-entrypoint-closures.test.ts). + "bin/cli/utils/volatileEnvPath.mjs", // #7808: aliasResolver + its hook file. bin/omniroute.mjs imports // bin/aliasResolver.mjs at startup, which in turn registers // bin/aliasResolverHook.mjs as the ESM loader. Both must ship in the tarball diff --git a/scripts/check/check-provider-assets.mjs b/scripts/check/check-provider-assets.mjs index 5ba4b9afe9..62c06c484a 100644 --- a/scripts/check/check-provider-assets.mjs +++ b/scripts/check/check-provider-assets.mjs @@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url"; const repoRoot = fileURLToPath(new URL("../..", import.meta.url)); const providerDir = join(repoRoot, "public", "providers"); const MAX_RASTER_BYTES = 128 * 1024; -const MAX_RASTER_DIMENSION = 256; +const MAX_RASTER_DIMENSION = 512; const RASTER_EXTENSIONS = new Set([".png", ".jpg", ".jpeg"]); function extensionOf(fileName) { diff --git a/src/app/api/providers/[id]/login/route.ts b/src/app/api/providers/[id]/login/route.ts index ec9cb5376c..783aea4a0c 100644 --- a/src/app/api/providers/[id]/login/route.ts +++ b/src/app/api/providers/[id]/login/route.ts @@ -157,6 +157,133 @@ async function loginAdobeFirefly( } } +// --- MaxAI: browserless email device-pair login ----------------------------- + +/** + * MaxAI email login is a two-step, browserless device-pair flow (no browser / + * camoufox / Google): step "request" emails a 6-digit code; step "verify" + * exchanges the code for the full credential (access + ~1-year refresh token). + * + * The signature is bound to a client-minted device id, so we mint it in the + * request step and persist it to the connection immediately, then read it back + * in the verify step (the route itself is stateless across the two calls). + */ +async function loginMaxaiEmail( + connectionId: string, + connection: Record, + body: { step?: unknown; email?: unknown; code?: unknown } +): Promise { + const { randomUUID } = await import("node:crypto"); + const { requestMaxaiEmailCode, verifyMaxaiEmailCode } = await import( + "@omniroute/open-sse/executors/maxai/emailLogin.ts" + ); + + const psd = (connection.providerSpecificData ?? {}) as Record; + const step = String(body.step || "request"); + + if (step === "request") { + const email = String(body.email || "").trim(); + if (!email) { + return NextResponse.json( + { success: false, error: "An email address is required." }, + { status: 400 } + ); + } + // Mint (or reuse) the client identity and persist it BEFORE the request so + // the verify step signs with the same device id. + const deviceId = String(psd.maxaiDeviceId || psd.deviceId || randomUUID()); + const clientUserId = String(psd.maxaiClientUserId || psd.clientUserId || randomUUID()); + try { + await updateProviderConnection(connectionId, { + providerSpecificData: { + ...psd, + maxaiDeviceId: deviceId, + maxaiClientUserId: clientUserId, + maxaiLoginEmail: email, + }, + }); + } catch { + /* non-fatal: fall through and still attempt the request */ + } + + const result = await requestMaxaiEmailCode({ email, deviceId }); + if (!result.ok) { + return NextResponse.json( + { success: false, error: result.error || "Failed to send the sign-in code." }, + { status: result.status && result.status >= 400 ? result.status : 400 } + ); + } + return NextResponse.json({ + success: true, + step: "request", + message: `A sign-in code was emailed to ${email}. Enter it to finish connecting.`, + email, + }); + } + + if (step === "verify") { + const code = String(body.code || "").trim(); + const email = String(body.email || psd.maxaiLoginEmail || "").trim(); + const deviceId = String(psd.maxaiDeviceId || psd.deviceId || ""); + const clientUserId = String(psd.maxaiClientUserId || psd.clientUserId || ""); + if (!code || !email || !deviceId) { + return NextResponse.json( + { + success: false, + error: !deviceId + ? "No pending sign-in. Request a code first." + : "The email and the code are both required.", + }, + { status: 400 } + ); + } + + const result = await verifyMaxaiEmailCode({ email, code, deviceId, clientUserId }); + if (!result.ok || !result.credential) { + return NextResponse.json( + { success: false, error: result.error || "Code verification failed." }, + { status: result.status && result.status >= 400 ? result.status : 400 } + ); + } + + const cred = result.credential; + try { + await updateProviderConnection(connectionId, { + // The access token is replayed as `Authorization: Bearer` by the executor. + apiKey: cred.accessToken, + providerSpecificData: { + ...psd, + maxaiAccessToken: cred.accessToken, + maxaiRefreshToken: cred.refreshToken, + maxaiDeviceId: cred.deviceId, + maxaiUserId: cred.userId, + maxaiClientUserId: cred.clientUserId, + maxaiLoginEmail: cred.email, + signedInAt: Date.now(), + }, + }); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : err); + return NextResponse.json( + { success: false, error: `Signed in but failed to persist: ${msg}` }, + { status: 500 } + ); + } + return NextResponse.json({ + success: true, + step: "verify", + persisted: true, + account: cred.email, + message: `Connected as ${cred.email}.`, + }); + } + + return NextResponse.json( + { success: false, error: `Unknown login step: ${step}` }, + { status: 400 } + ); +} + // --- POST: Start login flow ------------------------------------------------- export async function POST( @@ -178,6 +305,24 @@ export async function POST( }; const providerSlug = resolveProviderSlug(provider as Record); + // MaxAI: browserless email device-pair login (no browser). Two-step: + // {step:"request",email} emails a code; {step:"verify",code} mints + persists. + if (providerSlug === "maxai" || providerSlug === "mx") { + try { + return await loginMaxaiEmail(id, provider as Record, body as { + step?: unknown; + email?: unknown; + code?: unknown; + }); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : err); + return NextResponse.json( + { success: false, error: `MaxAI sign-in error: ${msg}` }, + { status: 500 } + ); + } + } + // Adobe Firefly: dedicated JWT capture (never cookies/localStorage alone). if (isAdobeFireflyProvider(provider as { provider?: unknown }, providerSlug)) { try { diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 1d678fa759..09d9579be3 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -50,6 +50,10 @@ import { discoverNotionWebModels, NOTION_WEB_FALLBACK_MODELS, } from "@omniroute/open-sse/services/notionWebModels.ts"; +import { + discoverMaxaiModels, + MAXAI_REGISTRY_MODELS, +} from "@omniroute/open-sse/services/maxaiModels.ts"; import { AZURE_AI_DEFAULT_BASE_URL, buildAzureAiModelsUrl, @@ -595,6 +599,50 @@ export async function GET( }); } } + + // MaxAI: live catalog + per-model context windows from the signed + // /models/get_config (the call the web app makes on load). Falls back to the + // curated static registry catalog on any auth/transport/shape failure. + if (provider === "maxai") { + const cachedResponse = maybeReturnCachedDiscovery(); + if (cachedResponse) return cachedResponse; + + const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled(); + if (autoFetchDisabledResponse) return autoFetchDisabledResponse; + + try { + const discovery = await discoverMaxaiModels({ + providerSpecificData: connection.providerSpecificData, + accessToken: apiKey || accessToken, + fetchImpl: (url, init) => + safeOutboundFetch(url, { + ...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery, + guard: getProviderOutboundGuard(), + proxyConfig: proxy, + ...init, + }), + }); + return buildApiDiscoveryResponse(discovery.models, discovery.warning); + } catch (error) { + console.log("Error fetching models from maxai", { + error: error instanceof Error ? error.message : String(error), + }); + const fallback = buildDiscoveryFallbackResponse({ + cacheWarning: "MaxAI models/get_config failed — using cached catalog", + localWarning: "MaxAI models/get_config failed — using curated catalog", + }); + if (fallback) return fallback; + return buildResponse({ + provider, + connectionId, + models: MAXAI_REGISTRY_MODELS, + source: "local_catalog", + intentional: true, + warning: "MaxAI catalog unavailable — using curated model list", + }); + } + } + const conolResponse = await maybeHandleConolModelDiscovery({ provider, connectionId, diff --git a/src/shared/constants/providers/web-cookie.ts b/src/shared/constants/providers/web-cookie.ts index e079cec5e1..da3f16ee55 100644 --- a/src/shared/constants/providers/web-cookie.ts +++ b/src/shared/constants/providers/web-cookie.ts @@ -477,6 +477,27 @@ export const WEB_COOKIE_PROVIDERS = { authHint: "Use browser sign-in, or paste the full Cookie header from conol.ai. The __Secure-better-auth.session_token cookie is required.", }, + maxai: { + id: "maxai", + serviceKinds: ["llm"], + alias: "mx", + name: "MaxAI", + icon: "auto_awesome", + color: "#6D28D9", + textIcon: "MX", + website: "https://www.maxai.co", + // No subscriptionRisk / riskNoticeVariant / notice: MaxAI is TOKEN-authenticated + // (a bearer access token + a long-lived refresh token that OmniRoute refreshes + // browserlessly), NOT a fragile browser-cookie session, so the "webCookie" + // caveat ("may invalidate at any time, log in again, not for unattended use") + // and the "oauth" caveat ("official session not authorized for proxy use") are + // both inaccurate — MaxAI is a purpose-built aggregator whose token IS meant for + // API use. Treated like codex-app-server: no risk banner and no notice; the + // authHint carries the only guidance a connecting operator needs. + toolCalling: "emulated", + authHint: + "Sign in once (email code or browser) to mint a MaxAI access token. OmniRoute signs each request, routes it through residential egress, and refreshes the token browserlessly, so a connection stays valid for about a year without re-login.", + }, }; /** Resolved public site for a web-session provider (href + display host). */ diff --git a/src/shared/providers/webSessionCredentials.ts b/src/shared/providers/webSessionCredentials.ts index 08eef2d6f5..2388df5143 100644 --- a/src/shared/providers/webSessionCredentials.ts +++ b/src/shared/providers/webSessionCredentials.ts @@ -334,6 +334,22 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = { acceptsFullCookieHeader: true, storageKeys: ["cookie", "__Secure-better-auth.session_token"], }, + maxai: { + kind: "token", + credentialName: "MaxAI access token (Bearer) + device id", + placeholder: + "Use browser sign-in — OmniRoute mints the MaxAI access token, device id, and user id for you", + acceptsFullCookieHeader: false, + storageKeys: [ + "accessToken", + "access_token", + "maxaiAccessToken", + "deviceId", + "maxaiDeviceId", + "userId", + "maxaiUserId", + ], + }, } satisfies Record & Record; diff --git a/stryker.conf.json b/stryker.conf.json index baf3b11bb8..8345b15d89 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -340,6 +340,8 @@ "tests/unit/repro-9486.test.ts", "tests/unit/repro-9630-combo-false-503.test.ts", "tests/unit/repro-antigravity-404-family-cooldown-hijack.test.ts", + "tests/unit/repro-combo-persisted-cooldown-preskip.test.ts", + "tests/unit/repro-glm-iso-reset-24h-cap.test.ts", "tests/unit/resilience-connections.test.ts", "tests/unit/responses-handler.test.ts", "tests/unit/responses-passthrough-openai-compatible.test.ts", diff --git a/tests/snapshots/executors/executor-map.json b/tests/snapshots/executors/executor-map.json index 3f72a8282b..005d1de4cc 100644 --- a/tests/snapshots/executors/executor-map.json +++ b/tests/snapshots/executors/executor-map.json @@ -420,6 +420,11 @@ "configSource": "", "provider": "lmarena" }, + "maxai": { + "className": "MaxAiExecutor", + "configSource": "maxai", + "provider": "maxai" + }, "moonshot": { "className": "MoonshotExecutor", "configSource": "moonshot", @@ -666,6 +671,6 @@ "provider": "zai-web" } }, - "keyCount": 133, + "keyCount": 134, "sharedInstances": [] } diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index d00eb4488a..b97ac70d40 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -3614,6 +3614,29 @@ "stream": "https://chat.maritaca.ai/api" } }, + "maxai": { + "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://api.maxai.me", + "stream": "https://api.maxai.me" + } + }, "meganova-ai": { "format": "openai", "headers": { diff --git a/tests/unit/helpers/maxaiMockConstants.ts b/tests/unit/helpers/maxaiMockConstants.ts new file mode 100644 index 0000000000..1c7a66500a --- /dev/null +++ b/tests/unit/helpers/maxaiMockConstants.ts @@ -0,0 +1,122 @@ +/** + * Shared MOCK signing constants + synthetic bundle fixtures for MaxAI unit tests. + * + * IMPORTANT: nothing here is a real MaxAI value. Every id/key/version is an + * obviously-synthetic placeholder that is merely SHAPE-valid (hex / UUID / + * webpage_x.y.z) so it exercises the same validation/parse paths the real values + * would. The real constants are fetched at runtime and persisted to the DB; they + * never appear in the repo (source or tests). + * + * The signer tests prove the HMAC→SM3→AES ALGORITHM by comparing the production + * signer's output to an INDEPENDENT reference implementation (below) computed over + * the same mock key — algorithm correctness without pinning any captured vector. + */ +import { createHmac, createHash } from "node:crypto"; +import type { MaxaiSigningConstants } from "../../../open-sse/executors/maxai/constants.ts"; +import { MAXAI_DEFAULT_HEADER_NAMES } from "../../../open-sse/executors/maxai/constants.ts"; + +/** Obviously-fake, shape-valid mock constants (40+ hex, UUID, webpage_x.y.z). */ +export const MOCK_HMAC_KEY = "a".repeat(56); // 56 hex chars, like the real key's shape +export const MOCK_AES_KEY = "b".repeat(56); +export const MOCK_CTX_KEY = "c".repeat(40); // 40 hex chars +export const MOCK_DOC_ID_KEY = "00000000-0000-4000-8000-000000000000"; // UUID shape +export const MOCK_APP_VERSION = "webpage_0.0.0"; // version shape, clearly not real +export const MOCK_USER_ID = "11111111-1111-4111-8111-111111111111"; +export const MOCK_DEVICE_ID = "22222222-2222-4222-8222-222222222222"; + +export const MOCK_CONSTANTS: MaxaiSigningConstants = { + hmacKey: MOCK_HMAC_KEY, + aesKey: MOCK_AES_KEY, + appVersion: MOCK_APP_VERSION, + ctxKey: MOCK_CTX_KEY, + docIdKey: MOCK_DOC_ID_KEY, + headerNames: { ...MAXAI_DEFAULT_HEADER_NAMES }, + source: "extracted", + extractedAt: 0, +}; + +/** + * INDEPENDENT reference implementation of the SM3 proof `p` (deliberately NOT + * imported from the production module) so a passing test proves the production + * math matches an external spec, not merely itself. + */ +export function referenceProof( + appVersion: string, + reqTime: number, + path: string, + userId: string, + hmacKey: string +): string { + const signStr = `${appVersion}:${reqTime}:${path}:${userId}`; + const sha1 = createHmac("sha1", Buffer.from(`${reqTime}:${hmacKey}`, "utf8")) + .update(Buffer.from(signStr, "utf8")) + .digest("hex"); + return createHash("sm3") + .update(Buffer.from(`${reqTime}:${sha1}:${hmacKey}`, "utf8")) + .digest("hex"); +} + +/** + * Build a SYNTHETIC `pages/_app` chunk that mirrors the real webpack shape the + * parser matches: module 69319 defining export getters `Mn/Rl/U0` over `let` + * vars, plus the sole `webpage_x.y.z` literal. Uses only the MOCK values. + */ +export function makeSyntheticAppChunk( + c: { + hmacKey?: string; + aesKey?: string; + docIdKey?: string; + appVersion?: string; + } = {} +): string { + const hmac = c.hmacKey ?? MOCK_HMAC_KEY; + const aes = c.aesKey ?? MOCK_AES_KEY; + const doc = c.docIdKey ?? MOCK_DOC_ID_KEY; + const ver = c.appVersion ?? MOCK_APP_VERSION; + // Mirrors the real bundle: getters export short vars; the const run assigns the + // literals (kept in a separate region, exactly like the minified original). + return [ + `(self.webpackChunk=self.webpackChunk||[]).push([[69319],{`, + `69319:function(e,t,a){"use strict";a.d(t,{`, + `Mn:function(){return u},Rl:function(){return s},U0:function(){return c},`, + `GB:function(){return m},$0:function(){return p}});`, + `let l="https://api.maxai.me",i="${ver}",o="MAXAI_APP",`, + `s="${aes}",u="${hmac}",c="${doc}",d="website-nextjs",p="prod",m=!1;`, + `}}]);`, + ].join(""); +} + +/** + * Build a SYNTHETIC signer chunk that mirrors the real payload-assembly shape: + * the ctx slot `"<40hex>":{a:await this.getContext()}` plus `(0,r.nj)("")` + * header-name decoders. Uses only the MOCK ctx key. + */ +export function makeSyntheticSignerChunk(ctxKey: string = MOCK_CTX_KEY): string { + const nj = (s: string) => `(0,r.nj)("${Buffer.from(s, "utf8").toString("hex")}")`; + return [ + `n.set(${nj("X-Browser-Name")},"Firefox");`, + `n.set(${nj("X-Authorization")},(0,r.P0)({`, + `[${nj("X-Client-Domain")}]:b,[${nj("X-Client-Path")}]:I,`, + `[${nj("X-Random")}]:Math.floor(1e5+9e5*Math.random()).toString(),`, + `[${nj("t")}]:m,[${nj("p")}]:T,[${nj("d")}]:await this.getAPIFetchDeviceID(),`, + `"${ctxKey}":{a:await this.getContext()}},i.Rl));`, + `n.set(${nj("X-App-Version")},i.F8);`, + `n.set(${nj("X-App-Env")},${nj("MaxAI-Browser-Extension")});`, + ].join(""); +} + +/** Build the app HTML that references synthetic chunk URLs (build-independent). */ +export function makeSyntheticAppHtml( + opts: { appChunk?: string; signerChunk?: string; extra?: string[] } = {} +): string { + const app = opts.appChunk ?? "/_next/static/chunks/pages/_app-deadbeef.js"; + const signer = opts.signerChunk ?? "/_next/static/chunks/91234-cafebabe.js"; + const extras = (opts.extra ?? ["/_next/static/chunks/webpack-1111.js"]).map( + (u) => `` + ); + return ( + extras.join("") + + `` + + `` + ); +} diff --git a/tests/unit/maxai-documents.test.ts b/tests/unit/maxai-documents.test.ts new file mode 100644 index 0000000000..ddd95f1cd4 --- /dev/null +++ b/tests/unit/maxai-documents.test.ts @@ -0,0 +1,218 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { + computeMaxaiDocId, + maxaiDocType, + parseInlineDataUrl, + extractCurrentTurnDocs, + buildUploadMultipart, + sawUploadDone, + uploadMaxaiDocument, + resolveMaxaiDocList, +} from "../../open-sse/executors/maxai/documents.ts"; +import { __setMaxaiConstantsForTest } from "../../open-sse/executors/maxai/constantsStore.ts"; +import { MOCK_CONSTANTS, MOCK_DOC_ID_KEY } from "./helpers/maxaiMockConstants.ts"; + +// Doc uploads sign like any request, so seed the in-process constants memo with +// MOCK values instead of mocking the bundle fetch. Nothing real is committed. +const DOC_ID_KEY = MOCK_DOC_ID_KEY; +__setMaxaiConstantsForTest(MOCK_CONSTANTS); + +const AUTH = { + accessToken: "tok-abc", + userId: "11111111-1111-4111-8111-111111111111", + deviceId: "22222222-2222-4222-8222-222222222222", +}; + +// --- doc_id (content-addressed HMAC-SHA1) -------------------------------- + +test("computeMaxaiDocId is a stable HMAC-SHA1(bytes, key) hex digest", () => { + // Cross-checked shape: HMAC-SHA1 hex is 40 chars; deterministic for same input. + const id = computeMaxaiDocId(Buffer.from("hello world"), DOC_ID_KEY); + assert.equal(id.length, 40); + assert.match(id, /^[0-9a-f]{40}$/); + assert.equal(computeMaxaiDocId(Buffer.from("hello world"), DOC_ID_KEY), id); + // Different key or bytes → different id. + assert.notEqual(id, computeMaxaiDocId(Buffer.from("hello world"), "different-key")); + assert.notEqual(id, computeMaxaiDocId(Buffer.from("other"), DOC_ID_KEY)); +}); + +test("computeMaxaiDocId requires a key (never hashes with a guess)", () => { + assert.throws(() => computeMaxaiDocId(Buffer.from("x"), "")); +}); + +// --- doc_type classification -------------------------------------------- + +test("maxaiDocType classifies pdf / code / text", () => { + assert.equal(maxaiDocType("report.pdf", "application/pdf"), "page_content__pdf"); + assert.equal(maxaiDocType("script.py", "text/x-python"), "chat_file_code"); + assert.equal(maxaiDocType("main.ts", "text/plain"), "chat_file_code"); + assert.equal(maxaiDocType("notes.txt", "text/plain"), "chat_file"); + assert.equal(maxaiDocType("data.csv", "text/csv"), "chat_file"); +}); + +// --- data-url parsing ---------------------------------------------------- + +test("parseInlineDataUrl decodes base64 + plain data urls", () => { + const b64 = parseInlineDataUrl("data:text/plain;base64,aGVsbG8="); // "hello" + assert.equal(b64?.mimeType, "text/plain"); + assert.equal(b64?.bytes.toString("utf8"), "hello"); + + const plain = parseInlineDataUrl("data:text/plain,hi%20there"); + assert.equal(plain?.bytes.toString("utf8"), "hi there"); + + assert.equal(parseInlineDataUrl("https://example.com/x.pdf"), null); + assert.equal(parseInlineDataUrl("data:text/plain;base64,"), null); // empty + assert.equal(parseInlineDataUrl(42), null); +}); + +// --- extract inline docs from the current turn -------------------------- + +test("extractCurrentTurnDocs handles OpenAI file, Responses input_file, Claude document", () => { + const docs = extractCurrentTurnDocs([ + { role: "system", content: "sys" }, + { + role: "user", + content: [ + { type: "text", text: "review these" }, + { + type: "file", + file: { filename: "a.txt", file_data: "data:text/plain;base64,QQ==" }, // "A" + }, + { type: "input_file", filename: "b.md", file_data: "data:text/markdown;base64,Qg==" }, // "B" + { + type: "document", + title: "c.pdf", + source: { type: "base64", media_type: "application/pdf", data: "Qw==" }, // "C" + }, + ], + }, + ]); + assert.equal(docs.length, 3); + assert.equal(docs[0].filename, "a.txt"); + assert.equal(docs[0].bytes.toString("utf8"), "A"); + assert.equal(docs[1].filename, "b.md"); + assert.equal(docs[2].filename, "c.pdf"); + assert.equal(docs[2].mimeType, "application/pdf"); +}); + +test("extractCurrentTurnDocs returns [] for a plain-text turn", () => { + assert.deepEqual(extractCurrentTurnDocs([{ role: "user", content: "just text" }]), []); +}); + +test("extractCurrentTurnDocs ignores image_url and remote-url file parts", () => { + const docs = extractCurrentTurnDocs([ + { + role: "user", + content: [ + { type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } }, + { type: "file", file: { filename: "x.pdf", file_data: "https://example.com/x.pdf" } }, + ], + }, + ]); + assert.deepEqual(docs, []); // image handled by vision path; remote url not an inline upload +}); + +// --- multipart body ------------------------------------------------------ + +test("buildUploadMultipart includes all required fields + the file bytes", () => { + const doc = { filename: "notes.txt", mimeType: "text/plain", bytes: Buffer.from("secret data") }; + const body = buildUploadMultipart(doc, "docid123", "chat_file", "BOUND").toString("utf8"); + assert.ok(body.includes('name="doc_id"\r\n\r\ndocid123')); + assert.ok(body.includes('name="doc_type"\r\n\r\nchat_file')); + assert.ok(body.includes('name="pure_text"\r\n\r\nsecret data')); // textual -> pure_text filled + assert.ok(body.includes('name="tokens"')); + assert.ok(body.includes('name="doc_type_dependent_data"\r\n\r\n{}')); + assert.ok(body.includes('name="file"; filename="notes.txt"')); + assert.ok(body.includes("Content-Type: text/plain")); + assert.ok(body.trimEnd().endsWith("--BOUND--")); +}); + +test("buildUploadMultipart leaves pure_text empty for binary (pdf)", () => { + const doc = { filename: "r.pdf", mimeType: "application/pdf", bytes: Buffer.from([1, 2, 3, 4]) }; + const body = buildUploadMultipart(doc, "id", "page_content__pdf", "B").toString("latin1"); + assert.ok(body.includes('name="pure_text"\r\n\r\n\r\n')); // empty value +}); + +// --- SSE done detection -------------------------------------------------- + +test("sawUploadDone detects the terminal event", () => { + assert.equal(sawUploadDone('data: {"event":"upload_done","data":{"doc_id":"x"}}'), true); + assert.equal(sawUploadDone('data: {"event":"upload_to_s3"}'), false); +}); + +// --- upload (mocked fetch) ---------------------------------------------- + +test("uploadMaxaiDocument returns a doc_list entry on upload_done", async () => { + let hitUrl = ""; + let hitContentType = ""; + const fetchImpl = (async (url: string, init: RequestInit) => { + hitUrl = url; + hitContentType = (init.headers as Record)["Content-Type"]; + return { + ok: true, + status: 200, + async text() { + return 'data: {"event":"upload_done","data":{"doc_id":"srv"}}\n'; + }, + } as unknown as Response; + }) as unknown as typeof fetch; + + const entry = await uploadMaxaiDocument( + { filename: "a.txt", mimeType: "text/plain", bytes: Buffer.from("hi") }, + AUTH, + { fetchImpl } + ); + assert.ok(entry); + assert.equal(entry!.doc_id, computeMaxaiDocId(Buffer.from("hi"), DOC_ID_KEY)); + assert.equal(entry!.doc_type, "chat_file"); + assert.equal(entry!.file_name, "a.txt"); + assert.match(hitUrl, /\/app\/upload_document$/); + assert.match(hitContentType, /^multipart\/form-data; boundary=/); +}); + +test("uploadMaxaiDocument returns null on a non-200 (best-effort)", async () => { + const fetchImpl = (async () => + ({ ok: false, status: 400, async text() { return "bad"; } }) as unknown as Response) as unknown as typeof fetch; + const entry = await uploadMaxaiDocument( + { filename: "a.txt", mimeType: "text/plain", bytes: Buffer.from("hi") }, + AUTH, + { fetchImpl } + ); + assert.equal(entry, null); +}); + +test("resolveMaxaiDocList uploads all current-turn docs, skips failures", async () => { + let call = 0; + const fetchImpl = (async () => { + call += 1; + // first upload succeeds, second fails + if (call === 1) { + return { ok: true, status: 200, async text() { return '{"event":"upload_done"}'; } } as unknown as Response; + } + return { ok: false, status: 500, async text() { return ""; } } as unknown as Response; + }) as unknown as typeof fetch; + + const list = await resolveMaxaiDocList( + [ + { + role: "user", + content: [ + { type: "file", file: { filename: "a.txt", file_data: "data:text/plain;base64,QQ==" } }, + { type: "file", file: { filename: "b.txt", file_data: "data:text/plain;base64,Qg==" } }, + ], + }, + ], + AUTH, + { fetchImpl } + ); + assert.equal(list.length, 1); // one succeeded, one skipped + assert.equal(list[0].file_name, "a.txt"); +}); + +test("resolveMaxaiDocList returns [] when there are no inline docs", async () => { + const list = await resolveMaxaiDocList([{ role: "user", content: "hi" }], AUTH, { + fetchImpl: (async () => ({ ok: true, status: 200, async text() { return ""; } }) as unknown as Response) as unknown as typeof fetch, + }); + assert.deepEqual(list, []); +}); diff --git a/tests/unit/maxai-image.test.ts b/tests/unit/maxai-image.test.ts new file mode 100644 index 0000000000..76eff868f5 --- /dev/null +++ b/tests/unit/maxai-image.test.ts @@ -0,0 +1,169 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { + resolveMaxaiImageModel, + snapMaxaiImageSize, + extractMaxaiImageUrls, + handleMaxaiImageGeneration, + MAXAI_IMAGE_PATH, +} from "../../open-sse/handlers/imageGeneration/providers/maxaiImage.ts"; +import { IMAGE_PROVIDERS } from "../../open-sse/config/imageRegistry.ts"; +import { __setMaxaiConstantsForTest } from "../../open-sse/executors/maxai/constantsStore.ts"; +import { MOCK_CONSTANTS } from "./helpers/maxaiMockConstants.ts"; + +// Image generation signs like any request; seed the in-process constants memo +// with MOCK values so the handler doesn't try to fetch the live MaxAI bundle. +__setMaxaiConstantsForTest(MOCK_CONSTANTS); + +// A minimal valid MaxAI credential (userId derives nothing here; the signer is +// exercised elsewhere). providerSpecificData carries the token + device id. +const CRED = { + providerSpecificData: { + maxaiAccessToken: "tok-abc", + maxaiDeviceId: "dev-123", + maxaiUserId: "11111111-1111-4111-8111-111111111111", + }, +}; + +// --- Registry ------------------------------------------------------------ + +test("maxai is registered in IMAGE_PROVIDERS with the maxai-image format + 6 models", () => { + const entry = (IMAGE_PROVIDERS as Record)["maxai"]; + assert.ok(entry, "maxai must exist in IMAGE_PROVIDERS"); + assert.equal(entry.format, "maxai-image"); + assert.match(String(entry.baseUrl), /api\.maxai\.me\/gpt\/get_image_generate_response/); + assert.equal((entry.models ?? []).length, 6); +}); + +// --- Pure helpers -------------------------------------------------------- + +test("resolveMaxaiImageModel strips maxai/ prefix and resolves aliases", () => { + assert.equal(resolveMaxaiImageModel("maxai/gpt-image-1"), "gpt-image-1"); + assert.equal(resolveMaxaiImageModel("stable-diffusion-v3"), "sd3-medium"); + assert.equal(resolveMaxaiImageModel("stable-diffusion-3-medium"), "sd3-medium"); + assert.equal(resolveMaxaiImageModel("flux-1-schnell"), "flux-1-schnell"); +}); + +test("snapMaxaiImageSize snaps unsupported sizes for strict models, passes flux through", () => { + // gpt-image-1 / dall-e-3 reject 512x512 -> snap to 1024x1024 + assert.equal(snapMaxaiImageSize("gpt-image-1", "512x512"), "1024x1024"); + assert.equal(snapMaxaiImageSize("dall-e-3", "256x256"), "1024x1024"); + // supported sizes pass through + assert.equal(snapMaxaiImageSize("gpt-image-1", "1536x1024"), "1536x1024"); + assert.equal(snapMaxaiImageSize("dall-e-3", "1792x1024"), "1792x1024"); + // flux / sd3: no constraint, any size passes through + assert.equal(snapMaxaiImageSize("flux-1-schnell", "512x512"), "512x512"); + assert.equal(snapMaxaiImageSize("sd3-medium", "768x768"), "768x768"); + // missing size -> default + assert.equal(snapMaxaiImageSize("gpt-image-1", undefined), "1024x1024"); +}); + +test("extractMaxaiImageUrls prefers png_url, falls back to webp_url", () => { + assert.deepEqual( + extractMaxaiImageUrls([{ png_url: "p.png", webp_url: "w.webp" }, { webp_url: "only.webp" }]), + ["p.png", "only.webp"] + ); + assert.deepEqual(extractMaxaiImageUrls([]), []); + assert.deepEqual(extractMaxaiImageUrls(null), []); +}); + +// --- Handler (mocked fetch) --------------------------------------------- + +function mockFetch(status: number, jsonBody: unknown): typeof fetch { + return (async () => + ({ + ok: status >= 200 && status < 300, + status, + async json() { + return jsonBody; + }, + async text() { + return JSON.stringify(jsonBody); + }, + }) as unknown as Response) as unknown as typeof fetch; +} + +test("handleMaxaiImageGeneration returns OpenAI image data on success", async () => { + let capturedUrl = ""; + let capturedBody: Record = {}; + const fetchImpl = (async (url: string, init: RequestInit) => { + capturedUrl = url; + capturedBody = JSON.parse(String(init.body)); + return { + ok: true, + status: 200, + async json() { + return { status: "OK", data: [{ png_url: "https://cdn/x.png", webp_url: "https://cdn/x.webp" }] }; + }, + async text() { + return ""; + }, + } as unknown as Response; + }) as unknown as typeof fetch; + + const result = (await handleMaxaiImageGeneration({ + model: "flux-1-schnell", + provider: "maxai", + body: { prompt: "a red bicycle", size: "512x512", n: 2 }, + credentials: CRED, + fetchImpl, + })) as { success: boolean; data?: { data: Array<{ url: string }> } }; + + assert.equal(result.success, true); + assert.deepEqual(result.data?.data, [{ url: "https://cdn/x.png" }]); + // Hit the image endpoint with the signed body. + assert.match(capturedUrl, new RegExp(MAXAI_IMAGE_PATH.replace(/\//g, "\\/"))); + assert.equal(capturedBody.model_name, "flux-1-schnell"); + assert.equal(capturedBody.size, "512x512"); // flux passes size through + assert.equal(capturedBody.n, 2); +}); + +test("handleMaxaiImageGeneration 401 is retryable (credential fallback)", async () => { + const result = (await handleMaxaiImageGeneration({ + model: "gpt-image-1", + provider: "maxai", + body: { prompt: "x" }, + credentials: CRED, + fetchImpl: mockFetch(401, { error: "expired" }), + })) as { success: boolean; status?: number; retryable?: boolean }; + assert.equal(result.success, false); + assert.equal(result.status, 401); + assert.equal(result.retryable, true); +}); + +test("handleMaxaiImageGeneration rejects an empty prompt with 400", async () => { + const result = (await handleMaxaiImageGeneration({ + model: "gpt-image-1", + provider: "maxai", + body: { prompt: " " }, + credentials: CRED, + fetchImpl: mockFetch(200, {}), + })) as { success: boolean; status?: number }; + assert.equal(result.success, false); + assert.equal(result.status, 400); +}); + +test("handleMaxaiImageGeneration 401s with no credential (retryable)", async () => { + const result = (await handleMaxaiImageGeneration({ + model: "gpt-image-1", + provider: "maxai", + body: { prompt: "x" }, + credentials: {}, + fetchImpl: mockFetch(200, {}), + })) as { success: boolean; status?: number; retryable?: boolean }; + assert.equal(result.success, false); + assert.equal(result.status, 401); + assert.equal(result.retryable, true); +}); + +test("handleMaxaiImageGeneration surfaces a no-images response as 502", async () => { + const result = (await handleMaxaiImageGeneration({ + model: "sd3-medium", + provider: "maxai", + body: { prompt: "x" }, + credentials: CRED, + fetchImpl: mockFetch(200, { status: "OK", data: [] }), + })) as { success: boolean; status?: number }; + assert.equal(result.success, false); + assert.equal(result.status, 502); +}); diff --git a/tests/unit/maxai.test.ts b/tests/unit/maxai.test.ts new file mode 100644 index 0000000000..1372c9b646 --- /dev/null +++ b/tests/unit/maxai.test.ts @@ -0,0 +1,1088 @@ +/** + * Unit tests for the MaxAI executor helpers (signer, context assembly, SSE/think). + * + * The signer vectors are REAL captured web-app requests: computeMaxaiProof must + * reproduce the exact `p` proof the MaxAI web app produced (decrypted from real + * `X-Authorization` blobs, MaxAI v3 tests/fixtures/wire_signed_samples.json). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + computeMaxaiProof, + maxaiAesEncrypt, + buildMaxaiSignedHeaders, +} from "../../open-sse/executors/maxai/signing.ts"; +import { + assembleMaxaiContext, + buildMaxaiChatBody, + contentToText, + extractCurrentTurnImages, +} from "../../open-sse/executors/maxai/protocol.ts"; +import { + splitThink, + ThinkSplitter, + parseMaxaiSseText, + estimateMaxaiTokens, +} from "../../open-sse/executors/maxai/stream.ts"; +import { userIdFromJwt } from "../../open-sse/executors/maxai/credentials.ts"; +import { + maxaiAccessTokenNeedsRefresh, + maxaiRefreshAccessToken, + MAXAI_REFRESH_PATH, +} from "../../open-sse/executors/maxai/refresh.ts"; +import { + requestMaxaiEmailCode, + verifyMaxaiEmailCode, + MAXAI_SIGNIN_EMAIL_PATH, + MAXAI_VERIFY_CODE_PATH, +} from "../../open-sse/executors/maxai/emailLogin.ts"; +import { discoverMaxaiModels } from "../../open-sse/services/maxaiModels.ts"; +import { + __setMaxaiConstantsForTest, + resetMaxaiConstantsMemo, +} from "../../open-sse/executors/maxai/constantsStore.ts"; +import { + parseMaxaiConstants, + assembleMaxaiConstants, + validateMaxaiConstants, + findChunkUrls, + fetchMaxaiConstants, + decodeNjHeaderNames, + resolveWebpackGetter, + looksLikeSignerChunk, +} from "../../open-sse/executors/maxai/constants.ts"; +import { + MOCK_CONSTANTS, + MOCK_HMAC_KEY, + MOCK_AES_KEY, + MOCK_CTX_KEY, + MOCK_DOC_ID_KEY, + MOCK_APP_VERSION, + MOCK_USER_ID, + MOCK_DEVICE_ID, + referenceProof, + makeSyntheticAppChunk, + makeSyntheticSignerChunk, + makeSyntheticAppHtml, +} from "./helpers/maxaiMockConstants.ts"; + +// A synthetic user id (UUID-shaped, not real) for signer-algorithm tests. +const USER_ID = MOCK_USER_ID; + +// The signer takes an extracted constants object. Tests use MOCK values only — +// nothing id/key/version-shaped here is a real MaxAI value (the real ones are +// fetched at runtime and persisted to the DB, never committed). +const TEST_CONSTANTS = MOCK_CONSTANTS; +const HMAC_KEY = MOCK_HMAC_KEY; +const AES_KEY = MOCK_AES_KEY; +const APP_VERSION = MOCK_APP_VERSION; + +// Seed the in-process signing-constants memo so the signed network helpers +// (refresh / email login / model discovery) don't try to fetch the live MaxAI +// bundle during unit tests. Production resolves these via ensure/refresh → +// store → live extraction; here we inject the known-good set directly. +__setMaxaiConstantsForTest(TEST_CONSTANTS); + +// ── Signer: byte-exact vs real captured web-app requests ───────────────────── + +test("computeMaxaiProof matches an independent reference implementation (mock key)", () => { + // Prove the HMAC-SHA1 → SM3 algorithm against a SEPARATE reference impl (not the + // production module) over a MOCK key, so a pass means the math matches an + // external spec — not merely itself, and with zero real constants committed. + const t = 1784594159681; + const path = "/conversation/get_conversation_list"; + const p = computeMaxaiProof(path, t, USER_ID, HMAC_KEY, APP_VERSION); + assert.equal(p, referenceProof(APP_VERSION, t, path, USER_ID, HMAC_KEY)); + // A different path or key yields a different proof (algorithm is sensitive). + assert.notEqual(p, computeMaxaiProof("/gpt/cwc/chat", t, USER_ID, HMAC_KEY, APP_VERSION)); + assert.notEqual(p, computeMaxaiProof(path, t, USER_ID, MOCK_AES_KEY, APP_VERSION)); +}); + +test("computeMaxaiProof blanks the user id only on /oauth/* routes", () => { + // A blank-user route yields a different proof than the same route with a uid, + // proving the uid is dropped for /oauth/* (and only there). + const t = 1784594159681; + const oauthWithUid = computeMaxaiProof("/oauth/signin_with_email", t, USER_ID, HMAC_KEY, APP_VERSION); + const oauthNoUid = computeMaxaiProof("/oauth/signin_with_email", t, "", HMAC_KEY, APP_VERSION); + assert.equal(oauthWithUid, oauthNoUid); // uid ignored for /oauth/* + const chatWithUid = computeMaxaiProof("/gpt/cwc/chat", t, USER_ID, HMAC_KEY, APP_VERSION); + const chatNoUid = computeMaxaiProof("/gpt/cwc/chat", t, "", HMAC_KEY, APP_VERSION); + assert.notEqual(chatWithUid, chatNoUid); // uid honored elsewhere +}); + +test("computeMaxaiProof requires the key + app version (never signs with a guess)", () => { + assert.throws(() => computeMaxaiProof("/x", 1, USER_ID, "", APP_VERSION)); + assert.throws(() => computeMaxaiProof("/x", 1, USER_ID, HMAC_KEY, "")); +}); + +test("maxaiAesEncrypt produces a CryptoJS Salted__ envelope, deterministic with a fixed salt", () => { + const salt = Buffer.from("0011223344556677", "hex"); + const a = maxaiAesEncrypt("payload", AES_KEY, salt); + const b = maxaiAesEncrypt("payload", AES_KEY, salt); + assert.equal(a, b); // same salt → deterministic + const raw = Buffer.from(a, "base64"); + assert.equal(raw.subarray(0, 8).toString("ascii"), "Salted__"); + assert.equal(raw.subarray(8, 16).toString("hex"), "0011223344556677"); + // Random salt differs each call. + assert.notEqual(maxaiAesEncrypt("payload", AES_KEY), maxaiAesEncrypt("payload", AES_KEY)); +}); + +// ── Constants extractor: parse SYNTHETIC bundle chunks → the signing constants ── +// The fixtures are generated in-code (helpers/maxaiMockConstants.ts) with MOCK +// values — no real MaxAI bundle, key, id, or app version is committed anywhere. + +const APP_CHUNK = makeSyntheticAppChunk(); +const SIGNER_CHUNK = makeSyntheticSignerChunk(); + +test("parseMaxaiConstants extracts every value from a webpack-shaped chunk", () => { + const parsed = parseMaxaiConstants(APP_CHUNK, SIGNER_CHUNK); + assert.equal(parsed.hmacKey, MOCK_HMAC_KEY); + assert.equal(parsed.aesKey, MOCK_AES_KEY); + assert.equal(parsed.appVersion, MOCK_APP_VERSION); + assert.equal(parsed.docIdKey, MOCK_DOC_ID_KEY); + assert.equal(parsed.ctxKey, MOCK_CTX_KEY); + // Header names decoded from the nj(hex) calls in the signer chunk. + assert.equal(parsed.headerNames.authorization, "X-Authorization"); + assert.equal(parsed.headerNames.clientDomain, "X-Client-Domain"); + assert.equal(parsed.headerNames.random, "X-Random"); +}); + +test("resolveWebpackGetter follows an export getter to its literal value", () => { + const src = 'a.d(t,{Mn:function(){return u}});let s="zzz",u="deadbeefcafe";'; + assert.equal(resolveWebpackGetter(src, "Mn"), "deadbeefcafe"); + assert.equal(resolveWebpackGetter(src, "Nope"), null); +}); + +test("decodeNjHeaderNames decodes hex header names and skips non-ASCII/garbage", () => { + const names = decodeNjHeaderNames(SIGNER_CHUNK); + assert.ok(names.includes("X-Authorization")); + assert.ok(names.includes("X-Client-Domain")); + assert.ok(names.includes("X-Random")); +}); + +test("looksLikeSignerChunk fingerprints the signer chunk by content, not by number", () => { + // The signer chunk matches (ctx slot + nj decoders); the app chunk does not. + assert.equal(looksLikeSignerChunk(SIGNER_CHUNK), true); + assert.equal(looksLikeSignerChunk(APP_CHUNK), false); + assert.equal(looksLikeSignerChunk("var x=1;"), false); +}); + +test("assembleMaxaiConstants requires all five extracted values (null when any missing)", () => { + const good = assembleMaxaiConstants(parseMaxaiConstants(APP_CHUNK, SIGNER_CHUNK)); + assert.ok(good); + assert.equal(good!.hmacKey, MOCK_HMAC_KEY); + // Missing a key → null (we never assemble a half-configured signer). + const noHmac = assembleMaxaiConstants({ + hmacKey: null, + aesKey: MOCK_AES_KEY, + appVersion: MOCK_APP_VERSION, + ctxKey: MOCK_CTX_KEY, + docIdKey: MOCK_DOC_ID_KEY, + headerNames: {}, + }); + assert.equal(noHmac, null); +}); + +test("assembleMaxaiConstants defaults header NAMES but requires the id/key/version values", () => { + // All five extracted values present but header-name map empty → header-name + // defaults fill in (they are plain HTTP labels, not keys/secrets). + const c = assembleMaxaiConstants({ + hmacKey: MOCK_HMAC_KEY, + aesKey: MOCK_AES_KEY, + appVersion: MOCK_APP_VERSION, + ctxKey: MOCK_CTX_KEY, + docIdKey: MOCK_DOC_ID_KEY, + headerNames: {}, + }); + assert.ok(c); + assert.equal(c!.headerNames.authorization, "X-Authorization"); + assert.equal(c!.headerNames.random, "X-Random"); + // A missing app_version (a required extracted value) → null. + assert.equal( + assembleMaxaiConstants({ + hmacKey: MOCK_HMAC_KEY, + aesKey: MOCK_AES_KEY, + appVersion: null, + ctxKey: MOCK_CTX_KEY, + docIdKey: MOCK_DOC_ID_KEY, + headerNames: {}, + }), + null + ); + // A missing ctxKey (required) → null. + assert.equal( + assembleMaxaiConstants({ + hmacKey: MOCK_HMAC_KEY, + aesKey: MOCK_AES_KEY, + appVersion: MOCK_APP_VERSION, + ctxKey: null, + docIdKey: MOCK_DOC_ID_KEY, + headerNames: {}, + }), + null + ); +}); + +test("validateMaxaiConstants: shape gate by default, proof gate when a vector is given", () => { + const c = assembleMaxaiConstants(parseMaxaiConstants(APP_CHUNK, SIGNER_CHUNK))!; + // Default: shape-only (no real vector is embedded in source). + assert.equal(validateMaxaiConstants(c), true); + // Malformed values fail the shape gate. + assert.equal(validateMaxaiConstants({ ...c, hmacKey: "not-hex" }), false); + assert.equal(validateMaxaiConstants({ ...c, docIdKey: "not-a-uuid" }), false); + // With a MOCK proof vector, the key that produced it validates and a wrong one doesn't. + const t = 1700000000000; + const path = "/gpt/cwc/chat"; + const vector = { + path, + reqTime: t, + userId: USER_ID, + appVersion: MOCK_APP_VERSION, + expectedProof: referenceProof(MOCK_APP_VERSION, t, path, USER_ID, MOCK_HMAC_KEY), + }; + assert.equal(validateMaxaiConstants(c, vector), true); + const wrongKey = { ...c, hmacKey: MOCK_AES_KEY }; + assert.equal(validateMaxaiConstants(wrongKey, vector), false); +}); + +test("findChunkUrls returns the pages/_app chunk + build-independent candidates", () => { + const html = makeSyntheticAppHtml({ + appChunk: "/_next/static/chunks/pages/_app-deadbeef.js", + signerChunk: "/_next/static/chunks/91234-cafebabe.js", + }); + const { appChunk, candidateChunks } = findChunkUrls(html); + assert.equal(appChunk, "/_next/static/chunks/pages/_app-deadbeef.js"); + // The signer chunk is just one of the candidates; it's chosen later BY CONTENT. + assert.ok(candidateChunks.includes("/_next/static/chunks/91234-cafebabe.js")); + assert.ok(!candidateChunks.includes("/_next/static/chunks/pages/_app-deadbeef.js")); +}); + +test("fetchMaxaiConstants finds the signer chunk BY CONTENT even when renumbered", async () => { + // Two numbered chunks: a decoy and the real signer under an ARBITRARY new id. + // The scan must pick the signer purely by its content fingerprint. + const html = makeSyntheticAppHtml({ + appChunk: "/_next/static/chunks/pages/_app-aaaa.js", + signerChunk: "/_next/static/chunks/99999-newbuildid.js", + extra: ["/_next/static/chunks/55555-decoy.js"], + }); + const fakeFetch = (async (url: string) => { + const u = String(url); + if (u.endsWith("/app/")) return new Response(html, { status: 200 }); + if (u.includes("/pages/_app-")) return new Response(APP_CHUNK, { status: 200 }); + if (u.includes("/99999-")) return new Response(SIGNER_CHUNK, { status: 200 }); + if (u.includes("/55555-")) return new Response("var decoy=1;", { status: 200 }); + return new Response("", { status: 404 }); + }) as unknown as typeof fetch; + + const c = await fetchMaxaiConstants({ fetchImpl: fakeFetch }); + assert.ok(c, "constants should be extracted from a renumbered signer chunk"); + assert.equal(c!.hmacKey, MOCK_HMAC_KEY); + assert.equal(c!.ctxKey, MOCK_CTX_KEY); + assert.equal(c!.source, "extracted"); +}); + +test("fetchMaxaiConstants returns null when the bundle can't be reached", async () => { + const fakeFetch = (async () => new Response("", { status: 500 })) as unknown as typeof fetch; + assert.equal(await fetchMaxaiConstants({ fetchImpl: fakeFetch }), null); + // Re-seed the memo for the remaining network tests (some run after this). + resetMaxaiConstantsMemo(); + __setMaxaiConstantsForTest(TEST_CONSTANTS); +}); + +test("buildMaxaiSignedHeaders emits the X-App/X-Browser companions + X-Authorization", () => { + const h = buildMaxaiSignedHeaders( + { + path: "/gpt/cwc/chat", + userId: USER_ID, + deviceId: MOCK_DEVICE_ID, + now: () => 1784594159681, + random: () => "950484", + }, + TEST_CONSTANTS + ); + assert.equal(h["X-Browser-Name"], "Firefox"); + assert.equal(h["X-Browser-Version"], "150.0"); + assert.equal(h["X-App-Version"], MOCK_APP_VERSION); + assert.equal(h["X-App-Env"], "MaxAI-Browser-Extension"); + assert.ok(h["X-Authorization"].length > 0); + assert.equal(Buffer.from(h["X-Authorization"], "base64").subarray(0, 8).toString("ascii"), "Salted__"); +}); + +// ── Context assembly ───────────────────────────────────────────────────────── + +test("assembleMaxaiContext: single user turn is sent bare", () => { + const text = assembleMaxaiContext([{ role: "user", content: "hello there" }]); + assert.equal(text, "hello there"); +}); + +test("assembleMaxaiContext: system leads, history labeled, current fenced last", () => { + const text = assembleMaxaiContext([ + { role: "system", content: "You are helpful." }, + { role: "user", content: "first question" }, + { role: "assistant", content: "first answer" }, + { role: "user", content: "second question" }, + ]); + assert.match(text, /^You are helpful\./); + assert.match(text, /=== Conversation so far \(for context\) ===/); + assert.match(text, /User: first question/); + assert.match(text, /Assistant: first answer/); + assert.match(text, /=== Current request \(respond to THIS\) ===\n\nsecond question$/); +}); + +test("assembleMaxaiContext: tool turns render as tool_response / tool_call blocks", () => { + const text = assembleMaxaiContext([ + { role: "user", content: "search for X" }, + { + role: "assistant", + content: "", + tool_calls: [{ function: { name: "web_search", arguments: '{"q":"X"}' } }], + }, + { role: "tool", tool_call_id: "call_1", content: "result: found X" }, + { role: "user", content: "summarize" }, + ]); + assert.match(text, //); + assert.match(text, /web_search/); + assert.match(text, //); + assert.match(text, /result: found X/); +}); + +test("assembleMaxaiContext throws when there is nothing to send", () => { + assert.throws(() => assembleMaxaiContext([]), /no content/); +}); + +test("contentToText flattens multipart content, dropping non-text parts", () => { + assert.equal(contentToText("plain"), "plain"); + assert.equal( + contentToText([ + { type: "text", text: "a" }, + { type: "image_url", image_url: { url: "x" } }, + { type: "text", text: "b" }, + ]), + "a\nb" + ); +}); + +test("buildMaxaiChatBody pins field order + constants", () => { + const body = buildMaxaiChatBody({ conversationId: "conv-1", text: "hi", modelName: "gpt-5.6", appVersion: APP_VERSION }); + const keys = Object.keys(body); + assert.equal(keys[0], "chat_mode"); + assert.equal(keys[3], "message_content"); + assert.equal(body.chat_mode, "pro_chat"); + assert.deepEqual(body.chat_history, []); + assert.deepEqual(body.message_content, [{ type: "text", text: "hi" }]); + assert.equal(body.model_name, "gpt-5.6"); + assert.equal(body.streaming, true); + assert.equal(body.platform_feature, "web_app"); +}); + +// ── Vision input (image_url parts) ─────────────────────────────────────────── + +test("buildMaxaiChatBody text-only path is unchanged (no imageUrls)", () => { + const body = buildMaxaiChatBody({ conversationId: "c", text: "hi", modelName: "gpt-5.6", appVersion: APP_VERSION }); + // Byte-identical to the pre-vision shape: a single text part. + assert.deepEqual(body.message_content, [{ type: "text", text: "hi" }]); + assert.deepEqual(body.doc_list, []); +}); + +test("buildMaxaiChatBody appends image_url parts after the text part", () => { + const body = buildMaxaiChatBody({ + conversationId: "c", + text: "what is this?", + modelName: "gpt-5.6-luna", + appVersion: APP_VERSION, + imageUrls: ["data:image/png;base64,AAAA", "https://example.com/cat.jpg"], + }); + assert.deepEqual(body.message_content, [ + { type: "text", text: "what is this?" }, + { type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } }, + { type: "image_url", image_url: { url: "https://example.com/cat.jpg" } }, + ]); + // Text part stays first so the flattened transcript leads. + assert.equal((body.message_content as Array<{ type: string }>)[0].type, "text"); +}); + +test("buildMaxaiChatBody skips empty/blank image urls", () => { + const body = buildMaxaiChatBody({ + conversationId: "c", + text: "t", + modelName: "gpt-5.6", + appVersion: APP_VERSION, + imageUrls: ["", "https://x/y.png"], + }); + assert.equal((body.message_content as unknown[]).length, 2); // text + 1 valid image +}); + +test("extractCurrentTurnImages pulls images from the LAST user turn only", () => { + const urls = extractCurrentTurnImages([ + { + role: "user", + content: [ + { type: "text", text: "old" }, + { type: "image_url", image_url: { url: "data:image/png;base64,OLD" } }, + ], + }, + { role: "assistant", content: "ok" }, + { + role: "user", + content: [ + { type: "text", text: "look" }, + { type: "image_url", image_url: { url: "https://c/1.jpg" } }, + { type: "image_url", image_url: "https://c/2.jpg" }, // shorthand form + ], + }, + ]); + // Only the current (last) user turn's images, both object and shorthand forms. + assert.deepEqual(urls, ["https://c/1.jpg", "https://c/2.jpg"]); +}); + +test("extractCurrentTurnImages returns [] for a plain-string user turn", () => { + assert.deepEqual(extractCurrentTurnImages([{ role: "user", content: "just text" }]), []); +}); + +test("extractCurrentTurnImages returns [] when there is no user turn", () => { + assert.deepEqual(extractCurrentTurnImages([{ role: "system", content: "sys" }]), []); +}); + +// ── SSE / think split ──────────────────────────────────────────────────────── + +test("parseMaxaiSseText extracts only mergeable text frames", () => { + const raw = [ + 'data: {"data_key":"text","text":"Hello","need_merge":true}', + "", + 'data: {"data_key":"next_action","action":{}}', + "", + 'data: {"data_key":"text","text":" world","need_merge":true}', + "", + "data: [DONE]", + ].join("\n"); + assert.equal(parseMaxaiSseText(raw), "Hello world"); +}); + +test("splitThink separates reasoning from answer", () => { + const { reasoning, answer } = splitThink("let me thinkThe answer is 42."); + assert.equal(reasoning, "let me think"); + assert.equal(answer, "The answer is 42."); +}); + +test("splitThink: no think tag → all answer", () => { + const { reasoning, answer } = splitThink("just a plain answer"); + assert.equal(reasoning, ""); + assert.equal(answer, "just a plain answer"); +}); + +test("ThinkSplitter handles a tag split across frames", () => { + const s = new ThinkSplitter(); + let reasoning = ""; + let answer = ""; + // "reasonans" + for (const delta of ["reasonans"]) { + const out = s.feed(delta); + reasoning += out.reasoning; + answer += out.answer; + } + const tail = s.flush(); + reasoning += tail.reasoning; + answer += tail.answer; + assert.equal(reasoning, "reason"); + assert.equal(answer, "ans"); +}); + +test("estimateMaxaiTokens ~ 4 chars/token", () => { + assert.equal(estimateMaxaiTokens(""), 0); + assert.equal(estimateMaxaiTokens("abcd"), 1); + assert.equal(estimateMaxaiTokens("abcde"), 2); +}); + +// ── Credentials ────────────────────────────────────────────────────────────── + +test("userIdFromJwt decodes subject.user_id (no signature verification)", () => { + // Build a fake JWT with { subject: { user_id } }. + const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"); + const payload = Buffer.from(JSON.stringify({ subject: { user_id: USER_ID } })).toString( + "base64url" + ); + const jwt = `${header}.${payload}.sig`; + assert.equal(userIdFromJwt(jwt), USER_ID); +}); + +// ── Browserless access-token refresh ───────────────────────────────────────── + +/** Build a fake (unsigned) JWT carrying an `exp` and optional subject.user_id. */ +function fakeJwt(expEpochSeconds: number, userId?: string): string { + const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"); + const claims: Record = { exp: expEpochSeconds }; + if (userId) claims.subject = { user_id: userId }; + const payload = Buffer.from(JSON.stringify(claims)).toString("base64url"); + return `${header}.${payload}.sig`; +} + +test("maxaiAccessTokenNeedsRefresh: absent / unparseable / near-expiry / fresh", () => { + const now = () => 1_000_000_000_000; // fixed ms clock + const nowSec = 1_000_000_000; + assert.equal(maxaiAccessTokenNeedsRefresh("", 3600, now), true); // absent + assert.equal(maxaiAccessTokenNeedsRefresh("not-a-jwt", 3600, now), true); // unparseable + // exp 30 min out with a 1h margin → needs refresh. + assert.equal(maxaiAccessTokenNeedsRefresh(fakeJwt(nowSec + 1800), 3600, now), true); + // exp 5h out with a 1h margin → still fresh. + assert.equal(maxaiAccessTokenNeedsRefresh(fakeJwt(nowSec + 5 * 3600), 3600, now), false); +}); + +test("maxaiRefreshAccessToken sends the exact web-app request + parses data.access_token", async () => { + const nowSec = Math.floor(Date.now() / 1000); + const refreshToken = fakeJwt(nowSec + 365 * 24 * 3600, USER_ID); // 1y refresh token + const newAccess = fakeJwt(nowSec + 24 * 3600, USER_ID); + let seen: { url: string; init: RequestInit } | null = null; + + const fakeFetch = (async (url: string, init: RequestInit) => { + seen = { url: String(url), init }; + return new Response(JSON.stringify({ data: { access_token: newAccess } }), { status: 200 }); + }) as unknown as typeof fetch; + + const result = await maxaiRefreshAccessToken({ + refreshToken, + deviceId: MOCK_DEVICE_ID, + fetchImpl: fakeFetch, + }); + + assert.equal(result.ok, true); + assert.equal(result.accessToken, newAccess); + assert.ok(result.expiresAt && result.expiresAt > nowSec); + + // Request shape: bare refresh path, refresh token as Bearer, noAuthLogout, app body. + assert.ok(seen); + const { url, init } = seen!; + assert.ok(url.endsWith(MAXAI_REFRESH_PATH)); + assert.equal(init.method, "POST"); + const headers = init.headers as Record; + assert.equal(headers["Authorization"], `Bearer ${refreshToken}`); + assert.equal(headers["noAuthLogout"], "true"); + assert.ok(headers["X-Authorization"] && headers["X-Authorization"].length > 0); + assert.equal(init.body, JSON.stringify({ app: "maxai_webapp" })); +}); + +test("maxaiRefreshAccessToken returns a structured error on non-200 (no throw)", async () => { + const nowSec = Math.floor(Date.now() / 1000); + const fakeFetch = (async () => + new Response("nope", { status: 418 })) as unknown as typeof fetch; + const result = await maxaiRefreshAccessToken({ + refreshToken: fakeJwt(nowSec + 1000, USER_ID), + deviceId: "dev", + fetchImpl: fakeFetch, + }); + assert.equal(result.ok, false); + assert.equal(result.status, 418); +}); + +test("maxaiRefreshAccessToken refuses when required inputs are missing", async () => { + const result = await maxaiRefreshAccessToken({ refreshToken: "", deviceId: "" }); + assert.equal(result.ok, false); + assert.equal(result.status, 0); +}); + +// ── Email login (browserless device-pair) ──────────────────────────────────── + +test("requestMaxaiEmailCode posts the signed signin request + treats status OK as success", async () => { + let seen: { url: string; init: RequestInit } | null = null; + const fakeFetch = (async (url: string, init: RequestInit) => { + seen = { url: String(url), init }; + return new Response(JSON.stringify({ data: { status: "OK" } }), { status: 200 }); + }) as unknown as typeof fetch; + + const r = await requestMaxaiEmailCode({ + email: "user@example.com", + deviceId: MOCK_DEVICE_ID, + fetchImpl: fakeFetch, + }); + + assert.equal(r.ok, true); + assert.ok(seen); + const { url, init } = seen!; + assert.ok(url.endsWith(MAXAI_SIGNIN_EMAIL_PATH)); + assert.equal(init.method, "POST"); + assert.equal(init.body, JSON.stringify({ email: "user@example.com", app: "maxai_webapp" })); + const headers = init.headers as Record; + assert.ok(headers["X-Authorization"] && headers["X-Authorization"].length > 0); +}); + +test("requestMaxaiEmailCode surfaces a non-OK detail as an error", async () => { + const fakeFetch = (async () => + new Response(JSON.stringify({ data: { status: "FAIL", detail: "Invalid email" } }), { + status: 200, + })) as unknown as typeof fetch; + const r = await requestMaxaiEmailCode({ email: "x@y.z", deviceId: "dev", fetchImpl: fakeFetch }); + assert.equal(r.ok, false); + assert.match(r.error ?? "", /Invalid email/); +}); + +test("verifyMaxaiEmailCode returns the full credential from auth_user", async () => { + const nowSec = Math.floor(Date.now() / 1000); + const accessToken = "acc.jwt.token"; + const refreshToken = "ref.jwt.token"; + let seen: { url: string; init: RequestInit } | null = null; + const fakeFetch = (async (url: string, init: RequestInit) => { + seen = { url: String(url), init }; + return new Response( + JSON.stringify({ + data: { + status: "OK", + auth_user: { + accessToken, + refreshToken, + userId: USER_ID, + email: "user@example.com", + clientUserId: "client-uuid-1", + }, + }, + }), + { status: 200 } + ); + }) as unknown as typeof fetch; + + const r = await verifyMaxaiEmailCode({ + email: "user@example.com", + code: "123456", + deviceId: "device-uuid-1", + clientUserId: "client-uuid-1", + fetchImpl: fakeFetch, + }); + + assert.equal(r.ok, true); + assert.deepEqual(r.credential, { + accessToken, + refreshToken, + userId: USER_ID, + email: "user@example.com", + deviceId: "device-uuid-1", + clientUserId: "client-uuid-1", + }); + assert.ok(nowSec > 0); // sanity anchor + + // Request shape: verify path + pinned body fields. + const { url, init } = seen!; + assert.ok(url.endsWith(MAXAI_VERIFY_CODE_PATH)); + const body = JSON.parse(String(init.body)); + assert.equal(body.email, "user@example.com"); + assert.equal(body.secret_code, "123456"); + assert.equal(body.app, "maxai_webapp"); + assert.equal(body.env, "prod_co"); + assert.equal(body.client_user_id, "client-uuid-1"); +}); + +test("verifyMaxaiEmailCode maps code 10119 to an expired-code message", async () => { + const fakeFetch = (async () => + new Response(JSON.stringify({ data: { status: "FAIL", code: 10119 } }), { + status: 200, + })) as unknown as typeof fetch; + const r = await verifyMaxaiEmailCode({ + email: "x@y.z", + code: "000000", + deviceId: "dev", + clientUserId: "cu", + fetchImpl: fakeFetch, + }); + assert.equal(r.ok, false); + assert.match(r.error ?? "", /expired|too many/i); +}); + +test("verifyMaxaiEmailCode defaults to an invalid-code message otherwise", async () => { + const fakeFetch = (async () => + new Response(JSON.stringify({ data: { status: "FAIL" } }), { status: 200 })) as unknown as typeof fetch; + const r = await verifyMaxaiEmailCode({ + email: "x@y.z", + code: "999999", + deviceId: "dev", + clientUserId: "cu", + fetchImpl: fakeFetch, + }); + assert.equal(r.ok, false); + assert.match(r.error ?? "", /Invalid code/); +}); + +test("email login guards missing inputs", async () => { + assert.equal((await requestMaxaiEmailCode({ email: "", deviceId: "" })).ok, false); + assert.equal( + (await verifyMaxaiEmailCode({ email: "", code: "", deviceId: "", clientUserId: "" })).ok, + false + ); +}); + +// ── Tool calling (prompted protocol) ────────────────────────────────── + +import { MaxAiExecutor } from "../../open-sse/executors/maxai.ts"; + +const TOOL_CRED = { + providerSpecificData: { + maxaiAccessToken: "acc.tok.en", + maxaiDeviceId: "dev-1", + maxaiUserId: USER_ID, + }, + accessToken: "acc.tok.en", +}; + +const WEATHER_TOOL = { + type: "function", + function: { + name: "get_weather", + description: "Get the current weather for a city.", + parameters: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }, + }, +}; + +/** Build a MaxAI SSE body streaming `full` as one mergeable text frame. */ +function maxaiSseBody(full: string): string { + return ( + `data: ${JSON.stringify({ data_key: "text", need_merge: true, text: full })}\n\n` + + "data: [DONE]\n\n" + ); +} + +/** Run MaxAiExecutor.execute with a stubbed global fetch returning `sseText`. */ +async function runToolExecute(opts: { + sseText: string; + stream: boolean; + tools?: unknown[]; +}): Promise<{ captured: { url: string; body: string } | null; response: Response }> { + const realFetch = globalThis.fetch; + let captured: { url: string; body: string } | null = null; + globalThis.fetch = (async (url: unknown, init: unknown) => { + captured = { + url: String(url), + body: String((init as RequestInit)?.body ?? ""), + }; + return new Response(opts.sseText, { status: 200 }); + }) as unknown as typeof fetch; + try { + const executor = new MaxAiExecutor(); + const result = await executor.execute({ + model: "gpt-5.6-luna", + stream: opts.stream, + credentials: TOOL_CRED, + body: { + model: "gpt-5.6-luna", + messages: [{ role: "user", content: "what's the weather in Paris?" }], + ...(opts.tools ? { tools: opts.tools } : {}), + stream: opts.stream, + }, + } as unknown as Parameters[0]); + const response = "response" in result ? result.response : (result as Response); + return { captured, response }; + } finally { + globalThis.fetch = realFetch; + } +} + +test("executor injects the contract into the upstream text when tools are present", async () => { + const { captured } = await runToolExecute({ + sseText: maxaiSseBody("Sure, let me check."), + stream: false, + tools: [WEATHER_TOOL], + }); + assert.ok(captured); + const chatBody = JSON.parse(captured!.body); + const sentText = chatBody.message_content[0].text as string; + // The prompted-tool contract + the tool name reach the model. + assert.match(sentText, //); + assert.match(sentText, /get_weather/); +}); + +test("executor parses a block from the reply into OpenAI tool_calls (non-stream)", async () => { + const toolBlock = + '{"name": "get_weather", "arguments": {"city": "Paris"}, "_nonce": "NONCE"}'; + // The parser needs the SAME nonce serializeToolsToPrompt derived from tools[]. + // getToolNonce is deterministic per tools ref+content, so re-derive it here. + const { getToolNonce } = await import("../../open-sse/translator/webTools.ts"); + const tools = [WEATHER_TOOL]; + const nonce = getToolNonce(tools); + const reply = `{"name": "get_weather", "arguments": {"city": "Paris"}, "_nonce": "${nonce}"}`; + void toolBlock; + + const { response } = await runToolExecute({ + sseText: maxaiSseBody(reply), + stream: false, + tools, + }); + assert.equal(response.status, 200); + const json = await response.json(); + const choice = json.choices[0]; + assert.equal(choice.finish_reason, "tool_calls"); + assert.ok(Array.isArray(choice.message.tool_calls)); + assert.equal(choice.message.tool_calls[0].function.name, "get_weather"); + assert.deepEqual(JSON.parse(choice.message.tool_calls[0].function.arguments), { city: "Paris" }); +}); + +test("executor tool mode emits a terminal SSE replay with tool_calls (stream)", async () => { + const { getToolNonce } = await import("../../open-sse/translator/webTools.ts"); + const tools = [WEATHER_TOOL]; + const nonce = getToolNonce(tools); + const reply = `{"name": "get_weather", "arguments": {"city": "Paris"}, "_nonce": "${nonce}"}`; + + const { response } = await runToolExecute({ + sseText: maxaiSseBody(reply), + stream: true, + tools, + }); + assert.equal(response.status, 200); + assert.match(response.headers.get("Content-Type") ?? "", /text\/event-stream/); + const sse = await response.text(); + assert.match(sse, /"tool_calls"/); + assert.match(sse, /get_weather/); + assert.match(sse, /\[DONE\]/); +}); + +test("executor without tools streams normally (no tool_calls, plain content)", async () => { + const { response } = await runToolExecute({ + sseText: maxaiSseBody("Paris is sunny today."), + stream: false, + }); + assert.equal(response.status, 200); + const json = await response.json(); + assert.equal(json.choices[0].finish_reason, "stop"); + assert.equal(json.choices[0].message.content, "Paris is sunny today."); + assert.equal(json.choices[0].message.tool_calls, undefined); +}); + +/** Like runToolExecute but returns a DIFFERENT sse body per upstream call, so we + * can simulate a narration-miss on turn 1 and a clean tool call on turn 2. */ +async function runToolExecuteSeq(bodies: string[]): Promise { + const realFetch = globalThis.fetch; + let call = 0; + globalThis.fetch = (async () => { + const body = bodies[Math.min(call, bodies.length - 1)]; + call += 1; + return new Response(body, { status: 200 }); + }) as unknown as typeof fetch; + try { + const executor = new MaxAiExecutor(); + const result = await executor.execute({ + model: "maxai/deepseek-r1", + stream: false, + credentials: TOOL_CRED, + body: { + model: "maxai/deepseek-r1", + messages: [{ role: "user", content: "what's the weather in Ghent?" }], + tools: [WEATHER_TOOL], + stream: false, + }, + } as unknown as Parameters[0]); + return "response" in result ? result.response : (result as Response); + } finally { + globalThis.fetch = realFetch; + } +} + +test("executor recovers a tool narration-miss via one nudged retry", async () => { + // Turn 1: the model NARRATES about the block but emits none parseable. + const narration = + "I can use the get_current_weather tool here via a special block. Let me think about the arguments..."; + // Turn 2 (after nudge): a clean, parseable tool call. Omit _nonce (tolerated + // for models that don't echo it) so the test isn't coupled to the internal + // per-tools-reference nonce the executor injected. + const clean = `{"name": "get_current_weather", "arguments": {"city": "Ghent"}}`; + + const response = await runToolExecuteSeq([maxaiSseBody(narration), maxaiSseBody(clean)]); + assert.equal(response.status, 200); + const json = await response.json(); + assert.equal(json.choices[0].finish_reason, "tool_calls"); + assert.equal(json.choices[0].message.tool_calls[0].function.name, "get_current_weather"); + assert.deepEqual(JSON.parse(json.choices[0].message.tool_calls[0].function.arguments), { + city: "Ghent", + }); +}); + +test("executor does NOT retry a genuine no-tool answer (no narration signal)", async () => { + // A plain answer with no tool intent must pass through unchanged (single call). + let calls = 0; + const realFetch = globalThis.fetch; + globalThis.fetch = (async () => { + calls += 1; + return new Response(maxaiSseBody("The weather in Ghent is mild and cloudy."), { status: 200 }); + }) as unknown as typeof fetch; + try { + const executor = new MaxAiExecutor(); + const result = await executor.execute({ + model: "maxai/gpt-5.6", + stream: false, + credentials: TOOL_CRED, + body: { + model: "maxai/gpt-5.6", + messages: [{ role: "user", content: "how's Ghent?" }], + tools: [WEATHER_TOOL], + stream: false, + }, + } as unknown as Parameters[0]); + const response = "response" in result ? result.response : (result as Response); + const json = await response.json(); + assert.equal(json.choices[0].finish_reason, "stop"); + assert.equal(calls, 1); // no retry + } finally { + globalThis.fetch = realFetch; + } +}); + +// ── Model discovery (/models/get_config → per-model context windows) ────────── + +const DISCOVERY_CRED = { + providerSpecificData: { + maxaiAccessToken: "acc.tok.en", + maxaiDeviceId: "dev-1", + maxaiUserId: USER_ID, + }, + accessToken: "acc.tok.en", +}; + +/** A minimal /models/get_config body with the fields the mapper reads. */ +function modelsConfigBody(models: unknown[]): string { + return JSON.stringify({ data: { chat_models: models } }); +} + +test("discoverMaxaiModels maps curated chat models with live max_tokens as the window", async () => { + const fakeFetch = (async () => + new Response( + modelsConfigBody([ + { + model_name: "gpt-5.6-luna", + ui_display_name: "GPT-5.6 Luna", + type: "chat", + group: "fast", + max_tokens: 1_050_000, + is_deprecated: false, + capabilities: { vision: true, thinking_mode: false }, + }, + { + model_name: "gpt-5.6-thinking", + ui_display_name: "GPT-5.6 Thinking", + type: "chat", + group: "reasoning", + max_tokens: 1_050_000, + is_deprecated: false, + capabilities: { vision: false, thinking_mode: true }, + }, + ]), + { status: 200 } + )) as unknown as typeof fetch; + + const { models, warning } = await discoverMaxaiModels({ + providerSpecificData: DISCOVERY_CRED.providerSpecificData, + accessToken: DISCOVERY_CRED.accessToken, + fetchImpl: fakeFetch, + }); + + const luna = models.find((m) => m.id === "gpt-5.6-luna"); + assert.ok(luna); + assert.equal(luna!.inputTokenLimit, 1_050_000); + assert.equal(luna!.name, "GPT-5.6 Luna"); + assert.equal(luna!.toolCalling, true); + assert.equal(luna!.supportsVision, true); + const thinking = models.find((m) => m.id === "gpt-5.6-thinking"); + assert.equal(thinking!.supportsReasoning, true); + // Two curated returned, so the "no longer offered" warning names the rest. + assert.ok(warning && /no longer offers/.test(warning)); +}); + +test("discoverMaxaiModels drops deprecated, non-chat, and non-curated models", async () => { + const fakeFetch = (async () => + new Response( + modelsConfigBody([ + { model_name: "gpt-5.6-luna", type: "chat", max_tokens: 1_050_000, is_deprecated: false }, + { model_name: "gpt-5-mini", type: "chat", max_tokens: 400_000, is_deprecated: true }, // deprecated + { model_name: "some-image-model", type: "image", max_tokens: 0 }, // non-chat + { model_name: "not-in-catalog", type: "chat", max_tokens: 123 }, // non-curated + ]), + { status: 200 } + )) as unknown as typeof fetch; + + const { models } = await discoverMaxaiModels({ + providerSpecificData: DISCOVERY_CRED.providerSpecificData, + accessToken: DISCOVERY_CRED.accessToken, + fetchImpl: fakeFetch, + }); + assert.deepEqual( + models.map((m) => m.id), + ["gpt-5.6-luna"] + ); +}); + +test("discoverMaxaiModels falls back to the catalog window when max_tokens is absent", async () => { + const fakeFetch = (async () => + new Response( + modelsConfigBody([{ model_name: "claude-5-sonnet", type: "chat" }]), + { status: 200 } + )) as unknown as typeof fetch; + const { models } = await discoverMaxaiModels({ + providerSpecificData: DISCOVERY_CRED.providerSpecificData, + accessToken: DISCOVERY_CRED.accessToken, + fetchImpl: fakeFetch, + }); + const sonnet = models.find((m) => m.id === "claude-5-sonnet"); + assert.ok(sonnet); + assert.ok(sonnet!.inputTokenLimit > 0); // from catalog fallback (1_000_000) +}); + +test("discoverMaxaiModels throws on non-200 and on missing chat_models", async () => { + const err418 = (async () => new Response("nope", { status: 418 })) as unknown as typeof fetch; + await assert.rejects( + discoverMaxaiModels({ + providerSpecificData: DISCOVERY_CRED.providerSpecificData, + accessToken: DISCOVERY_CRED.accessToken, + fetchImpl: err418, + }), + /418/ + ); + + const noModels = (async () => + new Response(JSON.stringify({ data: {} }), { status: 200 })) as unknown as typeof fetch; + await assert.rejects( + discoverMaxaiModels({ + providerSpecificData: DISCOVERY_CRED.providerSpecificData, + accessToken: DISCOVERY_CRED.accessToken, + fetchImpl: noModels, + }), + /no chat_models/ + ); +}); + +test("discoverMaxaiModels refuses when the connection is unconfigured", async () => { + await assert.rejects( + discoverMaxaiModels({ providerSpecificData: {}, accessToken: "" }), + /not configured/ + ); +}); + +// ── Body-too-large classification (context_length_exceeded) ────────────────── + +test("executor classifies a MaxAI 'too long' rejection as context_length_exceeded", async () => { + const realFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + code: -2, + detail: + "Something went wrong. It's probably due to the message you submitted being too long. Please reload the conversation and submit something shorter.", + }), + { status: 422 } + )) as unknown as typeof fetch; + try { + const executor = new MaxAiExecutor(); + const result = await executor.execute({ + model: "maxai/gpt-5.6", + stream: false, + credentials: TOOL_CRED, + body: { + model: "maxai/gpt-5.6", + messages: [{ role: "user", content: "a very long transcript..." }], + stream: false, + }, + } as unknown as Parameters[0]); + const response = "response" in result ? result.response : (result as Response); + assert.equal(response.status, 400); + const json = await response.json(); + assert.equal(json.error.code, "context_length_exceeded"); + } finally { + globalThis.fetch = realFetch; + } +}); diff --git a/tests/unit/provider-node-reserved-prefix.test.ts b/tests/unit/provider-node-reserved-prefix.test.ts index 8ec06f680f..8c7b57e83e 100644 --- a/tests/unit/provider-node-reserved-prefix.test.ts +++ b/tests/unit/provider-node-reserved-prefix.test.ts @@ -171,7 +171,7 @@ test("shared set size includes live REGISTRY and retired Designer + Felo + Qwen // 1 and adds 2 distinct tombstones "qwen-web"/"qw", a net +1) on top of the // live REGISTRY walk, minus the 3 GPL-derived Raycast/Hailuo Web // ids/aliases removed from REGISTRY by #11691's migration 166. - assert.equal(RESERVED_PREFIX_COUNT, 400); + assert.equal(RESERVED_PREFIX_COUNT, 402); }); test("isReservedProviderPrefix rejects non-string input", () => { diff --git a/tests/unit/ratelimit-admission-control-6593.test.ts b/tests/unit/ratelimit-admission-control-6593.test.ts index 873ed0038b..cdad084a32 100644 --- a/tests/unit/ratelimit-admission-control-6593.test.ts +++ b/tests/unit/ratelimit-admission-control-6593.test.ts @@ -239,6 +239,18 @@ test("#6593 a maxWaitMs override of 0 is treated as no override", () => { } }); +test("maxai receives a provider-scoped 5min execution budget (slow reasoning models)", () => { + // The default 15s Bottleneck expiration kills MaxAI reasoning turns (30s-min+) + // mid-think; maxai (and its mx alias) floor at 300s so they complete. + assert.equal(rateLimitManager.resolveRequestQueueMaxWaitMs("maxai", 15_000), 300_000); + assert.equal(rateLimitManager.resolveRequestQueueMaxWaitMs("MaxAI", 15_000), 300_000); + assert.equal(rateLimitManager.resolveRequestQueueMaxWaitMs("mx", 15_000), 300_000); + // A larger configured value is preserved (floor never lowers it). + assert.equal(rateLimitManager.resolveRequestQueueMaxWaitMs("maxai", 600_000), 600_000); + // Other providers are unaffected. + assert.equal(rateLimitManager.resolveRequestQueueMaxWaitMs("openai", 15_000), 15_000); +}); + test("#6593 DEFAULT_REQUEST_QUEUE_MAX_DEPTH defaults to 0 (disabled) absent an env override", () => { assert.equal(process.env.RATE_LIMIT_MAX_QUEUE_DEPTH, undefined); assert.equal(resilienceSettings.DEFAULT_REQUEST_QUEUE_MAX_DEPTH, 0);