Compare commits

..

5 Commits

Author SHA1 Message Date
adevwithpurpose
50b8937fe6 Merge remote-tracking branch 'origin/release/v3.8.50' into fix/10244-cliproxy-win-platform-detection
# Conflicts:
#	tests/unit/binaryManager.test.ts
2026-08-18 11:39:35 -03:00
adevwithpurpose
ca4ba987b5 fix(cliproxy): thread runtime platform as a parameter instead of re-reading os.platform()
extractZip(), installVersion(), and rollbackVersion() each independently called
os.platform() inline in their own module scope even after #10244 switched the
detection helpers to os.platform()/os.arch(). Each independent call site is its
own opportunity for a bundler to constant-fold that particular occurrence away.

Detect the runtime platform once per orchestrating call (installVersion,
downloadRelease, rollbackVersion) and thread the already-detected value down as
an explicit parameter into extractZip and the symlink/copy decisions, instead of
re-reading the global in every helper.
2026-08-17 22:40:54 -03:00
Diego Rodrigues de Sa e Souza
060a906dee Merge branch 'release/v3.8.50' into fix/10244-cliproxy-win-platform-detection 2026-08-17 11:56:53 -03:00
adevwithpurpose
6b5d82133a fix(cliproxy): use runtime platform for binary install paths
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-17 10:51:31 -03:00
adevwithpurpose
6bc9dad0ce fix(cliproxy): read os.platform()/os.arch() at runtime in binaryManager platform detection (#10244)
detectPlatform()/detectArch() read the module's process.platform/process.arch,
which Turbopack `next build` (run only on Linux) constant-folds, pruning every
Windows/arm64 branch from the published npm artifact — so the embedded CLIProxyAPI
installer downloads the Linux ELF binary on Windows. Switch to runtime os.platform()/
os.arch() calls (the repo's established anti-fold pattern) so the Windows/ARM branches
survive any build machine. Add a regression guard mocking os.platform()/os.arch() to
win32/arm64 asserting the Windows/ARM path is reachable — RED before, GREEN after.
2026-08-15 11:34:33 -03:00
43 changed files with 115 additions and 2754 deletions

View File

@@ -763,9 +763,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# CLI_CONTINUE_BIN=cn
# CLI_QODER_BIN=qoder
# CLI_QWEN_BIN=qwen
# CLI_AIDER_BIN=aider
# CLI_GOOSE_BIN=goose
# CLI_GEMINI_BIN=gemini
# CLI_AUGGIE_BIN=auggie
# AUGGIE_BIN=auggie
@@ -1805,12 +1802,6 @@ APP_LOG_TO_FILE=true
# Used by: open-sse/executors/cloudflare-ai.ts
# 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) ──
# Override the Deno Deploy REST API base used by the proxy-pool relay deployer.
# Default: https://api.deno.com/v2 (omit unless mocking).

View File

@@ -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)
- **💸 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)
- **🤖 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)
- **🧭 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)
@@ -618,28 +618,6 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
<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">
## 🌐 340 AI Providers — 90+ Free

View File

@@ -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

View File

@@ -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))

View File

@@ -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

View File

@@ -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_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.",
@@ -583,7 +582,7 @@
"src/lib/memory/retrieval.ts": "1073",
"src/lib/tailscaleTunnel.ts": "1202",
"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/analytics/charts.tsx": "1035",
"src/shared/services/cliRuntime.ts": "1122",

View File

@@ -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
server and writes the config locally.
| 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-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-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-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-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-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-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 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 |
| 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-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-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-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-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-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-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` 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-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):
@@ -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 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"
# Explicit command path: pass through whatever comes after --
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-codex`, `launch-codex` (`model_providers.omniroute.base_url`) | with `/v1` | Yes |
| `setup-qwen` (`modelProviders.openai[].baseUrl`) | with `/v1` | Yes |
| `run gemini` (`GOOGLE_GEMINI_BASE_URL`) | root | No — the SDK appends `/v1beta/models/…` |
---

View File

@@ -329,13 +329,10 @@ prefix). Traefik should route `PathPrefix(`/omniroute`)` to the container withou
`StripPrefix`, so Next.js receives `/omniroute/...` and serves assets from
`/omniroute/_next/...`.
The Docker healthcheck probes the lightweight `/healthz` lifecycle endpoint prefixed
with the active `OMNIROUTE_BASE_PATH`. `/api/monitoring/health` remains available for
human/dashboard diagnostics; to point the container HEALTHCHECK back at it (for example
for deep health enforcement), set `OMNIROUTE_HEALTHCHECK_PATH=/api/monitoring/health`.
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.
The Docker healthcheck probes `/api/monitoring/health` prefixed with the active
`OMNIROUTE_BASE_PATH`. That path is a **deep** check (DB + monitoring summary). It is
appropriate for Dockers infrequent `HEALTHCHECK`, but **not** for Kubernetes
`livenessProbe` intervals.
For orchestrators (Kubernetes, Nomad, etc.):

View File

@@ -286,26 +286,6 @@ The written profile references the inference key by env var
base Codex setup (the `[model_providers.omniroute]` block), see
[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
Each supported CLI has a remote-aware setup command (all honour the active

View File

@@ -224,10 +224,8 @@ prefiksu). Traefik powinien routować `PathPrefix(`/omniroute`)` do kontenera be
`StripPrefix`, żeby Next.js otrzymywał `/omniroute/...` i serwował assety z
`/omniroute/_next/...`.
Healthcheck Dockera sonduje lekki endpoint cyklu życia `/healthz` z prefiksem aktywnego
`OMNIROUTE_BASE_PATH`. `/api/monitoring/health` pozostaje dostępny do diagnostyki
człowieka/pulpit; aby ustawić HEALTHCHECK kontenera z powrotem na niego (np. dla
głębokiej kontroli stanu), ustaw `OMNIROUTE_HEALTHCHECK_PATH=/api/monitoring/health`.
Healthcheck Dockera sonduje `/api/monitoring/health` z prefiksem aktywnego
`OMNIROUTE_BASE_PATH`.
## Docker Compose z Caddy (HTTPS Auto-TLS)

View File

@@ -367,11 +367,11 @@ GET /v1/models?prefix=dual # both forms (server default)
GET /v1/models?prefix=canonical # only the full provider-id prefix
```
| 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. |
| `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. |
| 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. |
| `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. |
A `dual`-mode mirror can also be recognised without the query parameter: it carries a `parent`
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,
devin-cli, etc.). See [Provider Reference](./PROVIDER_REFERENCE.md) for the full list.
| Method | Path | Description |
| ------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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.) |
| POST | `/api/cli-tools/apply` | Write a tool's generated config (`dryRun` previews; `422` + `containerEphemeralTarget` when containerized; `migration` notes a legacy Codex YAML) |
| 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/[id]/restore` | Restore a CLI tool from a backup |
| 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 |
| Method | Path | Description |
| ------ | --------------------------------------- | ---------------------------------------------------------------------------------------------- |
| 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.) |
| POST | `/api/cli-tools/apply` | Apply a CLI tool configuration to a provider connection |
| 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/[id]/restore` | Restore a CLI tool from a backup |
| 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 |
**Auth:** Requires management session.

View File

@@ -349,9 +349,6 @@ npm install -g kilocode
# 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
pip install aider-chat

View File

@@ -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_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_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. |
| `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. |
@@ -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_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_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). |
| `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. |

View File

@@ -248,26 +248,6 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderConfig> = {
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": {
id: "microsoft-designer-web",
alias: "msdesigner",

View File

@@ -31,7 +31,6 @@ import { difyProvider } from "./registry/dify/index.ts";
import { ovhcloudProvider } from "./registry/ovhcloud/index.ts";
import { claudeProvider } from "./registry/claude/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 { inner_aiProvider } from "./registry/inner-ai/index.ts";
import { qoderProvider } from "./registry/qoder/index.ts";
@@ -291,7 +290,6 @@ export const REGISTRY: Record<string, RegistryEntry> = {
ovhcloud: ovhcloudProvider,
claude: claudeProvider,
"claude-web": claude_webProvider,
"cloudflare-playground": cloudflarePlaygroundProvider,
bedrock: bedrockProvider,
"inner-ai": inner_aiProvider,
qoder: qoderProvider,

View File

@@ -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)" },
],
};

View File

@@ -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(() => {});
}
}
}
}

View File

@@ -260,70 +260,6 @@ export function parseStreamResponse(raw: string): string {
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 {
if (typeof value !== "string") return "";
const trimmed = value.trim();
@@ -429,7 +365,9 @@ export class GeminiWebExecutor extends BaseExecutor {
_signal?: AbortSignal
): Promise<boolean> {
try {
const cookie = resolveGeminiWebCookie(credentials as unknown as ExecuteInput["credentials"]);
const cookie = resolveGeminiWebCookie(
credentials as unknown as ExecuteInput["credentials"]
);
if (!cookie) return false;
const pairs = parseCookies(cookie);
return pairs.some((p) => p.value.length > 0);
@@ -568,52 +506,20 @@ export class GeminiWebExecutor extends BaseExecutor {
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
let responseText = "";
const responseImages: string[] = [];
let captured = false;
const responsePromise = new Promise<void>((resolve) => {
page.on("response", async (resp: any) => {
if (!resp.url().includes("StreamGenerate")) return;
if (!imageMode && captured) return;
if (imageMode) {
// Image mode: merge text + image URLs across every frame and
// resolve as soon as an image appears (images can land in a
// later frame than the text).
try {
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();
if (captured || !resp.url().includes("StreamGenerate")) return;
captured = true;
try {
const raw = await resp.text();
responseText = parseStreamResponse(raw);
} catch {
/* ignore */
}
resolve();
});
});
@@ -632,36 +538,12 @@ export class GeminiWebExecutor extends BaseExecutor {
await page.waitForTimeout(300);
await page.keyboard.press("Enter");
// Wait for response or timeout. Image generation (Nano Banana) is
// noticeably slower than text — the UI renders the asset only after
// the full generation completes — so image mode gets a wider window.
await Promise.race([responsePromise, page.waitForTimeout(imageMode ? 90000 : 30000)]);
// Wait for response or timeout
await Promise.race([responsePromise, page.waitForTimeout(30000)]);
if (signal?.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) {
return {
response: new Response(JSON.stringify({ error: "No response from Gemini" }), {

View File

@@ -71,7 +71,6 @@ import { LMArenaExecutor } from "./lmarena.ts";
import { GrokCliExecutor } from "./grok-cli.ts";
import { CodeBuddyCnExecutor } from "./codebuddy-cn.ts";
import { ZenmuxFreeExecutor } from "./zenmux-free.ts";
import { CloudflarePlaygroundExecutor } from "./cloudflare-playground.ts";
import { TinyCmsExecutor } from "./tinycms.ts";
import { HyperAgentExecutor } from "./hyperagent.ts";
import { XaiExecutor } from "./xai.ts";
@@ -205,8 +204,6 @@ const executors = {
"codebuddy-cn": new CodeBuddyCnExecutor(),
cbcn: new CodeBuddyCnExecutor(), // Alias for codebuddy-cn
"zenmux-free": new ZenmuxFreeExecutor(),
"cloudflare-playground": new CloudflarePlaygroundExecutor(),
cfp: new CloudflarePlaygroundExecutor(), // Alias for cloudflare-playground
"tinycms-web": new TinyCmsExecutor(),
tcw: new TinyCmsExecutor(), // Alias
hyperagent: new HyperAgentExecutor(),
@@ -326,7 +323,6 @@ export { LMArenaExecutor } from "./lmarena.ts";
export { GrokCliExecutor } from "./grok-cli.ts";
export { CodeBuddyCnExecutor } from "./codebuddy-cn.ts";
export { ZenmuxFreeExecutor } from "./zenmux-free.ts";
export { CloudflarePlaygroundExecutor } from "./cloudflare-playground.ts";
export { TinyCmsExecutor } from "./tinycms.ts";
export { HyperAgentExecutor } from "./hyperagent.ts";
export { XaiExecutor } from "./xai.ts";

View File

@@ -51,7 +51,6 @@ import {
extractMarkdownImageUrls,
CHATGPT_WEB_IMAGE_ID_RE,
} from "./imageGeneration/providers/chatgptWeb.ts";
import { handleGeminiWebImageGeneration } from "./imageGeneration/providers/geminiWeb.ts";
import { handleNvidiaNimImageGeneration } from "./imageGeneration/providers/nvidiaNim.ts";
import { handleSegmindImageGeneration } from "./imageGeneration/providers/segmind.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") {
return handleDesignerWebImageGeneration({
model,
@@ -2697,22 +2683,6 @@ export function saveImageErrorResult({
error,
requestBody = null,
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({
method: "POST",
@@ -2729,7 +2699,6 @@ export function saveImageErrorResult({
success: false,
status,
error,
...(retryable !== undefined ? { retryable } : {}),
};
}

View File

@@ -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,
});
}

View File

@@ -800,11 +800,7 @@ function canRunAtCompressionStage(
stage: CompressionStage | undefined
): boolean {
const effectiveStage = stage ?? "pre-translation";
// `assertValidEngine` não exige `metadata`, então uma engine registrada sem
// esse campo é legal — e sem a guarda derrubava o pipeline inteiro com
// TypeError em vez de falhar aberto. Metadata ausente é o mesmo caso de "não
// declarou estágio" e cai no mesmo fallback: só pre-translation.
const stages = engine.metadata?.executionStages;
const stages = engine.metadata.executionStages;
return stages ? stages.includes(effectiveStage) : effectiveStage === "pre-translation";
}

View File

@@ -8,15 +8,6 @@
* event loop is busy (#10052) and can restart the only replica mid-session.
* 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
* 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
@@ -44,34 +35,10 @@ function normalizeBasePath(value) {
return `/${segments.join("/")}`;
}
/**
* Normalize an explicit health-check path override (OMNIROUTE_HEALTHCHECK_PATH).
* 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) {
/** Prefixes the health route with the configured Next.js basePath. */
export function resolveHealthPath(basePathValue) {
const basePath = normalizeBasePath(basePathValue);
const healthPath = normalizeHealthPath(healthPathValue) || DEFAULT_HEALTH_PATH;
return basePath ? `${basePath}${healthPath}` : healthPath;
return basePath ? `${basePath}${DEFAULT_HEALTH_PATH}` : DEFAULT_HEALTH_PATH;
}
/**
@@ -151,10 +118,7 @@ async function main() {
}
try {
const healthPath = resolveHealthPath(
process.env.OMNIROUTE_BASE_PATH,
process.env.OMNIROUTE_HEALTHCHECK_PATH
);
const healthPath = resolveHealthPath(process.env.OMNIROUTE_BASE_PATH);
await probeHealth({ port, hosts, healthPath });
process.exit(0);
} catch (err) {

View File

@@ -23,21 +23,11 @@ export default function CallbackPage() {
useEffect(() => {
const params = new URLSearchParams(window.location.search);
let code = params.get("code");
const code = params.get("code");
const state = params.get("state");
const error = params.get("error");
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 = {
code,
state,
@@ -73,13 +63,6 @@ export default function CallbackPage() {
// same-origin fallback when the opener was severed by COOP.
const trustedTargetOrigins = [
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://127.0.0.1:1455", // Same Codex helper, IPv4 literal form.
];

View File

@@ -1,31 +1,5 @@
import { redirect } from "next/navigation";
/**
* 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()}`);
}
export default function InitPage() {
redirect("/dashboard");
}

View File

@@ -1,5 +1,4 @@
import { ZED_HOSTED_CONFIG } from "../constants/oauth";
import { getRuntimePorts } from "../../runtime/ports";
import {
createZedNativeAuthData,
parseZedCallbackPayload,
@@ -22,55 +21,15 @@ import {
*
* `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
* redirects to loopback + native_app_port, ignoring any path we'd send. When
* the dashboard itself listens on a loopback port, `buildAuthUrl` reuses it as
* native_app_port so the redirect lands back on OmniRoute (auto-completed via
* the /callback relay); otherwise the dead default port is used and the user
* completes the flow by pasting the browser's full URL.
* redirects to loopback + native_app_port, ignoring any `redirect_uri` we'd
* send, so `redirectUri` here is unused by exchangeToken (kept only to
* satisfy OAuthModal's generic "session must have a redirectUri" guard).
*/
/**
* 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 = {
config: ZED_HOSTED_CONFIG,
flowType: "authorization_code",
buildAuthUrl: (config: typeof ZED_HOSTED_CONFIG, redirectUri?: string) => {
const nativeAppPort =
resolveDashboardLoopbackPort(redirectUri) || config.defaultNativeAppPort || 58443;
const authData = createZedNativeAuthData(config, { nativeAppPort });
buildAuthUrl: (config: typeof ZED_HOSTED_CONFIG) => {
const authData = createZedNativeAuthData(config);
return {
authUrl: authData.authUrl,
codeVerifier: authData.privateKeyVerifier,

View File

@@ -456,17 +456,11 @@ export default function OAuthModal({
// Claude Code and Cline OAuth flows can finish on provider-hosted pages that
// 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.
// zed-hosted's native-app sign-in redirects the browser to a local
// 127.0.0.1:<native_app_port> callback. On true localhost that port IS the
// dashboard's own (buildAuthUrl reuses it), so the redirect lands on the
// /callback relay and the popup flow auto-completes. Elsewhere (LAN/remote)
// the port is unreachable — nothing can auto-close the popup, so always
// show the manual paste-URL input.
if (
provider === "claude" ||
provider === "cline" ||
(provider === "zed-hosted" && !isTrueLocalhost)
) {
// zed-hosted's native-app sign-in always redirects the browser to a local
// 127.0.0.1:<port> callback that OmniRoute never listens on (the port is
// arbitrary and unrelated to the dashboard's own port) — nothing can
// auto-close the popup, so always show the manual paste-URL input.
if (provider === "claude" || provider === "cline" || provider === "zed-hosted") {
forceManual = true;
}
@@ -886,17 +880,6 @@ export default function OAuthModal({
}
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 state = authData?.state || null;
let errorParam = null;

View File

@@ -364,23 +364,13 @@ export function OAuthManualInputPanel({
code: (chunks) => <code className="font-mono">{chunks}</code>,
})}
</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:&lt;port&gt;/?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
value={callbackUrl}
onChange={(event) => onCallbackUrlChange(event.target.value)}
placeholder={
provider === "claude" || provider === "cline"
? "code#state or /callback?code=..."
: provider === "zed-hosted"
? "http://127.0.0.1:<port>/?user_id=...&access_token=..."
: placeholderUrl
: placeholderUrl
}
className="font-mono text-xs"
/>

View File

@@ -55,25 +55,6 @@ export const NOAUTH_PROVIDERS = {
// #7286: tools[] is prompt-emulated via webTools.ts (parseToolCallsFromText).
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": {
id: "felo-web",
alias: "felo",

View File

@@ -9,14 +9,6 @@ interface ImageGenerationResult {
status?: number;
error?: 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 {
@@ -24,14 +16,6 @@ interface ImageCredentialRetryOptions {
requestedModel: string | null;
credentials: any;
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 {
@@ -50,7 +34,7 @@ function isCredentialSentinel(credentials: any): boolean {
return Boolean(credentials?.allRateLimited || credentials?.allExpired);
}
async function defaultSelectNextCredentials(
async function selectNextCredentials(
provider: string,
requestedModel: string | null,
excludedConnectionIds: Set<string>
@@ -72,7 +56,6 @@ export async function executeImageWithCredentialFallback({
requestedModel,
credentials,
execute,
selectNextCredentials = defaultSelectNextCredentials,
}: ImageCredentialRetryOptions): Promise<ImageCredentialRetryResult> {
// Local/no-auth image providers intentionally have no credential row. They
// still need one direct attempt, but there is no account identity to refresh
@@ -110,8 +93,7 @@ export async function executeImageWithCredentialFallback({
lastCredentials = currentCredentials;
lastResult = await execute(currentCredentials);
const isAuthFailure = Number(lastResult.status) === 401 || lastResult.retryable === true;
if (lastResult.success || !isAuthFailure || !connectionId) {
if (lastResult.success || Number(lastResult.status) !== 401 || !connectionId) {
return { credentials: lastCredentials, result: lastResult };
}

View File

@@ -134,57 +134,45 @@ function runSmoke(target: string): Promise<SmokeResult> {
child.stdout.on("data", (c) => (stdout += String(c)));
child.stderr.on("data", (c) => (stderr += String(c)));
const timer = setTimeout(() => child.kill("SIGKILL"), TIMEOUT_MS);
// Resolve on "exit", not "close": some CLIs leave grandchildren holding the
// stdio pipes after the parent dies, and "close" would wait on them forever.
child.on("exit", (code) => {
child.on("close", (code) => {
clearTimeout(timer);
setTimeout(() => {
const combined = redact(stdout + "\n" + stderr);
resolve({
exitCode: code,
stdout: redact(stdout),
stderr: redact(stderr),
classification: classify(code, combined),
});
}, 250); // small grace period to flush buffered output
const combined = redact(stdout + "\n" + stderr);
resolve({
exitCode: code,
stdout: redact(stdout),
stderr: redact(stderr),
classification: classify(code, combined),
});
});
});
}
// NOTE: node:test treats `timeout: 0` as "time out immediately", not "no
// timeout" — size the budget from the per-target cap instead.
const SWEEP_TIMEOUT_MS = (Object.keys(SMOKE_TARGETS).length + 1) * (TIMEOUT_MS + 30_000);
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)}`
);
});
}
test("upstream CLI smoke sweep (opt-in via RUN_CLI_SMOKE=1)", { timeout: 0 }, 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)}`
);
});
}
});

View File

@@ -1196,29 +1196,6 @@
"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": {
"format": "openai",
"headers": {

View File

@@ -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");
});

View File

@@ -1,72 +0,0 @@
/**
* Gate de estágio × engine sem `metadata`.
*
* `assertValidEngine()` (engines/registry.ts) valida `id`, `apply`, `compress`,
* `getConfigSchema` e `validateConfig` — NÃO exige `metadata`. Uma engine sem
* esse campo é, portanto, um registro legal. Mas `canRunAtCompressionStage`
* lia `engine.metadata.executionStages` sem guarda, então essa engine legal
* derrubava o pipeline inteiro com `TypeError: Cannot read properties of
* undefined` — em vez de falhar aberto, que é o contrato da compressão.
*
* O fallback documentado para quem não declara `executionStages` é "só
* pre-translation". Metadata ausente é o mesmo caso de "não declarou", e tem de
* cair no mesmo fallback.
*/
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import {
registerCompressionEngine,
unregisterCompressionEngine,
} from "../../../open-sse/services/compression/engines/registry.ts";
import { applyStackedCompression } from "../../../open-sse/services/compression/strategySelector.ts";
import type { CompressionEngine } from "../../../open-sse/services/compression/engines/types.ts";
const ENGINE_ID = "metadata-less-test-engine";
const body = () => ({ messages: [{ role: "user", content: "hello world" }] });
describe("stage gate — engine sem metadata", () => {
beforeEach(() => {
// Registro deliberadamente sem `metadata`: é o que assertValidEngine aceita.
registerCompressionEngine({
id: ENGINE_ID,
name: "metadata-less test engine",
targets: ["messages"],
stackable: true,
apply(input: Record<string, unknown>) {
return { body: input, compressed: false, stats: null };
},
compress() {
return { text: "", stats: null };
},
getConfigSchema() {
return [];
},
validateConfig() {
return { valid: true, errors: [] };
},
} as unknown as CompressionEngine);
});
afterEach(() => unregisterCompressionEngine(ENGINE_ID));
it("não derruba o pipeline no estágio pré-tradução (default)", () => {
const r = applyStackedCompression(body(), [{ engine: ENGINE_ID }]);
assert.equal(r.compressed, false);
assert.deepEqual(r.body, body());
});
it("não derruba o pipeline no estágio pós-tradução — pula pelo fallback", () => {
const r = applyStackedCompression(body(), [{ engine: ENGINE_ID }], {
compressionStage: "post-translation",
});
assert.equal(r.compressed, false);
assert.deepEqual(r.body, body());
// Fallback documentado: quem não declara estágio só roda pre-translation.
assert.ok(
r.stats?.validationWarnings?.some((w) => w.includes(ENGINE_ID) && w.includes("skipped")),
`esperado aviso de skip por estágio, veio ${JSON.stringify(r.stats?.validationWarnings)}`
);
});
});

View File

@@ -2,7 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
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(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");
});
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");
});

View File

@@ -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", () => {
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"
);
const runner = fs.readFileSync(
@@ -57,7 +57,7 @@ test("uses the live next-free migration slot without runner compatibility specia
"utf8"
);
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", () => {

View File

@@ -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;
}
});

View File

@@ -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");
});

View File

@@ -261,45 +261,6 @@ test.describe("modelsDevSync-extended", { concurrency: 1 }, async () => {
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 () => {
const modelsDev = await importFresh("capabilities-storage");
const capabilities = modelsDev.transformModelsDevToCapabilities(MOCK_MODELS_DEV_DATA);

View File

@@ -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");
});

View File

@@ -15,11 +15,8 @@ import CallbackPage from "@/app/callback/page";
* Regression guard for ported upstream PR decolua/9router#998 (security):
* the OAuth callback page must never relay {code, state} to a wildcard
* 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
* parent, the loopback hostname variants of the same port (localhost vs
* 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).
* complete the OAuth flow as the user. Only the same-origin parent and
* Codex's fixed loopback helper (127.0.0.1:1455) are trusted targets.
*/
describe("OAuth callback page — postMessage target origin scope (#998)", () => {
let container: HTMLDivElement;
@@ -84,15 +81,7 @@ describe("OAuth callback page — postMessage target origin scope (#998)", () =>
await Promise.resolve();
});
const loopbackSamePort = window.location.port
? [`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 trusted = new Set([window.location.origin, "http://localhost:1455", "http://127.0.0.1:1455"]);
const targetOrigins = postMessageSpy.mock.calls.map((call) => call[1]);
expect(targetOrigins.length).toBeGreaterThan(0);
for (const origin of targetOrigins) {

View File

@@ -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}/`);
});