#4023 added validateWebCookieProvider (generic /models session ping -> AUTH_007
SESSION_EXPIRED on 401/403) and dispatched ALL web-cookie providers to it at the TOP
of validateProviderApiKey, BEFORE the SPECIALTY_VALIDATORS table. That shadowed the
rich per-provider validators (validateGrokWebProvider with #3474 IP-reputation/
Cloudflare guidance, validateChatGptWebProvider cf-mitigated, claude/gemini/copilot/
qwen/t3-web), which became dead code and broke all 41 web-cookie assertions in
provider-validation-specialty.test.ts (latent on release/v3.8.32; surfaced by a
__RUN_ALL__ unit run).
Move the generic dispatch to AFTER SPECIALTY_VALIDATORS so it is a FALLBACK only for
web-cookie providers without a dedicated validator. Rich validators run first
(provider-validation-specialty 112/112 restored); the generic + its AUTH_007
capability are preserved (web-cookie-auth007 stays 5/5, it calls the function directly).
Rebaselines validation.ts file-size 4518->4522 (+4, justified). Note: the sibling
pricing half of this restore-green already landed via #4447; this PR carries only the
stranded web-cookie validator fix.
* fix(cli): non-interactive-safe prompts + singular `context` alias
Two CLI-polish follow-ups (the third — an async-warn refactor for #2807 — is moot:
#4373 replaced the Gemini remote-URL drop+warn with fileData pass-through, so no
async warn remains).
1) createPrompt (io.mjs) — the shared interactive helper used rl.question with no
EOF guard, so a non-interactive stdin (pipe, CI, `< /dev/null`) left the await
pending and Node warned about an 'unsettled top-level await' while the command
hung. ask/askSecret now resolve on the readline `close` event (fired on EOF)
with the default / empty string. A genuinely piped line still arrives via the
question callback first, so `echo value | omniroute …` keeps working — only the
no-input EOF case falls back. This fixes every command that prompts, centrally
(mirrors the contexts `confirm()` fix from #4397).
2) `contexts` gains a singular `context` alias — the connect output and older docs
said `omniroute context current`; the alias keeps that muscle-memory working.
Tests: cli-io.test.ts (ask default/empty + askSecret resolve on EOF, no hang) and a
cli-contexts.test.ts case asserting the `context` alias is registered. 11/11.
* test(cli): mock .alias() in the fake program for the contexts subcommand test
The existing 'registers a current subcommand' test uses a minimal fake commander
program; registerContexts now calls .alias("context"), so the fake needs an alias()
stub (returns this) or the chain throws. Add it. (Self-introduced by the alias in
this branch; my 4 new tests already pass in CI.)
* docs(compression): design spec for the unified compression config panel
Engine-centric central panel (single source for master + per-engine on/off + level),
default pipeline derived from the engines map; Combos page owns named ordered pipelines +
active-profile selection; per-engine pages keep only detailed config. Phased: (1) core
consolidation + Model A migration, (2) named profiles + active selector, (3) per-request
x-omniroute-compression header.
* docs(compression): Phase 1 implementation plan for the unified config panel
12 TDD tasks: engine catalog, engines map + activeComboId, deriveDefaultPlan,
migration 102 + backfill, resolveCompressionPlan, runtime wiring, API, default-combo
shim, the engine-grid panel, consolidation, menu. Ends with a recap + the pending
follow-ups (Phase 2 named profiles/active selector, Phase 3 header, deferred items).
* feat(compression): engine catalog metadata (levels, single-mode, order)
* feat(compression): add engines map + activeComboId to CompressionConfig
* feat(compression): deriveDefaultPlan (engines map -> mode/pipeline)
Pure function that converts a per-engine EngineToggle map + masterEnabled
flag into a { mode, stackedPipeline } plan: off when master is off or no
engines on; single-mode when exactly one single-mode engine is enabled;
stacked (sorted by stackPriority) otherwise.
* feat(compression): persist+backfill engines map and activeComboId (migration 102)
* feat(compression): resolveCompressionPlan precedence resolver (header>override>active>default)
* feat(compression): selectCompressionStrategy uses resolveCompressionPlan
* feat(api): settings/compression carries engines map + activeComboId
Extends compressionSettingsUpdateSchema with engines (Record<string,{enabled:boolean,level?:string}>)
and activeComboId (string|null) so the PUT route accepts and persists these fields.
GET already returns the full getCompressionSettings() object which includes both fields.
TDD: added route round-trip tests (PUT+GET) for engines, activeComboId, null clear,
and schema rejection of invalid engines shape.
* refactor(compression): default-combo route is a read-only shim (default derived from engines)
* feat(dashboard): engine-grid compression panel (single source for on/off + level)
* refactor(dashboard): remove duplicate compression toggles; per-engine pages keep only detailed config
* feat(compression): unified panel menu order + derived-pipeline integration coverage
* fix(compression): import ENGINE_IDS via types re-export so it resolves under vitest
The bare "@omniroute/open-sse/.../engineCatalog.ts" specifier resolves under tsc/tsx
but not under vitest's MCP config: Vite externalizes a brand-new open-sse module to
Node, which can't load the .ts subpath. types.ts is already in Vite's graph, so route
ENGINE_IDS through its re-export. Fixes 3 failing MCP vitest suites (cacheTools,
dbHealthTool, essentialTools).
* fix(compression): engines map drives dispatch only when explicitly panel-saved
Code-review finding: the legacy seeded default combo (present on every install via
migration 042/043) was silently overriding a panel-configured engines map — the
default-combo block in chatCore set compressionComboApplied=true, skipping the
engines-map override, so an operator's panel toggles were ignored.
Gate the engines-map path on a new runtime-only CompressionConfig.enginesExplicit
(true when a stored engines row exists). Panel-saved installs: the engines map is
authoritative (deriveDefaultPlanFromConfig + new enginesMapDerivesStackedPipeline
guard the chatCore default-combo block). Legacy/backfilled installs: the map is
display-only and dispatch stays on the historical defaultMode/default-combo path —
zero behaviour change until the operator opts in via the panel.
Also documents why resolveCacheAwareConfig's getCacheAwareStrategy(config.defaultMode)
arg is safe (skipSystemPrompt is mode-independent).
* docs(compression): add MDX frontmatter + escape inline brace to fix fumadocs build
docs/compression/**/*.md is compiled as MDX by the Next build (fumadocs,
source.config.ts). The two planning docs lacked the required `title`
frontmatter (build error: 'title: expected string, received undefined') and the
design doc had a bare {rtk,caveman} that MDX parsed as a JSX expression. Adds
frontmatter matching the sibling docs and backticks the brace. Fixes the
dast-smoke build step.
Future-reference 'Quick end-to-end check' in REMOTE-MODE.md: connect -> mint scoped
token -> route a command -> switch back -> tear down, with a placeholder host. Makes
explicit (and verified live) that `contexts remove` is LOCAL-only — it drops the saved
credential but does NOT revoke the server-side token; use `tokens revoke <id>` to kill
access. Also documents that `--yes` is required for non-interactive removal and that
removing the active context falls back to default.
A client (e.g. Fabric-AI) calling POST /v1/responses with `input: []`
used to be translated into `messages: []`, which every upstream Chat-
Completions provider rejects with `400: at least one message is required`
(surfaced to the client as a confusing 406).
Mirror the existing empty-string handling: when input[] is empty, inject
a single placeholder user message so the request is always valid. Touches
`openaiResponsesToOpenAIRequest` only (the same translator that already
covers the empty-string case at the top of the function), preserves any
caller-supplied `instructions` as a system message, and leaves every
non-empty input path byte-identical.
Adds a TDD test reproducing the empty input[] case (RED before fix, GREEN
after) and updates one pre-existing test that enshrined the broken
behavior (`assert.deepEqual(call.body.messages, [])`) to assert the new
correct shape.
Inspired-by: https://github.com/decolua/9router/pull/419
Co-authored-by: anuragg-saxenaa <anuragg.saxenaa@gmail.com>
OmniRoute already references gpt-4.1-mini, gpt-4.1-nano, o3-mini, and o4-mini
throughout sibling subsystems (cost estimator, modelCapabilities, taskFitness,
free-model catalog, multiple aggregator registries) but the direct openai
provider entry exposes only o3/gpt-4.1. Symmetrically, the gemini entry has
gemini-2.0-flash and gemini-3.1-flash-lite-preview but not the lite/lite-preview
siblings that pair with them. Inspired by an upstream effort to refresh both
static lists; scope is intentionally minimal:
- ADD openai: gpt-4.1-mini, gpt-4.1-nano, o3-mini, o4-mini
(o3-mini/o4-mini get REASONING_UNSUPPORTED, matching the o3 entry)
- ADD gemini: gemini-2.0-flash-lite, gemini-3-flash-lite-preview
SKIP (already present elsewhere — kept in their typed registries):
- TTS (tts-1, tts-1-hd, gpt-4o-mini-tts) -> audioRegistry.ts
- OpenAI embeddings (text-embedding-3-small/-large/ada-002) -> embeddingRegistry.ts
- Gemini embeddings (text-embedding-00*, gemini-embedding-*) -> embeddingRegistry.ts
- gemini-3.1-flash-image-preview -> imageRegistry.ts
SKIP (deliberate OmniRoute curation):
- gpt-5/gpt-5-mini/gpt-5-nano/gpt-5.1/gpt-5.2 (superseded by gpt-5.4 series)
- o1, o1-mini (superseded by o3)
- gpt-4-turbo (legacy; gpt-4o is the canonical mid-tier)
- o3-pro (no references anywhere in OmniRoute)
TDD: tests/unit/provider-registry-openai-gemini-expanded-models.test.ts
asserts the new ids and pins the existing ids so a future refactor cannot
silently drop them.
Inspired-by: https://github.com/decolua/9router/pull/398
Co-authored-by: Ibrahim Ryan <ryan@nuevanext.com>
api-airforce carries a real live https://api.airforce/v1/models catalog but was
left out of NAMED_OPENAI_STYLE_PROVIDERS, so the import route served its small
hardcoded seed (grok-3, grok-2-1212, claude-3.7-sonnet …) — models that no
longer exist upstream, so chat failed even though the connection test passed.
Add api-airforce to NAMED_OPENAI_STYLE_PROVIDERS (same shape as #4249/#4202/#3976
and the provider-model-sweep rows) so import does a live <baseUrl>/models fetch.
The registry seed stays as the offline fallback, so import never breaks if the
upstream is unreachable.
TDD: extends tests/unit/provider-sweep-live-discovery.test.ts (red proven: the
api-airforce case did not probe upstream and served local_catalog).
Segundo incremento da decomposição do chatCore.ts (5127 LOC). Extrai a primeira
fatia PURA da fase de request-setup do handleChatCore: a metadata de roteamento de
modelo (apiFormat + targetFormat via structural-narrowing sem 'as any', + o
requestedModel com fallback) — para um leaf testável em chatCore/requestSetup.ts.
- resolveChatCoreRequestSetup(modelInfo, body, model): ChatCoreRequestSetup — puro,
byte-idêntico ao bloco inline. Tipo ChatCoreRequestSetup é a semente do futuro
ChatCoreContext (as fases maiores — setup/dispatch/streaming — virão depois).
- chatCore.ts: bloco inline (~21 ln) vira 5 linhas.
- 5 testes de caracterização (narrowing + fallback do requestedModel).
185/185 chatcore tests (caracterização do handleChatCore preservada), typecheck 0,
complexity OK 1896 (mudança neutra), file-size encolhe.
Timing: feito como leaf PURO seguro porque a v3.8.30 está finalizando — o
ChatCoreContext mutável (carrega body + 20 inputs no hot-path) cabe melhor em v3.8.31.
Follow-up: atribuir requestSetup.ts a um batch de mutação no nightly (como os demais
chatCore leaves); 5 testes cobrem as branches por ora.
The pool tools (omniroute_pool_status/sessions/reset/warm/health) shipped in
open-sse/mcp-server/tools/poolTools.ts but were never imported or registered in
server.ts, so they were defined-but-dead — the PR4 observability step of the
#3368 web-session roadmap was one wiring step from live.
Wire them through the standard registration loop (import + tool-count tally +
RESERVED_MCP_NAMES + Object.values(poolTools).forEach), add per-tool scopes
(read:health for status/sessions/health, write:resilience for reset/warm) to
both the inline tool defs and the canonical MCP_TOOL_SCOPES map. The forEach is
typed structurally (no new no-explicit-any) since the shape is pinned by tests.
Tests: extend mcp-tool-collections-shape with poolTools; add a dedicated guard
pinning the server.ts wiring (import/registration/reserved-name), the scope
contract (inline == MCP_TOOL_SCOPES, all in MCP_SCOPE_LIST), and live handler
behavior against the in-memory PoolRegistry.
Round-robin combo members deep-queued under concurrency saturation: the
per-model rate-limit semaphore had an unbounded queue and only emitted
SEMAPHORE_TIMEOUT after the full queueTimeoutMs (default 30s), so failover to
the next combo member happened far too late (or the client died first).
The per-model semaphore now accepts a bounded queue depth and rejects with
SEMAPHORE_QUEUE_FULL once the queue is full — the round-robin loop already
cascades to the next member on that code, so a low depth fails over immediately.
A new `queueDepth` combo-config knob (global default / provider override /
per-combo; default 20 for backward compatibility, 0 = never queue) is plumbed
via a resolveComboQueueDepth helper and surfaced in Settings → Combo Defaults.
TDD: rateLimitSemaphore.test.ts (bounded queue + SEMAPHORE_QUEUE_FULL, RED
before the maxQueueSize cap) and combo-config.test.ts (queueDepth cascade,
helper clamps, schema range).
Co-authored-by: KooshaPari <KooshaPari@users.noreply.github.com>
* chore(release): open v3.8.31 development cycle
* fix(mitm): exact host membership in MITM hosts test (CodeQL false positive) (#4386)
getMitmToolHosts returns string[], so .includes(host) is Array.prototype.includes
(exact membership). CodeQL's js/incomplete-url-substring-sanitization heuristic
misreads it as a String.includes() URL-substring sanitization check and raises a
HIGH alert. Switch to .some(h => h === host) — identical semantics, explicit intent,
no flagged pattern. Surfaced post-v3.8.30 (#4325) once the test landed on main.
Test-only change (no runtime behavior); the suite still passes and the CodeQL
re-scan on merge clears the alert.
* fix(codex): request reasoning summaries (#4359)
Adds reasoning.summary=auto + reasoning.encrypted_content include for Codex. Thanks @xz-dev.
* fix(embeddings): inject NVIDIA NIM input_type for asymmetric embed models (#4341)
NVIDIA NIM asymmetric embedding models (e.g. nvidia/nv-embedqa-e5-v5) reject
requests without an `input_type` ("query" | "passage") with 400 "'input_type'
parameter is required". The embedding registry now carries a model-level
default param for the asymmetric NVIDIA model, and the embeddings handler
injects a model's default params into the upstream body only when the client
omitted them, leaving a client-supplied value untouched.
Reported-by: hydraromania (https://github.com/decolua/9router/issues/1378)
Co-authored-by: hydraromania <252583922+hydraromania@users.noreply.github.com>
* fix(api): migrate deprecated Codex [features].codex_hooks to [features].hooks (#4342)
Codex renamed the `codex_hooks` feature flag to `hooks`; recent Codex CLI
versions ignore the old key and warn. When OmniRoute rewrites an existing
config.toml (configure/reset Codex provider) it now renames
[features].codex_hooks -> [features].hooks, preserving the value and never
clobbering an already-present `hooks`, then drops the deprecated key. The
migration is a no-op when the flag is absent and runs on both the POST and
DELETE config paths.
Reported-by: Bian-Sh (https://github.com/decolua/9router/issues/1327)
Co-authored-by: Bian-Sh <24520547+Bian-Sh@users.noreply.github.com>
* fix(translator): drop the null flush on the same-format response path (#4344)
The streaming response translator's same-format fast path returned
`[chunk]` unconditionally, so the end-of-stream null/flush signal
(chunk === null) propagated as a literal `[null]`. Downstream this surfaced
as an empty `data: null` SSE event between chunks and crashed strict clients
(e.g. Factory Droid BYOK on /v1/responses). The fast path now returns `[]`
for the null flush while still passing real chunks through unchanged.
Reported-by: thaitryhand (https://github.com/decolua/9router/issues/1052)
Co-authored-by: thaitryhand <248103256+thaitryhand@users.noreply.github.com>
* fix(translator): strip assistant echo fields on the OpenAI target path (Mistral 422) (#4350)
Strict OpenAI-compatible upstreams (e.g. mistral/codestral-latest) reject
client-only assistant echo fields sent back as input with 422
extra_forbidden (the report hit messages[].assistant.reasoning_content via
Codex /responses). Only reasoning_content was stripped on the OpenAI target
path; the sibling fields reasoning / refusal / annotations / cache_control
leaked through. They are now all dropped on the non-reasoner OpenAI target
path. `audio` is intentionally preserved (OpenAI audio models reference a
prior assistant audio response by id; Mistral never emits audio).
Reported-by: xxy9468615 (https://github.com/decolua/9router/issues/1649)
Co-authored-by: xxy9468615 <63351664+xxy9468615@users.noreply.github.com>
* fix(cli): honor TAILSCALE_AUTHKEY for non-interactive tailscale login (#4343)
* fix(cli): honor TAILSCALE_AUTHKEY for non-interactive tailscale login (port from 9router#1263)
startTailscaleLogin built `tailscale up` without ever reading
process.env.TAILSCALE_AUTHKEY, so a pre-authenticated / headless daemon
waited for an interactive auth URL and timed out (~15s). When
TAILSCALE_AUTHKEY is set it is now passed via `--auth-key=` (an argv element
to spawn(binary, args) — no shell interpolation, Hard Rule #13); when unset,
behavior is unchanged. The arg builder is extracted into a pure exported
`tailscaleUpArgs()` for testing.
Reported-by: ipeterpetrus (https://github.com/decolua/9router/issues/1263)
Co-authored-by: ipeterpetrus <93033698+ipeterpetrus@users.noreply.github.com>
* chore(quality): rebaseline tailscaleTunnel.ts file-size to 1202 (#1263 +13)
---------
Co-authored-by: ipeterpetrus <93033698+ipeterpetrus@users.noreply.github.com>
* fix(dashboard): OAuth modal surfaces the real error on a non-JSON response (#4351)
* fix(dashboard): OAuth modal surfaces real error on non-JSON responses (port from 9router#1318)
The OAuth connect/reauth modal called `await res.json()` unconditionally, so
a non-JSON error response (e.g. a plain-text 500 page from a build/OAuth
endpoint) threw `Unexpected token 'I'...` and hid the real failure. New
shared helpers parseResponseBody / getErrorMessage (src/shared/utils/api.ts)
read the body safely (JSON when JSON, raw text otherwise) and produce a clean
message either way; every modal fetch site now uses them.
Reported-by: DNNYF (https://github.com/decolua/9router/issues/1318)
Co-authored-by: DNNYF <74033321+DNNYF@users.noreply.github.com>
* fix(dashboard): type OAuth modal response body as Record<string, unknown> (t11 any-budget)
Switch the parseResponseBody casts from Record<string, any> to
Record<string, unknown> so OAuthModal.tsx stays within its t11 explicit-any
budget. getErrorMessage already takes unknown; the success paths typecheck
clean under strict:false. No runtime change.
---------
Co-authored-by: DNNYF <74033321+DNNYF@users.noreply.github.com>
* fix(translator): accept AI SDK-style { type: image, image: data-URL } content parts (#4345)
* fix(translator): accept AI SDK-style { type: image, image: "data:..." } parts (port from 9router#1330)
Several OpenAI-input translators only recognized images shaped as
`image_url.url` (or an object with `.source`/`.url`), so an AI SDK-style
content part where `image` is a bare data-URL STRING was silently dropped
before reaching a vision provider (OpenCode is one affected client; the gap
is generic). The OpenAI->Claude, OpenAI->Kiro and OpenAI->Gemini/Antigravity
translators now parse a string `image` data URL into each provider's native
image shape (Claude base64 source, Kiro images[].source.bytes, Gemini
inlineData).
Reported-by: mugnimaestra (https://github.com/decolua/9router/issues/1330)
Co-authored-by: mugnimaestra <13349159+mugnimaestra@users.noreply.github.com>
* chore(quality): freeze openai-to-kiro.ts file-size at 807 (#1330 +9, over 800 cap)
---------
Co-authored-by: mugnimaestra <13349159+mugnimaestra@users.noreply.github.com>
* fix(dashboard): show a disabled connection's last error in the row (#4352)
* fix(dashboard): show a disabled connection's last error in the row (port from 9router#1447)
The provider card's error badge counts a disabled connection (isActive ===
false) that has an error — its effective status is still
error/expired/unavailable — but the connection row hid the lastError text for
disabled rows, so the operator saw the count without the cause. The row's
error-visibility decision is extracted into shouldShowConnectionLastError()
and now shows the error whenever there is one, regardless of the active
toggle.
Reported-by: ntdung6868 (https://github.com/decolua/9router/issues/1447)
Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com>
* chore(quality): rebaseline ConnectionRow.tsx file-size to 942 (#1447 +1 import)
---------
Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com>
* fix(providers): bound the OAuth connection-test probe with a timeout (#4347)
* fix(providers): bound OAuth connection-test probe with a timeout (port from 9router#1449)
The OAuth path of "Test Connection One-by-One" called bare fetch() with no
AbortController/signal, so a provider probe that accepted the socket but never
responded wedged the test queue forever. Both the initial probe and the
post-refresh retry are now bounded with AbortSignal.timeout(30s) — matching the
API-key path's existing budget — and a timed-out probe resolves as a failure
with a clear "Test timed out after 30s" message in the route's normal error shape.
Reported-by: ntdung6868 (https://github.com/decolua/9router/issues/1449)
Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com>
* chore(quality): rebaseline providers test route file-size to 887 (#1449 + sibling #1444 growth)
---------
Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com>
* fix(providers): label a deactivated account distinctly from a revoked token (#4353)
A Codex connection whose OAuth refresh is fully healthy but whose ChatGPT
account has been deactivated by the provider gets a 401 from the upstream
API. The connection test labeled that the same as a bad credential
("Token invalid or revoked" -> upstream_auth_error), so an operator could not
tell a deactivated account from a revoked token. The test now reads the
401/403 body and, when it indicates account deactivation, classifies it as
account_deactivated (which the dashboard already renders as "Account
Deactivated"); a plain auth 401 is unchanged.
Reported-by: ntdung6868 (https://github.com/decolua/9router/issues/1444)
Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com>
* fix(db): cascade-delete orphaned model aliases when a provider is removed (#4348)
* fix(db): cascade-delete orphaned model aliases when a provider is removed (port from 9router#1409)
Deleting a custom provider removed its connections and node but left the
imported model-alias rows (key=<alias>, value="<providerId>/<model>") behind,
so re-importing the same provider was blocked by stale "already exists" aliases.
Add a deleteModelAliasesForProvider(providerId) DB helper that drops every alias
whose stored value begins with "<providerId>/", and call it from the provider-node
DELETE handler so a fresh import is unblocked.
Reported-by: nguyenvanhuy0612 (https://github.com/decolua/9router/issues/1409)
Co-authored-by: nguyenvanhuy0612 <57367674+nguyenvanhuy0612@users.noreply.github.com>
* chore(quality): rebaseline models.ts file-size to 1221 (#1409 + sibling #1294 growth)
---------
Co-authored-by: nguyenvanhuy0612 <57367674+nguyenvanhuy0612@users.noreply.github.com>
* fix(api): persist max_input_tokens/max_output_tokens when adding a custom model (#4349)
The POST /api/provider-models handler read the rest of the body but never
the two token-limit fields, and addCustomModel() had no parameter for them,
so the form values were dropped on write while the DB layer and /v1/models
catalog already round-trip inputTokenLimit/outputTokenLimit. Accept the two
optional limits in the schema, forward them through the handler, and persist
them in addCustomModel(). TDD: failing-then-passing unit test.
Reported-by: codename-zen (https://github.com/decolua/9router/issues/1294)
Co-authored-by: codename-zen <263238141+codename-zen@users.noreply.github.com>
* docs: feature-documentation catch-up (v3.8.20 → v3.8.30) (#4391)
One-time reconciliation of the docs with every user-facing feature shipped since
v3.8.20 (we had never done a dedicated pass, so debt had accumulated):
- README: new '✨ What's New' section (curated v3.8.20→v3.8.30 highlights).
- New guides: CLI-INTEGRATIONS (all setup-*/launch commands), MITM-TPROXY-DECRYPT
(transparent-decrypt epic), CONTEXT_EDITING (delegated Anthropic clear_tool_uses).
- Refreshed: AUTO-COMBO (auto/<category>:<tier> + Arena-ELO), API_REFERENCE
(x-omniroute-no-memory), MEMORY (int8 quantization + off-by-default), RESILIENCE
(model-lockout success-decay), RTK, AGENTBRIDGE, TRAFFIC_INSPECTOR, GUARDRAILS,
CLOUD_AGENT, ENVIRONMENT, SETUP_GUIDE, CLI-TOOLS, MCP-SERVER.
- Regenerated PROVIDER_REFERENCE (231 providers); synced the count in README/CLAUDE/AGENTS.
- Allowlisted external-tool env vars (OPENAI_API_BASE, PROMPTFOO_PROVIDER_KEY) and the
STREAM_RECOVERY config-object name in the docs-accuracy gates.
All claims source-verified; check:docs-all (sync/counts/env/links/fabricated) passes.
Going forward this runs every release via generate-release step 6b.
* fix(executors): don't inject thinking when tool_choice forces a tool (native Claude) (#4389)
Forced tool_choice now strips the adaptive thinking injection to avoid Anthropic 400. Thanks @NomenAK.
* fix(translator): Gemini accepts HTTP/HTTPS image URLs (port from 9router#344) (#4373)
OpenAI-style `image_url` parts with an `http://` or `https://` URL reached
`convertOpenAIContentToParts` and were dropped with only a `console.warn`,
because Gemini's `inlineData` requires base64 (the helper is synchronous and
cannot fetch+encode upstream assets). Gemini's `Part` schema, however, natively
accepts `fileData: { fileUri }` for remote URIs — the model fetches the asset
itself.
The helper now emits a `fileData` part (`mimeType: "image/*"`, inferred upstream
on fetch) for HTTP/HTTPS URLs instead of silently dropping them. Vision requests
that pass a URL — not a data: URI — now reach Gemini intact.
No behavioral change for:
- `data:` URIs → still emitted as `inlineData` with the parsed media type.
- Unsupported schemes (e.g. `ftp:`) → still skipped (Gemini would reject them).
The openai-to-claude side already passed HTTP/HTTPS URLs through as
`source: { type: "url", url }` (lines 573–578) — the upstream PR's Claude-side
change was already covered.
Regression test: tests/unit/gemini-helper-http-image-url-port344.test.ts
(4 cases: https URL, http URL, data: URI no-regression, unsupported-scheme guard).
Inspired-by: https://github.com/decolua/9router/pull/344
Co-authored-by: Ibrahim Ryan <ryan@nuevanext.com>
* fix(executors): strip stream_options for qwen non-streaming / thinking Claude Code requests (port from 9router#663) (#4374)
Claude-Code-compatible providers force the executor-level `stream` flag on
via `upstreamStream = stream || isClaudeCodeCompatible`
(open-sse/handlers/chatCore.ts), but the outgoing body keeps the caller's
original `stream: false`. The shared `stream && targetFormat === "openai"`
branch in DefaultExecutor.transformRequest then injected
`stream_options: { include_usage: true }` onto a body that still said
`stream: false`, and qwen upstream rejected the request with
`400 "'stream_options' only set this when you set stream: true"`. The same
rejection surfaced when the body carried `thinking` / `enable_thinking`.
The qwen branch now skips the injection (and strips any client-sent
`stream_options`) when the body explicitly says `stream: false` or
requests thinking, leaving regular qwen streaming requests with the
include_usage injection intact. Other providers are unaffected.
Adds a TDD regression with 4 cases covering both opt-out paths and the
normal-streaming positive control.
Inspired-by: https://github.com/decolua/9router/pull/663
Co-authored-by: anuragg-saxenaa <anuragg.saxenaa@gmail.com>
* fix(security): scope OAuth callback postMessage to a trusted-origin allowlist (port from 9router#998) (#4372)
The OAuth callback at `/callback` previously fell back to
`window.opener.postMessage({ code, state, ... }, "*")` whenever the opener
was cross-origin. The fallback was intended to support remote-OmniRoute +
local-loopback callbacks (where opener and callback live on different
origins), but the same code path also delivers the OAuth code/state to any
hostile opener that pops the well-known callback URL — letting that
attacker complete the OAuth flow as the user.
Replace the wildcard fallback with iteration over a fixed allowlist:
`window.location.origin` (same-origin parent — the popup-mode dashboard)
plus Codex's fixed loopback helper (`http://localhost:1455` and the IPv4
literal `http://127.0.0.1:1455`). The browser drops `postMessage` to any
opener whose actual origin is not in `targetOrigin`, so the message reaches
only known parents and is silently dropped for any other. The same-origin
fallback path is unchanged — methods 2 (`BroadcastChannel`) and 3
(`localStorage` storage event) still cover same-origin openers that COOP
severed.
The `openerSameOrigin` probe stays in place to drive the auto-close vs
manual-copy UI decision (no behavior change for the success path).
Adds a regression test (`tests/unit/ui/oauth-callback-postmessage-scope.test.tsx`)
that mounts the page with a stubbed cross-origin opener and asserts no
`postMessage` call ever uses `"*"` and every call lands on an allowlisted
origin. The test failed against the pre-fix code (red), passes after the
fix (green) — TDD per CLAUDE.md hard rule #18.
Partial port of upstream decolua/9router#998: the upstream PR also
re-enabled TLS verification on a DNS-bypass fetch in `open-sse/utils/proxyFetch.js`;
that part is N/A here because OmniRoute's `proxyFetch.ts` never disabled
TLS verification (no `rejectUnauthorized: false` anywhere in the file).
Inspired-by: https://github.com/decolua/9router/pull/998
Co-authored-by: aeonframework <aeon@aeonframework.dev>
* fix(sse): default combo per-target timeout to 120s for fast failover (#4365)
Combo per-target timeout inherited the full FETCH_TIMEOUT_MS (600s) when a
combo did not set its own targetTimeoutMs, so a single hung/slow target stalled
the whole combo for up to 10 minutes before falling through to the next model.
Introduce DEFAULT_COMBO_TARGET_TIMEOUT_MS (120s) as the unset-default in
resolveComboTargetTimeoutMs (new 3rd arg) and wire it in phaseComboSetup. The
upstream ceiling (600s) and per-combo opt-out (targetTimeoutMs, up to the
ceiling) are preserved; single non-combo requests are unchanged. For streaming
requests this only bounds time-to-first-headers, so token generation is not cut
short.
TDD: failing-then-passing unit test in tests/unit/combo-config.test.ts.
* refactor(combo): de-dup exhausted-target skip predicate across both dispatchers (#4362)
Primeiro incremento da de-dup dos 2 dispatchers de combo (handleComboChat +
handleRoundRobinCombo). O bloco de pre-check #1731/#1731v2 (skip de target já
exhausted no provider/connection) era BYTE-IDÊNTICO nos dois (mesmas condições,
mesmas mensagens), diferindo só na tag de log e no control-flow.
- comboPredicates.ts: getExhaustedTargetSkipReason(target, exhaustedProviders,
exhaustedConnections) — predicate PURO que retorna a mensagem de skip (ou null);
cada dispatcher mantém seu próprio log-tag + control-flow (return null / continue)
+ fallbackCount. No mutate do stryker (cobertura de mutação).
- combo.ts: −20 linhas (os 2 blocos viram 1 chamada cada).
- 7 testes de caracterização travam condições + strings exatas.
Comportamento preservado: 376/376 testes combo (357 caracterização + 7 novos),
integração sse-correctness 5/5, typecheck 0, complexity neutro (1895), file-size
encolhe. Próximo incremento: de-dup do error-handling/exhausted-tracking (handleTargetError).
* refactor(combo): de-dup upstream-error exhaustion classification across both dispatchers (#4366)
Segundo incremento da de-dup dos 2 dispatchers (handleTargetError). Após cada erro
de target, ambos rodavam um bloco quase-idêntico que marca o provider exhausted
(#1731), a conexão connection-errored (#1731v2) ou o provider transiently rate-limited.
- combo/targetExhaustion.ts: applyComboTargetExhaustion(target, opts) — atualiza os
3 Sets de exhaustion e retorna providerExhausted. As MUTAÇÕES de Set (que dirigem o
skip de targets, lidas por getExhaustedTargetSkipReason) são BYTE-IDÊNTICAS nos dois;
as diferenças reais viram parâmetros: tag, allAccountsRateLimited (termo extra do RR,
false no handleComboChat), exhaustedLogLevel (info no handleComboChat, debug no RR).
Connection-level extraído p/ markConnectionLevelExhaustion (privado, <15 complexity).
- combo.ts: −73 linhas; 4 imports órfãos removidos.
- 7 testes de caracterização travam as mutações + o return.
ÚNICA mudança de comportamento: o WORDING das mensagens de log do RR ganha o sufixo
'on remaining targets' (cosmético; mesmo #code, mesmas mutações, mesmos níveis de log).
376/376 combo (caracterização preservada), integração sse 5/5, typecheck 0, complexity
neutro (1895), file-size encolhe.
* refactor(chatCore): extract checkHeapPressureGuard leaf (god-file decomposition start) (#4371)
Primeiro incremento da decomposição do chatCore.ts (5127 LOC, hot-path mais quente).
O guard de memória do topo do handleChatCore (rejeita 503 quando o heap V8 passa o
threshold de shed) vira um leaf testável, co-locado com o threshold em heapPressure.ts.
- heapPressure.ts: checkHeapPressureGuard(heapUsedMb, thresholdMb) — retorna o result
503 pronto ou null. Byte-idêntico ao guard inline (mesmo check, mesma 503, mesmo warn).
A figura de heap fica em telemetria INTERNA, nunca no response do cliente (Hard Rule #12).
- chatCore.ts: o bloco inline (~22 ln) vira 3 linhas; import órfão de HEAP_PRESSURE_THRESHOLD_MB
trocado por checkHeapPressureGuard.
- 3 testes novos (incl. assert Rule #12: o MB medido não vaza no payload).
complexity-baseline 1895->1896: drift de base pós-#4338 (medido com minhas mudanças
stashed = 1896); esta mudança é complexity-NEUTRA (helper complexity 2, handleChatCore só
perde código). 190/190 chatcore tests, typecheck 0, file-size encolhe.
* Localize CLI and stabilize fetch, memory, and coverage handling (#4383)
en-only i18n, fetch-start-timeout hardening, EngineConfigPage icon fix, CI build-artifact-reuse overhaul. Memory production hunk dropped as a no-op (tests kept). Thanks @JxnLexn.
* test(combo): reset circuit breakers between stream-readiness cases (restore green) (#4396)
The combo-dispatch cases in combo-stream-readiness-fallback.test.ts deliberately
fail `glm` (zombie streams / repeated 504s), which legitimately trips the
per-provider circuit breaker. That OPEN state is a module-level singleton, so it
leaked into the next test and combo.ts then SKIPPED `glm/*` targets entirely
("Skipping … circuit breaker OPEN"). That made "combo does not retry stream
readiness timeouts on the same model" never attempt glm/zombie — expected
['glm/zombie','openai/gpt-5.4-mini'] but got ['openai/gpt-5.4-mini'].
This was a pre-existing red on release/v3.8.31 (present at the cycle-open tip),
order-dependent: the test passes in isolation, fails after the preceding cases.
Add a test.beforeEach(resetAllCircuitBreakers) so each scenario starts from a
clean breaker slate. Test-isolation only — the breaker behavior is correct and
no production code or assertion changes. Full combo suite: 390/390 green.
* fix(cli): decline confirm() cleanly on non-interactive stdin + document contexts workflow
The `contexts remove` command already has `--yes` to skip confirmation, but when
run without it under a non-interactive stdin (pipe, CI, EOF) the [y/N] prompt could
never be answered — the readline question stayed pending and Node warned about an
"unsettled top-level await" at exit. confirm() now detects `!process.stdin.isTTY`
and declines cleanly (returns false), pointing at `--yes` for non-interactive use.
Exported confirm() for a regression test.
Docs: REMOTE-MODE.md gains a full "Managing contexts" section (list/current/use to
switch between remote and local, add/show/rename, remove with --yes, export/import),
fixes a `context current` -> `contexts current` typo, and the README remote-mode
snippet now shows switching back to local. Verified against the live CLI: command
signatures, --yes, and the non-TTY decline path all behave as documented.
Tests: cli-contexts.test.ts asserts confirm() declines on non-TTY stdin (RED before,
GREEN after). All docs gates (fabricated/links/symbols) pass.
* ci(t11): bump any-budget for executors/base.ts (2 false-positive "any" strings)
Unblocks the Fast Quality Gates on release/v3.8.31: `check:any-budget:t11` was red on
`open-sse/executors/base.ts` for ALL PRs (pre-existing base drift, unrelated to this
branch). The checker counts `\bany\b` after stripping comments but NOT strings, and the
native-Claude tool_choice logic uses the API value `"any"` in two string literals
(`tb.tool_choice === "any"`, `.type === "any"`). There are zero actual TypeScript
`any` types in the file — budget set to the matched count, mirroring the existing
cursor.ts false-positive entry right below it.
* perf: combos UI split + next config + 1-click redis + bifrost sidecar (#3932) (#4381)
Combos UI split + next.config perf + 1-click local Redis launcher + bifrost relay. Review fixes (co-author): --rm/--restart conflict, error sanitization, UI/route names, dead-guard re-doc, bifrost Zod, IPv6 + CLI test fixes. Thanks @KooshaPari.
* fix(plugin): prefix OC static-catalog combo+raw keys with providerId (#4384)
OC parses model ids on '/'; combo keys now carry 'omniroute/' (was 'combo/'). Live-validated against the VPS via OpenCode. Thanks @herjarsa.
* docs(env): document TAILSCALE_AUTHKEY (env/docs contract drift on .31)
Second pre-existing base-drift fix needed to get Fast Quality Gates green: the
`repository contract is in sync` test (check:env-doc-sync) was red on ALL .31 PRs.
PR #4343 added a code reference to `process.env.TAILSCALE_AUTHKEY`
(src/lib/tailscaleTunnel.ts) for non-interactive `tailscale up`, but never added the
var to .env.example / docs/reference/ENVIRONMENT.md — the contract requires code vars
to appear in both. Add the (commented) entry to .env.example next to TAILSCALE_BIN and
a row to ENVIRONMENT.md. Verified: `node scripts/check/check-env-doc-sync.mjs` → in sync.
Unrelated to this branch's confirm()/docs change; surfaced because touching a quality
script triggers the TIA fail-safe full unit suite.
* chore(release): v3.8.31 — 2026-06-20
Finalize the v3.8.31 release: reconcile the CHANGELOG (full commit-to-bullet
coverage, 26 bullets across Features/Fixed/Security/Maintenance), refresh the
README What's New section, back-fill the .30/.31 sections into the 41 i18n
CHANGELOG mirrors, and add TAILSCALE_AUTHKEY to the env contract
(.env.example + ENVIRONMENT.md).
* chore(release): align mitm-hosts test comment with main to clear merge conflict
The same CodeQL false-positive fix landed twice — #4386 on release/v3.8.31 and
#4387 directly on main — with only the explanatory comment differing (the
assertion is byte-identical). Adopt main's comment wording on the release branch
so the release PR merges without a comment-only conflict.
* test(translator): align stale openai->gemini remote-URL tests with #4373
Third pre-existing base-drift fix to get Fast Quality Gates green on .31. PR #4373
('Gemini accepts HTTP/HTTPS image URLs', port of 9router#344) intentionally changed
convertOpenAIContentToParts so remote http(s) image URLs pass through as a native
`fileData: { fileUri }` part instead of the old #2807 drop+warn — and added its own
test (gemini-helper-http-image-url-port344.test.ts) for the new behavior. But it left
three tests in translator-openai-to-gemini.test.ts asserting the OLD drop+warn
contract, so they fail deterministically (verified: the file fails in isolation on
clean .31). These only surface under the TIA fail-safe FULL suite, which a quality-
script touch triggers.
Align the three stale tests to the real, intended behavior (captured by running the
function): remote URLs -> fileData.fileUri (mimeType image/*), still never inlineData
(the sync path cannot fetch+encode). This is test-vs-code alignment to a deliberate,
separately-tested change — not a weakened assertion. 40/40 in the file; 94/94 across
the translator + env-doc combo that previously failed.
* chore(quality): reconcile complexity baseline 1896->1900 (/review-prs v3.8.31 batch) (#4410)
* fix(release): reconcile full-CI drift for v3.8.31 (gemini tests #4373, any-budget #4389, masking allowlist #4384)
The release PR's full CI surfaced cumulative cycle drift the per-PR fast gates
skip:
- tests/unit/translator-openai-to-gemini.test.ts: realign 3 cases to #4373's
HTTP/HTTPS-URL fileData pass-through (they asserted the old warn-and-drop).
- scripts/check/check-t11-any-budget.mjs: base.ts budget 0→2 — #4389 compares
tool_choice against the string literal "any" (not a TS any type).
- config/quality/test-masking-allowlist.json: allowlist #4384's opencode combos
net-assert reduction (obsolete combo/ namespace removed).
No production behavior change.
* chore(release): re-trigger full CI for v3.8.31 finalization
Force a fresh pull_request CI run on the head carrying the cycle-drift fixes
(gemini #4373 tests, any-budget #4389, masking allowlist #4384) — the prior
synchronize event did not spawn a ci.yml run.
* chore: re-trigger CI (no Actions runs registered for prior push)
---------
Co-authored-by: Xiangzhe <32761048+xz-dev@users.noreply.github.com>
Co-authored-by: hydraromania <252583922+hydraromania@users.noreply.github.com>
Co-authored-by: Bian-Sh <24520547+Bian-Sh@users.noreply.github.com>
Co-authored-by: thaitryhand <248103256+thaitryhand@users.noreply.github.com>
Co-authored-by: xxy9468615 <63351664+xxy9468615@users.noreply.github.com>
Co-authored-by: ipeterpetrus <93033698+ipeterpetrus@users.noreply.github.com>
Co-authored-by: DNNYF <74033321+DNNYF@users.noreply.github.com>
Co-authored-by: mugnimaestra <13349159+mugnimaestra@users.noreply.github.com>
Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com>
Co-authored-by: nguyenvanhuy0612 <57367674+nguyenvanhuy0612@users.noreply.github.com>
Co-authored-by: codename-zen <263238141+codename-zen@users.noreply.github.com>
Co-authored-by: Anton <39598727+NomenAK@users.noreply.github.com>
Co-authored-by: Ibrahim Ryan <ryan@nuevanext.com>
Co-authored-by: anuragg-saxenaa <anuragg.saxenaa@gmail.com>
Co-authored-by: aeonframework <aeon@aeonframework.dev>
Co-authored-by: Jan Leon <Jan.gaschler@gmail.com>
Co-authored-by: KooshaPari <42529354+KooshaPari@users.noreply.github.com>
Co-authored-by: Hernan Javier Ardila Sanchez <hjasgr@gmail.com>
Release v3.8.31 — see CHANGELOG.md [3.8.31] for full notes and contributors.
Merged over known non-blocking reds (all correctness gates green): Integration Tests (2/2) is env/flaky (polls a real upstream batch that did not complete in the poll window); SonarQube/SonarCloud is the advisory server-side new-code quality gate. Unit (8 shards), Coverage, Node 22/24/26, Lint, PR Test Policy, Quality Ratchet, Docs-Strict, Quality-Extended and all 4 CodeQL analyses are green.
Use exact array-element membership (.some((h) => h === host)) instead of Array.prototype.includes() in the MITM hosts unit test, so CodeQL's js/incomplete-url-substring-sanitization heuristic does not misread an Array.includes membership check as a String.includes URL-substring test. Functionally identical. Mirrors #4386 (already merged into release/v3.8.31). Closes the only open code-scanning alert (#660).
* fix(deps): bump undici to 7.28.0 and dompurify to 3.4.11 (security)
Resolves Dependabot alerts on package-lock.json and electron/package-lock.json:
- undici 7.x -> 7.28.0: TLS certificate validation bypass via dropped requestTls in SOCKS5 ProxyAgent (GHSA-vmh5-mc38-953g, HIGH) + cross-user information disclosure via shared-cache whitespace bypass (GHSA-pr7r-676h-xcf6, MEDIUM). Fixed in the root (jsdom transitive) and electron lockfiles.
- dompurify -> 3.4.11: permanent ALLOWED_ATTR pollution via setConfig() bypassing the hook clone-guard (GHSA-cmwh-pvxp-8882, MEDIUM). Bumped the overrides floor from ^3.4.9 to ^3.4.11.
Also bumps node-gyp's transitive undici 6.26.0 -> 6.27.0, clearing the <6.27.0 advisories (WebSocket DoS, Set-Cookie handling) surfaced by npm audit. Lockfile/override-only change; no production source touched.
* ci(quality): exclude dependency manifests/lockfiles from PR test-policy
The PR test-policy gate classifies any changed file under src/, open-sse/, electron/, or bin/ as production code requiring tests. This false-flags lockfile/manifest-only changes (e.g. this Dependabot security bump touching electron/package-lock.json), since a lockfile cannot have a meaningful unit test.
Adds package.json / package-lock.json to EXCLUDED_PATTERNS, consistent with the existing .md/.yaml/.yml exclusions. Real production-code changes remain flagged.
OmniRoute v3.8.29 — 115 commits since v3.8.28. Full CHANGELOG + 41 i18n mirrors. All content quality gates green (build, unit 8/8, vitest 188/188, PR test policy, quality gates extended, docs sync, quality ratchet). Remaining red CI checks are pre-existing release flakes (coverage-shard/integration/node-compat teardown), a new transitive undici advisory in electron devDeps, and a workflow-level CodeQL fail (0 open alerts). VPS-validated by the operator.