mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-18 21:22:28 +03:00
Compare commits
5 Commits
refactor/e
...
fix/10244-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50b8937fe6 | ||
|
|
ca4ba987b5 | ||
|
|
060a906dee | ||
|
|
6b5d82133a | ||
|
|
6bc9dad0ce |
@@ -763,9 +763,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
|||||||
# CLI_CONTINUE_BIN=cn
|
# CLI_CONTINUE_BIN=cn
|
||||||
# CLI_QODER_BIN=qoder
|
# CLI_QODER_BIN=qoder
|
||||||
# CLI_QWEN_BIN=qwen
|
# CLI_QWEN_BIN=qwen
|
||||||
# CLI_AIDER_BIN=aider
|
|
||||||
# CLI_GOOSE_BIN=goose
|
|
||||||
# CLI_GEMINI_BIN=gemini
|
|
||||||
# CLI_AUGGIE_BIN=auggie
|
# CLI_AUGGIE_BIN=auggie
|
||||||
# AUGGIE_BIN=auggie
|
# AUGGIE_BIN=auggie
|
||||||
|
|
||||||
@@ -1805,12 +1802,6 @@ APP_LOG_TO_FILE=true
|
|||||||
# Used by: open-sse/executors/cloudflare-ai.ts
|
# Used by: open-sse/executors/cloudflare-ai.ts
|
||||||
# CLOUDFLARE_ACCOUNT_ID=
|
# CLOUDFLARE_ACCOUNT_ID=
|
||||||
|
|
||||||
# ── Cloudflare AI Playground ──
|
|
||||||
# Full desktop Chrome binary path, used when Playwright's bundled Chromium is
|
|
||||||
# blocked by the headless fingerprint check.
|
|
||||||
# Used by: open-sse/executors/cloudflare-playground.ts
|
|
||||||
# CLOUDFLARE_PLAYGROUND_CHROME_PATH=
|
|
||||||
|
|
||||||
# ── Deno Deploy proxy relay (#4643 / 9router#1437) ──
|
# ── Deno Deploy proxy relay (#4643 / 9router#1437) ──
|
||||||
# Override the Deno Deploy REST API base used by the proxy-pool relay deployer.
|
# Override the Deno Deploy REST API base used by the proxy-pool relay deployer.
|
||||||
# Default: https://api.deno.com/v2 (omit unless mocking).
|
# Default: https://api.deno.com/v2 (omit unless mocking).
|
||||||
|
|||||||
24
README.md
24
README.md
@@ -548,7 +548,7 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
|
|||||||
- **🗜️ Compression hardening** — default-on inflation guard, Caveman packs for DE / FR / JA + Chinese (wényán), RTK filters for Gradle & .NET. → [Compression](docs/compression/COMPRESSION_ENGINES.md)
|
- **🗜️ Compression hardening** — default-on inflation guard, Caveman packs for DE / FR / JA + Chinese (wényán), RTK filters for Gradle & .NET. → [Compression](docs/compression/COMPRESSION_ENGINES.md)
|
||||||
- **💸 Honest flat-rate cost** — subscription / coding-plan providers read **$0** in cost analytics; budget, quota & routing keep estimating. → [API Reference](docs/reference/API_REFERENCE.md)
|
- **💸 Honest flat-rate cost** — subscription / coding-plan providers read **$0** in cost analytics; budget, quota & routing keep estimating. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||||
- **⚖️ Quota-Share routing** — split a shared account's quota fairly across pooled keys, work-conserving so idle slices are lent out. → [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md)
|
- **⚖️ Quota-Share routing** — split a shared account's quota fairly across pooled keys, work-conserving so idle slices are lent out. → [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md)
|
||||||
- **🤖 One-command CLI/agent setup** — `setup-*` configures 12+ coding tools; `omniroute run` launches 7 CLIs (Claude Code, Codex, Aider, Goose, OpenCode, Qwen Code, Gemini CLI) with zero config written; `omniroute configure` is an interactive provider+model picker with per-context favorites. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
|
- **🤖 One-command CLI/agent setup** — `setup-*` configures 12+ coding tools; `omniroute launch` / `launch-codex` are zero-config. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
|
||||||
- **🛰️ Remote mode** — drive a remote OmniRoute with scoped tokens (`connect` / `contexts` / `tokens`) + an `antigravity` OAuth helper for VPS installs. → [Remote Mode](docs/guides/REMOTE-MODE.md)
|
- **🛰️ Remote mode** — drive a remote OmniRoute with scoped tokens (`connect` / `contexts` / `tokens`) + an `antigravity` OAuth helper for VPS installs. → [Remote Mode](docs/guides/REMOTE-MODE.md)
|
||||||
- **🧭 Smarter auto-routing** — `auto/<category>:<tier>` combos, **Fusion** (model panel + judge), task-aware routing, per-request model / mode / USD-budget overrides. → [Auto-Combo](docs/routing/AUTO-COMBO.md)
|
- **🧭 Smarter auto-routing** — `auto/<category>:<tier>` combos, **Fusion** (model panel + judge), task-aware routing, per-request model / mode / USD-budget overrides. → [Auto-Combo](docs/routing/AUTO-COMBO.md)
|
||||||
- **🗜️ Pluggable compression** — 12 composable engines + Compression Studios: LLMLingua-2, two-tier Ultra, omniglyph, per-step fidelity gate, GCF v3.2, drag-reorder editor. → [Compression](docs/compression/COMPRESSION_ENGINES.md)
|
- **🗜️ Pluggable compression** — 12 composable engines + Compression Studios: LLMLingua-2, two-tier Ultra, omniglyph, per-step fidelity gate, GCF v3.2, drag-reorder editor. → [Compression](docs/compression/COMPRESSION_ENGINES.md)
|
||||||
@@ -618,28 +618,6 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
|
|||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
**Launch any supported CLI through OmniRoute in one command** — no config files written,
|
|
||||||
credentials injected per process, Qwen/Gemini get a throwaway isolated home:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
omniroute run claude --model openai/gpt-5.4 # Claude Code
|
|
||||||
omniroute run codex --model glm/glm-5.2 # OpenAI Codex CLI
|
|
||||||
omniroute run aider --model glm/glm-5.2 -- --message "reply OK"
|
|
||||||
omniroute run goose --model glm/glm-5.2
|
|
||||||
omniroute run opencode --model glm/glm-5.2 -- run "reply OK"
|
|
||||||
omniroute run qwen --model glm/glm-5.2 -- -p "reply OK"
|
|
||||||
omniroute run gemini --model glm/glm-5.2 -- --skip-trust -p "reply OK"
|
|
||||||
|
|
||||||
# Or pick provider+model interactively and write the tool's own config:
|
|
||||||
omniroute configure codex # also: claude opencode qwen aider goose cline continue kilo
|
|
||||||
```
|
|
||||||
|
|
||||||
Every command honors the active remote context (`omniroute connect <host>`), `--dry-run`
|
|
||||||
previews the exact env/args without executing, and `--api-key-env NAME` keeps secrets out
|
|
||||||
of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
|
|
||||||
|
|
||||||
<br/>
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
## 🌐 340 AI Providers — 90+ Free
|
## 🌐 340 AI Providers — 90+ Free
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
- feat(providers): add **Cloudflare AI Playground** as a No Auth provider (`cloudflare-playground`, alias `cfp`) — free anonymous chat over the reverse-engineered `cf_agent` WebSocket protocol (PartySocket transport, no account/API key/cookies) with GLM 5.2, Kimi K2.7 Code, DeepSeek V4 Pro, gpt-oss-120B, Llama 3.3 70B, Qwen2.5 Coder 32B and 14 more curated models. The executor drives a headless Chromium via Playwright (the WS upgrade is TLS-fingerprint-gated), translates the `cf_agent` frame stream into OpenAI SSE, and surfaces upstream rate limits (3021) as HTTP 429. Fixes #10389
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
- **fix(ops):** Docker HEALTHCHECK defaults to the lightweight `/healthz` lifecycle probe instead of the heavy `/api/monitoring/health` path, with an `OMNIROUTE_HEALTHCHECK_PATH` opt-in override ([#10311](https://github.com/diegosouzapw/OmniRoute/pull/10311))
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
- **fix(providers):** zed-hosted OAuth now redirects the browser back to the dashboard's own loopback port (auto-completing the login), and the manual paste path accepts Zed's user_id/access_token callback URL instead of erroring with "No authorization code found" ([#10517](https://github.com/diegosouzapw/OmniRoute/pull/10517)) - thanks @phatchau036
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"_rebaseline_2026_08_18_10517_zed_hosted_oauth_callback_port": "PR #10517 (phatchau036, fix/zed-hosted-oauth-callback-port) own growth: src/shared/components/OAuthModal.tsx 1131->1148 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 1134->1149, +15/+18, crosses the frozen 1134 cap). Wires the zed-hosted native-app callback auto-complete: forceManual gating on isTrueLocalhost for zed-hosted, the loopback-redirect-URI comment block, and the exchangeToken full-URL-as-code branch, all at the existing provider-switch chokepoints this modal already carries growth for (seventh bump: 969->989->993->998->1030->1056->1100->1149; structural shrink tracked in #3501). The actual port-derivation logic lives in src/lib/oauth/providers/zed-hosted.ts (not frozen here) and was hardened during pre-merge review to use the server's own getRuntimePorts() instead of a browser-guessed scheme/port, covered by the new tests/unit/zed-hosted-loopback-port-derivation.test.ts (8/8 passing).",
|
|
||||||
"_rebaseline_2026_08_13_10243_codex_fingerprint_merge": "PR #10243 (xz-dev, Codex OAuth fingerprint convergence) merge into release/v3.8.50: src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts crossed the 1000-line new-file cap for the first time (974 on base, 997 on the PR's own branch, 1013 after merging + prettier reflow) purely from combining two independent, already-legitimate feature additions that landed on the same shared UI-helper file — this PR's own Codex fingerprint-mode select/toggle wiring (CODEX_FINGERPRINT_MODE_VALUES, getCodexFingerprintModeLabel, CodexFingerprintModeValue) plus #8949's unrelated Codex account-service-tier helpers merged concurrently on release/v3.8.50. Neither addition alone crosses the cap; git's line-level auto-merge does not detect a threshold crossing. Not modularized as part of this conflict-resolution merge commit (out of scope — this is a merge, not a feature change). Covered by the PR's own tests/unit/codex-fingerprint-convergence.test.ts, tests/unit/executor-codex.test.ts, tests/unit/provider-specific-data-schema.test.ts (all passing post-merge).",
|
"_rebaseline_2026_08_13_10243_codex_fingerprint_merge": "PR #10243 (xz-dev, Codex OAuth fingerprint convergence) merge into release/v3.8.50: src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts crossed the 1000-line new-file cap for the first time (974 on base, 997 on the PR's own branch, 1013 after merging + prettier reflow) purely from combining two independent, already-legitimate feature additions that landed on the same shared UI-helper file — this PR's own Codex fingerprint-mode select/toggle wiring (CODEX_FINGERPRINT_MODE_VALUES, getCodexFingerprintModeLabel, CodexFingerprintModeValue) plus #8949's unrelated Codex account-service-tier helpers merged concurrently on release/v3.8.50. Neither addition alone crosses the cap; git's line-level auto-merge does not detect a threshold crossing. Not modularized as part of this conflict-resolution merge commit (out of scope — this is a merge, not a feature change). Covered by the PR's own tests/unit/codex-fingerprint-convergence.test.ts, tests/unit/executor-codex.test.ts, tests/unit/provider-specific-data-schema.test.ts (all passing post-merge).",
|
||||||
"_rebaseline_2026_08_09_8984_api_key_cache_mode": "PR #8984 own growth during the 2026-08-09 rebase: src/lib/db/apiKeys.ts 1529->1545 (+16 = the per-key apiKeys.cacheDefaultMode column + its row parsers and cascade wiring; additive at the existing connection write/read chokepoints). Covered by tests/unit/chatcore-semantic-cache.test.ts. (chatCore.ts stays at the pre-existing base-red ceiling — upstream tip already exceeds the frozen 5042, this PR only adds +2 on top; not re-bumped per the no-inherit-ratchet rule.)",
|
"_rebaseline_2026_08_09_8984_api_key_cache_mode": "PR #8984 own growth during the 2026-08-09 rebase: src/lib/db/apiKeys.ts 1529->1545 (+16 = the per-key apiKeys.cacheDefaultMode column + its row parsers and cascade wiring; additive at the existing connection write/read chokepoints). Covered by tests/unit/chatcore-semantic-cache.test.ts. (chatCore.ts stays at the pre-existing base-red ceiling — upstream tip already exceeds the frozen 5042, this PR only adds +2 on top; not re-bumped per the no-inherit-ratchet rule.)",
|
||||||
"_rebaseline_2026_08_09_9207_breaker_halfopen_recovery": "PR #9207 own growth during the 2026-08-09 rebase: open-sse/services/accountFallback.ts 1978->2020 (+42 = recordProviderSuccess now also transitions the provider circuit breaker from HALF_OPEN to CLOSED when a request succeeds, so the breaker is not stuck half-open after repeated failures; the transition and its reset wiring grow the existing provider-success path, not extractable). Covered by tests/unit/provider-breaker-halfopen-recovery.test.ts.",
|
"_rebaseline_2026_08_09_9207_breaker_halfopen_recovery": "PR #9207 own growth during the 2026-08-09 rebase: open-sse/services/accountFallback.ts 1978->2020 (+42 = recordProviderSuccess now also transitions the provider circuit breaker from HALF_OPEN to CLOSED when a request succeeds, so the breaker is not stuck half-open after repeated failures; the transition and its reset wiring grow the existing provider-success path, not extractable). Covered by tests/unit/provider-breaker-halfopen-recovery.test.ts.",
|
||||||
@@ -583,7 +582,7 @@
|
|||||||
"src/lib/memory/retrieval.ts": "1073",
|
"src/lib/memory/retrieval.ts": "1073",
|
||||||
"src/lib/tailscaleTunnel.ts": "1202",
|
"src/lib/tailscaleTunnel.ts": "1202",
|
||||||
"src/lib/usage/providerLimits.ts": "1013",
|
"src/lib/usage/providerLimits.ts": "1013",
|
||||||
"src/shared/components/OAuthModal.tsx": "1146",
|
"src/shared/components/OAuthModal.tsx": "1134",
|
||||||
"src/shared/components/RequestLoggerV2.tsx": "1629",
|
"src/shared/components/RequestLoggerV2.tsx": "1629",
|
||||||
"src/shared/components/analytics/charts.tsx": "1035",
|
"src/shared/components/analytics/charts.tsx": "1035",
|
||||||
"src/shared/services/cliRuntime.ts": "1122",
|
"src/shared/services/cliRuntime.ts": "1122",
|
||||||
|
|||||||
@@ -368,7 +368,7 @@ const result = await executor.execute({
|
|||||||
});
|
});
|
||||||
````
|
````
|
||||||
|
|
||||||
Resolution goes through the `ExecutorRegistry` (`executors/registry.ts`): every specialized executor is declared in the built-in table of `executors/index.ts` and registered via `registerExecutor(alias, instance)` at module load; `getExecutor()` consults the registry and falls back to a memoized `DefaultExecutor` for any provider without a specialized entry. The full alias → executor mapping is characterized by the golden test `tests/unit/executor-map-golden.test.ts`.
|
The factory is generated from `config/providerRegistry.ts` which lists all 338 providers and their executor class.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -54,23 +54,23 @@ Every command honours the **active context** (set with `omniroute connect`, see
|
|||||||
with `--remote` (or an active remote context) it fetches the catalog from that
|
with `--remote` (or an active remote context) it fetches the catalog from that
|
||||||
server and writes the config locally.
|
server and writes the config locally.
|
||||||
|
|
||||||
| Command | Tool | What it writes | Key flags | Local vs remote |
|
| Command | Tool | What it writes | Key flags | Local vs remote |
|
||||||
| -------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | --------------- |
|
| -------------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | --------------- |
|
||||||
| `omniroute setup-codex` | OpenAI Codex CLI | `~/.codex/<name>.config.toml` — one profile per compatible text model (`codex --profile <name>`) | `--remote` `--api-key` `--only` `--dry-run` `--port` `--codex-home` | Both |
|
| `omniroute setup-codex` | OpenAI Codex CLI | `~/.codex/<name>.config.toml` — one profile per compatible text model (`codex --profile <name>`) | `--remote` `--api-key` `--only` `--dry-run` `--port` `--codex-home` | Both |
|
||||||
| `omniroute setup-claude` | Claude Code | `~/.claude/profiles/<name>/settings.json` — one profile per matched model (`CLAUDE_CONFIG_DIR`) | `--remote` `--api-key` `--only` `--dry-run` `--port` `--claude-home` | Both |
|
| `omniroute setup-claude` | Claude Code | `~/.claude/profiles/<name>/settings.json` — one profile per matched model (`CLAUDE_CONFIG_DIR`) | `--remote` `--api-key` `--only` `--dry-run` `--port` `--claude-home` | Both |
|
||||||
| `omniroute setup-opencode` | OpenCode (openai-compatible) | `~/.config/opencode/opencode.json` — `omniroute` provider with every catalog model (`opencode -m omniroute/<model>`) | `--remote` `--api-key` `--only` `--model` `--dry-run` `--port` | Both |
|
| `omniroute setup-opencode` | OpenCode (openai-compatible) | `~/.config/opencode/opencode.json` — `omniroute` provider with every catalog model (`opencode -m omniroute/<model>`) | `--remote` `--api-key` `--only` `--model` `--dry-run` `--port` | Both |
|
||||||
| `omniroute setup-cline` | Cline | `~/.cline/data/{globalState,secrets}.json` (CLI mode) + prints VS Code extension settings | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--cline-dir` | Both |
|
| `omniroute setup-cline` | Cline | `~/.cline/data/{globalState,secrets}.json` (CLI mode) + prints VS Code extension settings | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--cline-dir` | Both |
|
||||||
| `omniroute setup-kilo` | Kilo Code | `~/.local/share/kilo/auth.json` (CLI) + merges `kilocode.*` into VS Code `settings.json` if present | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--auth-path` `--vscode-settings` | Both |
|
| `omniroute setup-kilo` | Kilo Code | `~/.local/share/kilo/auth.json` (CLI) + merges `kilocode.*` into VS Code `settings.json` if present | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--auth-path` `--vscode-settings` | Both |
|
||||||
| `omniroute setup-continue` | Continue / `cn` CLI | `~/.continue/config.yaml` — `provider: openai` models, key via `${{ secrets.OMNIROUTE_API_KEY }}` | `--remote` `--api-key` `--only` `--dry-run` `--port` `--config-path` | Both |
|
| `omniroute setup-continue` | Continue / `cn` CLI | `~/.continue/config.yaml` — `provider: openai` models, key via `${{ secrets.OMNIROUTE_API_KEY }}` | `--remote` `--api-key` `--only` `--dry-run` `--port` `--config-path` | Both |
|
||||||
| `omniroute setup-cursor` | Cursor | Nothing — prints the in-app steps (Cursor config is opaque SQLite) | `--remote` `--api-key` `--only` `--port` | Both |
|
| `omniroute setup-cursor` | Cursor | Nothing — prints the in-app steps (Cursor config is opaque SQLite) | `--remote` `--api-key` `--only` `--port` | Both |
|
||||||
| `omniroute setup-roo` | Roo Code | `~/.omniroute/roo-settings.json` (import doc) + sets `roo-cline.autoImportSettingsPath` if a VS Code `settings.json` exists | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--import-path` `--vscode-settings` | Both |
|
| `omniroute setup-roo` | Roo Code | `~/.omniroute/roo-settings.json` (import doc) + sets `roo-cline.autoImportSettingsPath` if a VS Code `settings.json` exists | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--import-path` `--vscode-settings` | Both |
|
||||||
| `omniroute setup-crush` | Crush | `~/.config/crush/crush.json` — `openai-compat` provider, key via `$OMNIROUTE_API_KEY` | `--remote` `--api-key` `--only` `--dry-run` `--port` `--config-path` | Both |
|
| `omniroute setup-crush` | Crush | `~/.config/crush/crush.json` — `openai-compat` provider, key via `$OMNIROUTE_API_KEY` | `--remote` `--api-key` `--only` `--dry-run` `--port` `--config-path` | Both |
|
||||||
| `omniroute setup-goose` | Goose | `~/.config/goose/config.yaml` (`GOOSE_PROVIDER`/`OPENAI_HOST`/`GOOSE_MODEL`) + prints env recipe | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` | Both |
|
| `omniroute setup-goose` | Goose | `~/.config/goose/config.yaml` (`GOOSE_PROVIDER`/`OPENAI_HOST`/`GOOSE_MODEL`) + prints env recipe | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` | Both |
|
||||||
| `omniroute setup-aider` | Aider | `~/.aider.conf.yml` (`openai-api-base` + `model: openai/<id>`) + prints env recipe | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` | Both |
|
| `omniroute setup-aider` | Aider | `~/.aider.conf.yml` (`openai-api-base` + `model: openai/<id>`) + prints env recipe | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` | Both |
|
||||||
| `omniroute setup-qwen` | Qwen Code | `~/.qwen/settings.json` — V4 `modelProviders.openai` array + `OMNIROUTE_API_KEY` in `~/.qwen/.env` | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` `--env-path` | Both |
|
| `omniroute setup-qwen` | Qwen Code | `~/.qwen/settings.json` — V4 `modelProviders.openai` array + `OMNIROUTE_API_KEY` in `~/.qwen/.env` | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` `--env-path` | Both |
|
||||||
| `omniroute run <target>` | Runtime launch (generic) | Nothing — spawn `claude`/`codex`/`aider`/`goose`/`opencode`/`qwen`/`gemini` with the right env and args; Qwen and Gemini use a temporary isolated home | `--remote` `--base-url` `--context` `--provider` `--model` `--api-key` `--api-key-env` `--dry-run` `--json` `--port` `--profile` `--token` | Both |
|
| `omniroute run <target>` | Runtime launch (generic) | Nothing — spawn `claude`/`codex`/`aider`/`goose`/`opencode`/`qwen` with the right env and args; Qwen uses a temporary isolated home | `--remote` `--base-url` `--context` `--provider` `--model` `--api-key` `--api-key-env` `--dry-run` `--json` `--port` `--profile` `--token` | Both |
|
||||||
| `omniroute launch` | Claude Code | Nothing — spawns `claude` with `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` injected | `--remote` `--api-key` `--token` `--profile` `--port` | Both |
|
| `omniroute launch` | Claude Code | Nothing — spawns `claude` with `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` injected | `--remote` `--api-key` `--token` `--profile` `--port` | Both |
|
||||||
| `omniroute launch-codex` | OpenAI Codex CLI | Nothing — spawns `codex` with the `omniroute` provider injected via `-c` flags | `--remote` `--api-key` `--profile` (`-p`) `--port` | Both |
|
| `omniroute launch-codex` | OpenAI Codex CLI | Nothing — spawns `codex` with the `omniroute` provider injected via `-c` flags | `--remote` `--api-key` `--profile` (`-p`) `--port` | Both |
|
||||||
|
|
||||||
Notes on flags (verified in the command source):
|
Notes on flags (verified in the command source):
|
||||||
|
|
||||||
@@ -153,7 +153,6 @@ omniroute run aider --model glm/glm-5.2 -- --message "reply OK"
|
|||||||
omniroute run goose --model glm/glm-5.2
|
omniroute run goose --model glm/glm-5.2
|
||||||
omniroute run opencode --model glm/glm-5.2 -- run "reply OK"
|
omniroute run opencode --model glm/glm-5.2 -- run "reply OK"
|
||||||
omniroute run qwen --model glm/glm-5.2 -- -p "reply OK"
|
omniroute run qwen --model glm/glm-5.2 -- -p "reply OK"
|
||||||
omniroute run gemini --model glm/glm-5.2 -- --skip-trust -p "reply OK"
|
|
||||||
|
|
||||||
# Explicit command path: pass through whatever comes after --
|
# Explicit command path: pass through whatever comes after --
|
||||||
omniroute run claude -- --print-system-prompt "review this diff"
|
omniroute run claude -- --print-system-prompt "review this diff"
|
||||||
@@ -209,7 +208,6 @@ tool expects (verified in the command source):
|
|||||||
| `setup-claude` (`ANTHROPIC_BASE_URL`), `launch` | root | No — Claude Code appends `/v1/messages` |
|
| `setup-claude` (`ANTHROPIC_BASE_URL`), `launch` | root | No — Claude Code appends `/v1/messages` |
|
||||||
| `setup-codex`, `launch-codex` (`model_providers.omniroute.base_url`) | with `/v1` | Yes |
|
| `setup-codex`, `launch-codex` (`model_providers.omniroute.base_url`) | with `/v1` | Yes |
|
||||||
| `setup-qwen` (`modelProviders.openai[].baseUrl`) | with `/v1` | Yes |
|
| `setup-qwen` (`modelProviders.openai[].baseUrl`) | with `/v1` | Yes |
|
||||||
| `run gemini` (`GOOGLE_GEMINI_BASE_URL`) | root | No — the SDK appends `/v1beta/models/…` |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -329,13 +329,10 @@ prefix). Traefik should route `PathPrefix(`/omniroute`)` to the container withou
|
|||||||
`StripPrefix`, so Next.js receives `/omniroute/...` and serves assets from
|
`StripPrefix`, so Next.js receives `/omniroute/...` and serves assets from
|
||||||
`/omniroute/_next/...`.
|
`/omniroute/_next/...`.
|
||||||
|
|
||||||
The Docker healthcheck probes the lightweight `/healthz` lifecycle endpoint prefixed
|
The Docker healthcheck probes `/api/monitoring/health` prefixed with the active
|
||||||
with the active `OMNIROUTE_BASE_PATH`. `/api/monitoring/health` remains available for
|
`OMNIROUTE_BASE_PATH`. That path is a **deep** check (DB + monitoring summary). It is
|
||||||
human/dashboard diagnostics; to point the container HEALTHCHECK back at it (for example
|
appropriate for Docker’s infrequent `HEALTHCHECK`, but **not** for Kubernetes
|
||||||
for deep health enforcement), set `OMNIROUTE_HEALTHCHECK_PATH=/api/monitoring/health`.
|
`livenessProbe` intervals.
|
||||||
That path is a **deep** check (DB + monitoring summary) — appropriate for Docker's
|
|
||||||
infrequent `HEALTHCHECK` if you opt back in, but **not** for Kubernetes `livenessProbe`
|
|
||||||
intervals.
|
|
||||||
|
|
||||||
For orchestrators (Kubernetes, Nomad, etc.):
|
For orchestrators (Kubernetes, Nomad, etc.):
|
||||||
|
|
||||||
|
|||||||
@@ -286,26 +286,6 @@ The written profile references the inference key by env var
|
|||||||
base Codex setup (the `[model_providers.omniroute]` block), see
|
base Codex setup (the `[model_providers.omniroute]` block), see
|
||||||
[CODEX-CLI-CONFIGURATION.md](./CODEX-CLI-CONFIGURATION.md).
|
[CODEX-CLI-CONFIGURATION.md](./CODEX-CLI-CONFIGURATION.md).
|
||||||
|
|
||||||
### Launching a CLI against the remote (no config written)
|
|
||||||
|
|
||||||
`omniroute run <target>` also honours the active context: the remote base URL
|
|
||||||
and the context credential are injected into the spawned process only.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
omniroute connect 192.168.0.15
|
|
||||||
omniroute run claude --model openai/gpt-5.4 # Claude Code → remote
|
|
||||||
omniroute run gemini --model glm/glm-5.2 -- --skip-trust -p "hello"
|
|
||||||
omniroute run opencode --model glm/glm-5.2 -- run "reply OK"
|
|
||||||
|
|
||||||
# Preview exactly what would be spawned (env KEY NAMES only, never values):
|
|
||||||
omniroute run codex --dry-run --json
|
|
||||||
```
|
|
||||||
|
|
||||||
Targets: `claude`, `codex`, `aider`, `goose`, `opencode`, `qwen`, `gemini`
|
|
||||||
(single source: `bin/cli/cli-manifest.mjs`). Qwen and Gemini run with a
|
|
||||||
temporary isolated home that is removed on exit, so the launch never touches —
|
|
||||||
or leaks into — your personal tool configuration.
|
|
||||||
|
|
||||||
### Per-CLI setup commands
|
### Per-CLI setup commands
|
||||||
|
|
||||||
Each supported CLI has a remote-aware setup command (all honour the active
|
Each supported CLI has a remote-aware setup command (all honour the active
|
||||||
|
|||||||
@@ -224,10 +224,8 @@ prefiksu). Traefik powinien routować `PathPrefix(`/omniroute`)` do kontenera be
|
|||||||
`StripPrefix`, żeby Next.js otrzymywał `/omniroute/...` i serwował assety z
|
`StripPrefix`, żeby Next.js otrzymywał `/omniroute/...` i serwował assety z
|
||||||
`/omniroute/_next/...`.
|
`/omniroute/_next/...`.
|
||||||
|
|
||||||
Healthcheck Dockera sonduje lekki endpoint cyklu życia `/healthz` z prefiksem aktywnego
|
Healthcheck Dockera sonduje `/api/monitoring/health` z prefiksem aktywnego
|
||||||
`OMNIROUTE_BASE_PATH`. `/api/monitoring/health` pozostaje dostępny do diagnostyki
|
`OMNIROUTE_BASE_PATH`.
|
||||||
człowieka/pulpit; aby ustawić HEALTHCHECK kontenera z powrotem na niego (np. dla
|
|
||||||
głębokiej kontroli stanu), ustaw `OMNIROUTE_HEALTHCHECK_PATH=/api/monitoring/health`.
|
|
||||||
|
|
||||||
## Docker Compose z Caddy (HTTPS Auto-TLS)
|
## Docker Compose z Caddy (HTTPS Auto-TLS)
|
||||||
|
|
||||||
|
|||||||
@@ -367,11 +367,11 @@ GET /v1/models?prefix=dual # both forms (server default)
|
|||||||
GET /v1/models?prefix=canonical # only the full provider-id prefix
|
GET /v1/models?prefix=canonical # only the full provider-id prefix
|
||||||
```
|
```
|
||||||
|
|
||||||
| Mode | Emits | Notes |
|
| Mode | Emits | Notes |
|
||||||
| ----------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --- | --- | --- |
|
||||||
| `dual` | `cc/claude-sonnet-4-6` **and** `claude/claude-sonnet-4-6` | **Default.** Both ids route to the same model; kept so client configs that hardcoded either form keep working. Roughly doubles the catalog. |
|
| `dual` | `cc/claude-sonnet-4-6` **and** `claude/claude-sonnet-4-6` | **Default.** Both ids route to the same model; kept so client configs that hardcoded either form keep working. Roughly doubles the catalog. |
|
||||||
| `alias` | `cc/claude-sonnet-4-6` | One entry per model. Providers without a distinct alias still emit their entry, so nothing is lost. |
|
| `alias` | `cc/claude-sonnet-4-6` | One entry per model. Providers without a distinct alias still emit their entry, so nothing is lost. |
|
||||||
| `canonical` | `claude/claude-sonnet-4-6` | ⚠️ The canonical row is only emitted when the canonical provider id **differs** from the alias, so providers without a distinct alias emit nothing in this mode. Prefer `alias` for a de-duplicated list. |
|
| `canonical` | `claude/claude-sonnet-4-6` | ⚠️ The canonical row is only emitted when the canonical provider id **differs** from the alias, so providers without a distinct alias emit nothing in this mode. Prefer `alias` for a de-duplicated list. |
|
||||||
|
|
||||||
A `dual`-mode mirror can also be recognised without the query parameter: it carries a `parent`
|
A `dual`-mode mirror can also be recognised without the query parameter: it carries a `parent`
|
||||||
field pointing at the primary id.
|
field pointing at the primary id.
|
||||||
@@ -1514,16 +1514,16 @@ Admin-only endpoints for operational management.
|
|||||||
Manage CLI tools that integrate with OmniRoute (antigravity, chipotle, commandCode,
|
Manage CLI tools that integrate with OmniRoute (antigravity, chipotle, commandCode,
|
||||||
devin-cli, etc.). See [Provider Reference](./PROVIDER_REFERENCE.md) for the full list.
|
devin-cli, etc.). See [Provider Reference](./PROVIDER_REFERENCE.md) for the full list.
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
| ------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------ | --------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||||
| GET | `/api/cli-tools/all-statuses` | Status of all CLI tools (installed, version, last seen) |
|
| GET | `/api/cli-tools/all-statuses` | Status of all CLI tools (installed, version, last seen) |
|
||||||
| GET | `/api/cli-tools/[id]/status` | Status of a specific CLI tool (id can be: antigravity, chipotle, commandCode, devin-cli, etc.) |
|
| GET | `/api/cli-tools/[id]/status` | Status of a specific CLI tool (id can be: antigravity, chipotle, commandCode, devin-cli, etc.) |
|
||||||
| POST | `/api/cli-tools/apply` | Write a tool's generated config (`dryRun` previews; `422` + `containerEphemeralTarget` when containerized; `migration` notes a legacy Codex YAML) |
|
| POST | `/api/cli-tools/apply` | Apply a CLI tool configuration to a provider connection |
|
||||||
| GET | `/api/cli-tools/backups` | List CLI tool configuration backups |
|
| GET | `/api/cli-tools/backups` | List CLI tool configuration backups |
|
||||||
| POST | `/api/cli-tools/backups` | Create a backup of all CLI tool configurations |
|
| POST | `/api/cli-tools/backups` | Create a backup of all CLI tool configurations |
|
||||||
| POST | `/api/cli-tools/[id]/restore` | Restore a CLI tool from a backup |
|
| POST | `/api/cli-tools/[id]/restore` | Restore a CLI tool from a backup |
|
||||||
| GET | `/api/cli-tools/antigravity-mitm` | Antigravity MITM proxy status (the "antigravity-mitm" CLI tool) |
|
| GET | `/api/cli-tools/antigravity-mitm` | Antigravity MITM proxy status (the "antigravity-mitm" CLI tool) |
|
||||||
| POST | `/api/cli-tools/antigravity-mitm/alias` | Configure antigravity-mitm aliases |
|
| POST | `/api/cli-tools/antigravity-mitm/alias` | Configure antigravity-mitm aliases |
|
||||||
|
|
||||||
**Auth:** Requires management session.
|
**Auth:** Requires management session.
|
||||||
|
|
||||||
|
|||||||
@@ -349,9 +349,6 @@ npm install -g kilocode
|
|||||||
# Qwen Code
|
# Qwen Code
|
||||||
npm install -g @qwen-code/qwen-code
|
npm install -g @qwen-code/qwen-code
|
||||||
|
|
||||||
# Google Gemini CLI (launchable via `omniroute run gemini` → /v1beta surface)
|
|
||||||
npm install -g @google/gemini-cli
|
|
||||||
|
|
||||||
# Aider
|
# Aider
|
||||||
pip install aider-chat
|
pip install aider-chat
|
||||||
|
|
||||||
|
|||||||
@@ -390,9 +390,6 @@ Controls how OmniRoute discovers and launches CLI sidecars (Claude Code, Codex,
|
|||||||
| `CLI_CONTINUE_BIN` | `cn` | `src/shared/services/cliRuntime.ts` | Custom path to Continue CLI binary. |
|
| `CLI_CONTINUE_BIN` | `cn` | `src/shared/services/cliRuntime.ts` | Custom path to Continue CLI binary. |
|
||||||
| `CLI_QODER_BIN` | `qoder` | `src/shared/services/cliRuntime.ts` | Custom path to Qoder CLI binary. |
|
| `CLI_QODER_BIN` | `qoder` | `src/shared/services/cliRuntime.ts` | Custom path to Qoder CLI binary. |
|
||||||
| `CLI_QWEN_BIN` | `qwen` | `src/shared/services/cliRuntime.ts` | Custom path to the Qwen Code CLI binary. |
|
| `CLI_QWEN_BIN` | `qwen` | `src/shared/services/cliRuntime.ts` | Custom path to the Qwen Code CLI binary. |
|
||||||
| `CLI_AIDER_BIN` | `aider` | `src/shared/services/cliRuntime.ts` | Custom path to the Aider CLI binary. |
|
|
||||||
| `CLI_GOOSE_BIN` | `goose` | `src/shared/services/cliRuntime.ts` | Custom path to the Goose CLI binary. |
|
|
||||||
| `CLI_GEMINI_BIN` | `gemini` | `src/shared/services/cliRuntime.ts` | Custom path to the Google Gemini CLI binary (used by detection and `omniroute run gemini`). |
|
|
||||||
| `CLI_DEVIN_BIN` | `devin` | `open-sse/executors/devin-cli.ts` | Custom path to the Devin CLI binary (v3.8.0). Used by the Windsurf/Devin executor. |
|
| `CLI_DEVIN_BIN` | `devin` | `open-sse/executors/devin-cli.ts` | Custom path to the Devin CLI binary (v3.8.0). Used by the Windsurf/Devin executor. |
|
||||||
| `DEVIN_DESKTOP_VERSION` | `3.6.27` | `open-sse/executors/devin-desktop.ts` | Devin Desktop `ide_version`. Overrides must use `x.y.z` format; invalid values fall back to the verified default. |
|
| `DEVIN_DESKTOP_VERSION` | `3.6.27` | `open-sse/executors/devin-desktop.ts` | Devin Desktop `ide_version`. Overrides must use `x.y.z` format; invalid values fall back to the verified default. |
|
||||||
| `DEVIN_DESKTOP_EXTENSION_VERSION` | `1.48.2` | `open-sse/executors/devin-desktop.ts` | Bundled Codeium/language-server `extension_version`, distinct from Desktop `ide_version`. Overrides must use `x.y.z`; invalid values use the bundled default. |
|
| `DEVIN_DESKTOP_EXTENSION_VERSION` | `1.48.2` | `open-sse/executors/devin-desktop.ts` | Bundled Codeium/language-server `extension_version`, distinct from Desktop `ide_version`. Overrides must use `x.y.z`; invalid values use the bundled default. |
|
||||||
@@ -971,7 +968,6 @@ Chrome-driven session refresh (ARP) for the Adobe Firefly web provider (`open-ss
|
|||||||
| `AWS_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Region used to construct AWS Bedrock endpoints (Kiro, audio). |
|
| `AWS_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Region used to construct AWS Bedrock endpoints (Kiro, audio). |
|
||||||
| `AWS_DEFAULT_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Fallback when `AWS_REGION` is not set. |
|
| `AWS_DEFAULT_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Fallback when `AWS_REGION` is not set. |
|
||||||
| `CLOUDFLARE_ACCOUNT_ID` | _(unset)_ | `open-sse/executors/cloudflare-ai.ts` | Account ID for Cloudflare Workers AI. |
|
| `CLOUDFLARE_ACCOUNT_ID` | _(unset)_ | `open-sse/executors/cloudflare-ai.ts` | Account ID for Cloudflare Workers AI. |
|
||||||
| `CLOUDFLARE_PLAYGROUND_CHROME_PATH` | _(unset)_ | `open-sse/executors/cloudflare-playground.ts` | Full desktop Chrome binary path for the Cloudflare AI Playground executor, used when the headless fingerprint check blocks Playwright's bundled Chromium. |
|
|
||||||
| `CLOUDFLARE_API_BASE` | `https://api.cloudflare.com/client/v4` | `src/app/api/settings/proxy/cloudflare-deploy/route.ts` | Override the Cloudflare REST API base used by the proxy-pool Workers relay deployer (#4640 / 9router#1360). |
|
| `CLOUDFLARE_API_BASE` | `https://api.cloudflare.com/client/v4` | `src/app/api/settings/proxy/cloudflare-deploy/route.ts` | Override the Cloudflare REST API base used by the proxy-pool Workers relay deployer (#4640 / 9router#1360). |
|
||||||
| `NEXT_PUBLIC_CLOUDFLARE_RELAY_DEFAULT_PROJECT` | `omniroute-relay` | `src/app/(dashboard)/dashboard/settings/components/proxy/CloudflareRelayModal.tsx` | Default worker project name suggested in the proxy-pool "Deploy Relay" modal. |
|
| `NEXT_PUBLIC_CLOUDFLARE_RELAY_DEFAULT_PROJECT` | `omniroute-relay` | `src/app/(dashboard)/dashboard/settings/components/proxy/CloudflareRelayModal.tsx` | Default worker project name suggested in the proxy-pool "Deploy Relay" modal. |
|
||||||
| `NEXT_PUBLIC_CLOUDFLARE_RELAY_ENABLED` | `true` | `src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx` | Set to `false` to hide the Cloudflare Workers relay option from the Proxy Pool tab. |
|
| `NEXT_PUBLIC_CLOUDFLARE_RELAY_ENABLED` | `true` | `src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx` | Set to `false` to hide the Cloudflare Workers relay option from the Proxy Pool tab. |
|
||||||
|
|||||||
@@ -248,26 +248,6 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
|
|||||||
supportedSizes: ["1024x1024", "1024x1536", "1536x1024"],
|
supportedSizes: ["1024x1024", "1024x1536", "1536x1024"],
|
||||||
},
|
},
|
||||||
|
|
||||||
// #10466: Gemini Web session image generation (Nano Banana). Same
|
|
||||||
// web-cookie transport as the gemini-web chat provider — the handler
|
|
||||||
// drives the session executor in image mode and extracts the generated
|
|
||||||
// asset URLs from the StreamGenerate frames.
|
|
||||||
"gemini-web": {
|
|
||||||
id: "gemini-web",
|
|
||||||
alias: "gweb",
|
|
||||||
baseUrl: "https://gemini.google.com/app",
|
|
||||||
authType: "apikey",
|
|
||||||
authHeader: "cookie",
|
|
||||||
format: "gemini-web",
|
|
||||||
// `-web` suffix on purpose: the bare `nano-banana` id is owned by
|
|
||||||
// adobe-firefly (operator decision 2026-07-31, pinned by the
|
|
||||||
// cheaperinference-image-models guard). parseImageModel's bare-model scan
|
|
||||||
// walks providers in insertion order, so a bare `nano-banana` here would
|
|
||||||
// steal that resolution. Keep this id distinct.
|
|
||||||
models: [{ id: "nano-banana-web", name: "Nano Banana (Gemini Web Image)" }],
|
|
||||||
supportedSizes: ["1024x1024", "1024x1536", "1536x1024"],
|
|
||||||
},
|
|
||||||
|
|
||||||
"microsoft-designer-web": {
|
"microsoft-designer-web": {
|
||||||
id: "microsoft-designer-web",
|
id: "microsoft-designer-web",
|
||||||
alias: "msdesigner",
|
alias: "msdesigner",
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import { difyProvider } from "./registry/dify/index.ts";
|
|||||||
import { ovhcloudProvider } from "./registry/ovhcloud/index.ts";
|
import { ovhcloudProvider } from "./registry/ovhcloud/index.ts";
|
||||||
import { claudeProvider } from "./registry/claude/index.ts";
|
import { claudeProvider } from "./registry/claude/index.ts";
|
||||||
import { claude_webProvider } from "./registry/claude/web/index.ts";
|
import { claude_webProvider } from "./registry/claude/web/index.ts";
|
||||||
import { cloudflarePlaygroundProvider } from "./registry/cloudflare-playground/index.ts";
|
|
||||||
import { bedrockProvider } from "./registry/bedrock/index.ts";
|
import { bedrockProvider } from "./registry/bedrock/index.ts";
|
||||||
import { inner_aiProvider } from "./registry/inner-ai/index.ts";
|
import { inner_aiProvider } from "./registry/inner-ai/index.ts";
|
||||||
import { qoderProvider } from "./registry/qoder/index.ts";
|
import { qoderProvider } from "./registry/qoder/index.ts";
|
||||||
@@ -291,7 +290,6 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
|||||||
ovhcloud: ovhcloudProvider,
|
ovhcloud: ovhcloudProvider,
|
||||||
claude: claudeProvider,
|
claude: claudeProvider,
|
||||||
"claude-web": claude_webProvider,
|
"claude-web": claude_webProvider,
|
||||||
"cloudflare-playground": cloudflarePlaygroundProvider,
|
|
||||||
bedrock: bedrockProvider,
|
bedrock: bedrockProvider,
|
||||||
"inner-ai": inner_aiProvider,
|
"inner-ai": inner_aiProvider,
|
||||||
qoder: qoderProvider,
|
qoder: qoderProvider,
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
/**
|
|
||||||
* Cloudflare AI Playground — No Auth provider registry entry.
|
|
||||||
*
|
|
||||||
* Free, anonymous access to the Cloudflare AI Playground
|
|
||||||
* (https://playground.ai.cloudflare.com) — no account, no API key, no cookies.
|
|
||||||
* Chat runs over a PartySocket WebSocket speaking Cloudflare's `cf_agent`
|
|
||||||
* protocol; the only gate is a browser-grade TLS fingerprint on the WS upgrade,
|
|
||||||
* which the `cloudflare-playground` executor satisfies by driving a headless
|
|
||||||
* Chromium via Playwright (see executors/cloudflare-playground.ts).
|
|
||||||
*
|
|
||||||
* Model catalog captured from the playground's live `getModels` RPC
|
|
||||||
* (2026-08-15, 63 models total; the 20 chat/text-generation entries are listed
|
|
||||||
* here). Model IDs use the playground's `org/model` slug form — the executor
|
|
||||||
* prefixes them with `@cf/` when talking to the upstream.
|
|
||||||
*/
|
|
||||||
import type { RegistryEntry } from "../../shared.ts";
|
|
||||||
|
|
||||||
export const cloudflarePlaygroundProvider: RegistryEntry = {
|
|
||||||
id: "cloudflare-playground",
|
|
||||||
alias: "cfp",
|
|
||||||
format: "openai",
|
|
||||||
executor: "cloudflare-playground",
|
|
||||||
baseUrl: "https://playground.ai.cloudflare.com",
|
|
||||||
authType: "none",
|
|
||||||
authHeader: "none",
|
|
||||||
models: [
|
|
||||||
// Frontier/open-weight flagships first.
|
|
||||||
{ id: "zai-org/glm-5.2", name: "GLM 5.2 (Z.ai)", supportsReasoning: true },
|
|
||||||
{ id: "moonshotai/kimi-k2.7-code", name: "Kimi K2.7 Code (Moonshot)", supportsReasoning: true },
|
|
||||||
{ id: "moonshotai/kimi-k2.6", name: "Kimi K2.6 (Moonshot)", supportsReasoning: true },
|
|
||||||
{
|
|
||||||
id: "deepseek-ai/deepseek-v4-pro-0813",
|
|
||||||
name: "DeepSeek V4 Pro (DeepSeek)",
|
|
||||||
supportsReasoning: true,
|
|
||||||
},
|
|
||||||
{ id: "deepseek-ai/deepseek-v4-flash-0731", name: "DeepSeek V4 Flash (DeepSeek)" },
|
|
||||||
{ id: "zai-org/glm-4.7-flash", name: "GLM 4.7 Flash (Z.ai)", supportsReasoning: true },
|
|
||||||
{ id: "openai/gpt-oss-120b", name: "GPT-OSS 120B (OpenAI)" },
|
|
||||||
{ id: "openai/gpt-oss-20b", name: "GPT-OSS 20B (OpenAI)" },
|
|
||||||
{ id: "meta-llama/llama-3.3-70b-instruct-fp8-fast", name: "Llama 3.3 70B Instruct (Meta)" },
|
|
||||||
{ id: "meta/llama-3.1-8b-instruct-fp8", name: "Llama 3.1 8B Instruct (Meta)" },
|
|
||||||
{ id: "meta/llama-4-scout-17b-16e-instruct", name: "Llama 4 Scout 17B (Meta)" },
|
|
||||||
{ id: "nvidia/nemotron-3-120b-a12b", name: "Nemotron 3 120B (NVIDIA)" },
|
|
||||||
{ id: "qwen/qwen2.5-coder-32b-instruct", name: "Qwen2.5 Coder 32B (Qwen)" },
|
|
||||||
{ id: "qwen/qwen3-30b-a3b-fp8", name: "Qwen3 30B A3B (Qwen)" },
|
|
||||||
{ id: "qwen/qwq-32b", name: "QwQ 32B (Qwen)", supportsReasoning: true },
|
|
||||||
{
|
|
||||||
id: "deepseek-ai/deepseek-r1-distill-qwen-32b",
|
|
||||||
name: "DeepSeek R1 Distill Qwen 32B",
|
|
||||||
supportsReasoning: true,
|
|
||||||
},
|
|
||||||
{ id: "google/gemma-4-26b-a4b-it", name: "Gemma 4 26B A4B (Google)" },
|
|
||||||
{ id: "mistralai/mistral-small-3.1-24b-instruct", name: "Mistral Small 3.1 24B" },
|
|
||||||
{ id: "ibm-granite/granite-4.0-h-micro", name: "Granite 4.0 H Micro (IBM)" },
|
|
||||||
{ id: "aisingapore/gemma-sea-lion-v4-27b-it", name: "Gemma SEA-LION V4 27B (AI Singapore)" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -1,591 +0,0 @@
|
|||||||
/**
|
|
||||||
* CloudflarePlaygroundExecutor — Cloudflare AI Playground (No Auth) provider
|
|
||||||
*
|
|
||||||
* Reverse-engineered access to the free, anonymous Cloudflare AI Playground
|
|
||||||
* (https://playground.ai.cloudflare.com). No account, no API key, no cookies:
|
|
||||||
* chat runs over a PartySocket WebSocket speaking Cloudflare's `cf_agent` RPC
|
|
||||||
* protocol, and the only gate is a browser-grade TLS fingerprint on the WS
|
|
||||||
* upgrade. This executor therefore drives a headless Chromium via Playwright,
|
|
||||||
* opens the WebSocket *inside the page context* (only a real browser TLS stack
|
|
||||||
* passes the upgrade), and translates the `cf_agent` frame stream into
|
|
||||||
* OpenAI-format chat completion chunks.
|
|
||||||
*
|
|
||||||
* Protocol (captured live 2026-08-15):
|
|
||||||
* - Transport: wss://playground.ai.cloudflare.com/agents/playground/<room>?_pk=<uuid>
|
|
||||||
* - Resume: {"type":"cf_agent_stream_resume_request"}
|
|
||||||
* - Config: {"type":"rpc","method":"setConfig","args":[{model,temperature,stream}]}
|
|
||||||
* - Chat: {"id":<cid>,"init":{"method":"POST","body":{messages,trigger}},"type":"cf_agent_use_chat_request"}
|
|
||||||
* - Stream: start → start-step → (reasoning-start/delta/end)* → text-start →
|
|
||||||
* text-delta* → finish-step → finish{messageMetadata.finishReason} → {done:true}
|
|
||||||
* - Errors: {"error":true,"body":"{message,details}","id":<cid>} — e.g.
|
|
||||||
* "3021: rate limiting: inference request per min rate reached"
|
|
||||||
*
|
|
||||||
* Notes:
|
|
||||||
* - The playground's system prompt is server-side (set via setConfig by the
|
|
||||||
* app itself); client `system` messages are dropped. Tool calls are not
|
|
||||||
* implemented (v1) — text-only chat.
|
|
||||||
* - Upstream rate limits arrive in-band as `error:true` frames. Non-streaming
|
|
||||||
* requests surface them as HTTP 429/502; streaming requests emit an SSE
|
|
||||||
* error chunk before `[DONE]` (the response status is already committed).
|
|
||||||
* A server-side chat timeout follows the same rule: streaming requests
|
|
||||||
* emit a `timeout_error` chunk before `[DONE]` instead of silently
|
|
||||||
* completing (#10494).
|
|
||||||
* - Set CLOUDFLARE_PLAYGROUND_CHROME_PATH to point at a full desktop Chrome
|
|
||||||
* binary when Playwright's bundled Chromium gets fingerprint-blocked.
|
|
||||||
*/
|
|
||||||
import { randomUUID } from "crypto";
|
|
||||||
import { BaseExecutor, type ExecuteInput } from "./base.ts";
|
|
||||||
import { makeExecutorErrorResult as makeErrorResult } from "../utils/error.ts";
|
|
||||||
import type { Browser, Page } from "playwright";
|
|
||||||
|
|
||||||
export const PLAYGROUND_URL = "https://playground.ai.cloudflare.com/";
|
|
||||||
const PLAYGROUND_WS_BASE = "wss://playground.ai.cloudflare.com/agents/playground/";
|
|
||||||
const PLAYGROUND_UA =
|
|
||||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36";
|
|
||||||
const BROWSER_ARGS = [
|
|
||||||
"--disable-blink-features=AutomationControlled",
|
|
||||||
"--no-first-run",
|
|
||||||
"--no-default-browser-check",
|
|
||||||
];
|
|
||||||
const MODEL_PREFIX = "@cf/";
|
|
||||||
const DEFAULT_MODEL = "zai-org/glm-4.7-flash";
|
|
||||||
const DEFAULT_TEMPERATURE = 0.7;
|
|
||||||
const NAV_TIMEOUT_MS = 45_000;
|
|
||||||
const CHAT_TIMEOUT_MS = 120_000;
|
|
||||||
const BLOCKED_MESSAGE =
|
|
||||||
"Cloudflare Playground blocked the headless browser (fingerprint check). Set CLOUDFLARE_PLAYGROUND_CHROME_PATH to a full desktop Chrome binary and retry.";
|
|
||||||
|
|
||||||
// ── Frame parsing & translation (pure — unit-tested against live captures) ──
|
|
||||||
|
|
||||||
export interface CfChatFrame {
|
|
||||||
id?: string;
|
|
||||||
type?: string;
|
|
||||||
error?: boolean;
|
|
||||||
done?: boolean;
|
|
||||||
body?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parse a raw WS frame. Returns null for non-JSON / unrelated frames. */
|
|
||||||
export function parseCfFrame(raw: string): CfChatFrame | null {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(raw) as CfChatFrame;
|
|
||||||
if (msg && typeof msg === "object" && typeof msg.type === "string") return msg;
|
|
||||||
} catch {
|
|
||||||
/* non-JSON — ignore */
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CfStreamEvent {
|
|
||||||
type: "role" | "content" | "reasoning" | "finish";
|
|
||||||
value?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Translates `cf_agent_use_chat_response` frames for one chat id into
|
|
||||||
* OpenAI-format stream events. Frames for other ids (RPC responses such as
|
|
||||||
* `setConfig` also carry `done:true`!) and non-chat frame types
|
|
||||||
* (`cf_agent_identity`, `cf_agent_state`, ...) are ignored.
|
|
||||||
*/
|
|
||||||
export class CfStreamParser {
|
|
||||||
readonly chatId: string;
|
|
||||||
done = false;
|
|
||||||
text = "";
|
|
||||||
reasoningText = "";
|
|
||||||
finishReason: string | null = null;
|
|
||||||
error: { status: number; message: string } | null = null;
|
|
||||||
private seenStart = false;
|
|
||||||
|
|
||||||
constructor(chatId: string) {
|
|
||||||
this.chatId = chatId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the SSE-relevant event, or null when the frame is ignorable. */
|
|
||||||
push(raw: string): CfStreamEvent | null {
|
|
||||||
const msg = parseCfFrame(raw);
|
|
||||||
if (!msg || msg.type !== "cf_agent_use_chat_response" || msg.id !== this.chatId) return null;
|
|
||||||
|
|
||||||
if (msg.error) {
|
|
||||||
this.error = classifyError(msg.body);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (msg.done) {
|
|
||||||
this.done = true;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let body: Record<string, unknown>;
|
|
||||||
try {
|
|
||||||
body =
|
|
||||||
typeof msg.body === "string"
|
|
||||||
? (JSON.parse(msg.body) as Record<string, unknown>)
|
|
||||||
: (msg.body as Record<string, unknown>);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!body || typeof body.type !== "string") return null;
|
|
||||||
|
|
||||||
switch (body.type) {
|
|
||||||
case "start":
|
|
||||||
if (this.seenStart) return null;
|
|
||||||
this.seenStart = true;
|
|
||||||
return { type: "role" };
|
|
||||||
case "reasoning-delta": {
|
|
||||||
const delta = typeof body.delta === "string" ? body.delta : "";
|
|
||||||
if (!delta) return null;
|
|
||||||
this.reasoningText += delta;
|
|
||||||
return { type: "reasoning", value: delta };
|
|
||||||
}
|
|
||||||
case "text-delta": {
|
|
||||||
const delta = typeof body.delta === "string" ? body.delta : "";
|
|
||||||
if (!delta) return null;
|
|
||||||
this.text += delta;
|
|
||||||
return { type: "content", value: delta };
|
|
||||||
}
|
|
||||||
case "finish": {
|
|
||||||
const meta = (body.messageMetadata ?? {}) as Record<string, unknown>;
|
|
||||||
const reason = typeof meta.finishReason === "string" ? meta.finishReason : "stop";
|
|
||||||
this.finishReason = reason;
|
|
||||||
return { type: "finish", value: reason };
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
// reasoning-start/end, start-step, finish-step, text-start/end, heartbeat — ignored.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Map an in-band upstream error frame to an HTTP-ish status + clean message. */
|
|
||||||
function classifyError(body: unknown): { status: number; message: string } {
|
|
||||||
let detail = "";
|
|
||||||
if (typeof body === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(body) as Record<string, unknown>;
|
|
||||||
detail = String(parsed.details || parsed.message || "");
|
|
||||||
} catch {
|
|
||||||
detail = body;
|
|
||||||
}
|
|
||||||
} else if (body && typeof body === "object") {
|
|
||||||
const parsed = body as Record<string, unknown>;
|
|
||||||
detail = String(parsed.details || parsed.message || "");
|
|
||||||
}
|
|
||||||
const status = /rate|limit|quota|throttl/i.test(detail) ? 429 : 502;
|
|
||||||
return { status, message: detail || "Cloudflare Playground upstream error" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Message conversion ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface CfChatMessage {
|
|
||||||
role: "user" | "assistant";
|
|
||||||
parts: Array<{ type: "text"; text: string }>;
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert OpenAI-format messages to the playground's chat body shape.
|
|
||||||
* `system` messages are dropped (the playground's persona is server-side) and
|
|
||||||
* tool/image parts are flattened to text — v1 is text-only chat.
|
|
||||||
*/
|
|
||||||
export function toCfMessages(
|
|
||||||
messages: Array<{ role?: string; content?: unknown }>
|
|
||||||
): CfChatMessage[] {
|
|
||||||
const out: CfChatMessage[] = [];
|
|
||||||
for (const message of messages ?? []) {
|
|
||||||
if (message.role !== "user" && message.role !== "assistant") continue;
|
|
||||||
let text = "";
|
|
||||||
if (typeof message.content === "string") {
|
|
||||||
text = message.content;
|
|
||||||
} else if (Array.isArray(message.content)) {
|
|
||||||
text = message.content
|
|
||||||
.map((part) =>
|
|
||||||
typeof part === "string" ? part : ((part as { text?: string })?.text ?? "")
|
|
||||||
)
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
if (!text) continue;
|
|
||||||
out.push({ role: message.role, parts: [{ type: "text", text }], id: `m${out.length + 1}` });
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Transport ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface CfTransportConfig {
|
|
||||||
model: string;
|
|
||||||
messages: CfChatMessage[];
|
|
||||||
temperature: number;
|
|
||||||
signal?: AbortSignal | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CfTransport {
|
|
||||||
start(
|
|
||||||
config: CfTransportConfig
|
|
||||||
): Promise<{ ok: true } | { ok: false; status: number; message: string }>;
|
|
||||||
frames(): AsyncGenerator<string>;
|
|
||||||
close(): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open the anonymous playground session inside the browser page context. */
|
|
||||||
function openPlaygroundSession(args: {
|
|
||||||
chatId: string;
|
|
||||||
model: string;
|
|
||||||
messages: CfChatMessage[];
|
|
||||||
temperature: number;
|
|
||||||
wsBase: string;
|
|
||||||
}): void {
|
|
||||||
const { chatId, model, messages, temperature, wsBase } = args;
|
|
||||||
const pk = crypto.randomUUID();
|
|
||||||
const room = "playground-" + crypto.randomUUID().replace(/-/g, "").slice(0, 25);
|
|
||||||
const socket = new WebSocket(wsBase + room + "?_pk=" + pk);
|
|
||||||
const push = (raw: string) => {
|
|
||||||
try {
|
|
||||||
(window as unknown as { __cfpPush: (raw: string) => void }).__cfpPush(raw);
|
|
||||||
} catch {
|
|
||||||
/* page torn down */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
socket.onopen = () => {
|
|
||||||
socket.send(JSON.stringify({ type: "cf_agent_stream_resume_request" }));
|
|
||||||
socket.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "rpc",
|
|
||||||
id: "cfp-config",
|
|
||||||
method: "setConfig",
|
|
||||||
args: [{ model, temperature, stream: true }],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
socket.send(
|
|
||||||
JSON.stringify({
|
|
||||||
id: chatId,
|
|
||||||
init: { method: "POST", body: JSON.stringify({ messages, trigger: "submit-message" }) },
|
|
||||||
type: "cf_agent_use_chat_request",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
socket.onmessage = (event: MessageEvent) => push(String(event.data));
|
|
||||||
socket.onerror = () =>
|
|
||||||
push(
|
|
||||||
JSON.stringify({
|
|
||||||
id: chatId,
|
|
||||||
type: "cf_agent_use_chat_response",
|
|
||||||
error: true,
|
|
||||||
body: JSON.stringify({
|
|
||||||
message: "Playground WebSocket error",
|
|
||||||
details: "ws transport failed",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PlaywrightCfTransport implements CfTransport {
|
|
||||||
private browser: Browser | null = null;
|
|
||||||
private page: Page | null = null;
|
|
||||||
private pending: string[] = [];
|
|
||||||
private waiters: Array<(frame: string | null) => void> = [];
|
|
||||||
private closed = false;
|
|
||||||
private abortSignal: AbortSignal | null = null;
|
|
||||||
private abortListener: (() => void) | null = null;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private chatId: string,
|
|
||||||
private chromeExecutablePath?: string
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async start(
|
|
||||||
config: CfTransportConfig
|
|
||||||
): Promise<{ ok: true } | { ok: false; status: number; message: string }> {
|
|
||||||
try {
|
|
||||||
const playwright = await importPlaywright();
|
|
||||||
const executablePath =
|
|
||||||
this.chromeExecutablePath ?? process.env.CLOUDFLARE_PLAYGROUND_CHROME_PATH;
|
|
||||||
this.browser = await playwright.chromium.launch({
|
|
||||||
...(executablePath ? { executablePath } : {}),
|
|
||||||
headless: true,
|
|
||||||
args: BROWSER_ARGS,
|
|
||||||
});
|
|
||||||
const context = await this.browser.newContext({ userAgent: PLAYGROUND_UA });
|
|
||||||
const page = await context.newPage();
|
|
||||||
this.page = page;
|
|
||||||
await page.goto(PLAYGROUND_URL, { waitUntil: "domcontentloaded", timeout: NAV_TIMEOUT_MS });
|
|
||||||
const title = await page.title().catch(() => "");
|
|
||||||
if (title.includes("Attention Required")) {
|
|
||||||
// #10494: this branch used to return without closing the browser it
|
|
||||||
// just launched, leaking a Chromium process for every blocked
|
|
||||||
// request. Close it on every non-success start path, same as the
|
|
||||||
// catch block below.
|
|
||||||
await this.close().catch(() => {});
|
|
||||||
return { ok: false, status: 502, message: BLOCKED_MESSAGE };
|
|
||||||
}
|
|
||||||
await page.exposeFunction("__cfpPush", (raw: string) => {
|
|
||||||
this.push(raw);
|
|
||||||
});
|
|
||||||
// Bundlers (esbuild/webpack keepNames) inject a `__name` helper call into
|
|
||||||
// serialized function bodies; define it in the page context so
|
|
||||||
// page.evaluate(openPlaygroundSession) doesn't throw ReferenceError.
|
|
||||||
await page.evaluate(() => {
|
|
||||||
(window as unknown as { __name?: unknown }).__name = (fn: unknown) => fn;
|
|
||||||
});
|
|
||||||
await page.evaluate(openPlaygroundSession, {
|
|
||||||
...config,
|
|
||||||
chatId: this.chatId,
|
|
||||||
wsBase: PLAYGROUND_WS_BASE,
|
|
||||||
});
|
|
||||||
if (config.signal) {
|
|
||||||
this.abortSignal = config.signal;
|
|
||||||
this.abortListener = () => {
|
|
||||||
void this.close();
|
|
||||||
};
|
|
||||||
config.signal.addEventListener("abort", this.abortListener, { once: true });
|
|
||||||
}
|
|
||||||
return { ok: true };
|
|
||||||
} catch (error) {
|
|
||||||
await this.close().catch(() => {});
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
status: 502,
|
|
||||||
message: `Cloudflare Playground browser session failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
push(raw: string): void {
|
|
||||||
const waiter = this.waiters.shift();
|
|
||||||
if (waiter) waiter(raw);
|
|
||||||
else this.pending.push(raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
async *frames(): AsyncGenerator<string> {
|
|
||||||
while (this.pending.length > 0 || !this.closed) {
|
|
||||||
if (this.pending.length > 0) {
|
|
||||||
yield this.pending.shift()!;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const frame = await new Promise<string | null>((resolve) => this.waiters.push(resolve));
|
|
||||||
if (frame === null) return;
|
|
||||||
yield frame;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async close(): Promise<void> {
|
|
||||||
if (this.closed) return;
|
|
||||||
this.closed = true;
|
|
||||||
if (this.abortSignal && this.abortListener) {
|
|
||||||
this.abortSignal.removeEventListener("abort", this.abortListener);
|
|
||||||
}
|
|
||||||
this.abortSignal = null;
|
|
||||||
this.abortListener = null;
|
|
||||||
for (const waiter of this.waiters.splice(0)) waiter(null);
|
|
||||||
const browser = this.browser;
|
|
||||||
this.browser = null;
|
|
||||||
if (browser) await browser.close().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function importPlaywright(): Promise<typeof import("playwright")> {
|
|
||||||
try {
|
|
||||||
return await import("playwright");
|
|
||||||
} catch {
|
|
||||||
throw new Error(
|
|
||||||
"Playwright is not available. Install it (npm i playwright && npx playwright install chromium) or set CLOUDFLARE_PLAYGROUND_CHROME_PATH to a Chrome binary."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Executor ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function sseChunk(
|
|
||||||
cid: string,
|
|
||||||
created: number,
|
|
||||||
model: string,
|
|
||||||
payload: { delta?: Record<string, unknown>; finish_reason?: string | null; error?: unknown }
|
|
||||||
): string {
|
|
||||||
const base = { id: cid, object: "chat.completion.chunk", created, model };
|
|
||||||
if (payload.error) {
|
|
||||||
return `data: ${JSON.stringify({ ...base, error: payload.error })}\n\n`;
|
|
||||||
}
|
|
||||||
return `data: ${JSON.stringify({
|
|
||||||
...base,
|
|
||||||
choices: [
|
|
||||||
{ index: 0, delta: payload.delta ?? {}, finish_reason: payload.finish_reason ?? null },
|
|
||||||
],
|
|
||||||
})}\n\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CloudflarePlaygroundExecutor extends BaseExecutor {
|
|
||||||
constructor(
|
|
||||||
private transportFactory: (chatId: string) => CfTransport = (chatId) =>
|
|
||||||
new PlaywrightCfTransport(chatId),
|
|
||||||
// Injectable so tests can force the timeout branch without waiting
|
|
||||||
// CHAT_TIMEOUT_MS (120s) for a real timer to fire.
|
|
||||||
private chatTimeoutMs: number = CHAT_TIMEOUT_MS
|
|
||||||
) {
|
|
||||||
super("cloudflare-playground", { id: "cloudflare-playground", baseUrl: PLAYGROUND_URL });
|
|
||||||
}
|
|
||||||
|
|
||||||
async execute(input: ExecuteInput) {
|
|
||||||
const { body, signal, stream: wantStream } = input;
|
|
||||||
const bodyObj = (body || {}) as Record<string, unknown>;
|
|
||||||
const rawModel = (bodyObj.model as string) || DEFAULT_MODEL;
|
|
||||||
const model = rawModel.startsWith(MODEL_PREFIX) ? rawModel : MODEL_PREFIX + rawModel;
|
|
||||||
const temperature =
|
|
||||||
typeof bodyObj.temperature === "number" ? bodyObj.temperature : DEFAULT_TEMPERATURE;
|
|
||||||
const chatId = `chatcmpl-cfp-${randomUUID().slice(0, 12)}`;
|
|
||||||
const created = Math.floor(Date.now() / 1000);
|
|
||||||
|
|
||||||
const transport = this.transportFactory(chatId);
|
|
||||||
const started = await transport.start({
|
|
||||||
model,
|
|
||||||
messages: toCfMessages(
|
|
||||||
(bodyObj.messages as Array<{ role?: string; content?: unknown }>) || []
|
|
||||||
),
|
|
||||||
temperature,
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
if (started.ok !== true) {
|
|
||||||
return makeErrorResult(started.status, started.message, body, PLAYGROUND_URL);
|
|
||||||
}
|
|
||||||
|
|
||||||
const timedOut = { current: false };
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
timedOut.current = true;
|
|
||||||
void transport.close();
|
|
||||||
}, this.chatTimeoutMs);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!wantStream) {
|
|
||||||
const parser = new CfStreamParser(chatId);
|
|
||||||
for await (const raw of transport.frames()) {
|
|
||||||
parser.push(raw);
|
|
||||||
if (parser.error || parser.done) break;
|
|
||||||
}
|
|
||||||
if (parser.error) {
|
|
||||||
return makeErrorResult(parser.error.status, parser.error.message, body, PLAYGROUND_URL);
|
|
||||||
}
|
|
||||||
if (timedOut.current && !parser.text) {
|
|
||||||
return makeErrorResult(504, "Cloudflare Playground timed out", body, PLAYGROUND_URL);
|
|
||||||
}
|
|
||||||
const text = parser.text;
|
|
||||||
const messagePayload: Record<string, unknown> = { role: "assistant", content: text };
|
|
||||||
if (parser.reasoningText) {
|
|
||||||
messagePayload.reasoning_content = parser.reasoningText;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
response: new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
id: chatId,
|
|
||||||
object: "chat.completion",
|
|
||||||
created,
|
|
||||||
model: rawModel,
|
|
||||||
choices: [
|
|
||||||
{
|
|
||||||
index: 0,
|
|
||||||
message: messagePayload,
|
|
||||||
finish_reason: parser.finishReason ?? "stop",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
usage: {
|
|
||||||
prompt_tokens: 0,
|
|
||||||
completion_tokens: Math.ceil((text.length + parser.reasoningText.length) / 4),
|
|
||||||
total_tokens: 0,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{ headers: { "Content-Type": "application/json" } }
|
|
||||||
),
|
|
||||||
url: PLAYGROUND_URL,
|
|
||||||
headers: {},
|
|
||||||
transformedBody: body,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Streaming: translate cf_agent frames → OpenAI SSE chunks.
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const responseStream = new ReadableStream<Uint8Array>({
|
|
||||||
async start(controller) {
|
|
||||||
const parser = new CfStreamParser(chatId);
|
|
||||||
let roleSent = false;
|
|
||||||
const enqueue = (payload: {
|
|
||||||
delta?: Record<string, unknown>;
|
|
||||||
finish_reason?: string | null;
|
|
||||||
error?: unknown;
|
|
||||||
}) => {
|
|
||||||
controller.enqueue(encoder.encode(sseChunk(chatId, created, rawModel, payload)));
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
for await (const raw of transport.frames()) {
|
|
||||||
if (signal?.aborted) break;
|
|
||||||
const event = parser.push(raw);
|
|
||||||
if (event) {
|
|
||||||
if (event.type === "role" && !roleSent) {
|
|
||||||
enqueue({ delta: { role: "assistant" }, finish_reason: null });
|
|
||||||
roleSent = true;
|
|
||||||
} else if (event.type === "reasoning") {
|
|
||||||
enqueue({ delta: { reasoning_content: event.value }, finish_reason: null });
|
|
||||||
} else if (event.type === "content") {
|
|
||||||
enqueue({ delta: { content: event.value }, finish_reason: null });
|
|
||||||
} else if (event.type === "finish") {
|
|
||||||
enqueue({ delta: {}, finish_reason: event.value ?? "stop" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (parser.error) {
|
|
||||||
enqueue({
|
|
||||||
error: {
|
|
||||||
message: parser.error.message,
|
|
||||||
type: "upstream_error",
|
|
||||||
code: `HTTP_${parser.error.status}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (parser.done || timedOut.current) break;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!signal?.aborted) controller.error(error);
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
await transport.close().catch(() => {});
|
|
||||||
// #10494: a timeout used to fall straight through to a bare
|
|
||||||
// [DONE], so a client receiving an empty or partial stream saw
|
|
||||||
// an ordinary successful completion. Emit an explicit error
|
|
||||||
// chunk first (same shape as the parser.error branch above) so
|
|
||||||
// the client can distinguish a timed-out/partial answer from a
|
|
||||||
// real completion.
|
|
||||||
if (timedOut.current) {
|
|
||||||
try {
|
|
||||||
enqueue({
|
|
||||||
error: {
|
|
||||||
message: "Cloudflare Playground timed out",
|
|
||||||
type: "timeout_error",
|
|
||||||
code: "HTTP_504",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
/* stream already torn down */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
||||||
controller.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
response: new Response(responseStream, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "text/event-stream",
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
Connection: "keep-alive",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
url: PLAYGROUND_URL,
|
|
||||||
headers: {},
|
|
||||||
transformedBody: body,
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
if (!wantStream) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
await transport.close().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -260,70 +260,6 @@ export function parseStreamResponse(raw: string): string {
|
|||||||
return lastText;
|
return lastText;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract generated-image URLs from a Gemini StreamGenerate response (#10466).
|
|
||||||
*
|
|
||||||
* When the web UI generates images (Nano Banana), the model's answer frames
|
|
||||||
* carry the assets in the candidate's extension block, NOT in the text:
|
|
||||||
*
|
|
||||||
* inner[4][0][12][7][0] → array of generated-image entries
|
|
||||||
* entry[0][3][3] → the image URL — either a plain string or a
|
|
||||||
* list of strings (take the first http(s) one)
|
|
||||||
*
|
|
||||||
* This path is corroborated by the two maintained reverse-engineered clients
|
|
||||||
* (gpt4free's Gemini provider and HanaokaYuzu/Gemini-API's _parse_candidate).
|
|
||||||
* Deliberately NOT collected: `inner[4][0][12][1]` — those are web-search
|
|
||||||
* result thumbnails, not generated content; mixing them in would serve
|
|
||||||
* scraped images as "generated" (#10466 acceptance criteria).
|
|
||||||
*
|
|
||||||
* Frames are cumulative snapshots, so later frames repeat earlier images;
|
|
||||||
* we dedupe while preserving first-seen order. A `=s2048` size suffix is
|
|
||||||
* appended (gpt4free's proven heuristic) so callers get full-resolution
|
|
||||||
* assets instead of UI thumbnails.
|
|
||||||
*/
|
|
||||||
export function parseStreamResponseImages(raw: string): string[] {
|
|
||||||
const urls: string[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const lines = raw.split("\n");
|
|
||||||
|
|
||||||
for (const rawLine of lines) {
|
|
||||||
const line = rawLine.trim();
|
|
||||||
if (!line || line === ")]}'" || /^\d+$/.test(line)) continue;
|
|
||||||
if (!line.includes("wrb.fr")) continue;
|
|
||||||
try {
|
|
||||||
const arr = JSON.parse(line);
|
|
||||||
if (!Array.isArray(arr) || !Array.isArray(arr[0]) || arr[0][0] !== "wrb.fr") continue;
|
|
||||||
const payload = arr[0]?.[2];
|
|
||||||
if (typeof payload !== "string") continue;
|
|
||||||
const inner = JSON.parse(payload);
|
|
||||||
const imageEntries = inner?.[4]?.[0]?.[12]?.[7]?.[0];
|
|
||||||
if (!Array.isArray(imageEntries)) continue;
|
|
||||||
for (const entry of imageEntries) {
|
|
||||||
const urlField = entry?.[0]?.[3]?.[3];
|
|
||||||
let url = "";
|
|
||||||
if (typeof urlField === "string") {
|
|
||||||
url = urlField;
|
|
||||||
} else if (Array.isArray(urlField)) {
|
|
||||||
const firstHttp = urlField.find(
|
|
||||||
(u: unknown) => typeof u === "string" && /^https?:\/\//.test(u)
|
|
||||||
);
|
|
||||||
url = typeof firstHttp === "string" ? firstHttp : "";
|
|
||||||
}
|
|
||||||
if (!url || !/^https?:\/\//.test(url)) continue;
|
|
||||||
// Upgrade to full resolution unless a size directive is already present
|
|
||||||
// (googleusercontent size syntax: trailing `=s2048`, `=w1024-h512`, ...).
|
|
||||||
if (!/=[swh]\d+/.test(url)) url += "=s2048";
|
|
||||||
if (seen.has(url)) continue;
|
|
||||||
seen.add(url);
|
|
||||||
urls.push(url);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Skip unparseable lines
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return urls;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readCredentialString(value: unknown): string {
|
function readCredentialString(value: unknown): string {
|
||||||
if (typeof value !== "string") return "";
|
if (typeof value !== "string") return "";
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -429,7 +365,9 @@ export class GeminiWebExecutor extends BaseExecutor {
|
|||||||
_signal?: AbortSignal
|
_signal?: AbortSignal
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const cookie = resolveGeminiWebCookie(credentials as unknown as ExecuteInput["credentials"]);
|
const cookie = resolveGeminiWebCookie(
|
||||||
|
credentials as unknown as ExecuteInput["credentials"]
|
||||||
|
);
|
||||||
if (!cookie) return false;
|
if (!cookie) return false;
|
||||||
const pairs = parseCookies(cookie);
|
const pairs = parseCookies(cookie);
|
||||||
return pairs.some((p) => p.value.length > 0);
|
return pairs.some((p) => p.value.length > 0);
|
||||||
@@ -568,52 +506,20 @@ export class GeminiWebExecutor extends BaseExecutor {
|
|||||||
|
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|
||||||
// #10466: image mode — the /v1/images/generations handler sets
|
|
||||||
// x_gemini_web_image_mode. Generated images arrive in the candidate's
|
|
||||||
// extension block ([12][7][0]) of the StreamGenerate frames, sometimes
|
|
||||||
// only in a LATER frame of the stream (or a follow-up StreamGenerate
|
|
||||||
// call), so image mode captures every StreamGenerate response, merges
|
|
||||||
// image URLs across frames, and resolves as soon as one is found.
|
|
||||||
// Chat mode keeps the original first-response-only behavior.
|
|
||||||
const imageMode = (body as Record<string, unknown>)?.x_gemini_web_image_mode === true;
|
|
||||||
|
|
||||||
// Capture first StreamGenerate response
|
// Capture first StreamGenerate response
|
||||||
let responseText = "";
|
let responseText = "";
|
||||||
const responseImages: string[] = [];
|
|
||||||
let captured = false;
|
let captured = false;
|
||||||
const responsePromise = new Promise<void>((resolve) => {
|
const responsePromise = new Promise<void>((resolve) => {
|
||||||
page.on("response", async (resp: any) => {
|
page.on("response", async (resp: any) => {
|
||||||
if (!resp.url().includes("StreamGenerate")) return;
|
if (captured || !resp.url().includes("StreamGenerate")) return;
|
||||||
if (!imageMode && captured) return;
|
captured = true;
|
||||||
if (imageMode) {
|
try {
|
||||||
// Image mode: merge text + image URLs across every frame and
|
const raw = await resp.text();
|
||||||
// resolve as soon as an image appears (images can land in a
|
responseText = parseStreamResponse(raw);
|
||||||
// later frame than the text).
|
} catch {
|
||||||
try {
|
/* ignore */
|
||||||
const raw = await resp.text();
|
|
||||||
const text = parseStreamResponse(raw);
|
|
||||||
if (text) responseText = text;
|
|
||||||
for (const url of parseStreamResponseImages(raw)) {
|
|
||||||
if (!responseImages.includes(url)) responseImages.push(url);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* ignore unreadable frames */
|
|
||||||
}
|
|
||||||
if (responseImages.length > 0) resolve();
|
|
||||||
} else {
|
|
||||||
// Chat mode: byte-for-byte the original first-response capture —
|
|
||||||
// resolve even if reading the body throws, so the flow falls
|
|
||||||
// through to the "No response from Gemini" 502 instead of
|
|
||||||
// burning the full wait window.
|
|
||||||
captured = true;
|
|
||||||
try {
|
|
||||||
const raw = await resp.text();
|
|
||||||
responseText = parseStreamResponse(raw);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
resolve();
|
|
||||||
}
|
}
|
||||||
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -632,36 +538,12 @@ export class GeminiWebExecutor extends BaseExecutor {
|
|||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
await page.keyboard.press("Enter");
|
await page.keyboard.press("Enter");
|
||||||
|
|
||||||
// Wait for response or timeout. Image generation (Nano Banana) is
|
// Wait for response or timeout
|
||||||
// noticeably slower than text — the UI renders the asset only after
|
await Promise.race([responsePromise, page.waitForTimeout(30000)]);
|
||||||
// the full generation completes — so image mode gets a wider window.
|
|
||||||
await Promise.race([responsePromise, page.waitForTimeout(imageMode ? 90000 : 30000)]);
|
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
throw signal.reason instanceof Error ? signal.reason : new Error("Request aborted");
|
throw signal.reason instanceof Error ? signal.reason : new Error("Request aborted");
|
||||||
}
|
}
|
||||||
|
|
||||||
// #10466 image mode: return the captured image URLs to the image
|
|
||||||
// handler via a custom field (same precedent as chatgpt-web's
|
|
||||||
// x_image_resolution_failed). An image-only answer can carry little or
|
|
||||||
// no text, so the empty-text 502 below must not fire when images
|
|
||||||
// were captured.
|
|
||||||
if (imageMode) {
|
|
||||||
await this.persistRotatedCookies(context, cookie, credentials, onCredentialsRefreshed, log);
|
|
||||||
const modelId = model || "gemini-2.5-pro";
|
|
||||||
return {
|
|
||||||
response: new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
...formatChatCompletion(responseText, modelId),
|
|
||||||
x_gemini_web_image_urls: responseImages,
|
|
||||||
}),
|
|
||||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
||||||
),
|
|
||||||
url: GEMINI_URL,
|
|
||||||
headers: {},
|
|
||||||
transformedBody: body,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!responseText) {
|
if (!responseText) {
|
||||||
return {
|
return {
|
||||||
response: new Response(JSON.stringify({ error: "No response from Gemini" }), {
|
response: new Response(JSON.stringify({ error: "No response from Gemini" }), {
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import { SEARCH_PROVIDERS } from "../config/searchRegistry.ts";
|
import { SEARCH_PROVIDERS } from "../config/searchRegistry.ts";
|
||||||
import {
|
|
||||||
registerExecutor,
|
|
||||||
getRegisteredExecutor,
|
|
||||||
hasRegisteredExecutor,
|
|
||||||
} from "./registry.ts";
|
|
||||||
import { AntigravityExecutor } from "./antigravity.ts";
|
import { AntigravityExecutor } from "./antigravity.ts";
|
||||||
import { GithubExecutor } from "./github.ts";
|
import { GithubExecutor } from "./github.ts";
|
||||||
import { GheCopilotExecutor } from "./ghe-copilot.ts";
|
import { GheCopilotExecutor } from "./ghe-copilot.ts";
|
||||||
@@ -76,19 +71,12 @@ import { LMArenaExecutor } from "./lmarena.ts";
|
|||||||
import { GrokCliExecutor } from "./grok-cli.ts";
|
import { GrokCliExecutor } from "./grok-cli.ts";
|
||||||
import { CodeBuddyCnExecutor } from "./codebuddy-cn.ts";
|
import { CodeBuddyCnExecutor } from "./codebuddy-cn.ts";
|
||||||
import { ZenmuxFreeExecutor } from "./zenmux-free.ts";
|
import { ZenmuxFreeExecutor } from "./zenmux-free.ts";
|
||||||
import { CloudflarePlaygroundExecutor } from "./cloudflare-playground.ts";
|
|
||||||
import { TinyCmsExecutor } from "./tinycms.ts";
|
import { TinyCmsExecutor } from "./tinycms.ts";
|
||||||
import { HyperAgentExecutor } from "./hyperagent.ts";
|
import { HyperAgentExecutor } from "./hyperagent.ts";
|
||||||
import { XaiExecutor } from "./xai.ts";
|
import { XaiExecutor } from "./xai.ts";
|
||||||
import { PromptQlExecutor } from "./promptql.ts";
|
import { PromptQlExecutor } from "./promptql.ts";
|
||||||
import { ConolWebExecutor } from "./conol-web.ts";
|
import { ConolWebExecutor } from "./conol-web.ts";
|
||||||
|
|
||||||
// R0.3 — declarative built-in table. The object literal stays as the single
|
|
||||||
// place built-ins are declared (compile-time duplicate-key safety; the
|
|
||||||
// check:known-symbols gate parses this literal from source), but lookup goes
|
|
||||||
// through the ExecutorRegistry (./registry.ts): every entry is registered at
|
|
||||||
// module load below, and getExecutor()/hasSpecializedExecutor() consult the
|
|
||||||
// registry — the literal is never read at request time.
|
|
||||||
const executors = {
|
const executors = {
|
||||||
antigravity: new AntigravityExecutor(),
|
antigravity: new AntigravityExecutor(),
|
||||||
agy: new AntigravityExecutor(),
|
agy: new AntigravityExecutor(),
|
||||||
@@ -216,8 +204,6 @@ const executors = {
|
|||||||
"codebuddy-cn": new CodeBuddyCnExecutor(),
|
"codebuddy-cn": new CodeBuddyCnExecutor(),
|
||||||
cbcn: new CodeBuddyCnExecutor(), // Alias for codebuddy-cn
|
cbcn: new CodeBuddyCnExecutor(), // Alias for codebuddy-cn
|
||||||
"zenmux-free": new ZenmuxFreeExecutor(),
|
"zenmux-free": new ZenmuxFreeExecutor(),
|
||||||
"cloudflare-playground": new CloudflarePlaygroundExecutor(),
|
|
||||||
cfp: new CloudflarePlaygroundExecutor(), // Alias for cloudflare-playground
|
|
||||||
"tinycms-web": new TinyCmsExecutor(),
|
"tinycms-web": new TinyCmsExecutor(),
|
||||||
tcw: new TinyCmsExecutor(), // Alias
|
tcw: new TinyCmsExecutor(), // Alias
|
||||||
hyperagent: new HyperAgentExecutor(),
|
hyperagent: new HyperAgentExecutor(),
|
||||||
@@ -232,13 +218,6 @@ const executors = {
|
|||||||
cnl: new ConolWebExecutor(), // Alias
|
cnl: new ConolWebExecutor(), // Alias
|
||||||
};
|
};
|
||||||
|
|
||||||
// Bootstrap: register every built-in in the ExecutorRegistry. registerExecutor
|
|
||||||
// throws on duplicates, so an alias collision fails at module load, exactly as
|
|
||||||
// loudly as a duplicate object key would have failed at lint time.
|
|
||||||
for (const [alias, executor] of Object.entries(executors)) {
|
|
||||||
registerExecutor(alias, executor);
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultCache = new Map();
|
const defaultCache = new Map();
|
||||||
|
|
||||||
// #6699 — providers that exist ONLY as Cloud Agent task-API entries
|
// #6699 — providers that exist ONLY as Cloud Agent task-API entries
|
||||||
@@ -264,8 +243,7 @@ const CHAT_UNSUPPORTED_CLOUD_AGENT_PROVIDERS = new Set(["jules"]);
|
|||||||
const CHAT_UNSUPPORTED_SEARCH_PROVIDERS = new Set(Object.keys(SEARCH_PROVIDERS));
|
const CHAT_UNSUPPORTED_SEARCH_PROVIDERS = new Set(Object.keys(SEARCH_PROVIDERS));
|
||||||
|
|
||||||
export function getExecutor(provider) {
|
export function getExecutor(provider) {
|
||||||
const registered = getRegisteredExecutor(provider);
|
if (executors[provider]) return executors[provider];
|
||||||
if (registered) return registered;
|
|
||||||
if (CHAT_UNSUPPORTED_CLOUD_AGENT_PROVIDERS.has(provider)) {
|
if (CHAT_UNSUPPORTED_CLOUD_AGENT_PROVIDERS.has(provider)) {
|
||||||
const err = new Error(
|
const err = new Error(
|
||||||
`Provider "${provider}" is a cloud-agent provider and does not support direct chat completions; use the Cloud Agents task API instead.`
|
`Provider "${provider}" is a cloud-agent provider and does not support direct chat completions; use the Cloud Agents task API instead.`
|
||||||
@@ -285,11 +263,9 @@ export function getExecutor(provider) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function hasSpecializedExecutor(provider) {
|
export function hasSpecializedExecutor(provider) {
|
||||||
return hasRegisteredExecutor(provider);
|
return !!executors[provider];
|
||||||
}
|
}
|
||||||
|
|
||||||
export { registerExecutor, listExecutorAliases } from "./registry.ts";
|
|
||||||
|
|
||||||
export { BaseExecutor } from "./base.ts";
|
export { BaseExecutor } from "./base.ts";
|
||||||
export { AntigravityExecutor } from "./antigravity.ts";
|
export { AntigravityExecutor } from "./antigravity.ts";
|
||||||
export { GithubExecutor } from "./github.ts";
|
export { GithubExecutor } from "./github.ts";
|
||||||
@@ -347,7 +323,6 @@ export { LMArenaExecutor } from "./lmarena.ts";
|
|||||||
export { GrokCliExecutor } from "./grok-cli.ts";
|
export { GrokCliExecutor } from "./grok-cli.ts";
|
||||||
export { CodeBuddyCnExecutor } from "./codebuddy-cn.ts";
|
export { CodeBuddyCnExecutor } from "./codebuddy-cn.ts";
|
||||||
export { ZenmuxFreeExecutor } from "./zenmux-free.ts";
|
export { ZenmuxFreeExecutor } from "./zenmux-free.ts";
|
||||||
export { CloudflarePlaygroundExecutor } from "./cloudflare-playground.ts";
|
|
||||||
export { TinyCmsExecutor } from "./tinycms.ts";
|
export { TinyCmsExecutor } from "./tinycms.ts";
|
||||||
export { HyperAgentExecutor } from "./hyperagent.ts";
|
export { HyperAgentExecutor } from "./hyperagent.ts";
|
||||||
export { XaiExecutor } from "./xai.ts";
|
export { XaiExecutor } from "./xai.ts";
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import type { BaseExecutor } from "./base.ts";
|
|
||||||
|
|
||||||
// R0.3 — ExecutorRegistry: runtime registry for provider executors, mirroring
|
|
||||||
// open-sse/translator/registry.ts. Built-ins register at module load from
|
|
||||||
// executors/index.ts; getExecutor() resolves through this map instead of a
|
|
||||||
// hard-coded object literal. This is the seam the v4 plan (M1.6
|
|
||||||
// host.registerProvider) extends — today the surface is internal-only.
|
|
||||||
//
|
|
||||||
// The alias → executor mapping is characterized by
|
|
||||||
// tests/unit/executor-map-golden.test.ts (tests/snapshots/executors/): any
|
|
||||||
// change to keys, classes or instance sharing shows up as a golden diff.
|
|
||||||
|
|
||||||
const registry = new Map<string, BaseExecutor>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register an executor under an alias. Aliases are unique: registering the
|
|
||||||
* same alias twice throws, preserving the guarantee the old object literal
|
|
||||||
* gave at compile time (duplicate keys were impossible).
|
|
||||||
*/
|
|
||||||
export function registerExecutor(alias: string, executor: BaseExecutor): void {
|
|
||||||
if (registry.has(alias)) {
|
|
||||||
throw new Error(`executor alias already registered: "${alias}"`);
|
|
||||||
}
|
|
||||||
registry.set(alias, executor);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRegisteredExecutor(alias: string): BaseExecutor | undefined {
|
|
||||||
return registry.get(alias);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasRegisteredExecutor(alias: string): boolean {
|
|
||||||
return registry.has(alias);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** All registered aliases, in registration order. */
|
|
||||||
export function listExecutorAliases(): string[] {
|
|
||||||
return [...registry.keys()];
|
|
||||||
}
|
|
||||||
@@ -51,7 +51,6 @@ import {
|
|||||||
extractMarkdownImageUrls,
|
extractMarkdownImageUrls,
|
||||||
CHATGPT_WEB_IMAGE_ID_RE,
|
CHATGPT_WEB_IMAGE_ID_RE,
|
||||||
} from "./imageGeneration/providers/chatgptWeb.ts";
|
} from "./imageGeneration/providers/chatgptWeb.ts";
|
||||||
import { handleGeminiWebImageGeneration } from "./imageGeneration/providers/geminiWeb.ts";
|
|
||||||
import { handleNvidiaNimImageGeneration } from "./imageGeneration/providers/nvidiaNim.ts";
|
import { handleNvidiaNimImageGeneration } from "./imageGeneration/providers/nvidiaNim.ts";
|
||||||
import { handleSegmindImageGeneration } from "./imageGeneration/providers/segmind.ts";
|
import { handleSegmindImageGeneration } from "./imageGeneration/providers/segmind.ts";
|
||||||
import { handleDesignerWebImageGeneration } from "./imageGeneration/providers/designerWeb.ts";
|
import { handleDesignerWebImageGeneration } from "./imageGeneration/providers/designerWeb.ts";
|
||||||
@@ -494,19 +493,6 @@ export async function handleImageGeneration({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// #10466: Gemini Web session image generation (Nano Banana)
|
|
||||||
if (providerConfig.format === "gemini-web") {
|
|
||||||
return handleGeminiWebImageGeneration({
|
|
||||||
model,
|
|
||||||
provider,
|
|
||||||
body,
|
|
||||||
credentials,
|
|
||||||
log,
|
|
||||||
signal,
|
|
||||||
clientHeaders,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (providerConfig.format === "designer-web") {
|
if (providerConfig.format === "designer-web") {
|
||||||
return handleDesignerWebImageGeneration({
|
return handleDesignerWebImageGeneration({
|
||||||
model,
|
model,
|
||||||
@@ -2697,22 +2683,6 @@ export function saveImageErrorResult({
|
|||||||
error,
|
error,
|
||||||
requestBody = null,
|
requestBody = null,
|
||||||
path = "/v1/images/generations",
|
path = "/v1/images/generations",
|
||||||
// #10494: opt-in signal for executeImageWithCredentialFallback — set by a
|
|
||||||
// provider handler when the failure is account/session-specific (expired
|
|
||||||
// or blocked credentials) rather than a generic request/provider error, so
|
|
||||||
// the retry loop tries the next eligible account even when the upstream
|
|
||||||
// status isn't a plain 401. Defaults to unset (existing 401-only behavior
|
|
||||||
// for every other provider is unchanged).
|
|
||||||
retryable = undefined,
|
|
||||||
}: {
|
|
||||||
provider: string;
|
|
||||||
model: string;
|
|
||||||
status: number;
|
|
||||||
startTime: number;
|
|
||||||
error: unknown;
|
|
||||||
requestBody?: unknown;
|
|
||||||
path?: string;
|
|
||||||
retryable?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
saveCallLog({
|
saveCallLog({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -2729,7 +2699,6 @@ export function saveImageErrorResult({
|
|||||||
success: false,
|
success: false,
|
||||||
status,
|
status,
|
||||||
error,
|
error,
|
||||||
...(retryable !== undefined ? { retryable } : {}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,228 +0,0 @@
|
|||||||
// Gemini Web image generation handler (#10466).
|
|
||||||
//
|
|
||||||
// Exposes the gemini-web session provider through POST /v1/images/generations.
|
|
||||||
// Follows the chatgpt-web precedent (./chatgptWeb.ts): the web-session chat
|
|
||||||
// executor is driven with an image-generation prompt, and the generated
|
|
||||||
// assets are extracted from the response.
|
|
||||||
//
|
|
||||||
// Transport: GeminiWebExecutor in image mode (x_gemini_web_image_mode). The
|
|
||||||
// executor types the prompt into gemini.google.com, captures every
|
|
||||||
// StreamGenerate frame, and returns generated-image URLs in the custom
|
|
||||||
// `x_gemini_web_image_urls` field. URLs point at lh3.googleusercontent.com
|
|
||||||
// with a `=s2048` full-resolution size directive; they are public (no
|
|
||||||
// cookies needed to fetch them).
|
|
||||||
//
|
|
||||||
// Prompting: the web UI only GENERATES images when the prompt uses a
|
|
||||||
// generation verb ("generate"/"create"/"draw"); otherwise it answers with
|
|
||||||
// web-search thumbnails. The prompt builder therefore always leads with an
|
|
||||||
// explicit generation directive (corroborated by gemini-webapi's docs).
|
|
||||||
|
|
||||||
import { GeminiWebExecutor } from "../../../executors/gemini-web.ts";
|
|
||||||
import { fetchRemoteImage } from "@/shared/network/remoteImageFetch";
|
|
||||||
import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGeneration.ts";
|
|
||||||
|
|
||||||
/** Each image is one gemini.google.com turn (~30-60s). Cap like chatgpt-web. */
|
|
||||||
const GEMINI_WEB_IMAGE_N_MAX = 4;
|
|
||||||
|
|
||||||
export function buildGeminiWebImagePrompt(body: Record<string, unknown>): string {
|
|
||||||
const prompt = String(body.prompt || "").trim();
|
|
||||||
const details: string[] = [
|
|
||||||
`Generate an image for this prompt: ${prompt}`,
|
|
||||||
"Use the image generation model. Do not search the web for existing images.",
|
|
||||||
];
|
|
||||||
if (typeof body.size === "string" && body.size.trim()) {
|
|
||||||
details.push(`Requested aspect/size: ${body.size.trim()}.`);
|
|
||||||
}
|
|
||||||
if (typeof body.style === "string" && body.style.trim()) {
|
|
||||||
details.push(`Requested style: ${body.style.trim()}.`);
|
|
||||||
}
|
|
||||||
return details.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #10494: the underlying GeminiWebExecutor's browser-automation catch paths
|
|
||||||
* classify an expired/blocked Gemini Web session as HTTP 400 ("the session
|
|
||||||
* is so expired it lands on a different page" — see gemini-web.ts's
|
|
||||||
* Playwright selector/click-timeout branch, #9407) or HTTP 500 (its generic
|
|
||||||
* automation-failure catch-all, which covers a blocked/CAPTCHA/login page
|
|
||||||
* this handler has no further way to inspect). Both statuses previously
|
|
||||||
* passed straight through to executeImageWithCredentialFallback, which only
|
|
||||||
* advances to another account on a plain 401 — so an expired/blocked
|
|
||||||
* session never triggered account fallback, contrary to #10466's
|
|
||||||
* acceptance criteria ("Expired or blocked sessions ... can fall back
|
|
||||||
* normally inside an image Combo"). HTTP 503 (missing Playwright browser —
|
|
||||||
* a host/config problem, not a per-account issue) is intentionally excluded,
|
|
||||||
* as is the local 401 this handler already returns before any account is
|
|
||||||
* selected (missing session cookie — handled by the 401 path already).
|
|
||||||
*/
|
|
||||||
export function isExpiredOrBlockedGeminiWebSession(status: number): boolean {
|
|
||||||
return status === 400 || status === 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleGeminiWebImageGeneration({
|
|
||||||
model,
|
|
||||||
provider,
|
|
||||||
body,
|
|
||||||
credentials,
|
|
||||||
log,
|
|
||||||
signal,
|
|
||||||
clientHeaders,
|
|
||||||
// Injectable so unit tests can drive the handler without a live Gemini
|
|
||||||
// session; production uses the real executor.
|
|
||||||
executorFactory = () => new GeminiWebExecutor(),
|
|
||||||
// Injectable for tests; production fetches the public googleusercontent URL.
|
|
||||||
imageFetcher = fetchRemoteImage,
|
|
||||||
}: {
|
|
||||||
model: string;
|
|
||||||
provider: string;
|
|
||||||
body: Record<string, unknown>;
|
|
||||||
credentials: Record<string, unknown> | null | undefined;
|
|
||||||
log: {
|
|
||||||
info: (scope: string, message: string) => void;
|
|
||||||
warn: (scope: string, message: string) => void;
|
|
||||||
error: (scope: string, message: string) => void;
|
|
||||||
} | null;
|
|
||||||
signal?: AbortSignal | null;
|
|
||||||
clientHeaders?: Record<string, string> | null;
|
|
||||||
executorFactory?: () => {
|
|
||||||
execute: (input: Record<string, unknown>) => Promise<{ response: Response }>;
|
|
||||||
};
|
|
||||||
imageFetcher?: (url: string) => Promise<{ buffer: Buffer; contentType: string }>;
|
|
||||||
}) {
|
|
||||||
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 Gemini Web image generation",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!credentials?.apiKey) {
|
|
||||||
return saveImageErrorResult({
|
|
||||||
provider,
|
|
||||||
model,
|
|
||||||
status: 401,
|
|
||||||
startTime,
|
|
||||||
error: "Gemini Web credentials missing session cookie",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawCount = Number.isInteger(body.n) && (body.n as number) > 0 ? (body.n as number) : 1;
|
|
||||||
if (rawCount > GEMINI_WEB_IMAGE_N_MAX) {
|
|
||||||
return saveImageErrorResult({
|
|
||||||
provider,
|
|
||||||
model,
|
|
||||||
status: 400,
|
|
||||||
startTime,
|
|
||||||
error: `Gemini Web image generation supports n=1..${GEMINI_WEB_IMAGE_N_MAX} (got ${rawCount}); each n is a separate ~30-60s web turn.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const requestedCount = rawCount;
|
|
||||||
if (log && requestedCount > 1) {
|
|
||||||
log.warn(
|
|
||||||
"IMAGE",
|
|
||||||
`Gemini Web returns image(s) per chat turn; requested n=${requestedCount} will run sequentially`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const wantsBase64 = body.response_format === "b64_json";
|
|
||||||
const images: Array<{ url?: string; b64_json?: string }> = [];
|
|
||||||
const requestBody = {
|
|
||||||
model,
|
|
||||||
prompt: prompt.slice(0, 500),
|
|
||||||
size: body.size || undefined,
|
|
||||||
n: requestedCount,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < requestedCount; i++) {
|
|
||||||
const executor = executorFactory();
|
|
||||||
const result = await executor.execute({
|
|
||||||
model,
|
|
||||||
body: {
|
|
||||||
messages: [{ role: "user", content: buildGeminiWebImagePrompt(body) }],
|
|
||||||
x_gemini_web_image_mode: true,
|
|
||||||
},
|
|
||||||
stream: false,
|
|
||||||
credentials,
|
|
||||||
signal,
|
|
||||||
log,
|
|
||||||
clientHeaders,
|
|
||||||
});
|
|
||||||
|
|
||||||
const responseText = await result.response.text();
|
|
||||||
if (result.response.status >= 400) {
|
|
||||||
return saveImageErrorResult({
|
|
||||||
provider,
|
|
||||||
model,
|
|
||||||
status: result.response.status,
|
|
||||||
startTime,
|
|
||||||
error: responseText,
|
|
||||||
requestBody,
|
|
||||||
retryable: isExpiredOrBlockedGeminiWebSession(result.response.status),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = "";
|
|
||||||
let urls: string[] = [];
|
|
||||||
try {
|
|
||||||
const json = JSON.parse(responseText);
|
|
||||||
content = String(json?.choices?.[0]?.message?.content || "");
|
|
||||||
urls = Array.isArray(json?.x_gemini_web_image_urls)
|
|
||||||
? (json.x_gemini_web_image_urls as unknown[]).filter(
|
|
||||||
(u): u is string => typeof u === "string" && /^https?:\/\//.test(u)
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
} catch {
|
|
||||||
content = responseText;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (urls.length === 0) {
|
|
||||||
// Distinguish "refused / no image produced" from a transport failure:
|
|
||||||
// the executor returns 200 with an empty URL list when the model
|
|
||||||
// answered with text only (e.g. a policy refusal or a web-search
|
|
||||||
// answer instead of generation). Surface the assistant text so the
|
|
||||||
// caller can see WHY nothing was generated.
|
|
||||||
return saveImageErrorResult({
|
|
||||||
provider,
|
|
||||||
model,
|
|
||||||
status: 502,
|
|
||||||
startTime,
|
|
||||||
error: `Gemini Web completed without generating an image. Assistant text: ${content.slice(0, 300) || "(empty)"}`,
|
|
||||||
requestBody,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const url of urls) {
|
|
||||||
if (!wantsBase64) {
|
|
||||||
images.push({ url });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const fetched = await imageFetcher(url);
|
|
||||||
images.push({ b64_json: fetched.buffer.toString("base64") });
|
|
||||||
} catch (err) {
|
|
||||||
return saveImageErrorResult({
|
|
||||||
provider,
|
|
||||||
model,
|
|
||||||
status: 502,
|
|
||||||
startTime,
|
|
||||||
error: `Gemini Web generated an image but OmniRoute could not download it for b64_json conversion: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
requestBody,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return saveImageSuccessResult({
|
|
||||||
provider,
|
|
||||||
model,
|
|
||||||
startTime,
|
|
||||||
requestBody,
|
|
||||||
responseBody: { images_count: images.length },
|
|
||||||
images,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -8,15 +8,6 @@
|
|||||||
* event loop is busy (#10052) and can restart the only replica mid-session.
|
* event loop is busy (#10052) and can restart the only replica mid-session.
|
||||||
* Used by Dockerfile and docker-compose files.
|
* Used by Dockerfile and docker-compose files.
|
||||||
*
|
*
|
||||||
* #10311 — the container HEALTHCHECK previously probed the heavy
|
|
||||||
* /api/monitoring/health path (synchronous SQLite reads + deep monitoring
|
|
||||||
* aggregation) on the same single-process event loop as catalog rebuild /
|
|
||||||
* long-context compression. Under load that probe could stall past the 5s
|
|
||||||
* timeout and flip the container `unhealthy`, restarting it mid-session and
|
|
||||||
* killing active SSE streams. /healthz is a pure in-memory lifecycle check
|
|
||||||
* with no DB access. Operators who want the deep monitoring probe can opt
|
|
||||||
* back in with OMNIROUTE_HEALTHCHECK_PATH.
|
|
||||||
*
|
|
||||||
* #3151 — in some Docker network setups the server binds to a container IP and
|
* #3151 — in some Docker network setups the server binds to a container IP and
|
||||||
* a probe against `127.0.0.1` is not reachable, while `localhost`/`::1` (or vice
|
* a probe against `127.0.0.1` is not reachable, while `localhost`/`::1` (or vice
|
||||||
* versa) is. The previous version probed ONLY `127.0.0.1` and swallowed every
|
* versa) is. The previous version probed ONLY `127.0.0.1` and swallowed every
|
||||||
@@ -44,34 +35,10 @@ function normalizeBasePath(value) {
|
|||||||
return `/${segments.join("/")}`;
|
return `/${segments.join("/")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Prefixes the health route with the configured Next.js basePath. */
|
||||||
* Normalize an explicit health-check path override (OMNIROUTE_HEALTHCHECK_PATH).
|
export function resolveHealthPath(basePathValue) {
|
||||||
* Returns "" when absent/invalid so callers fall back to DEFAULT_HEALTH_PATH.
|
|
||||||
* Mirrors normalizeBasePath's safety rules (no query/hash/backslash, no "." /
|
|
||||||
* ".." segments, must start with "/").
|
|
||||||
*/
|
|
||||||
function normalizeHealthPath(value) {
|
|
||||||
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
||||||
if (!trimmed) return "";
|
|
||||||
if (!trimmed.startsWith("/") || /[?#\\]/.test(trimmed)) return "";
|
|
||||||
const segments = trimmed.split("/").filter(Boolean);
|
|
||||||
if (segments.some((segment) => segment === "." || segment === "..")) return "";
|
|
||||||
return `/${segments.join("/")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the health route to probe. By default the lightweight /healthz
|
|
||||||
* lifecycle endpoint (pure in-memory, no DB reads). An explicit
|
|
||||||
* OMNIROUTE_HEALTHCHECK_PATH override opts back into the deep monitoring
|
|
||||||
* probe. The configured Next.js basePath is always prefixed.
|
|
||||||
*
|
|
||||||
* @param {string} [basePathValue] value of OMNIROUTE_BASE_PATH
|
|
||||||
* @param {string} [healthPathValue] value of OMNIROUTE_HEALTHCHECK_PATH
|
|
||||||
*/
|
|
||||||
export function resolveHealthPath(basePathValue, healthPathValue) {
|
|
||||||
const basePath = normalizeBasePath(basePathValue);
|
const basePath = normalizeBasePath(basePathValue);
|
||||||
const healthPath = normalizeHealthPath(healthPathValue) || DEFAULT_HEALTH_PATH;
|
return basePath ? `${basePath}${DEFAULT_HEALTH_PATH}` : DEFAULT_HEALTH_PATH;
|
||||||
return basePath ? `${basePath}${healthPath}` : healthPath;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -151,10 +118,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const healthPath = resolveHealthPath(
|
const healthPath = resolveHealthPath(process.env.OMNIROUTE_BASE_PATH);
|
||||||
process.env.OMNIROUTE_BASE_PATH,
|
|
||||||
process.env.OMNIROUTE_HEALTHCHECK_PATH
|
|
||||||
);
|
|
||||||
await probeHealth({ port, hosts, healthPath });
|
await probeHealth({ port, hosts, healthPath });
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -23,21 +23,11 @@ export default function CallbackPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
let code = params.get("code");
|
const code = params.get("code");
|
||||||
const state = params.get("state");
|
const state = params.get("state");
|
||||||
const error = params.get("error");
|
const error = params.get("error");
|
||||||
const errorDescription = params.get("error_description");
|
const errorDescription = params.get("error_description");
|
||||||
|
|
||||||
// Zed native-app sign-in: the redirect carries user_id + access_token and no
|
|
||||||
// ?code= — the FULL URL is the exchange payload (zed-hosted's exchangeToken
|
|
||||||
// parses and RSA-decrypts it server-side). Rewritten here from `/` by the
|
|
||||||
// root page handler so the waiting OAuth modal receives it via the same
|
|
||||||
// postMessage/BroadcastChannel/localStorage relay as every other provider.
|
|
||||||
const zedAccessToken = params.get("access_token") || params.get("accessToken");
|
|
||||||
if (!code && zedAccessToken && (params.get("user_id") || params.get("userId"))) {
|
|
||||||
code = window.location.href;
|
|
||||||
}
|
|
||||||
|
|
||||||
const callbackData = {
|
const callbackData = {
|
||||||
code,
|
code,
|
||||||
state,
|
state,
|
||||||
@@ -73,13 +63,6 @@ export default function CallbackPage() {
|
|||||||
// same-origin fallback when the opener was severed by COOP.
|
// same-origin fallback when the opener was severed by COOP.
|
||||||
const trustedTargetOrigins = [
|
const trustedTargetOrigins = [
|
||||||
window.location.origin, // Same origin (dashboard popup mode).
|
window.location.origin, // Same origin (dashboard popup mode).
|
||||||
// Loopback hostname variants of the same port: the dashboard may be open
|
|
||||||
// on 127.0.0.1:PORT while Zed's redirect (or vice versa) lands on
|
|
||||||
// localhost:PORT — both names are the operator's own machine, so the
|
|
||||||
// callback may be delivered to either. Same rationale as the 1455 entries.
|
|
||||||
...(window.location.port
|
|
||||||
? [`http://localhost:${window.location.port}`, `http://127.0.0.1:${window.location.port}`]
|
|
||||||
: []),
|
|
||||||
"http://localhost:1455", // Codex helper (fixed loopback port).
|
"http://localhost:1455", // Codex helper (fixed loopback port).
|
||||||
"http://127.0.0.1:1455", // Same Codex helper, IPv4 literal form.
|
"http://127.0.0.1:1455", // Same Codex helper, IPv4 literal form.
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,31 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
/**
|
export default function InitPage() {
|
||||||
* Root entry. Zed's native-app sign-in always redirects the browser to the
|
|
||||||
* loopback ROOT (`http://127.0.0.1:<dashboard-port>/?user_id=...&access_token=...`),
|
|
||||||
* ignoring any path — when the dashboard port is reused as native_app_port
|
|
||||||
* (see zed-hosted.ts), that redirect lands HERE. Forward the payload to the
|
|
||||||
* /callback relay (which postMessages it to the waiting OAuth modal) instead of
|
|
||||||
* the plain /dashboard redirect below, which would drop the query string.
|
|
||||||
*/
|
|
||||||
export default async function InitPage({
|
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
|
||||||
}) {
|
|
||||||
const params = (await searchParams) || {};
|
|
||||||
const query = new URLSearchParams();
|
|
||||||
for (const [key, value] of Object.entries(params)) {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
query.set(key, value);
|
|
||||||
} else if (Array.isArray(value)) {
|
|
||||||
for (const item of value) {
|
|
||||||
if (typeof item === "string") query.append(key, item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (query.get("user_id") && query.get("access_token")) {
|
|
||||||
redirect(`/callback?${query.toString()}`);
|
|
||||||
}
|
|
||||||
redirect("/dashboard");
|
redirect("/dashboard");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { ZED_HOSTED_CONFIG } from "../constants/oauth";
|
import { ZED_HOSTED_CONFIG } from "../constants/oauth";
|
||||||
import { getRuntimePorts } from "../../runtime/ports";
|
|
||||||
import {
|
import {
|
||||||
createZedNativeAuthData,
|
createZedNativeAuthData,
|
||||||
parseZedCallbackPayload,
|
parseZedCallbackPayload,
|
||||||
@@ -22,55 +21,15 @@ import {
|
|||||||
*
|
*
|
||||||
* `code` at exchange time is the pasted native-app callback URL/query string
|
* `code` at exchange time is the pasted native-app callback URL/query string
|
||||||
* (`http://127.0.0.1:<port>/?user_id=...&access_token=...`) — Zed always
|
* (`http://127.0.0.1:<port>/?user_id=...&access_token=...`) — Zed always
|
||||||
* redirects to loopback + native_app_port, ignoring any path we'd send. When
|
* redirects to loopback + native_app_port, ignoring any `redirect_uri` we'd
|
||||||
* the dashboard itself listens on a loopback port, `buildAuthUrl` reuses it as
|
* send, so `redirectUri` here is unused by exchangeToken (kept only to
|
||||||
* native_app_port so the redirect lands back on OmniRoute (auto-completed via
|
* satisfy OAuthModal's generic "session must have a redirectUri" guard).
|
||||||
* the /callback relay); otherwise the dead default port is used and the user
|
|
||||||
* completes the flow by pasting the browser's full URL.
|
|
||||||
*/
|
*/
|
||||||
/**
|
|
||||||
* Extract the dashboard's loopback port so Zed's browser redirect can land back
|
|
||||||
* on OmniRoute itself. Zed always redirects to `http://127.0.0.1:<native_app_port>/`
|
|
||||||
* — it ignores any path/redirect_uri — so reusing the dashboard's own loopback
|
|
||||||
* port (e.g. 20128) turns the dead "site can't be reached" page into a loadable
|
|
||||||
* `/callback` relay (the root page forwards ?user_id=...&access_token=... there).
|
|
||||||
*
|
|
||||||
* The redirect URI only tells us WHICH HOSTNAME the browser used (loopback vs.
|
|
||||||
* LAN/remote) — its scheme and port reflect what the *browser* sees, which can
|
|
||||||
* differ from what the OmniRoute Node process actually listens on (e.g. a local
|
|
||||||
* TLS-terminating reverse proxy fronting the dashboard on 443 while the real
|
|
||||||
* process listens on 20128 in plain HTTP). Trusting the browser-supplied port
|
|
||||||
* previously produced `http://127.0.0.1:443/` redirects that nothing serves in
|
|
||||||
* plain HTTP. This runs server-side, so once the hostname is confirmed loopback
|
|
||||||
* (any scheme — Zed's own redirect is always plain http regardless of how the
|
|
||||||
* dashboard was reached), use the server's own authoritative listening port
|
|
||||||
* (`getRuntimePorts()`, sourced from OMNIROUTE_PORT/PORT/DASHBOARD_PORT) instead
|
|
||||||
* of re-deriving it from the client-observed scheme/port. Non-loopback redirect
|
|
||||||
* URIs (remote/LAN deployments) return null → keep the default port and rely on
|
|
||||||
* the manual paste flow.
|
|
||||||
*/
|
|
||||||
function resolveDashboardLoopbackPort(redirectUri: unknown): number | null {
|
|
||||||
try {
|
|
||||||
const url = new URL(String(redirectUri));
|
|
||||||
if (!/^(localhost|127\.0\.0\.1|\[::1\])$/i.test(url.hostname)) return null;
|
|
||||||
const { dashboardPort } = getRuntimePorts();
|
|
||||||
return Number.isInteger(dashboardPort) && dashboardPort > 0 ? dashboardPort : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exported for direct unit coverage of the port-derivation logic without
|
|
||||||
// exercising the live Zed OAuth handshake (see resolveDashboardLoopbackPort.test.ts).
|
|
||||||
export const __test__ = { resolveDashboardLoopbackPort };
|
|
||||||
|
|
||||||
export const zedHosted = {
|
export const zedHosted = {
|
||||||
config: ZED_HOSTED_CONFIG,
|
config: ZED_HOSTED_CONFIG,
|
||||||
flowType: "authorization_code",
|
flowType: "authorization_code",
|
||||||
buildAuthUrl: (config: typeof ZED_HOSTED_CONFIG, redirectUri?: string) => {
|
buildAuthUrl: (config: typeof ZED_HOSTED_CONFIG) => {
|
||||||
const nativeAppPort =
|
const authData = createZedNativeAuthData(config);
|
||||||
resolveDashboardLoopbackPort(redirectUri) || config.defaultNativeAppPort || 58443;
|
|
||||||
const authData = createZedNativeAuthData(config, { nativeAppPort });
|
|
||||||
return {
|
return {
|
||||||
authUrl: authData.authUrl,
|
authUrl: authData.authUrl,
|
||||||
codeVerifier: authData.privateKeyVerifier,
|
codeVerifier: authData.privateKeyVerifier,
|
||||||
|
|||||||
@@ -456,17 +456,11 @@ export default function OAuthModal({
|
|||||||
// Claude Code and Cline OAuth flows can finish on provider-hosted pages that
|
// Claude Code and Cline OAuth flows can finish on provider-hosted pages that
|
||||||
// show an auth code instead of redirecting back to OmniRoute.
|
// show an auth code instead of redirecting back to OmniRoute.
|
||||||
// Start directly in manual mode so users always have an input to paste code/url.
|
// Start directly in manual mode so users always have an input to paste code/url.
|
||||||
// zed-hosted's native-app sign-in redirects the browser to a local
|
// zed-hosted's native-app sign-in always redirects the browser to a local
|
||||||
// 127.0.0.1:<native_app_port> callback. On true localhost that port IS the
|
// 127.0.0.1:<port> callback that OmniRoute never listens on (the port is
|
||||||
// dashboard's own (buildAuthUrl reuses it), so the redirect lands on the
|
// arbitrary and unrelated to the dashboard's own port) — nothing can
|
||||||
// /callback relay and the popup flow auto-completes. Elsewhere (LAN/remote)
|
// auto-close the popup, so always show the manual paste-URL input.
|
||||||
// the port is unreachable — nothing can auto-close the popup, so always
|
if (provider === "claude" || provider === "cline" || provider === "zed-hosted") {
|
||||||
// show the manual paste-URL input.
|
|
||||||
if (
|
|
||||||
provider === "claude" ||
|
|
||||||
provider === "cline" ||
|
|
||||||
(provider === "zed-hosted" && !isTrueLocalhost)
|
|
||||||
) {
|
|
||||||
forceManual = true;
|
forceManual = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -886,17 +880,6 @@ export default function OAuthModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const input = callbackUrl.trim();
|
const input = callbackUrl.trim();
|
||||||
|
|
||||||
// zed-hosted: the native-app callback (http://127.0.0.1:<port>/?user_id=...&access_token=...)
|
|
||||||
// carries no ?code= param — the FULL pasted URL (or JSON/query blob) is the
|
|
||||||
// payload. zed-hosted's exchangeToken parses user_id/access_token out of it
|
|
||||||
// and RSA-decrypts the token with the private key held in codeVerifier, so
|
|
||||||
// skip the generic code/state extraction below.
|
|
||||||
if (provider === "zed-hosted") {
|
|
||||||
await exchangeTokens(input, authData?.state || null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let code = null;
|
let code = null;
|
||||||
let state = authData?.state || null;
|
let state = authData?.state || null;
|
||||||
let errorParam = null;
|
let errorParam = null;
|
||||||
|
|||||||
@@ -364,23 +364,13 @@ export function OAuthManualInputPanel({
|
|||||||
code: (chunks) => <code className="font-mono">{chunks}</code>,
|
code: (chunks) => <code className="font-mono">{chunks}</code>,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
{provider === "zed-hosted" && (
|
|
||||||
<p className="text-xs text-amber-500 mb-2">
|
|
||||||
After signing in, Zed redirects to a local address like{" "}
|
|
||||||
<code className="font-mono">http://127.0.0.1:<port>/?user_id=...</code> which the
|
|
||||||
browser may show as unreachable — that is expected. Copy the FULL URL from the
|
|
||||||
browser address bar (the access token is inside it) and paste it above.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<Input
|
<Input
|
||||||
value={callbackUrl}
|
value={callbackUrl}
|
||||||
onChange={(event) => onCallbackUrlChange(event.target.value)}
|
onChange={(event) => onCallbackUrlChange(event.target.value)}
|
||||||
placeholder={
|
placeholder={
|
||||||
provider === "claude" || provider === "cline"
|
provider === "claude" || provider === "cline"
|
||||||
? "code#state or /callback?code=..."
|
? "code#state or /callback?code=..."
|
||||||
: provider === "zed-hosted"
|
: placeholderUrl
|
||||||
? "http://127.0.0.1:<port>/?user_id=...&access_token=..."
|
|
||||||
: placeholderUrl
|
|
||||||
}
|
}
|
||||||
className="font-mono text-xs"
|
className="font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -55,25 +55,6 @@ export const NOAUTH_PROVIDERS = {
|
|||||||
// #7286: tools[] is prompt-emulated via webTools.ts (parseToolCallsFromText).
|
// #7286: tools[] is prompt-emulated via webTools.ts (parseToolCallsFromText).
|
||||||
toolCalling: "emulated",
|
toolCalling: "emulated",
|
||||||
},
|
},
|
||||||
"cloudflare-playground": {
|
|
||||||
id: "cloudflare-playground",
|
|
||||||
alias: "cfp",
|
|
||||||
name: "Cloudflare AI Playground",
|
|
||||||
icon: "cloud",
|
|
||||||
color: "#F38020",
|
|
||||||
textIcon: "CF",
|
|
||||||
website: "https://playground.ai.cloudflare.com",
|
|
||||||
noAuth: true,
|
|
||||||
hasFree: true,
|
|
||||||
serviceKinds: ["llm"],
|
|
||||||
freeNote:
|
|
||||||
"Free — Cloudflare's AI Playground: GLM 5.2, Kimi K2.7 Code, DeepSeek V4 Pro, gpt-oss-120B and 16 more. No account, no API key.",
|
|
||||||
authHint:
|
|
||||||
"No credentials required — anonymous browser sessions over a reverse-engineered cf_agent WebSocket protocol (Playwright transport).",
|
|
||||||
notice: {
|
|
||||||
text: "Cloudflare AI Playground uses a reverse-engineered anonymous WebSocket protocol (no official API). Requires Playwright with a Chromium browser on first request. Rate limits apply per IP (error 3021).",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"felo-web": {
|
"felo-web": {
|
||||||
id: "felo-web",
|
id: "felo-web",
|
||||||
alias: "felo",
|
alias: "felo",
|
||||||
|
|||||||
@@ -9,14 +9,6 @@ interface ImageGenerationResult {
|
|||||||
status?: number;
|
status?: number;
|
||||||
error?: unknown;
|
error?: unknown;
|
||||||
data?: unknown;
|
data?: unknown;
|
||||||
// #10494: opt-in signal a provider handler can set (via
|
|
||||||
// saveImageErrorResult's `retryable` option) when a non-401 failure is
|
|
||||||
// still account/session-specific — e.g. an expired or blocked Gemini Web
|
|
||||||
// session, which the underlying browser-automation executor surfaces as
|
|
||||||
// 400/500 rather than 401. Only honored together with a connectionId, same
|
|
||||||
// as the existing 401 path, so providers that never set it keep the
|
|
||||||
// original 401-only fallback behavior unchanged.
|
|
||||||
retryable?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImageCredentialRetryOptions {
|
interface ImageCredentialRetryOptions {
|
||||||
@@ -24,14 +16,6 @@ interface ImageCredentialRetryOptions {
|
|||||||
requestedModel: string | null;
|
requestedModel: string | null;
|
||||||
credentials: any;
|
credentials: any;
|
||||||
execute: (credentials: any) => Promise<ImageGenerationResult>;
|
execute: (credentials: any) => Promise<ImageGenerationResult>;
|
||||||
// Injectable so unit tests can drive multi-account fallback deterministically
|
|
||||||
// without a live DB-backed credential store; production always uses the real
|
|
||||||
// getProviderCredentialsWithQuotaPreflight-backed selectNextCredentials below.
|
|
||||||
selectNextCredentials?: (
|
|
||||||
provider: string,
|
|
||||||
requestedModel: string | null,
|
|
||||||
excludedConnectionIds: Set<string>
|
|
||||||
) => Promise<any>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImageCredentialRetryResult {
|
interface ImageCredentialRetryResult {
|
||||||
@@ -50,7 +34,7 @@ function isCredentialSentinel(credentials: any): boolean {
|
|||||||
return Boolean(credentials?.allRateLimited || credentials?.allExpired);
|
return Boolean(credentials?.allRateLimited || credentials?.allExpired);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function defaultSelectNextCredentials(
|
async function selectNextCredentials(
|
||||||
provider: string,
|
provider: string,
|
||||||
requestedModel: string | null,
|
requestedModel: string | null,
|
||||||
excludedConnectionIds: Set<string>
|
excludedConnectionIds: Set<string>
|
||||||
@@ -72,7 +56,6 @@ export async function executeImageWithCredentialFallback({
|
|||||||
requestedModel,
|
requestedModel,
|
||||||
credentials,
|
credentials,
|
||||||
execute,
|
execute,
|
||||||
selectNextCredentials = defaultSelectNextCredentials,
|
|
||||||
}: ImageCredentialRetryOptions): Promise<ImageCredentialRetryResult> {
|
}: ImageCredentialRetryOptions): Promise<ImageCredentialRetryResult> {
|
||||||
// Local/no-auth image providers intentionally have no credential row. They
|
// Local/no-auth image providers intentionally have no credential row. They
|
||||||
// still need one direct attempt, but there is no account identity to refresh
|
// still need one direct attempt, but there is no account identity to refresh
|
||||||
@@ -110,8 +93,7 @@ export async function executeImageWithCredentialFallback({
|
|||||||
|
|
||||||
lastCredentials = currentCredentials;
|
lastCredentials = currentCredentials;
|
||||||
lastResult = await execute(currentCredentials);
|
lastResult = await execute(currentCredentials);
|
||||||
const isAuthFailure = Number(lastResult.status) === 401 || lastResult.retryable === true;
|
if (lastResult.success || Number(lastResult.status) !== 401 || !connectionId) {
|
||||||
if (lastResult.success || !isAuthFailure || !connectionId) {
|
|
||||||
return { credentials: lastCredentials, result: lastResult };
|
return { credentials: lastCredentials, result: lastResult };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,57 +134,45 @@ function runSmoke(target: string): Promise<SmokeResult> {
|
|||||||
child.stdout.on("data", (c) => (stdout += String(c)));
|
child.stdout.on("data", (c) => (stdout += String(c)));
|
||||||
child.stderr.on("data", (c) => (stderr += String(c)));
|
child.stderr.on("data", (c) => (stderr += String(c)));
|
||||||
const timer = setTimeout(() => child.kill("SIGKILL"), TIMEOUT_MS);
|
const timer = setTimeout(() => child.kill("SIGKILL"), TIMEOUT_MS);
|
||||||
// Resolve on "exit", not "close": some CLIs leave grandchildren holding the
|
child.on("close", (code) => {
|
||||||
// stdio pipes after the parent dies, and "close" would wait on them forever.
|
|
||||||
child.on("exit", (code) => {
|
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
setTimeout(() => {
|
const combined = redact(stdout + "\n" + stderr);
|
||||||
const combined = redact(stdout + "\n" + stderr);
|
resolve({
|
||||||
resolve({
|
exitCode: code,
|
||||||
exitCode: code,
|
stdout: redact(stdout),
|
||||||
stdout: redact(stdout),
|
stderr: redact(stderr),
|
||||||
stderr: redact(stderr),
|
classification: classify(code, combined),
|
||||||
classification: classify(code, combined),
|
});
|
||||||
});
|
|
||||||
}, 250); // small grace period to flush buffered output
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: node:test treats `timeout: 0` as "time out immediately", not "no
|
test("upstream CLI smoke sweep (opt-in via RUN_CLI_SMOKE=1)", { timeout: 0 }, async (t) => {
|
||||||
// timeout" — size the budget from the per-target cap instead.
|
if (!ENABLED) {
|
||||||
const SWEEP_TIMEOUT_MS = (Object.keys(SMOKE_TARGETS).length + 1) * (TIMEOUT_MS + 30_000);
|
t.skip("RUN_CLI_SMOKE!=1 — real smoke is operator opt-in, never automatic");
|
||||||
|
return;
|
||||||
test(
|
|
||||||
"upstream CLI smoke sweep (opt-in via RUN_CLI_SMOKE=1)",
|
|
||||||
{ timeout: SWEEP_TIMEOUT_MS },
|
|
||||||
async (t) => {
|
|
||||||
if (!ENABLED) {
|
|
||||||
t.skip("RUN_CLI_SMOKE!=1 — real smoke is operator opt-in, never automatic");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
assert.ok(MODEL, "OMNIROUTE_SMOKE_MODEL must name the provider/model to exercise");
|
|
||||||
assert.ok(
|
|
||||||
process.env[API_KEY_ENV] !== undefined,
|
|
||||||
`credential env var '${API_KEY_ENV}' must exist (value is never printed)`
|
|
||||||
);
|
|
||||||
assert.ok(await serverReachable(), `OmniRoute is not reachable at ${BASE_URL}`);
|
|
||||||
|
|
||||||
for (const target of selectedTargets()) {
|
|
||||||
await t.test(`smoke: ${target}`, async (st) => {
|
|
||||||
if (!binaryAvailable(target)) {
|
|
||||||
st.skip(`binary '${target}' not installed on this machine`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await runSmoke(target);
|
|
||||||
st.diagnostic(`${target}: exit=${result.exitCode} class=${result.classification}`);
|
|
||||||
assert.equal(
|
|
||||||
result.classification,
|
|
||||||
"pass",
|
|
||||||
`${target} smoke failed (exit=${result.exitCode}, class=${result.classification}).\n` +
|
|
||||||
`stderr (redacted): ${result.stderr.slice(0, 500)}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
assert.ok(MODEL, "OMNIROUTE_SMOKE_MODEL must name the provider/model to exercise");
|
||||||
|
assert.ok(
|
||||||
|
process.env[API_KEY_ENV] !== undefined,
|
||||||
|
`credential env var '${API_KEY_ENV}' must exist (value is never printed)`
|
||||||
|
);
|
||||||
|
assert.ok(await serverReachable(), `OmniRoute is not reachable at ${BASE_URL}`);
|
||||||
|
|
||||||
|
for (const target of selectedTargets()) {
|
||||||
|
await t.test(`smoke: ${target}`, async (st) => {
|
||||||
|
if (!binaryAvailable(target)) {
|
||||||
|
st.skip(`binary '${target}' not installed on this machine`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await runSmoke(target);
|
||||||
|
st.diagnostic(`${target}: exit=${result.exitCode} class=${result.classification}`);
|
||||||
|
assert.equal(
|
||||||
|
result.classification,
|
||||||
|
"pass",
|
||||||
|
`${target} smoke failed (exit=${result.exitCode}, class=${result.classification}).\n` +
|
||||||
|
`stderr (redacted): ${result.stderr.slice(0, 500)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
{
|
|
||||||
"cloudAgentGuard": {
|
|
||||||
"jules": {
|
|
||||||
"message": "Provider \"jules\" is a cloud-agent provider and does not support direct chat completions; use the Cloud Agents task API instead.",
|
|
||||||
"status": 400,
|
|
||||||
"throws": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"fallback": {
|
|
||||||
"className": "DefaultExecutor",
|
|
||||||
"configSource": "openai",
|
|
||||||
"provider": "golden-test-unknown-provider"
|
|
||||||
},
|
|
||||||
"searchGuard": {
|
|
||||||
"brave-search": {
|
|
||||||
"message": "Provider \"brave-search\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.",
|
|
||||||
"status": 400,
|
|
||||||
"throws": true
|
|
||||||
},
|
|
||||||
"duckduckgo-free": {
|
|
||||||
"message": "Provider \"duckduckgo-free\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.",
|
|
||||||
"status": 400,
|
|
||||||
"throws": true
|
|
||||||
},
|
|
||||||
"exa-search": {
|
|
||||||
"message": "Provider \"exa-search\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.",
|
|
||||||
"status": 400,
|
|
||||||
"throws": true
|
|
||||||
},
|
|
||||||
"firecrawl": {
|
|
||||||
"message": "Provider \"firecrawl\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.",
|
|
||||||
"status": 400,
|
|
||||||
"throws": true
|
|
||||||
},
|
|
||||||
"google-pse-search": {
|
|
||||||
"message": "Provider \"google-pse-search\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.",
|
|
||||||
"status": 400,
|
|
||||||
"throws": true
|
|
||||||
},
|
|
||||||
"jina-search": {
|
|
||||||
"message": "Provider \"jina-search\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.",
|
|
||||||
"status": 400,
|
|
||||||
"throws": true
|
|
||||||
},
|
|
||||||
"linkup-search": {
|
|
||||||
"message": "Provider \"linkup-search\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.",
|
|
||||||
"status": 400,
|
|
||||||
"throws": true
|
|
||||||
},
|
|
||||||
"ollama-search": {
|
|
||||||
"message": "Provider \"ollama-search\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.",
|
|
||||||
"status": 400,
|
|
||||||
"throws": true
|
|
||||||
},
|
|
||||||
"perplexity-search": {
|
|
||||||
"message": "Provider \"perplexity-search\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.",
|
|
||||||
"status": 400,
|
|
||||||
"throws": true
|
|
||||||
},
|
|
||||||
"searchapi-search": {
|
|
||||||
"message": "Provider \"searchapi-search\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.",
|
|
||||||
"status": 400,
|
|
||||||
"throws": true
|
|
||||||
},
|
|
||||||
"searxng-search": {
|
|
||||||
"message": "Provider \"searxng-search\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.",
|
|
||||||
"status": 400,
|
|
||||||
"throws": true
|
|
||||||
},
|
|
||||||
"serper-search": {
|
|
||||||
"message": "Provider \"serper-search\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.",
|
|
||||||
"status": 400,
|
|
||||||
"throws": true
|
|
||||||
},
|
|
||||||
"tavily-search": {
|
|
||||||
"message": "Provider \"tavily-search\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.",
|
|
||||||
"status": 400,
|
|
||||||
"throws": true
|
|
||||||
},
|
|
||||||
"youcom-search": {
|
|
||||||
"message": "Provider \"youcom-search\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.",
|
|
||||||
"status": 400,
|
|
||||||
"throws": true
|
|
||||||
},
|
|
||||||
"zai-search": {
|
|
||||||
"message": "Provider \"zai-search\" is a search provider and does not support chat completions; use the /v1/search endpoint instead.",
|
|
||||||
"status": 400,
|
|
||||||
"throws": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,691 +0,0 @@
|
|||||||
{
|
|
||||||
"entries": {
|
|
||||||
"9router": {
|
|
||||||
"className": "NineRouterExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "9router"
|
|
||||||
},
|
|
||||||
"adapta-web": {
|
|
||||||
"className": "AdaptaWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "adapta-web"
|
|
||||||
},
|
|
||||||
"adobe-firefly": {
|
|
||||||
"className": "AdobeFireflyExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "adobe-firefly"
|
|
||||||
},
|
|
||||||
"adp-web": {
|
|
||||||
"className": "AdaptaWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "adapta-web"
|
|
||||||
},
|
|
||||||
"agy": {
|
|
||||||
"className": "AntigravityExecutor",
|
|
||||||
"configSource": "antigravity",
|
|
||||||
"provider": "antigravity"
|
|
||||||
},
|
|
||||||
"amazon-q": {
|
|
||||||
"className": "KiroExecutor",
|
|
||||||
"configSource": "kiro",
|
|
||||||
"provider": "amazon-q"
|
|
||||||
},
|
|
||||||
"antigravity": {
|
|
||||||
"className": "AntigravityExecutor",
|
|
||||||
"configSource": "antigravity",
|
|
||||||
"provider": "antigravity"
|
|
||||||
},
|
|
||||||
"auggie": {
|
|
||||||
"className": "AuggieExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "auggie"
|
|
||||||
},
|
|
||||||
"azure-ai": {
|
|
||||||
"className": "AzureAiExecutor",
|
|
||||||
"configSource": "openai",
|
|
||||||
"provider": "azure-ai"
|
|
||||||
},
|
|
||||||
"azure-openai": {
|
|
||||||
"className": "AzureOpenAIExecutor",
|
|
||||||
"configSource": "openai",
|
|
||||||
"provider": "azure-openai"
|
|
||||||
},
|
|
||||||
"bb-web": {
|
|
||||||
"className": "BlackboxWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "blackbox-web"
|
|
||||||
},
|
|
||||||
"bedrock": {
|
|
||||||
"className": "BedrockExecutor",
|
|
||||||
"configSource": "bedrock",
|
|
||||||
"provider": "bedrock"
|
|
||||||
},
|
|
||||||
"blackbox-web": {
|
|
||||||
"className": "BlackboxWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "blackbox-web"
|
|
||||||
},
|
|
||||||
"cbcn": {
|
|
||||||
"className": "CodeBuddyCnExecutor",
|
|
||||||
"configSource": "codebuddy-cn",
|
|
||||||
"provider": "codebuddy-cn"
|
|
||||||
},
|
|
||||||
"cf": {
|
|
||||||
"className": "CloudflareAIExecutor",
|
|
||||||
"configSource": "cloudflare-ai",
|
|
||||||
"provider": "cloudflare-ai"
|
|
||||||
},
|
|
||||||
"cfp": {
|
|
||||||
"className": "CloudflarePlaygroundExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "cloudflare-playground"
|
|
||||||
},
|
|
||||||
"cgpt-codex": {
|
|
||||||
"className": "ChatGptWebCodexExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "chatgpt-web-codex"
|
|
||||||
},
|
|
||||||
"cgpt-web": {
|
|
||||||
"className": "ChatGptWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "chatgpt-web"
|
|
||||||
},
|
|
||||||
"chatgpt-web": {
|
|
||||||
"className": "ChatGptWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "chatgpt-web"
|
|
||||||
},
|
|
||||||
"chatgpt-web-codex": {
|
|
||||||
"className": "ChatGptWebCodexExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "chatgpt-web-codex"
|
|
||||||
},
|
|
||||||
"cheaperinference": {
|
|
||||||
"className": "CheaperInferenceExecutor",
|
|
||||||
"configSource": "cheaperinference",
|
|
||||||
"provider": "cheaperinference"
|
|
||||||
},
|
|
||||||
"chipotle": {
|
|
||||||
"className": "ChipotleExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "chipotle"
|
|
||||||
},
|
|
||||||
"cinf": {
|
|
||||||
"className": "CheaperInferenceExecutor",
|
|
||||||
"configSource": "cheaperinference",
|
|
||||||
"provider": "cheaperinference"
|
|
||||||
},
|
|
||||||
"claude-web": {
|
|
||||||
"className": "ClaudeWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "claude-web"
|
|
||||||
},
|
|
||||||
"cliproxyapi": {
|
|
||||||
"className": "CliproxyapiExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "cliproxyapi"
|
|
||||||
},
|
|
||||||
"cloudflare-ai": {
|
|
||||||
"className": "CloudflareAIExecutor",
|
|
||||||
"configSource": "cloudflare-ai",
|
|
||||||
"provider": "cloudflare-ai"
|
|
||||||
},
|
|
||||||
"cloudflare-playground": {
|
|
||||||
"className": "CloudflarePlaygroundExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "cloudflare-playground"
|
|
||||||
},
|
|
||||||
"cmd": {
|
|
||||||
"className": "CommandCodeExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "command-code"
|
|
||||||
},
|
|
||||||
"cnl": {
|
|
||||||
"className": "ConolWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "conol-web"
|
|
||||||
},
|
|
||||||
"codebuddy-cn": {
|
|
||||||
"className": "CodeBuddyCnExecutor",
|
|
||||||
"configSource": "codebuddy-cn",
|
|
||||||
"provider": "codebuddy-cn"
|
|
||||||
},
|
|
||||||
"codex": {
|
|
||||||
"className": "CodexExecutor",
|
|
||||||
"configSource": "codex",
|
|
||||||
"provider": "codex"
|
|
||||||
},
|
|
||||||
"command-code": {
|
|
||||||
"className": "CommandCodeExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "command-code"
|
|
||||||
},
|
|
||||||
"conol-web": {
|
|
||||||
"className": "ConolWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "conol-web"
|
|
||||||
},
|
|
||||||
"copilot": {
|
|
||||||
"className": "CopilotWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "copilot-web"
|
|
||||||
},
|
|
||||||
"copilot-m365-web": {
|
|
||||||
"className": "CopilotM365WebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "copilot-m365-web"
|
|
||||||
},
|
|
||||||
"copilot-web": {
|
|
||||||
"className": "CopilotWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "copilot-web"
|
|
||||||
},
|
|
||||||
"cpa": {
|
|
||||||
"className": "CliproxyapiExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "cliproxyapi"
|
|
||||||
},
|
|
||||||
"cu": {
|
|
||||||
"className": "CursorExecutor",
|
|
||||||
"configSource": "cursor",
|
|
||||||
"provider": "cursor"
|
|
||||||
},
|
|
||||||
"cursor": {
|
|
||||||
"className": "CursorExecutor",
|
|
||||||
"configSource": "cursor",
|
|
||||||
"provider": "cursor"
|
|
||||||
},
|
|
||||||
"cw-web": {
|
|
||||||
"className": "ClaudeWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "claude-web"
|
|
||||||
},
|
|
||||||
"dario": {
|
|
||||||
"className": "DarioExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "dario"
|
|
||||||
},
|
|
||||||
"db": {
|
|
||||||
"className": "DoubaoWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "doubao-web"
|
|
||||||
},
|
|
||||||
"ddgw": {
|
|
||||||
"className": "DuckDuckGoWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "duckduckgo-web"
|
|
||||||
},
|
|
||||||
"deepseek-web": {
|
|
||||||
"className": "DeepSeekWebWithAutoRefreshExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "deepseek-web"
|
|
||||||
},
|
|
||||||
"devin": {
|
|
||||||
"className": "DevinCliExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "devin-cli"
|
|
||||||
},
|
|
||||||
"devin-cli": {
|
|
||||||
"className": "DevinCliExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "devin-cli"
|
|
||||||
},
|
|
||||||
"devin-cli-agentic": {
|
|
||||||
"className": "DevinCliAgenticExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "devin-cli-agentic"
|
|
||||||
},
|
|
||||||
"devin-desktop": {
|
|
||||||
"className": "DevinDesktopExecutor",
|
|
||||||
"configSource": "devin-desktop",
|
|
||||||
"provider": "devin-desktop"
|
|
||||||
},
|
|
||||||
"doubao-web": {
|
|
||||||
"className": "DoubaoWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "doubao-web"
|
|
||||||
},
|
|
||||||
"dr": {
|
|
||||||
"className": "DarioExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "dario"
|
|
||||||
},
|
|
||||||
"ds-web": {
|
|
||||||
"className": "DeepSeekWebWithAutoRefreshExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "deepseek-web"
|
|
||||||
},
|
|
||||||
"duckduckgo-web": {
|
|
||||||
"className": "DuckDuckGoWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "duckduckgo-web"
|
|
||||||
},
|
|
||||||
"felo": {
|
|
||||||
"className": "FeloWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "felo-web"
|
|
||||||
},
|
|
||||||
"felo-web": {
|
|
||||||
"className": "FeloWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "felo-web"
|
|
||||||
},
|
|
||||||
"firefly": {
|
|
||||||
"className": "AdobeFireflyExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "adobe-firefly"
|
|
||||||
},
|
|
||||||
"gc": {
|
|
||||||
"className": "GrokCliExecutor",
|
|
||||||
"configSource": "grok-cli",
|
|
||||||
"provider": "grok-cli"
|
|
||||||
},
|
|
||||||
"gembiz": {
|
|
||||||
"className": "GeminiBusinessExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "gemini-business"
|
|
||||||
},
|
|
||||||
"gemini-business": {
|
|
||||||
"className": "GeminiBusinessExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "gemini-business"
|
|
||||||
},
|
|
||||||
"gemini-web": {
|
|
||||||
"className": "GeminiWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "gemini-web"
|
|
||||||
},
|
|
||||||
"ghe-copilot": {
|
|
||||||
"className": "GheCopilotExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "ghe-copilot"
|
|
||||||
},
|
|
||||||
"github": {
|
|
||||||
"className": "GithubExecutor",
|
|
||||||
"configSource": "github",
|
|
||||||
"provider": "github"
|
|
||||||
},
|
|
||||||
"gitlab": {
|
|
||||||
"className": "GitlabExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "gitlab"
|
|
||||||
},
|
|
||||||
"gitlab-duo": {
|
|
||||||
"className": "GitlabExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "gitlab-duo"
|
|
||||||
},
|
|
||||||
"glm": {
|
|
||||||
"className": "GlmExecutor",
|
|
||||||
"configSource": "glm",
|
|
||||||
"provider": "glm"
|
|
||||||
},
|
|
||||||
"glm-cn": {
|
|
||||||
"className": "GlmExecutor",
|
|
||||||
"configSource": "glm-cn",
|
|
||||||
"provider": "glm-cn"
|
|
||||||
},
|
|
||||||
"glmt": {
|
|
||||||
"className": "GlmExecutor",
|
|
||||||
"configSource": "glmt",
|
|
||||||
"provider": "glmt"
|
|
||||||
},
|
|
||||||
"grok-cli": {
|
|
||||||
"className": "GrokCliExecutor",
|
|
||||||
"configSource": "grok-cli",
|
|
||||||
"provider": "grok-cli"
|
|
||||||
},
|
|
||||||
"grok-web": {
|
|
||||||
"className": "GrokWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "grok-web"
|
|
||||||
},
|
|
||||||
"gweb": {
|
|
||||||
"className": "GeminiWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "gemini-web"
|
|
||||||
},
|
|
||||||
"ha": {
|
|
||||||
"className": "HyperAgentExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "hyperagent"
|
|
||||||
},
|
|
||||||
"hailuo-web": {
|
|
||||||
"className": "HailuoWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "hailuo-web"
|
|
||||||
},
|
|
||||||
"hc": {
|
|
||||||
"className": "HuggingChatExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "huggingchat"
|
|
||||||
},
|
|
||||||
"huggingchat": {
|
|
||||||
"className": "HuggingChatExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "huggingchat"
|
|
||||||
},
|
|
||||||
"hyperagent": {
|
|
||||||
"className": "HyperAgentExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "hyperagent"
|
|
||||||
},
|
|
||||||
"in-ai": {
|
|
||||||
"className": "InnerAiExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "inner-ai"
|
|
||||||
},
|
|
||||||
"inner-ai": {
|
|
||||||
"className": "InnerAiExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "inner-ai"
|
|
||||||
},
|
|
||||||
"kimi": {
|
|
||||||
"className": "MoonshotExecutor",
|
|
||||||
"configSource": "kimi",
|
|
||||||
"provider": "kimi"
|
|
||||||
},
|
|
||||||
"kimi-coding": {
|
|
||||||
"className": "KimiExecutor",
|
|
||||||
"configSource": "kimi-coding",
|
|
||||||
"provider": "kimi-coding"
|
|
||||||
},
|
|
||||||
"kimi-coding-apikey": {
|
|
||||||
"className": "KimiExecutor",
|
|
||||||
"configSource": "kimi-coding-apikey",
|
|
||||||
"provider": "kimi-coding-apikey"
|
|
||||||
},
|
|
||||||
"kimi-web": {
|
|
||||||
"className": "KimiWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "kimi-web"
|
|
||||||
},
|
|
||||||
"kiro": {
|
|
||||||
"className": "KiroExecutor",
|
|
||||||
"configSource": "kiro",
|
|
||||||
"provider": "kiro"
|
|
||||||
},
|
|
||||||
"lma": {
|
|
||||||
"className": "LMArenaExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "lmarena"
|
|
||||||
},
|
|
||||||
"lmarena": {
|
|
||||||
"className": "LMArenaExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "lmarena"
|
|
||||||
},
|
|
||||||
"microsoft-designer-web": {
|
|
||||||
"className": "MicrosoftDesignerWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "microsoft-designer-web"
|
|
||||||
},
|
|
||||||
"moonshot": {
|
|
||||||
"className": "MoonshotExecutor",
|
|
||||||
"configSource": "moonshot",
|
|
||||||
"provider": "moonshot"
|
|
||||||
},
|
|
||||||
"ms-web": {
|
|
||||||
"className": "MuseSparkWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "muse-spark-web"
|
|
||||||
},
|
|
||||||
"msdesigner": {
|
|
||||||
"className": "MicrosoftDesignerWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "microsoft-designer-web"
|
|
||||||
},
|
|
||||||
"muse-spark-web": {
|
|
||||||
"className": "MuseSparkWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "muse-spark-web"
|
|
||||||
},
|
|
||||||
"nlpcloud": {
|
|
||||||
"className": "NlpCloudExecutor",
|
|
||||||
"configSource": "nlpcloud",
|
|
||||||
"provider": "nlpcloud"
|
|
||||||
},
|
|
||||||
"notion-web": {
|
|
||||||
"className": "NotionWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "notion-web"
|
|
||||||
},
|
|
||||||
"nr": {
|
|
||||||
"className": "NineRouterExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "9router"
|
|
||||||
},
|
|
||||||
"nw": {
|
|
||||||
"className": "NotionWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "notion-web"
|
|
||||||
},
|
|
||||||
"opencode": {
|
|
||||||
"className": "OpencodeExecutor",
|
|
||||||
"configSource": "opencode-zen",
|
|
||||||
"provider": "opencode-zen"
|
|
||||||
},
|
|
||||||
"opencode-go": {
|
|
||||||
"className": "OpencodeExecutor",
|
|
||||||
"configSource": "opencode-go",
|
|
||||||
"provider": "opencode-go"
|
|
||||||
},
|
|
||||||
"opencode-zen": {
|
|
||||||
"className": "OpencodeExecutor",
|
|
||||||
"configSource": "opencode-zen",
|
|
||||||
"provider": "opencode-zen"
|
|
||||||
},
|
|
||||||
"pepper": {
|
|
||||||
"className": "ChipotleExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "chipotle"
|
|
||||||
},
|
|
||||||
"perplexity-web": {
|
|
||||||
"className": "PerplexityWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "perplexity-web"
|
|
||||||
},
|
|
||||||
"poe-web": {
|
|
||||||
"className": "PoeWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "poe-web"
|
|
||||||
},
|
|
||||||
"pol": {
|
|
||||||
"className": "PollinationsExecutor",
|
|
||||||
"configSource": "pollinations",
|
|
||||||
"provider": "pollinations"
|
|
||||||
},
|
|
||||||
"pollinations": {
|
|
||||||
"className": "PollinationsExecutor",
|
|
||||||
"configSource": "pollinations",
|
|
||||||
"provider": "pollinations"
|
|
||||||
},
|
|
||||||
"pplx-web": {
|
|
||||||
"className": "PerplexityWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "perplexity-web"
|
|
||||||
},
|
|
||||||
"pql": {
|
|
||||||
"className": "PromptQlExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "promptql"
|
|
||||||
},
|
|
||||||
"promptql": {
|
|
||||||
"className": "PromptQlExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "promptql"
|
|
||||||
},
|
|
||||||
"qoder": {
|
|
||||||
"className": "QoderExecutor",
|
|
||||||
"configSource": "qoder",
|
|
||||||
"provider": "qoder"
|
|
||||||
},
|
|
||||||
"qw": {
|
|
||||||
"className": "QwenWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "qwen-web"
|
|
||||||
},
|
|
||||||
"qwen-web": {
|
|
||||||
"className": "QwenWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "qwen-web"
|
|
||||||
},
|
|
||||||
"raycast": {
|
|
||||||
"className": "RaycastExecutor",
|
|
||||||
"configSource": "raycast",
|
|
||||||
"provider": "raycast"
|
|
||||||
},
|
|
||||||
"rc": {
|
|
||||||
"className": "RaycastExecutor",
|
|
||||||
"configSource": "raycast",
|
|
||||||
"provider": "raycast"
|
|
||||||
},
|
|
||||||
"t3-web": {
|
|
||||||
"className": "T3ChatWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "t3-web"
|
|
||||||
},
|
|
||||||
"t3chat": {
|
|
||||||
"className": "T3ChatWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "t3-web"
|
|
||||||
},
|
|
||||||
"tasw": {
|
|
||||||
"className": "TencentAIStudioWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "tencent-aistudio-web"
|
|
||||||
},
|
|
||||||
"tcw": {
|
|
||||||
"className": "TinyCmsExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "tinycms-web"
|
|
||||||
},
|
|
||||||
"tencent-aistudio-web": {
|
|
||||||
"className": "TencentAIStudioWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "tencent-aistudio-web"
|
|
||||||
},
|
|
||||||
"theoldllm": {
|
|
||||||
"className": "TheOldLlmExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "theoldllm"
|
|
||||||
},
|
|
||||||
"tinycms-web": {
|
|
||||||
"className": "TinyCmsExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "tinycms-web"
|
|
||||||
},
|
|
||||||
"tllm": {
|
|
||||||
"className": "TheOldLlmExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "theoldllm"
|
|
||||||
},
|
|
||||||
"trae": {
|
|
||||||
"className": "TraeExecutor",
|
|
||||||
"configSource": "trae",
|
|
||||||
"provider": "trae"
|
|
||||||
},
|
|
||||||
"v0": {
|
|
||||||
"className": "V0VercelWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "v0-vercel-web"
|
|
||||||
},
|
|
||||||
"v0-vercel-web": {
|
|
||||||
"className": "V0VercelWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "v0-vercel-web"
|
|
||||||
},
|
|
||||||
"ven": {
|
|
||||||
"className": "VeniceWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "venice-web"
|
|
||||||
},
|
|
||||||
"venice-web": {
|
|
||||||
"className": "VeniceWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "venice-web"
|
|
||||||
},
|
|
||||||
"veo-free": {
|
|
||||||
"className": "VeoAIFreeWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "veoaifree-web"
|
|
||||||
},
|
|
||||||
"veoaifree-web": {
|
|
||||||
"className": "VeoAIFreeWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "veoaifree-web"
|
|
||||||
},
|
|
||||||
"vertex": {
|
|
||||||
"className": "VertexExecutor",
|
|
||||||
"configSource": "vertex",
|
|
||||||
"provider": "vertex"
|
|
||||||
},
|
|
||||||
"vertex-partner": {
|
|
||||||
"className": "VertexExecutor",
|
|
||||||
"configSource": "vertex",
|
|
||||||
"provider": "vertex"
|
|
||||||
},
|
|
||||||
"xai": {
|
|
||||||
"className": "XaiExecutor",
|
|
||||||
"configSource": "xai",
|
|
||||||
"provider": "xai"
|
|
||||||
},
|
|
||||||
"xai-oauth": {
|
|
||||||
"className": "XaiExecutor",
|
|
||||||
"configSource": "xai-oauth",
|
|
||||||
"provider": "xai-oauth"
|
|
||||||
},
|
|
||||||
"xao": {
|
|
||||||
"className": "XaiExecutor",
|
|
||||||
"configSource": "xai-oauth",
|
|
||||||
"provider": "xai-oauth"
|
|
||||||
},
|
|
||||||
"ybw": {
|
|
||||||
"className": "YuanbaoWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "yuanbao-web"
|
|
||||||
},
|
|
||||||
"yuanbao-web": {
|
|
||||||
"className": "YuanbaoWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "yuanbao-web"
|
|
||||||
},
|
|
||||||
"zai-web": {
|
|
||||||
"className": "ZaiWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "zai-web"
|
|
||||||
},
|
|
||||||
"zc": {
|
|
||||||
"className": "ZcodeExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "zcode"
|
|
||||||
},
|
|
||||||
"zcode": {
|
|
||||||
"className": "ZcodeExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "zcode"
|
|
||||||
},
|
|
||||||
"zed-hosted": {
|
|
||||||
"className": "ZedHostedExecutor",
|
|
||||||
"configSource": "zed-hosted",
|
|
||||||
"provider": "zed-hosted"
|
|
||||||
},
|
|
||||||
"zenmux-free": {
|
|
||||||
"className": "ZenmuxFreeExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "zenmux-free"
|
|
||||||
},
|
|
||||||
"zmf": {
|
|
||||||
"className": "ZenmuxFreeExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "zenmux-free"
|
|
||||||
},
|
|
||||||
"zw": {
|
|
||||||
"className": "ZaiWebExecutor",
|
|
||||||
"configSource": "<custom-config>",
|
|
||||||
"provider": "zai-web"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"keyCount": 137,
|
|
||||||
"sharedInstances": []
|
|
||||||
}
|
|
||||||
@@ -1196,29 +1196,6 @@
|
|||||||
"stream": "https://api.cloudflare.com/client/v4/accounts"
|
"stream": "https://api.cloudflare.com/client/v4/accounts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cloudflare-playground": {
|
|
||||||
"format": "openai",
|
|
||||||
"headers": {
|
|
||||||
"apiKey": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
"nonStream": {
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
"oauth": {
|
|
||||||
"Accept": "text/event-stream",
|
|
||||||
"Authorization": "Bearer <TOK>",
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"url": {
|
|
||||||
"nonStream": "https://playground.ai.cloudflare.com",
|
|
||||||
"stream": "https://playground.ai.cloudflare.com"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"clova-studio": {
|
"clova-studio": {
|
||||||
"format": "openai",
|
"format": "openai",
|
||||||
"headers": {
|
"headers": {
|
||||||
|
|||||||
@@ -1,526 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for the Cloudflare AI Playground (No Auth) provider.
|
|
||||||
*
|
|
||||||
* Validates:
|
|
||||||
* - NOAUTH_PROVIDERS contains the cloudflare-playground entry (noAuth category)
|
|
||||||
* - Registry entry has correct shape (authType none), curated 20-model catalog
|
|
||||||
* - Executor resolves for both the primary id and the alias (cfp)
|
|
||||||
* - cf_agent frame → OpenAI SSE translation, exercised with REAL frames captured
|
|
||||||
* from the playground on 2026-08-15 (including decoy RPC `done:true` frames
|
|
||||||
* that must NOT terminate the chat stream, and a real 3021 rate-limit error)
|
|
||||||
* - Streaming + non-streaming responses, clean upstream errors (no stack traces)
|
|
||||||
*/
|
|
||||||
import test from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
|
|
||||||
import { NOAUTH_PROVIDERS } from "../../src/shared/constants/providers/noauth.ts";
|
|
||||||
import { REGISTRY } from "../../open-sse/config/providers/index.ts";
|
|
||||||
import { getExecutor } from "../../open-sse/executors/index.ts";
|
|
||||||
import {
|
|
||||||
CloudflarePlaygroundExecutor,
|
|
||||||
CfStreamParser,
|
|
||||||
PlaywrightCfTransport,
|
|
||||||
toCfMessages,
|
|
||||||
type CfTransport,
|
|
||||||
} from "../../open-sse/executors/cloudflare-playground.ts";
|
|
||||||
|
|
||||||
const CHAT_ID = "chatcmpl-cfp-test123";
|
|
||||||
|
|
||||||
// ── Fixtures: REAL frames captured from the playground (2026-08-15) ─────────
|
|
||||||
|
|
||||||
const identityFrame = JSON.stringify({
|
|
||||||
name: "playground-8d57d26b34b144108fd1f49d2",
|
|
||||||
agent: "playground",
|
|
||||||
type: "cf_agent_identity",
|
|
||||||
});
|
|
||||||
const stateFrame = JSON.stringify({
|
|
||||||
state: {
|
|
||||||
model: "@cf/zai-org/glm-4.7-flash",
|
|
||||||
temperature: 1,
|
|
||||||
stream: true,
|
|
||||||
system: "You are a helpful assistant.",
|
|
||||||
},
|
|
||||||
type: "cf_agent_state",
|
|
||||||
});
|
|
||||||
/** Decoy: the setConfig RPC response also carries `done:true` — must be ignored. */
|
|
||||||
const decoyRpcDone = JSON.stringify({
|
|
||||||
id: "cfp-config",
|
|
||||||
done: true,
|
|
||||||
type: "cf_agent_rpc_response",
|
|
||||||
});
|
|
||||||
|
|
||||||
const cfFrame = (chatId: string, body: unknown) =>
|
|
||||||
JSON.stringify({ id: chatId, type: "cf_agent_use_chat_response", body: JSON.stringify(body) });
|
|
||||||
|
|
||||||
/** Full success stream built for a given chat id. */
|
|
||||||
const buildSuccessFrames = (chatId: string) => [
|
|
||||||
identityFrame,
|
|
||||||
stateFrame,
|
|
||||||
decoyRpcDone,
|
|
||||||
cfFrame(chatId, { type: "start" }),
|
|
||||||
cfFrame(chatId, { type: "start-step" }),
|
|
||||||
cfFrame(chatId, { type: "reasoning-start", id: "r1" }),
|
|
||||||
cfFrame(chatId, { type: "reasoning-delta", delta: "thinking about it...", id: "r1" }),
|
|
||||||
cfFrame(chatId, { type: "reasoning-end", id: "r1" }),
|
|
||||||
cfFrame(chatId, { type: "text-start", id: "t1" }),
|
|
||||||
cfFrame(chatId, { type: "text-delta", delta: "Hello ", id: "t1" }),
|
|
||||||
cfFrame(chatId, { type: "text-delta", delta: "world!", id: "t1" }),
|
|
||||||
cfFrame(chatId, { type: "finish-step" }),
|
|
||||||
cfFrame(chatId, { type: "finish", messageMetadata: { finishReason: "stop" } }),
|
|
||||||
JSON.stringify({ id: chatId, type: "cf_agent_use_chat_response", done: true }),
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Real rate-limit error frame (kimi-k2.6, captured live), built for a chat id. */
|
|
||||||
const buildRateLimitFrame = (chatId: string) =>
|
|
||||||
JSON.stringify({
|
|
||||||
error: true,
|
|
||||||
body: JSON.stringify({
|
|
||||||
message: "The model is currently rate limited. Please wait a moment and try again.",
|
|
||||||
details: "3021: rate limiting: inference request per min rate reached",
|
|
||||||
}),
|
|
||||||
done: false,
|
|
||||||
id: chatId,
|
|
||||||
type: "cf_agent_use_chat_response",
|
|
||||||
});
|
|
||||||
|
|
||||||
class FakeTransport implements CfTransport {
|
|
||||||
constructor(
|
|
||||||
private framesList: string[],
|
|
||||||
private fail: { status: number; message: string } | null = null
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async start(): Promise<{ ok: true } | { ok: false; status: number; message: string }> {
|
|
||||||
return this.fail ? { ok: false, ...this.fail } : { ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
async *frames(): AsyncGenerator<string> {
|
|
||||||
for (const frame of this.framesList) yield frame;
|
|
||||||
}
|
|
||||||
|
|
||||||
async close(): Promise<void> {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeExecutor(
|
|
||||||
buildFrames: (chatId: string) => string[],
|
|
||||||
fail?: { status: number; message: string }
|
|
||||||
) {
|
|
||||||
return new CloudflarePlaygroundExecutor((chatId) => new FakeTransport(buildFrames(chatId), fail));
|
|
||||||
}
|
|
||||||
|
|
||||||
const executeArgs = (body: Record<string, unknown>, stream: boolean) =>
|
|
||||||
({ body, credentials: {}, signal: null, stream }) as unknown as Parameters<
|
|
||||||
CloudflarePlaygroundExecutor["execute"]
|
|
||||||
>[0];
|
|
||||||
|
|
||||||
// ── Catalog / NOAUTH_PROVIDERS ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("cloudflare-playground is present in NOAUTH_PROVIDERS (noAuth category)", () => {
|
|
||||||
const p = (NOAUTH_PROVIDERS as Record<string, unknown>)["cloudflare-playground"] as Record<
|
|
||||||
string,
|
|
||||||
unknown
|
|
||||||
>;
|
|
||||||
assert.ok(p, "NOAUTH_PROVIDERS['cloudflare-playground'] must exist");
|
|
||||||
assert.equal(p.id, "cloudflare-playground");
|
|
||||||
assert.equal(p.alias, "cfp");
|
|
||||||
assert.equal((p.name as string).includes("Cloudflare"), true);
|
|
||||||
assert.equal(p.noAuth, true);
|
|
||||||
assert.equal(p.hasFree, true);
|
|
||||||
assert.ok(typeof p.freeNote === "string" && (p.freeNote as string).length > 0);
|
|
||||||
assert.ok(typeof p.authHint === "string" && (p.authHint as string).length > 0);
|
|
||||||
assert.ok(typeof p.website === "string" && (p.website as string).includes("cloudflare.com"));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("cloudflare-playground registry entry has no-auth shape and curated models", () => {
|
|
||||||
const entry = REGISTRY["cloudflare-playground"];
|
|
||||||
assert.ok(entry, "REGISTRY['cloudflare-playground'] must exist");
|
|
||||||
assert.equal(entry.alias, "cfp");
|
|
||||||
assert.equal(entry.format, "openai");
|
|
||||||
assert.equal(entry.executor, "cloudflare-playground");
|
|
||||||
assert.equal(entry.authType, "none");
|
|
||||||
assert.equal(entry.authHeader, "none");
|
|
||||||
assert.equal(entry.baseUrl, "https://playground.ai.cloudflare.com");
|
|
||||||
|
|
||||||
assert.ok(
|
|
||||||
entry.models.length >= 15,
|
|
||||||
`expected a curated catalog, got ${entry.models.length} models`
|
|
||||||
);
|
|
||||||
// No model id carries the upstream @cf/ prefix (executor adds it).
|
|
||||||
for (const model of entry.models) {
|
|
||||||
assert.ok(!model.id.startsWith("@cf/"), `model id must be prefix-free: ${model.id}`);
|
|
||||||
}
|
|
||||||
// Flagships present.
|
|
||||||
const ids = new Set(entry.models.map((m) => m.id));
|
|
||||||
for (const expected of [
|
|
||||||
"zai-org/glm-5.2",
|
|
||||||
"moonshotai/kimi-k2.6",
|
|
||||||
"deepseek-ai/deepseek-v4-flash-0731",
|
|
||||||
"openai/gpt-oss-120b",
|
|
||||||
"qwen/qwen2.5-coder-32b-instruct",
|
|
||||||
]) {
|
|
||||||
assert.ok(ids.has(expected), `expected model ${expected} in catalog`);
|
|
||||||
}
|
|
||||||
// Reasoning flags on the known thinking models.
|
|
||||||
const glm = entry.models.find((m) => m.id === "zai-org/glm-5.2");
|
|
||||||
assert.equal(glm?.supportsReasoning, true);
|
|
||||||
const llama = entry.models.find((m) => m.id === "meta-llama/llama-3.3-70b-instruct-fp8-fast");
|
|
||||||
assert.equal(llama?.supportsReasoning, undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("executor resolves for both the id and the cfp alias", () => {
|
|
||||||
const byId = getExecutor("cloudflare-playground");
|
|
||||||
const byAlias = getExecutor("cfp");
|
|
||||||
assert.ok(byId instanceof CloudflarePlaygroundExecutor);
|
|
||||||
assert.ok(byAlias instanceof CloudflarePlaygroundExecutor);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Frame → SSE translation (real captured traffic) ─────────────────────────
|
|
||||||
|
|
||||||
test("CfStreamParser translates a real captured stream (decoys ignored)", () => {
|
|
||||||
const parser = new CfStreamParser(CHAT_ID);
|
|
||||||
let events = 0;
|
|
||||||
for (const frame of buildSuccessFrames(CHAT_ID)) {
|
|
||||||
const event = parser.push(frame);
|
|
||||||
if (event) events += 1;
|
|
||||||
}
|
|
||||||
assert.equal(parser.text, "Hello world!");
|
|
||||||
assert.equal(parser.reasoningText, "thinking about it...");
|
|
||||||
assert.equal(parser.finishReason, "stop");
|
|
||||||
assert.equal(parser.done, true);
|
|
||||||
assert.equal(parser.error, null);
|
|
||||||
// role + 1 reasoning + 2 content + 1 finish
|
|
||||||
assert.equal(events, 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("CfStreamParser ignores done:true frames that belong to other ids/RPCs", () => {
|
|
||||||
const parser = new CfStreamParser(CHAT_ID);
|
|
||||||
// Decoy RPC response with done:true
|
|
||||||
parser.push(decoyRpcDone);
|
|
||||||
assert.equal(parser.done, false, "RPC done:true must not end the chat stream");
|
|
||||||
// A chat-response frame for a DIFFERENT chat id
|
|
||||||
parser.push(
|
|
||||||
JSON.stringify({ id: "chatcmpl-OTHER", type: "cf_agent_use_chat_response", done: true })
|
|
||||||
);
|
|
||||||
assert.equal(parser.done, false, "foreign chat id must not end the stream");
|
|
||||||
// The real one
|
|
||||||
parser.push(JSON.stringify({ id: CHAT_ID, type: "cf_agent_use_chat_response", done: true }));
|
|
||||||
assert.equal(parser.done, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("CfStreamParser maps the real 3021 rate-limit frame to HTTP 429", () => {
|
|
||||||
const parser = new CfStreamParser(CHAT_ID);
|
|
||||||
parser.push(buildRateLimitFrame(CHAT_ID));
|
|
||||||
assert.ok(parser.error, "rate-limit frame must surface as an error");
|
|
||||||
assert.equal(parser.error?.status, 429);
|
|
||||||
assert.ok((parser.error?.message ?? "").includes("rate limiting"));
|
|
||||||
assert.equal(parser.done, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("toCfMessages drops system/tool, flattens parts, keeps user/assistant", () => {
|
|
||||||
const out = toCfMessages([
|
|
||||||
{ role: "system", content: "You are a helpful assistant." },
|
|
||||||
{ role: "user", content: "hi" },
|
|
||||||
{ role: "assistant", content: [{ type: "text", text: "hello" }] },
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: [
|
|
||||||
{ type: "text", text: "a" },
|
|
||||||
{ type: "text", text: "b" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ role: "tool", content: "tool result" },
|
|
||||||
{ role: "user", content: "" },
|
|
||||||
]);
|
|
||||||
assert.equal(out.length, 3);
|
|
||||||
assert.deepEqual(out[0].parts, [{ type: "text", text: "hi" }]);
|
|
||||||
assert.equal(out[1].parts[0].text, "hello");
|
|
||||||
assert.equal(out[2].parts[0].text, "a\nb");
|
|
||||||
assert.equal(out[0].role, "user");
|
|
||||||
assert.equal(out[1].role, "assistant");
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Executor behavior (fake transport, real frames) ─────────────────────────
|
|
||||||
|
|
||||||
test("executor streams OpenAI SSE chunks from captured frames", async () => {
|
|
||||||
const executor = makeExecutor(buildSuccessFrames);
|
|
||||||
const result = await executor.execute(
|
|
||||||
executeArgs(
|
|
||||||
{ model: "zai-org/glm-4.7-flash", messages: [{ role: "user", content: "hi" }] },
|
|
||||||
true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const response = result.response;
|
|
||||||
assert.equal(response.status, 200);
|
|
||||||
assert.match(response.headers.get("content-type") ?? "", /text\/event-stream/);
|
|
||||||
|
|
||||||
const raw = await response.text();
|
|
||||||
assert.ok(raw.endsWith("data: [DONE]\n\n"), "stream must end with [DONE]");
|
|
||||||
|
|
||||||
const chunks = raw
|
|
||||||
.split("\n")
|
|
||||||
.filter((line) => line.startsWith("data: ") && line !== "data: [DONE]")
|
|
||||||
.map((line) => JSON.parse(line.slice(6)));
|
|
||||||
assert.ok(chunks.length >= 5, `expected several chunks, got ${chunks.length}`);
|
|
||||||
|
|
||||||
const first = chunks[0];
|
|
||||||
assert.equal(first.choices[0].delta.role, "assistant");
|
|
||||||
assert.equal(first.choices[0].finish_reason, null);
|
|
||||||
|
|
||||||
const reasoningChunk = chunks.find((c) => c.choices?.[0]?.delta?.reasoning_content);
|
|
||||||
assert.equal(reasoningChunk?.choices?.[0]?.delta?.reasoning_content, "thinking about it...");
|
|
||||||
|
|
||||||
const content = chunks
|
|
||||||
.filter((c) => c.choices?.[0]?.delta?.content)
|
|
||||||
.map((c) => c.choices[0].delta.content)
|
|
||||||
.join("");
|
|
||||||
assert.equal(content, "Hello world!");
|
|
||||||
|
|
||||||
const last = chunks[chunks.length - 1];
|
|
||||||
assert.equal(last.choices[0].finish_reason, "stop");
|
|
||||||
assert.equal(last.model, "zai-org/glm-4.7-flash");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("executor returns JSON for non-streaming requests", async () => {
|
|
||||||
const executor = makeExecutor(buildSuccessFrames);
|
|
||||||
const result = await executor.execute(
|
|
||||||
executeArgs(
|
|
||||||
{ model: "moonshotai/kimi-k2.6", messages: [{ role: "user", content: "hi" }] },
|
|
||||||
false
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const response = result.response;
|
|
||||||
assert.equal(response.status, 200);
|
|
||||||
assert.match(response.headers.get("content-type") ?? "", /application\/json/);
|
|
||||||
|
|
||||||
const parsed = JSON.parse(await response.text()) as {
|
|
||||||
choices: Array<{
|
|
||||||
message: { content: string; reasoning_content?: string };
|
|
||||||
finish_reason: string;
|
|
||||||
}>;
|
|
||||||
model: string;
|
|
||||||
};
|
|
||||||
assert.equal(parsed.choices[0].message.content, "Hello world!");
|
|
||||||
assert.equal(parsed.choices[0].message.reasoning_content, "thinking about it...");
|
|
||||||
assert.equal(parsed.choices[0].finish_reason, "stop");
|
|
||||||
assert.equal(parsed.model, "moonshotai/kimi-k2.6");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("executor surfaces the 3021 rate limit as a clean 429 (no stack traces)", async () => {
|
|
||||||
const executor = makeExecutor((chatId) => [buildRateLimitFrame(chatId)]);
|
|
||||||
const result = await executor.execute(
|
|
||||||
executeArgs(
|
|
||||||
{ model: "moonshotai/kimi-k2.6", messages: [{ role: "user", content: "hi" }] },
|
|
||||||
false
|
|
||||||
)
|
|
||||||
);
|
|
||||||
assert.equal(result.response.status, 429);
|
|
||||||
const parsed = JSON.parse(await result.response.text()) as {
|
|
||||||
error: { message: string; type: string };
|
|
||||||
};
|
|
||||||
assert.ok(parsed.error.message.includes("rate limiting"));
|
|
||||||
assert.equal(parsed.error.type, "upstream_error");
|
|
||||||
assert.ok(!parsed.error.message.includes(" at "), "no stack-trace leak");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("executor returns a clean 502 when the browser session cannot start", async () => {
|
|
||||||
const executor = makeExecutor(buildSuccessFrames, {
|
|
||||||
status: 502,
|
|
||||||
message: "Cloudflare Playground browser session failed: boom",
|
|
||||||
});
|
|
||||||
const result = await executor.execute(
|
|
||||||
executeArgs(
|
|
||||||
{ model: "zai-org/glm-4.7-flash", messages: [{ role: "user", content: "hi" }] },
|
|
||||||
true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
assert.equal(result.response.status, 502);
|
|
||||||
const parsed = JSON.parse(await result.response.text()) as { error: { message: string } };
|
|
||||||
assert.ok(parsed.error.message.includes("browser session failed"));
|
|
||||||
assert.ok(!parsed.error.message.includes(" at "), "no stack-trace leak");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("executor prefixes bare model ids with @cf/ (upstream convention)", async () => {
|
|
||||||
const seen: string[] = [];
|
|
||||||
class CapturingTransport extends FakeTransport {
|
|
||||||
async start(config: Parameters<CfTransport["start"]>[0]) {
|
|
||||||
seen.push(config.model);
|
|
||||||
return { ok: true } as const;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const executor = new CloudflarePlaygroundExecutor(
|
|
||||||
(chatId) => new CapturingTransport(buildSuccessFrames(chatId))
|
|
||||||
);
|
|
||||||
await executor.execute(
|
|
||||||
executeArgs(
|
|
||||||
{ model: "zai-org/glm-4.7-flash", messages: [{ role: "user", content: "hi" }] },
|
|
||||||
false
|
|
||||||
)
|
|
||||||
);
|
|
||||||
assert.equal(seen.length, 1);
|
|
||||||
assert.equal(seen[0], "@cf/zai-org/glm-4.7-flash");
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── #10494: browser/transport resource leak on blocked-request paths ───────
|
|
||||||
|
|
||||||
test("PlaywrightCfTransport.start() closes the browser when Cloudflare Attention Required is detected", async () => {
|
|
||||||
const playwright = await import("playwright");
|
|
||||||
const originalLaunch = playwright.chromium.launch;
|
|
||||||
let closeCalls = 0;
|
|
||||||
|
|
||||||
playwright.chromium.launch = (async () =>
|
|
||||||
({
|
|
||||||
newContext: async () => ({
|
|
||||||
newPage: async () => ({
|
|
||||||
goto: async () => {},
|
|
||||||
title: async () => "Attention Required! | Cloudflare",
|
|
||||||
exposeFunction: async () => {},
|
|
||||||
evaluate: async () => {},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
close: async () => {
|
|
||||||
closeCalls += 1;
|
|
||||||
},
|
|
||||||
}) as unknown as ReturnType<typeof playwright.chromium.launch>) as typeof playwright.chromium.launch;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const transport = new PlaywrightCfTransport("chat-attention-required");
|
|
||||||
const started = await transport.start({
|
|
||||||
model: "@cf/test-model",
|
|
||||||
messages: [],
|
|
||||||
temperature: 0.7,
|
|
||||||
});
|
|
||||||
assert.equal(started.ok, false);
|
|
||||||
if (started.ok === false) {
|
|
||||||
assert.equal(started.status, 502);
|
|
||||||
}
|
|
||||||
assert.equal(closeCalls, 1, "browser launched for the challenge check must be closed");
|
|
||||||
} finally {
|
|
||||||
playwright.chromium.launch = originalLaunch;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── #10494: streaming timeout must not be misreported as a clean [DONE] ────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A transport whose frames() hangs (never yields) once its initial queue is
|
|
||||||
* drained, mirroring PlaywrightCfTransport's real behavior: frames() only
|
|
||||||
* resolves again once close() is called (real close() unblocks pending
|
|
||||||
* waiters with null, ending the generator). This lets tests force the
|
|
||||||
* executor's internal chat-timeout branch deterministically instead of
|
|
||||||
* waiting for CHAT_TIMEOUT_MS.
|
|
||||||
*/
|
|
||||||
class HangingTransport implements CfTransport {
|
|
||||||
closeCalls = 0;
|
|
||||||
private closed = false;
|
|
||||||
private queue: string[];
|
|
||||||
private waiters: Array<(frame: string | null) => void> = [];
|
|
||||||
|
|
||||||
constructor(initialFrames: string[] = []) {
|
|
||||||
this.queue = [...initialFrames];
|
|
||||||
}
|
|
||||||
|
|
||||||
async start(): Promise<{ ok: true } | { ok: false; status: number; message: string }> {
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
async *frames(): AsyncGenerator<string> {
|
|
||||||
while (true) {
|
|
||||||
if (this.queue.length > 0) {
|
|
||||||
yield this.queue.shift()!;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const frame = await new Promise<string | null>((resolve) => this.waiters.push(resolve));
|
|
||||||
if (frame === null) return;
|
|
||||||
yield frame;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Idempotent, mirroring PlaywrightCfTransport.close(): the timer callback
|
|
||||||
// and the streaming finally block both call close() on the timeout path.
|
|
||||||
async close(): Promise<void> {
|
|
||||||
if (this.closed) return;
|
|
||||||
this.closed = true;
|
|
||||||
this.closeCalls += 1;
|
|
||||||
for (const waiter of this.waiters.splice(0)) waiter(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSseChunks(raw: string) {
|
|
||||||
return raw
|
|
||||||
.split("\n\n")
|
|
||||||
.filter((chunk) => chunk.startsWith("data: ") && chunk !== "data: [DONE]")
|
|
||||||
.map((chunk) => JSON.parse(chunk.slice(6)));
|
|
||||||
}
|
|
||||||
|
|
||||||
test("streaming: an empty timeout (no frames at all) emits an explicit error chunk, not a bare [DONE]", async () => {
|
|
||||||
const transport = new HangingTransport([]);
|
|
||||||
const executor = new CloudflarePlaygroundExecutor(() => transport, 20);
|
|
||||||
const result = await executor.execute(
|
|
||||||
executeArgs(
|
|
||||||
{ model: "zai-org/glm-4.7-flash", messages: [{ role: "user", content: "hi" }] },
|
|
||||||
true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const raw = await result.response.text();
|
|
||||||
assert.ok(raw.endsWith("data: [DONE]\n\n"), "stream must still end with [DONE]");
|
|
||||||
assert.equal(transport.closeCalls, 1, "timed-out transport must be closed");
|
|
||||||
|
|
||||||
const chunks = parseSseChunks(raw);
|
|
||||||
assert.ok(chunks.length >= 1, "an error chunk must be emitted before [DONE]");
|
|
||||||
const errorChunk = chunks.find((c) => c.error);
|
|
||||||
assert.ok(errorChunk, "expected an explicit error chunk on timeout");
|
|
||||||
assert.equal(errorChunk.error.type, "timeout_error");
|
|
||||||
assert.equal(errorChunk.error.code, "HTTP_504");
|
|
||||||
assert.ok(!errorChunk.error.message.includes(" at "), "no stack-trace leak");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("streaming: a partial answer followed by a timeout emits content THEN an explicit error chunk", async () => {
|
|
||||||
// The executor mints its own random chat id (chatcmpl-cfp-<uuid>) and only
|
|
||||||
// the transportFactory receives it — frames must reference that same id or
|
|
||||||
// CfStreamParser silently ignores them (see `msg.id !== this.chatId`
|
|
||||||
// above). Build the partial frames from the factory callback, exactly like
|
|
||||||
// buildSuccessFrames()/makeExecutor() do above.
|
|
||||||
let transport!: HangingTransport;
|
|
||||||
const executor = new CloudflarePlaygroundExecutor((chatId) => {
|
|
||||||
const partialFrames = [
|
|
||||||
JSON.stringify({
|
|
||||||
id: chatId,
|
|
||||||
type: "cf_agent_use_chat_response",
|
|
||||||
body: JSON.stringify({ type: "start" }),
|
|
||||||
}),
|
|
||||||
JSON.stringify({
|
|
||||||
id: chatId,
|
|
||||||
type: "cf_agent_use_chat_response",
|
|
||||||
body: JSON.stringify({ type: "text-delta", delta: "Hello", id: "t1" }),
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
transport = new HangingTransport(partialFrames);
|
|
||||||
return transport;
|
|
||||||
}, 20);
|
|
||||||
const result = await executor.execute(
|
|
||||||
executeArgs(
|
|
||||||
{ model: "zai-org/glm-4.7-flash", messages: [{ role: "user", content: "hi" }] },
|
|
||||||
true
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const raw = await result.response.text();
|
|
||||||
assert.ok(raw.endsWith("data: [DONE]\n\n"));
|
|
||||||
assert.equal(transport.closeCalls, 1);
|
|
||||||
|
|
||||||
const chunks = parseSseChunks(raw);
|
|
||||||
const content = chunks
|
|
||||||
.filter((c) => c.choices?.[0]?.delta?.content)
|
|
||||||
.map((c) => c.choices[0].delta.content)
|
|
||||||
.join("");
|
|
||||||
assert.equal(content, "Hello", "the partial content already streamed must not be dropped");
|
|
||||||
|
|
||||||
const errorChunk = chunks.find((c) => c.error);
|
|
||||||
assert.ok(errorChunk, "a partial-then-timeout stream must still surface an explicit error");
|
|
||||||
assert.equal(errorChunk.error.type, "timeout_error");
|
|
||||||
|
|
||||||
// The error chunk must come after the content, so a client processing the
|
|
||||||
// stream in order sees the partial answer followed by a clear failure —
|
|
||||||
// never a silent, successful-looking [DONE] right after partial content.
|
|
||||||
const errorIndex = chunks.indexOf(errorChunk);
|
|
||||||
const lastContentIndex = chunks.findLastIndex((c) => c.choices?.[0]?.delta?.content);
|
|
||||||
assert.ok(errorIndex > lastContentIndex, "error chunk must follow the streamed content");
|
|
||||||
});
|
|
||||||
@@ -2,7 +2,7 @@ import test from "node:test";
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { resolveHealthPath } from "../../scripts/dev/healthcheck.mjs";
|
import { resolveHealthPath } from "../../scripts/dev/healthcheck.mjs";
|
||||||
|
|
||||||
test("resolveHealthPath keeps the default lightweight /healthz route at the domain root (#10311)", () => {
|
test("resolveHealthPath keeps the default route at the domain root", () => {
|
||||||
assert.equal(resolveHealthPath(""), "/healthz");
|
assert.equal(resolveHealthPath(""), "/healthz");
|
||||||
assert.equal(resolveHealthPath(undefined), "/healthz");
|
assert.equal(resolveHealthPath(undefined), "/healthz");
|
||||||
});
|
});
|
||||||
@@ -11,17 +11,3 @@ test("resolveHealthPath prefixes the health route with OMNIROUTE_BASE_PATH", ()
|
|||||||
assert.equal(resolveHealthPath("/omniroute/"), "/omniroute/healthz");
|
assert.equal(resolveHealthPath("/omniroute/"), "/omniroute/healthz");
|
||||||
assert.equal(resolveHealthPath("/omniroute"), "/omniroute/healthz");
|
assert.equal(resolveHealthPath("/omniroute"), "/omniroute/healthz");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resolveHealthPath honors an explicit OMNIROUTE_HEALTHCHECK_PATH override", () => {
|
|
||||||
assert.equal(resolveHealthPath(undefined, "/api/monitoring/health"), "/api/monitoring/health");
|
|
||||||
assert.equal(
|
|
||||||
resolveHealthPath("/omniroute", "/api/monitoring/health"),
|
|
||||||
"/omniroute/api/monitoring/health"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveHealthPath ignores an invalid/empty OMNIROUTE_HEALTHCHECK_PATH and falls back to /healthz", () => {
|
|
||||||
assert.equal(resolveHealthPath("", " "), "/healthz");
|
|
||||||
assert.equal(resolveHealthPath("", "/../etc/passwd"), "/healthz");
|
|
||||||
assert.equal(resolveHealthPath("", "/health?utm=1"), "/healthz");
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ test("hashes canonical owners and never persists the raw owner", () => {
|
|||||||
|
|
||||||
test("uses the live next-free migration slot without runner compatibility special cases", () => {
|
test("uses the live next-free migration slot without runner compatibility special cases", () => {
|
||||||
const migration = fs.readFileSync(
|
const migration = fs.readFileSync(
|
||||||
new URL("../../src/lib/db/migrations/157_exclusive_connection_leases.sql", import.meta.url),
|
new URL("../../src/lib/db/migrations/155_exclusive_connection_leases.sql", import.meta.url),
|
||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
const runner = fs.readFileSync(
|
const runner = fs.readFileSync(
|
||||||
@@ -57,7 +57,7 @@ test("uses the live next-free migration slot without runner compatibility specia
|
|||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
assert.match(migration, /CREATE TABLE IF NOT EXISTS exclusive_connection_leases/);
|
assert.match(migration, /CREATE TABLE IF NOT EXISTS exclusive_connection_leases/);
|
||||||
assert.doesNotMatch(runner, /case "157"/);
|
assert.doesNotMatch(runner, /case "155"/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("enforces global active owner and connection uniqueness", () => {
|
test("enforces global active owner and connection uniqueness", () => {
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
import test from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
// R0.3 GOLDEN LOCK (characterization BEFORE the ExecutorRegistry refactor):
|
|
||||||
// freeze the full provider-id → executor mapping of open-sse/executors/index.ts —
|
|
||||||
// every specialized key with its executor class, effective provider identity and
|
|
||||||
// which PROVIDERS config entry backs it — plus the getExecutor() dispatch rules
|
|
||||||
// (specialized hit, DefaultExecutor fallback + cache, cloud-agent guard #6699,
|
|
||||||
// search-provider guard #10274). The registry refactor must keep this snapshot
|
|
||||||
// byte-identical: any drift in keys, classes, provider identity or guard behavior
|
|
||||||
// is a golden diff, not a silent routing change.
|
|
||||||
|
|
||||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-executor-golden-"));
|
|
||||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
|
||||||
|
|
||||||
// Dynamic imports AFTER DATA_DIR is set so db/core.ts picks up the temp path.
|
|
||||||
const { getExecutor, hasSpecializedExecutor, DefaultExecutor } = await import(
|
|
||||||
"../../open-sse/executors/index.ts"
|
|
||||||
);
|
|
||||||
const { PROVIDERS } = await import("../../open-sse/config/constants.ts");
|
|
||||||
const { SEARCH_PROVIDERS } = await import("../../open-sse/config/searchRegistry.ts");
|
|
||||||
const { goldenSnapshot } = await import("../helpers/goldenSnapshot.ts");
|
|
||||||
|
|
||||||
test.after(() => {
|
|
||||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// The specialized keys are not exported; enumerate them through the public
|
|
||||||
// surface by probing every plausible id source AND the literal keys read from
|
|
||||||
// the module source. Reading the source keeps the golden honest: a key added
|
|
||||||
// to (or removed from) the hard-coded map cannot hide from the snapshot.
|
|
||||||
function readSpecializedKeys(): string[] {
|
|
||||||
const src = fs.readFileSync(
|
|
||||||
path.resolve(path.dirname(new URL(import.meta.url).pathname), "../../open-sse/executors/index.ts"),
|
|
||||||
"utf8"
|
|
||||||
);
|
|
||||||
const mapMatch = src.match(/const executors = \{([\s\S]*?)\n\};/);
|
|
||||||
assert.ok(mapMatch, "executors map literal not found in open-sse/executors/index.ts");
|
|
||||||
const keys: string[] = [];
|
|
||||||
for (const line of mapMatch[1].split("\n")) {
|
|
||||||
const m = line.match(/^\s*(?:"([^"]+)"|([A-Za-z0-9_$-]+)):\s*new /);
|
|
||||||
if (m) keys.push(m[1] ?? m[2]);
|
|
||||||
}
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map a ProviderConfig object back to its PROVIDERS key by identity, so the
|
|
||||||
// snapshot records WHICH config backs each executor without freezing the whole
|
|
||||||
// (huge, frequently-edited) config content.
|
|
||||||
const providerConfigKeyByRef = new Map<object, string>();
|
|
||||||
for (const [key, cfg] of Object.entries(PROVIDERS)) {
|
|
||||||
if (cfg && typeof cfg === "object" && !providerConfigKeyByRef.has(cfg)) {
|
|
||||||
providerConfigKeyByRef.set(cfg, key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeExecutor(instance: unknown): {
|
|
||||||
className: string;
|
|
||||||
provider: string | null;
|
|
||||||
configSource: string | null;
|
|
||||||
} {
|
|
||||||
const inst = instance as { constructor: { name: string }; provider?: string; config?: object };
|
|
||||||
const cfg = inst.config;
|
|
||||||
return {
|
|
||||||
className: inst.constructor.name,
|
|
||||||
provider: typeof inst.provider === "string" ? inst.provider : null,
|
|
||||||
configSource:
|
|
||||||
cfg == null ? null : (providerConfigKeyByRef.get(cfg) ?? "<custom-config>"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const specializedKeys = readSpecializedKeys();
|
|
||||||
|
|
||||||
test("golden: specialized executor map — key → class + provider identity + config source", () => {
|
|
||||||
assert.ok(specializedKeys.length >= 100, `suspiciously few keys: ${specializedKeys.length}`);
|
|
||||||
|
|
||||||
const entries: Record<
|
|
||||||
string,
|
|
||||||
{ className: string; provider: string | null; configSource: string | null }
|
|
||||||
> = {};
|
|
||||||
const byInstance = new Map<unknown, string[]>();
|
|
||||||
|
|
||||||
for (const key of [...specializedKeys].sort()) {
|
|
||||||
assert.equal(hasSpecializedExecutor(key), true, `hasSpecializedExecutor(${key})`);
|
|
||||||
const instance = getExecutor(key);
|
|
||||||
entries[key] = describeExecutor(instance);
|
|
||||||
const group = byInstance.get(instance) ?? [];
|
|
||||||
group.push(key);
|
|
||||||
byInstance.set(instance, group);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keys sharing the SAME instance share per-instance state (session pools,
|
|
||||||
// rotation cooldowns); today every map entry is its own `new X()`. Freeze that.
|
|
||||||
const sharedInstances = [...byInstance.values()]
|
|
||||||
.filter((keys) => keys.length > 1)
|
|
||||||
.map((keys) => keys.sort())
|
|
||||||
.sort((a, b) => a[0].localeCompare(b[0]));
|
|
||||||
|
|
||||||
goldenSnapshot("executors/executor-map", {
|
|
||||||
keyCount: specializedKeys.length,
|
|
||||||
entries,
|
|
||||||
sharedInstances,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("golden: getExecutor dispatch rules — fallback, cache and 400-guards", () => {
|
|
||||||
// 1. Unknown provider → DefaultExecutor for that provider, memoized.
|
|
||||||
const unknown = "golden-test-unknown-provider";
|
|
||||||
assert.equal(hasSpecializedExecutor(unknown), false);
|
|
||||||
const fallback = getExecutor(unknown);
|
|
||||||
assert.ok(fallback instanceof DefaultExecutor, "fallback must be DefaultExecutor");
|
|
||||||
assert.equal(getExecutor(unknown), fallback, "DefaultExecutor fallback must be cached");
|
|
||||||
|
|
||||||
// 2. Cloud-agent guard (#6699) and search guard (#10274) → status-400 throw.
|
|
||||||
const guardOutcome = (provider: string) => {
|
|
||||||
try {
|
|
||||||
getExecutor(provider);
|
|
||||||
return { throws: false as const };
|
|
||||||
} catch (err) {
|
|
||||||
const e = err as Error & { status?: number };
|
|
||||||
return { throws: true as const, status: e.status ?? null, message: e.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const searchProviders = Object.keys(SEARCH_PROVIDERS).sort();
|
|
||||||
goldenSnapshot("executors/dispatch-rules", {
|
|
||||||
fallback: describeExecutor(fallback),
|
|
||||||
cloudAgentGuard: { jules: guardOutcome("jules") },
|
|
||||||
searchGuard: Object.fromEntries(searchProviders.map((p) => [p, guardOutcome(p)])),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import test from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
// R0.3 — unit tests for the ExecutorRegistry seam itself (registration
|
|
||||||
// semantics + wiring of the built-ins). Behavior parity of the full map is
|
|
||||||
// covered separately by tests/unit/executor-map-golden.test.ts.
|
|
||||||
|
|
||||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-executor-registry-"));
|
|
||||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
|
||||||
|
|
||||||
const { registerExecutor, getRegisteredExecutor, hasRegisteredExecutor, listExecutorAliases } =
|
|
||||||
await import("../../open-sse/executors/registry.ts");
|
|
||||||
const { getExecutor, hasSpecializedExecutor, BaseExecutor, DefaultExecutor } = await import(
|
|
||||||
"../../open-sse/executors/index.ts"
|
|
||||||
);
|
|
||||||
|
|
||||||
test.after(() => {
|
|
||||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("built-ins are registered at module load and resolve through the registry", () => {
|
|
||||||
const aliases = listExecutorAliases();
|
|
||||||
assert.ok(aliases.length >= 100, `expected the built-in table, got ${aliases.length} aliases`);
|
|
||||||
for (const alias of ["antigravity", "kiro", "glm", "9router", "conol-web"]) {
|
|
||||||
assert.ok(hasRegisteredExecutor(alias), `missing built-in: ${alias}`);
|
|
||||||
assert.equal(getExecutor(alias), getRegisteredExecutor(alias));
|
|
||||||
assert.ok(getExecutor(alias) instanceof BaseExecutor);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("registerExecutor throws on duplicate alias", () => {
|
|
||||||
assert.throws(() => registerExecutor("kiro", getRegisteredExecutor("kiro")!), {
|
|
||||||
message: /already registered: "kiro"/,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("registering a new alias makes it resolvable via getExecutor and hasSpecializedExecutor", () => {
|
|
||||||
const alias = "registry-test-provider";
|
|
||||||
assert.equal(hasSpecializedExecutor(alias), false);
|
|
||||||
const instance = new DefaultExecutor(alias);
|
|
||||||
registerExecutor(alias, instance);
|
|
||||||
assert.equal(hasSpecializedExecutor(alias), true);
|
|
||||||
assert.equal(getExecutor(alias), instance);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("registry lookup is exact — Object.prototype names are not executors", () => {
|
|
||||||
// The old object-literal lookup (`executors[provider]`) leaked prototype
|
|
||||||
// members: getExecutor("constructor") returned Object's constructor. The Map
|
|
||||||
// registry must treat these as unknown providers (DefaultExecutor fallback).
|
|
||||||
for (const name of ["constructor", "toString", "hasOwnProperty", "__proto__"]) {
|
|
||||||
assert.equal(hasSpecializedExecutor(name), false, name);
|
|
||||||
assert.ok(getExecutor(name) instanceof DefaultExecutor, name);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
// #10494: Gemini Web image-generation account fallback gap.
|
|
||||||
//
|
|
||||||
// #10466's acceptance criteria require that "expired or blocked sessions
|
|
||||||
// return a clear session/provider error and can fall back normally inside an
|
|
||||||
// image Combo." The gemini-web image handler passed the executor's raw HTTP
|
|
||||||
// status straight through to executeImageWithCredentialFallback, whose retry
|
|
||||||
// loop only advances to the next account on a plain HTTP 401 — but the
|
|
||||||
// underlying GeminiWebExecutor's browser-automation catch paths surface an
|
|
||||||
// expired/blocked session as 400 (Playwright selector/click timeout — "the
|
|
||||||
// session is so expired it lands on a different page", #9407) or 500 (the
|
|
||||||
// generic automation-failure catch-all), never 401. So expired/blocked
|
|
||||||
// Gemini Web sessions never triggered account fallback.
|
|
||||||
//
|
|
||||||
// Covers:
|
|
||||||
// - isExpiredOrBlockedGeminiWebSession() classification (unit).
|
|
||||||
// - A multi-account regression: first account fails with a classified
|
|
||||||
// status, the retry loop advances to a second account, which succeeds.
|
|
||||||
// - An invalid-session test that drives the REAL GeminiWebExecutor (Playwright
|
|
||||||
// launch mocked, same technique as tests/unit/gemini-web.test.ts) so the
|
|
||||||
// classified status is the executor's actual status code, not a synthetic
|
|
||||||
// one, and confirms the handler marks it retryable end to end.
|
|
||||||
import test from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { mkdtempSync, rmSync } from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
const TEST_DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-geminiweb-image-fallback-"));
|
|
||||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
|
||||||
|
|
||||||
const { isExpiredOrBlockedGeminiWebSession, handleGeminiWebImageGeneration } = await import(
|
|
||||||
"../../open-sse/handlers/imageGeneration/providers/geminiWeb.ts"
|
|
||||||
);
|
|
||||||
const { executeImageWithCredentialFallback } = await import(
|
|
||||||
"../../src/sse/services/imageCredentialRetry.ts"
|
|
||||||
);
|
|
||||||
const { GeminiWebExecutor } = await import("../../open-sse/executors/gemini-web.ts");
|
|
||||||
const core = await import("../../src/lib/db/core.ts");
|
|
||||||
|
|
||||||
test.after(() => {
|
|
||||||
core.resetDbInstance();
|
|
||||||
rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Classification (unit) ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("isExpiredOrBlockedGeminiWebSession classifies 400/500 as retryable, everything else as not", () => {
|
|
||||||
assert.equal(isExpiredOrBlockedGeminiWebSession(400), true);
|
|
||||||
assert.equal(isExpiredOrBlockedGeminiWebSession(500), true);
|
|
||||||
assert.equal(isExpiredOrBlockedGeminiWebSession(401), false, "handled by the plain 401 path");
|
|
||||||
assert.equal(
|
|
||||||
isExpiredOrBlockedGeminiWebSession(503),
|
|
||||||
false,
|
|
||||||
"missing-Playwright-browser is a host/config problem, not a per-account issue"
|
|
||||||
);
|
|
||||||
assert.equal(isExpiredOrBlockedGeminiWebSession(502), false);
|
|
||||||
assert.equal(isExpiredOrBlockedGeminiWebSession(200), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Multi-account regression: 2 accounts, first classified-fails, second succeeds ──
|
|
||||||
|
|
||||||
test("executeImageWithCredentialFallback: expired/blocked (400) on account 1 falls back to account 2", async () => {
|
|
||||||
const attempts: string[] = [];
|
|
||||||
const accountA = { connectionId: "conn-a", apiKey: "cookie-a" };
|
|
||||||
const accountB = { connectionId: "conn-b", apiKey: "cookie-b" };
|
|
||||||
|
|
||||||
const execution = await executeImageWithCredentialFallback({
|
|
||||||
provider: "gemini-web",
|
|
||||||
requestedModel: "gemini-2.5-pro",
|
|
||||||
credentials: accountA,
|
|
||||||
// Simulates the real handler path: geminiWeb.ts sets retryable via
|
|
||||||
// saveImageErrorResult when the executor status is classified as an
|
|
||||||
// expired/blocked session (400/500), not just a plain 401.
|
|
||||||
execute: async (creds) => {
|
|
||||||
attempts.push(creds.connectionId);
|
|
||||||
if (creds.connectionId === "conn-a") {
|
|
||||||
return { success: false, status: 400, error: "session expired", retryable: true };
|
|
||||||
}
|
|
||||||
return { success: true, data: { created: 1, data: [{ url: "https://example/img.png" }] } };
|
|
||||||
},
|
|
||||||
selectNextCredentials: async () => accountB,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(attempts, ["conn-a", "conn-b"], "must try both accounts in order");
|
|
||||||
assert.equal(execution.result.success, true);
|
|
||||||
assert.equal(execution.credentials.connectionId, "conn-b");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("executeImageWithCredentialFallback: a non-retryable 400 (e.g. bad prompt) does NOT burn a second account", async () => {
|
|
||||||
const attempts: string[] = [];
|
|
||||||
const accountA = { connectionId: "conn-a", apiKey: "cookie-a" };
|
|
||||||
|
|
||||||
const execution = await executeImageWithCredentialFallback({
|
|
||||||
provider: "gemini-web",
|
|
||||||
requestedModel: "gemini-2.5-pro",
|
|
||||||
credentials: accountA,
|
|
||||||
execute: async (creds) => {
|
|
||||||
attempts.push(creds.connectionId);
|
|
||||||
return { success: false, status: 400, error: "Prompt is required" }; // retryable unset
|
|
||||||
},
|
|
||||||
selectNextCredentials: async () => {
|
|
||||||
throw new Error("must not be called for a non-retryable failure");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(attempts, ["conn-a"]);
|
|
||||||
assert.equal(execution.result.success, false);
|
|
||||||
assert.equal(execution.result.status, 400);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Invalid-session test against the REAL executor's actual status code ────
|
|
||||||
|
|
||||||
test("handler classifies the REAL GeminiWebExecutor's session-expired 400 as retryable", async () => {
|
|
||||||
const playwright = await import("playwright");
|
|
||||||
const originalLaunch = playwright.chromium.launch;
|
|
||||||
|
|
||||||
// Mirrors tests/unit/gemini-web.test.ts's pattern for a fake page whose
|
|
||||||
// waitForSelector() times out — the exact path (#9407) that makes the
|
|
||||||
// real executor return a 400 tagged "the session is so expired it lands
|
|
||||||
// on a different page".
|
|
||||||
playwright.chromium.launch = (async () =>
|
|
||||||
({
|
|
||||||
newContext: async () => ({
|
|
||||||
addCookies: async () => {},
|
|
||||||
newPage: async () => ({
|
|
||||||
on: () => {},
|
|
||||||
goto: async () => {},
|
|
||||||
waitForTimeout: async () => {},
|
|
||||||
waitForSelector: async () => {
|
|
||||||
const err = new Error("Timeout 10000ms exceeded while waiting for selector");
|
|
||||||
err.name = "TimeoutError";
|
|
||||||
throw err;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
close: async () => {},
|
|
||||||
}) as unknown as ReturnType<typeof playwright.chromium.launch>) as typeof playwright.chromium.launch;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const executor = new GeminiWebExecutor();
|
|
||||||
const direct = await executor.execute({
|
|
||||||
model: "gemini-2.5-pro",
|
|
||||||
body: { messages: [{ role: "user", content: "hi" }], x_gemini_web_image_mode: true },
|
|
||||||
stream: false,
|
|
||||||
credentials: { apiKey: "expired-session-cookie" },
|
|
||||||
signal: AbortSignal.timeout(10000),
|
|
||||||
log: null,
|
|
||||||
});
|
|
||||||
// Confirm the REAL executor really does surface this as 400 (not a
|
|
||||||
// synthetic status invented by the test).
|
|
||||||
assert.equal(direct.response.status, 400, "sanity: executor's real session-expired status");
|
|
||||||
|
|
||||||
const res = await handleGeminiWebImageGeneration({
|
|
||||||
model: "gemini-2.5-pro",
|
|
||||||
provider: "gemini-web",
|
|
||||||
body: { prompt: "a kitten" },
|
|
||||||
credentials: { apiKey: "expired-session-cookie", connectionId: "conn-real" },
|
|
||||||
log: null,
|
|
||||||
signal: null,
|
|
||||||
clientHeaders: {},
|
|
||||||
executorFactory: () => new GeminiWebExecutor(),
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(res.success, false);
|
|
||||||
assert.equal(res.status, 400);
|
|
||||||
assert.equal(
|
|
||||||
(res as { retryable?: boolean }).retryable,
|
|
||||||
true,
|
|
||||||
"the handler must mark the real executor's session-expired status as retryable"
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
playwright.chromium.launch = originalLaunch;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
// Tests for gemini-web image generation (#10466).
|
|
||||||
//
|
|
||||||
// Fixtures are built from the documented StreamGenerate frame layout for
|
|
||||||
// generated images (corroborated by gpt4free's Gemini provider and
|
|
||||||
// HanaokaYuzu/Gemini-API's _parse_candidate):
|
|
||||||
//
|
|
||||||
// wrb.fr line → JSON [ "wrb.fr", null, "<payload>" ]
|
|
||||||
// payload → JSON [ ..., [4] = [ candidate ] ]
|
|
||||||
// candidate[1] = [ "answer text" ]
|
|
||||||
// candidate[12][1] = web-search images (must NOT be collected)
|
|
||||||
// candidate[12][7][0] = generated-image entries
|
|
||||||
// entry[0][3][3] = image URL (string OR list of strings)
|
|
||||||
import test from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { mkdtempSync } from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-gweb-image-"));
|
|
||||||
|
|
||||||
const { parseStreamResponse, parseStreamResponseImages } =
|
|
||||||
await import("../../open-sse/executors/gemini-web.ts");
|
|
||||||
const { handleGeminiWebImageGeneration, buildGeminiWebImagePrompt } =
|
|
||||||
await import("../../open-sse/handlers/imageGeneration/providers/geminiWeb.ts");
|
|
||||||
const { parseImageModel, getImageProvider } =
|
|
||||||
await import("../../open-sse/config/imageRegistry.ts");
|
|
||||||
|
|
||||||
// ─── Fixture builders ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Build one wrb.fr StreamGenerate line with the given candidate. */
|
|
||||||
function frameLine(candidate: unknown): string {
|
|
||||||
const payload = JSON.stringify([null, [], null, null, [candidate]]);
|
|
||||||
return JSON.stringify([["wrb.fr", null, payload]]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Candidate carrying answer text and/or generated images. */
|
|
||||||
function candidate({
|
|
||||||
text = "",
|
|
||||||
generatedUrls = [],
|
|
||||||
webImageUrls = [],
|
|
||||||
}: {
|
|
||||||
text?: string;
|
|
||||||
generatedUrls?: Array<string | string[]>;
|
|
||||||
webImageUrls?: string[];
|
|
||||||
} = {}): unknown[] {
|
|
||||||
const cand: unknown[] = [];
|
|
||||||
cand[1] = [text];
|
|
||||||
if (webImageUrls.length > 0 || generatedUrls.length > 0) {
|
|
||||||
const ext: unknown[] = [];
|
|
||||||
if (webImageUrls.length > 0) {
|
|
||||||
// [12][1]: web-search result thumbnails — [[ [url, ...], ... ]]
|
|
||||||
ext[1] = webImageUrls.map((u) => [[[u]]]);
|
|
||||||
}
|
|
||||||
if (generatedUrls.length > 0) {
|
|
||||||
// [12][7][0]: generated-image entries; parser reads entry[0][3][3] = url
|
|
||||||
ext[7] = [generatedUrls.map((u) => [[null, null, null, [null, null, null, u]]])];
|
|
||||||
}
|
|
||||||
cand[12] = ext;
|
|
||||||
}
|
|
||||||
return cand;
|
|
||||||
}
|
|
||||||
|
|
||||||
function streamResponse(lines: string[]): string {
|
|
||||||
return [")]}'", ...lines.map((l) => `${l.length}\n${l}`)].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
const IMG_URL = "https://lh3.googleusercontent.com/gg-dl/generated-abc123";
|
|
||||||
const IMG_URL_2 = "https://lh3.googleusercontent.com/gg-dl/generated-def456";
|
|
||||||
const WEB_URL = "https://example.com/web-search-thumb.jpg";
|
|
||||||
|
|
||||||
// ─── parseStreamResponseImages ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("extracts generated-image URL from a realistic frame (string form)", () => {
|
|
||||||
const raw = streamResponse([
|
|
||||||
frameLine(candidate({ text: "Here you go!", generatedUrls: [IMG_URL] })),
|
|
||||||
]);
|
|
||||||
assert.deepEqual(parseStreamResponseImages(raw), [`${IMG_URL}=s2048`]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles list-form URL field (takes first http entry)", () => {
|
|
||||||
const raw = streamResponse([
|
|
||||||
frameLine(candidate({ generatedUrls: [["not-a-url", IMG_URL, IMG_URL_2]] })),
|
|
||||||
]);
|
|
||||||
assert.deepEqual(parseStreamResponseImages(raw), [`${IMG_URL}=s2048`]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("dedupes across cumulative frames, preserving first-seen order", () => {
|
|
||||||
// Frames are cumulative snapshots: frame 2 repeats image 1 and adds image 2.
|
|
||||||
const raw = streamResponse([
|
|
||||||
frameLine(candidate({ text: "partial", generatedUrls: [IMG_URL] })),
|
|
||||||
frameLine(candidate({ text: "full answer", generatedUrls: [IMG_URL, IMG_URL_2] })),
|
|
||||||
]);
|
|
||||||
assert.deepEqual(parseStreamResponseImages(raw), [`${IMG_URL}=s2048`, `${IMG_URL_2}=s2048`]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does NOT collect web-search images at [12][1]", () => {
|
|
||||||
const raw = streamResponse([
|
|
||||||
frameLine(candidate({ text: "found these", webImageUrls: [WEB_URL] })),
|
|
||||||
]);
|
|
||||||
assert.deepEqual(parseStreamResponseImages(raw), []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not double-append size directive when one is present", () => {
|
|
||||||
const sized = `${IMG_URL}=w1024-h512`;
|
|
||||||
const raw = streamResponse([frameLine(candidate({ generatedUrls: [sized] }))]);
|
|
||||||
assert.deepEqual(parseStreamResponseImages(raw), [sized]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns [] for text-only frames (chat responses unaffected)", () => {
|
|
||||||
const raw = streamResponse([frameLine(candidate({ text: "just text, no images" }))]);
|
|
||||||
assert.deepEqual(parseStreamResponseImages(raw), []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("skips malformed lines without throwing", () => {
|
|
||||||
const raw = [
|
|
||||||
")]}'",
|
|
||||||
"garbage not json",
|
|
||||||
JSON.stringify([["wrb.fr", null, "{broken json"]]),
|
|
||||||
frameLine(candidate({ generatedUrls: [IMG_URL] })),
|
|
||||||
].join("\n");
|
|
||||||
assert.deepEqual(parseStreamResponseImages(raw), [`${IMG_URL}=s2048`]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("text parser still extracts text from image-bearing frames", () => {
|
|
||||||
const raw = streamResponse([
|
|
||||||
frameLine(candidate({ text: "Here is your image!", generatedUrls: [IMG_URL] })),
|
|
||||||
]);
|
|
||||||
assert.equal(parseStreamResponse(raw), "Here is your image!");
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── buildGeminiWebImagePrompt ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("prompt leads with an explicit generation directive", () => {
|
|
||||||
const prompt = buildGeminiWebImagePrompt({ prompt: "a red panda", size: "1024x1536" });
|
|
||||||
assert.match(prompt, /^Generate an image for this prompt: a red panda/);
|
|
||||||
assert.match(prompt, /Do not search the web/);
|
|
||||||
assert.match(prompt, /1024x1536/);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── handleGeminiWebImageGeneration ─────────────────────────────────────────
|
|
||||||
|
|
||||||
function fakeExecutor(jsonBody: object, status = 200) {
|
|
||||||
return {
|
|
||||||
execute: async () => ({
|
|
||||||
response: new Response(JSON.stringify(jsonBody), {
|
|
||||||
status,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseArgs = {
|
|
||||||
model: "nano-banana-web",
|
|
||||||
provider: "gemini-web",
|
|
||||||
body: { prompt: "a red panda eating bamboo" },
|
|
||||||
credentials: { apiKey: "***" },
|
|
||||||
log: null,
|
|
||||||
signal: null,
|
|
||||||
clientHeaders: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
test("success: returns image URLs in OpenAI image response shape", async () => {
|
|
||||||
const res = await handleGeminiWebImageGeneration({
|
|
||||||
...baseArgs,
|
|
||||||
executorFactory: () =>
|
|
||||||
fakeExecutor({
|
|
||||||
choices: [{ message: { role: "assistant", content: "Here you go!" } }],
|
|
||||||
x_gemini_web_image_urls: [IMG_URL],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
assert.equal(res.success, true);
|
|
||||||
assert.equal(res.data.data.length, 1);
|
|
||||||
assert.equal(res.data.data[0].url, IMG_URL);
|
|
||||||
assert.ok(res.data.created > 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("success: b64_json downloads the image via injected fetcher", async () => {
|
|
||||||
const bytes = Buffer.from("fake-png-bytes");
|
|
||||||
const res = await handleGeminiWebImageGeneration({
|
|
||||||
...baseArgs,
|
|
||||||
body: { prompt: "a red panda", response_format: "b64_json" },
|
|
||||||
executorFactory: () =>
|
|
||||||
fakeExecutor({
|
|
||||||
choices: [{ message: { role: "assistant", content: "" } }],
|
|
||||||
x_gemini_web_image_urls: [IMG_URL],
|
|
||||||
}),
|
|
||||||
imageFetcher: async (url: string) => {
|
|
||||||
assert.equal(url, IMG_URL);
|
|
||||||
return { buffer: bytes, contentType: "image/png" };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
assert.equal(res.success, true);
|
|
||||||
assert.equal(res.data.data[0].b64_json, bytes.toString("base64"));
|
|
||||||
assert.equal(res.data.data[0].url, undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("b64_json download failure surfaces a specific 502", async () => {
|
|
||||||
const res = await handleGeminiWebImageGeneration({
|
|
||||||
...baseArgs,
|
|
||||||
body: { prompt: "a red panda", response_format: "b64_json" },
|
|
||||||
executorFactory: () =>
|
|
||||||
fakeExecutor({
|
|
||||||
choices: [{ message: { role: "assistant", content: "" } }],
|
|
||||||
x_gemini_web_image_urls: [IMG_URL],
|
|
||||||
}),
|
|
||||||
imageFetcher: async () => {
|
|
||||||
throw new Error("Remote image fetch error 403");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
assert.equal(res.success, false);
|
|
||||||
assert.equal(res.status, 502);
|
|
||||||
assert.match(res.error, /generated an image but OmniRoute could not download it/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("no images generated: 502 includes assistant text (refusal visibility)", async () => {
|
|
||||||
const res = await handleGeminiWebImageGeneration({
|
|
||||||
...baseArgs,
|
|
||||||
executorFactory: () =>
|
|
||||||
fakeExecutor({
|
|
||||||
choices: [{ message: { role: "assistant", content: "I can't generate that image." } }],
|
|
||||||
x_gemini_web_image_urls: [],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
assert.equal(res.success, false);
|
|
||||||
assert.equal(res.status, 502);
|
|
||||||
assert.match(res.error, /without generating an image/);
|
|
||||||
assert.match(res.error, /I can't generate that image/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("missing prompt → 400", async () => {
|
|
||||||
const res = await handleGeminiWebImageGeneration({
|
|
||||||
...baseArgs,
|
|
||||||
body: { prompt: " " },
|
|
||||||
});
|
|
||||||
assert.equal(res.success, false);
|
|
||||||
assert.equal(res.status, 400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("missing cookie → 401", async () => {
|
|
||||||
const res = await handleGeminiWebImageGeneration({
|
|
||||||
...baseArgs,
|
|
||||||
credentials: {},
|
|
||||||
});
|
|
||||||
assert.equal(res.success, false);
|
|
||||||
assert.equal(res.status, 401);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("n above the cap → 400 with the cap named", async () => {
|
|
||||||
const res = await handleGeminiWebImageGeneration({
|
|
||||||
...baseArgs,
|
|
||||||
body: { prompt: "a red panda", n: 5 },
|
|
||||||
});
|
|
||||||
assert.equal(res.success, false);
|
|
||||||
assert.equal(res.status, 400);
|
|
||||||
assert.match(res.error, /n=1\.\.4/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("executor error status passes through", async () => {
|
|
||||||
const res = await handleGeminiWebImageGeneration({
|
|
||||||
...baseArgs,
|
|
||||||
executorFactory: () => fakeExecutor({ error: "Missing Gemini cookies" }, 401),
|
|
||||||
});
|
|
||||||
assert.equal(res.success, false);
|
|
||||||
assert.equal(res.status, 401);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("n=2 runs sequentially and collects both turns' images", async () => {
|
|
||||||
let calls = 0;
|
|
||||||
const res = await handleGeminiWebImageGeneration({
|
|
||||||
...baseArgs,
|
|
||||||
body: { prompt: "a red panda", n: 2 },
|
|
||||||
executorFactory: () => ({
|
|
||||||
execute: async () => {
|
|
||||||
calls++;
|
|
||||||
const url = calls === 1 ? IMG_URL : IMG_URL_2;
|
|
||||||
return {
|
|
||||||
response: new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
choices: [{ message: { role: "assistant", content: "" } }],
|
|
||||||
x_gemini_web_image_urls: [url],
|
|
||||||
}),
|
|
||||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
||||||
),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
assert.equal(calls, 2);
|
|
||||||
assert.equal(res.success, true);
|
|
||||||
assert.deepEqual(
|
|
||||||
res.data.data.map((d: { url?: string }) => d.url),
|
|
||||||
[IMG_URL, IMG_URL_2]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Registry wiring ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("registry: gemini-web/nano-banana resolves to the gemini-web provider", () => {
|
|
||||||
const parsed = parseImageModel("gemini-web/nano-banana-web");
|
|
||||||
assert.equal(parsed.provider, "gemini-web");
|
|
||||||
assert.equal(parsed.model, "nano-banana-web");
|
|
||||||
const config = getImageProvider("gemini-web");
|
|
||||||
assert.ok(config);
|
|
||||||
assert.equal(config.format, "gemini-web");
|
|
||||||
assert.equal(config.authHeader, "cookie");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("registry: alias gweb/nano-banana resolves too", () => {
|
|
||||||
const parsed = parseImageModel("gweb/nano-banana-web");
|
|
||||||
assert.equal(parsed.provider, "gemini-web");
|
|
||||||
assert.equal(parsed.model, "nano-banana-web");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("registry regression: bare nano-banana still routes to adobe-firefly", () => {
|
|
||||||
// adobe-firefly owns the bare nano-banana ids (operator decision 2026-07-31);
|
|
||||||
// the new gemini-web entry must not steal that resolution.
|
|
||||||
const parsed = parseImageModel("nano-banana");
|
|
||||||
assert.equal(parsed.provider, "adobe-firefly");
|
|
||||||
});
|
|
||||||
@@ -261,45 +261,6 @@ test.describe("modelsDevSync-extended", { concurrency: 1 }, async () => {
|
|||||||
assert.deepEqual(modelsDev.getModelsDevPricing(), {});
|
assert.deepEqual(modelsDev.getModelsDevPricing(), {});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("getModelsDevPricing memoizes until save/clear (#9685)", async () => {
|
|
||||||
const modelsDev = await importFresh("pricing-memo");
|
|
||||||
const pricing = modelsDev.transformModelsDevToPricing(MOCK_MODELS_DEV_DATA);
|
|
||||||
modelsDev.saveModelsDevPricing(pricing);
|
|
||||||
|
|
||||||
const first = modelsDev.getModelsDevPricing();
|
|
||||||
const second = modelsDev.getModelsDevPricing();
|
|
||||||
assert.equal(first, second, "repeated reads must return the same memoized object");
|
|
||||||
|
|
||||||
// Mutating DB under the cache must not be visible until invalidation.
|
|
||||||
const db = core.getDbInstance();
|
|
||||||
db.prepare("DELETE FROM key_value WHERE namespace = 'models_dev_pricing'").run();
|
|
||||||
assert.equal(
|
|
||||||
modelsDev.getModelsDevPricing(),
|
|
||||||
first,
|
|
||||||
"raw SQL without save/clear must not bypass the memo"
|
|
||||||
);
|
|
||||||
|
|
||||||
modelsDev.clearModelsDevPricing();
|
|
||||||
assert.deepEqual(modelsDev.getModelsDevPricing(), {});
|
|
||||||
|
|
||||||
modelsDev.saveModelsDevPricing(pricing);
|
|
||||||
const afterSave = modelsDev.getModelsDevPricing();
|
|
||||||
assert.notEqual(afterSave, first, "save must invalidate the memo");
|
|
||||||
assert.equal(afterSave.openai["gpt-4o"].input, 2.5);
|
|
||||||
|
|
||||||
// Copilot review: DB reset must invalidate the memo so import/restore doesn't serve stale pricing.
|
|
||||||
const beforeReset = modelsDev.getModelsDevPricing();
|
|
||||||
core.resetDbInstance();
|
|
||||||
const afterReset = modelsDev.getModelsDevPricing();
|
|
||||||
assert.notEqual(
|
|
||||||
afterReset,
|
|
||||||
beforeReset,
|
|
||||||
"resetDbInstance must invalidate the memo (Copilot #10055)"
|
|
||||||
);
|
|
||||||
// Data is still on disk after resetDbInstance(), but the cache was cleared and re-read from fresh DB.
|
|
||||||
assert.equal(afterReset.openai["gpt-4o"].input, 2.5, "DB reset re-reads from fresh connection");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("modelsDev capabilities helpers create the table, persist rows, filter by provider/model, and expose context limits", async () => {
|
test("modelsDev capabilities helpers create the table, persist rows, filter by provider/model, and expose context limits", async () => {
|
||||||
const modelsDev = await importFresh("capabilities-storage");
|
const modelsDev = await importFresh("capabilities-storage");
|
||||||
const capabilities = modelsDev.transformModelsDevToCapabilities(MOCK_MODELS_DEV_DATA);
|
const capabilities = modelsDev.transformModelsDevToCapabilities(MOCK_MODELS_DEV_DATA);
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import test from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { resolveHealthPath } from "../../scripts/dev/healthcheck.mjs";
|
|
||||||
|
|
||||||
// Issue #10311: the official Docker image's HEALTHCHECK (scripts/dev/healthcheck.mjs)
|
|
||||||
// must default to the lightweight lifecycle probe (/healthz) rather than the heavy
|
|
||||||
// /api/monitoring/health path (SQLite reads + deep monitoring aggregation on the same
|
|
||||||
// event loop as catalog rebuild / compression). This test asserts the expected fix.
|
|
||||||
test("resolveHealthPath defaults to the lightweight /healthz lifecycle probe, not the heavy monitoring path", () => {
|
|
||||||
assert.equal(resolveHealthPath(""), "/healthz");
|
|
||||||
assert.equal(resolveHealthPath(undefined), "/healthz");
|
|
||||||
});
|
|
||||||
@@ -15,11 +15,8 @@ import CallbackPage from "@/app/callback/page";
|
|||||||
* Regression guard for ported upstream PR decolua/9router#998 (security):
|
* Regression guard for ported upstream PR decolua/9router#998 (security):
|
||||||
* the OAuth callback page must never relay {code, state} to a wildcard
|
* the OAuth callback page must never relay {code, state} to a wildcard
|
||||||
* postMessage target ("*"), as a hostile opener can read the code/state and
|
* postMessage target ("*"), as a hostile opener can read the code/state and
|
||||||
* complete the OAuth flow as the user. Trusted targets are the same-origin
|
* complete the OAuth flow as the user. Only the same-origin parent and
|
||||||
* parent, the loopback hostname variants of the same port (localhost vs
|
* Codex's fixed loopback helper (127.0.0.1:1455) are trusted targets.
|
||||||
* 127.0.0.1 — Zed native-app redirects may land on the other spelling than the
|
|
||||||
* dashboard the modal was opened from; same port means the same OmniRoute
|
|
||||||
* server), and Codex's fixed loopback helper (127.0.0.1:1455).
|
|
||||||
*/
|
*/
|
||||||
describe("OAuth callback page — postMessage target origin scope (#998)", () => {
|
describe("OAuth callback page — postMessage target origin scope (#998)", () => {
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
@@ -84,15 +81,7 @@ describe("OAuth callback page — postMessage target origin scope (#998)", () =>
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
const loopbackSamePort = window.location.port
|
const trusted = new Set([window.location.origin, "http://localhost:1455", "http://127.0.0.1:1455"]);
|
||||||
? [`http://localhost:${window.location.port}`, `http://127.0.0.1:${window.location.port}`]
|
|
||||||
: [];
|
|
||||||
const trusted = new Set([
|
|
||||||
window.location.origin,
|
|
||||||
...loopbackSamePort,
|
|
||||||
"http://localhost:1455",
|
|
||||||
"http://127.0.0.1:1455",
|
|
||||||
]);
|
|
||||||
const targetOrigins = postMessageSpy.mock.calls.map((call) => call[1]);
|
const targetOrigins = postMessageSpy.mock.calls.map((call) => call[1]);
|
||||||
expect(targetOrigins.length).toBeGreaterThan(0);
|
expect(targetOrigins.length).toBeGreaterThan(0);
|
||||||
for (const origin of targetOrigins) {
|
for (const origin of targetOrigins) {
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
import test from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression coverage for #10517.
|
|
||||||
*
|
|
||||||
* Zed's native-app sign-in always redirects the browser to
|
|
||||||
* `http://127.0.0.1:<native_app_port>/`, ignoring any path/redirect_uri we send.
|
|
||||||
* `zed-hosted.ts::buildAuthUrl` reuses the dashboard's own loopback port as
|
|
||||||
* native_app_port so that redirect lands back on OmniRoute instead of a dead
|
|
||||||
* "site can't be reached" page.
|
|
||||||
*
|
|
||||||
* Before this fix, the port was re-derived from the browser-supplied
|
|
||||||
* `redirectUri` string (`OAuthModal.tsx`'s `window.location.port ||
|
|
||||||
* (protocol === "https:" ? "443" : "80")` fallback), which produced
|
|
||||||
* `http://127.0.0.1:443/` when the dashboard was reached over HTTPS on its
|
|
||||||
* default port (e.g. behind a local TLS-terminating reverse proxy) — a scheme
|
|
||||||
* mismatch, since nothing serves plain HTTP on 443 and Zed's redirect is
|
|
||||||
* always plain http regardless of how the browser reached the dashboard.
|
|
||||||
*
|
|
||||||
* The fix runs server-side (this code executes in the Next.js API route, not
|
|
||||||
* the browser) and derives the port from the OmniRoute process's own
|
|
||||||
* authoritative listening port (`getRuntimePorts()`, sourced from
|
|
||||||
* OMNIROUTE_PORT/PORT/DASHBOARD_PORT) once the redirect URI's hostname is
|
|
||||||
* confirmed loopback — no longer trusting the browser-observed scheme/port.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const originalEnv = {
|
|
||||||
OMNIROUTE_PORT: process.env.OMNIROUTE_PORT,
|
|
||||||
PORT: process.env.PORT,
|
|
||||||
DASHBOARD_PORT: process.env.DASHBOARD_PORT,
|
|
||||||
};
|
|
||||||
|
|
||||||
function resetPortEnv() {
|
|
||||||
delete process.env.OMNIROUTE_PORT;
|
|
||||||
delete process.env.PORT;
|
|
||||||
delete process.env.DASHBOARD_PORT;
|
|
||||||
}
|
|
||||||
|
|
||||||
test.after(() => {
|
|
||||||
resetPortEnv();
|
|
||||||
for (const [key, value] of Object.entries(originalEnv)) {
|
|
||||||
if (value !== undefined) process.env[key] = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const { __test__ } = await import("../../src/lib/oauth/providers/zed-hosted.ts");
|
|
||||||
const { resolveDashboardLoopbackPort } = __test__;
|
|
||||||
|
|
||||||
test("resolveDashboardLoopbackPort: loopback hostname over HTTPS on the default port resolves via server config, not a guessed 443", () => {
|
|
||||||
resetPortEnv();
|
|
||||||
process.env.OMNIROUTE_PORT = "20128";
|
|
||||||
|
|
||||||
// This is the exact shape OAuthModal.tsx's buggy fallback used to produce
|
|
||||||
// for the true-localhost + default-port case (scheme hardcoded to "http"
|
|
||||||
// regardless of the real protocol, port guessed from the protocol default).
|
|
||||||
// Even with a scheme/port combination that does not reflect reality, the
|
|
||||||
// hostname alone is enough — the real port comes from server config.
|
|
||||||
const port = resolveDashboardLoopbackPort("http://localhost:443/callback");
|
|
||||||
assert.equal(port, 20128, "must use the server's own configured port, never the guessed 443");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveDashboardLoopbackPort: respects OMNIROUTE_PORT override", () => {
|
|
||||||
resetPortEnv();
|
|
||||||
process.env.OMNIROUTE_PORT = "31415";
|
|
||||||
|
|
||||||
assert.equal(resolveDashboardLoopbackPort("http://127.0.0.1:20128/callback"), 31415);
|
|
||||||
assert.equal(resolveDashboardLoopbackPort("http://localhost/callback"), 31415);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveDashboardLoopbackPort: falls back to PORT then DASHBOARD_PORT precedence like getRuntimePorts", () => {
|
|
||||||
resetPortEnv();
|
|
||||||
process.env.PORT = "9000";
|
|
||||||
assert.equal(resolveDashboardLoopbackPort("http://localhost:20128/callback"), 9000);
|
|
||||||
|
|
||||||
resetPortEnv();
|
|
||||||
process.env.DASHBOARD_PORT = "9500";
|
|
||||||
assert.equal(resolveDashboardLoopbackPort("http://127.0.0.1:20128/callback"), 9500);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveDashboardLoopbackPort: IPv6 loopback literal resolves to the server port", () => {
|
|
||||||
resetPortEnv();
|
|
||||||
process.env.OMNIROUTE_PORT = "20128";
|
|
||||||
assert.equal(resolveDashboardLoopbackPort("http://[::1]:20128/callback"), 20128);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveDashboardLoopbackPort: non-loopback (remote/LAN) redirect URIs return null", () => {
|
|
||||||
resetPortEnv();
|
|
||||||
process.env.OMNIROUTE_PORT = "20128";
|
|
||||||
|
|
||||||
assert.equal(resolveDashboardLoopbackPort("https://omniroute.example.com/callback"), null);
|
|
||||||
assert.equal(resolveDashboardLoopbackPort("http://192.168.1.50:20128/callback"), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveDashboardLoopbackPort: malformed/missing redirect URIs return null", () => {
|
|
||||||
resetPortEnv();
|
|
||||||
assert.equal(resolveDashboardLoopbackPort(undefined), null);
|
|
||||||
assert.equal(resolveDashboardLoopbackPort("not a url"), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("zedHosted.buildAuthUrl: reuses the server's configured port as native_app_port for a loopback redirect, regardless of the browser-observed scheme", async () => {
|
|
||||||
resetPortEnv();
|
|
||||||
process.env.OMNIROUTE_PORT = "20128";
|
|
||||||
|
|
||||||
const { zedHosted } = await import("../../src/lib/oauth/providers/zed-hosted.ts");
|
|
||||||
const { ZED_HOSTED_CONFIG } = await import("../../src/lib/oauth/constants/oauth.ts");
|
|
||||||
|
|
||||||
// Simulate the redirect URI OAuthModal.tsx sends when the dashboard is
|
|
||||||
// reached over HTTPS on its implicit default port (window.location.port is
|
|
||||||
// empty): hostname is loopback, but scheme/port do not reflect the real
|
|
||||||
// OmniRoute listener.
|
|
||||||
const built = zedHosted.buildAuthUrl(ZED_HOSTED_CONFIG, "http://localhost:443/callback");
|
|
||||||
assert.equal(built.redirectUri, "http://127.0.0.1:20128/");
|
|
||||||
|
|
||||||
const url = new URL(built.authUrl);
|
|
||||||
assert.equal(url.searchParams.get("native_app_port"), "20128");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("zedHosted.buildAuthUrl: remote/LAN redirect URIs keep the configured default native app port", async () => {
|
|
||||||
resetPortEnv();
|
|
||||||
process.env.OMNIROUTE_PORT = "20128";
|
|
||||||
|
|
||||||
const { zedHosted } = await import("../../src/lib/oauth/providers/zed-hosted.ts");
|
|
||||||
const { ZED_HOSTED_CONFIG } = await import("../../src/lib/oauth/constants/oauth.ts");
|
|
||||||
|
|
||||||
const built = zedHosted.buildAuthUrl(ZED_HOSTED_CONFIG, "https://omniroute.example.com/callback");
|
|
||||||
assert.equal(built.redirectUri, `http://127.0.0.1:${ZED_HOSTED_CONFIG.defaultNativeAppPort}/`);
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user