Skip to content

fix(plugin): prefix combo keys + emit providerID + drop combo/ prefix - #4384

Merged
diegosouzapw merged 1 commit into
diegosouzapw:release/v3.8.31from
herjarsa:fix/theoldllm-context-length
Jun 20, 2026
Merged

diegosouzapw merged 1 commit into
diegosouzapw:release/v3.8.31from
herjarsa:fix/theoldllm-context-length

Conversation

@herjarsa

@herjarsa herjarsa commented Jun 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Six-part fix for the regression where the omniroute provider is misdetected or rejected by OpenCode's static-catalog reader and the auth loader returns empty credentials.

Why the upstream plugin breaks

Surface Symptom
combo/MASTER (original) "No credentials for provider: omniroute" — combo/ parsed as provider=combo
bare MASTER (fix #1) "Unable to determine provider for model 'master-light'" — bare key misread
omniroute/MASTER + bare raw (fix #2) provider not detected — mixed keys rejected by OC schema
omniroute/MASTER + 10s timeout catalog empty (stub published) — server cold-start exceeded 10s
+ 60s timeout catalog loads, but "No credentials for provider: omniroute" on send
+ auth loader lookup (this PR) ✓ end-to-end works

Fixes

  1. 99531c846 drop combo/ prefix from combo model keys
  2. 44baa3eb6 emit providerID on static-catalog entries (later dropped)
  3. 5904c495 prefix combo keys with providerId (omniroute/<slug>)
  4. 0407b655b prefix raw model keys + drop providerID on entries
  5. 22a1b9653 raise config shim fetch timeout 10s → 60s
  6. 842ae03b1 auth loader handles full auth record shape (this PR)

Fix 6 — auth loader full-record shape (commit 842ae03)

The auth loader assumed OC's getAuth() returns a single auth entry ({ type, key }) per the @opencode-ai/sdk@1.17.8 type contract. Some OC runtimes return the FULL auth record ({ omniroute: { type, key }, openai: { ... }, ... }). When the loader treated the full record as a single entry, validation failed silently and the loader fell through to return {} — producing the original "No credentials for provider: omniroute" error.

Fix: accept either shape. If getAuth() returns an object that has a key matching the hook's providerId, pick that entry; otherwise treat the response as the single entry per the SDK type.

Test plan

  • 259/259 plugin tests pass
  • Smoke test verifies both auth shapes produce credentials
  • Manual QA: restart OC, send a message → request succeeds, models + creds resolve

Supersedes #4378

PR #4378 shipped fixes 1+2 but not 3+4+5+6. None of those alone is sufficient.

@gemini-code-assist gemini-code-assist Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates combo static-block keys to use the provider ID prefix instead of the hardcoded combo/ namespace, preventing credentials-not-found errors in OpenCode. It also adds default and per-model context lengths for the [removed-provider] provider to fix missing context window reports, along with a regression test. Feedback from the review highlights a discrepancy where mapped.id is not prefixed with the provider ID in the hook, which could still trigger provider resolution errors, and suggests updating the JSDoc for buildComboKey to match its new behavior.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

}

const comboKey = buildComboKey(combo, usedComboKeys);
const comboKey = buildComboKey(combo, usedComboKeys, resolved.providerId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The PR description states that mapComboToModelV2 returns id: ${providerId}/${combo.id}. However, the actual implementation of mapComboToModelV2 (which is not modified in this PR) still returns id: combo.id without the provider prefix. This creates a discrepancy where the model's id field is not prefixed, which can cause OpenCode to fail with "Unable to determine provider" when routing requests using model.id.

To align with the intended design, we should prefix mapped.id with the providerId here.

Suggested change
const comboKey = buildComboKey(combo, usedComboKeys, resolved.providerId);
const comboKey = buildComboKey(combo, usedComboKeys, resolved.providerId);
mapped.id = `${resolved.providerId}/${combo.id}`;

Comment on lines 2252 to 2267
/**
* Build a combo's static-block key (`combo/<slug>`), guaranteeing uniqueness
* across an entire static catalog. If `<slug>` is already present in `used`,
* suffixes a short UUID-prefix disambiguator from `combo.id` so the second
* combo doesn't silently overwrite the first. Mutates `used` in place by
* recording the chosen key. Returns the final `combo/<...>` key.
* Build a combo's static-block key (bare slug like `MASTER`, `MASTER-LIGHT`),
* guaranteeing uniqueness across an entire static catalog. If `<slug>` is
* already present in `used`, suffixes a short UUID-prefix disambiguator from
* `combo.id` so the second combo doesn't silently overwrite the first.
* Mutates `used` in place by recording the chosen key. Returns the final
* bare key.
*
* Falls back to `combo/<id>` when the friendly name slugifies to the empty
* NOTE: the key MUST NOT carry a `combo/` namespace prefix — OpenCode
* parses model IDs on `/` to extract a provider prefix, and `combo/MASTER`
* would be treated as provider=`combo`, model=`MASTER`, causing a
* credentials-not-found error. See PR #4184.
*
* Falls back to bare `<id>` when the friendly name slugifies to the empty
* string (e.g. a combo named just punctuation).
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The JSDoc for buildComboKey states that it returns a "bare key" or "bare slug like MASTER". However, the implementation has been updated to return a provider-prefixed key (${providerId}/${slug}). The documentation should be updated to accurately reflect this behavior to avoid confusing future maintainers.

Suggested change
/**
* Build a combo's static-block key (`combo/<slug>`), guaranteeing uniqueness
* across an entire static catalog. If `<slug>` is already present in `used`,
* suffixes a short UUID-prefix disambiguator from `combo.id` so the second
* combo doesn't silently overwrite the first. Mutates `used` in place by
* recording the chosen key. Returns the final `combo/<...>` key.
* Build a combo's static-block key (bare slug like `MASTER`, `MASTER-LIGHT`),
* guaranteeing uniqueness across an entire static catalog. If `<slug>` is
* already present in `used`, suffixes a short UUID-prefix disambiguator from
* `combo.id` so the second combo doesn't silently overwrite the first.
* Mutates `used` in place by recording the chosen key. Returns the final
* bare key.
*
* Falls back to `combo/<id>` when the friendly name slugifies to the empty
* NOTE: the key MUST NOT carry a `combo/` namespace prefix — OpenCode
* parses model IDs on `/` to extract a provider prefix, and `combo/MASTER`
* would be treated as provider=`combo`, model=`MASTER`, causing a
* credentials-not-found error. See PR #4184.
*
* Falls back to bare `<id>` when the friendly name slugifies to the empty
* string (e.g. a combo named just punctuation).
*/
/**
* Build a combo's static-block key prefixed with the provider ID (e.g.,
* `omniroute/MASTER`), guaranteeing uniqueness across an entire static catalog.
* If the key is already present in `used`, suffixes a short UUID-prefix
* disambiguator from `combo.id` so the second combo doesn't silently overwrite
* the first. Mutates `used` in place by recording the chosen key. Returns the
* final prefixed key.
*
* NOTE: the key MUST NOT carry a `combo/` namespace prefix — OpenCode
* parses model IDs on `/` to extract a provider prefix, and `combo/MASTER`
* would be treated as provider=`combo`, model=`MASTER`, causing a
* credentials-not-found error. See PR #4184.
*
* Falls back to `<providerId>/<id>` when the friendly name slugifies to the
* empty string (e.g. a combo named just punctuation).
*/

herjarsa added a commit to herjarsa/OmniRoute that referenced this pull request Jun 20, 2026
The previous fix (commit 5904f9c) prefixed ONLY combo keys
(`omniroute/<slug>`) but left raw models with bare keys
(`claude-opus-4`). OC's static-catalog reader parses every key on
`/` and rejects the entire provider block if ANY key resolves to a
parsed providerID that has no corresponding provider block. So a
mixed block of `omniroute/master-light` + bare `claude-opus-4` was
rejected entirely — the user reported "provider not detected".

Fix in two parts:

1. Prefix bare raw-model keys with ${providerId}/ so every key
   resolves to the same parsed providerID as the parent block. Already-
   prefixed raw models (e.g. `cc/claude-opus-4-7` from the Claude Code
   alias) are left as-is to avoid double-prefixing.

2. Drop the `providerID` field from static catalog entries. The
   `providerID` field is not part of OC's expected schema for static
   entries (the parent block ID is the provider). Stamping it caused
   some OC versions to reject the block. Keep it on ModelV2 (dynamic
   hook) where OC does consume it.

Also fixed: combo dedup suppression — when a combo's name exactly
matches a raw model's id (the intentional /v1/models pre-mirror
pattern), suppress the collision warning. Detect via
`existing.id === combo.name` OR `existing.id.endsWith(`/${combo.name}`)`
to handle the prefixed-key case.

Test plan:
- 259/259 plugin tests pass
- Static catalog produces `omniroute/claude-opus-4` (bare key prefixed)
  and `cc/claude-opus-4-7` (already prefixed, unchanged)
- Static entries no longer have `providerID` field
- Dynamic hook produces `omniroute/<id>` keys with `providerID` field
- Combo dedup with intentional name collision is silent

Supersedes PR diegosouzapw#4378 and PR diegosouzapw#4384. The earlier PRs shipped an
incomplete fix that broke the static catalog.
@herjarsa
herjarsa force-pushed the fix/theoldllm-context-length branch from 0407b65 to 22a1b96 Compare June 20, 2026 15:07
herjarsa added a commit to herjarsa/OmniRoute that referenced this pull request Jun 20, 2026
The previous fix (commit 5904f9c) prefixed ONLY combo keys
(`omniroute/<slug>`) but left raw models with bare keys
(`claude-opus-4`). OC's static-catalog reader parses every key on
`/` and rejects the entire provider block if ANY key resolves to a
parsed providerID that has no corresponding provider block. So a
mixed block of `omniroute/master-light` + bare `claude-opus-4` was
rejected entirely — the user reported "provider not detected".

Fix in two parts:

1. Prefix bare raw-model keys with ${providerId}/ so every key
   resolves to the same parsed providerID as the parent block. Already-
   prefixed raw models (e.g. `cc/claude-opus-4-7` from the Claude Code
   alias) are left as-is to avoid double-prefixing.

2. Drop the `providerID` field from static catalog entries. The
   `providerID` field is not part of OC's expected schema for static
   entries (the parent block ID is the provider). Stamping it caused
   some OC versions to reject the block. Keep it on ModelV2 (dynamic
   hook) where OC does consume it.

Also fixed: combo dedup suppression — when a combo's name exactly
matches a raw model's id (the intentional /v1/models pre-mirror
pattern), suppress the collision warning. Detect via
`existing.id === combo.name` OR `existing.id.endsWith(`/${combo.name}`)`
to handle the prefixed-key case.

Test plan:
- 259/259 plugin tests pass
- Static catalog produces `omniroute/claude-opus-4` (bare key prefixed)
  and `cc/claude-opus-4-7` (already prefixed, unchanged)
- Static entries no longer have `providerID` field
- Dynamic hook produces `omniroute/<id>` keys with `providerID` field
- Combo dedup with intentional name collision is silent

Supersedes PR diegosouzapw#4378 and PR diegosouzapw#4384. The earlier PRs shipped an
incomplete fix that broke the static catalog.
@diegosouzapw
diegosouzapw force-pushed the fix/theoldllm-context-length branch from 22a1b96 to af8cb06 Compare June 20, 2026 15:27
@herjarsa
herjarsa force-pushed the fix/theoldllm-context-length branch from af8cb06 to 842ae03 Compare June 20, 2026 15:37
@diegosouzapw
diegosouzapw changed the base branch from main to release/v3.8.31 June 20, 2026 16:08
… drop stale providerID field

OpenCode parses static-catalog model ids on '/' to recover (providerID, modelID).
Combo keys now carry the OWNING provider prefix ('omniroute/MASTER') instead of a
'combo/' namespace (which resolved provider='combo', no creds -> 'Unable to determine
provider'). Raw model keys are prefixed too; already-prefixed ids (e.g. 'cc/…') are
preserved. The 'providerID' entry field (ignored by OC on the static path) is dropped
from ALL static entries (incl. auto-combos) for internal consistency. Docblock corrected.

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
@diegosouzapw
diegosouzapw force-pushed the fix/theoldllm-context-length branch from 842ae03 to ee52b58 Compare June 20, 2026 16:10
@diegosouzapw
diegosouzapw merged commit fd1391c into diegosouzapw:release/v3.8.31 Jun 20, 2026
3 checks passed
@diegosouzapw

diegosouzapw commented Jun 20, 2026 •

Copy link
Copy Markdown
Owner

Merged into release/v3.8.31 🎉 Thanks @herjarsa! This is the correct fix for the OpenCode combo-resolution bug.

Adjustments before merge (co-authored): dropped the providerID field from all static-catalog entries (it was inconsistently left on auto-combos; OC ignores it on the static path), corrected the buildComboKey docblock to describe the <providerId>/<slug> format, and dropped the bundled [provider removed at its operator's request]/#4184 commits (already in release).

Live OpenCode → VPS validation (Rule #18) (OmniRoute v3.8.31, 42 combos):

$ opencode models omniroute
omniroute/combo-codex-1
omniroute/teste                         # OC parses the omniroute/<slug> key ✔

# the bare slug OC sends upstream after stripping the prefix:
POST /v1/chat/completions {model:"teste"}      -> 503 ALL_ACCOUNTS_INACTIVE   (combo RESOLVED, just no active upstream)
POST /v1/chat/completions {model:"zzz-bogus"}  -> 400 "Unable to determine provider … use a provider/model prefix"

The contrast is the proof: a real combo slug resolves (reaches routing), while a non-combo gives the exact "Unable to determine provider" error the old combo/<slug> key produced (parsed as provider=combo). omniroute/<slug> resolves end-to-end. Ships in the next release.

@diegosouzapw diegosouzapw mentioned this pull request Jun 20, 2026
diegosouzapw added a commit that referenced this pull request Jun 20, 2026
… 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.
diegosouzapw added a commit that referenced this pull request Jun 20, 2026
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.
diegosouzapw added a commit that referenced this pull request Jun 20, 2026
…lose drift)

The Quality Ratchet failed on the release PR: eslintWarnings 3839 > baseline
3836. The +3 is end-of-cycle drift from legitimately-merged feature PRs
(#4381/#4383/#4373/#4389/#4384/#4410) — `any` is allowed (warn) in open-sse/
and tests/. Verified the release reconciliation files add 0 warnings (gemini
test delta 79<->79, mitm test 0). Same precedent as prior cycle-close
rebaselines. Authorized as part of the end-to-end release.
diegosouzapw added a commit that referenced this pull request Jun 20, 2026
…4397)

* 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 (decolua/9router#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 (decolua/9router#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 (decolua/9router#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 (decolua/9router#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 (decolua/9router#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 (decolua/9router#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 (decolua/9router#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 (decolua/9router#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 (decolua/9router#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 (decolua/9router#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 (decolua/9router#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 (decolua/9router#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: decolua/9router#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: decolua/9router#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: decolua/9router#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>
tkgo11 pushed a commit to tkgo11/OmniRoute that referenced this pull request Sep 23, 2026
…iegosouzapw#4384)

OC parses model ids on '/'; combo keys now carry 'omniroute/' (was 'combo/'). Live-validated against the VPS via OpenCode. Thanks @herjarsa.
tkgo11 pushed a commit to tkgo11/OmniRoute that referenced this pull request Sep 23, 2026
…souzapw#4373, any-budget diegosouzapw#4389, masking allowlist diegosouzapw#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 diegosouzapw#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 — diegosouzapw#4389 compares
  tool_choice against the string literal "any" (not a TS any type).
- config/quality/test-masking-allowlist.json: allowlist diegosouzapw#4384's opencode combos
  net-assert reduction (obsolete combo/ namespace removed).
No production behavior change.
tkgo11 pushed a commit to tkgo11/OmniRoute that referenced this pull request Sep 23, 2026
Force a fresh pull_request CI run on the head carrying the cycle-drift fixes
(gemini diegosouzapw#4373 tests, any-budget diegosouzapw#4389, masking allowlist diegosouzapw#4384) — the prior
synchronize event did not spawn a ci.yml run.
tkgo11 pushed a commit to tkgo11/OmniRoute that referenced this pull request Sep 23, 2026
…lose drift)

The Quality Ratchet failed on the release PR: eslintWarnings 3839 > baseline
3836. The +3 is end-of-cycle drift from legitimately-merged feature PRs
(diegosouzapw#4381/diegosouzapw#4383/diegosouzapw#4373/diegosouzapw#4389/diegosouzapw#4384/diegosouzapw#4410) — `any` is allowed (warn) in open-sse/
and tests/. Verified the release reconciliation files add 0 warnings (gemini
test delta 79<->79, mitm test 0). Same precedent as prior cycle-close
rebaselines. Authorized as part of the end-to-end release.
tkgo11 pushed a commit to tkgo11/OmniRoute that referenced this pull request Sep 23, 2026
…iegosouzapw#4397)

* chore(release): open v3.8.31 development cycle

* fix(mitm): exact host membership in MITM hosts test (CodeQL false positive) (diegosouzapw#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 (diegosouzapw#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 (diegosouzapw#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 (diegosouzapw#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 (decolua/9router#1378)

Co-authored-by: hydraromania <252583922+hydraromania@users.noreply.github.com>

* fix(api): migrate deprecated Codex [features].codex_hooks to [features].hooks (diegosouzapw#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 (decolua/9router#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 (diegosouzapw#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 (decolua/9router#1052)

Co-authored-by: thaitryhand <248103256+thaitryhand@users.noreply.github.com>

* fix(translator): strip assistant echo fields on the OpenAI target path (Mistral 422) (diegosouzapw#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 (decolua/9router#1649)

Co-authored-by: xxy9468615 <63351664+xxy9468615@users.noreply.github.com>

* fix(cli): honor TAILSCALE_AUTHKEY for non-interactive tailscale login (diegosouzapw#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 diegosouzapw#13); when unset,
behavior is unchanged. The arg builder is extracted into a pure exported
`tailscaleUpArgs()` for testing.

Reported-by: ipeterpetrus (decolua/9router#1263)
Co-authored-by: ipeterpetrus <93033698+ipeterpetrus@users.noreply.github.com>

* chore(quality): rebaseline tailscaleTunnel.ts file-size to 1202 (diegosouzapw#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 (diegosouzapw#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 (decolua/9router#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 (diegosouzapw#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 (decolua/9router#1330)
Co-authored-by: mugnimaestra <13349159+mugnimaestra@users.noreply.github.com>

* chore(quality): freeze openai-to-kiro.ts file-size at 807 (diegosouzapw#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 (diegosouzapw#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 (decolua/9router#1447)
Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com>

* chore(quality): rebaseline ConnectionRow.tsx file-size to 942 (diegosouzapw#1447 +1 import)

---------

Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com>

* fix(providers): bound the OAuth connection-test probe with a timeout (diegosouzapw#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 (decolua/9router#1449)
Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com>

* chore(quality): rebaseline providers test route file-size to 887 (diegosouzapw#1449 + sibling diegosouzapw#1444 growth)

---------

Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com>

* fix(providers): label a deactivated account distinctly from a revoked token (diegosouzapw#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 (decolua/9router#1444)

Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com>

* fix(db): cascade-delete orphaned model aliases when a provider is removed (diegosouzapw#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 (decolua/9router#1409)
Co-authored-by: nguyenvanhuy0612 <57367674+nguyenvanhuy0612@users.noreply.github.com>

* chore(quality): rebaseline models.ts file-size to 1221 (diegosouzapw#1409 + sibling diegosouzapw#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 (diegosouzapw#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 (decolua/9router#1294)

Co-authored-by: codename-zen <263238141+codename-zen@users.noreply.github.com>

* docs: feature-documentation catch-up (v3.8.20 → v3.8.30) (diegosouzapw#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) (diegosouzapw#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) (diegosouzapw#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: decolua/9router#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) (diegosouzapw#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: decolua/9router#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) (diegosouzapw#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 diegosouzapw#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: decolua/9router#998

Co-authored-by: aeonframework <aeon@aeonframework.dev>

* fix(sse): default combo per-target timeout to 120s for fast failover (diegosouzapw#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 (diegosouzapw#4362)

Primeiro incremento da de-dup dos 2 dispatchers de combo (handleComboChat +
handleRoundRobinCombo). O bloco de pre-check diegosouzapw#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 (diegosouzapw#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
(diegosouzapw#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) (diegosouzapw#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 diegosouzapw#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 diegosouzapw#12: o MB medido não vaza no payload).

complexity-baseline 1895->1896: drift de base pós-diegosouzapw#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 (diegosouzapw#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) (diegosouzapw#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 (diegosouzapw#3932) (diegosouzapw#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 (diegosouzapw#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 diegosouzapw#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 — diegosouzapw#4386 on release/v3.8.31 and
diegosouzapw#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 diegosouzapw#4373

Third pre-existing base-drift fix to get Fast Quality Gates green on .31. PR diegosouzapw#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 diegosouzapw#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) (diegosouzapw#4410)

* fix(release): reconcile full-CI drift for v3.8.31 (gemini tests diegosouzapw#4373, any-budget diegosouzapw#4389, masking allowlist diegosouzapw#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 diegosouzapw#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 — diegosouzapw#4389 compares
  tool_choice against the string literal "any" (not a TS any type).
- config/quality/test-masking-allowlist.json: allowlist diegosouzapw#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 diegosouzapw#4373 tests, any-budget diegosouzapw#4389, masking allowlist diegosouzapw#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants