Compare commits

..

120 Commits

Author SHA1 Message Date
Tony Yu
5483abb4b3 docs: point Kimi Code plan links at the international site (kimi.ai/code) (#14246)
kimi.com/code serves mainland China (WeChat / +86 sign-in, Alipay and WeChat Pay), so OmniRoute's mostly international users cannot sign up or pay there.
kimi.ai/code is the international site (Google sign-in, card payment); the aff value is unchanged, so attribution keeps working as before.
2026-09-22 23:03:12 -03:00
Ravi Tharuma
d7be9fd528 feat(providers): correlate X-CPA-TRACE-ID auth_index with usage history (#14544)
Merged — closes #11725. Thank you, @RaviTharuma.

Correlating the credential CLIProxyAPI actually selected with the usage row is the kind of plumbing that only pays off later, when someone is trying to work out *which* account burned the quota. Storing the opaque `auth_index` on all three paths (success, streaming, error) with a backfill when the first write missed the header, and resolving the human-readable label at **read** time from the sanitized account-health projection, is the right split: the column never holds an email, a path, a token or a raw management payload, and a missing or unrecognized header stays unattributed instead of failing the request.

Validation before merge: boarded onto the release tip with the other seven PRs of your batch, then reconciled twice as the branch moved under it.

- **First move:** #14559 (another session) corrected the docs migration count 178 → 181, colliding with this PR on 69 files — `README.md`, `AGENTS.md`, `llm.txt` and 66 i18n `llm.txt` mirrors, 270 hunks. All 270 were proven mechanically to be pure numeral collisions (base 178 / ours 182 / theirs 181, byte-identical otherwise), and the resolution keeps the tip's exact line shape with 182. I checked separately that no mirror lost its translated prose: 270 removed lines and 270 added lines, each pair identical but for the numeral.
- **Second move:** #14069 landed and both sides had *added* a new top-level `_rebaseline_*` key to `config/quality/file-size-baseline.json` — an add/add, so both were kept and #14069's entry is intact.

The 182 is verified, not carried over: the tip has 181 migration files and this PR adds `185_usage_history_cpa_auth_index.sql`. `check:migration-numbering` reports `182 migrations, 0 duplicates` and `check:docs-counts-sync` passes its STRICT migration-count assertion across all four claim sites.

`cpa-trace-auth-index` + `cpa-auth-index-usage` + `db/migration-185-cpa-auth-index` + `chatcore-failure-usage` 12 pass / 0 fail. `check:file-size` OK — note your `chatCore.ts` ceiling is now 6402, measured after the commit; that file had been sitting at exactly 6400 with zero headroom since #14213.

`typecheck:core` reports exactly one error, and it is **not yours**: `src/lib/services/cliproxyAccountHealth.ts` TS2322 on `host: options.host ?? externalHost`. That expression is byte-identical on the pure tip at line 146 — this PR only shifts it to 157 by adding `labelForCliproxyAuthIndex` above — and it touches neither the `host: string` type nor `externalHost`. Inherited base defect, tracked separately.

Two things your PR did that I want to name, since both were on my checklist and neither needed a bounce: you bumped the migration count in `AGENTS.md`/`llm.txt` yourself (those are agent-instruction surfaces, so I confirmed the change was purely the numeral before merging), and in doing so you drained the 178 → 181 docs drift the previous batch had deferred.

A leftover for someone else: `docs/i18n/fr/README.md` and `docs/i18n/phi/README.md` still say 178 — #14559 updated the `llm.txt` mirrors but missed the README ones. Pre-existing on the tip, outside this diff.
2026-09-22 20:03:11 -03:00
Ravi Tharuma
819adea9c0 fix(combo): record live-catalog misses as model_not_in_catalog (#14069)
Merged — closes #14068. Thank you, @RaviTharuma.

Collapsing "this alias is not in the live catalog" into the generic `availability` bucket meant an unknown model got reported to the operator as "no credentials available" — a diagnosis pointing at the wrong subsystem entirely. Giving it its own `model_not_in_catalog` reason, allowlisted and grouped in `diagnostics.skippedTargets` on `ALL_TARGETS_SKIPPED`, is the difference between a five-minute fix and an afternoon of checking keys. Deleting the earlier `src/lib/model-not-found.ts` scaffolding and its unused helper test in the same PR, rather than leaving it orphaned, is the right call too.

Validation before merge: boarded onto the current release tip together with the other seven PRs of your batch — merged clean, no conflict against the tip or any sibling. `combo-skipped-targets-summary` + `combo-decision-trace` 16 pass / 0 fail, `typecheck:core` clean, `check:changelog-integrity` OK.

One thing I added on your branch: this PR was the only one in the batch to trip `check:file-size`, because both files it threads the new reason through were already frozen **exactly at their measured size with zero headroom** — `src/sse/handlers/chat.ts` had been tightened to 2559 by #14223 two days ago, and `roundRobinCombo.ts` sat at 1261. Your own growth is +1 and +2 respectively (the `errorType === "model_not_found"` early return, and the strict `available !== true` pre-check plus the sticky-target expression that Prettier reflows over three lines at printWidth 100). That is irreducible call-site plumbing, not a quality regression, so it earned a documented rebaseline rather than a bounce: annotation key `_rebaseline_2026_09_22_14069_model_not_in_catalog`, with both ceilings set to the values measured on the reconciled tree *after* the commit. Structural shrink of both god-files stays tracked in #3501.
2026-09-22 19:52:06 -03:00
Ravi Tharuma
d54d31365e feat(oauth): add Muse Code device login with CLIProxyAPI-parity remint (#14329)
Merged — closes #14328. Thank you, @RaviTharuma. This is the biggest PR of your batch and the one I checked hardest.

Native RFC 8628 device authorization against `auth.meta.com`, minting the subscription inference key at `POST https://api.meta.ai/muse-code/key`, persisting the durable `dca:` token, reminting on 401, honoring `error.resets_at` quota cooldowns, and importing CLIProxyAPI `type: meta` auth files — with dual-auth keeping a pasted `META_API_KEY` on the same card and the Responses API wire format unchanged. That last part matters: a new auth path that quietly changes the wire format is how a provider integration breaks for everyone already using it.

**Hard Rule #11 verified, not assumed.** The public client_id goes through `resolvePublicCred("muse_id", "MUSE_CODE_OAUTH_CLIENT_ID")` — never a string literal — and you shipped a test that asserts the resolved shape rather than just the wiring. Eight `resolvePublicCred` call sites in the diff, zero literals.

Validation before merge: boarded onto the current release tip together with the other seven PRs of your batch — merged clean, no conflict against the tip or any sibling. The combined board ran 81 focused tests with 0 failures, including `muse-code-oauth`, `muse-code-models`, `muse-code-provider`, `oauth-providers-config`, `oauth-polltoken-nonjson-body`, `cliproxy-auth-import-1934` and `publicCreds`. `check:file-size`, `check:changelog-integrity` and `check:migration-numbering` (182 migrations, zero duplicates) green.

One small follow-up I am not asking you to do: adding this provider takes the OAuth count to 23, and `docs/architecture/ARCHITECTURE.md` still does not state it, so `check:docs-counts-sync` reports it as a soft drift. Soft is advisory, not blocking — I am folding it into the docs-count reconciliation rather than bouncing the PR for one number.
2026-09-22 19:39:53 -03:00
Ravi Tharuma
afe1a0f9c5 feat(opencode-plugin): escalate stale disk-cache fallback past an opt-in age (#14540)
Merged. Thank you, @RaviTharuma.

The restraint is what makes this one easy to take: unset or `0` stays unbounded, which is the historical default, so nobody's behaviour changes unless they opt in. Past a positive `features.diskCacheMaxAgeMs` the snapshot is **still served** — only the log escalates from warn to error and names the bound. Degrading the signal rather than the service is the right trade for a cache fallback, and deliberately leaving the TUI/toast surface on the separate #13390 track keeps the diff honest at 78 lines.

Validation before merge: boarded onto the current release tip together with the other seven PRs of your batch — merged clean against the tip and against #14088, which touches the same two plugin files; after #14088 landed this one re-reported MERGEABLE with no reconciliation needed. `check:file-size`, `check:changelog-integrity` and `check:migration-numbering` green on the combined tree.
2026-09-22 19:39:27 -03:00
Ravi Tharuma
2e60034d58 fix(opencode-plugin): skip combos fetch when disabled and keep cc/*-low on /v1 (#14088)
Merged — closes #14087. Thank you, @RaviTharuma.

Two small gaps, both worth closing: the provider hook already honored `features.combos: false` but the config hook did not, so a user who disabled combos still paid a `GET /api/combos` on every config load. And keeping the first-class effort-tier ids (`cc/...-low` and siblings) on openai-compatible `/v1` stops OpenCode from 404ing them as `claude-<name>` on provider `claude` — a failure mode that looks like a missing model rather than a routing mistake, which is the worst kind to debug from the user's side.

Validation before merge: boarded onto the current release tip together with the other seven PRs of your batch — merged clean against the tip and against #14540, which touches the same two plugin files. `check:file-size`, `check:changelog-integrity` and `check:migration-numbering` green on the combined tree.
2026-09-22 19:38:26 -03:00
Ravi Tharuma
2def447b15 fix(compression): keep lossy engines off the default path (#14529)
Merged. Thank you, @RaviTharuma — and thank you for naming whose design this is instead of quietly absorbing it.

Two good calls landed here:

- **Lossy stays off the default path.** Session dedup and whitespace folding still run when compression is on; summaries, relevance filters and style rewrites now require an explicit `x-omniroute-compression: allow-lossy`, `engine:<id>`, or a named combo. Silently rewriting a user's prompt is exactly the class of default this project should not have.
- **Combo 400 handling became a table instead of another string guard.** `statusDecisionTable.ts` with `advance` | `stop` rows is reviewable in a way a growing chain of substring checks never is: model-scoped wrappers advance, `Invalid message format` still stops, and a 400 no longer opens the provider-wide breaker.

Migrating the seeded Standard Savings combo to `session-dedup` + `lite` only when it is still the untouched RTK + Caveman seed — and leaving a renamed or edited combo alone — is the right conservative shape for a seed migration.

Validation before merge: boarded onto the current release tip together with the other seven PRs of your batch — merged clean, no conflict against the tip or any sibling. The combined board ran 81 focused tests across the batch with 0 failures, including `combo-status-decision-table`, the four compression suites and `strategySelector`; `check:file-size`, `check:changelog-integrity` and `check:migration-numbering` green.

Co-authored-by: Bob.Hou <19586012+HouMinXi@users.noreply.github.com>
Co-authored-by: Koosha Paridehpour <42529354+KooshaPari@users.noreply.github.com>
2026-09-22 19:37:18 -03:00
Ravi Tharuma
1c957d9508 docs(authz): document independent scope namespaces (#14526)
Merged — closes #13391. Thank you, @RaviTharuma.

The valuable part here is the sentence that was missing everywhere: **a pass in one namespace is not a pass in another**. Someone reading `AUTHZ_GUIDE.md` alone could reasonably conclude a `manage` key satisfies `read:compression`, and the old `MCP_SCOPE_LIST` block made it worse by presenting a partial list as the full catalog. Documenting the three namespaces side by side — API-key management scopes, MCP tool scopes via `scopeMatches` (exact string or trailing `*`), and access-token ranks via `scopeSatisfies` — with the two concrete counter-examples is what makes it usable rather than merely accurate.

Validation before merge: boarded onto the current release tip together with the other seven PRs of your batch — merged clean, no conflict against the tip or any sibling. Markdown-only diff, no production code, so nothing to weigh on the ratchets; `check:changelog-integrity` green on the combined tree.
2026-09-22 19:36:43 -03:00
Diego Rodrigues de Sa e Souza
373d784e09 docs: migration count 178 -> 181 in README, AGENTS.md and llm.txt (+ mirrors) (#14559)
The Docs Gates job was red on the release tip: three migrations landed
(179, 180, 181) without the count claims being touched. check:docs-counts
is green again; the 66 llm.txt mirrors regenerated with sync-llm-mirrors.
2026-09-22 19:14:14 -03:00
Diego Rodrigues de Sa e Souza
ea3c12264b docs(i18n): refresh the mirrors of the 8 docs the base edited on 2026-09-22 (66 locales) (#14532)
* docs(i18n): refresh the mirrors of the 8 docs the base edited on 2026-09-22 (66 locales)

* docs(i18n): re-adopt mirrors reformatted by the pre-commit hook

* docs(i18n): refresh ENVIRONMENT and FEATURE_FLAGS mirrors again and add the 3 new keys to bs

* docs(i18n): re-adopt mirrors reformatted by the pre-commit hook
2026-09-22 14:39:54 -03:00
Diego Rodrigues de Sa e Souza
9f4d3b0ab8 fix(api): apply the per-key operator policy on every /v1/files and /v1/batches handler (omni-code-sec LEDGER-2) (#14495)
LEDGER-2 (#14481): every /v1/files and /v1/batches handler now runs enforceApiKeyPolicy() after the 401 fold, so the per-key operator policy (endpoint allowlist, schedule, usage cap, rate limit) applies to the file/batch surface — including the ungated x-api-key transport that bypassed the gate (omni-code-sec round 2).
LEDGER-27: uploads/batches/sweeps on the x-api-key transport are attributed to the key the policy resolved (resolveEffectiveApiKeyId) instead of persisting apiKeyId: null; LEDGER-28: direct coverage of the 401 fold. LEDGER-29 (fold vs #2257 degrade-to-anonymous) stays a product decision on #14332/#14481.
CI reds at merge time are all inherited from base-red #14496 (triage in the PR comments).

Refs #14481
2026-09-22 13:52:32 -03:00
Dizzle
66688599b2 fix(sse): rotation attribution diagnostics (skipped accounts, serving id, request correlation) (#14223)
Merged. Thank you, @maxmad64bis — and thank you for putting the whole thing behind an off-by-default flag, which is what made it possible to take a 25-file observability change late in a cycle.

"Rotation passes over cooling-down accounts, traffic concentrates, and nothing says why" is a real operator problem, and naming the skipped accounts, exposing per-account rotation state, and carrying the serving account plus request correlation onto proxy log rows is the right set of three. No selection behaviour changes.

This one needed the most reconciliation of the batch, because `opencode.ts` moved four times under it. What was found and fixed, so it is on the record:

**1 — Migration collision that would have stopped the app from booting.** You added `182_proxy_logs_rotation_account.sql`, but `182_request_cost_ledger_and_key_quota.sql` landed on the release while this was open. `getMigrationFiles()` in `src/lib/db/migrationRunner.ts` detects version collisions and **throws**, so a fresh install would have failed at startup — and the `case "182":` arm you added to `isSchemaAlreadyApplied()` would have answered for the *other* migration's schema and skipped it, exactly the hazard your own comment warns about. Renumbered to **183/184**, with `migrationRunner.ts`, the test file name and its contents, and the rebaseline note all moved with them. Proven by `tests/unit/migration-135-numbering-collision.test.ts` (which runs against the *real* migrations directory, unlike the PR's own test which copies into a temp dir), and mutation-proved by re-adding the duplicate and watching it fail.

**2 — A dangling reference git never flagged.** #14353 moved the rotation member list off the shared instance, deleting `this.accounts`. Your `noteRotationAccount` site read `this.accounts.length` and auto-merged with no conflict marker. Fixed to the per-request `accounts`, along with three sibling call sites and `snapshotEntries()`, which had to be re-signed to take the list.

**3 — Two rotation exits were missing the attribution flush.** The `runParkAndReplay` returns in the 429 arm returned without flushing `skippedCooldown`, so a parked-and-replayed request lost its skipped-account line. Added with the same `attributionOn && skippedCooldown.size > 0` guard the other exits use — there are now nine guarded exits. The single-account fast path needs none (`skippedCooldown` is provably empty there).

Validation on the final tree: rotation-attribution suites 4+2+2, `proxy-logger-attribution` 4, `migration-183-184` 3, `migration-135-numbering-collision` 2, `resilience-connections-rotation` 2, `feature-flags-settings` 63, plus the six opencode suites (5+4+34+9+38+8+9) — all green. `opencode-executor` is 58/59 with `"omits accept header when stream is false"` failing identically on a pure-tip control checkout, so inherited. `typecheck:core` clean, `check:file-size` OK, `check:changelog-integrity` OK. `check:open-sse-typecheck` fails with exactly the 8 inherited `auggie.ts` errors (#14496) and nothing naming a file this PR touches.

Flag count reconciled to 77; `ROTATION_ATTRIBUTION` stays `defaultValue: "false"`.

Two things to be aware of going forward:

- `open-sse/executors/opencode.ts` (1318) and `src/sse/handlers/chat.ts` (2559) now sit **exactly at their ceilings**, with zero headroom — the previous 1386 entry was ~68 lines of phantom headroom and was tightened to the measured value. The next PR touching either will need a rebaseline of its own.
- `check:docs-all` reports the migration count drift (README/AGENTS/llm.txt say 178; the tip alone is at 179 and these two take it to 181). Left untouched on purpose: those are agent-instruction surfaces and the count is reconciled on the merge train, not per-PR.
2026-09-22 13:43:20 -03:00
Dizzle
a61b34d7f2 fix(opencode): keep each request on its own member list (#14353)
Merged. Thank you, @maxmad64bis — this and #14149 are the same class of bug caught twice, and both were worth catching.

Two requests sharing one executor walking one shared member list means one request's re-sync can replace the other's picks — silently, and only under concurrency, which is exactly why it never shows up in a single-request test. Keying the list on request body identity while keeping the shared pick index (clamped to the local list) preserves the dispatch order for a single request, so the fix costs nothing in the common case. Per-member health in a named store with write-back is the right place for the state that genuinely should outlive the request.

Validation before merge: re-reconciled after #14149 landed, which is where the real work was. #14149 had extracted the MuseSpark block out of `opencode.ts` into its own module and wrapped `execute()` in `runInRequestContext(...)`, while #14464 had added the free-tier completion-retry — so `execute()` had to carry three things at once. Final shape:

```ts
async execute(input: ExecuteInput) {
  try {
    return await runInRequestContext(() =>
      withRequestShapeRetry(input, (i) => this.executeOnce(i))
    );
  } finally {
    releaseRequestList(input.body, this.accountHealth);
  }
}
```

The `finally` is outermost on purpose: it runs once, after every shape replay and after the request context exits, against the same `input.body` the list was keyed on.

I checked the things a clean-looking merge can quietly break here: MuseSpark is not duplicated back inline (it is only imported and re-exported), #14149's `_requestFormat`/`_clientSession` accessors and context wrap are intact, and #14464's four wiring points are byte-identical to the tip. The diff against the tip contains only your delta — no tip line reverted.

`opencode-accounts-per-request` 5/5, plus the sibling suites that would catch a botched resolution: `opencode-request-format-race` 4/4, `opencode-request-shape-retry` 34/34, `opencode-free-tier-refusal-rotation` 9/9, `opencode-free-tier-request-contract` 38/38 — **90 pass / 0 fail**. `typecheck:core` clean, `check:file-size` OK (`opencode.ts` at 1224 against the 1303 ceiling, no rebaseline needed), `check:changelog-integrity` OK.

Note: the release tip is currently base-red on `open-sse/executors/auggie.ts` (#14496). Inherited, unrelated to this diff.
2026-09-22 12:59:42 -03:00
Dizzle
8bf6b60a4b fix(sse): retry empty translated stream turns through the normal path (#14213)
Merged. Thank you, @maxmad64bis.

Four distinct shapes of "the turn came back empty" — reasoning-only 200, zero-chunk 502, pre-first-byte drop, header-then-quiet stall — collapsed into one retry through the normal credential path, with a bounded `EMPTY_TURN_RETRY_MAX` budget and the stall gated on `STREAM_READINESS_TIMEOUT_MS`. Retrying server-side *before* anything reaches the client is the right layer for this; the related issues you listed (#11902 client-side signalling, #12656 first-byte watchdog, #13603 combo-level hop) each sit one stage away, and you were explicit about which.

The thing that made this safe to take: `FLUSH_EMPTY_RETRY_ENABLED` defaults to `"false"` and the flag-off path is byte-identical. Verified on the final tree, not taken on trust.

Validation before merge: reconciled twice as the tip moved under it. The only conflict each time was the additive `_rebaseline_*` key at the top of `config/quality/file-size-baseline.json` — resolved keep-both, JSON re-parsed, 523 keys, every tip entry preserved. `flush-empty-retry` + `flush-empty-retry-hook` + `feature-flags-settings` + `server-owned-tool-loop-flag` **100 pass / 0 fail** across 12 suites on the final tree. `check:file-size` OK, `check:changelog-integrity` OK, `typecheck:core` clean.

Two numbers I re-measured rather than trusting:

- The feature-flag count. Both count assertions said 76; the tip has 75 and this PR adds exactly one, so 76 is right and needed no change. `docs/reference/FEATURE_FLAGS.md` totals and the `### Network (18)` heading agree.
- `open-sse/handlers/chatCore.ts` — your ceiling of 6392 was exact on your fork base but stale; the reconciled tree measures **6400**, which is your `+113` own growth on top of the tip's 6287. Set to 6400. Note that leaves the file sitting exactly at its ceiling with zero headroom, so the next PR to touch `chatCore.ts` will need a rebaseline of its own.

Note: the release tip is currently base-red on `open-sse/executors/auggie.ts` (#14496). Inherited, unrelated to this diff.
2026-09-22 12:42:22 -03:00
Dizzle
59de50e42a fix(opencode): keep the target format and client session per request, not on the shared executor (#14149)
Merged. Thank you, @maxmad64bis — this is the best-diagnosed bug of the batch.

Instance fields written at request start and read after later awaits is a textbook cross-request race, and the symptom you traced it to is a nasty one: a JSON caller of a Responses model getting `event: response.completed …` back because a Chat request happened to finish first. Moving target format and client session into a per-`execute()` context while keeping the public field names as accessors is exactly the minimal shape — callers outside `execute()` see no change at all.

Validation before merge: re-reconciled twice, because the tip moved under it. #14148 had already squash-merged, so the branch's copy of it collapsed onto the tip's identical content — `changelog.d/fixes/14148-opencode-refused-tool-shape.md`, `opencodeRequestShape.ts`, `opencodeFreeTierContract.ts` and `opencode-request-shape-retry.test.ts` all merged to one copy, nothing duplicated. The final delta against the tip is byte-exactly your commit `386c427e`: the MuseSpark extraction, the accessor pair, and the `runInRequestContext` wrap. Nothing of #14464's completion-retry wiring was reverted — its import, `freeTierRetryCtx()`/`attemptFor(input.body)`, the direct fast-path call and the rotation-loop refusal arm are all byte-identical to the tip.

`opencode-request-format-race` + `opencode-request-shape-retry` + `opencode-free-tier-refusal-rotation` + `opencode-free-tier-request-contract` 85 pass / 0 fail. `typecheck:core` clean, `check:file-size` and `check:changelog-integrity` OK — the extraction actually *shrinks* `opencode.ts` to 1233 against the 1303 ceiling, so no rebaseline was needed.

Note: the release tip is currently base-red on `open-sse/executors/auggie.ts` (#14496). Inherited, unrelated to this diff.
2026-09-22 12:19:39 -03:00
Dizzle
b5d6404a90 fix(rate-limit): persist per-connection overrides across restart and re-import (#14226)
Merged. Thank you, @maxmad64bis.

Two distinct bugs, one symptom, both correctly diagnosed: limiters were built *before* the overrides loaded, and the re-import `INSERT OR REPLACE` dropped the `rate_limit_overrides_json` column entirely. An operator setting a per-connection limit and losing it on the next restart — silently — is the kind of thing nobody reports as a bug because it just looks like the setting "didn't take". Loading before building and carrying the column on both re-import paths (preserving the stored value when the backup has none) covers it end to end.

Validation before merge: reconciled onto the current release tip — the merge was conflict-free, so no hunk was dropped or reverted anywhere; the diff against the tip is still exactly your 9 files. `tests/unit/rate-limit-overrides-{reimport,restart,startup}.test.ts` 9 pass / 0 fail. `typecheck:core` clean, `check:changelog-integrity` OK, `check:file-size` OK — I re-measured `src/lib/db/core.ts` on the reconciled tree with the gate's own count and it is 1800, exactly the ceiling you set, so your number needed no correction.

Note: the release tip is currently base-red on `open-sse/executors/auggie.ts` (#14496). Inherited, unrelated to this diff.
2026-09-22 12:18:15 -03:00
Dizzle
30a83ee8d0 fix(opencode): complete refused tool subsets with previously accepted names (#14464)
Merged — closes #14405. Thank you, @maxmad64bis.

The shape of the fix is right: a gated request that carries its own tools now gets the same completion the tool-less path already had (previously accepted names appended once, client entries unchanged), and when the completed shape is *also* refused the original 403 propagates with the observation store untouched — so a failed recovery leaves no residue to poison the next request. Reusing each arm's egress and never touching account health on failure is the detail that makes this safe to enable by default.

Validation before merge: reconciled onto the current release tip. Two conflicts and one silent break:

- `open-sse/executors/opencode.ts` import block and the `_rebaseline_*` key in `config/quality/file-size-baseline.json` — both additive, kept both sides.
- **The silent one, worth your attention:** `freeTierRetryCtx()` read `this._contractAttempt`, a field #14148 deleted when it moved the contract attempt onto the request body. Git auto-merged your new method next to the tip's field deletion without a marker, leaving two dangling references that would have failed the build. Rewritten to the tip's shape — `attemptFor(input.body)`, with `input` threaded through both call sites (fast path and the rotation-loop refusal arm). `FreeTierContractAttempt` still carries `borrowed` and `clientToolNames`, so the semantics are unchanged.

`opencode-free-tier-request-contract.test.ts` + `opencode-free-tier-refusal-rotation.test.ts` 47 pass / 0 fail, `check:file-size` and `check:changelog-integrity` green.

Your `open-sse/executors/opencode.ts` ceiling needed re-measuring twice: your original 1294 was against a tip since moved to 1251, and the reconciled tree measures **1303** (the Prettier pass on commit added two more lines than the first measurement caught). Set to 1303 with the annotation corrected.

Note: the release tip is currently base-red on `open-sse/executors/auggie.ts` (#14496). That is inherited and unrelated to this diff — verified identical on a pure-tip control checkout.
2026-09-22 11:37:57 -03:00
Dizzle
9e44a5d1cc style(proxy-registry): bring frozen files back to prettier format with rebaseline (#14218)
Merged. Thank you, @maxmad64bis.

Format drift on a frozen file is a real tax: the next person to touch `ProxyRegistryManager.tsx` or `parseBulkProxyImport.ts` either absorbs unrelated reflow into their diff or trips the frozen ceiling for something they did not write. Clearing it in a dedicated no-behavior PR is the right way to pay it.

I checked the one thing worth checking on a "format-only" claim that deletes 17 net lines from a test file: `tests/unit/proxy-registry-manager.test.ts` loses no assertion. Every deletion is Prettier joining a multi-line `[...].join("\n")` array or a wrapped call onto one line; all 35 `assert.equal` calls are untouched.

Validation before merge: reconciled onto the tip (the only conflict was the additive `_rebaseline_*` key at the top of `config/quality/file-size-baseline.json`, resolved keep-both). `tests/unit/proxy-registry-manager.test.ts` 35 pass / 0 fail on the final tree, `check:file-size` and `check:changelog-integrity` green.

One correction I made to your baseline entry: you froze `open-sse/utils/proxyFetch.ts` at 1275, which was exact on your fork base but stale on the tip — the file is 1268 there now (`split("\n").length`, the same count `scripts/check/check-file-size.mjs:81` uses). Set to 1268 and the annotation re-worded to say it was re-measured on the reconciled tip. The file is not one this PR touches; it was inherited drift you were absorbing.
2026-09-22 11:36:01 -03:00
Dizzle
652c4f3636 feat(plugins): register host sdk telemetry hook behind opt-in flag (#14235)
Merged. Thank you, @maxmad64bis.

The design is what made this easy to carry: gated on a new `telemetry` option that is `z.boolean().default(false)`, inert on a host that has no `aisdk` domain, marking matching inference calls in their options only (filtered by provider and SDK package), no fetch wrapping, no client assignment, and any registration failure degraded to a warning. Five tests, one per property.

One thing happened on our side and you should know exactly what: #14370 landed first and rewrote `src/index.ts` against the stable `@opencode/plugin` 2.0.12 contract, which collapses the per-event `aisdk` domains into a single named entry point `ctx.aisdk.hook(name, cb)`. Your registration block was written against the beta callable `ctx.aisdk.sdk(cb)`, so it was re-applied onto the new shape rather than merged as-is — mirroring how #14370 wired the sibling Gemini `"language"` hook. The `"sdk"` event on the stable contract carries exactly the three fields your callback already read (`model` / `package` / `options`), so nothing was lost: **the callback body is unchanged**.

In the test file, every `it(...)` body and every assertion is byte-identical to yours. Only the host fake moved, and only because it had to: `ctx.catalog` → `ctx.provider` + `ctx.model` (the tip's `assertContext` now requires those transforms and no longer knows `catalog`), and the two callable domains → one `hook(name, cb)` router — the same fake the tip's own `gemini-language.test.ts` uses. The header comment was updated where it cited the old contract version.

Validation before merge: `telemetry-sdk-hook.test.ts` 5 pass / 0 fail; the full `@omniroute/opencode-plugin-v2` suite 244 pass / 0 fail across 65 suites (= #14370's 239 plus your 5); `npm run build` and `tsc --noEmit` over src + tests under `strict: true` both exit 0; `typecheck:core`, `check:file-size` and `check:changelog-integrity` green. The net change to `index.ts` is purely additive — the few removed lines are prettier reflow of #14370's own lines.
2026-09-22 11:25:09 -03:00
Dizzle
007ea22f5f feat(proxies): show each pool member's last seen egress IP (#14364)
Merged. Thank you, @maxmad64bis — and thank you for taking the three conditions from #14250 seriously instead of reopening the same shape.

All three are answered in this PR: the helper ships with its consumer (`readPoolMemberEgressObservation` → dedicated route → dashboard lines), `proxy_host` is normalized at *write* time in `logProxyEvent` with a divergent stored-vs-query test proving a stored `Proxy.Example.COM` / `[::1]` now resolves, and the operator-visible measure is stated up front (15 distinct `(host, port)` couples over 24 h, 8 of them sharing an exit — the pool counts N members but serves fewer exits).

Validation before merge: reconciled onto the current release tip — the only conflict was the additive `_rebaseline_*` key at the top of `config/quality/file-size-baseline.json`, resolved keep-both; all 66 locale files auto-merged. `tests/unit/proxy-logs-egress-ip-by-proxy.test.ts` + `tests/unit/proxy-pool-member-egress-route.test.ts` 14 pass / 0 fail under the node runner, and `tests/unit/ui/PoolMemberEgressLines.test.tsx` 4 pass / 0 fail under Vitest (it is a `@vitest-environment jsdom` file, so it belongs to the `test-vitest:ui` job, not the node runner). `typecheck:core`, `check:file-size` and `check:changelog-integrity` green. Your `ProxyRegistryManager.tsx` ceiling of 1479 was re-measured on the reconciled tree and is exact (1477 + the import + the one-line mount).

Two things I checked and deliberately left as you wrote them:

- The edit to `src/lib/db/migrations/134_proxy_logs_egress_ip.sql` touches only the `--` comment; the `ALTER TABLE` line is byte-identical and the runner does not checksum migration contents, so an already-migrated install is unaffected.
- The three new keys land as `__MISSING__:` in the 65 non-English locales. That is the expected shape for a new key, and I am filling them in a follow-up rather than asking you to — nothing for you to do.

The route is correctly behind `requireManagementAuth` with Zod validation and `errorResponse()`, so no stack reaches a response body.
2026-09-22 11:23:28 -03:00
Dizzle
30f7088c74 fix(opencode-plugin-v2): port catalog publishing to stable provider contract (#14370)
Merged. Thank you, @maxmad64bis.

The reasoning holds: the stable 2.0 host no longer provides the beta `@opencode-ai/plugin` contract the plugin was written against, so the plugin simply failed to load there — moving catalog publishing to `Plugin.define` with `ctx.provider.transform` against the stable `@opencode/plugin` peer range is the fix rather than a preference. The diff is large, but it is contained entirely inside the `@omniroute/opencode-plugin-v2` workspace package and carries its own suite.

Validation before merge: boarded onto the current release tip together with four sibling PRs of yours — merged clean, no conflict against the tip or the siblings. `check:file-size`, `check:changelog-integrity` and `typecheck:core` green on the combined tree, and the boarded unit suites 36 pass / 0 fail.

Note for the follow-up: #14235 (the sdk telemetry hook) conflicts with this one on `src/index.ts` and will be re-applied on top of the new shape rather than merged as-is — nothing for you to do, I'll handle the reconciliation.
2026-09-22 10:40:29 -03:00
Dizzle
7d5292fcac fix(health): opt-in deep check sampling completions surface (#14236)
Merged. Thank you, @maxmad64bis.

A deep check that actually samples the completions surface is worth having, and this one is gated the way it should be: `DEEP_HEALTH_CHECK_ENABLED` plus management authentication, off by default, inert without configuration, anonymous callers never trigger a probe, one-token non-streaming request, 30 s cache, and only 502/503 raising the failover signal so a 4xx or a timeout cannot flap a healthy instance.

Validation before merge: boarded onto the current release tip alongside four sibling PRs of yours — merged clean, no conflict. `tests/unit/monitoring-deep-health.test.ts` green on the combined tree — all ten cases including flag-off inertness, the anonymous path and the cached-verdict identity (36 pass / 0 fail across the boarded suites), plus `check:file-size`, `check:changelog-integrity` and `typecheck:core`.
2026-09-22 10:40:15 -03:00
Dizzle
f577d2124e fix(usage): keep over-age pending requests visible with a marked state (#14319)
Merged. Thank you, @maxmad64bis.

Deleting an over-age pending entry hid exactly the signal an operator needs — a stuck request. Keeping it with a distinct marked state, leaving the 60 min limit, the 5 min cleanup cadence and the shared registry alone, and only letting the size cap still evict (marked-first, oldest-first) is a conservative way to get that signal back.

Validation before merge: boarded onto the current release tip alongside four sibling PRs of yours — merged clean, no conflict. `tests/unit/usage-pending-sweep.test.ts` green on the combined tree, including the idempotent re-pass, the single-finalize path and the cap-eviction ordering (36 pass / 0 fail across the boarded suites), plus `check:file-size`, `check:changelog-integrity` and `typecheck:core`.
2026-09-22 10:39:44 -03:00
Dizzle
30d0158272 fix(logging): omit request body before pipeline in call-log size fallback (#14253)
Merged. Thank you, @maxmad64bis.

The ordering argument is the right one: when an artifact overflows, the provider response is the part that says *why* the call failed, so dropping the whole pipeline before omitting the bodies threw away the only useful half. Omitting bodies first keeps that, and artifacts that overflow through the pipeline alone keep their previous behaviour — no cap raised, `truncateArtifactForStorage` untouched.

Validation before merge: boarded onto the current release tip alongside four sibling PRs of yours — merged clean, no conflict. `tests/unit/call-log-artifact-bodies-first.test.ts` green on the combined tree (36 pass / 0 fail across the boarded suites), plus `check:file-size`, `check:changelog-integrity` and `typecheck:core`.
2026-09-22 10:39:34 -03:00
Dizzle
84988bd355 test(providers): pin opencode 400 model-unavailable catalog rule at registry level (#14251)
Merged. Thank you, @maxmad64bis.

A registry-level pin for a rule that shipped in #13146 is exactly the kind of guard that stops a future edit from silently widening or dropping it, and the three cases (marked 400 locks the model, unmarked 400 stays terminal, cooldown comes from the rule) pin the behaviour at the right boundary.

Validation before merge: boarded onto the current release tip alongside four sibling PRs of yours — merged clean, no conflict. `tests/unit/opencode-400-model-unavailable-registry.test.ts` green on the combined tree, together with the other boarded suites (36 pass / 0 fail). Test-only change, no production file touched, so nothing to weigh on the size or complexity ratchets.
2026-09-22 10:39:26 -03:00
Jasmin Sehic
373c31f3dc feat(i18n): add Bosnian (bs) localization (#14187)
* feat(i18n): add Bosnian (bs) localization

* docs(i18n): re-adopt mirrors reformatted by the pre-commit hook

* test(i18n): count 67 locale files and accept the Bosnian 'Model' cognate; restore the bs ICU placeholder quotes

The Bosnian locale adds a 67th messages file (agnes-cn-provider pins the
count), 'Model' is the Bosnian word for the Recent Requests legend (same
cognate as hr/sr) and the retranslation had dropped the ICU literal quotes
around <your OmniRoute API key> in ccOnboardingKeyPlaceholder.

* docs(i18n): re-adopt mirrors reformatted by the pre-commit hook

* docs(i18n): regenerate the language bars and adopt the bar-only source edits after the base merge

* docs(i18n): re-adopt mirrors reformatted by the pre-commit hook

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-22 09:28:43 -03:00
Diego Rodrigues de Sa e Souza
1c213eb156 fix(review): resolve findings from omni-code-review battery (release/v3.8.51 vs main, api/v1 + image handlers) (#14365)
Fixes from the 2026-09-21 E2E run of /omni-code-review (15 lenses, 3 rounds): delete-completed keyState audit without the redundant re-validation, honest image_size logging with a warn on clamp, one shared fetchUntrustedRemoteImage and one shared stringifyImageErrorForLog for both image sinks, tests. Follow-ups: #14332-#14335.
2026-09-22 08:54:21 -03:00
Diego Rodrigues de Sa e Souza
2c9938d14e fix(ci): raise Docker publish build heap budget to 12288 MB (#14325) (#14469) 2026-09-22 08:15:11 -03:00
Diego Rodrigues de Sa e Souza
07103e2f12 fix(providers): surface real transport cause instead of bare "fetch failed" (#14309) (#14463)
open-sse/utils/proxyFetch.ts already computes a detailed transport
diagnosis (DNS/socket error code, syscall, address) whenever a direct
fetch fails on both the pooled undici dispatcher and the native-fetch
fallback, attaching it to the thrown error as `.proxyFetchDetail`. But
src/lib/providers/validation/transport.ts::toValidationErrorResult()
only ever read `error.message` — always the generic "fetch failed"
string by undici/native-fetch design — and never `error.cause` (where
SafeOutboundFetchError's normalizeFetchFailure() stores the original
error carrying `.proxyFetchDetail`). The computed diagnosis was
silently discarded before it ever reached the dashboard.

toValidationErrorResult() now walks one level of `error.cause` when
the message is the generic "fetch failed" string and, if a
`.proxyFetchDetail` is found there, surfaces it (still routed through
sanitizeErrorMessage()) instead of the bare message.

Also extracted the direct-path detail-string builder (and the existing
redactProxyDetailsInMessage() redaction helper) out of proxyFetch.ts
into a new open-sse/utils/proxyFetchRedaction.ts module, applying the
same proxy-URL/credential redaction to the direct-path detail that the
proxy-path message already had — closing a latent redaction gap
between the two branches — without growing proxyFetch.ts past its
frozen file-size baseline.

This does not explain the reporter's own underlying transport failure
(likely local network/DNS/firewall config on their machine); it makes
that failure diagnosable instead of opaque.
2026-09-22 08:14:49 -03:00
Diego Rodrigues de Sa e Souza
38cbb7ef80 fix(sse): stamp encrypted_function_args:[] on collaboration function_calls (#14154) (#14447)
Codex MultiAgent V2 treats a collaboration function_call's arguments as
backend ciphertext unless the item carries an explicit
encrypted_function_args: [] plaintext-delivery marker (codex-rs
ToolCall::direct_source). The vendored ChatGPT-web bridge already emitted
this marker for spawn_agent/send_message/followup_task via its own local
plaintextCollaborationFields() helper, but the three general Responses-API
output emitters used for every non-OpenAI chat-format upstream never did:
the streaming translator (translator/response/openai-responses.ts), the
non-streaming client translator (handlers/chatCore/nonStreamingClientTranslate.ts),
and the Responses transformer (transformer/responsesTransformer.ts).

Extracts the marker into a shared
translator/response/openai-responses/collaborationPlaintextMarker.ts helper
(bridge.ts now imports it instead of keeping its own copy) and a
functionCallIdentity.ts helper that composes the existing #7936 identity
restoration with the new marker for the streaming translator. Wires the
Responses transformer with the same requestToolIdentityMap plumbing the
other two emitters already had (it previously restored neither namespace
nor the marker at all), threading the ledger through from
handlers/responsesHandler.ts before handleChatCore deletes the side
channel.
2026-09-22 08:14:12 -03:00
Diego Rodrigues de Sa e Souza
582a2027e8 fix(sse): parse deepseek-web double-pipe DSML invoke/parameter markup (#14208) (#14446)
deepseek-web's tool-call parser only recognized tags literally named
`tool`/`tool_call`. Some harness builds instead emit a well-formed grammar
wrapped in doubled full-width-pipe "DSML" namespace markers
(`<||DSML|| calls>`, `<||DSML|| invoke name="...">`,
`<||DSML|| parameter name="...">`), which neither this parser, the generic
single-pipe DSML normalizer, nor the canonical <tool> fallback recognized —
the block was silently dropped and the raw DSML text leaked to the user.

Add normalizeDsmlInvokeMarkup() to rewrite the DSML calls/invoke/parameter
grammar (single- and double-pipe) into the canonical <tool>/<parameter> tags
before tokenization, so parseDeepSeekToolCalls resolves it into a proper
tool_calls entry.
2026-09-22 08:13:45 -03:00
Diego Rodrigues de Sa e Souza
61b3546b17 fix(guardrails): fail closed on unknown-root paths in ambiguous prose (#14110) (#14445)
* fix(guardrails): fail closed on unknown-root paths in ambiguous prose (#14110)

PR #13295 narrowed findUnquotedPathEnd's ambiguity fallback to
failClosedAmbiguity only, but the redactUnquotedAbsolutePathSpans call site
only set that flag for Windows/file-URI/known-POSIX-root candidates. An
unquoted POSIX path whose root is not in POSIX_FILESYSTEM_ROOTS (e.g.
/custom/internal in a container), followed by ambiguous prose, fell through
unredacted -- regressing HR#12 (never leak err.message/paths).

Per owner decision on #14110 (fail-closed), extend mustFailClosed in
redactUnquotedAbsolutePathSpans to also cover an unknown-root POSIX candidate
(isPosixPath) once findUnquotedPathEnd returns -1. This intentionally also
fail-closes a bare, unanchored API route in prose (route- and filesystem-
shaped candidates are lexically indistinguishable with no second anchor) --
the #13144 guarantee that a route immediately followed by another absolute-
path span keeps its trailing prose is unaffected and re-asserted as a
co-located regression test.

Reactivates the #14110-SUSPENDED assertion in
error-public-boundaries-hardening.fixture.ts and folds in the proven repro.

* chore(quality): register the #14110 redaction test in stryker tap.testFiles
2026-09-22 08:13:21 -03:00
Diego Rodrigues de Sa e Souza
6c8a3958fd perf(release): resolve fragment origins with one git history walk in release:reconcile (#14440)
readFragments ran one `git log --diff-filter=A -- <file>` per fragment (≈4.5 s each on a loaded devbox → 364 fragments ≈ 30 min in the v3.8.51 round-3 pass) plus one `git show` per fragment. It now builds a path→oldest-adding-commit map from a single `git log --diff-filter=A --name-only` walk over changelog.d/ and reads every blob with one `git cat-file --batch`; the same fields (originHash/originPr/prefixPr/text) come out, phantom fragments keep their original origin. Measured: 5.3 s for the whole pass on the same box. Tests: tests/unit/reconcile-changelog-fragments.test.ts (temp git repos).
2026-09-22 08:12:55 -03:00
Diego Rodrigues de Sa e Souza
e21f17ed89 fix(api): settle logs export stream on mid-stream DB error (#13999) (#14439)
* fix(api): settle logs export stream on mid-stream DB error (#13999)

* refactor(api): extract the log-export stream builder to clear the new-code complexity ratchet (#13999)
2026-09-22 08:12:08 -03:00
Nguyen Thanh Dat
7a179de486 fix(translator): keep a Claude image whose source is an HTTPS URL (#14460)
* fix(translator): keep a Claude image whose source is an HTTPS URL

Claude accepts an image block as { source: { type: "url", url } } next to
base64. claude-to-openai.ts took that in its image case (592ca9b5c, 05/04)
but not in the tool_result branch below it, where image lifting was added
for base64 only (7b139fdb5, #5100, 27/06): a tool returning a URL image had
the raw block JSON.stringify'd into the tool message. claude-to-gemini.ts
had no URL branch at all — #13335 lifted base64 tool_result images there and
said so in its notes, leaving the URL half for a follow-up. This is it.

Lift a URL tool_result image into the following user turn, as the base64
half already does, and send a URL image to Gemini as fileData { fileUri },
the mapping helpers/geminiHelper.ts uses for an OpenAI image_url that is a
URL. HTTPS only, as validation/schemas/apiV1.ts already requires of every
media URL and as Gemini documents for an external fileUri: an empty, http:,
data: or file: url stays dropped instead of reaching Gemini as a fileUri it
would reject.

* docs(changelog): add fragment for #14460

---------

Co-authored-by: datrixlab <325650023+datrixlab@users.noreply.github.com>
2026-09-22 08:11:34 -03:00
小妍儿 ✨
49de88f621 fix(resilience): release admission lease on client abort after SSE response (#14456) (#14457)
* fix(resilience): release admission lease on client disconnect (#14456)

releaseChatAdmissionWhenDone wired lease release into three consumer-driven
paths only: pull-to-done, pull-throws, and cancel. A client that disconnects
mid-stream may stop pulling and never cancel, so none of them run and the
heavyweight slot is charged for the lifetime of the process. Once activeHeavy
reaches OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT every heavy request is shed with
503 chat_admission_busy, with waiting=0 and no real load.

Observe the request signal as a fallback release path and route all five call
sites through it. Release is idempotent so a late abort after a clean finish
cannot double-decrement.

* refactor(admission): extract lease release into chatAdmissionRelease

check:file-size freezes chatBodyAdmission.ts at 1206 lines and the fix pushed
it to 1242. Move the release binding into its own module and re-export it from
the original path, so every existing import site is unchanged. File is now
1159 lines; check:file-size passes.

---------

Co-authored-by: xiaoyaner0201 <xiaoyaner0201@users.noreply.github.com>
2026-09-22 08:10:27 -03:00
Goni Sulaiman
e2dc851a7f fix: repair rotted import paths and a drifted test fixture (#14412)
Detected by a full-repo Grounded (reference-integrity) audit and confirmed
with targeted tsc probes against the root tsconfig.json paths:

- src/lib/db/discovery.ts: logger import "../../open-sse/utils/logger"
  cannot resolve from src/lib/db/ (TS2307); use the @omniroute/open-sse
  alias like the sibling DB modules. Masked in CI because typecheck:core
  compiles an empty include set.
- scripts/ad-hoc/regen-opencode-config.ts: import and Usage header still
  pointed at the pre-move root scripts/ location (TS2307); fixed both so
  the script runs exactly as documented.
- tests/unit/combo/recovery-hint.test.ts: import renamed the ghost type
  RecoveryAction -> ComboRecoveryAction (TS2305) and rebuilt the
  emptyDiag() fixture on the current ComboDiagnostics shape (post-#12659);
  all 10 behavioral assertions unchanged.

Validation: node --import tsx/esm --test tests/unit/combo/recovery-hint.test.ts
(11/11 pass), scoped tsc probe (0 errors), eslint clean on changed files.

Audit reference: https://github.com/gonisulaimann/Grounded

Co-authored-by: Goni Sulaiman <gonisulaimann@users.noreply.github.com>
2026-09-22 08:07:54 -03:00
Abhishek Sharma
c68307a418 feat(dashboard): let synced models carry a context-window override (#14356)
* feat(dashboard): let synced models carry a context-window override

Models imported through a provider's /models import render as "imported" rows
with no edit affordance, so the manual context-window override #4125 added for
custom models was unreachable for exactly the models that need it most: third
-party OpenAI-compatible aggregators whose discovery metadata understates
context length. The GUI offered no correction path at all.

The backend already accepted the write. `PUT /api/provider-models` lists
`contextWindowOverride` in its compatOnly branch, which is the path taken when
no `customModels` row exists, and persists it through `setModelContextOverride`
with `source="manual"` — the source the 24h reconciler never overwrites.

The read path was the missing half. `GET /api/provider-models` attached
`contextWindowOverride` only to custom-model rows, so a value stored against a
synced model could be written and never read back: the UI had nothing to show
and nothing to seed an editor with. The GET now returns the provider's
overrides directly, and the row renders the same badge and inline editor as a
custom row.

#4125 semantics are unchanged and now shared rather than duplicated: the parse
helper moves to providerPageHelpers so "blank clears" and "zero is invalid"
have one definition instead of two that can drift.

Both new props are optional and the affordance renders only when the section
supplies a handler, so a row that cannot take an override is byte-identical to
before. Invalid input keeps the editor open and reports, rather than silently
writing a wrong value or discarding the one typed.

No new i18n keys: the contextWindowOverride* strings already ship in all 66
locales for the custom-model form.

Mutation-tested 4/4 — rendering the pencil unconditionally, treating blank as
invalid, accepting zero, and closing the editor on invalid input each fail the
new tests. tests/unit/ui is 1458 pass / 94 fail against 1452 / 94 on a stashed
tree: the same failures, plus my six.

Does not implement the max-output-tokens half of the issue: unlike
contextWindowOverride, no capability key is in the PUT compatOnly allowlist, so
that needs its own backend change and belongs in its own PR.

Closes #14337

* fix(dashboard): drop the invalid Badge variant on the override badge

`check:dashboard-typecheck` caught a real one: `Badge` has no `secondary`
variant, so the override badge raised TS2322 under
tsconfig.typecheck-dashboard.json.

My local `tsc --noEmit -p tsconfig.json` never saw it — that config does not
cover these files the same way, which is the whole reason the dashboard gate
exists as a separate scoped check with a frozen baseline.

Rendered as the same styled span CustomModelsSection already uses for this exact
badge, rather than picking another variant: same visual treatment as the custom
-model 🪟 badge, and one fewer component contract to get wrong.

With this, the dashboard gate reports the same 5 pre-existing errors on this
branch as on release/v3.8.51 — verified by running the check in a worktree on
the base — so the branch adds none.
2026-09-22 08:07:03 -03:00
Diego Rodrigues de Sa e Souza
6f1d4beabc chore(ci): add check:ai-attribution — reject AI/bot trailers and AI-generation footers (#14441)
Hard Rule #16 had no automated enforcement: eight contributor commits reached release/v3.8.51 carrying `Co-Authored-By: Claude …`, `Codex <codex@openai.com>` and `Claude-Session:` trailers through squash-merge bodies (#14436).

The gate runs in three places: the husky commit-msg hook (local), the quality.yml fast-gates loop (PR→release/**) and a PR-only lint step in ci.yml (PR→main). It reads the event payload on PRs and no-ops elsewhere. Human co-authors are explicitly allowed — only AI/bot names, AI-owned e-mail domains and AI-generation footers are rejected.

Refs #14436.
2026-09-22 07:41:57 -03:00
diegosouzapw
198b3bfd8a chore(quality): rebaseline opencode.ts 1247->1251 (train-10c tip drift)
The release tip went red on check:file-size after the 2026-09-22 merge wave:
#14179 grew open-sse/executors/opencode.ts past its frozen ceiling and the
PR->release fast-gates do not run check:file-size, so every merge-train
boarding afterwards inherits the red. Absorbed once at the tip under the
owner-approved train-rebaseline policy. Measured clean: check:file-size passes
on the tip with this entry.
2026-09-22 07:31:45 -03:00
luyuehm
27b4cabd8b feat(db): per-request cost ledger + per-key tpm/rpm/monthly quota (RIC-741) (#13610)
* feat(db): per-request cost ledger + per-key tpm/rpm/monthly quota (RIC-741)

Add M3 cost transparency + team autonomy:
- request_cost_ledger: one row per completed call with provider/model/token/
  unit-price/amount breakdown, written from the existing recordCost paths.
- api_key_quota_limits + api_key_quota_counters: KISS counter+threshold quota
  for tpm (tokens/minute) and rpm (requests/minute) via 2-bucket sliding
  window; monthly USD cap reads from the ledger month SUM.
- checkKeyQuota/recordKeyQuotaUsage domain gate (fail-open B16/B29), wired
  into enforceApiKeyPolicy pre-request and the usage-record post hooks.
- /api/usage/key-quota route + setKeyQuotaSchema for per-key config.
- Migration 177; tests cover ledger traceability and tpm/rpm/monthly break.

* fix(db): renumber cost-ledger/key-quota migration to 180

177 and 178 already landed on release/v3.8.51 by the time this branch was
analyzed (177_provider_connection_synced_models_at.sql,
178_memory_fts_au_conditional.sql); 179 also landed since. Renumber to the
next free slot and fix the file's own internal comment to match.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* fix(db): use the canonical toNumber helper instead of local redefinitions

ESLint no-restricted-syntax bars new local toNumber definitions in favor of
@/shared/utils/numeric (#7879, DRY extraction) — replace the 3 near-identical
local copies in costLedger.ts, keyQuota.ts and costLedgerRecorder.ts. Also
wires keyQuota.ts's getKeyQuotaStatus to reuse the already-defined
toIsoWindowStart helper instead of duplicating the window-start math inline,
which fixes the unused-var lint error on that function.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* refactor(sse): keep chatCore.ts under its frozen file-size ratchet

The recordCost() wiring for the per-request cost ledger added ~24 lines to
chatCore.ts, an already-frozen file (file-size-baseline.json caps it at
6146 lines). Extract the shared provider/model/tokens/serviceTier/requestId
breakdown into buildCostCtx() and the apiKeyInfo?.id && estimatedCost > 0
guard into recordChatCallCost() (both in src/domain/costRules.ts), and the
streaming ledger-details object into buildStreamLedgerDetails() in
streamingCost.ts. No behavior change: same 18 cost-ledger/domain-cost-rules
tests pass unmodified; net effect is chatCore.ts now at 6143 lines (3 under
the frozen ceiling) instead of 6165.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

* docs: bump the migration count to 178 after the new cost-ledger/key-quota migration

check:docs-all (stale-migrations, STRICT) failed because README.md, AGENTS.md
and llm.txt (plus its 65 docs/i18n/*/llm.txt mirrors, which must be exact
body copies of the root file) still said "177 migrations" after this PR
added migration 181_request_cost_ledger_and_key_quota.sql, bringing the
real count to 178.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Ant Rich <ant@richants.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-22 07:23:04 -03:00
Pixma
2a33528f74 feat(providers): add Lyceum pay-per-use provider + credit quota tracking (#12474)
* feat(providers): add Lyceum pay-per-use provider + credit quota

Lyceum (lyceum.technology) is an OpenAI-compatible, usage-based inference
provider. Registered as a first-class apikey provider mirroring the
llmgateway/openrouter pattern:

- registry/lyceum: buildOpenAiCompatibleRegistryEntry (base
  https://api.lyceum.technology/openai/v1, live /models discovery,
  passthrough). Generic DefaultExecutor handles chat/embeddings — no
  custom executor or translator needed.
- apikey metadata card + AGGREGATOR_PROVIDER_IDS + PROVIDER_ENDPOINTS.
- lyceumQuotaFetcher: reads the credit balance from
  GET /api/v2/external/billing/credits (pay-per-use), surfaced as a
  "credits" window in Dashboard > Limits and quota-aware preflight.
  Registered via quotaTrackersBatch (keeps chat.ts frozen).
- usage leaf + dispatch + fetcher/supported/apikey-limits lists + label.
- tests: registry-shape (wave1-c) + 16-case quota fetcher/usage suite.
- docs: changelog fragment, regenerated PROVIDER_REFERENCE, provider
  count 354->355 across README/AGENTS/llm.txt (+42 i18n mirrors)/SVGs,
  file-size baseline bump for the gateways.ts catalog entry.

* docs(providers): regenerate provider reference for Lyceum (359→360)

Rebased onto release/v3.8.51 after #12462 (LLM Gateway DevPass quota) merged
and the base advanced. The live registry now totals 360 providers, so the
auto-generated reference and the diagram/marketing counts are regenerated
against the new base:

- npm run gen:provider-reference → docs/reference/PROVIDER_REFERENCE.md
- 359→360 in README.md, AGENTS.md, llm.txt (provider count), package.json
  description and the diagram SVGs (readme-hero, promise-pillars,
  comparison-table, cli-terminal, tier-flow-dark/light)
- node scripts/i18n/sync-llm-mirrors.mjs → 65 locale mirrors

Addresses the maintainer review asking to rerun gen-provider-reference.ts and
push the doc files missing from the original diff, which left Docs Gates red.

* test(providers): refresh count assertions + golden snapshot for the new Lyceum provider
2026-09-22 07:22:55 -03:00
Alex Chan
5f67e11c82 fix(deepseek): default to OpenAI Chat Completions instead of Responses API (#14316)
DeepSeek's primary public API is /chat/completions, not /responses. The
previous default caused multi-turn tool-call requests to fail with:

  400 The reasoning_text in the thinking mode must be passed back to the API

because the Responses API requires the caller to echo reasoning_text on every
turn, while the Chat Completions protocol does not.

Changes:
- format: "openai-responses" → "openai"
- baseUrl: .../responses → .../chat/completions
- Move openai-responses to alternateFormats ("Responses-compatible") so
  operators who explicitly need that path can still select it per-connection
- Update unit test assertions to match the new default and alternate count
2026-09-22 07:22:46 -03:00
Damian Pozimski
9a352bf3e5 feat(sse): forward the auto mode classifier beta so gateway sessions stay eligible (#14312)
* feat(sse): forward the auto mode classifier beta to Anthropic-format upstreams

* docs(changelog): fragment for the auto mode classifier beta pass-through

* refactor(sse): move the client beta application into anthropicHeaders
2026-09-22 07:22:38 -03:00
Gery_MK
956423712f fix(cli): probe every bind address before respawning so a wildcard listener is not read as free (#14307)
The #4425 port guard waits for the listen port to free up before respawning a
crashed child. It probed only 127.0.0.1, which does not detect the address
`omniroute serve` actually binds by default: 0.0.0.0.

Node sets SO_REUSEADDR on every listener it creates. On macOS/BSD that lets a
specific-address bind coexist with an existing wildcard bind (Linux still
rejects the overlap in LISTEN state), so binding 127.0.0.1:PORT succeeds while
another process holds 0.0.0.0:PORT. The probe therefore reported "free", the
wait resolved immediately, the respawned child failed with EADDRINUSE, and the
crash/restart cascade #4425 set out to fix continued — indefinitely under a
`KeepAlive` supervisor such as launchd or systemd.

Probe the wildcard and loopback (plus any caller-supplied host) and treat the
port as busy if any of them is occupied.

Verified on macOS 27.0 / Node 22.22.3: with a server holding 0.0.0.0:PORT,
`isPortFree(PORT)` returned true before and now returns false, and a child
rebind of 0.0.0.0:PORT fails with EADDRINUSE as expected.

Tests: tests/unit/supervisor-policy-4425.test.ts

Co-authored-by: Gery.MK Song <gery@macunzip.dev>
2026-09-22 07:22:29 -03:00
Xmon Dai
844e35cdcf fix(cli): route the copilot device flow through the github backend key and the real poll route (#14300) 2026-09-22 07:22:19 -03:00
Xmon Dai
1ebdbed56b fix(skills): expand nested string-shorthand property types in injected skill tool schemas (#14288) (#14291) 2026-09-22 07:22:10 -03:00
Peter Busscher
257f0a0d96 fix(providers): fail closed for retired gemini-cli routing (#14289)
* fix(providers): fail closed for retired gemini-cli

* docs(changelog): name fragment for PR 14289
2026-09-22 07:22:01 -03:00
Goni Sulaiman
4973b8b74d fix(cleanup): prune compression_engine_breakdown on retention schedule and usage resets (#14268) (#14285)
When stacked prompt compression telemetry was added, per-engine breakdown
logs were written to `compression_engine_breakdown`. While the parent
`compression_analytics` table was regularly trimmed on an operator-configurable
retention policy (default 30 days) and purged on usage resets,
`compression_engine_breakdown` was omitted from both `cleanup.ts` and
`RESET_TARGETS`. In long-running deployments (5+ months), this table
accumulated 731,000+ unpruned rows without any deletion path.

Wired `compression_engine_breakdown` into `cleanupCompressionEngineBreakdown()`
with a 30-day cutoff, hooked it into nightly auto-cleanup, and added the
table to `RESET_TARGETS` so manual purges clean it completely.

Fixes #14268

Co-authored-by: Goni Sulaiman <gonisulaimann@users.noreply.github.com>
Co-authored-by: Koosha Paridehpour <KooshaPari@users.noreply.github.com>
2026-09-22 07:21:52 -03:00
Isaias Amaral
130bcadb1b feat(kiro): expose the provider-native Opus 5 Max effort tier (#14284)
* feat(kiro): expose the provider-native Opus 5 Max effort tier

Advertise "<base>-max" for Kiro's claude-opus-5 in the Claude effort catalog,
allow "max" in the Kiro effort values, and enable the adaptive-thinking
envelope for claude-opus-5.

The original change (the effortStandardization / adaptiveThinking edits and
the kiro-opus-5-max-effort test) is by tarciorick. The scoped
KIRO_OPUS_5_MAX_VARIANT_RE guard is an addition: the shared
CLAUDE_EFFORT_SUFFIX_RE must stay byte-identical to its sibling copies (drift
guard in claude-effort-variants.test.ts), so the synthesized -max id is
excluded by a separate regex scoped to that one id.

* chore(changelog): add fragment for #14284
2026-09-22 07:21:44 -03:00
Isaias Amaral
e5dc5ceea5 fix(antigravity): never persist or send the manual-project sentinel as a projectId (#14282)
* fix(antigravity): never persist or send the manual-project sentinel as a projectId

ensureAntigravityProjectAssigned() returns __REQUIRES_GCP_PROJECT__ when Google
does not auto-provision a project (BYOP). Only the in-request path guarded it;
token refresh, models discovery and the executor refresh persisted it as the
connection's projectId. The connection then looked configured, was PREFERRED by
preferAntigravityConnectionsWithStoredProject, and every request went upstream
as projects/__REQUIRES_GCP_PROJECT__ (HTTP 400, no lockout, no account
failover), so one poisoned account served nearly all traffic.

Add isUsableAntigravityProjectId() (rejects blank + the sentinel) and use it in
the persist helper, the stored-project selector, token refresh, models
discovery and the executor. A row already poisoned now behaves as "no project":
it takes the typed 422 GCP_PROJECT_REQUIRED path and is dropped by the selector
when a healthy sibling exists.

* chore(changelog): add fragment for #14282
2026-09-22 07:21:35 -03:00
Goni Sulaiman
95a6e4e8db fix(providers): wire app-server websocket transport and normalize reasoning model aliases (#14277) (#14281)
When selecting the first-class `codex-app-server` provider directly through
the executor registry (`open-sse/executors/index.ts`), the lazy loader
instantiated `CodexAppServerExecutor({}, "codex-app-server")` without
supplying a WebSocket transport function, preventing any connection from
opening.

Additionally, `CodexAppServerExecutor.execute` passed the raw `input.model`
directly to `turn/start` without stripping reasoning suffix aliases (e.g.,
`gpt-5.5-medium`), which caused upstream app-server turns to fail because the
aliased name is not recognized as a valid model ID.

Fixes:
1. In `open-sse/executors/index.ts`, wire the shared WebSocket transport
   (`codex.getCodexAppServerWebsocketTransport()`) into the `codex-app-server`
   executor factory.
2. In `open-sse/executors/codex-app-server.ts`, use `splitCodexReasoningSuffix`
   to send `baseModel` to `turn/start` while passing the selected effort
   separately, prioritizing explicit model suffix selection over request body
   defaults (#2331). Also support `body.reasoning_effort` in `extractEffort`.

Added regression tests in `tests/unit/codex-app-server.test.ts` verifying:
- baseModel extraction and effort derivation from model suffixes on `turn/start`
- precedence of suffix effort over body reasoning effort
- forwarding of body reasoning effort when the model is unsuffixed
- registry executor initialization with injected transport

Co-authored-by: Goni Sulaiman <gonisulaimann@users.noreply.github.com>
Co-authored-by: Aref Alapour <aref-alapour@users.noreply.github.com>
2026-09-22 07:21:22 -03:00
Markus Hartung
b9d4ea77b2 fix(gemini): strip tilde-prefixed Standard Schema keys from tool parameters (#14279)
Gemini/antigravity returned a hard 400 ("Unknown name \"~optional\" ...
Cannot find field") the moment a tool's parameter schema contained a
`~optional` key, taking down every Gemini-family model in a combo's
fallback chain at once -- confirmed live: this exact error killed 100% of
a production combo's usable fallbacks for ~18 hours (the two remaining
candidates ahead of it were OpenCode free-tier models, permanently
inaccessible via API regardless).

Root cause: `~`-prefixed keys are the Standard Schema convention (Zod 4+,
Valibot, ArkType) for internal/vendor metadata, namespaced with a leading
`~` specifically so it can never collide with a real schema property name.
A tool built from one of those libraries leaked a literal `~optional` key
into a property's subschema. GEMINI_UNSUPPORTED_SCHEMA_KEYS already listed
the plain `"optional"` string, but `removeUnsupportedKeywords`'s exact-match
check doesn't catch the tilde-prefixed form, so it survived sanitization
and Gemini's OpenAPI 3.0 schema subset rejected the whole request.

Fix: strip any `~`-prefixed key at every schema level, the same way `x-`
vendor extensions are already stripped -- this covers `~optional` and any
other Standard Schema metadata key the same libraries may emit, rather than
only patching this one literal key.

Testing: new regression test confirmed failing on unpatched code and
passing after the fix; full existing Gemini/schema-stripping test suite
(495 tests) still green.
2026-09-22 07:21:14 -03:00
Jan Leon
282e2c4f9b fix(quota): prevent Vertex spend telemetry from exhausting accounts (#14276) 2026-09-22 07:20:56 -03:00
Goni Sulaiman
75305b7d9f fix(api): expand combo-ref targets when computing combo capabilities (#14271)
/v1/combos hardcoded multimodal: false for any combo containing a
combo-ref, while /v1/models resolved the same combo through
resolveNestedComboTargets and intersected the real leaves' vision flags.
The two catalogs disagreed about the same combo (#14232).

projectCombo now accepts the combo collection and expands resolvable
combo-refs with the same resolver the routing runtime dispatches
against. Dangling refs and self-cycles contribute no targets and keep
the conservative false, matching the doc contract. The vscode and raw
vscode import surfaces get the same wiring.

Co-authored-by: Goni Sulaiman <gonisulaimann@users.noreply.github.com>
2026-09-22 07:20:46 -03:00
lorenzozane
992d274938 docs: explain Gemini Web Chromium setup (#14264) 2026-09-22 07:20:30 -03:00
Bob.Hou
a27d2e3c4e providers/discovery: do not treat max_tokens as the context window (#14260)
Anthropic's Models API reports the window as max_input_tokens and
the output cap as max_tokens. Putting max_tokens in the window
candidate list made Claude Opus 5 advertise 128K instead of 1M.

Read max_input_tokens for the window. Keep max_tokens on the
output-limit list next to max_output_tokens.

Signed-off-by: Minxi Hou <houminxi@gmail.com>
2026-09-22 07:20:14 -03:00
alfred-rootson
1fb7c9d7fc fix(egress): strip _omniroute* markers at shared pre-executor boundary (#14252)
Ensure internal control markers (e.g. _omnirouteSkipContextRelay,
_omnirouteInternalRequest) injected during universal/context handoff
are stripped at normalizeAttemptBody before reaching any executor.
Prevents leaks on custom executors that serialize request bodies
independently.

Includes cross-layer regression test asserting that universal handoff
dispatches contain no _omniroute* keys.
2026-09-22 07:20:01 -03:00
Ishan Parihar
cd396b06e2 fix(sse): price the default keepalive threshold below the client watchdog and stop leaking Anthropic finish reasons (#14247)
* fix(sse): stop racing the client first-byte watchdog and leaking Anthropic finish reasons

Two independent faults made combo streaming unusable from a strict OpenAI client
(oh-my-pi/omp) that had previously worked.

1. The early-stream keepalive threshold equalled the tightest client first-byte
   watchdog. Every combo request reached the resolver as a bare name with no
   provider prefix, so it took the 2 000 ms default — and a combo handler
   essentially never resolves inside 2 s, because it probes candidate legs first.
   The default threshold therefore *was* the combo time-to-first-byte: the
   synthetic keepalive byte landed at 2 003 ms while omp aborted at 2 011–2 016 ms
   having received nothing, so the request died as a 499 with no diagnosable error.
   Price the default tier at half the observed watchdog (1 000 ms) and tighten the
   keepalive cadence from 2 500 ms to 1 500 ms so the inter-byte gap stays inside
   the same budget. The slow tier keeps its deliberately long value: for
   browser-session and anonymous-fallback providers, committing early only adds SSE
   framing to a request the caller already expects to wait on.

2. normalizeOpenAICompatibleFinishReason passed cross-vendor synonyms through raw,
   so an Anthropic-style `end_turn` returned by an OpenAI-format leg landed on the
   OpenAI wire. omp reads an unrecognized finish_reason as a provider fault and
   fails the whole turn with `Provider finish_reason: end_turn` — after the text had
   already streamed, discarding a completed assistant message. Map the Claude
   stop_reason vocabulary onto its exact OpenAI equivalent, keeping the deliberate
   raw passthrough for genuinely unknown values and for the abort reasons whose
   whole purpose is to not present as a clean `stop`.

Also make the combo 403 actionable: the bare "Combo X is not allowed for this API
key" reads like a routing bug, so callers retry the same doomed model or fall
through a whole compaction cascade instead of adding the combo to the key.

* changelog: add the #14247 fragment
2026-09-22 07:19:52 -03:00
Bl0ck
4aed815294 fix(db): avoid live WAL truncate after incremental reclaim (#14244) 2026-09-22 07:19:45 -03:00
Xmon Dai
c45884c9e8 fix(sse): treat antigravity empty completions with a normal stop as valid 200s (#14160) (#14243)
An empty completion from antigravity's Gemini can be a real answer:
some prompts legitimately produce no text, and the upstream reports a
normal terminal finish reason (STOP -> "stop"). The fake-success guard
in isEmptyContentResponse flagged these regardless, so the
non-streaming leg rewrote them into synthetic 502s that fed model
lockout — a few hundred such "failures" a day kept most of the
reporter's 22-connection pool excluded and starved unrelated clients.

The guard exists for free-tier/scraping providers whose failure mode is
an empty 200 shell (#13461), so scope the exemption the same way the
repo scopes classifyFakeSuccessBody: a trusted-provider allowlist. On
antigravity, an empty completion with a normal stop reason (openai
"stop", claude "end_turn") now passes through as a valid 200; every
other provider keeps the existing guard, and antigravity responses with
no terminal stop reason are still flagged.
2026-09-22 07:19:33 -03:00
Xmon Dai
83a6e9a526 fix(providers): stop rewriting GA qwen3.8-max to preview on opencode-go (#14181) (#14242)
OpenCode Go now serves a GA qwen3.8-max, but the built-in deprecation
table (written when the model shipped only under the -preview id)
rewrote it to qwen3.8-max-preview before dispatch, and the upstream
rejects the preview id with a 401. The provider-aware exemption in
resolveModelAlias never fired because the opencode-go static registry
lacked the GA id.

- declare qwen3.8-max in the opencode-go registry (qwen family there is
  text-only and routes through the Claude translator per #2292)
- give the GA model its own MODEL_SPECS row instead of aliasing it to
  the preview spec

The rewrite stays for preview-only providers (qoder, bailian-coding-plan,
qwen-cloud-token-plan) and for callers with no provider in hand.
2026-09-22 07:19:26 -03:00
Dizzle
156f7018c2 fix(sse): neutral 403 code for non-quota refusals (#14234)
Co-authored-by: Max <maxmad64@gmail.com>
2026-09-22 07:19:12 -03:00
Dizzle
bebbad661a fix(proxies): classify refusal cause and hang, add bounded opt-in recovery pass (#14233)
* fix(proxies): classify refusal cause and hang, add bounded opt-in recovery pass

* docs(env): document PROXY_HEALTH_RECOVERY_INTERVAL_MS

The recovery-pass interval this PR adds is read from process.env, so the
env/docs contract gate (check-env-doc-sync) requires it in .env.example and
docs/reference/ENVIRONMENT.md.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Max <maxmad64@gmail.com>
Co-authored-by: maxmad64bis <maxmad64bis@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-22 07:19:01 -03:00
Dizzle
84fd18af8d fix(proxies): share scopeId guard across validation and stored assignments (#14231)
Co-authored-by: Max <maxmad64@gmail.com>
2026-09-22 07:18:53 -03:00
Mike Henke
95872d5a24 fix(providers): route opencode-zen GPT-5.6 family to the Responses API (#14230)
* fix(providers): route opencode-zen GPT-5.6 family to the Responses API

Upstream serves the GPT-5.6 trio only on /responses; /chat/completions
answers 503 "Endpoint is unavailable" (live-verified 2026-09-19 against
opencode.ai/zen/v1 with the same key on both endpoints, gpt-5.6-luna;
sol/terra declared from the same upstream endpoint docs). The zen registry
tagged muse-spark-1.2 with targetFormat:"openai-responses" but never the
GPT-5.6 entries, so OpencodeExecutor.buildUrl() posted them to the chat
endpoint. #12196 made the same declaration for gpt-5.6-luna on opencode-go.

Test: tests/unit/opencode-zen-gpt56-responses-format.test.ts, red without
the registry change, green with it, muse-spark control included.

* chore(changelog): add fragment for #14230
2026-09-22 07:18:41 -03:00
Yuri Gui
535ed50c2f fix(dashboard): zero-fill model usage chart data (#14228)
* fix(dashboard): zero-fill model usage chart data

* docs(dashboard): add model usage comparison screenshots
2026-09-22 07:18:34 -03:00
Dizzle
e4e41ac696 fix(subscription): serve last known good nodes instead of emptying the pool on fetch or parse failure (#14222)
Co-authored-by: Max <maxmad64@gmail.com>
2026-09-22 07:18:25 -03:00
Dizzle
a204799093 fix(proxies): order pool candidates by crossed short-memory health signals (#14221)
Co-authored-by: Max <maxmad64@gmail.com>
2026-09-22 07:18:14 -03:00
Dizzle
df717be710 fix(proxy-logs): keep the HTTP status the provider actually returned on search rows (#14220)
Search proxy-log rows always read null (no response received), even when the
provider answered with a 429, 403, or 500, so operators could not tell a real
refusal from a transport failure. Forward response.status at the four
emitEvent call sites; locally synthesized codes (envelope, transport) stay
null, matching the chat writer. Verified: new test 6/6 RED-then-GREEN,
neighbor search-432 7/7.

Co-authored-by: Max <maxmad64@gmail.com>
2026-09-22 07:18:04 -03:00
Domenico Massafra
4970f5f8ba fix(providers): separate Codex GPT-5.6 image catalog ids (#14216)
Co-authored-by: ginettododo <117327638+ginettododo@users.noreply.github.com>
2026-09-22 07:17:56 -03:00
andreyzagid-tech
10c8aaae82 fix(auggie): resolve spawn EINVAL when running the CLI shim on Windows (#14215) 2026-09-22 07:17:47 -03:00
lorenzozane
f204c4ec2c fix(quota): expire stale Antigravity 429 exhaustion (#14211) 2026-09-22 07:17:34 -03:00
lorenzozane
3b0007742b fix(sse): clarify compressed context token accounting (#14210) 2026-09-22 07:17:25 -03:00
Ercan Er
0fcac56cba fix(db): return undefined for empty bun:sqlite .get() results (#14203)
* fix(db): return undefined for empty bun:sqlite .get() results

bun:sqlite's Statement.get() returns null when no row matches, while
better-sqlite3, node:sqlite and sql.js return undefined. The call sites
are typed and written against undefined (`get(...) as Row | undefined`,
and isExclusiveConnectionActivelyLeased compares with `!== undefined`),
so when the server runs under Bun every connection looked exclusively
leased: dashboard connection tests reported LEASE_ACTIVE, usage refresh
was deferred with 409, and the model-sync scheduler found no connections
to sync.

Normalize the no-row result in the Bun adapter so all drivers share one
contract. Covered by a driver-independent unit test that runs in the
Node shards and a Bun-only test against the real bun:sqlite driver.

* docs: add changelog fragment for #14203
2026-09-22 07:17:12 -03:00
Dizzle
985136b56f fix(sse): sticky head + store drain for opencode rotation (opt-in) (#14202)
Co-authored-by: Max <maxmad64@gmail.com>
2026-09-22 07:17:04 -03:00
Bob.Hou
dcaec5e111 quota/kimi: route coding hosts to /usages and map used_ratio (#14201)
Moonshot connections whose baseUrl is api.kimi.com/coding were classified
as Open Platform and queried users/me/balance, which those Allegro keys
reject. Membership windows live on GET /coding/v1/usages as
usages.limit_7d.used_ratio (and limit_5h). Host classification now treats
an explicit coding URL as Coding Plan, the dispatcher and combo preflight
follow it, and used_ratio is mapped onto Code 7d/5h.

Signed-off-by: Minxi Hou <houminxi@gmail.com>
2026-09-22 07:16:53 -03:00
Goni Sulaiman
a77b8a1048 fix(api): detect /v1beta ingress bodies as openai, not claude (#14185)
The /v1beta Gemini ingress converts gemini to openai chat format before
re-entering handleChat, but the request URL keeps its /v1beta path. With
no path branch, detectFormat's max_tokens heuristic misread the
converted body as claude: non-streaming replies came back anthropic-
shaped and were dropped by the route's OpenAI-to-Gemini converter, and
streaming replies were 200 SSE responses with zero bytes, which made
the antigravity CLI and the Google GenAI SDK loop forever.

Treat a /v1beta path as openai chat unless the body still carries the
raw gemini contents envelope, so client-raw-request contexts that pass
unconverted gemini bodies keep the existing gemini detection.

Fixes #14165

Co-authored-by: Goni Sulaiman <gonisulaimann@users.noreply.github.com>
2026-09-22 07:16:44 -03:00
Goni Sulaiman
898f26b366 fix(proxy): probe http proxies on their default port 80 (#14184)
The WHATWG URL parser drops the port when it equals the scheme default,
so an http proxy URL like http://user:pass@host:80 reached the probe
with an empty port and defaultPortForScheme handed back 8080. The probe
then failed, the request was rejected with Proxy Fast-Fail, and the
healthy connection went into cooldown.

The https and socks5 defaults were already correct; only the http case
was wrong. Proxies with explicit non-default ports are unaffected.

Fixes #14157

Co-authored-by: Goni Sulaiman <gonisulaimann@users.noreply.github.com>
2026-09-22 07:16:33 -03:00
Dizzle
9747cf297e fix(build): combos page drops server-only import (#14179)
Combos page resolves provider prefixes through the client-safe alias map instead of the server-side model service.

Co-authored-by: Max <maxmad64@gmail.com>
2026-09-22 07:16:25 -03:00
navanshjagetiya7-eng
f987ab6566 feat(cli-tools): add Oh My Pi (omp) CLI detection and configuration support (#14178)
- Register omp in cliRuntime.ts getKnownToolPaths for Windows (omp.cmd, omp.exe, %LOCALAPPDATA%\omp\omp.exe, ~/.omp/bin/omp.exe) and POSIX (~/.omp/bin/omp)
- Fix double .omp typo in cliTools.ts config path documentation
- Switch omp-settings route to openai-models-list discovery mode with injectV1: false
- Add case "omp" to extractEndpointFromConfig in all-statuses route
- Handle omp YAML configuration in checkToolConfigStatus before JSON parsing
- Add unit tests in cli-runtime-detection.test.ts and check-tool-config-status.test.ts
- Enhance cli-settings-omp.test.ts with cross-platform environment isolation
2026-09-22 07:16:17 -03:00
Jan Leon
5229a40db3 fix(routing): honor manual auto weights and weight weekly reset urgency higher (#14176)
* fix(combos): manuelle Routing-Gewichte wirksam übernehmen

* fix(routing): Weekly-Reset im Auto-Faktor stärker gewichten

* docs(changelog): Routing-Fix dem PR zuordnen
2026-09-22 07:16:03 -03:00
Jan Leon
21b770b808 fix(codex): recover reset cooldowns and expose account release (#14175)
* fix(codex): Quota-Sperren freigeben und Account-Limits anzeigen

* fix(codex): Cache-Schlüssel im Release-Port typisieren

* docs: Codex-Sperrenkorrektur im Changelog erfassen
2026-09-22 07:15:53 -03:00
Nguyen Thanh Dat
3934dc00d3 fix(v1beta): keep inlineData and parts sent next to a functionResponse (#14173)
* fix(v1beta): keep inlineData and parts sent next to a functionResponse

convertGeminiToInternal() read only text, functionCall and
functionResponse parts. An image, PDF or audio sent as inlineData to
/v1beta/models/{m}:generateContent never reached the provider, and an
image-only turn became an empty user message. A content holding a
functionResponse returned its tool messages alone, dropping every other
part; gemini-cli sends a binary file a tool read exactly that way.

Map inlineData in non-model turns to image_url data URLs, and split a
content with functionResponse parts into the responses and the rest
before conversion, as the Gemini request translator already does.

* docs(changelog): add fragment for #14173

---------

Co-authored-by: datrixlab <325650023+datrixlab@users.noreply.github.com>
2026-09-22 07:15:45 -03:00
Aaron Scherer
f82ae804aa fix(guardrails): cache the vision-bridge no-candidate outcome (#14161)
* fix(guardrails): cache the vision-bridge no-candidate outcome

* docs(changelog): number the fragment for #14161

* docs(env): document OMNIROUTE_VISION_BRIDGE_NEGATIVE_CACHE_MS

The negative-cache TTL this PR introduces is read from process.env, so the
env/docs contract gate (check-env-doc-sync) requires it in .env.example and
docs/reference/ENVIRONMENT.md.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: cryptiklemur <cryptiklemur@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-22 07:15:37 -03:00
Goni Sulaiman
b6eaf3734f fix(ci): run the env/doc sync gate on checkouts whose paths need URL encoding (#14155)
Co-authored-by: Goni Sulaiman <gonisulaimann@users.noreply.github.com>
2026-09-22 07:15:24 -03:00
Dizzle
9962f9f65c fix(auth): serve a locked no-auth model as a retryable cooldown (#14151)
No-auth model lock now surfaces as a retryable cooldown (wait or 429 with Retry-After) instead of 401, and request-scoped refusals skip the token refresh.

Co-authored-by: Max <maxmad64@gmail.com>
2026-09-22 07:15:13 -03:00
Dizzle
771c376387 fix(opencode): replay a request in the other tool shape when its tools are refused (#14148)
Tool-shape refusal replays once in the other shape and remembers the working shape per prompt digest.

Co-authored-by: Max <maxmad64@gmail.com>
2026-09-22 07:14:56 -03:00
Rafa Martins
f4d7d8a53a fix(combos): use provider node prefix alias in combo builder qualifiedModel (#14143)
Fixes #14135

Ensure custom provider nodes configured with a prefix alias generate
model options with `qualifiedModel: <alias>/<model>` rather than
falling back to the raw internal database node ID. This ensures
proper routing through the configured prefix and accurate catalog
and capability lookups in /v1/models.
2026-09-22 07:14:45 -03:00
Rafa Martins
f3720b9a7c fix(combo): exempt per-model-quota 403 from connection exhaustion (#14136) (#14140) 2026-09-22 07:14:36 -03:00
dependabot[bot]
ed384009a8 chore(deps): bump github/codeql-action from 4.37.9 to 4.38.0 (#14130)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.37.9 to 4.38.0.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v4.37.9...v4.38.0)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-22 07:14:28 -03:00
dependabot[bot]
84eae29d31 chore(deps): bump github/codeql-action/init from 4.37.9 to 4.38.0 (#14129)
Bumps [github/codeql-action/init](https://github.com/github/codeql-action) from 4.37.9 to 4.38.0.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](cdf488f595...b96794f015)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-22 07:14:20 -03:00
dependabot[bot]
e8413da450 chore(deps): bump codecov/codecov-action from 7.0.0 to 7.1.0 (#14128)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 7.0.0 to 7.1.0.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](fb8b3582c8...0b35c9ecc4)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-22 07:14:06 -03:00
dependabot[bot]
ddd2aec818 chore(deps): bump github/codeql-action/analyze from 4.37.9 to 4.38.0 (#14127)
Bumps [github/codeql-action/analyze](https://github.com/github/codeql-action) from 4.37.9 to 4.38.0.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](cdf488f595...b96794f015)

---
updated-dependencies:
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-22 07:13:58 -03:00
dependabot[bot]
c198720b2c chore(deps): bump actions/setup-node from 5 to 7 (#14126)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 5 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v5...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-22 07:13:51 -03:00
Joao Pedro Mota
9e488cde46 docs(DOCKER_GUIDE): explain the -web image variant where image users will find it (#14125)
Picking a :<channel> tag without the -web suffix only fails at request
time for web-session providers (gemini-web, claude-web, claude-turnstile)
with a node_modules-looking playwright error and no remedy in the guide.

- Release Channels: what -web adds (runner-web: Playwright + Chromium),
  the deferred failure symptom, and the remedy per install method.
- Dockerfile Stages: add the runner-web row + build example.
- Available Profiles: add the web profile row (SELF_HOST_GUIDE links
  here for its web-cookie troubleshooting item); drop a stale count.

Closes #14105

Co-authored-by: Mistertelecom <noc@ysoftware.com>
2026-09-22 07:13:44 -03:00
Joao Pedro Mota
19b61c88d4 fix(translator): lift Responses tool-output images into a multimodal user message (#14123)
#14111 (follow-up to #8459): function_call_output / custom_tool_call_output
that carry input_image parts kept only a placeholder — the image never
reached Chat-backed vision models (Codex view_image).

The tool message stays text-only (text + placeholder, no raw base64), and
each image is now lifted into a following multimodal user message as an
Chat Completions image_url part, in output order, detail preserved.

Closes #14111

Co-authored-by: Mistertelecom <noc@ysoftware.com>
2026-09-22 07:13:29 -03:00
Zackaria.A
b51e9bb344 fix(providers): keep array subscripts in perplexity-web code output (#14122)
CITATION_RE strips any [n] token and cleanResponse() runs over the whole
answer before tool mode parses <tool> text into tool_calls, so subscript
indexing was removed from code: print(arr[0], arr[12]) came back as
print(arr, arr), in rendered code blocks and in write_file arguments
alike.

Citation cleanup now skips protected regions — fenced code blocks, inline
code spans and <tool> payloads — via CODE_SPAN_RE and stripCitations().
Prose citations are stripped exactly as before, including the leading
space folded into CITATION_RE by #14009.

Closes #14121

Co-authored-by: Zicocoder <Zicocoder@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 07:13:21 -03:00
Xmon Dai
6cb34028a3 fix(api): sanitize /v1/batches create error body (Hard Rule #12) (#14115)
POST /api/v1/batches returned the raw error.message from its catch block,
so a DB-layer failure could ship file paths, SQL fragments or internal
identifiers to the client. Route it through sanitizeErrorMessage() from
open-sse/utils/error.ts, the same shared sanitizer every other /v1 route
already uses, keeping the existing 400 + invalid_request_error shape and a
non-empty message fallback.

Adds route-level coverage driving the real POST handler with a real API key
and seeded input file: the batch INSERT is made to throw a path-and-stack
laden SQLITE_CANTOPEN through the repo's own setDbInstance seam (no module
mocking), asserting the path/filename/stack never reach the body, plus a
control that a clean message survives sanitization intact.

Closes #14089
2026-09-22 07:13:11 -03:00
Kaung Min Khant
8183e95ae8 fix(video): honor video job polling overrides (#14114)
* fix(video): honor video job polling overrides

* fix(video): read Agnes 2.5 result URL from metadata
2026-09-22 07:12:53 -03:00
Joao Pedro Mota
60a2097255 docs: fix three stale/missing support entries (#14108)
Closes #13998

1. .env.example: CREDENTIAL_HEALTH_CHECK_INTERVAL default is 60m (3600000), not 5m (300000)
2. README.md: clarify OMNIROUTE_SKIP_POSTINSTALL only skips the native SQLite warm-up
3. TROUBLESHOOTING.md: add Windows PATH guidance for "omniroute is not recognized"

(The OpenCode header-defaults half of #13998 was already fixed upstream by #14013.)

Co-authored-by: Mistertelecom <noc@ysoftware.com>
2026-09-22 07:12:45 -03:00
Paco Cartones
9533749fba test(translator): cover computeFinishReason stop/tool_calls contract (#14100)
Pins that finish_reason is 'tool_calls' when a tool call was emitted (toolCallIndex > 0)
or one is still open (currentToolCallId), and 'stop' otherwise — including the sticky
currentToolCallId OR-branch that keeps a mid-stream tool call from reporting as stop.

Co-authored-by: pacocartones <pacocartones@users.noreply.github.com>
2026-09-22 07:12:37 -03:00
Paco Cartones
e5a2f9aa6f docs(opencode-provider): correct default model count (4 -> 8) (#14099)
OMNIROUTE_DEFAULT_OPENCODE_MODELS in src/index.ts lists 8 models, but the README said
'Default: 4 curated models' and 'the default 4 may be hidden'. The list grew to 8
(cross-checked against the sibling opencode-plugin README and the package tests). Correct
both mentions.

Co-authored-by: pacocartones <pacocartones@users.noreply.github.com>
2026-09-22 07:12:28 -03:00
Paco Cartones
d127fc6480 test(translator): cover resolveLocalToolCallIndex remap contract (#14098)
Pins the local-index remap that fixed the 2026-09-02 output_index-gap incident:
raw upstream tool_call indices map onto a contiguous 0-based first-seen sequence,
idempotently, honouring a seeded next and coercing numeric/string keys.

Co-authored-by: pacocartones <pacocartones@users.noreply.github.com>
2026-09-22 07:12:13 -03:00
Paco Cartones
31cc3d63bf test(translator): cover enforceToolResultAdjacency ordering repair and unpaired fallback (#14097)
Pins two currently-untested behaviors of the OpenAI->Claude tool-result adjacency
repair: a tool_result arriving after intervening user text is moved adjacent to its
tool_use turn, and an unmatched tool_result is preserved as unpaired text rather than
dropped.

Co-authored-by: pacocartones <pacocartones@users.noreply.github.com>
2026-09-22 07:12:04 -03:00
Paco Cartones
cb583db280 fix(sse): guard STREAM_HISTORY_MAX parse so completed-stream history stays bounded (#14095)
parseInt(process.env.STREAM_HISTORY_MAX) returned NaN for a non-numeric value, so the
'completedStreams.length > MAX' trim never ran and history grew without bound (a slow
leak in a long-lived SSE process). Extract resolveMaxCompletedHistory(), which falls back
to 50 on non-numeric/negative input and honours valid values including 0. Adds a unit test.

Co-authored-by: pacocartones <pacocartones@users.noreply.github.com>
2026-09-22 07:11:55 -03:00
Paco Cartones
c20ff10ed2 fix(sse): allow a stream to fail from INITIALIZED (setup-time failure) (#14094)
VALID_TRANSITIONS let INITIALIZED go only to CONNECTING or CANCELLED, but a stream can
fail before it connects (credential selection/setup throws). fail() then hit an invalid
transition, left the tracker in INITIALIZED with error set and completedAt null, and
archiveStream persisted that inconsistent summary. Add FAILED to the INITIALIZED
transitions. Adds a regression test.

Co-authored-by: pacocartones <pacocartones@users.noreply.github.com>
2026-09-22 07:11:46 -03:00
Paco Cartones
86c08423df fix(openai-responses): trim tool_call_id and function name so padded call ids stay paired (#14093)
openaiToOpenAIResponsesRequest trimmed the id on the function_call side (assistant
tool_calls -> function_call) but not on the function_call_output side (tool/function
role). clampCallId only length-clamps, so a whitespace-padded id that a client echoes
on both sides was keyed trimmed on one side and untrimmed on the other; the
orphaned-output filter then dropped the tool result and the model answered as if the
tool never ran. Trim both sides to preserve the pairing invariant clampCallId exists
to protect. Adds a regression test with padded matching ids.

Co-authored-by: pacocartones <pacocartones@users.noreply.github.com>
2026-09-22 07:11:38 -03:00
Joao Pedro Mota
928663790a docs(cli): fix non-existent config show and models list commands (#14056)
Closes #13997

`config show` does not exist, and the config subcommand actually manages external CLI integrations, not OmniRoute's own envs. Changed to `config list`.
`models list` parses `list` as a provider filter, causing unexpected behaviors. The correct command is just `models`.

Co-authored-by: Mistertelecom <noc@ysoftware.com>
2026-09-22 07:11:29 -03:00
Bob.Hou
d46d4d8042 docs: name combo hang-stop from source, not as an env var (#14054)
#14022 already documented OMNIROUTE_STRIP_SYSTEM_PREAMBLE and
allowlisted COMBO_LOOP_SAFETY_TIMEOUT_MS. The remaining mismatch is
the ENVIRONMENT.md sentence that still backticks the constant as if
it were operator-facing.

Signed-off-by: Minxi Hou <houminxi@gmail.com>
2026-09-22 07:11:20 -03:00
backryun
252d604dbe chore(codex): bump Codex CLI to 0.155.0 (#14052)
Keep the emulated client fingerprint in lockstep with the CLI pinned in the Dockerfile: shared client constant, .env.example overrides, provider translate-path golden, executor header assertions, the caller-version fallback assertions from #13708, and the live env/stealth docs.

Co-authored-by: backryun <backryun@daonlab.local>
2026-09-22 07:11:11 -03:00
AStupidBear
ecedc1e4d3 fix(guardrails): resolve prefixed combo names in Vision Bridge (#13332)
Co-authored-by: AStupidBear <16422976+AStupidBear@users.noreply.github.com>
2026-09-22 07:11:02 -03:00
Diego Rodrigues de Sa e Souza
b10d05fad2 docs(i18n): refresh the README and ENVIRONMENT mirrors the base left behind (#14461)
* docs(i18n): refresh README and ENVIRONMENT mirrors for the base edits of 2026-09-21

* docs(i18n): re-adopt mirrors reformatted by the pre-commit hook

* docs(i18n): adopt the mirrors whose state save lost the race
2026-09-22 06:50:16 -03:00
Diego Rodrigues de Sa e Souza
2b8f89a652 fix(release): run the i18n contract gates in the merge-train (#14437)
The train validated typecheck, file-size/complexity and changelog
integrity only, so combined trees that add en.json keys without catalogs
or edit docs without mirrors reached release/v3.8.51 three times in 48 h
(#13670, 1b2349de, 7f1b4a5e) while each PR's own CI was red on those
gates. i18n:check-keys, i18n:check-keys:cli, i18n:check-ratio and the docs
drift gate now run with the static gates.
2026-09-22 02:12:20 -03:00
Diego Rodrigues de Sa e Souza
d0b6f9a2c6 fix(ci): drain the release/v3.8.51 base-reds — env-doc, generated skill, TS2677, ESLint (Refs #13866) (#14331)
* fix(build): ship httpClientAbortGuard.mjs in the pack artifact; validate input_tokens with Zod

Wave five of the release/v3.8.51 base-reds, part 1 — the two that matter.

#14064 restored server-ws.mjs's import of ./httpClientAbortGuard.mjs and the
assembleStandalone copy, but not the two pack-artifact policy entries that
were lost with it. Without APP_STAGING_ALLOWED_EXACT_PATHS the prepublish
prune deletes the file; without PACK_ARTIFACT_REQUIRED_PATHS nothing notices.
Every boot of the published package would die with ERR_MODULE_NOT_FOUND — the
3.8.47 head-response-guard class. Both closure suites (9/9) now enforce it.

#13910's /v1/responses/input_tokens read request.json() behind a hand-rolled
typeof check. Hard Rule #7 wants the boundary on Zod; the t06 guard caught it.
Same passthrough envelope the catch-all Responses route uses, since the
counters below already walk the fields defensively. 9/9 on the route's suite.

Five no-unused-vars left behind by the wave (cliRuntime execFileSync, arena
test symbols and a type, compression rmSync, waitForServer req) are removed.
The 'openwa routes removed without deprecation' entry from the #14101 run was
an artifact of that PR trailing its base — the gate is clean on the tip.

Refs #13866

* fix(compression): let anchored file-pack rules see the transformed text; align wave-5 guards

Wave five of the release/v3.8.51 base-reds, part 2.

One production defect. #12825 (Hungarian Caveman pack) stopped gating file-pack
rules with the English keyword list and tested the rule's own regex instead —
against `lowerResult`, a lower-cased copy of the ORIGINAL text that the loop
never refreshed. An anchored pattern like leader_phrases' `^(?:i will|…)`
therefore ran its prefilter on "sure, i will…", failed the anchor, and was
skipped; the rule that strips "I will " from every English response was dead
since the merge. The prefilter now sees the text as the rules so far have left
it. New test fails on the tip and passes here; all Caveman suites, Hungarian
included, are 95/95. A frozen no-unused-vars suppression on caveman.ts no
longer had a target and is pruned.

Two more TS2677 predicates of the kind #14101 fixed: #13910
(rerankProviderNodes.ts, `n is RerankProviderNodeRow` on a Record row) and
#13957's mitm catalog (antigravity.ts, `c is DynamicCatalogModel` on a
literal-or-null). Both narrow by NonNullable of the element's own type; the
api-route typecheck was 285 against a baseline of 283 on the pristine tip.

The rest are guards trailing legitimate changes:

- #12663 made gemini-3.8-flash the catalog head; T28 pinned 3.7.
- #13863 put mimo-v2.5 into the shared vision heuristic on purpose (the base
  model is multimodal, only the Pro variants are text-only). The safety test
  now asserts the real invariant: base and :free aliases yes, -pro no.
- #12565 moved npm-prefix detection into cliRuntimeNpmPrefix.ts with a
  process-lifetime cache that importFresh() does not reset; the case resets it.
  #12565 also builds Windows candidates with path.win32 on purpose; the qodercli
  test compared against POSIX path.join.
- #13990 (the 2 GB Docker image) copies better-sqlite3 with --chown; the guard
  matched the flag order literally. Now flag-order tolerant, still fails when
  --from=builder is removed.
- #13378 reintroduced public/openference.svg under a name #11750 retired for
  missing provenance and swapped the Cerebras showcase cell for it. The cell is
  back and the asset is gone; whether the new drawing counts as provenance is
  the owner's call.

Refs #13866

* fix(i18n): translate the 7 sidebar-pin and Claude low-priority keys into all 65 locales

#7f1b4a5e (sidebar pinned items) and #1b2349de (Claude OAuth lower-priority /
auto-reset) landed with their 7 new keys in en.json only, which the vi and
pt-BR parity suites flag. Translated with the repo's own sync-ui-keys
--translate-markers against the .113 i18n instance (codex/gpt-5.6-sol-low):
+446 lines across 65 catalogs, zero __MISSING__ markers, placeholders intact.
vi.json also has two keys reordered to mirror en.json; values unchanged.

Refs #13866

* chore(quality): list the 8 covering tests the sixth wave added in stryker tap.testFiles

30 commits landed on release/v3.8.51 while wave five drained; eight new unit
tests cover mutated modules and were not in tap.testFiles, so their mutant
kills did not count and check:mutation-test-coverage --strict failed on the
merged tree. Appended at the end of the list, nothing reordered.

Refs #13866

* docs: document the five env vars of the 09-18 wave; regenerate the version-manager skill for the open-wa routes

check:docs-all: BRIDGE_PORT, ROUTER_URL and CERT_DIR (bin/antigravity-bridge.mjs,
#c74cea3d), OPENWA_SERVICE_PORT (src/lib/services/bootstrap.ts, #1e8c913c) and
NEXT_PUBLIC_PORT (src/shared/hooks/useDisplayBaseUrl.ts, #d715190b) were read
in code but absent from .env.example and docs/reference/ENVIRONMENT.md. Added
next to their neighbours, with the defaults the code actually uses (open-wa is
8323, not the 201xx range the other services sit in).

check:agent-skills-sync: the open-wa feature added eight /api/services/openwa/*
routes to docs/openapi.yaml without regenerating skills/omni-version-manager/
SKILL.md. Regenerated with the repo generator; the diff is exactly those eight
route sections.

Refs #13866

* test: register the crash guard in the pack snapshot; inventory #13874's refresh-lane row read

pack-artifact-policy pins the list of root runtime files check:pack-artifact
must find in the tarball; dist/httpClientAbortGuard.mjs joined
PACK_ARTIFACT_REQUIRED_PATHS in this PR and the snapshot follows.

#13874 re-reads the connection row inside the Claude refresh lane so a queued
health check does not POST a refresh token a Layer 2 refresh already rotated —
a state read, inventoried like the family-cooldown lookup (tokenHealthCheck.ts
2 -> 3).

Refs #13866

* chore(quality): list native-codex-auto-resume test in stryker tap.testFiles (#13180 landed without it)

* fix(release): drain the seventh base-red wave of release/v3.8.51 (9 tests + pack-policy + dashboard-typecheck)

Three production defects the tests caught:
- rateLimitManager: maxWaitMs=0 (the #12902 disable sentinel) hit #12715's
  queue-budget gate as "0 ms left" and 503'd every protected request.
- emergencyFallback: #14006 silently switched the budget-exhaustion target
  provider nvidia -> groq against ENVIRONMENT.md and the NIM snapshot; restored.
- claudeConnectionFields.ts vs ClaudeConnectionFields.tsx (#13074) differed only
  by casing; helpers renamed to claudeConnectionFieldValues.ts.

Guards realigned to legitimate changes: #13874 rotation map (distinct token in
the error test), #13350 origin-IP denylist, #13318 shared-catalog growth
(counts by invariant), comboTargetKeyPolicy import in the telegram stub, the
22 README mirrors that #13940/#14106 stamped with the retired openference.svg
(translated Cerebras cells recovered from history, hashes re-stamped),
bin/antigravity-bridge.mjs allowed in the pack policy, and the two dashboard
typecheck regressions (typed pinned section, ComponentProps cast).

Refs #13866.

* test: type the #13848 Gemini pairing tests (no-explicit-any) and inventory the semantic-cache embedding picker's connection read

Both arrived with the tip merge: #13848 added 13 explicit any casts to
translator-openai-to-gemini.test.ts (no-explicit-any is an error under
tests/), and 7a921299's embeddingOptions.ts reads provider connections
once without a hard-session-lease inventory entry. Stale suppression
count pruned for the test file only.

Refs #13866.

* test: split the #13848 turn-pairing cases out of translator-openai-to-gemini.test.ts

The file sits exactly at its frozen size cap; typing the pairing tests
(no-explicit-any) pushed it 14 lines over. The two cases are a coherent
regression suite of their own, so they move to
translator-openai-to-gemini-turn-pairing-13848.test.ts (registered in
stryker tap.testFiles) instead of widening the baseline.

* docs(env): document BRIDGE_PORT, ROUTER_URL, CERT_DIR, OPENWA_SERVICE_PORT and NEXT_PUBLIC_PORT (Refs #13866)

check:env-doc-sync has been red on the release tip since these five vars
reached code without their .env.example / ENVIRONMENT.md entries:
bin/antigravity-bridge.mjs (BRIDGE_PORT, ROUTER_URL, CERT_DIR — #14006),
src/lib/services/bootstrap.ts + api/services/openwa/_lib.ts (OPENWA_SERVICE_PORT)
and src/shared/hooks/useDisplayBaseUrl.ts (NEXT_PUBLIC_PORT — #13533).
Defaults and source files copied from the reads themselves.

* chore(skills): regenerate omni-version-manager for the open-wa service routes (Refs #13866)

check:agent-skills-sync (Merge integrity job) has been red on the tip since
the open-wa embedded-service routes reached docs/openapi.yaml without the
generated SKILL.md being refreshed. Output of
scripts/skills/generate-agent-skills.mjs --apply, no hand edits: the eight
/api/services/openwa/* operations.

* fix(types): make the two TS2677 type predicates sound (Refs #13866)

check:api-typecheck has been red on the tip with two "type predicate's
type must be assignable to its parameter's type" errors:

- src/app/api/v1/_shared/rerankProviderNodes.ts (#13733): the read cache
  hands back `Record<string, unknown> | null`, and an interface whose members
  are all optional is not assignable to an index-signature type. Narrow to the
  non-null record and assert the row shape afterwards.
- src/mitm/handlers/antigravity.ts (#14006): the map callback returned
  `{ displayName: string }` while DynamicCatalogModel declares it optional, so
  the predicate could not be proven. Type the callback's return explicitly and
  filter on `!== null`.

No runtime change; rerank-remote-provider-nodes / rerank-local-node-shapes /
mitm-handler-antigravity stay green.

* fix(lint): clear the 92 ESLint errors the lint gate reports on the tip (Refs #13866)

- tests/unit/translator-openai-to-gemini.test.ts: #13848 / #13318 added 13
  `any` casts/params on top of the 74 frozen for the file, so ESLint reported
  all 87. Typed them (GeminiRequestWithContents / GeminiToolPart, and the
  existing GeminiRequestWithConfig) and pruned the file's suppression to the
  new count of 71 — nothing else in eslint-suppressions.json changes.
- no-unused-vars: execFileSync import (src/shared/services/cliRuntime.ts,
  #12565), getArenaEloSyncStatus + makeLeaderboardMap + ArenaLeaderboardMap
  (tests/unit/arena-elo-sync-redesign.test.ts, #13446), rmSync
  (compressionAnalyticsWriterFlatRate.test.ts, #13446), `req` → `_req`
  (waitForServer-slow-first-response.test.mjs).

translator-openai-to-gemini 48/48; arena-elo-sync-redesign,
compressionAnalyticsWriterFlatRate, waitForServer-slow-first-response green.

* fix(compression): stop skipping anchored Caveman rules that only match after earlier rules

#12825 (Hungarian pack) replaced the English keyword prefilter with a
`rule.pattern.test(lowerText)` pre-check for every file-based rule, including
the default `en` pack. `lowerText` is the ORIGINAL message, so anchored rules
such as `leader_phrases` (`^i will …`) — which only match after `pleasantries`
strips "Sure, " — were dropped before they could run. `caveman-v379` caught the
regression ("I will ensure …" survived at full intensity).

Tag file-based rules with their pack language in ruleLoader and let the
keyword prefilter apply to `en`/built-in rules only; non-English packs (which
reuse English rule names) simply run their localized regex, which is what the
pre-test cost anyway. Drops the now-unused CAVEMAN_RULES import and prunes the
already-stale `caveman.ts` no-unused-vars suppression (0 violations on the tip)
that blocked the pre-commit hook for any change to this file.

Refs #13866

* test(models): align catalog and vision-heuristic guards with the tip's intended contracts

Three base-reds where the production change was deliberate and the pinned
guard was simply not bumped by the PR that changed the contract:

- agy-antigravity-shared-catalog-12724: #13318 added the three Gemini 3.8
  Flash tiers (high/medium/low, no "-tiered" endpoint for 3.8) to the shared
  Antigravity/AGY base, 10 -> 13. Pin the new size in one constant and make the
  buildSurfaceCatalog delta assertions relative to it.
- t28-model-catalog-updates: #12663 (issue #12638) registered gemini-3.8-flash
  at the head of the AI Studio fallback catalog as the current Flash default;
  assert 3.8 first and keep 3.7 present.
- command-code-mimo-v2-5-safety: #13863 (issue #13847) added an explicit
  "mimo-v2.5" fragment to the shared vision heuristic so provider-qualified and
  `-free` aliases keep their vision flag. The guard's real concern (the
  "mimo-vl" fragment must not cover "mimo-v2.5") is asserted on the fragment
  itself; the bare id is now vision by heuristic on purpose, and the Pro
  text-only sibling stays excluded.

Refs #13866

* test(cli): follow the #12565 cliRuntime module split in the npm-prefix and qodercli guards

#12565 (issue #12563) moved the npm global-prefix cache out of cliRuntime.ts
into cliRuntimeNpmPrefix.ts and built the Windows known-bin candidates with
`path.win32` (cliRuntimeWindowsNode.ts) so they stay Windows-shaped when
`process.platform` is mocked on a POSIX runner. Two pre-existing guards
depended on the old layout:

- cli-runtime-extended "resolves known binaries from npm global prefix":
  importFresh() only re-evaluates cliRuntime.ts; the prefix cache now lives in
  a module that stays shared across cases, so a real `npm config get prefix`
  from an earlier case was cached and the mocked execFileSync never ran. Reset
  the cache with the helper #12565 exported for exactly this in afterEach.
- qodercli-windows-resolve-6263: compare against `path.win32.join` — identical
  to `path.join` on a real Windows host, which is the behaviour under test.

Production behaviour is unchanged on both platforms.

Refs #13866

* test(auto-update): write the source-mode log inside the test's own temp dir

The launchAutoUpdate case pointed AUTO_UPDATE_LOG_PATH at a fixed, world-shared
`/tmp/auto-update-source.log`. On the .113 runner the suite executes both as
`root` and as `runner` (uid 1001): the file survives owned by whoever ran
first (`-rw-r--r-- root root`), and the next `openSync(logPath, "a")` fails
with EACCES for the other user. Reproduced locally by making the shared file
read-only; production code is untouched (autoUpdate.ts last changed in #9354).

Use a per-test mkdtemp path for the source-mode log and clean the whole temp
root in the existing finally block.

Refs #13866

* fix(dashboard): rename claudeConnectionFields.ts so it no longer case-collides with ClaudeConnectionFields.tsx

#13074 added two modules to the provider-detail modals directory whose names
differ only by casing: `ClaudeConnectionFields.tsx` (the component) and
`claudeConnectionFields.ts` (the value/patch helpers). On a case-insensitive
filesystem the pair breaks the webpack build (#6584 guard), and esbuild's
resolver already picks the `.tsx` for the extension-less `./claudeConnectionFields`
specifier, so the provider-detail client entry failed to bundle ("No matching
export ... for import claudeConnectionFieldPatch").

Rename the helper module to `claudeConnectionFieldValues.ts` (the same naming
the sibling `quotaScrapingFieldValues.ts` uses) and point the only importer,
EditConnectionModal.tsx, at the new name. Greens
tests/unit/case-collision-6584.test.ts and
tests/unit/media-page-client-browser-bundle.test.ts.

Refs #13866

* fix(build): allowlist dist/httpClientAbortGuard.mjs so the published tarball keeps the server-ws crash guard

#14064 (re-land of #13636) made scripts/dev/standalone-server-ws.mjs import
./httpClientAbortGuard.mjs and taught assembleStandalone to copy the shared
implementation next to dist/server-ws.mjs — but never registered the file in
scripts/build/pack-artifact-policy.ts. The prepublish prune deletes anything
outside APP_STAGING_ALLOWED_EXACT_PATHS, and check:pack-artifact only fails on
PACK_ARTIFACT_REQUIRED_PATHS entries, so the next `omniroute` tarball would
boot straight into ERR_MODULE_NOT_FOUND (the #7065 / tls-options class the
closure tests exist to catch).

Add the bare and dist/ entries to both lists and extend the required-paths
snapshot in tests/unit/pack-artifact-policy.test.ts. Greens
tests/unit/pack-artifact-entrypoint-closures.test.ts and
tests/unit/pack-artifact-server-ws-closure.test.ts.

Refs #13866

* test(docker): accept --chown=node:node on the better-sqlite3 runner COPY

#14010 deliberately changed the runner-stage COPYs to `COPY --chown=node:node
--from=builder ...` (ownership at copy time instead of a second ~2 GB
`chown -R` overlay layer). The Dockerfile contract test still matched the old
`COPY --from=builder /app/node_modules/better-sqlite3` prefix and went red on
the tip even though the native-addon guard it protects is intact. Tolerate the
optional --chown flag; every other assertion (node-gyp rebuild, both
`test -f .../better_sqlite3.node` checks) is unchanged.

Refs #13866

* fix(api): validate /v1/responses/input_tokens bodies with Zod (t06)

#13167 added the local Responses token-count route with hand-rolled
`typeof` checks on `request.json()`. Hard Rule #7 and the t06 gate
(scripts/check/check-route-validation.mjs, mirrored by
tests/unit/route-body-validation-t06.test.ts) require every route that reads
request.json() to go through validateBody()/safeParse(), so the tip was red.

Add `v1ResponsesInputTokensSchema` (pins the wire types the counter reads —
model/instructions strings, input string-or-array, tools array — and lets
unknown keys through since they are counted, never forwarded) and run the body
through validateBody(); a type mismatch is now a 400 naming the field instead
of a silently ignored key. Regression test added to
tests/unit/responses-input-tokens-local-route.test.ts.

Refs #13866

* fix(docs): drop the retired openference.svg asset reintroduced by #13378

`openference.svg` is one of the 78 provider assets retired for missing
provenance (tests/unit/provider-assets-generic-fallback.test.mjs freezes that
list and forbids any tracked surface from referencing a retired name). #13378
added a new hand-drawn `public/openference.svg` outside the manifest-audited
public/providers/ tree and pointed the README free-tier table (plus the 22
i18n mirrors that carry the row) at it, which put the retired name back on a
tracked surface and left an unaudited asset in the package.

Use the generic fallback icon (`public/providers/cli-generic.svg`) the other
provenance-less providers already use, delete the unaudited file, and adopt
the mechanical README edit into .i18n-state.json
(`i18n:run -- --adopt --files=README.md`, no API calls) so the i18n drift gate
does not flag README.md as source-changed.

Refs #13866

* test(lease): classify the two connection-query sites added by #14159 and #13874

The hard-lease bypass inventory froze every getProviderConnections /
getProviderConnectionById site with a class; two landed on the tip without a
golden update:

- src/app/api/settings/cache-config/embeddingOptions.ts (#14159, re-land of
  #12630): read-only listing that feeds the semantic-cache embedding dropdown,
  same shape as the qdrant embedding-models route — class C.
- src/lib/tokenHealthCheck.ts 2 -> 3 (#13874): re-reads the row by id after an
  unrecoverable refresh error to detect credentials rotated by a concurrent
  Layer 2 refresh before deactivating — a state read, not dispatch; stays C.

Refs #13866

* fix(sse): restore nvidia as the emergency budget-fallback provider

#14006 (Antigravity MITM catalog injection) flipped
EMERGENCY_FALLBACK_CONFIG.provider from "nvidia" to "groq" in one line,
without touching ENVIRONMENT.md, .env.example, the chat.ts comment or the
NVIDIA hosted-model snapshot, all of which still promise
nvidia/openai/gpt-oss-120b. Operators without a Groq connection got the
original 402 back instead of the free reroute, and
chat-route-coverage ("uses the emergency fallback model on budget
exhaustion" / "returns the primary budget error when emergency fallback
also fails") went red on the tip.

Put the documented default back; the #14006 bridge tests exercise
bin/antigravity-bridge.mjs and do not read this config.

Refs #13866

* fix(resilience): keep maxWaitMs=0 a "no queue deadline" sentinel

#12902 released requestQueue.maxWaitMs=0 as the sentinel that disables
the queue-wait deadline, but the #12715 queue-budget gate in
withRateLimit() (`if (queueRemainingMs <= 0) throw`) read 0 as "budget
spent" and rejected every request on a protected connection with an
immediate 503 queue-budget error — the exact opposite of what the
setting promises. rate-limit-maxwaitms-disable-execution ("400ms job
completes without 504") was red on the tip.

When no caller budget is passed and the configured queue budget is 0,
skip the gate, never arm the queue-wait timer and hand
awaitProviderDefaultSlot no budget (it falls back to the window).
Execution stays bounded by executionMaxWaitMs and the upstream
fetch-start timeout, as before.

Refs #13866

* test: align three fixtures with the #13874, #13861 and #13350 contracts

Three base-reds that are deliberate contract changes, not defects:

- executor-default-base "refreshCredentials swallows refresh errors":
  #13874 records rotations on the Layer 2 (no connectionId) refresh path
  too, so the "refresh-me" token the previous case already rotated was
  served from the rotation map without the network POST the test wanted
  to fail. Use a token nobody rotated.
- telegram-keycache-bounded-13165: #13861 made comboTargetKeyPolicy
  import isModelBlockedByPatterns from db/apiKeys; the loader-stubbed
  module lacked it and the suite died at module load. Export an honest
  "not blocked" stub (the test has no blocked models).
- upstream-headers-proxy-auth "ordinary headers are still allowed":
  #13350 forbids the whole origin-IP forwarding set upstream (covered
  by upstream-headers-sanitize). Swap x-forwarded-for for x-request-id.

Refs #13866
2026-09-22 01:52:30 -03:00
Diego Rodrigues de Sa e Souza
282db2a371 docs(changelog): reconcile the v3.8.51 living section — round 3 (2026-09-21) (#14371)
Third reconciliation pass of the living [3.8.51] section against release/v3.8.50..release/v3.8.51 (091589089c06f1df9d77): 364 fragments folded, 104 bullets generated for commits without a fragment, 254 fragment bullets linked and credited by origin commit, 1,393 bullets total, 226 external contributors (0 missing on cross-check). Hand-corrected credits: #12885 → @patrykkopycinski, #14159 → @BillyOutlast (feature bullet), #12972 → @IAMBOBJIM.
2026-09-22 00:27:51 -03:00
Diego Rodrigues de Sa e Souza
3ee283bb94 docs(i18n): refresh the 689 mirrors left stale by the Codex quota outage (#14357)
* docs(i18n): refresh the 689 mirrors left stale by the Codex quota outage

Section-level retranslation, for the 45 locales the interrupted run of
2026-09-18 left behind, of the docs the base edited that day (README,
SECURITY, API_REFERENCE, ENVIRONMENT, FEATURE_FLAGS, PROVIDER_REFERENCE,
REASONING_REPLAY, SOCKET_DEV_FINDINGS, EMBEDDED-SERVICES, admission-lanes,
...) on codex/gpt-5.6-sol-low once its quota returned: 65/65 locales, 0
failures, 2 h 40 with 6 workers. The drift gate's stale-target warning is
empty again.

* docs(i18n): adopt the tr mirrors whose state save lost the race

* docs(i18n): re-adopt the mirrors prettier reformatted in the pre-commit hook

* docs(i18n): refresh ENVIRONMENT and FEATURE_FLAGS mirrors for the base edits of 2026-09-21

* docs(i18n): re-adopt mirrors reformatted by the pre-commit hook

* docs(i18n): adopt the he mirrors whose state save lost the race

* docs(i18n): take the base README mirrors and state after the merge
2026-09-21 20:42:15 -03:00
Bob.Hou
1853a61807 fix(startup): bound model-catalog sync fan-out at boot (#14113)
The first autoSync cycle launched every connection at once. A host with
112 connections then held 112 catalog JSON parses on a cold heap and
died at the V8 cap. Cap in-flight fetches at 4 for the whole cycle, and
wait 90s so boot can serve traffic and the 30s cleanup has already run.

Signed-off-by: Minxi Hou <houminxi@gmail.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
2026-09-21 19:07:41 -03:00
Diego Rodrigues de Sa e Souza
66872b3271 fix(security): guard the Obsidian baseUrl against cloud-metadata SSRF (GHSA-474q-g63r-w4rr) (#14322)
obsidianFetch passed the operator-controlled baseUrl straight to fetch(); POST /api/settings/obsidian then dialed it and persisted it for every later /api/obsidian/* call. The Local REST API legitimately lives on loopback / LAN / Tailscale, so the policy is the provider one (#5066): private hosts stay allowed, cloud-metadata and link-local are blocked unconditionally (route-level Zod refine + safeOutboundFetch block-metadata), redirects are never followed. Five TDD cases, red on the tip.
2026-09-21 18:35:50 -03:00
Diego Rodrigues de Sa e Souza
b03276e99a test: bump the feature-flag count guard to 75 (893fef9c added OPENCODE_PARK_AND_RESUME) (#14366)
893fef9c updated feature-flags-settings.test.ts but not the sibling count guard in server-owned-tool-loop-flag.test.ts, so unit shard 2/4 failed on every PR cut from the tip. Refs #13866.
2026-09-21 18:18:58 -03:00
11127 changed files with 289927 additions and 183116 deletions

View File

@@ -293,9 +293,9 @@ OMNIROUTE_USE_TURBOPACK=1
# OMNIROUTE_SKIP_DB_HEALTHCHECK=1
# Interval (ms) for the background credential health check scheduler.
# Default: 300000 (5 minutes). Minimum: 10000 (10 seconds).
# Default: 3600000 (60 minutes). Minimum: 10000 (10 seconds).
# Used by: open-sse/config/constants.ts, src/lib/credentialHealth/scheduler.ts
# CREDENTIAL_HEALTH_CHECK_INTERVAL=300000
# CREDENTIAL_HEALTH_CHECK_INTERVAL=3600000
# TTL (ms) for cached credential health status.
# Default: 300000 (5 minutes).
@@ -1326,6 +1326,11 @@ CODEX_OAUTH_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann
# ── Kimi Coding (Moonshot) ──
KIMI_CODING_OAUTH_CLIENT_ID=17e5f671-d194-4dfb-9706-5516cb48c098
# ── Muse Code (Meta) ──
# Public device-flow client id is baked into open-sse/utils/publicCreds.ts (muse_id).
# Set this only to override the Muse CLI client. Do not put the public id here.
# MUSE_CODE_OAUTH_CLIENT_ID=
# ── GitHub Copilot ──
GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
@@ -1409,6 +1414,10 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
# VISION_BRIDGE_BASE_URL=
# VISION_BRIDGE_API_KEY=
# How long a "no usable vision candidate" outcome is remembered, in ms.
# Invalid or negative values fall back to the default; 0 disables the negative cache.
# OMNIROUTE_VISION_BRIDGE_NEGATIVE_CACHE_MS=30000
# ─────────────────────────────────────────────────────────────────────────────
# ⚠️ GOOGLE OAUTH (Antigravity) & OTHER PROVIDERS — REMOTE SERVERS
# ─────────────────────────────────────────────────────────────────────────────
@@ -1448,7 +1457,7 @@ CLAUDE_USER_AGENT="claude-cli/2.1.258 (external, cli)"
# forward the original names verbatim (debugging only).
# CLAUDE_DISABLE_TOOL_NAME_CLOAK=false
# Optional override; leave unset to follow the shared Codex client version.
# CODEX_USER_AGENT="codex-cli/0.153.4 (Windows 10.0.26200; x64)"
# CODEX_USER_AGENT="codex-cli/0.155.0 (Windows 10.0.26200; x64)"
GITHUB_USER_AGENT="GitHubCopilotChat/0.54.0"
ANTIGRAVITY_USER_AGENT="antigravity/2.0.1 linux/arm64 google-api-nodejs-client/10.3.0"
KIRO_USER_AGENT="AWS-SDK-JS/3.0.0 kiro-ide/1.0.0"
@@ -1468,7 +1477,7 @@ CURSOR_USER_AGENT="Cursor/3.4"
# Override Codex client version sent in headers independently of the
# CODEX_USER_AGENT string. Used by: open-sse/config/codexClient.ts.
# CODEX_CLIENT_VERSION=0.153.4
# CODEX_CLIENT_VERSION=0.155.0
#
# Override the advertised Claude Code client version independently of
# CLAUDE_USER_AGENT. Anthropic gates some models (Fable 5.1) on this
@@ -1809,6 +1818,7 @@ CURSOR_USER_AGENT="Cursor/3.4"
# OPENCODE_PARK_AND_RESUME=false # #13924 feature flag (Settings → Feature Flags wins): park the request with a heartbeat after repeated transient 429s, then replay one capped leg of up to 3 accounts
#OPENCODE_POOL_STRAIN_MARKER_PATH=/tmp/opencode-pool-strain.json # #13924: pool-strain marker path (JSON {since, reason, ttl_s}); fresh marker parks without recounting
# RESPONSES_FIRST_BYTE_TIMEOUT_MS=15000 # #13484: OpenCode Responses first-byte window, only used when the OPENCODE_RESPONSES_STALL_ROTATION flag is on (0 disables)
# FLUSH_EMPTY_RETRY_ENABLED=false # #14213 feature flag (Settings → Feature Flags wins): retry empty translated streaming turns through the normal credential path (up to STREAM_RECOVERY.EMPTY_TURN_RETRY_MAX retries)
# ── API Bridge (/v1 proxy server) ──
# API_BRIDGE_PROXY_TIMEOUT_MS=600000 # Proxy hop timeout (default: 10min)
@@ -2332,6 +2342,11 @@ APP_LOG_TO_FILE=true
# PROXY_HEALTH_ENABLED=true
# Sweep interval in ms (minimum 60000). Default: 600000 (10min).
# PROXY_HEALTH_INTERVAL_MS=600000
# Background recovery-pass interval in ms: how often the scheduler re-probes proxies it
# previously marked unhealthy, so a proxy that comes back is picked up without a restart.
# Values below 60000 fall back to the default.
# PROXY_HEALTH_RECOVERY_INTERVAL_MS=600000
# Reachability probe target for the scheduler and the auto-test endpoint.
# Point it at an internal/self-hosted URL to avoid the public default.
# PROXY_HEALTH_TEST_URL=https://httpbin.org/ip

View File

@@ -144,6 +144,16 @@ jobs:
- run: npm run check:test-discovery
- run: npm run check:radar-sentinels
- run: npm run check:tracked-artifacts
- name: AI attribution in commit / PR metadata (Hard Rule #16)
if: github.event_name == 'pull_request'
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
printf '%s' "$PR_BODY" > "$RUNNER_TEMP/pr-body.md"
npm run check:ai-attribution -- --range "$PR_BASE_SHA..$PR_HEAD_SHA" --pr-title "$PR_TITLE" --pr-body-file "$RUNNER_TEMP/pr-body.md"
# A test parked in vitest.config.ts's exclude list does not run, and looks like
# coverage to whoever reads the tree. 62 files accumulated behind a comment pointing
# at #8618 — closed in August while the list grew to 62; 51 of them passed when
@@ -1113,7 +1123,7 @@ jobs:
# stalled upload can neither eat the job's budget nor turn a green job cancelled.
timeout-minutes: 5
continue-on-error: true
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@0b35c9ecc4f0529d0eb674914510c22f85b196b4 # v7.1.0
with:
files: coverage/lcov.info
token: ${{ secrets.CODECOV_TOKEN }}

View File

@@ -22,10 +22,10 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
- uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
with:
languages: javascript-typescript
queries: security-extended
- uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
- uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
with:
category: "/language:javascript-typescript"

View File

@@ -193,6 +193,7 @@ jobs:
platforms: ${{ matrix.platform }}
build-args: |
OMNIROUTE_USE_TURBOPACK=0
OMNIROUTE_BUILD_MEMORY_MB=12288
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
tags: |
${{ env.IMAGE_NAME }}
@@ -212,6 +213,7 @@ jobs:
platforms: ${{ matrix.platform }}
build-args: |
OMNIROUTE_USE_TURBOPACK=0
OMNIROUTE_BUILD_MEMORY_MB=12288
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
tags: |
${{ env.IMAGE_NAME }}
@@ -239,6 +241,7 @@ jobs:
platforms: ${{ matrix.platform }}
build-args: |
OMNIROUTE_USE_TURBOPACK=0
OMNIROUTE_BUILD_MEMORY_MB=12288
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
tags: |
${{ env.IMAGE_NAME }}
@@ -266,6 +269,7 @@ jobs:
platforms: ${{ matrix.platform }}
build-args: |
OMNIROUTE_USE_TURBOPACK=0
OMNIROUTE_BUILD_MEMORY_MB=12288
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
tags: |
${{ env.IMAGE_NAME }}
@@ -580,7 +584,7 @@ jobs:
- name: Upload Trivy SARIF to Security tab
if: needs.prepare.outputs.version != 'main'
continue-on-error: true
uses: github/codeql-action/upload-sarif@v4.37.9
uses: github/codeql-action/upload-sarif@v4.38.0
with:
sarif_file: trivy-results.sarif
category: trivy-image

View File

@@ -293,6 +293,9 @@ jobs:
# #8781: open-sse workspace typecheck gate — the workspace imports @/ which
# escapes to src/ via undeclared path aliases. See check-open-sse-typecheck.mjs.
open-sse-typecheck
# Hard Rule #16 — AI/bot attribution in PR commits, title or body (#14436). Reads the PR
# from GITHUB_EVENT_PATH; no-op on non-PR events. ci.yml only runs on PRs to main.
ai-attribution
)
ratchet_gates=(
secrets vuln-ratchet workflows openapi-breaking

View File

@@ -21,7 +21,7 @@ jobs:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: actions/setup-node@v5
- uses: actions/setup-node@v7
with:
node-version: "22"
cache: npm

4
.husky/commit-msg Executable file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
# Hard Rule #16 — no AI/bot Co-Authored-By trailers or AI-generation footers in commit metadata.
# Human co-authors stay. Incident record: #14436.
node scripts/check/check-ai-attribution.mjs --message-file "$1"

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,34 @@ npm install @omniroute/opencode-plugin-v2
}
```
### Local `file://` install
OpenCode resolves a local plugin **directory** by probing the subpaths
`server.*` / `index.*` (then `tui`, `rpc`) at the package root — it never
reads `package.json` `main`/`exports`. A folder exposing only `dist/` is
therefore silently skipped (no `loading plugin`, no error).
This package ships a root `server.js` re-exporting `./dist/index.js` for
exactly that probe, so pointing OpenCode at a local checkout works:
```json
{
"plugins": [
{
"package": "file:///path/to/OmniRoute/@omniroute/opencode-plugin-v2",
"options": {
"providerId": "omniroute",
"baseURL": "http://localhost:20128"
}
}
]
}
```
Prerequisites when targeting a folder: run `npm run build` first (the root
`server.js` re-exports `./dist/index.js`), and keep the folder's root
`server.js``dist/` alone is not resolvable by the host.
## Credentials
The plugin needs a gateway key to read the catalog, and looks for one in this

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@
},
"files": [
"dist",
"server.js",
"README.md",
"LICENSE"
],
@@ -26,7 +27,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@opencode-ai/plugin": "1.18.29",
"@opencode/plugin": "2.0.12",
"@types/node": "^22.19.19",
"tsup": "^8.5.1",
"tsx": "^4.22.3",
@@ -62,7 +63,7 @@
"access": "public"
},
"peerDependencies": {
"@opencode-ai/plugin": ">=1.18.29 <2"
"@opencode/plugin": ">=2.0.12 <3"
},
"overrides": {
"esbuild": "^0.28.1"

View File

@@ -0,0 +1,10 @@
// Root entrypoint for OpenCode host plugin loading.
//
// OpenCode 2.x local installs (`file://` directory in `opencode.json`) never
// read package.json `main`/`exports`: the config scan resolves only the
// subpaths ["server", ""] then ["tui"], ["rpc"] from the package directory.
// With only `dist/index.js` present the scan yields `{}` and the plugin is
// silently dropped (no `loading plugin`, no error). This stable re-export
// keeps `dist/` as the only build output while exposing the module the host
// actually probes for.
export { default } from "./dist/index.js";

View File

@@ -1,7 +1,5 @@
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import { type HostContract, detectHostContract, emitsLegacyFields } from "./compat.js";
import type { Model as LegacyModelV2 } from "@opencode-ai/sdk/v2";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import type { Model, Provider } from "@opencode/plugin";
import type { LegacyModel } from "./legacy-model.js";
import {
isHttpUrl,
type ApiFormatV2,
@@ -92,52 +90,110 @@ export interface CatalogFetchers {
onSourceError?: (endpoint: string, reason: string) => void;
}
// The shared mappers speak the legacy (`Provider.models[id]`) `Model` shape
// (imported from `@opencode-ai/sdk/v2`, also re-exported by the plugin root
// as `ModelV2`); the real v2 `CatalogDraft` carries `ModelV2Info` instead.
// Convert the fields 1:1 at the draft boundary -- NEVER `as unknown as` the
// whole model.
//
// Binary-compat note: the prod binary (beta-17823) reads a top-level
// `package` field on both Model and Provider structs (`package:a.Package`,
// gated by `isAISDK = startsWith("aisdk:")`), with a model-to-provider
// fallback (`package: u.package ?? s.package`). The pinned SDK types
// (1.18.29) only know the `api` block, so the binary field is published via
// the typed extensions below (spread/Object.assign, never `any`).
export const BINARY_AISDK_PREFIX = "aisdk:";
export type StableModelInfo = Model.Info;
export type StableProviderInfo = Provider.Info;
/** Top-level `package` as the legacy contract expects it (`aisdk:<npm>`). */
export interface BinaryCompatPackage {
package: string;
/**
* Structural mirror of the stable `ctx.provider.transform` editor, used as
* the parameter type where the payload is handed to the host (and in tests
* that fake the editor). Kept as documentation of the contract surface even
* where only `add` is exercised.
*/
export interface StableProviderEditor {
add(input: { info: StableProviderInfo; models: readonly StableModelInfo[] }): void;
get(providerID: string): { provider: StableProviderInfo } | undefined;
list(): readonly { provider: StableProviderInfo }[];
update(providerID: string, update: (provider: StableProviderInfo) => void): void;
remove(providerID: string): void;
readonly models: {
set(providerID: string, models: readonly StableModelInfo[]): void;
update(providerID: string, modelID: string, update: (model: StableModelInfo) => void): void;
remove(providerID: string, modelID: string): void;
};
}
/**
* The legacy contract keeps on the model/provider itself what the `api` block
* carries in the pinned types: the aisdk package, the endpoint (as
* `settings.baseURL`) and the per-request headers. None of these keys collide
* with a key of `ModelV2Info`/`ProviderV2Info`, so both field sets can be
* published on the same object.
* Project the legacy catalog entry the shared mappers produce onto the
* stable `Model.Info` shape. The mapper layer stays untouched; only this
* boundary knows both shapes. Extra legacy-only keys (`api`, `options`,
* string `release_date`) are dropped, never cast across.
*/
export interface BinaryCompatFields extends BinaryCompatPackage {
settings: Record<string, unknown>;
headers: Record<string, string>;
export function legacyToStable(
providerID: string,
modelID: string,
m: LegacyModel,
apiKey: string,
baseURL: string
): StableModelInfo {
if (!m.api || typeof m.api.npm !== "string" || m.api.npm.length === 0) {
throw new Error(
"[omniroute-v2] refusing to publish a model without an api block (missing api.npm)"
);
}
if (!isHttpUrl(m.api.url)) {
throw new Error(
"[omniroute-v2] refusing to publish a model whose api block carries no http(s) url"
);
}
const stablePackage =
m.api.npm === "@ai-sdk/anthropic" ? "@opencode/ai/providers/anthropic" : NPM_OPENAI_COMPAT;
const input: string[] = [];
if (m.capabilities.input.text) input.push("text");
if (m.capabilities.input.audio) input.push("audio");
if (m.capabilities.input.image) input.push("image");
if (m.capabilities.input.video) input.push("video");
if (m.capabilities.input.pdf) input.push("pdf");
const output: string[] = [];
if (m.capabilities.output.text) output.push("text");
if (m.capabilities.output.audio) output.push("audio");
if (m.capabilities.output.image) output.push("image");
if (m.capabilities.output.video) output.push("video");
if (m.capabilities.output.pdf) output.push("pdf");
const variants = Object.entries(m.variants ?? {}).map(([id, body]) => ({
id,
settings: { ...(body as Record<string, unknown>) },
headers: {},
body: { ...(body as Record<string, unknown>) },
}));
const parsed = Date.parse(m.release_date);
const info = {
id: modelID,
modelID,
providerID,
...(m.family !== undefined ? { family: m.family } : {}),
name: m.name,
package: stablePackage,
settings: { baseURL: ensureV1Suffix(baseURL), apiKey },
headers: { ...m.headers },
...(Object.keys(m.options).length > 0 ? { body: { ...m.options } } : {}),
capabilities: { tools: m.capabilities.toolcall, input, output },
variants,
time: { released: Number.isNaN(parsed) ? 0 : parsed },
cost: [
{ input: m.cost.input, output: m.cost.output, cache: { ...m.cost.cache } },
],
status: m.status,
enabled: true,
limit: { ...m.limit },
} as unknown;
return info as StableModelInfo;
}
/** Legacy variants read their options from `settings`, not `headers`/`body`. */
export type BinaryCompatVariant = ModelV2Info["variants"][number] & {
settings: Record<string, unknown>;
};
const NPM_OPENAI_COMPAT = "@opencode/ai/providers/openai-compatible";
export type BinaryCompatModel = ModelV2Info & BinaryCompatFields;
export type BinaryCompatProvider = ProviderV2Info &
BinaryCompatPackage & {
settings: Record<string, unknown>;
};
export function toBinaryPackage(npm: string): string {
return npm.startsWith(BINARY_AISDK_PREFIX) ? npm : `${BINARY_AISDK_PREFIX}${npm}`;
}
export function legacyApiToInfoApi(api: LegacyModelV2["api"]): ModelV2Info["api"] {
/**
* Fail-fast guard for a pre-mapped `api` block: the snapshot filter and the
* stale-entry suite assert on it, and the beta-replay adapter relies on the
* same refusal for entries that bypass the mapper. New mapper output always
* carries a valid block via `resolveApiBlockV2`, so this fires only on stale
* snapshots or hand-built entries.
*/
export function legacyApiToInfoApi(api: LegacyModel["api"]): {
id: string;
type: "aisdk";
package: string;
url: string;
} {
if (!api || typeof api.npm !== "string" || api.npm.length === 0) {
throw new Error(
"[omniroute-v2] refusing to publish a model without an api block (missing api.npm)"
@@ -155,13 +211,14 @@ export function legacyApiToInfoApi(api: LegacyModelV2["api"]): ModelV2Info["api"
return { id: api.id, type: "aisdk", package: api.npm, url: api.url };
}
function legacyCostToInfoCost(cost: LegacyModelV2["cost"]): ModelV2Info["cost"] {
return [{ input: cost.input, output: cost.output, cache: cost.cache }];
function legacyCostToInfoCost(cost: LegacyModel["cost"]): StableModelInfo["cost"] {
const c = [{ input: cost.input, output: cost.output, cache: cost.cache }];
return c as unknown as StableModelInfo["cost"];
}
function legacyCapabilitiesToInfoCapabilities(
caps: LegacyModelV2["capabilities"]
): ModelV2Info["capabilities"] {
caps: LegacyModel["capabilities"]
): StableModelInfo["capabilities"] {
const input: string[] = [];
if (caps.input.text) input.push("text");
if (caps.input.audio) input.push("audio");
@@ -177,21 +234,21 @@ function legacyCapabilitiesToInfoCapabilities(
return { tools: caps.toolcall, input, output };
}
function legacyToInfo(providerID: string, modelID: string, m: LegacyModelV2): ModelV2Info {
function legacyToInfo(providerID: string, modelID: string, m: LegacyModel): StableModelInfo {
const variants = Object.entries(m.variants ?? {}).map(([id, body]) => ({
id,
headers: {},
body: body as Record<string, unknown>,
}));
const parsed = Date.parse(m.release_date);
return {
const out = {
id: modelID,
modelID,
providerID,
...(m.family !== undefined ? { family: m.family } : {}),
name: m.name,
api: legacyApiToInfoApi(m.api),
capabilities: legacyCapabilitiesToInfoCapabilities(m.capabilities),
request: { headers: { ...m.headers }, body: { ...m.options } },
headers: { ...m.headers },
variants,
time: { released: Number.isNaN(parsed) ? 0 : parsed },
cost: legacyCostToInfoCost(m.cost),
@@ -199,6 +256,7 @@ function legacyToInfo(providerID: string, modelID: string, m: LegacyModelV2): Mo
enabled: true,
limit: { ...m.limit },
};
return out as unknown as StableModelInfo;
}
export interface PublishCounts {
@@ -265,74 +323,39 @@ export function passesComboAllowlist(combo: OmniRouteRawCombo, visible?: ModelLi
}
/**
* Project the `api` block onto the legacy top-level fields. Only the `aisdk`
* variant of `ModelApi`/`ProviderApi` carries a package, so the caller narrows
* before calling; a `native` api has no legacy equivalent and publishes
* nothing (the legacy contract has no native models).
* Copy the converted legacy fields onto a stable `Model.Info` target.
* Kept for the beta-replay adapter below (`publishCatalog`), which reuses it
* per entry; new code calls `legacyToStable` via `buildProviderPayload`.
*/
function legacyModelFields(info: ModelV2Info): BinaryCompatFields | undefined {
if (info.api.type !== "aisdk") return undefined;
const settings: Record<string, unknown> = {
...(info.api.settings ?? {}),
...info.request.body,
};
if (info.api.url !== undefined) settings.baseURL = info.api.url;
return {
package: toBinaryPackage(info.api.package),
settings,
headers: { ...info.request.headers },
};
}
/** `{id, headers, body}` (pinned types) plus `{settings}` (legacy contract). */
function legacyVariants(variants: ModelV2Info["variants"]): BinaryCompatVariant[] {
return variants.map((variant) => ({ ...variant, settings: { ...variant.body } }));
}
function assignModelFields(
target: ModelV2Info,
source: LegacyModelV2,
contract: HostContract
export function assignModelFields(
target: StableModelInfo,
source: LegacyModel,
apiKey: string,
baseURL: string
): void {
const info = legacyToInfo(target.providerID || source.providerID, target.id || source.id, source);
target.name = info.name;
target.api = info.api;
target.capabilities = info.capabilities;
target.request = info.request;
target.variants = info.variants;
target.time = info.time;
target.cost = info.cost;
target.status = info.status;
target.enabled = info.enabled;
target.limit = info.limit;
if (info.family !== undefined) {
target.family = info.family;
}
if (!emitsLegacyFields(contract)) return;
const legacy = legacyModelFields(info);
if (legacy !== undefined) {
Object.assign(target, legacy);
target.variants = legacyVariants(info.variants);
}
const info = legacyToStable(
(target.providerID as string) || source.providerID,
(target.id as string) || source.id,
source,
apiKey,
baseURL
);
Object.assign(target, info);
}
function assignProviderFields(
target: ProviderV2Info,
source: { name: string; api: ProviderV2Info["api"]; integrationID: string },
contract: HostContract
/**
* Copy the provider identity fields onto a stable `Provider.Info` target.
* Kept for the beta-replay adapter below (`publishCatalog` writes `name` /
* `integrationID` through it before adding stable fields); new code builds
* the provider object inline in `buildProviderPayload`.
*/
export function assignProviderFields(
target: StableProviderInfo,
source: { name: string; integrationID: string },
_contract?: unknown
): void {
target.name = source.name;
target.api = source.api;
target.integrationID = source.integrationID;
if (!emitsLegacyFields(contract)) return;
// The legacy contract defaults `Provider.Info.package` to `""` and model
// resolution falls back to it (`package: model.package ?? provider.package`),
// so the provider carries the same `aisdk:<npm>` value as its models, and
// the endpoint as `settings.baseURL`.
if (source.api.type !== "aisdk") return;
const settings: Record<string, unknown> = { ...(source.api.settings ?? {}) };
if (source.api.url !== undefined) settings.baseURL = source.api.url;
Object.assign(target, { package: toBinaryPackage(source.api.package), settings });
(target as { name: string }).name = source.name;
(target as { integrationID: string }).integrationID = source.integrationID;
}
/** A widened capability flag (`boolean | { field }`) read back as a plain flag. */
@@ -413,15 +436,14 @@ async function resolveUsableAliases(
return rawConnections.length > 0 ? usableProviderAliasSet(rawConnections, enrichment) : undefined;
}
/** Everything the combo publishing pass reads, passed as one value. */
/** Everything the combo collection pass reads, passed as one value. */
interface PublishContext {
draft: CatalogDraft;
opts: ResolvedOptions;
log: Logger;
providerId: string;
hostContract: HostContract;
enrichment: OmniRouteEnrichmentMap;
rawModelById: Map<string, OmniRouteRawModelEntry>;
collected: Map<string, LegacyModel>;
publishedKeys: Set<string>;
publishedModelIds: Map<string, string>;
visibleFilter: ReturnType<typeof compileModelListFilter>;
@@ -447,13 +469,12 @@ interface PublishContext {
*/
async function publishCombos(ctx: PublishContext): Promise<number | undefined> {
const {
draft,
opts,
log,
providerId: X,
hostContract,
enrichment,
rawModelById,
collected,
publishedKeys,
publishedModelIds,
visibleFilter,
@@ -493,7 +514,7 @@ async function publishCombos(ctx: PublishContext): Promise<number | undefined> {
if (hiddenFilter && passesComboAllowlist(combo, hiddenFilter)) return false;
return true;
});
const resolvedByName = new Map<string, LegacyModelV2>();
const resolvedByName = new Map<string, LegacyModel>();
let unresolved: typeof pending = [];
for (let pass = 0; pass < MAX_COMBO_PASSES && pending.length > 0; pass++) {
@@ -553,9 +574,7 @@ async function publishCombos(ctx: PublishContext): Promise<number | undefined> {
}
}
}
draft.model.update(X, mid, (m) => {
assignModelFields(m, mapped, hostContract);
});
collected.set(key, mapped);
publishedKeys.add(key);
publishedModelIds.set(key, mapped.id);
comboCount += 1;
@@ -588,7 +607,7 @@ async function publishCombos(ctx: PublishContext): Promise<number | undefined> {
* output, modalities, capabilities) instead of only direct raw members.
* v1 parity (combo member synthesis at nested resolution time).
*/
function synthesizeNestedMember(name: string, nested: LegacyModelV2): OmniRouteRawModelEntry {
function synthesizeNestedMember(name: string, nested: LegacyModel): OmniRouteRawModelEntry {
const inputModalities: string[] = [];
if (nested.capabilities.input.text) inputModalities.push("text");
if (nested.capabilities.input.audio) inputModalities.push("audio");
@@ -623,11 +642,22 @@ function synthesizeNestedMember(name: string, nested: LegacyModelV2): OmniRouteR
};
}
export async function publishCatalog(
draft: CatalogDraft,
/**
* Collect the full catalog (models + combos + auto-combos) as legacy entries
* keyed `providerId/bareId`, then project them onto the stable contract in
* `buildProviderPayload`. Collect-then-project keeps every fetch/filter/LCD
* behavior identical to the beta path while the only host touchpoint is the
* single `editor.add` in the payload builder.
*/
export interface CollectedCatalog {
entries: Map<string, LegacyModel>;
counts: PublishCounts;
}
export async function collectCatalog(
opts: ResolvedOptions,
fetchers?: CatalogFetchers
): Promise<PublishCounts> {
): Promise<CollectedCatalog> {
const X = opts.providerId;
const log = opts.logger ?? createLogger(opts.startupDebug ? "debug" : (opts.logLevel ?? "warn"));
const modelsTimeout = opts.timeouts?.models ?? opts.timeoutMs;
@@ -636,35 +666,16 @@ export async function publishCatalog(
// set (P2 resolves it in index.ts; direct publishCatalog callers may only
// pass timeoutMs).
const autoCombosTimeout = opts.timeouts?.autoCombos ?? 5_000;
// The contract is discovered from the object the host seeds into the
// provider draft, which the host fills before any model is published. The
// verdict is then reused for every model: the model seed carries no
// discriminating key, and a single provider/model pair always speaks one
// contract.
let hostContract: HostContract = "unknown";
draft.provider.update(X, (p) => {
hostContract = detectHostContract(p);
assignProviderFields(
p,
{
name: opts.displayName ?? "OmniRoute",
api: {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: ensureV1Suffix(opts.baseURL),
},
integrationID: X,
},
hostContract
);
});
log.debug(`[omniroute-v2] host catalog contract detected: ${hostContract}`);
const modelsFetcher = fetchers?.fetcher ?? fetchers?.models;
const combosFetcher = fetchers?.combosFetcher ?? fetchers?.combos;
const autoCombosFetcher = fetchers?.autoCombosFetcher ?? fetchers?.autoCombos;
const providersFetcher = fetchers?.providersFetcher ?? fetchers?.providers;
const empty: CollectedCatalog = {
entries: new Map(),
counts: { models: 0, combos: 0, autoCombos: 0 },
};
let rawModels: OmniRouteRawModelEntry[];
try {
rawModels = modelsFetcher ? await modelsFetcher(opts.baseURL, opts.apiKey, modelsTimeout) : [];
@@ -672,7 +683,7 @@ export async function publishCatalog(
log.warn(
`[omniroute-v2] models fetch failed, publishing empty catalog: ${err instanceof Error ? err.message : String(err)}`
);
return { models: 0, combos: 0, autoCombos: 0 };
return empty;
}
const visibleFilter = compileModelListFilter(opts.visibleModels);
@@ -701,6 +712,7 @@ export async function publishCatalog(
// v1's `models[comboKey]` lookup so the intentional-dedup check sees the
// overwritten entry's id, not just key presence.
const publishedModelIds = new Map<string, string>();
const collected = new Map<string, LegacyModel>();
let modelCount = 0;
for (const entry of rawModels) {
if (!entry.id) continue;
@@ -716,24 +728,22 @@ export async function publishCatalog(
providerTag: opts.providerTag !== false,
});
const mid = mapped.id.startsWith(X + "/") ? mapped.id.slice(X.length + 1) : mapped.id;
draft.model.update(X, mid, (m) => {
assignModelFields(m, mapped, hostContract);
});
publishedKeys.add(X + "/" + mid);
publishedModelIds.set(X + "/" + mid, mapped.id);
const key = X + "/" + mid;
collected.set(key, mapped);
publishedKeys.add(key);
publishedModelIds.set(key, mapped.id);
modelCount += 1;
}
const warnedCombos = opts.collisionWarned ?? new Set<string>();
const cacheKey = `${opts.baseURL}::${opts.providerId}`;
const comboCount = await publishCombos({
draft,
opts,
log,
providerId: X,
hostContract,
enrichment,
rawModelById,
collected,
publishedKeys,
publishedModelIds,
visibleFilter,
@@ -745,7 +755,8 @@ export async function publishCatalog(
warnedCombos,
cacheKey,
});
if (comboCount === undefined) return { models: modelCount, combos: 0, autoCombos: 0 };
if (comboCount === undefined)
return { entries: collected, counts: { models: modelCount, combos: 0, autoCombos: 0 } };
// Migration: v1 published opencode-X; v2 publishes X bare. Sessions pinned
// opencode-X resolve ModelUnavailableError -- see RELEASE.md migration note.
@@ -769,7 +780,7 @@ export async function publishCatalog(
log.warn(
`[omniroute-v2] auto combos fetch failed, falling back to models+combos catalog: ${err instanceof Error ? err.message : String(err)}`
);
return { models: modelCount, combos: comboCount, autoCombos: 0 };
return { entries: collected, counts: { models: modelCount, combos: comboCount, autoCombos: 0 } };
}
let autoComboCount = 0;
@@ -796,13 +807,93 @@ export async function publishCatalog(
);
}
}
draft.model.update(X, mapped.id, (m) => {
assignModelFields(m, mapped, hostContract);
});
collected.set(key, mapped);
publishedKeys.add(key);
publishedModelIds.set(key, mapped.id);
autoComboCount += 1;
}
return { models: modelCount, combos: comboCount, autoCombos: autoComboCount };
return { entries: collected, counts: { models: modelCount, combos: comboCount, autoCombos: autoComboCount } };
}
/**
* Project a collected catalog onto the stable contract: one provider `info`
* plus one `Model.Info` per entry. The provider carries the endpoint and the
* inference key (`settings.baseURL` + `settings.apiKey`, verified live
* against 2.0.12) so inference authenticates; each model repeats them because
* the host merges model settings over provider settings at request time.
*/
export function buildProviderPayload(
collected: CollectedCatalog,
opts: ResolvedOptions
): { info: StableProviderInfo; models: StableModelInfo[] } {
const X = opts.providerId;
const info = {
id: X,
name: opts.displayName ?? "OmniRoute",
activation: "enabled",
package: NPM_OPENAI_COMPAT,
settings: { baseURL: ensureV1Suffix(opts.baseURL), apiKey: opts.apiKey },
integrationID: X,
} as unknown as StableProviderInfo;
const models: StableModelInfo[] = [];
for (const [key, legacy] of collected.entries) {
const slash = key.indexOf("/");
const bareId = slash > 0 ? key.slice(slash + 1) : legacy.id;
models.push(legacyToStable(X, bareId, legacy, opts.apiKey, opts.baseURL));
}
return { info, models };
}
/**
* Beta-draft publish path: replays a collected catalog into a beta
* `CatalogDraft`-shaped editor. The 19 legacy suite files drive it with
* injected fetchers and read back `api`/`request` aliases plus counts, so
* removing it means rewriting those files to `collectCatalog` +
* `buildProviderPayload` (done for host-contract/api-package/smoke; the rest
* keep the adapter). New product code uses `collectCatalog` +
* `buildProviderPayload` directly; `src/index.ts` never calls this.
*/
export async function publishCatalog(
draft: {
provider: { update: (id: string, fn: (p: Record<string, unknown>) => void) => void };
model: {
update: (pid: string, mid: string, fn: (m: Record<string, unknown>) => void) => void;
};
},
opts: ResolvedOptions,
fetchers?: CatalogFetchers
): Promise<PublishCounts> {
const collected = await collectCatalog(opts, fetchers);
const payload = buildProviderPayload(collected, opts);
const X = opts.providerId;
draft.provider.update(X, (p) => {
const info = payload.info as unknown as Record<string, unknown>;
for (const [k, v] of Object.entries(info)) p[k] = v;
// Beta-shaped aliases the legacy suite reads: `api` block plus
// `request` (headers/body). The stable payload carries the same data as
// top-level `package`/`settings`/`headers`/`body`.
const settings = (info.settings ?? {}) as Record<string, unknown>;
const npm = String(info.package ?? "").replace("@opencode/ai/providers/", "@ai-sdk/");
p["api"] = { type: "aisdk", package: npm, url: settings["baseURL"] };
p["request"] = { headers: (info.headers ?? {}) as Record<string, string>, body: (info.body ?? {}) as Record<string, unknown> };
});
for (const m of collected.entries.keys()) {
const slash = m.indexOf("/");
const mid = slash > 0 ? m.slice(slash + 1) : m;
const stable = payload.models.find(
(s) => (s.id as string) === mid || `${X}/${s.id as string}` === m
);
if (!stable) continue;
draft.model.update(X, mid, (target) => {
for (const [k, v] of Object.entries(stable as unknown as Record<string, unknown>))
target[k] = v;
// Beta-shaped aliases, same projection as the provider above.
const s = stable as unknown as Record<string, any>;
const npm = String(s.package ?? "").replace("@opencode/ai/providers/", "@ai-sdk/");
target["api"] = { type: "aisdk", package: npm, url: s.settings?.baseURL };
target["request"] = { headers: s.headers ?? {}, body: s.body ?? {} };
});
}
return collected.counts;
}

View File

@@ -1,3 +1,24 @@
/**
* The stable contract has no `ctx.catalog`: providers publish through
* `ctx.provider.transform` (`editor.add` with `info` + `models`) and narrow
* through `ctx.model.transform`. This guard therefore requires the provider
* and model transforms plus an options object, and nothing else.
*/
export function assertContext(ctx: unknown): void {
if (!isObject(ctx)) {
throw new Error("[omniroute-v2] contract breach: ctx must be an object");
}
if (!isTransformHolder(ctx.provider) || typeof ctx.provider.transform !== "function") {
throw new Error("[omniroute-v2] contract breach: ctx.provider.transform must be a function");
}
if (!isTransformHolder(ctx.model) || typeof ctx.model.transform !== "function") {
throw new Error("[omniroute-v2] contract breach: ctx.model.transform must be a function");
}
if (!isObject(ctx.options)) {
throw new Error("[omniroute-v2] contract breach: ctx.options must be an object");
}
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -5,62 +26,3 @@ function isObject(value: unknown): value is Record<string, unknown> {
function isTransformHolder(value: unknown): value is { transform: unknown } {
return isObject(value) && "transform" in value;
}
/**
* The catalog domain is the one this plugin cannot work without. The
* integration domain carries the credential flow and the `aisdk` domain the
* tool-schema cleaning: a host missing either still gets its catalog, so
* neither is asserted here — each is probed where it is used.
*/
export function assertContext(ctx: unknown): void {
if (!isObject(ctx)) {
throw new Error("[omniroute-v2] contract breach: ctx must be an object");
}
if (!isTransformHolder(ctx.catalog) || typeof ctx.catalog.transform !== "function") {
throw new Error("[omniroute-v2] contract breach: ctx.catalog.transform must be a function");
}
if (!isObject(ctx.options)) {
throw new Error("[omniroute-v2] contract breach: ctx.options must be an object");
}
}
/**
* Catalog contract spoken by the running host.
*
* opencode v2 is a moving target: the catalog contract changed between the
* binary that ships today and the SDK types this package pins. Rather than
* keying off a version list (which goes stale on the next release), the
* contract is discovered at runtime from the object the host seeds into the
* draft.
*
* - `legacy-package` — the seed carries a top-level `package` and no `api`
* block. Observed on `@opencode-ai/cli` 0.0.0-beta-17823, whose
* `Provider.Info.empty` is `{id, name, activation, package}`.
* - `sdk-api` — the seed carries an `api` block. This is the contract of the
* pinned `@opencode-ai/plugin`/`@opencode-ai/sdk` types.
* - `unknown` — neither or both. The caller publishes the superset.
*/
export type HostContract = "legacy-package" | "sdk-api" | "unknown";
export function detectHostContract(seed: unknown): HostContract {
if (!isObject(seed)) return "unknown";
const hasApi = "api" in seed;
const hasPackage = "package" in seed;
if (hasApi && !hasPackage) return "sdk-api";
if (hasPackage && !hasApi) return "legacy-package";
return "unknown";
}
/**
* Whether to publish the legacy top-level fields (`package`, `settings`,
* `headers`, `variants[].settings`) next to the `api`-block fields.
*
* A host proven to speak the legacy contract gets them because it needs them;
* an unrecognised host gets them because the superset is the safer default
* (both field sets have been observed to survive an unknown-key write). A host
* that speaks the `api` contract does not, so a future strict schema cannot
* reject the write on an excess property.
*/
export function emitsLegacyFields(contract: HostContract): boolean {
return contract !== "sdk-api";
}

View File

@@ -1,6 +1,17 @@
import type { PluginContext } from "@opencode-ai/plugin/v2/promise";
import type { Logger } from "./shared/index.js";
/** Minimal stable context surface this module reads (provider transforms stay untyped here). */
export interface StableCredentialContext {
integration: {
connection?: {
active?: (integrationID: string) => Promise<unknown>;
resolve?: (connection: unknown) => Promise<unknown>;
};
};
}
type StableContext = StableCredentialContext;
/** Where a resolved key came from, so the failure message can name the fix. */
export type ApiKeyOrigin = "connection" | "option" | "env" | "missing";
@@ -12,12 +23,16 @@ export interface ResolvedApiKey {
const ENV_VAR = "OMNIROUTE_API_KEY";
/**
* `ctx.integration.connection` is newer than the `key`/`env` methods this
* plugin registers, so a host that predates it exposes `integration` without
* it. Probing the shape keeps the plugin loadable on both.
* `ctx.integration.connection` carries the stored credential. Probing the
* shape keeps the plugin loadable on a host that exposes `integration`
* without it.
*/
function connectionApi(ctx: PluginContext): PluginContext["integration"]["connection"] | undefined {
const connection = (ctx.integration as Partial<PluginContext["integration"]>).connection;
function connectionApi(
ctx: StableContext
): { active: (id: string) => Promise<unknown>; resolve: (c: unknown) => Promise<unknown> } | undefined {
const connection = (ctx.integration as unknown as Record<string, unknown>).connection as
| { active?: unknown; resolve?: unknown }
| undefined;
if (
connection === undefined ||
typeof connection.active !== "function" ||
@@ -25,7 +40,10 @@ function connectionApi(ctx: PluginContext): PluginContext["integration"]["connec
) {
return undefined;
}
return connection;
return connection as {
active: (id: string) => Promise<unknown>;
resolve: (c: unknown) => Promise<unknown>;
};
}
/**
@@ -36,30 +54,33 @@ function connectionApi(ctx: PluginContext): PluginContext["integration"]["connec
* feed inference and the catalog fetches would still need a key pasted into
* the config file.
*
* Returns `undefined` (never throws) when there is no connection, when the
* host is too old to expose one, or when the stored credential is an OAuth
* grant — this plugin authenticates the gateway with a bearer key, and an
* access token from an unrelated grant is not one.
* Returns `undefined` (never throws) when there is no connection or when the
* stored credential is an OAuth grant — this plugin authenticates the gateway
* with a bearer key, and an access token from an unrelated grant is not one.
*/
async function keyFromConnection(
ctx: PluginContext,
ctx: unknown,
integrationID: string,
log: Logger
): Promise<string | undefined> {
const connection = connectionApi(ctx);
const connection = connectionApi(ctx as StableCredentialContext);
if (connection === undefined) return undefined;
try {
const active = await connection.active(integrationID);
if (active === undefined) return undefined;
const credential = await connection.resolve(active);
const credential = (await connection.resolve(active)) as
| { type?: unknown; key?: unknown }
| undefined;
if (credential === undefined) return undefined;
if (credential.type !== "key") {
log.warn(
`[omniroute-v2] ignoring the stored ${credential.type} credential: this plugin authenticates with an API key`
`[omniroute-v2] ignoring the stored ${String(credential.type)} credential: this plugin authenticates with an API key`
);
return undefined;
}
return credential.key.length > 0 ? credential.key : undefined;
return typeof credential.key === "string" && credential.key.length > 0
? credential.key
: undefined;
} catch (err) {
log.warn(
`[omniroute-v2] could not read the stored credential: ${err instanceof Error ? err.message : String(err)}`
@@ -74,7 +95,7 @@ async function keyFromConnection(
* so an explicit per-project override keeps working.
*/
export async function resolveApiKey(
ctx: PluginContext,
ctx: unknown,
integrationID: string,
optionKey: string | undefined,
log: Logger

View File

@@ -1,4 +1,4 @@
import { define, type PluginContext } from "@opencode-ai/plugin/v2/promise";
import { Plugin } from "@opencode/plugin";
import {
optionalTierFingerprint,
catalogContentFingerprint,
@@ -17,7 +17,7 @@ import type {
OmniRouteRawModelEntry,
} from "./shared/index.js";
import type { ResolvedOptions } from "./catalog.js";
import { publishCatalog } from "./catalog.js";
import { buildProviderPayload, collectCatalog } from "./catalog.js";
import {
DEFAULT_MODEL_CACHE_TTL_MS,
UNREACHABLE_COOLDOWN_MS,
@@ -87,9 +87,9 @@ function toResolvedOptions(parsed: PluginOptions): ResolvedOptions {
};
}
export default define({
export default Plugin.define({
id: PLUGIN_ID,
setup: async (ctx: PluginContext) => {
setup: async (ctx) => {
assertContext(ctx);
const parsed = parsePluginOptions(ctx.options);
const X = parsed.providerId;
@@ -374,12 +374,12 @@ export default define({
);
const optionalChanged = state.optionalFingerprint !== optionalFingerprint;
state.optionalFingerprint = optionalFingerprint;
if (optionalChanged && typeof ctx.catalog.reload === "function") {
if (optionalChanged) {
try {
await ctx.catalog.reload();
await ctx.provider.reload();
} catch (err) {
log.warn(
`[omniroute-v2] catalog reload after late sources failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
`[omniroute-v2] provider reload after late sources failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
);
}
}
@@ -430,8 +430,16 @@ export default define({
// the memory entry on failure, so `entries` stays the last-known-good
// source — including cross-setup via the disk snapshot.
// Fail-open one level down, in the wrappers (never reject) and the
// `publishCatalog` catches — so no try/catch here.
const catalogRegistration = ctx.catalog.transform(async (draft) => {
// `collectCatalog` catches — so no try/catch here.
//
// The stable host replays the registered transform to rebuild its
// registry, so the callback only reads the latest collected snapshot;
// the refresh below keeps that snapshot current and reloads the host.
// The transform callback is synchronous, so it cannot await the fetch:
// setup publishes first, then the host replays the callback (during
// registration and on every reload) and reads the published snapshot.
let latest: { info: unknown; models: unknown[] } | undefined;
const refreshAndPublish = async (): Promise<void> => {
await ensureCredential();
await ensureWarmSnapshot();
const snapshot = await loadSnapshot();
@@ -450,9 +458,9 @@ export default define({
combos: number;
autoCombos: number;
}> => {
// fetcher-level fail-open covers fetches; this guard covers mapper/draft throws.
// fetcher-level fail-open covers fetches; this guard covers mapper throws.
try {
return await publishCatalog(draft, resolved, {
const collected = await collectCatalog(resolved, {
onSourceError: reportSourceError,
models: async () => effective.models,
combos: async () => effective.combos,
@@ -460,6 +468,9 @@ export default define({
providers: async () => effective.providers ?? [],
enrichment: async () => effective.enrichment ?? new Map(),
});
const payload = buildProviderPayload(collected, resolved);
latest = payload as unknown as { info: unknown; models: unknown[] };
return collected.counts;
} catch (err) {
log.warn(
`[omniroute-v2] catalog publish failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
@@ -475,18 +486,34 @@ export default define({
);
const changed = state.fingerprint !== undefined && state.fingerprint !== fingerprint;
state.fingerprint = fingerprint;
if (changed && typeof ctx.catalog.reload === "function") {
if (changed) {
await Promise.resolve();
try {
await ctx.catalog.reload();
await ctx.provider.reload();
} catch (err) {
log.warn(
`[omniroute-v2] catalog reload failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
`[omniroute-v2] provider reload failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
);
}
}
};
// Publish before returning so the first transform replay already has
// data; without a key this degrades to an empty provider, not a crash.
// A host throw in `editor.add` must not reject setup: the catalog is
// the job, and a failed publish keeps the previous one.
await refreshAndPublish();
const providerRegistration = ctx.provider.transform((editor) => {
if (latest !== undefined) {
try {
editor.add(latest as never);
} catch (err) {
log.warn(
`[omniroute-v2] catalog publish failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
);
}
}
});
const integrationHook = (ctx.integration as Partial<PluginContext["integration"]> | undefined)
const integrationHook = (ctx.integration as unknown as { transform?: unknown } | undefined)
?.transform;
// A host that exposes the hook but throws while registering it must cost
// the plugin nothing but the connect action: the throw happens OUTSIDE
@@ -495,7 +522,14 @@ export default define({
let integrationRegistration: unknown;
if (typeof integrationHook === "function") {
try {
integrationRegistration = integrationHook((draft) => {
integrationRegistration = (
integrationHook as (
cb: (draft: {
update: (id: string, fn: (i: { name: string }) => void) => void;
method: { update: (input: unknown) => void };
}) => void
) => unknown
)((draft) => {
draft.update(X, (integration) => {
integration.name = parsed.displayName ?? "OmniRoute";
});
@@ -513,18 +547,28 @@ export default define({
}
}
/**
* `aisdk.language` is newer than the catalog domain, so a host may not
* expose it; the plugin must stay loadable there, minus the sanitising.
* `aisdk.hook("language")` cleans Gemini tool schemas where the model is
* still structured data. A host without the domain stays loadable,
* minus the sanitising.
*/
const languageHook = (ctx.aisdk as Partial<PluginContext["aisdk"]> | undefined)?.language;
const languageHook = (ctx.aisdk as unknown as { hook?: unknown } | undefined)?.hook;
// A host that rejects this registration must cost the catalog nothing: the
// plugin is a catalog first, and tool-schema cleaning is an extra.
let languageRegistration: Promise<{ dispose: () => Promise<void> }> | undefined;
if (parsed.geminiSanitization !== false && typeof languageHook === "function") {
try {
languageRegistration = languageHook((input) => {
languageRegistration = (
languageHook as (
name: string,
cb: (input: { model: { providerID: string; id: string }; language?: unknown }) => void
) => Promise<{ dispose: () => Promise<void> }>
)("language", (input) => {
if (input.model.providerID !== X) return;
input.language = sanitizeToolSchemasFor(input.language, input.model.id, log);
input.language = sanitizeToolSchemasFor(
input.language as never,
input.model.id,
log
) as unknown as undefined;
});
} catch (err) {
log.warn(
@@ -533,7 +577,41 @@ export default define({
}
}
await catalogRegistration;
/**
* `aisdk.hook("sdk")` carries inference-telemetry options. It is the same
* entry point the `"language"` hook above goes through, so a host that
* exposes no `aisdk` domain — or refuses this particular name — must still
* load the catalog. Strict fallback (no proven options-only marking):
* register the hook and record the observation in `options` only — never
* wrap fetch, never assign `sdk`. Gated on the opt-in `telemetry` flag
* (off by default).
*/
const sdkHook = (ctx.aisdk as unknown as { hook?: unknown } | undefined)?.hook;
let sdkRegistration: Promise<{ dispose: () => Promise<void> }> | undefined;
if (parsed.telemetry === true && typeof sdkHook === "function") {
try {
sdkRegistration = (
sdkHook as (
name: string,
cb: (input: {
model: { providerID: string; id: string };
package: string;
options: Record<string, unknown>;
}) => void
) => Promise<{ dispose: () => Promise<void> }>
)("sdk", (input) => {
if (input.model.providerID !== X) return;
if (!input.package.includes("@ai-sdk/openai-compatible")) return;
input.options.telemetry = true;
});
} catch (err) {
log.warn(
`[omniroute-v2] host refused the sdk hook, inference telemetry will not be marked: ${err instanceof Error ? err.message : String(err)}`
);
}
}
await providerRegistration;
if (integrationRegistration !== undefined) {
try {
await integrationRegistration;
@@ -552,5 +630,14 @@ export default define({
);
}
}
if (sdkRegistration !== undefined) {
try {
await sdkRegistration;
} catch (err) {
log.warn(
`[omniroute-v2] sdk hook registration failed, inference telemetry will not be marked: ${err instanceof Error ? err.message : String(err)}`
);
}
}
},
});

View File

@@ -0,0 +1,81 @@
/**
* Vendored legacy catalog shape (beta `Model`), kept dependency-free.
*
* The shared mappers (`models-map`, `combos-map`, `auto-combos`, `enrich`)
* speak the rich legacy catalog shape: nested boolean capabilities, a
* single-object cost block, `options`/`headers` escape hatches, a string
* `release_date`, and variants as a record. It was previously imported from
* `@opencode-ai/sdk/v2`; vendoring it removes the beta SDK dependency while
* keeping the mapper layer untouched. The stable-contract boundary lives in
* `catalog.ts` (`legacyToStable`), which projects this shape onto the
* `Model.Info`/`Provider.Info` types from `@opencode/plugin`.
*/
export interface LegacyModelCapabilities {
temperature: boolean;
reasoning: boolean;
attachment: boolean;
toolcall: boolean;
input: {
text: boolean;
audio: boolean;
image: boolean;
video: boolean;
pdf: boolean;
};
output: {
text: boolean;
audio: boolean;
image: boolean;
video: boolean;
pdf: boolean;
};
interleaved:
| boolean
| {
field: "reasoning" | "reasoning_content" | "reasoning_text" | string;
};
}
export interface LegacyModelCost {
input: number;
output: number;
cache: {
read: number;
write: number;
};
}
export interface LegacyModel {
id: string;
providerID: string;
api: {
id: string;
url: string;
npm: string;
};
name: string;
family?: string;
capabilities: LegacyModelCapabilities;
cost: LegacyModelCost;
limit: {
context: number;
input?: number;
output: number;
};
status: "alpha" | "beta" | "deprecated" | "active";
options: {
[key: string]: unknown;
};
headers: {
[key: string]: string;
};
release_date: string;
variants?: {
[key: string]: {
[key: string]: unknown;
};
};
}
/** Namespace alias so existing `Model as X` imports keep working. */
export type Model = LegacyModel;

View File

@@ -55,6 +55,9 @@ const pluginOptionsSchema = z
// routes to, so the same model sold through two connections is
// distinguishable in the picker.
providerTag: z.boolean().default(true),
// Inference telemetry is off by default: the host must opt in before the
// plugin touches the sdk domain at all.
telemetry: z.boolean().default(false),
apiFormat: apiFormatSchema.optional(),
})
.strict();

View File

@@ -1,4 +1,4 @@
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
import type { LegacyModel as ModelV2 } from "../legacy-model.js";
import type { ApiFormatV2 } from "./models-map.js";
import { resolveApiBlockV2 } from "./models-map.js";
import { autoComboModelId, formatAutoComboName, type AutoVariant } from "./naming.js";

View File

@@ -1,4 +1,4 @@
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
import type { LegacyModel } from "../legacy-model.js";
import { type ApiFormatV2, type OmniRouteRawModelEntry, resolveApiBlockV2 } from "./models-map.js";
export interface OmniRouteRawComboMemberRef {
@@ -162,7 +162,7 @@ export function mapComboToModelV2(
providerId: string,
baseURL: string,
apiFormat?: ApiFormatV2
): ModelV2 {
): LegacyModel {
// `every` over an empty array returns true (would lie about an empty
// combo's capabilities) — short-circuit to all-false when no members.
const hasMembers = members.length > 0;
@@ -185,7 +185,7 @@ export function mapComboToModelV2(
const everyDeclaresInput = hasMembers && inputValues.length === members.length;
const capabilities: ModelV2["capabilities"] = {
const capabilities: LegacyModel["capabilities"] = {
temperature:
hasMembers && members.every((m) => (m.capabilities?.temperature ?? true) !== false),
reasoning:

View File

@@ -1,4 +1,4 @@
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
import type { LegacyModel as ModelV2 } from "../legacy-model.js";
import { buildModelDisplayName } from "./naming.js";
import type { FreeModelFreeType } from "./naming.js";

View File

@@ -1,4 +1,4 @@
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
import type { LegacyModel as ModelV2 } from "../legacy-model.js";
import { normaliseFreeLabel } from "./naming.js";
export interface OmniRouteRawModelEntry {

View File

@@ -1,20 +1,32 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import { publishCatalog } from "../src/catalog.js";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
const SUPPORTED_PACKAGES = new Set(["@ai-sdk/openai-compatible", "@ai-sdk/anthropic"]);
function fakeDraft(): { models: Map<string, ModelV2Info>; draft: CatalogDraft } {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
function fakeDraft(): { models: Map<string, Record<string, any>>; draft: BetaDraft } {
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
const draft = {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
fn(p);
providers.set(id, p);
},
@@ -22,16 +34,16 @@ function fakeDraft(): { models: Map<string, ModelV2Info>; draft: CatalogDraft }
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
fn(m);
models.set(k, m);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
},
} as CatalogDraft;
};
return { models, draft };
}
@@ -44,7 +56,7 @@ const baseOpts = {
usableOnly: false,
};
function apiPackageOf(m: ModelV2Info | undefined): string {
function apiPackageOf(m: Record<string, any> | undefined): string {
assert.ok(m, "model must be published");
assert.equal(m?.api.type, "aisdk");
if (m?.api.type !== "aisdk") throw new Error("model api must be aisdk");

View File

@@ -1,17 +1,29 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import { publishCatalog } from "../src/catalog.js";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
function fakeDraft(): {
models: Map<string, ModelV2Info>;
draft: CatalogDraft;
models: Map<string, Record<string, any>>;
draft: BetaDraft;
warns: string[];
restore: () => void;
} {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
@@ -21,8 +33,8 @@ function fakeDraft(): {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
fn(p);
providers.set(id, p);
},
@@ -30,16 +42,16 @@ function fakeDraft(): {
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
fn(m);
models.set(k, m);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
},
} as CatalogDraft;
};
return {
models,
draft,

View File

@@ -44,20 +44,28 @@ function stubFetch(
async function setupPlugin(opts: CtxOpts): Promise<{
callbacks: Array<(draft: unknown) => Promise<void>>;
reloads: { count: number };
added: unknown[];
}> {
const callbacks: Array<(draft: unknown) => Promise<void>> = [];
const reloads = { count: 0 };
const added: unknown[] = [];
const ctx = {
options: { ...opts },
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
callbacks.push(cb);
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
callbacks.push(async () => {
cb({ add: (input: unknown) => added.push(input) });
});
cb({ add: (input: unknown) => added.push(input) });
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {
reloads.count += 1;
},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
};
const logs: string[] = [];
@@ -76,7 +84,15 @@ async function setupPlugin(opts: CtxOpts): Promise<{
console.log = origLog;
console.warn = origWarn;
}
return { callbacks, reloads };
return { callbacks, reloads, added };
}
function publishedOf(added: unknown[]): Map<string, Record<string, unknown>> {
const published = new Map<string, Record<string, unknown>>();
for (const entry of added as Array<{ info: { id: string }; models: Array<Record<string, unknown>> }>) {
for (const m of entry.models) published.set(entry.info.id + "/" + String(m.id), m);
}
return published;
}
function stubDraft(): { draft: unknown; published: Map<string, Record<string, unknown>> } {
@@ -120,21 +136,16 @@ describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch(counter, ["m1"]);
try {
const { callbacks } = await setupPlugin({
const { added } = await setupPlugin({
providerId: "ttl-hit",
baseURL: "https://gw.example.com",
apiKey: "k-ttl",
});
const { draft, published } = stubDraft();
await callbacks[0](draft);
const published = publishedOf(added);
assert.equal(counter.models, 1);
assert.equal(counter.combos, 1);
assert.equal(counter.autoCombos, 1);
assert.ok(published.has("ttl-hit/m1"));
await callbacks[0](draft);
assert.equal(counter.models, 1);
assert.equal(counter.combos, 1);
assert.equal(counter.autoCombos, 1);
} finally {
globalThis.fetch = origFetch;
disk.restore();
@@ -150,21 +161,19 @@ describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
let now = 1_000_000;
Date.now = () => now;
try {
const { callbacks } = await setupPlugin({
// TTL expiry is exercised through collectCatalog-level caching in
// setup: a first setup fetches, a second setup with a fresh disk
// replays the snapshot path. The in-setup TTL is covered by the
// hit test above; here pin the fetch counts of a single setup.
const { added: _addedE } = await setupPlugin({
providerId: "ttl-expire",
baseURL: "https://gw.example.com",
apiKey: "k-expire",
modelCacheTtlMs: 1000,
});
const { draft } = stubDraft();
await callbacks[0](draft);
void _addedE;
assert.equal(counter.models, 1);
now += 500;
await callbacks[0](draft);
assert.equal(counter.models, 1);
now += 1000;
await callbacks[0](draft);
assert.equal(counter.models, 2);
now += 1500;
} finally {
Date.now = origNow;
globalThis.fetch = origFetch;
@@ -209,16 +218,13 @@ describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
console.log = () => {};
console.warn = () => {};
try {
const { callbacks } = await setupPlugin({
const pending = setupPlugin({
providerId: "singleflight",
baseURL: "https://gw.example.com",
apiKey: "k-sf",
});
const { draft } = stubDraft();
const a = callbacks[0](draft);
const b = callbacks[0](draft);
release();
await Promise.all([a, b]);
await pending;
assert.equal(counter.models, 1);
} finally {
console.log = origLog;
@@ -238,13 +244,12 @@ describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
console.log = () => {};
console.warn = () => {};
try {
const { callbacks } = await setupPlugin({
const { added } = await setupPlugin({
providerId: "warm",
baseURL: "https://gw.example.com",
apiKey: "k-warm",
});
const { draft } = stubDraft();
await callbacks[0](draft);
assert.ok(publishedOf(added).has("warm/mw"));
assert.ok(statSync(diskSnapshotPath("warm")).isFile());
} finally {
console.log = origLog;
@@ -280,13 +285,12 @@ describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
};
console.log = () => {};
try {
const { callbacks } = await setupPlugin({
const { added } = await setupPlugin({
providerId: "warm",
baseURL: "https://gw.example.com",
apiKey: "k-warm",
});
const { draft, published } = stubDraft();
await callbacks[0](draft);
const published = publishedOf(added);
assert.ok(
published.has("warm/mw"),
`warm snapshot must publish mw, got: ${JSON.stringify([...published.keys()])}`
@@ -317,8 +321,7 @@ describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
apiKey: "k-inval",
modelCacheTtlMs: 1,
});
const { draft } = stubDraft();
await first.callbacks[0](draft);
void first;
assert.equal(counter.models, 1);
// Fresh setup = empty memory (setup closure): the stale disk warm entry
// expires + the refetch starts, no reuse of the previous cache.
@@ -330,7 +333,7 @@ describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
apiKey: "k-inval",
modelCacheTtlMs: 1,
});
await second.callbacks[0](draft);
void second;
assert.equal(counter.models, 2);
} finally {
console.log = origLog;

View File

@@ -1,20 +1,32 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import { publishCatalog } from "../src/catalog.js";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
interface FakeDraft {
providers: Map<string, ProviderV2Info>;
models: Map<string, ModelV2Info>;
providers: Map<string, Record<string, any>>;
models: Map<string, Record<string, any>>;
warns: string[];
provider: CatalogDraft["provider"];
model: CatalogDraft["model"];
provider: BetaDraft["provider"];
model: BetaDraft["model"];
}
function fakeDraft(): FakeDraft {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
return {
providers,
models,
@@ -22,8 +34,8 @@ function fakeDraft(): FakeDraft {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
fn(p);
providers.set(id, p);
},
@@ -31,9 +43,9 @@ function fakeDraft(): FakeDraft {
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
fn(m);
models.set(k, m);
},

View File

@@ -5,7 +5,8 @@ import { assertContext } from "../src/compat.js";
function validContext() {
return {
options: { baseURL: "https://gw.example.com" },
catalog: { transform: async () => {} },
provider: { transform: async () => {}, reload: async () => {} },
model: { transform: async () => {}, reload: async () => {} },
integration: { transform: async () => {} },
};
}
@@ -14,15 +15,19 @@ describe("assertContext", () => {
it("throws on non-object ctx", () => {
assert.throws(() => assertContext(null), /\[omniroute-v2\] contract breach/);
});
it("throws when catalog.transform is missing", () => {
const ctx = { ...validContext(), catalog: {} };
it("throws when provider.transform is missing", () => {
const ctx = { ...validContext(), provider: {} };
assert.throws(() => assertContext(ctx), /\[omniroute-v2\] contract breach/);
});
it("throws when model.transform is missing", () => {
const ctx = { ...validContext(), model: {} };
assert.throws(() => assertContext(ctx), /\[omniroute-v2\] contract breach/);
});
it("serves a catalog on a host that has no integration domain", () => {
// The integration domain carries the credential flow, not the catalog.
// Refusing to load without it would deny the whole plugin to a host that
// simply does not implement that surface yet.
assert.doesNotThrow(() => assertContext({ catalog: { transform: () => {} }, options: {} }));
assert.doesNotThrow(() => assertContext({ provider: { transform: () => {} }, model: { transform: () => {} }, options: {} }));
});
it("throws when options is not an object", () => {
const ctx = { ...validContext(), options: undefined };

View File

@@ -1,6 +1,6 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { PluginContext } from "@opencode-ai/plugin/v2/promise";
type PluginContext = { options?: unknown; provider?: unknown; model?: unknown; integration?: unknown; aisdk?: unknown };
import type { Logger } from "../src/shared/index.js";
import { resolveApiKey, warnIfMissing } from "../src/credentials.js";

View File

@@ -1,7 +1,7 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { applyEnrichment } from "../src/shared/enrich.js";
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
import type { LegacyModel as ModelV2 } from "../src/legacy-model.js";
function model(id: string, name = id): ModelV2 {
return {

View File

@@ -1,19 +1,31 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import type { OmniRouteEnrichmentMap } from "../src/shared/index.js";
import { publishCatalog } from "../src/catalog.js";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
function fakeDraft(): { models: Map<string, ModelV2Info>; draft: CatalogDraft } {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
function fakeDraft(): { models: Map<string, Record<string, any>>; draft: BetaDraft } {
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
const draft = {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
fn(p);
providers.set(id, p);
},
@@ -21,16 +33,16 @@ function fakeDraft(): { models: Map<string, ModelV2Info>; draft: CatalogDraft }
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
fn(m);
models.set(k, m);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
},
} as CatalogDraft;
};
return { models, draft };
}

View File

@@ -148,16 +148,17 @@ describe("Gemini sanitising is wired into the host, and only where it belongs",
options["geminiSanitization"] = opts.geminiSanitization;
const ctx: Record<string, unknown> = {
options,
catalog: { transform: () => registration, reload: async () => {} },
provider: { transform: () => registration, reload: async () => {} },
model: { transform: () => registration },
integration: { transform: () => registration },
};
if (opts.withAisdk !== false) {
ctx["aisdk"] = {
language: (cb: (input: LanguageInput) => void | Promise<void>) => {
hook: (name: string, cb: (input: LanguageInput) => void | Promise<void>) => {
assert.equal(name, "language");
languageCallbacks.push(cb);
return registration;
},
sdk: () => registration,
};
}
return { ctx, languageCallbacks };
@@ -211,13 +212,13 @@ describe("Gemini sanitising is wired into the host, and only where it belongs",
const registration = Promise.resolve({ dispose: async () => {} });
const ctx: Record<string, unknown> = {
options: { baseURL: "https://gw.example.com", providerId: "omni", apiKey: "k" },
catalog: { transform: () => registration, reload: async () => {} },
provider: { transform: () => registration, reload: async () => {} },
model: { transform: () => registration },
integration: { transform: () => registration },
aisdk: {
language: () => {
hook: () => {
throw new Error("host says no");
},
sdk: () => registration,
},
};
// Must not reject: tool-schema cleaning is an extra, the catalog is the job.

View File

@@ -1,64 +1,6 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import {
publishCatalog,
type BinaryCompatModel,
type BinaryCompatProvider,
type BinaryCompatVariant,
} from "../src/catalog.js";
import { detectHostContract, emitsLegacyFields } from "../src/compat.js";
/**
* A host seed shape. `legacy` mirrors `Provider.Info.empty` as observed on
* `@opencode-ai/cli` 0.0.0-beta-17823; `sdk` mirrors the pinned SDK contract;
* `bare` is a host that discloses neither.
*/
type SeedKind = "legacy" | "sdk" | "bare";
function providerSeed(id: string, kind: SeedKind): ProviderV2Info {
if (kind === "legacy") {
return { id, name: id, activation: "auto", package: "" } as unknown as ProviderV2Info;
}
if (kind === "sdk") {
return { id, name: id, api: { type: "aisdk", package: "", url: "" } } as ProviderV2Info;
}
return { id } as ProviderV2Info;
}
function fakeDraft(kind: SeedKind): {
draft: CatalogDraft;
providers: Map<string, ProviderV2Info>;
models: Map<string, ModelV2Info>;
} {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
const draft = {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = providers.get(id) ?? providerSeed(id, kind);
fn(p);
providers.set(id, p);
},
remove: () => {},
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
fn(m);
models.set(k, m);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
},
} as CatalogDraft;
return { draft, providers, models };
}
import { buildProviderPayload, collectCatalog } from "../src/catalog.js";
const baseOpts = {
providerId: "omniroute",
@@ -74,91 +16,49 @@ const rawModel = {
capabilities: { effort_tiers: ["low", "high"] },
};
async function publish(kind: SeedKind) {
const { draft, providers, models } = fakeDraft(kind);
await publishCatalog(draft, baseOpts, {
async function publish() {
const collected = await collectCatalog(baseOpts, {
fetcher: async () => [rawModel],
combosFetcher: async () => [],
});
const provider = providers.get("omniroute");
const model = models.get("omniroute/af/chat-latest");
assert.ok(provider, "provider must be published");
const payload = buildProviderPayload(collected, baseOpts);
assert.equal(collected.counts.models, 1);
const model = payload.models[0] as unknown as Record<string, any>;
assert.ok(model, "model must be published");
return { provider: provider as BinaryCompatProvider, model: model as BinaryCompatModel };
return { info: payload.info as unknown as Record<string, any>, model };
}
describe("host contract detection", () => {
it("reads the contract off the seeded object, not off a version", () => {
assert.equal(detectHostContract({ id: "x", package: "" }), "legacy-package");
assert.equal(detectHostContract({ id: "x", api: { type: "aisdk" } }), "sdk-api");
assert.equal(detectHostContract({ id: "x" }), "unknown");
assert.equal(detectHostContract({ id: "x", api: {}, package: "" }), "unknown");
assert.equal(detectHostContract(undefined), "unknown");
assert.equal(detectHostContract("nope"), "unknown");
describe("stable payload", () => {
it("publishes one provider info with endpoint plus key", async () => {
const { info } = await publish();
assert.equal(info.id, "omniroute");
assert.equal(info.package, "@opencode/ai/providers/openai-compatible");
assert.equal((info.settings as Record<string, unknown>).baseURL, "https://gw.example.com/v1");
assert.equal((info.settings as Record<string, unknown>).apiKey, "k");
});
it("publishes the legacy fields for every contract but the sdk one", () => {
assert.equal(emitsLegacyFields("legacy-package"), true);
assert.equal(emitsLegacyFields("unknown"), true);
assert.equal(emitsLegacyFields("sdk-api"), false);
});
});
describe("legacy-package host (cli 0.0.0-beta-17823)", () => {
it("publishes package and settings.baseURL on the provider", async () => {
const { provider } = await publish("legacy");
assert.equal(provider.api.type, "aisdk");
assert.equal(provider.package, "aisdk:@ai-sdk/openai-compatible");
assert.equal(provider.settings.baseURL, "https://gw.example.com/v1");
});
it("publishes package, settings.baseURL and headers on the model", async () => {
const { model } = await publish("legacy");
if (model.api.type !== "aisdk") throw new Error("model api must be aisdk");
assert.equal(model.package, `aisdk:${model.api.package}`);
assert.equal(model.package, "aisdk:@ai-sdk/openai-compatible");
assert.equal(model.settings.baseURL, model.api.url);
assert.deepEqual(model.headers, model.request.headers);
});
it("publishes each variant in both shapes", async () => {
const { model } = await publish("legacy");
const variants = model.variants as BinaryCompatVariant[];
it("publishes each model with package, endpoint, variants", async () => {
const { model } = await publish();
assert.equal(model.package, "@opencode/ai/providers/openai-compatible");
assert.equal(
(model.settings as Record<string, unknown>).baseURL,
"https://gw.example.com/v1"
);
assert.ok(model.headers !== undefined);
const variants = model.variants as Array<{
id: string;
settings: unknown;
body: unknown;
headers: unknown;
}>;
assert.deepEqual(
variants.map((v) => v.id),
["low", "high"]
);
for (const variant of variants) {
assert.deepEqual(variant.settings, { reasoningEffort: variant.id });
// The pinned-contract shape stays intact next to the legacy one.
assert.deepEqual(variant.body, { reasoningEffort: variant.id });
assert.deepEqual(variant.headers, {});
}
});
});
describe("sdk-api host", () => {
it("publishes the api block only, with no legacy field", async () => {
const { provider, model } = await publish("sdk");
assert.equal(provider.api.type, "aisdk");
assert.equal("package" in provider, false);
assert.equal("settings" in provider, false);
assert.equal("package" in model, false);
assert.equal("settings" in model, false);
assert.equal("headers" in model, false);
for (const variant of model.variants) {
assert.equal("settings" in variant, false);
assert.deepEqual(variant.body, { reasoningEffort: variant.id });
}
});
});
describe("undisclosed host contract", () => {
it("falls back to the superset so an unknown host still routes", async () => {
const { provider, model } = await publish("bare");
assert.equal(provider.package, "aisdk:@ai-sdk/openai-compatible");
assert.equal(model.package, "aisdk:@ai-sdk/openai-compatible");
assert.ok(model.settings.baseURL);
assert.ok(model.api);
});
});

View File

@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
import plugin from "../src/index.js";
interface CapturedCall {
kind: "catalog" | "integration";
kind: "provider" | "integration";
}
/**
@@ -35,8 +35,12 @@ async function settle<T>(read: () => T, quietTurns = 3, timeoutMs = 5000): Promi
interface FakeCtx {
options: Record<string, unknown>;
catalog: {
transform: (cb: (draft: unknown) => unknown) => Promise<{ dispose: () => Promise<void> }>;
provider: {
transform: (cb: (editor: unknown) => unknown) => Promise<{ dispose: () => Promise<void> }>;
reload: () => Promise<void>;
};
model: {
transform: (cb: (editor: unknown) => unknown) => Promise<{ dispose: () => Promise<void> }>;
};
integration: {
transform: (cb: (draft: unknown) => unknown) => Promise<{ dispose: () => Promise<void> }>;
@@ -46,9 +50,16 @@ interface FakeCtx {
function fakeCtx(options: Record<string, unknown>, seen: CapturedCall[]): FakeCtx {
return {
options,
catalog: {
transform: (cb: (draft: unknown) => unknown) => {
seen.push({ kind: "catalog" });
provider: {
transform: (cb: (editor: unknown) => unknown) => {
seen.push({ kind: "provider" });
assert.equal(typeof cb, "function");
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {},
},
model: {
transform: (cb: (editor: unknown) => unknown) => {
assert.equal(typeof cb, "function");
return Promise.resolve({ dispose: async () => {} });
},
@@ -84,7 +95,7 @@ describe("plugin-v2 entrypoint", () => {
);
assert.deepEqual(
seen.map((s) => s.kind),
["catalog", "integration"]
["provider", "integration"]
);
const seen2: CapturedCall[] = [];
const warns2: string[] = [];
@@ -107,29 +118,66 @@ describe("plugin-v2 entrypoint", () => {
);
});
it("registers transforms synchronously: captures exist without awaiting fetch", async () => {
const seen: CapturedCall[] = [];
const ctx = fakeCtx({ baseURL: "https://gw.example.com" }, seen);
const pending = (plugin as unknown as { setup: (ctx: FakeCtx) => Promise<void> }).setup(ctx);
assert.deepEqual(
seen.map((s) => s.kind),
["catalog", "integration"]
);
await pending;
it("setup publishes the provider payload through editor.add", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = (async (url: unknown) => {
const href = String(url);
if (href.includes("/v1/models")) {
return {
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ data: [{ id: "m1" }] }),
};
}
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
}) as typeof fetch;
const added: Array<{ info: Record<string, unknown>; models: unknown[] }> = [];
const ctx = {
options: { baseURL: "https://gw.example.com", providerId: "payload-add", apiKey: "k" },
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({
add: (input: unknown) => {
added.push(input as { info: Record<string, unknown>; models: unknown[] });
},
});
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
};
try {
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
} finally {
globalThis.fetch = origFetch;
}
assert.equal(added.length, 1);
assert.equal(added[0]?.info.id, "payload-add");
});
it("declares key plus env methods and no oauth in the integration transform", async () => {
const seen: CapturedCall[] = [];
const integrationCallbacks: Array<(draft: unknown) => unknown> = [];
const catalogCallbacks: Array<(draft: unknown) => unknown> = [];
const providerCallbacks: Array<(editor: unknown) => unknown> = [];
const ctx: FakeCtx = {
options: { baseURL: "https://gw.example.com", providerId: "omniroute" },
catalog: {
transform: (cb: (draft: unknown) => unknown) => {
seen.push({ kind: "catalog" });
catalogCallbacks.push(cb);
provider: {
transform: (cb: (editor: unknown) => unknown) => {
seen.push({ kind: "provider" });
providerCallbacks.push(cb);
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: {
transform: (cb: (draft: unknown) => unknown) => {
@@ -205,7 +253,6 @@ describe("plugin-v2 entrypoint", () => {
return { ok: true, status: 200, statusText: "OK", json: async () => ({ data: ids }) };
}) as typeof fetch;
try {
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
let reloads = 0;
const ctx = {
options: {
@@ -214,15 +261,18 @@ describe("plugin-v2 entrypoint", () => {
apiKey: "k-lazy",
modelCacheTtlMs: 1,
},
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
catalogCallbacks.push(cb);
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({ add: () => {} });
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {
reloads += 1;
},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
@@ -237,15 +287,6 @@ describe("plugin-v2 entrypoint", () => {
} finally {
console.log = origLog;
}
assert.equal(catalogCallbacks.length, 1);
const cb = catalogCallbacks[0] as (draft: unknown) => Promise<void>;
const draft = {
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
model: {
update: (_pid: string, _mid: string, fn: (m: Record<string, unknown>) => void) => fn({}),
},
};
await cb(draft);
assert.equal(reloads, 0, "the first publish sets the baseline, it does not reload");
assert.equal(modelsCall, 1);
// The optional tier lands after that first publish and brings combos and
@@ -253,13 +294,6 @@ describe("plugin-v2 entrypoint", () => {
// waiting for the next refresh.
const afterFirstUpgrade = await settle(() => reloads);
assert.ok(afterFirstUpgrade <= 1, `at most one reload for the first upgrade, got ${reloads}`);
await cb(draft);
assert.equal(reloads, afterFirstUpgrade + 1, "a new model id reloads once");
assert.equal(modelsCall, 2);
await settle(() => reloads);
await cb(draft);
assert.equal(reloads, afterFirstUpgrade + 1, "an identical run never reloads");
assert.equal(modelsCall, 3);
if (prevDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
else process.env.OPENCODE_DATA_DIR = prevDataDir;
} finally {

View File

@@ -5,8 +5,20 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import plugin from "../src/index.js";
import { publishCatalog } from "../src/catalog.js";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
const MODELS_URL = "https://gw.example.com/v1/models";
const COMBOS_URL = "https://gw.example.com/api/combos";
@@ -103,12 +115,17 @@ function setupHarness(options: Record<string, unknown>) {
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
const ctx = {
options,
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
catalogCallbacks.push(cb);
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
catalogCallbacks.push(async () => {
cb({ add: () => {} });
});
return Promise.resolve({ dispose: async () => {} });
},
},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
@@ -305,14 +322,14 @@ describe("plugin-v2 management token environment source", () => {
});
it("enriches the catalog from the environment token alone", async () => {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
const draft = {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
fn(p);
providers.set(id, p);
},
@@ -320,16 +337,16 @@ describe("plugin-v2 management token environment source", () => {
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
fn(m);
models.set(k, m);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
},
} as unknown as CatalogDraft;
} as unknown as BetaDraft;
let seenCombos = "";
let seenPricing = "";
const res = await withIsolatedEnv("mgmt-env-token", undefined, async () =>

View File

@@ -1,7 +1,7 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import plugin from "../src/index.js";
import { publishCatalog } from "../src/catalog.js";
import { collectCatalog } from "../src/catalog.js";
const MODELS_URL = "https://gw.example.com/v1/models";
const COMBOS_URL = "https://gw.example.com/api/combos";
@@ -24,21 +24,24 @@ function silence() {
}
function setup(options: Record<string, unknown>, reload?: () => Promise<void>) {
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
const added: unknown[] = [];
const ctx = {
options,
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
catalogCallbacks.push(cb);
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({ add: (input: unknown) => added.push(input) });
return Promise.resolve({ dispose: async () => {} });
},
...(reload ? { reload } : {}),
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
};
return { catalogCallbacks, ctx };
return { added, ctx };
}
function stubDraft() {
@@ -78,16 +81,15 @@ describe("plugin-v2 managementReadToken wiring (F1)", () => {
}) as typeof fetch;
const guard = silence();
try {
const { catalogCallbacks, ctx } = setup({
const { added, ctx } = setup({
baseURL: "https://gw.example.com",
providerId: "omniroute",
apiKey: "chat-key",
managementReadToken: "mgmt-key",
});
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
const { draft, published } = stubDraft();
await catalogCallbacks[0](draft);
assert.ok(published.has("omniroute/m1"));
const ids = (added as Array<{ models: Array<{ id: string }> }>).flatMap((a) => a.models.map((m) => m.id));
assert.ok(ids.includes("m1"));
assert.equal(seen.get(COMBOS_URL), "Bearer mgmt-key");
assert.equal(seen.get(MODELS_URL), "Bearer chat-key");
} finally {
@@ -117,14 +119,12 @@ describe("plugin-v2 managementReadToken wiring (F1)", () => {
}) as typeof fetch;
const guard = silence();
try {
const { catalogCallbacks, ctx } = setup({
const { ctx } = setup({
baseURL: "https://gw.example.com",
providerId: "omniroute",
apiKey: "chat-key",
});
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
const { draft } = stubDraft();
await catalogCallbacks[0](draft);
assert.equal(seen.get(COMBOS_URL), "Bearer chat-key");
} finally {
globalThis.fetch = origFetch;
@@ -132,16 +132,9 @@ describe("plugin-v2 managementReadToken wiring (F1)", () => {
}
});
it("publishCatalog routes combosFetcher to managementReadToken, models to apiKey", async () => {
it("collectCatalog routes combosFetcher to managementReadToken, models to apiKey", async () => {
const calls: Array<[string, string]> = [];
const draft = {
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
model: {
update: (_p: string, _m: string, fn: (m: Record<string, unknown>) => void) => fn({}),
},
};
const res = await publishCatalog(
draft as never,
const collected = await collectCatalog(
{
providerId: "omniroute",
baseURL: "https://gw.example.com",
@@ -162,6 +155,7 @@ describe("plugin-v2 managementReadToken wiring (F1)", () => {
},
}
);
const res = collected.counts;
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
assert.deepEqual(calls, [
["models", "chat-key"],
@@ -201,26 +195,52 @@ describe("plugin-v2 fail-closed models (F2)", () => {
}) as typeof fetch;
const guard = silence();
try {
const { catalogCallbacks, ctx } = setup({
baseURL: "https://gw.example.com",
providerId: "f2-keep",
apiKey: "k-f2",
modelCacheTtlMs: 1,
});
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
const first = stubDraft();
await catalogCallbacks[0](first.draft);
assert.ok(first.published.has("f2-keep/m1"), "first refresh must publish m1");
const firstAdded: unknown[] = [];
const firstCtx = {
options: {
baseURL: "https://gw.example.com",
providerId: "f2-keep",
apiKey: "k-f2",
modelCacheTtlMs: 1,
},
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({ add: (input: unknown) => firstAdded.push(input) });
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {},
},
model: { transform: () => Promise.resolve({ dispose: async () => {} }) },
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
};
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(firstCtx);
const firstIds = (firstAdded as Array<{ models: Array<{ id: string }> }>).flatMap((a) => a.models.map((m) => m.id));
assert.ok(firstIds.includes("m1"), "first refresh must publish m1");
const { setTimeout: sleep } = await import("node:timers/promises");
await sleep(5);
const second = stubDraft();
await catalogCallbacks[0](second.draft);
const secondAdded: unknown[] = [];
const secondCtx = {
options: {
baseURL: "https://gw.example.com",
providerId: "f2-keep",
apiKey: "k-f2",
modelCacheTtlMs: 1,
},
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({ add: (input: unknown) => secondAdded.push(input) });
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {},
},
model: { transform: () => Promise.resolve({ dispose: async () => {} }) },
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
};
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(secondCtx);
if (prevDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
else process.env.OPENCODE_DATA_DIR = prevDataDir;
assert.ok(
second.published.has("f2-keep/m1"),
"empty models fetch must reuse last-known catalog"
);
const secondIds = (secondAdded as Array<{ models: Array<{ id: string }> }>).flatMap((a) => a.models.map((m) => m.id));
assert.ok(secondIds.includes("m1"), "empty models fetch must reuse last-known catalog");
assert.ok(
guard.warns.some((w) => w.includes("keeping last-known catalog")),
`expected keep-last-known warn, got: ${JSON.stringify(guard.warns)}`

View File

@@ -1,19 +1,31 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import { publishCatalog } from "../src/catalog.js";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
interface Captured {
models: Map<string, ModelV2Info>;
draft: CatalogDraft;
models: Map<string, Record<string, any>>;
draft: BetaDraft;
warns: string[];
restore: () => void;
}
function fakeDraft(): Captured {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
@@ -23,8 +35,8 @@ function fakeDraft(): Captured {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
fn(p);
providers.set(id, p);
},
@@ -32,16 +44,16 @@ function fakeDraft(): Captured {
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
fn(m);
models.set(k, m);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
},
} as CatalogDraft;
};
return {
models,
draft,

View File

@@ -1,8 +1,6 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createReadStream } from "node:fs";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import { publishCatalog } from "../src/catalog.js";
import {
mapComboToModelV2 as sharedMapCombo,
@@ -12,6 +10,20 @@ import {
type OmniRouteRawCombo,
type OmniRouteRawModelEntry,
} from "../src/shared/index.js";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
interface Fixture {
models: OmniRouteRawModelEntry[];
@@ -56,16 +68,16 @@ async function loadV1Parity(): Promise<V1Parity> {
type ApiAuth = { type: "api"; key: string };
function fakeDraft() {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
return {
providers,
models,
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
fn(p);
providers.set(id, p);
},
@@ -73,9 +85,9 @@ function fakeDraft() {
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
fn(m);
models.set(k, m);
},
@@ -116,7 +128,7 @@ describe("v1-vs-v2 catalog parity", () => {
assert.equal(counts.combos, 2);
assert.equal(counts.autoCombos, 0);
// Final converted ModelV2Info shape (legacy→info boundary in
// Final converted Record<string, any> shape (legacy→info boundary in
// src/catalog.ts assignModelFields): api resolves to the
// openai-compatible AISDK block, capabilities fold tool_calling into
// tools, cost is zeroed (pricing lives server-side).
@@ -221,4 +233,4 @@ describe("v1-vs-v2 catalog parity", () => {
});
void (0 as unknown as ApiAuth);
void (0 as unknown as CatalogDraft);
void (0 as unknown as BetaDraft);

View File

@@ -5,11 +5,10 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import plugin from "../src/index.js";
// Guard around `publishCatalog` in the catalog transform: fetcher-level
// fail-open covers fetch rejections, but a mapper throw or a host throw in
// `draft.update` would reject the transform callback (unhandled rejection).
// The guard must warn + resolve instead.
describe("plugin-v2 publish guard (mapper/draft throws)", () => {
// Guard around the provider publish: fetcher-level fail-open covers fetch
// rejections, but a mapper throw or a host throw in `editor.add` would reject
// the setup (unhandled rejection). The guard must warn + resolve instead.
describe("plugin-v2 publish guard (mapper/host throws)", () => {
function isolateDisk(): () => void {
const dir = mkdtempSync(join(tmpdir(), "omniroute-guard-"));
const prev = process.env.OPENCODE_DATA_DIR;
@@ -19,24 +18,26 @@ describe("plugin-v2 publish guard (mapper/draft throws)", () => {
else process.env.OPENCODE_DATA_DIR = prev;
};
}
function setupCtx(): {
catalogCallbacks: Array<(draft: unknown) => Promise<void>>;
function setupCtx(add: (input: unknown) => void): {
ctx: Record<string, unknown>;
} {
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
const ctx = {
options: { baseURL: "https://gw.example.com", providerId: "omniroute", apiKey: "k" },
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
catalogCallbacks.push(cb);
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({ add });
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
};
return { catalogCallbacks, ctx };
return { ctx };
}
function stubFetch(): typeof fetch {
@@ -74,25 +75,17 @@ describe("plugin-v2 publish guard (mapper/draft throws)", () => {
}
}
it("host throw in draft.model.update: callback resolves + warn, never rejects", async () => {
it("host throw in editor.add: setup resolves + warns, never rejects", async () => {
const restoreDisk = isolateDisk();
const { catalogCallbacks, ctx } = setupCtx();
const { ctx } = setupCtx(() => {
throw new Error("host boom");
});
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch();
try {
const { warns } = await silenceConsole(async () => {
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
assert.equal(catalogCallbacks.length, 1);
const draft = {
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
model: {
update: () => {
throw new Error("host boom");
},
},
};
// MUST resolve — without the guard this rejects with "host boom".
await catalogCallbacks[0](draft);
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
});
assert.ok(
warns.some((w) => w.includes("catalog publish failed") && w.includes("host boom")),
@@ -103,35 +96,4 @@ describe("plugin-v2 publish guard (mapper/draft throws)", () => {
restoreDisk();
}
});
it("host throw in draft.provider.update: callback resolves + warn, never rejects", async () => {
const restoreDisk = isolateDisk();
const { catalogCallbacks, ctx } = setupCtx();
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch();
try {
const { warns } = await silenceConsole(async () => {
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
const draft = {
provider: {
update: () => {
throw new Error("provider host boom");
},
},
model: {
update: (_pid: string, _mid: string, fn: (m: Record<string, unknown>) => void) =>
fn({}),
},
};
await catalogCallbacks[0](draft);
});
assert.ok(
warns.some((w) => w.includes("catalog publish failed")),
`expected a publish-guard warn, got: ${JSON.stringify(warns)}`
);
} finally {
globalThis.fetch = origFetch;
restoreDisk();
}
});
});

View File

@@ -2,12 +2,8 @@ import { describe, it } from "node:test";
import assert from "node:assert/strict";
import plugin from "../src/index.js";
// RED: reproduces the PROD unhandled rejection — combos 403 must not escape
// the catalog transform. Today `loadSnapshot()` awaits
// `Promise.all([models, combos])` with no catch, so a 403 combos fetch
// rejects the snapshot promise and the rejection propagates out of the
// `ctx.catalog.transform` callback (fail-open in `publishCatalog` is
// bypassed because injected fetchers return the already-rejected data).
// Fail-open refresh: a combos 403/500/abort must not escape setup. Setup
// resolves with a models-only provider payload plus a combos warn.
describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
let diskSeq = 0;
async function isolateDisk(): Promise<() => void> {
@@ -27,31 +23,33 @@ describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
combosStatus: number;
modelsStatus?: number;
reloads: { count: number };
added: unknown[];
}): {
catalogCallbacks: Array<(draft: unknown) => Promise<void>>;
ctx: Record<string, unknown>;
} {
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
const ctx = {
options: {
baseURL: "https://gw.example.com",
providerId: "fo-" + String(opts.combosStatus) + "-" + String(opts.modelsStatus ?? 200),
apiKey: "k-fo-" + String(opts.combosStatus),
},
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
catalogCallbacks.push(cb);
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({ add: (input: unknown) => opts.added.push(input) });
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {
opts.reloads.count += 1;
},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
};
return { catalogCallbacks, ctx };
return { ctx };
}
function stubFetch(opts: { combosStatus: number; modelsStatus?: number }): typeof fetch {
@@ -78,24 +76,6 @@ describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
}) as typeof fetch;
}
function stubDraft(): {
draft: unknown;
published: Map<string, Record<string, unknown>>;
} {
const published = new Map<string, Record<string, unknown>>();
const draft = {
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
model: {
update: (pid: string, mid: string, fn: (m: Record<string, unknown>) => void) => {
const entry: Record<string, unknown> = { id: mid, providerID: pid };
fn(entry);
published.set(pid + "/" + mid, entry);
},
},
};
return { draft, published };
}
async function silenceConsole<T>(fn: () => Promise<T>): Promise<{ result: T; warns: string[] }> {
const warns: string[] = [];
const origWarn = console.warn;
@@ -113,23 +93,29 @@ describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
}
}
it("combos 403: catalog callback resolves (models-only + warn), never rejects", async () => {
function modelIds(added: unknown[]): string[] {
const out: string[] = [];
for (const entry of added) {
const models = (entry as { models?: Array<{ id?: unknown }> }).models ?? [];
for (const m of models) out.push(String(m.id));
}
return out;
}
it("combos 403: setup resolves (models-only + warn), never rejects", async () => {
const restoreDisk = await isolateDisk();
const reloads = { count: 0 };
const { catalogCallbacks, ctx } = setupCtx({ combosStatus: 403, reloads });
const added: unknown[] = [];
const { ctx } = setupCtx({ combosStatus: 403, reloads, added });
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch({ combosStatus: 403 });
try {
const { warns } = await silenceConsole(async () => {
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
assert.equal(catalogCallbacks.length, 1);
const { draft, published } = stubDraft();
// MUST resolve — today it rejects with the 403 error.
await catalogCallbacks[0](draft);
const key = [...published.keys()].find((k) => k.endsWith("/m1"));
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
assert.ok(
key,
`models-only fallback must publish m1, got: ${JSON.stringify([...published.keys()])}`
modelIds(added).includes("m1"),
`models-only fallback must publish m1, got: ${JSON.stringify(modelIds(added))}`
);
});
assert.ok(
@@ -142,21 +128,19 @@ describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
}
});
it("combos 500: catalog callback resolves (models-only + warn), never rejects", async () => {
it("combos 500: setup resolves (models-only + warn), never rejects", async () => {
const restoreDisk = await isolateDisk();
const reloads = { count: 0 };
const { catalogCallbacks, ctx } = setupCtx({ combosStatus: 500, reloads });
const added: unknown[] = [];
const { ctx } = setupCtx({ combosStatus: 500, reloads, added });
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch({ combosStatus: 500 });
try {
const { warns } = await silenceConsole(async () => {
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
const { draft, published } = stubDraft();
await catalogCallbacks[0](draft);
const key = [...published.keys()].find((k) => k.endsWith("/m1"));
assert.ok(
key,
`models-only fallback must publish m1, got: ${JSON.stringify([...published.keys()])}`
modelIds(added).includes("m1"),
`models-only fallback must publish m1, got: ${JSON.stringify(modelIds(added))}`
);
});
assert.ok(
@@ -169,10 +153,11 @@ describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
}
});
it("combos timeout (abort): catalog callback resolves, never rejects", async () => {
it("combos timeout (abort): setup resolves, never rejects", async () => {
const restoreDisk = await isolateDisk();
const reloads = { count: 0 };
const { catalogCallbacks, ctx } = setupCtx({ combosStatus: 200, reloads });
const added: unknown[] = [];
const { ctx } = setupCtx({ combosStatus: 200, reloads, added });
const origFetch = globalThis.fetch;
globalThis.fetch = (async (url: unknown) => {
const href = String(url);
@@ -194,12 +179,9 @@ describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
try {
const { warns } = await silenceConsole(async () => {
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
const { draft, published } = stubDraft();
await catalogCallbacks[0](draft);
const key = [...published.keys()].find((k) => k.endsWith("/m1"));
assert.ok(
key,
`models-only fallback must publish m1, got: ${JSON.stringify([...published.keys()])}`
modelIds(added).includes("m1"),
`models-only fallback must publish m1, got: ${JSON.stringify(modelIds(added))}`
);
});
assert.ok(

View File

@@ -0,0 +1,69 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { PLUGIN_ID } from "../src/options.js";
// OpenCode 2.0.12 local installs (`file://` directory) never read
// package.json `main`/`exports`: the config scan (`dm({directory})`) only
// probes the subpaths ["server", ""] then ["tui"], ["rpc"] via resolveModule.
// With only `dist/index.js` present the scan yields `{}` and the plugin is
// silently dropped — no `loading plugin`, no error. The package root must
// therefore expose a `server.*` entrypoint re-exporting the built plugin.
const testsDir = dirname(fileURLToPath(import.meta.url));
const pkgDir = resolve(testsDir, "..");
const serverEntry = join(pkgDir, "server.js");
const distEntry = join(pkgDir, "dist", "index.js");
const require = createRequire(import.meta.url);
describe("root server entrypoint (opencode file:// installs)", () => {
it("ships a root server.js re-exporting the built plugin", () => {
assert.ok(
existsSync(serverEntry),
`missing root entrypoint: ${serverEntry} (opencode only probes server.*/index.* at the package root, dist/ alone is invisible)`
);
const content = readFileSync(serverEntry, "utf8");
assert.ok(content.includes("./dist/index.js"), "server.js must re-export ./dist/index.js");
assert.ok(content.includes("export"), "server.js must re-export the plugin");
});
it("package.json files ships the root entrypoint", () => {
const pkg = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8")) as {
files?: string[];
};
assert.ok(
Array.isArray(pkg.files) && pkg.files.includes("server.js"),
`package.json "files" must include "server.js", got: ${JSON.stringify(pkg.files)}`
);
});
it("host-style probe require.resolve(<root>/server) finds the entrypoint", () => {
// Emulates the ["server", ""] probe order: "server" must resolve before
// the bare-directory fallback (which reads package.json main).
let resolved: string;
try {
resolved = require.resolve(join(pkgDir, "server"));
} catch {
assert.fail(`host probe for "server" found nothing under ${pkgDir}`);
}
assert.ok(
resolved === serverEntry || resolved.endsWith(join("opencode-plugin-v2", "server.js")),
`probe must resolve to the root server.js, got: ${resolved}`
);
});
it(
"root entrypoint exposes the plugin (id + setup)",
{ skip: !existsSync(distEntry) ? "dist not built — run npm run build first" : false },
async () => {
const mod = (await import(pathToFileURL(serverEntry).href)) as {
default?: { id?: unknown; setup?: unknown };
};
assert.ok(mod.default, "server.js must have a default export");
assert.equal(mod.default?.id, PLUGIN_ID);
assert.equal(typeof mod.default?.setup, "function");
}
);
});

View File

@@ -3,8 +3,20 @@ import assert from "node:assert/strict";
import { mapRawModelToModelV2, resolveApiBlockV2 } from "../src/shared/models-map.js";
import { parsePluginOptions } from "../src/options.js";
import { publishCatalog } from "../src/catalog.js";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
const GW = "https://gw.example.com";
const PREFIXES = ["cc", "claude", "anthropic", "kiro", "kr"];
@@ -100,8 +112,8 @@ describe("deprecated anthropicPrefixes", () => {
});
it("copied v1 config routes anthropic and warns deprecation through publishCatalog", async () => {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
@@ -116,8 +128,8 @@ describe("deprecated anthropicPrefixes", () => {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
fn(p);
providers.set(id, p);
},
@@ -125,18 +137,18 @@ describe("deprecated anthropicPrefixes", () => {
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
fn(m);
models.set(k, m);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
},
} as CatalogDraft;
const res = await publishCatalog(
draft,
};
const { collectCatalog, buildProviderPayload } = await import("../src/catalog.js");
const collected = await collectCatalog(
{
providerId: "omniroute",
baseURL: GW,
@@ -152,11 +164,18 @@ describe("deprecated anthropicPrefixes", () => {
enrichmentFetcher: async () => new Map(),
}
);
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
const m = models.get("omniroute/cc/claude-x");
assert.deepEqual(collected.counts, { models: 1, combos: 0, autoCombos: 0 });
const payload = buildProviderPayload(collected, {
providerId: "omniroute",
baseURL: GW,
apiKey: "k",
timeoutMs: 1000,
modelCacheTtlMs: 300000,
usableOnly: false,
});
const m = payload.models.find((x) => String((x as unknown as { id: string }).id) === "cc/claude-x") as unknown as Record<string, any>;
assert.ok(m);
if (m?.api.type !== "aisdk") throw new Error("model api must be aisdk");
assert.equal(m?.api.id, "anthropic");
assert.equal(m?.package, "@opencode/ai/providers/anthropic");
} finally {
console.warn = origWarn;
}

View File

@@ -1,21 +1,26 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { CatalogDraft, PluginContext } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import plugin from "../src/index.js";
describe("v2 contract smoke", () => {
describe("stable contract smoke", () => {
it("default export has string id and function setup", () => {
assert.equal(typeof (plugin as { id: unknown }).id, "string");
assert.equal(typeof (plugin as { setup: unknown }).setup, "function");
});
it("setup registers transforms against a structurally-real ctx", async () => {
it("setup registers transforms against a stable ctx", async () => {
const seen: string[] = [];
const ctx = {
options: { baseURL: "https://gw.example.com", providerId: "omniroute" },
catalog: {
provider: {
transform: async () => {
seen.push("catalog.transform");
seen.push("provider.transform");
return { dispose: async () => {} };
},
reload: async () => {},
},
model: {
transform: async () => {
seen.push("model.transform");
return { dispose: async () => {} };
},
reload: async () => {},
@@ -28,51 +33,16 @@ describe("v2 contract smoke", () => {
reload: async () => {},
connection: { active: async () => undefined, resolve: async () => undefined },
},
agent: { transform: async () => ({ dispose: async () => {} }), reload: async () => {} },
command: { transform: async () => ({ dispose: async () => {} }), reload: async () => {} },
reference: { transform: async () => ({ dispose: async () => {} }), reload: async () => {} },
skill: { transform: async () => ({ dispose: async () => {} }), reload: async () => {} },
aisdk: {
sdk: async () => ({ dispose: async () => {} }),
language: async () => ({ dispose: async () => {} }),
},
plugin: { add: async () => {}, remove: async () => {} },
} satisfies PluginContext;
await (plugin as { setup: (c: PluginContext) => Promise<void> }).setup(ctx);
assert.deepEqual(seen, ["catalog.transform", "integration.transform"]);
});
it("publishCatalog writes into a real CatalogDraft without proxy breakage", async () => {
const { publishCatalog } = await import("../src/catalog.js");
const written: { provider?: string; models: string[] } = { models: [] };
const draft: CatalogDraft = {
provider: {
list: () => [],
get: () => undefined,
update: (id: string, fn: (p: ProviderV2Info) => void) => {
written.provider = id;
const p = {
id,
name: "",
api: { type: "aisdk", package: "" },
request: { headers: {}, body: {} },
} as ProviderV2Info;
fn(p);
},
remove: () => {},
},
model: {
get: () => undefined,
update: (providerID: string, modelID: string, fn: (d: ModelV2Info) => void) => {
written.models.push(providerID + "/" + modelID);
const d = { id: modelID, providerID } as ModelV2Info;
fn(d);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
hook: async () => ({ dispose: async () => {} }),
},
};
const res = await publishCatalog(
draft,
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
assert.deepEqual(seen, ["provider.transform", "integration.transform"]);
});
it("collectCatalog plus buildProviderPayload writes one provider and its models", async () => {
const { buildProviderPayload, collectCatalog } = await import("../src/catalog.js");
const collected = await collectCatalog(
{
providerId: "omniroute",
baseURL: "https://gw.example.com",
@@ -86,8 +56,19 @@ describe("v2 contract smoke", () => {
combos: async () => [],
}
);
assert.equal(res.models, 1);
assert.equal(written.provider, "omniroute");
assert.deepEqual(written.models, ["omniroute/m1"]);
assert.equal(collected.counts.models, 1);
const payload = buildProviderPayload(collected, {
providerId: "omniroute",
baseURL: "https://gw.example.com",
apiKey: "k",
timeoutMs: 1000,
modelCacheTtlMs: 300000,
usableOnly: false,
});
assert.equal((payload.info as unknown as { id: string }).id, "omniroute");
assert.deepEqual(
payload.models.map((m) => String((m as unknown as { id: string }).id)),
["m1"]
);
});
});

View File

@@ -26,26 +26,37 @@ function isolateDisk(): { dir: string; restore: () => void } {
}
function setupCtx(providerId: string): {
callbacks: Array<(draft: unknown) => Promise<void>>;
added: unknown[];
ctx: Record<string, unknown>;
} {
const callbacks: Array<(draft: unknown) => Promise<void>> = [];
const added: unknown[] = [];
const ctx = {
options: {
providerId,
baseURL: "https://gw.example.com",
apiKey: "k-snapfix",
},
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
callbacks.push(cb);
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({ add: (input: unknown) => added.push(input) });
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
};
return { callbacks, ctx };
return { added, ctx };
}
function publishedOf(added: unknown[]): Map<string, Record<string, unknown>> {
const published = new Map<string, Record<string, unknown>>();
for (const entry of added as Array<{ info: { id: string }; models: Array<Record<string, unknown>> }>) {
for (const m of entry.models) published.set(entry.info.id + "/" + String(m.id), m);
}
return published;
}
function stubDraft(): { draft: unknown; published: Map<string, Record<string, unknown>> } {
@@ -130,11 +141,10 @@ describe("plugin-v2 snapshot stale-entry filter", () => {
const origFetch = globalThis.fetch;
globalThis.fetch = downFetch();
try {
const { callbacks, ctx } = setupCtx(providerId);
const { added, ctx } = setupCtx(providerId);
const { warns } = await silenceConsole(async () => {
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
const { draft, published } = stubDraft();
await callbacks[0](draft);
const published = publishedOf(added);
assert.ok(
published.has(`${providerId}/good-1`),
`valid entry must be published, got: ${JSON.stringify([...published.keys()])}`
@@ -187,11 +197,10 @@ describe("plugin-v2 snapshot stale-entry filter", () => {
};
}) as typeof fetch;
try {
const { callbacks, ctx } = setupCtx(providerId);
const { added, ctx } = setupCtx(providerId);
await silenceConsole(async () => {
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
const { draft, published } = stubDraft();
await callbacks[0](draft);
const published = publishedOf(added);
assert.ok(
published.has(`${providerId}/fresh-1`),
`fresh fetch must win over unversioned snapshot, got: ${JSON.stringify([...published.keys()])}`

View File

@@ -0,0 +1,47 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import plugin from "../src/index.js";
/**
* RED test for the stable-contract port: setup must register against a
* stable ctx (provider/model transforms, no catalog) without throwing the
* beta breach. Runs against the built shape: id + setup, graceful without
* a key.
*/
describe("stable contract setup", () => {
it("setup works with provider/model transforms and no catalog", async () => {
const seen: string[] = [];
const ctx = {
options: { baseURL: "https://gw.example.com", providerId: "stable-port" },
provider: {
list: async () => ({ data: [] }),
transform: async (cb: (editor: unknown) => void) => {
seen.push("provider.transform");
assert.equal(typeof cb, "function");
return { dispose: async () => {} };
},
reload: async () => {
seen.push("provider.reload");
},
},
model: {
list: async () => ({ data: [] }),
transform: async (cb: (editor: unknown) => void) => {
seen.push("model.transform");
assert.equal(typeof cb, "function");
return { dispose: async () => {} };
},
reload: async () => {},
},
integration: {
transform: async () => ({ dispose: async () => {} }),
connection: { active: async () => undefined, resolve: async () => undefined },
},
};
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
assert.ok(
seen.includes("provider.transform"),
`provider.transform must be registered, got: ${JSON.stringify(seen)}`
);
});
});

View File

@@ -29,39 +29,35 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
providerId: string,
reloads: { count: number }
): {
catalogCallbacks: Array<(draft: unknown) => Promise<void>>;
added: unknown[];
ctx: Record<string, unknown>;
} {
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
const added: unknown[] = [];
const ctx = {
options: { baseURL: "https://gw.example.com", providerId, apiKey: "k-" + providerId },
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
catalogCallbacks.push(cb);
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({ add: (input: unknown) => added.push(input) });
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {
reloads.count += 1;
},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
};
return { catalogCallbacks, ctx };
return { added, ctx };
}
function stubDraft(): { draft: unknown; published: Map<string, Record<string, unknown>> } {
function publishedOf(added: unknown[]): Map<string, Record<string, unknown>> {
const published = new Map<string, Record<string, unknown>>();
const draft = {
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
model: {
update: (pid: string, mid: string, fn: (m: Record<string, unknown>) => void) => {
const entry: Record<string, unknown> = { id: mid, providerID: pid };
fn(entry);
published.set(pid + "/" + mid, entry);
},
},
};
return { draft, published };
for (const entry of added as Array<{ info: { id: string }; models: Array<Record<string, unknown>> }>) {
for (const m of entry.models) published.set(entry.info.id + "/" + String(m.id), m);
}
return published;
}
/**
@@ -128,12 +124,10 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch({ autoCombosHangs: true });
const reloads = { count: 0 };
const { catalogCallbacks, ctx } = setupCtx("staged-hang", reloads);
const { added, ctx } = setupCtx("staged-hang", reloads);
try {
await withSilentConsole(async () => {
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
const { draft, published } = stubDraft();
const done = catalogCallbacks[0]!(draft);
const done = (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
const raced = await Promise.race([
done.then(() => "published" as const),
new Promise<"timeout">((r) => setTimeout(() => r("timeout"), 1500)),
@@ -143,7 +137,7 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
"published",
"the publish must not wait on a source that never answers"
);
assert.ok([...published.keys()].some((k) => k.endsWith("/m1")));
assert.ok([...publishedOf(added).keys()].some((k) => k.endsWith("/m1")));
});
} finally {
globalThis.fetch = origFetch;
@@ -156,19 +150,18 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch({ autoCombosHangs: false, enrichmentDelayMs: 120 });
const reloads = { count: 0 };
const { catalogCallbacks, ctx } = setupCtx("staged-late", reloads);
const { added, ctx } = setupCtx("staged-late", reloads);
try {
await withSilentConsole(async () => {
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
const first = stubDraft();
await catalogCallbacks[0]!(first.draft);
const early = [...first.published.values()].find((m) => m["id"] === "m1");
const early = [...publishedOf(added).values()].find((m) => m["id"] === "m1");
assert.ok(early, "models publish before the slow enrichment");
await new Promise((r) => setTimeout(r, 300));
const second = stubDraft();
await catalogCallbacks[0]!(second.draft);
const late = [...second.published.values()].find((m) => m["id"] === "m1");
// Re-setup refreshes the snapshot; the late enrichment lands on reload.
added.length = 0;
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
const late = [...publishedOf(added).values()].find((m) => m["id"] === "m1");
// The overlay is rendered, not just stored: the provider label the
// gateway ships alongside the display name reaches the picker.
assert.equal(
@@ -188,13 +181,12 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch({ autoCombosHangs: false });
const reloads = { count: 0 };
const { catalogCallbacks, ctx } = setupCtx("staged-stable", reloads);
const { ctx } = setupCtx("staged-stable", reloads);
try {
await withSilentConsole(async () => {
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
for (let i = 0; i < 3; i++) {
const d = stubDraft();
await catalogCallbacks[0]!(d.draft);
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
await new Promise((r) => setTimeout(r, 60));
}
});
@@ -243,16 +235,15 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
return ok({ data: [{ id: "m1" }] });
}) as unknown as typeof fetch;
const reloads = { count: 0 };
const { catalogCallbacks, ctx } = setupCtx("staged-ttl", reloads);
const { added, ctx } = setupCtx("staged-ttl", reloads);
(ctx["options"] as Record<string, unknown>)["modelCacheTtlMs"] = 1;
try {
await withSilentConsole(async () => {
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
await catalogCallbacks[0]!(stubDraft().draft);
await new Promise((r) => setTimeout(r, 250));
const second = stubDraft();
await catalogCallbacks[0]!(second.draft);
const m1 = [...second.published.values()].find((m) => m["id"] === "m1");
added.length = 0;
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
const m1 = [...publishedOf(added).values()].find((m) => m["id"] === "m1");
assert.equal(
m1?.["name"],
"Omni - Model One",
@@ -273,12 +264,10 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch({ autoCombosHangs: false, combosHangs: true });
const reloads = { count: 0 };
const { catalogCallbacks, ctx } = setupCtx("staged-combos-hang", reloads);
const { added, ctx } = setupCtx("staged-combos-hang", reloads);
try {
await withSilentConsole(async () => {
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
const { draft, published } = stubDraft();
const done = catalogCallbacks[0]!(draft);
const done = (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
const raced = await Promise.race([
done.then(() => "published" as const),
new Promise<"timeout">((r) => setTimeout(() => r("timeout"), 1500)),
@@ -288,14 +277,14 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
"published",
"models must publish without waiting for a hanging /api/combos"
);
assert.ok([...published.keys()].some((k) => k.endsWith("/m1")));
assert.ok([...publishedOf(added).keys()].some((k) => k.endsWith("/m1")));
// "staged-combos-hang" contains "combo" as a substring — filter on the
// model id suffix instead: no published model id may start with a
// combo prefix.
assert.equal(
[...published.keys()].filter((k) => /\/combo/i.test(k)).length,
[...publishedOf(added).keys()].filter((k) => /\/combo/i.test(k)).length,
0,
`no combos known yet — models-only on the first publish is correct, got ${JSON.stringify([...published.keys()])}`
`no combos known yet — models-only on the first publish is correct, got ${JSON.stringify([...publishedOf(added).keys()])}`
);
});
} finally {
@@ -342,18 +331,17 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
return ok({ data: [{ id: "m1" }] });
}) as unknown as typeof fetch;
const reloads = { count: 0 };
const { catalogCallbacks, ctx } = setupCtx("staged-enrich-down", reloads);
const { added, ctx } = setupCtx("staged-enrich-down", reloads);
(ctx["options"] as Record<string, unknown>)["modelCacheTtlMs"] = 1;
try {
await withSilentConsole(async () => {
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
await catalogCallbacks[0]!(stubDraft().draft);
await new Promise((r) => setTimeout(r, 250));
await catalogCallbacks[0]!(stubDraft().draft);
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
await new Promise((r) => setTimeout(r, 250));
const third = stubDraft();
await catalogCallbacks[0]!(third.draft);
const m1 = [...third.published.values()].find((m) => m["id"] === "m1");
added.length = 0;
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
const m1 = [...publishedOf(added).values()].find((m) => m["id"] === "m1");
assert.equal(
m1?.["name"],
"Omni - Model One",
@@ -393,23 +381,20 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
return ok({ data: [{ id: "m1" }] });
}) as unknown as typeof fetch;
const reloads = { count: 0 };
const { catalogCallbacks, ctx } = setupCtx("staged-unreachable", reloads);
const { added: _addedU, ctx } = setupCtx("staged-unreachable", reloads);
(ctx["options"] as Record<string, unknown>)["modelCacheTtlMs"] = 1;
try {
await withSilentConsole(async () => {
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
// First transform: gateway healthy, entry stored.
await catalogCallbacks[0]!(stubDraft().draft);
await new Promise((r) => setTimeout(r, 250));
// Gateway goes down only now: the next transform fails totally while
// a prior entry exists, arming the cooldown.
down = true;
await new Promise((r) => setTimeout(r, 10));
await catalogCallbacks[0]!(stubDraft().draft);
// A fresh setup replays the same failing gateway through a new
// closure, so it refetches once and arms its own cooldown; the
// count assertion pins that single arming fetch.
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
const afterArming = modelCalls;
assert.ok(afterArming >= 2, "the failing transform tries the network once");
await catalogCallbacks[0]!(stubDraft().draft);
assert.equal(modelCalls, afterArming, "a transform inside the cooldown must not refetch");
});
} finally {
globalThis.fetch = origFetch;
@@ -427,12 +412,17 @@ describe("plugin-v2 staged refresh: optional sources never gate the publish", ()
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
const ctx = {
options: { baseURL: "https://gw.example.com", providerId: "staged-integ", apiKey: "k" },
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
catalogCallbacks.push(cb);
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
catalogCallbacks.push(async () => {
cb({ add: () => {} });
});
return Promise.resolve({ dispose: async () => {} });
},
},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: {
transform: () => {
throw new Error("host says no");

View File

@@ -0,0 +1,133 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import plugin from "../src/index.js";
/**
* Strict fallback (re-anchored): no `includeUsage` marking is proven anywhere
* outside node_modules. The repo-verifiable facts are:
* (a) the host mock in `gemini-language.test.ts` mounts a callable `aisdk`
* domain the plugin reaches through `hook(name, cb)` — the domain exists
* on the host and routes by name;
* (b) `@opencode/plugin 2.0.12` is the pinned contract reference
* (`package.json`), its `sdk`-event option shape is UNKNOWN here.
* Consequence: no options-only marking is proven → strict fallback: register
* the hook (domain exists, probed at runtime) and record the observation in
* `options` only; the pure telemetry core stays unported and unimported.
*
* Host shape: the stable `@opencode/plugin` contract publishes the catalog
* through `ctx.provider.transform` (`assertContext` requires the provider and
* model transforms) and exposes one named `ctx.aisdk.hook(name, cb)` entry
* point instead of a callable domain per event. The fake below mirrors that;
* the five properties it asserts are unchanged.
*/
interface SdkInput {
model: { id: string; providerID: string };
package: string;
options: Record<string, unknown>;
}
function hostCtx(opts: {
telemetry?: boolean;
withAisdk?: boolean;
sdkImpl?: (
cb: (input: SdkInput) => void | Promise<void>
) => Promise<{ dispose: () => Promise<void> }>;
}): {
ctx: Record<string, unknown>;
sdkCallbacks: Array<(input: SdkInput) => void | Promise<void>>;
} {
const sdkCallbacks: Array<(input: SdkInput) => void | Promise<void>> = [];
const registration = Promise.resolve({ dispose: async () => {} });
const options: Record<string, unknown> = {
baseURL: "https://gw.example.com",
providerId: "omni",
apiKey: "k",
};
if (opts.telemetry !== undefined) options["telemetry"] = opts.telemetry;
const ctx: Record<string, unknown> = {
options,
provider: { transform: () => registration, reload: async () => {} },
model: { transform: () => registration },
integration: { transform: () => registration },
};
if (opts.withAisdk !== false) {
ctx["aisdk"] = {
hook: (name: string, cb: (input: SdkInput) => void | Promise<void>) => {
// The stable host routes every aisdk event through one entry point;
// "language" is the Gemini sanitiser, only "sdk" is this test's subject.
if (name !== "sdk") return registration;
if (opts.sdkImpl !== undefined) return opts.sdkImpl(cb);
sdkCallbacks.push(cb);
return registration;
},
};
}
return { ctx, sdkCallbacks };
}
async function setupQuiet(ctx: Record<string, unknown>): Promise<void> {
const warn = console.warn;
const log = console.log;
console.warn = () => {};
console.log = () => {};
try {
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
} finally {
console.warn = warn;
console.log = log;
}
}
describe("aisdk.sdk telemetry hook (parity, option off by default)", () => {
it("an older host without the aisdk domain still loads (catalog only)", async () => {
const { ctx } = hostCtx({ telemetry: true, withAisdk: false });
await setupQuiet(ctx);
});
it("registers nothing when the option is off (default)", async () => {
const { ctx, sdkCallbacks } = hostCtx({});
await setupQuiet(ctx);
assert.equal(sdkCallbacks.length, 0, "telemetry off must not touch aisdk.sdk");
});
it("registers the hook when the option is on and the domain exists", async () => {
const { ctx, sdkCallbacks } = hostCtx({ telemetry: true });
await setupQuiet(ctx);
assert.equal(sdkCallbacks.length, 1, "telemetry on must register aisdk.sdk");
});
it("ignores models from other providers and marks only its own (options-only, no fetch)", async () => {
const { ctx, sdkCallbacks } = hostCtx({ telemetry: true });
await setupQuiet(ctx);
assert.equal(sdkCallbacks.length, 1);
const foreign: SdkInput = {
model: { id: "m", providerID: "some-other-provider" },
package: "@ai-sdk/openai-compatible",
options: {},
};
await sdkCallbacks[0]!(foreign);
assert.deepEqual(foreign.options, {}, "another provider's options are untouched");
const own: SdkInput = {
model: { id: "m", providerID: "omni" },
package: "@ai-sdk/openai-compatible",
options: {},
};
await sdkCallbacks[0]!(own);
assert.equal(
own.options["telemetry"],
true,
"own models carry an options-only telemetry mark, never a wrapped fetch"
);
});
it("a host that refuses the sdk hook still keeps its catalog", async () => {
const { ctx } = hostCtx({
telemetry: true,
sdkImpl: () => {
throw new Error("refused");
},
});
await setupQuiet(ctx);
});
});

View File

@@ -2,18 +2,30 @@ import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { parsePluginOptions, resolveTimeouts } from "../src/options.js";
import { publishCatalog } from "../src/catalog.js";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
function fakeDraft(): CatalogDraft {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
function fakeDraft(): BetaDraft {
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
return {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
fn(p);
providers.set(id, p);
},
@@ -21,16 +33,16 @@ function fakeDraft(): CatalogDraft {
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
fn(m);
models.set(k, m);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
},
} as CatalogDraft;
};
}
const BER = "https://gw.example.com";
@@ -124,7 +136,11 @@ describe("plugin-v2 P2 parity: per-endpoint timeouts", () => {
};
const ctx = {
options,
catalog: {
provider: {
transform: () => Promise.resolve({ dispose: async () => {} }),
reload: async () => {},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: {

View File

@@ -1,19 +1,31 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { CatalogDraft } from "@opencode-ai/plugin/v2/promise";
import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types";
import { publishCatalog } from "../src/catalog.js";
import { parsePluginOptions } from "../src/options.js";
type BetaDraft = {
provider: {
list?: () => unknown[];
get?: (id: string) => unknown;
update: (id: string, fn: (p: Record<string, any>) => void) => void;
remove?: () => void;
};
model: {
get?: (...a: string[]) => unknown;
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => void;
remove?: () => void;
default?: { get: () => undefined; set: () => void };
};
};
function fakeDraft(): { models: Map<string, ModelV2Info>; draft: CatalogDraft } {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
function fakeDraft(): { models: Map<string, Record<string, any>>; draft: BetaDraft } {
const providers = new Map<string, Record<string, any>>();
const models = new Map<string, Record<string, any>>();
const draft = {
provider: {
list: () => [],
get: (id: string) => providers.get(id) as never,
update: (id: string, fn: (p: ProviderV2Info) => void) => {
const p = (providers.get(id) ?? { id }) as ProviderV2Info;
update: (id: string, fn: (p: Record<string, any>) => void) => {
const p = (providers.get(id) ?? { id }) as Record<string, any>;
fn(p);
providers.set(id, p);
},
@@ -21,16 +33,16 @@ function fakeDraft(): { models: Map<string, ModelV2Info>; draft: CatalogDraft }
},
model: {
get: () => undefined,
update: (pid: string, mid: string, fn: (m: ModelV2Info) => void) => {
update: (pid: string, mid: string, fn: (m: Record<string, any>) => void) => {
const k = pid + "/" + mid;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as ModelV2Info;
const m = (models.get(k) ?? { id: mid, providerID: pid }) as Record<string, any>;
fn(m);
models.set(k, m);
},
remove: () => {},
default: { get: () => undefined, set: () => {} },
},
} as CatalogDraft;
};
return { models, draft };
}
@@ -190,12 +202,17 @@ describe("catalog usableOnly gating", () => {
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
await plugin.setup({
options: { baseURL: "https://gw.example.com", providerId: "usable-gate" },
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
catalogCallbacks.push(cb);
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
catalogCallbacks.push(async () => {
cb({ add: () => {} });
});
return Promise.resolve({ dispose: async () => {} });
},
},
},
model: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
});
const { mkdtempSync } = await import("node:fs");

View File

@@ -41,18 +41,20 @@ describe("warm snapshot is read under the credential actually in use", () => {
snapshotIdentityFingerprint(baseURL, hostKey, hostKey)
);
const published = new Map<string, Record<string, unknown>>();
const callbacks: Array<(draft: unknown) => Promise<void>> = [];
const added: unknown[] = [];
const registration = Promise.resolve({ dispose: async () => {} });
const ctx = {
options: { baseURL, providerId: "warmid", apiKey: "key-written-in-the-config" },
catalog: {
transform: (cb: (d: unknown) => Promise<void>) => {
callbacks.push(cb);
provider: {
transform: (cb: (editor: { add: (input: unknown) => void }) => void) => {
cb({ add: (input: unknown) => added.push(input) });
return registration;
},
reload: async () => {},
},
model: {
transform: () => registration,
},
integration: {
transform: () => registration,
connection: {
@@ -62,17 +64,10 @@ describe("warm snapshot is read under the credential actually in use", () => {
},
};
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
const draft = {
provider: { update: (_i: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
model: {
update: (pid: string, mid: string, fn: (m: Record<string, unknown>) => void) => {
const e: Record<string, unknown> = { id: mid, providerID: pid };
fn(e);
published.set(`${pid}/${mid}`, e);
},
},
};
await callbacks[0]!(draft);
const published = new Map<string, Record<string, unknown>>();
for (const entry of added as Array<{ info: { id: string }; models: Array<Record<string, unknown>> }>) {
for (const m of entry.models) published.set(`${entry.info.id}/${String(m.id)}`, m);
}
assert.ok(
[...published.keys()].some((k) => k.endsWith("/m-snap")),
`the snapshot must survive the credential switch, published: ${JSON.stringify([...published.keys()])}`

View File

@@ -230,6 +230,7 @@ Every field is optional. Defaults mirror v0.1.0 behaviour so existing `opencode.
| `visibleModels` | `string[]` | _unset_ | Allowlist — when set and non-empty, only models whose raw `/v1/models` ID matches are emitted. Bare IDs (no slash, e.g. `claude-opus-4-7`) match any `{prefix}/claude-opus-4-7`; full IDs (e.g. `cc/claude-opus-4-7`) match exactly. Composes with `usableOnly` and `hiddenModels` (all filters AND together). Unset or empty = no filter. |
| `hiddenModels` | `string[]` | _unset_ | Blocklist — models whose raw ID matches are dropped. Same matching rules as `visibleModels`. When a model is in both `visibleModels` and `hiddenModels`, the blocklist wins (deny takes precedence). Composes with `usableOnly` and `visibleModels` (all filters AND together). Unset or empty = no filter. |
| `diskCache` | `boolean` | `true` | Persist the last successful `/v1/models` + `/api/combos` + enrichment + connections + compression snapshot to `${OPENCODE_DATA_DIR ?? ~/.local/share/opencode}/plugins/omniroute-<providerId>.json`. On a subsequent cold start where `/v1/models` throws (network down / IP whitelist drop / 5xx) the static block hydrates from the snapshot so OC's model picker survives offline. Soft-fail on read/write — never blocks publishing. |
| `diskCacheMaxAgeMs` | `number` | _unset_ | Opt-in max age, in milliseconds, for a disk-cache fallback snapshot. Unset or `0` keeps the snapshot unbounded. A positive bound still serves the snapshot and escalates that fallback log from warn to error once the snapshot is older than the bound. |
| `geminiSanitization` | `boolean` | `true` | Strip `$schema`/`$ref`/`additionalProperties` from tool params when the model id matches `gemini` |
| `mcpAutoEmit` | `boolean` | `false` | Auto-write an `mcp.<providerId>` remote entry into the OC config pointing at `<baseURL>/api/mcp/stream` with the resolved Bearer token |
| `mcpToken` | `string` | _unset_ | Optional separate Bearer for the auto-emitted MCP entry. Falls back to the provider's `apiKey` (from `auth.json`) when unset |

View File

@@ -197,6 +197,13 @@ const featuresSchema = z
visibleModels: z.array(z.string().min(1)).optional(),
hiddenModels: z.array(z.string().min(1)).optional(),
diskCache: z.boolean().optional(),
/**
* Opt-in max age for a disk-cache fallback snapshot, in milliseconds.
* Unset or `0` keeps the historical unbounded default: a stale snapshot
* is still served. A positive bound does not refuse the snapshot; the
* fallback log escalates from warn to error once the snapshot is older.
*/
diskCacheMaxAgeMs: z.number().nonnegative().optional(),
providerTag: z.boolean().optional(),
debugLog: z.boolean().optional(),
startupDebug: z.boolean().optional(),
@@ -480,6 +487,27 @@ function coercePluginOptions(opts?: PluginOptions): OmniRoutePluginOptions {
*/
export const DEFAULT_ANTHROPIC_PREFIXES = ["cc", "claude", "anthropic", "kiro", "kr"];
/**
* First-class OmniRoute catalog suffixes (`GET /v1/models`). The Anthropic
* Messages translator looks these up as `claude-<model>` on provider
* `claude` and 404s. Keep them on openai-compatible `/v1` so the full
* catalog id (`cc/claude-haiku-4-5-20251001-low`) is sent unchanged.
*/
export const OPENAI_COMPAT_EFFORT_TIER_SUFFIXES = [
"-low",
"-medium",
"-high",
"-xhigh",
"-thinking",
"-minimal",
"-max",
] as const;
function hasOpenAiCompatEffortTierSuffix(modelId: string): boolean {
const lower = modelId.toLowerCase();
return OPENAI_COMPAT_EFFORT_TIER_SUFFIXES.some((suffix) => lower.endsWith(suffix));
}
/**
* Ensure a baseURL ends with `/v1` so the OpenAI-compat SDK constructs
* `/v1/chat/completions` correctly. The Anthropic SDK does NOT want `/v1`
@@ -511,7 +539,12 @@ export function ensureV1Suffix(url: string): string {
* Resolve the API block (id + url + npm package) for a given model id.
*
* Decision matrix:
* - If the model id's prefix (the substring before the first `/`) is in
* - If the model id ends with a first-class OmniRoute effort-tier suffix
* (`-low` / `-medium` / `-high` / `-xhigh` / `-thinking` / `-minimal` /
* `-max`), return the OpenAI-compat block even when the prefix is
* Anthropic. Those ids exist only in `GET /v1/models`; the Anthropic
* Messages path 404s them as `claude-<name>` on provider `claude`.
* - Else if the model id's prefix (the substring before the first `/`) is in
* `apiFormat.anthropicPrefixes` (or the default list), return the
* Anthropic SDK block: `id: "anthropic"`, `url: baseURL` (no `/v1`),
* `npm: "@ai-sdk/anthropic"`.
@@ -530,7 +563,7 @@ export function resolveApiBlock(
const prefixes = apiFormat?.anthropicPrefixes ?? DEFAULT_ANTHROPIC_PREFIXES;
const slash = modelId.indexOf("/");
const prefix = slash === -1 ? modelId : modelId.slice(0, slash);
const isAnthropic = prefixes.includes(prefix);
const isAnthropic = prefixes.includes(prefix) && !hasOpenAiCompatEffortTierSuffix(modelId);
return isAnthropic
? {
id: "anthropic",
@@ -5296,6 +5329,7 @@ export function createOmniRouteConfigHook(
sink.call(logger, message);
};
const features = resolved.features ?? {};
const wantCombos = features.combos !== false;
const wantAutoCombos = features.autoCombos !== false;
const wantEnrichment = features.enrichment !== false;
const wantCompressionMeta = features.compressionMetadata === true;
@@ -5448,6 +5482,7 @@ export function createOmniRouteConfigHook(
};
const doCombos = async (): Promise<void> => {
if (!wantCombos) return;
try {
localRawCombos = await combosFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
@@ -5560,13 +5595,20 @@ export function createOmniRouteConfigHook(
// "stale" alone reads as a transient blip, so a week-old catalog
// is indistinguishable from a five-minute-old one.
const snapshotAge = snapshot.writtenAt;
const ageMs = typeof snapshotAge === "number" ? now() - snapshotAge : undefined;
const snapshotAgeLabel =
typeof snapshotAge === "number"
? `${Math.round((Date.now() - snapshotAge) / 3_600_000)}h`
: "unknown";
typeof ageMs === "number" ? `${Math.round(ageMs / 3_600_000)}h` : "unknown";
const maxAgeMs = features.diskCacheMaxAgeMs;
const pastMaxAge =
typeof maxAgeMs === "number" &&
maxAgeMs > 0 &&
typeof ageMs === "number" &&
ageMs > maxAgeMs;
logAt(
"warn",
`config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models, age ${snapshotAgeLabel})`
pastMaxAge ? "error" : "warn",
`config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models, age ${snapshotAgeLabel}${
pastMaxAge ? `, past diskCacheMaxAgeMs=${maxAgeMs}` : ""
})`
);
localRawModels = snapshot.rawModels;
localRawCombos = snapshot.rawCombos;

View File

@@ -461,6 +461,31 @@ test("config: fetchers throw → warn + emit stub entry with models: {}", async
// 6. Combos fetcher throws → models-only catalog (no combos in models block)
// ────────────────────────────────────────────────────────────────────────────
test("config: features.combos=false skips /api/combos fetch", async () => {
const readAuthJson = stubReadAuthJson({
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
});
const fetcher = stubModelsFetcher([MODEL_CLAUDE]);
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
const logger = captureWarn();
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute", features: { combos: false } },
{ readAuthJson, fetcher, combosFetcher, logger }
);
const input = makeInput();
await hook(input);
assert.equal(fetcher.callCount(), 1, "models fetch still runs");
assert.equal(combosFetcher.callCount(), 0, "combos fetch suppressed by feature flag");
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry);
assert.equal(entry.models["claude-tier"], undefined, "no combo entry when combos are off");
assert.ok(entry.models["claude-sonnet-4-6"]);
});
test("config: combos fetcher throws → emit models-only catalog (no combos in models block)", async () => {
const readAuthJson = stubReadAuthJson({
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
@@ -1356,6 +1381,63 @@ test('config: stale-fallback warning falls back to "unknown" age without written
);
});
test("config: diskCacheMaxAgeMs escalates the fallback log but still serves the snapshot", async () => {
const readAuthJson = stubReadAuthJson({
"opencode-omniroute": { type: "api", key: "sk-test", baseURL: "https://or.example/v1" },
});
const fetcher = throwingModelsFetcher();
const combosFetcher = stubCombosFetcher([]);
const levels: string[] = [];
const logger = {
warn: (message: string) => {
levels.push(`warn:${message}`);
},
error: (message: string) => {
levels.push(`error:${message}`);
},
};
const writtenAt = 1_700_000_000_000;
const maxAgeMs = 24 * 3_600_000;
const diskSnapshotReader = emptyThenSnapshotReader({
rawModels: [MODEL_CLAUDE],
rawCombos: [],
rawEnrichment: new Map([["claude-sonnet-4-6", { name: "Claude Sonnet 4.6 (cached)" }]]),
rawCompressionCombos: [],
rawConnections: [],
writtenAt,
});
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute", features: { diskCache: true, diskCacheMaxAgeMs: maxAgeMs } },
{
readAuthJson,
fetcher,
combosFetcher,
diskSnapshotReader,
logger,
now: () => writtenAt + 48 * 3_600_000,
}
);
const input = makeInput();
await hook(input);
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry.models["claude-sonnet-4-6"], "snapshot past the bound is still served");
assert.ok(
levels.some(
(line) => line.startsWith("error:") && line.includes(`past diskCacheMaxAgeMs=${maxAgeMs}`)
),
"past-bound fallback escalates to error"
);
assert.equal(
levels.some((line) => line.startsWith("warn:") && line.includes("using stale disk cache")),
false,
"past-bound fallback is not only a warning"
);
});
test("config: cached rawEnrichment from earlier provider hook is reused (no refetch)", async () => {
const readAuthJson = stubReadAuthJson({
"opencode-omniroute": { type: "api", key: "sk-shared", baseURL: "https://or.example/v1" },

View File

@@ -19,6 +19,7 @@ import {
normaliseFreeLabel,
resolveApiBlock,
DEFAULT_ANTHROPIC_PREFIXES,
OPENAI_COMPAT_EFFORT_TIER_SUFFIXES,
ensureV1Suffix,
debugLogEnabled,
debugLogSetEnabled,
@@ -36,10 +37,7 @@ test("normaliseFreeLabel: '(Free)' suffix becomes [Free] prefix", () => {
});
test("normaliseFreeLabel: trailing ' Free' word becomes [Free] prefix", () => {
assert.equal(
normaliseFreeLabel("DeepSeek V4 Flash Free"),
"[Free] DeepSeek V4 Flash"
);
assert.equal(normaliseFreeLabel("DeepSeek V4 Flash Free"), "[Free] DeepSeek V4 Flash");
});
test("normaliseFreeLabel: trailing '-free' (hyphen) becomes [Free] prefix", () => {
@@ -59,10 +57,7 @@ test("normaliseFreeLabel: names without 'free' pass through unchanged", () => {
test("normaliseFreeLabel: 'free' in the middle of a name is NOT rewritten", () => {
// Only trailing/standalone "free" markers count; embedded "freedom" stays
assert.equal(
normaliseFreeLabel("Freedom Model"),
"Freedom Model"
);
assert.equal(normaliseFreeLabel("Freedom Model"), "Freedom Model");
});
test("normaliseFreeLabel: empty / whitespace-only inputs are handled", () => {
@@ -80,6 +75,22 @@ test("resolveApiBlock: cc/* models get the Anthropic SDK block (no /v1)", () =>
assert.equal(block.url, "https://api.example.com"); // NO /v1 suffix
});
test("resolveApiBlock: cc/* effort-tier catalog ids stay on openai-compatible /v1", () => {
assert.ok(OPENAI_COMPAT_EFFORT_TIER_SUFFIXES.includes("-low"));
for (const id of [
"cc/claude-haiku-4-5-20251001-low",
"cc/claude-opus-5-medium",
"cc/claude-opus-5-high",
"cc/claude-opus-5-xhigh",
"cc/claude-opus-4-6-thinking",
]) {
const block = resolveApiBlock(id, "https://api.example.com");
assert.equal(block.id, "openai-compatible", `${id} must not use Anthropic Messages`);
assert.equal(block.npm, "@ai-sdk/openai-compatible");
assert.equal(block.url, "https://api.example.com/v1");
}
});
test("resolveApiBlock: claude/*, anthropic/*, kiro/*, kr/* all route to Anthropic", () => {
for (const id of [
"claude/claude-opus-4-7",
@@ -128,13 +139,7 @@ test("resolveApiBlock: model id without '/' uses the id as prefix", () => {
});
test("DEFAULT_ANTHROPIC_PREFIXES: contains the canonical Anthropic aliases", () => {
assert.deepEqual(DEFAULT_ANTHROPIC_PREFIXES, [
"cc",
"claude",
"anthropic",
"kiro",
"kr",
]);
assert.deepEqual(DEFAULT_ANTHROPIC_PREFIXES, ["cc", "claude", "anthropic", "kiro", "kr"]);
});
test("ensureV1Suffix: idempotent for URLs that already end in /v1", () => {
@@ -245,8 +250,7 @@ test("createDebugLoggingFetch: records error without crashing the wrapped fetch"
test("createDebugLoggingFetch: URL instance input is captured (not 'undefined')", async () => {
const providerId = "test-provider-url-input";
debugLogClear(providerId);
const inner: typeof fetch = async () =>
new Response("ok", { status: 200 });
const inner: typeof fetch = async () => new Response("ok", { status: 200 });
const wrapped = createDebugLoggingFetch(inner, providerId, true);
await wrapped(new URL("https://api.example.com/v1/chat"));
const entries = debugLogRead(providerId);
@@ -258,8 +262,7 @@ test("createDebugLoggingFetch: URL instance input is captured (not 'undefined')"
test("createDebugLoggingFetch: Request object input captures URL and headers", async () => {
const providerId = "test-provider-request-input";
debugLogClear(providerId);
const inner: typeof fetch = async () =>
new Response("ok", { status: 200 });
const inner: typeof fetch = async () => new Response("ok", { status: 200 });
const wrapped = createDebugLoggingFetch(inner, providerId, true);
const req = new Request("https://api.example.com/v1/chat", {
method: "POST",

View File

@@ -12,7 +12,7 @@
> // opencode.json
> {
> "$schema": "https://opencode.ai/config.json",
> "plugin": ["@omniroute/opencode-plugin"]
> "plugin": ["@omniroute/opencode-plugin"],
> }
> ```
>
@@ -100,7 +100,7 @@ Returns the value to place under `provider.omniroute` inside `opencode.json`.
| `baseURL` | `string` | Yes | OmniRoute base URL. Accepts `http://host:port` **or** `http://host:port/v1`. Trailing slashes are tolerated. |
| `apiKey` | `string` | Yes | OmniRoute API key. Use `sk_omniroute` for local installs that have `REQUIRE_API_KEY=false`. |
| `displayName` | `string` | No | Custom name shown in the OpenCode UI. Default: `"OmniRoute"`. |
| `models` | `string[]` | No | Override the surfaced model catalog. Default: 4 curated models — see `OMNIROUTE_DEFAULT_OPENCODE_MODELS`. |
| `models` | `string[]` | No | Override the surfaced model catalog. Default: 8 curated models — see `OMNIROUTE_DEFAULT_OPENCODE_MODELS`. |
| `modelLabels` | `Record<string,string>` | No | Human-readable labels keyed by model id. |
Throws on empty/invalid input — `baseURL` must be a real URL, `apiKey` must be a non-empty string.
@@ -143,7 +143,7 @@ Duplicates and empty strings are dropped automatically, and order is preserved.
- **Requests 404 with `/v1/v1/...`** — you're on an old version (≤1.0.0). Update to `≥0.1.0` of this re-released package. The new build normalises `baseURL` automatically.
- **`401 Invalid API key`** — your OmniRoute instance has `REQUIRE_API_KEY=true` but the key you supplied doesn't exist there. Create one via the dashboard or set `REQUIRE_API_KEY=false` and use `sk_omniroute`.
- **OpenCode complains the provider has no models** — supply an explicit `models` list; the default 4 may be hidden by your provider visibility settings.
- **OpenCode complains the provider has no models** — supply an explicit `models` list; the default 8 may be hidden by your provider visibility settings.
## Related

View File

@@ -46,7 +46,7 @@ Repository map and Reference Documentation sections below.
## Project at a Glance
**OmniRoute** — unified AI proxy/router. One endpoint, 359 LLM providers, auto-fallback.
**OmniRoute** — unified AI proxy/router. One endpoint, 360 LLM providers, auto-fallback.
| Layer | Location | Purpose |
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -56,7 +56,7 @@ Repository map and Reference Documentation sections below.
| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) |
| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions |
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
| Database | `src/lib/db/` | SQLite domain modules (178 migrations) |
| Database | `src/lib/db/` | SQLite domain modules (182 migrations) |
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
| MCP Server | `open-sse/mcp-server/` | 110 tools (45 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules), 3 transports (stdio / SSE / Streamable HTTP), 33 scopes |
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
@@ -542,6 +542,7 @@ git push -u origin feat/your-feature
**Husky hooks**:
- **pre-commit**: lint-staged + `check-docs-sync` + `check:any-budget:t11` + `check:tracked-artifacts`
- **commit-msg**: `check:ai-attribution` — rejects AI/bot `Co-Authored-By` trailers and AI-generation footers in the message (Hard Rule #16; human co-authors allowed; also in the `quality.yml` fast-gates loop (PR→`release/**`) and a PR-only `ci.yml` lint step (PR→`main`) — #14436)
- **pre-push**: intentionally light (PATH/npm sanity only). `any-budget` + `tracked-artifacts`
already run on pre-commit; re-running them on every push was pure double-pay. CI still
enforces both. (Was Fase 6A.12 full pre-push gate; folded into pre-commit in #6716.)

View File

@@ -92,67 +92,67 @@
## [3.8.51] — TBD
_Living section — reconciled 2026-09-15 from all cycle commits (`091589089c``c0f92ec98a`, 916 non-merge commits). Bullets carry the merged PR and its author; direct pushes are listed with their commit hash. Regenerated at each `/generate-release` phase._
_Living section — reconciled 2026-09-21 from all cycle commits (`091589089c``06f1df9d77`, 1,356 non-merge commits). Bullets carry the merged PR and its author; direct pushes are listed with their commit hash. Regenerated at each `/generate-release` phase._
### 📊 Release by the numbers
| | |
| --- | ---: |
| 👥 People who contributed | **123** |
| 📝 Commits in the cycle | **916** |
| 🔀 Pull requests referenced | **892** |
| 📋 Changelog entries | **919** |
| 🙌 Contributors credited in entries | **121** |
| 🤖 Automated dependency commits | 18 |
| 👥 People who contributed | **242** |
| 📝 Commits in the cycle | **1,356** |
| 🔀 Pull requests referenced | **1,328** |
| 📋 Changelog entries | **1,393** |
| 🙌 Contributors credited in entries | **240** |
| 🤖 Automated dependency commits | 22 |
**Entries by type**
| Type | Count |
| --- | ---: |
| 🐛 Fixes | 592 |
| ✨ Features | 131 |
| 🧹 Chore | 93 |
| 📚 Docs | 42 |
| 🧪 Tests | 32 |
| 🐛 Fixes | 962 |
| ✨ Features | 185 |
| 🧹 Chore | 112 |
| 🧪 Tests | 50 |
| 📚 Docs | 49 |
| ♻️ Refactor | 8 |
| 🏗️ Build | 7 |
| ⚡ Performance | 7 |
| ⚙️ CI | 2 |
| ⚙️ CI | 5 |
| 🔒 Security | 3 |
| 📦 Dependencies | 3 |
| ⏪ Reverts | 2 |
| 📦 Dependencies | 2 |
| 🔒 Security | 1 |
### 🏆 Top 25 contributors this cycle
_By commits in `091589089c..c0f92ec98a`, author identities consolidated via `.mailmap` and the merged PR's GitHub login. Bots excluded._
_By commits in `091589089c..06f1df9d77`, author identities consolidated via `.mailmap` and the merged PR's GitHub login. Bots excluded._
| # | Contributor | Commits |
| ---: | --- | ---: |
| 🥇 | diegosouzapw | 323 |
| 🥈 | Bob.Hou (@HouMinXi) | 79 |
| 🥉 | Paco Cartones (@pacocartones) | 77 |
| 4 | Dizzle (@maxmad64bis) | 49 |
| 5 | Koosha Paridehpour (@KooshaPari) | 43 |
| 6 | Nguyen Thanh Dat (@ntdatt812) | 31 |
| 7 | Ravi Tharuma (@RaviTharuma) | 26 |
| 8 | Markus Hartung (@hartmark) | 22 |
| 🥇 | diegosouzapw | 444 |
| 🥈 | Bob.Hou (@HouMinXi) | 105 |
| 🥉 | Dizzle (@maxmad64bis) | 94 |
| 4 | Paco Cartones (@pacocartones) | 86 |
| 5 | Koosha Paridehpour (@KooshaPari) | 50 |
| 6 | Markus Hartung (@hartmark) | 31 |
| 7 | Nguyen Thanh Dat (@ntdatt812) | 31 |
| 8 | Ravi Tharuma (@RaviTharuma) | 28 |
| 9 | Webman (@jonlwheat2-gif) | 22 |
| 10 | backryun | 17 |
| 11 | anhtahaylove | 15 |
| 12 | Rafa Martins (@rafacpti23) | 10 |
| 13 | Syed Raheemuddin (@raheemuddin786) | 10 |
| 14 | Nguyn Viết Tuấn (@TheDemonTuan) | 8 |
| 15 | MumuTW | 7 |
| 16 | Paijo (@oyi77) | 7 |
| 17 | Tobias Andersen (@turbolego) | 6 |
| 18 | Bl0ck (@Bl0ck154) | 5 |
| 19 | SHANMUGAPRIYAN (@geek007git) | 5 |
| 20 | KaspaPulse | 5 |
| 21 | Abhishek Sharma (@abhisheksharma2411) | 4 |
| 22 | Abhishek Divekar (@adivekar-utexas) | 4 |
| 23 | Andrew B. (@AndrianBalanescu) | 4 |
| 24 | NoxzRCW | 4 |
| 25 | opensource-elearning | 4 |
| 10 | anhtahaylove | 19 |
| 11 | backryun | 17 |
| 12 | Patryk Kopyciński (@patrykkopycinski) | 14 |
| 13 | initguru | 12 |
| 14 | Nguyen Thanh Dat (@datrixlab) | 11 |
| 15 | Rafa Martins (@rafacpti23) | 11 |
| 16 | Syed Raheemuddin (@raheemuddin786) | 11 |
| 17 | Nguyễn Viết Tuấn (@TheDemonTuan) | 11 |
| 18 | Abhishek Sharma (@abhisheksharma2411) | 10 |
| 19 | Paijo (@oyi77) | 9 |
| 20 | lorenzozane (@lorenzozanee) | 8 |
| 21 | MumuTW | 8 |
| 22 | Bl0ck (@Bl0ck154) | 7 |
| 23 | Innokentiy Solntsev (@insoln) | 7 |
| 24 | Tuan Dinh (@tuandinh0801) | 7 |
| 25 | Tobias Andersen (@turbolego) | 7 |
### ✨ New Features
@@ -291,6 +291,59 @@ _By commits in `091589089c..c0f92ec98a`, author identities consolidated via `.ma
- **feat(release):** reconcile-changelog tool + version-anchored fragment aggregation ([#12987](https://github.com/diegosouzapw/OmniRoute/pull/12987))
- **feat(mcp):** carry context_length through MCP list_models_catalog projection (#12776) ([#13406](https://github.com/diegosouzapw/OmniRoute/pull/13406)) — thanks @KooshaPari
- **feat(cli):** make startup readiness budget configurable (#13369) ([#13433](https://github.com/diegosouzapw/OmniRoute/pull/13433)) — thanks @KooshaPari
- **feat(docs):** every Markdown page under `docs/` is now mirrored in all 65 dashboard locales, not only the 22-page core set — 152 sources × 65 locales = 9,880 mirrors (6,208 new), with the 🌐 language bar of every mirror rewritten for the full locale list. The docs drift gate (`npm run i18n:check`, blocking in CI) derives its scope from the tree, so it now guards all 152 pages. Found and fixed by the run in `scripts/i18n/run-translation.mjs`: a markdown table or tight bullet list with no blank line inside it (PROVIDER_REFERENCE.md's 244-row table, FREE_TIERS.md's 71-item list) was sent as one 1640 KB request that outlived the backend socket for verbose scripts (Greek, Amharic); oversized runs of table rows or list items are now cut at item boundaries and rejoined without a blank line, so no chunk exceeds 6 KB across the docs tree. 48 older mirrors whose tables had lost rows were retranslated with the fixed chunker. ([#14106](https://github.com/diegosouzapw/OmniRoute/pull/14106))
- **feat(usage):** `openai-compatible-*` connections can now report billing/quota in Provider Limits. The connection declares its own quota endpoint, auth mode and a dot-path mapping onto `UsageQuota` in `providerSpecificData.quotaEndpoint`, so no upstream-specific code is needed per service — a mapping that resolves nothing reports no quota rather than an exhausted-looking 0/0 ([#13616](https://github.com/diegosouzapw/OmniRoute/issues/13616)) ([#13673](https://github.com/diegosouzapw/OmniRoute/pull/13673)) — thanks @abhisheksharma2411
- **feat(sse):** track LLM Gateway DevPass quota — the `llmgateway` provider now reads its monthly plan-credit and weekly premium-model allowance from `GET /v1/key` and surfaces both windows in Dashboard Limits and quota-aware preflight ([#12462](https://github.com/diegosouzapw/OmniRoute/pull/12462)). — thanks @PixmaNts
- **feat(providers):** register `gemini-3.8-flash` ([#12638](https://github.com/diegosouzapw/OmniRoute/issues/12638)) — Gemini 3.8 Flash (DeepMind 2026-09-02) with tool calling and vision support ([#12663](https://github.com/diegosouzapw/OmniRoute/pull/12663)) — thanks @toor11
- **feat(proxylogs):** proxy log columns and detail pane now show the registry proxy name instead of a bare `host:port` when several registry entries share the same gateway ([#12814](https://github.com/diegosouzapw/OmniRoute/pull/12814)) — thanks @tiangao88
- **compression:** add Hungarian Caveman language pack with Hungarian-specific rules, language detection, localized output instructions, and language-pack tests. (#12825 - thanks @botii16)
- **feat(sse):** parse/scrub DSML tool-call markers embedded in reasoning and recognize adaptive thinking on the response side — `dsmlToolCalls.ts` module + translator/stream/handler wiring ([#12905](https://github.com/diegosouzapw/OmniRoute/pull/12905)) — thanks @initguru
- **feat(codex):** safely discover compatible models by classifying upstream models before activation to keep hidden, unsupported, retired, or newer-client models out of the active catalog, exposing candidate diagnostics while persisting only active models, adding GPT-6 Astra fallback definitions, and bumping the tested Codex CLI version to 0.153.4 ([#12933](https://github.com/diegosouzapw/OmniRoute/pull/12933)) — thanks @TheDemonTuan
- **feat(api):** `POST /api/keys` accepts `expiresAt` (ISO datetime, nullable) with the same semantics as the key-update path, so automation can create an expiring key in one operation instead of create-then-update. Omitted/null preserves the current non-expiring behavior; enforcement reuses the existing expiry policy ([#12952](https://github.com/diegosouzapw/OmniRoute/pull/12952)) — thanks @caniko
- **feat(build):** add build:fast and start:fast to bypass standalone tracing ([#13021](https://github.com/diegosouzapw/OmniRoute/pull/13021)) — thanks @tuandinh0801
- **feat(sse):** `OMNIROUTE_DISABLE_CONVERSATION_TRACKING=1` turns off conversation-history collection for operators who do not use the dashboard's conversation view. `resolveConversationId()` returns an untracked result before it reads SQLite or parses message history, and the switch also covers client-supplied session IDs. Routing-session handling is unchanged, tracking stays on by default, and existing records are not deleted. One reporting install held 5.97 million turn records at about 4.26 GB ([#13150](https://github.com/diegosouzapw/OmniRoute/pull/13150)) — thanks @cryptiklemur
- **feat(providers):** Add `auto/kimi`, `auto/qwen`, `auto/deepseek`, `auto/gpt`, and the `auto/claude-haiku` fast variant to the built-in routing catalog, including bare `k3` models on Kimi coding and web backends (issue #13214). ([#13709](https://github.com/diegosouzapw/OmniRoute/pull/13709)) — thanks @keii-2596
- **feat(usage):** Claude OAuth usage now shows the separate weekly Fable limit next to the shared five-hour and weekly meters. Anthropic reports it as a `weekly_scoped` entry in `limits[]`, which OmniRoute ignored, so the pool was invisible. The provider-limits cache keeps `modelQuotas` and restores it on stale-data fallback. The Fable meter is display-only and does not affect routing, account selection, or cooldowns ([#13266](https://github.com/diegosouzapw/OmniRoute/pull/13266)) — thanks @cryptiklemur
- **feat(playground): copy an individual Compare column's response.** Each column in the Compare tab now has a copy button beside the remove button, reusing the existing `useCopyToClipboard` hook to copy that column's response text and show a checkmark while `disabled` on an empty response. (The independent-scrolling half of this PR was already fixed separately in #13532.) (#13317 — thanks @ventulus95)
- **feat(models):** add Gemini 3.8 Flash tiers to Antigravity and AGY catalogs ([#13318](https://github.com/diegosouzapw/OmniRoute/pull/13318)) — thanks @tuandinh0801, with credit to #12499 (@Abhishekchhetri020)
- **feat(reasoning):** adaptive reasoning effort (`auto`) — the gateway resolves the thinking budget per user turn from deterministic request-shape signals (stateless per-turn pin) instead of forwarding a literal `auto`, applied at the gateway pre-translation for any harness (Claude Code, Cursor, Codex, opencode, Hermes) whose request dispatches to an OpenAI Chat-Completions-shaped upstream (`targetFormat === FORMATS.OPENAI``reasoning_effort` is an OpenAI-shaped field, so a Claude- or Gemini-targeted request is unaffected). Opt in via `X-OmniRoute-Effort: auto` or a model's `defaultReasoningEffort: "auto"` (now a valid `ModelSpec` value); any explicit client reasoning field always wins ([#13448](https://github.com/diegosouzapw/OmniRoute/pull/13448)) — thanks @patrykkopycinski
- **feat(dashboard):** Add a dedicated, full-width API-key routing editor with explicit model/combo choices, searchable selectors and protection for unsaved rule drafts. ([#13555](https://github.com/diegosouzapw/OmniRoute/pull/13555)) — thanks @JxnLexn
- **feat(proxies):** proxy pools and opencode's per-account rotation stop re-serving a proxy that just failed (refused TCP probe, or a 429 through it) for a period that doubles on each repeat up to a cap, without writing any proxy status; with every candidate set aside the choice is unchanged. Opt-in via the `PROXY_SKIP_RECENTLY_FAILED` feature flag (default off: selection unchanged) ([#13578](https://github.com/diegosouzapw/OmniRoute/pull/13578)) — thanks @maxmad64bis
- **feat(proxy-logs):** proxy log rows keep the HTTP status the provider actually returned (`upstream_status`, null when no response arrived), so a throttled egress IP (429), a refused one (403) and a provider outage (500) are no longer the same "error" line, and a 429 generated locally is no longer mistaken for one from the provider ([#13580](https://github.com/diegosouzapw/OmniRoute/pull/13580)) — thanks @maxmad64bis
- **feat(proxies):** the proxy pool editor shows, for the last 24 h, how many distinct egress IPs actually served the pool's members, how many connections went through them and the most seen behind one IP, read from the proxy log through a separate route so it can never break the pool screen; opt-in with the `PROXY_POOL_EGRESS_OBSERVATION` feature flag (default off) ([#13581](https://github.com/diegosouzapw/OmniRoute/pull/13581)) — thanks @maxmad64bis
- **feat(sse): learn hard request caps stated in 429 bodies and pace under them.** Providers such as TokenRouter reject bursts with prose like `Maximum 5 requests within 1 minutes` and no rate-limit headers, so the limiter never learned the ceiling and kept racing into it; every 429 also tore the limiter down and rebuilt it with no pacing. `updateFromResponseBody` now parses that phrasing (and `N requests per minute`, `N requests per M seconds`, `N RPM`) into a per-window cap, applies it to the limiter as an empty reservoir that refills `N` every window with calls spread `window / N` apart, and records it in `learnedRateLimits`. A learned cap is reapplied whenever the limiter is rebuilt after a 429 and when limits are restored at startup, unless the connection has an explicit RPM override. Fixes [#13594](https://github.com/diegosouzapw/OmniRoute/issues/13594). ([#13895](https://github.com/diegosouzapw/OmniRoute/pull/13895)) — thanks @costajohnt
- **feat(proxies):** a proxy pool stops re-serving a member the provider just refused through it and tries another member instead, reusing the existing skip cooldown; a later success through the member clears it. Opt-in with the `PROXY_SKIP_RECENTLY_FAILED` feature flag (default off: pool selection unchanged) ([#13602](https://github.com/diegosouzapw/OmniRoute/pull/13602)) — thanks @maxmad64bis
- **feat(flags):** add `DB_HEALTHCHECK_STARTUP_DEFERRED_ENABLED` (default off) — opt-in deferral of the startup DB health/integrity check past process boot via `setImmediate`; off keeps the pre-#13717 behavior of blocking `getDbInstance()` until the check has already run (#13717). — thanks @HouMinXi
- **feat(i18n):** 7 new locales — Hausa (`ha`), Yoruba (`yo`), Igbo (`ig`), Amharic (`am`), Uzbek (`uz`), Georgian (`ka`), Armenian (`hy`) — across the dashboard, docs mirrors, CLI, README and the site (66 locales, the full planned expansion from 43). (#13727)
- **feat(api):** `POST /v1/rerank` (and the memory engine's loopback rerank step) can route to OpenAI-compatible provider nodes on a LAN/Tailscale host — not only loopback — behind the new `RERANK_REMOTE_PROVIDER_NODES` feature flag (default off), subject to the provider outbound URL policy; the loopback host check is consolidated into `@/shared/network/loopbackNodeHost` shared by rerank, audio, and the local health checker ([#13732](https://github.com/diegosouzapw/OmniRoute/pull/13732)) ([#13733](https://github.com/diegosouzapw/OmniRoute/pull/13733)) — thanks @seanford
- **feat(security):** OmniRoute now warns at boot when the server that answers `/v1` inference is bound to a non-loopback interface while `REQUIRE_API_KEY` is disabled. The guard added in [#12568](https://github.com/diegosouzapw/OmniRoute/pull/12568) covered the API bridge (`API_HOST`, default loopback) and the live dashboard WebSocket, but not the Next server that actually serves `/v1/chat/completions` and `/v1/responses` — which binds `HOST || 0.0.0.0`, every interface by default. That matters because `GET /v1/models` follows the dashboard login posture (`requireAuthForModels`) while inference follows `REQUIRE_API_KEY`, so an instance with an admin password and `REQUIRE_API_KEY=false` answers `401` to the probe an operator naturally runs while inference stays open to anyone who can reach the port. The bound host is resolved from `OMNIROUTE_BOUND_HOST` (published by `scripts/dev/run-next.mjs`) then Next's own `HOSTNAME` (the Docker path); `HOST` is deliberately excluded because the standalone server ignores it and a warning naming the wrong interface is worse than none. New `docs/security/INFERENCE_AUTH_POSTURE.md` documents the split, how to actually probe inference, and the [#2257](https://github.com/diegosouzapw/OmniRoute/issues/2257) caveat that an invalid bearer degrades to anonymous. ([#13820](https://github.com/diegosouzapw/OmniRoute/pull/13820)) — thanks @abhisheksharma2411
- Expose combo wall-clock timeout (`comboTimeoutMs`) next to Target timeout in the combo editor and Combo defaults. Empty keeps the 10-minute hang-stop; a positive value replaces it. ([#13857](https://github.com/diegosouzapw/OmniRoute/pull/13857)) — thanks @HouMinXi
- **feat(i18n):** `retranslate-site` rewrites the site catalogs' verbatim-English leaves (2,059 across 63 catalogs; mean English residue 10.3 % → 6.3 %, the rest being brand names kept on purpose). (#13886)
- **feat(compression):** Lite tool-result truncation length is configurable (`lite.maxToolLength`, env `OMNIROUTE_LITE_MAX_TOOL_LENGTH`). Default stays 2000. An out-of-range step cap no longer hides a valid global cap; a toggle-only settings write keeps a stored cap; `maxToolLength: null` clears it. Dashboard copy no longer hard-codes 2,000 characters. ([#13915](https://github.com/diegosouzapw/OmniRoute/pull/13915) — refs [#13178](https://github.com/diegosouzapw/OmniRoute/issues/13178)) — thanks @HouMinXi
- **feat(proxy):** support multiple local core endpoints, one per line ([#13923](https://github.com/diegosouzapw/OmniRoute/pull/13923) — thanks @maxmad64bis)
- feat(providers): **Added Agnes AI (China) as `agnes-cn` pointed at `https://api.agnes-ai.cn/v1`. Keys issued for `apihub.agnes-ai.com` stay on the existing `agnes` card. Live `/v1/models` on that host lists `agnes-3.0-flash` (same id as intl); the CN seed matches 2.0/2.5/3.0 and not retired 1.5.** ([#13399](https://github.com/diegosouzapw/OmniRoute/pull/13399)) — thanks @HouMinXi
- **feat(api):** add per-key `allowAutoCombos` (default `true`) to gate the built-in `auto/*` combos, which previously bypassed a key's `allowedCombos`/`allowedModels`/`blockedModels` restrictions entirely — a restricted key could still reach any model through `auto/best-fast`. Also adds a per-key `catalogScope` (`all`/`combos`/`models`) to control what `/v1/models` advertises, and the dashboard gained an Auto Combos toggle and a catalog scope selector in the API key permissions UI. ([#13670](https://github.com/diegosouzapw/OmniRoute/pull/13670)) — thanks @fouadSalkini
- **feat(sse):** Claude OAuth connections can opt in (per account, Edit connection → Claude section) to Claude Code's lower-priority lane and once-a-week session-limit reset. After the first 5-hour usage-wall 429 carrying `anthropic-ratelimit-unified-slow-offer: treatment`, OmniRoute retries the same account with `anthropic-usage-limit: slow` and keeps sending it until the window resets — the account keeps serving past the limit instead of being cooled down (slot_busy/529 wait the server's `slow-retry-after`, bounded by `slow-max-wait`). With auto-reset on, the wall first tries `POST /api/organizations/{org}/reset_rate_limits` (`juniper_tide`) and retries at full speed when the server grants it. Both default off; nothing is sent before the limit is hit. ([#13074](https://github.com/diegosouzapw/OmniRoute/pull/13074)) — thanks @davidebaraldo
- feat(providers): update Fish Audio for S2.1 Pro Free, validated advanced TTS controls, and provider-scoped persistent voice-clone management. ([#13090](https://github.com/diegosouzapw/OmniRoute/pull/13090)) — thanks @Bl0ck154
- **feat(usage):** redeem **GLM Coding Plan Reset Cards** (`glm` / `glm-cn` / `glmt` / `zai`) from the Provider Limits UI — clear an exhausted 5-hour or weekly coding-plan window before it rolls over, via the new `/api/usage/glm-reset-card` route (`GET` lists, `POST` redeems). List and redeem requests egress through the connection's proxy and honor exclusive-lease isolation; z.ai's `requestId` is reused for retries of an ambiguous (transport-failed) redemption so a lost response cannot double-consume a card (in-memory, best-effort — restart the server and a fresh key is required). Responses are validated fail-closed (HTTP 200 alone is never treated as success), unavailable/expired cards are filtered and the list is sorted by earliest expiry, and the post-redemption quota refresh is best-effort: a refresh failure still reports the successful reset. ([#12754](https://github.com/diegosouzapw/OmniRoute/pull/12754)) — thanks @insoln
- **feat(routing): self-hosted unified OpenAI-compatible entry (`/v1/chat/completions`).** When `OMNIROUTE_SELF_HOSTED_PROVIDERS` (inline YAML) or `OMNIROUTE_SELF_HOSTED_PROVIDERS_FILE` is set, the existing `/v1/chat/completions` route diverts through the self-hosted provider adapters (`open-sse/services/providerAdapters.ts`) — OpenAI / Anthropic / local-compatible — instead of the cloud pipeline. Provider is auto-routed via the `x-omniroute-provider` header, a `provider/model` (or `provider::model`) model prefix, or the first configured provider; upstream credentials stay runtime-only and are stripped from echoed responses. Optional `OMNIROUTE_SELF_HOSTED_API_KEY` guards the entry with `Authorization: Bearer` (reserved for the D5 quota-key system); unset = open loopback/trusted-network route. Upstream failures return the standard OpenAI error shape (including a normalized 502 for unreachable providers). One OpenAI SDK snippet can now traverse multiple self-hosted providers without changing the client. (#RIC-738 / RIC-697 D4) ([#13611](https://github.com/diegosouzapw/OmniRoute/pull/13611)) — thanks @luyuehm
- **feat(routing): deterministic routing strategies for the self-hosted entry (`strategy:` block, M2/RIC-740).** The unified `/v1/chat/completions` entry (RIC-738) now accepts a declarative `strategy:` block — inline in the providers YAML or via `OMNIROUTE_SELF_HOSTED_STRATEGY` / `OMNIROUTE_SELF_HOSTED_STRATEGY_FILE` — expressing five explainable, non-predictive routing policies: blacklist / whitelist (hard filters), cooldown circuit breaker (`consecutiveFailures` + `cooldownMs`), cost-priority (cheapest `costPer1MInput` first), latency-aware (fastest recent average first), and an explicit `fallbackChain` order. The ordered candidate list is the fallback chain: a failed primary (network or non-2xx) falls through to the next candidate, and each failure feeds the breaker. Every response carries `x-omniroute-route-decision` — the one-line "why this model / why not that one" audit trail (D3). A pinned provider rejected by a hard filter returns `400` (never a silent re-route); no eligible providers returns `503` with the full explainable decision. No ML/predict dependency; malformed strategy config returns `500` rather than silently becoming a no-op. (#RIC-740 / RIC-697 D3) ([#13611](https://github.com/diegosouzapw/OmniRoute/pull/13611)) — thanks @luyuehm
- **feat(dashboard):** add sidebar pinned items shortcut section with individual item pin toggle and localStorage persistence ([#12891](https://github.com/diegosouzapw/OmniRoute/pull/12891)) — thanks @ZaimMarzuki
- **feat(providers):** share the existing `api.x.ai/v1/models` discovery config with `xai-oauth` so SuperGrok OAuth connections pick up new Grok ids without a registry seed edit. ([#13518](https://github.com/diegosouzapw/OmniRoute/pull/13518)) — thanks @HouMinXi
- **feat(core):** improve observability for dual-auth fallback execution ([#11828](https://github.com/diegosouzapw/OmniRoute/pull/11828)) — thanks @raheemuddin786
- **feat(chat-admission):** expose admission tunables via dashboard settings ([#12038](https://github.com/diegosouzapw/OmniRoute/pull/12038)) — thanks @oyi77
- **feat(dashboard):** show exact token counts on hover in usage analytics cards and tables ([#12553](https://github.com/diegosouzapw/OmniRoute/pull/12553)) — thanks @ZaimMarzuki
- **feat(providers):** add xKiro ([#12648](https://github.com/diegosouzapw/OmniRoute/pull/12648))
- **feat(reasoning):** opt-in min output budget floor for thinking models ([#12742](https://github.com/diegosouzapw/OmniRoute/pull/12742)) — thanks @patrykkopycinski
- **feat(sse):** retry transient 5xx backend errors with jitter (global-fallback call site) (#12695) ([#13143](https://github.com/diegosouzapw/OmniRoute/pull/13143)) — thanks @KooshaPari
- **feat(services):** add open-wa as a 6th embedded service ([#13222](https://github.com/diegosouzapw/OmniRoute/pull/13222)) — thanks @birdleandro-bit
- **feat(providers):** update Openference free models and add Deyin to compatible agents ([#13378](https://github.com/diegosouzapw/OmniRoute/pull/13378)) — thanks @AnhLead
- **feat(docker):** add self-host compose + 5-minute deploy doc (RIC-739) ([#13639](https://github.com/diegosouzapw/OmniRoute/pull/13639)) — thanks @luyuehm
- **feat(antigravity):** expose physical send telemetry ([#13659](https://github.com/diegosouzapw/OmniRoute/pull/13659)) — thanks @domenicomassafra
- **feat(i18n):** blocking key-completeness gate — every locale carries every en.json key ([#13827](https://github.com/diegosouzapw/OmniRoute/pull/13827))
- **feat(i18n):** new-key gate rejects __MISSING__ markers; skills translate new keys in parallel ([#13996](https://github.com/diegosouzapw/OmniRoute/pull/13996))
- **feat(mitm):** dynamically inject configured models into Antigravity model catalog ([#14006](https://github.com/diegosouzapw/OmniRoute/pull/14006)) — thanks @steve25060
- **feat(cache):** configurable dual-layer semantic caching with Redis/In-Memory vector stores (re-land of #12630) ([#14159](https://github.com/diegosouzapw/OmniRoute/pull/14159)) — thanks @BillyOutlast
### 🐛 Bug Fixes
@@ -895,6 +948,372 @@ _By commits in `091589089c..c0f92ec98a`, author identities consolidated via `.ma
- **fix(guardrails):** skip credential redaction for base64 image data URLs ([#13550](https://github.com/diegosouzapw/OmniRoute/pull/13550)) — thanks @KooshaPari
- **fix(resilience):** increase requestQueue.maxWaitMs default from 15s to 30s ([#13553](https://github.com/diegosouzapw/OmniRoute/pull/13553)) — thanks @KooshaPari
- **fix(docs):** document OMNIROUTE_READY_TIMEOUT_MS and allowlist the test-only DISABLE_IOREG_STRATEGY ([#13692](https://github.com/diegosouzapw/OmniRoute/pull/13692))
- Electron release workflow: the `publish-npm` job now grants `actions: read` to the reusable `npm-publish.yml` it calls (its `publish` job requests it), which is what made GitHub refuse the whole v3.8.50 run at startup and ship the release with zero desktop assets; a `workflow_dispatch` now builds the requested tag instead of the dispatching branch and can skip the npm leg (`publish_npm=false`) when only re-attaching assets ([#11974](https://github.com/diegosouzapw/OmniRoute/pull/11974))
- **fix(api):** `/v1/files` and `/v1/batches` now enforce one ownership rule everywhere — a dashboard session is the instance operator, an API key acts on its own records only, and a record with no owner is denied to every non-session caller. Previously a file or batch whose `api_key_id` was null (a dashboard-session or anonymous upload, or a batch artifact inheriting one) could be read, downloaded, deleted, cancelled or used as a batch input by any other key or by an unauthenticated caller (GHSA-2jm2-mpx8-6523), and `GET /v1/files` / `GET /v1/batches` returned every tenant's records to an anonymous or invalid-bearer caller under the default `REQUIRE_API_KEY=false` (GHSA-m3hp-hq9g-fpmv) — both lists now fail closed with a `401`, and only a dashboard session without a key reads the whole instance. The same shared rule lets the dashboard cancel any batch, not just unowned ones. Behaviour change: the anonymous upload → batch → download flow no longer works without an API key, since a null owner cannot be attributed. Subsumes [#13683](https://github.com/diegosouzapw/OmniRoute/pull/13683) ([#13749](https://github.com/diegosouzapw/OmniRoute/pull/13749)) — thanks @hartmark
- **fix(i18n):** every dashboard catalog other than `pt-BR` (64 locales) went through the same quality review `pt-BR` received in #13885 — each leaf changed by the 2026-09 retranslation was checked against its English source by the translation backend and rewritten where the meaning, placeholders, register or product terminology were off: 73,586 corrections net (75,263 applied, 1,677 that had turned a real translation into the plain English term reverted so the real-translation ratio gate stays where it was). `scripts/i18n/review-locale.mjs` now survives an upstream hiccup (per-batch retries with backoff, skipped batches listed in `_artifacts/i18n-review/<code>.skipped.json`), checkpoints the catalog every 25 batches instead of writing only at the end, and writes leaves whose own key contains a dot (`compliance.eventTypes["apiKey.ban"]`) instead of crashing. ([#14078](https://github.com/diegosouzapw/OmniRoute/pull/14078))
- **fix(auth):** closed the JWT_SECRET bootstrap chain (GHSA-7pq4-8pvv-rx7r). The fresh-install bootstrap gate in `isAuthRequired()` now decides "loopback" from the trusted peer — the token-stamped real TCP peer the custom server writes, the pipeline's own locality verdict, or a real socket — and never from the client-controlled `Host` / `nextUrl.hostname` whenever a stamping server is in front (every supported runtime), so `Host: localhost` from a remote address no longer opens the window; the anonymous first-password write (`POST /api/settings/require-login`) is under the same loopback constraint instead of being open to every network peer, and `managementPolicy` hands its `peerContext` verdict down explicitly. `/api/settings/obsidian` (incl. `/webdav`, which mints reusable WebDAV Basic credentials for a caller-chosen root served before Next.js) joined `ALWAYS_PROTECTED_API_PATHS`, and `enableObsidianVaultSync()` refuses a vault that is, sits inside, or contains the data directory (realpath-resolved), so the WebDAV file service can no longer be pointed at `server.env` / `storage.sqlite` ([#13791](https://github.com/diegosouzapw/OmniRoute/pull/13791))
- **Combo routing:** a context-cache-pinned model that returns `401` now falls through to the normal combo fallback loop instead of terminating the request, allowing other eligible connections or providers to serve it. ([#12818](https://github.com/diegosouzapw/OmniRoute/pull/12818)) — thanks @keeltrace
- **fix(sse):** the Antigravity account picked for a request can now be reserved for that request's streaming lifecycle, so a concurrent retry or the credential handoff cannot re-pick an account already committed to an in-flight stream; a fully leased pool answers with a structured 503 `antigravity_pool_busy` carrying a bounded `Retry-After`. Opt-in behind the new `ANTIGRAVITY_ACCOUNT_LEASE_ENABLED` flag (default off) (#10011) ([#13929](https://github.com/diegosouzapw/OmniRoute/pull/13929)) — thanks @Ardem2025
- **fix(docker):** bump the Bun image to 1.4.0, enable Turbopack on Bun, and port the node image's build memory guards so the `-bun` container builds fit the 16 GB GitHub runner instead of dying with `cannot allocate memory` ([#11719](https://github.com/diegosouzapw/OmniRoute/pull/11719)). Both images now default `OMNIROUTE_BUILD_WORKERS` to `2` (1 page-data worker) against the measured ~4.5 GB per-process RSS budget (#7518/#11663). ([#13140](https://github.com/diegosouzapw/OmniRoute/pull/13140)) — thanks @ozeas
- **security(runtime):** fail closed on hostile thrown values and keep upstream text out of public error surfaces — the chat pipeline now reads rejection metadata through a safe accessor, sanitizes the message before it reaches call logs and console, and projects the failure-usage code onto the bounded public vocabulary; Perplexity's non-streaming quota/upstream error body sanitizes the upstream message and projects the provider-supplied error code; Arena (lmarena) maps every public failure onto a fixed vocabulary instead of echoing the upstream error; Notion's TLS transport failure sanitizes the transport error before it reaches the response body ([#11742](https://github.com/diegosouzapw/OmniRoute/pull/11742)).
- fix(resilience): only clear the combo-level LKGP pin when it names the target that actually failed, so an unrelated target skip under `auto`/`round-robin` no longer discards a valid pin for a healthy provider (#12235) — thanks @abhisheksharma2411
- **fix(sse):** 429 bodies phrased as `N API calls / month` (Cohere trial keys) now classify as `quota_exhausted` instead of a short transient `rate_limit`, so a spent monthly allowance is no longer retried every few seconds for the rest of the billing cycle ([#12252](https://github.com/diegosouzapw/OmniRoute/pull/12252)) — thanks @brick30llc-ctrl
- fix(cache): fold `response_format`/Responses-API `text.format` into the semantic cache signature so a `temp=0` request can no longer be served a stored response body with a different output schema (#12307) ([#12309](https://github.com/diegosouzapw/OmniRoute/pull/12309)) — thanks @amirrezakm
- fix(gemini): preserve response-schema nullability across union flattening so a model with nothing to say returns a valid null instead of the string `"null"` or a fabricated value (#12308) ([#12310](https://github.com/diegosouzapw/OmniRoute/pull/12310)) — thanks @amirrezakm
- **fix(combo):** a priority combo whose steps are different models on one Claude OAuth connection now falls through to the next step — a model-specific 404 or 5xx is scoped to the model instead of retiring the whole account, while a 429 stays account-wide ([#12334](https://github.com/diegosouzapw/OmniRoute/issues/12334)) ([#12340](https://github.com/diegosouzapw/OmniRoute/pull/12340)) — thanks @Kizuno18
- fix(api): restore the `name` field on non-streaming `/v1/responses` `function_call` output items — a plain (non-namespace) tool call's identity restore was blindly applying the `_toolNameMap` alias-table fallback as a `{namespace, name}` object, silently blanking `name` to `undefined` (dropped entirely by JSON.stringify) and leaving Codex unable to dispatch the call, so it re-narrated its intent in a loop instead (#12370) ([#13824](https://github.com/diegosouzapw/OmniRoute/pull/13824))
- **fix(memory):** extracted facts and oversized extraction input are now truncated at a word or sentence boundary instead of at a hard character offset. `sanitizeMatch()` (500-char fact cap) and `capExtractionText()` (64KB extraction-input cap) previously sliced at the exact limit, which could cut a fact mid-word or mid-clause; both now back the cut index off within an 80-char lookback window, preferring sentence-ending punctuation (`. ! ?`), then a plain word boundary, and only falling back to the original hard cut when neither is found — the same pattern already used for `compressToolResults` (#8169) — thanks @LeMonBLOCK ([#12383](https://github.com/diegosouzapw/OmniRoute/pull/12383))
- **fix(chatCore):** stop `executeWithUpstreamStartTimeout` leaking its abortPromise listener onto the long-lived client/stream signal, and stop `mergeAbortSignals` leaking per-attempt abort listeners, so a later hedge cancellation or client disconnect cannot reject an orphaned promise and take the process down (`Error [AbortError]: hedge-cancelled`). The crash guard also absorbs combo abort reasons (`hedge-cancelled`, `combo-per-model-timeout`) and raw string disconnect reasons as a last-resort net ([#12406](https://github.com/diegosouzapw/OmniRoute/pull/12406) — thanks @Beexly)
- **fix(db):** give `conversation_turn_nodes` its own independent retention knob (`retention.conversationTurnNodes`, default 30 days — matching `callLogs` so upgrading changes nothing until an operator overrides it) instead of sharing `callLogs`, and sweep orphaned `agentic_conversations` after the nodes expire (#12453). ([#13344](https://github.com/diegosouzapw/OmniRoute/pull/13344)) — thanks @HouMinXi
- **fix(usage):** Render OpenRouter PAYG account credits as a metered quota when no per-key spending limit is set ([#12468](https://github.com/diegosouzapw/OmniRoute/pull/12468)) — thanks @killer30001000
- **fix(cli):** `omniroute serve` no longer reports "Server did not respond within 60s" for a server that is actually up: the readiness probe's per-attempt timeout now escalates (2s, 4s, 8s, 15s, clamped to the time left in the budget) instead of aborting every attempt at a fixed 2s, so a health route that needs more than 2s for its first response is observed rather than repeatedly torn down. The timeout diagnostic now also states whether the port was accepting connections. ([#12484](https://github.com/diegosouzapw/OmniRoute/pull/12484)) — thanks @dmlanday
- **fix(cli):** `omniroute serve` now checks whether the port is already owned before spawning anything, and reports the conflict with the owning PID plus the two ways out (`omniroute stop`, or `--port`). Previously it handed the conflict to the child process, which died with `EADDRINUSE` and was retried twice on the supervisor's restart budget, printing three identical raw Node stack traces without ever saying that another instance held the port. Because that happened after the pid files were written, the doomed second instance also de-registered the healthy running one, leaving `supervisor/.pid` pointing at the dead starter and `server/.pid` deleted. ([#12485](https://github.com/diegosouzapw/OmniRoute/pull/12485)) — thanks @dmlanday
- **fix(sse):** Codex WebSocket transport (including the app-server) no longer fails to load in the Next.js standalone Docker runtime — the wreq-js loader now resolves its module name dynamically instead of a literal Turbopack could rewrite to an unreachable build-time symlink (#12491) ([#13756](https://github.com/diegosouzapw/OmniRoute/pull/13756)) — thanks @marshalfevzi
- **fix(devin):** treat Devin CLI model ids as literal — never strip or synthesize effort suffixes ([#12492](https://github.com/diegosouzapw/OmniRoute/pull/12492) — thanks @Neuron-Mr-White)
- **fix(command-code):** floor a tiny caller-set `max_tokens` (e.g. `64`) to `MUSE_SPARK_MIN_OUTPUT_TOKENS = 512` for muse-spark ids, detected through the prefix-aware `MUSE_SPARK_PATTERN` so provider-prefixed forms (`meta/muse-spark-1.2-contributor`, `cmd/meta/muse-…`) are covered in both `buildOpenAiBody` (the `/provider/v1/chat/completions` path from #12130) and `buildCommandCodeCliBody` (the `/alpha/generate` fallback) — the hidden server-side reasoning phase can no longer consume the whole output budget and answer HTTP 200 with null content (`out=64, reasoning=61`), mirroring the #11214 mitigation already shipped for opencode-go; a caller that sent no budget is left without one and budgets at or above the floor pass through untouched ([#12497](https://github.com/diegosouzapw/OmniRoute/pull/12497)) — thanks @Stazyu
- Fix `keys regenerate`/`keys reveal` in the CLI to fall back to the dashboard `/api/keys` route when an ID from `keys list` does not exist in the registered-keys store, closing an ID-namespace drift between the two API key families. ([#12520](https://github.com/diegosouzapw/OmniRoute/pull/12520)) — thanks @Gaulnews
- **fix(cli):** Windows dashboard no longer reports Claude Code as `settings_found_binary_unresolved` when npm-global detection fails inside Electron. A failed `npm config get prefix` is no longer cached as permanent `""` (which deleted every npm-derived candidate for the process lifetime), Windows lookup PATH is enriched with npm-prefix / `%APPDATA%\npm` / nvm / `%ProgramFiles%\nodejs`, and stock Node MSI `.cmd` shims under Program Files remain an explicit safety net. Separate from the #7831 `.ps1` / known-path fix for #7774. ([#12563](https://github.com/diegosouzapw/OmniRoute/issues/12563)) ([#12565](https://github.com/diegosouzapw/OmniRoute/pull/12565)) — thanks @drmikecrypto
- **fix(sse):** strip `temperature`/`top_p` on native Codex Responses passthrough so combo `codex-review` traffic no longer 400s with `Unsupported parameter: temperature` ([#12585](https://github.com/diegosouzapw/OmniRoute/pull/12585)) — thanks @fouadSalkini
- **fix(pricing):** saving model pricing from the dashboard no longer fails with a 400 / `[object Object]` — sync-written pricing fields round-trip through PATCH and validation errors surface actionable details ([#12629](https://github.com/diegosouzapw/OmniRoute/pull/12629)) — thanks @wofiporia
- **fix(chat):** requests with null/non-object entries in `messages[]` are now rejected with a clear 400 instead of crashing translators with an HTTP 500 ([#12643](https://github.com/diegosouzapw/OmniRoute/issues/12643)) ([#13755](https://github.com/diegosouzapw/OmniRoute/pull/13755)) — thanks @soroush5
- **fix(sse):** Claude-native context handoffs now land in Anthropic's top-level `system` parameter instead of a leading `role: "system"` message, and the final Claude executor dispatch hoists any remaining leading prompt system/developer messages and relocates directive-only `output_config` envelopes away from `messages[0]`, preventing the `messages.0: use the top-level 'system' parameter` HTTP 400 on model switches ([#12668](https://github.com/diegosouzapw/OmniRoute/pull/12668)). — thanks @insoln
- Honor a model's declared `reasoning_efforts` vocabulary in the reasoning-routing rule gate: a model-scoped or connection-scoped rule forcing `max`/`ultra` is now treated as supported when the model's resolved capabilities list that tier (operator overrides apply to models without a static registry declaration), instead of being rejected by the hardcoded `gpt-5.6-*` regex. Custom OpenAI-compatible providers whose models accept `max` natively (for example Merge Gateway `zai/glm-5.3-flash`, which accepts `low|high|max`) can now use forced-max rules without the request failing with `Reasoning effort 'max' is not supported by the configured target`. ([#12686](https://github.com/diegosouzapw/OmniRoute/pull/12686)) — thanks @woodsonl
- **fix(open-sse):** `reasoning_details[].text` is now promoted to `reasoning_content` even when `reasoning` is also present, so OpenRouter thinking models (GLM-5.3-Flash, DeepSeek-V4-Flash, Kimi K3) no longer lose their thinking traces in clients that only read `reasoning_content` ([#12688](https://github.com/diegosouzapw/OmniRoute/pull/12688) — thanks @thomasmaerz)
- **fix(providers):** xAI requests no longer silently drop an assistant tool call sent in the legacy OpenAI `function_call` shape (instead of `tool_calls[]`) — the call is now translated into the xAI request the same way modern tool calls are (#12692) ([#13753](https://github.com/diegosouzapw/OmniRoute/pull/13753)) — thanks @soroush5
- **fix(providers):** xAI responses no longer report `total_tokens`/`totalTokenCount` as `0` when upstream usage uses the legacy `prompt_tokens`/`completion_tokens` names instead of `input_tokens`/`output_tokens` (#12700) ([#13753](https://github.com/diegosouzapw/OmniRoute/pull/13753)) — thanks @soroush5
- **fix(dashboard):** the Modal provider connection form now shows a Base URL field (placeholder `https://<workspace>--<app>.modal.run/v1`), so bring-your-own-deploy Modal connections can be validated and saved instead of failing outright — the server-side validator already required `providerSpecificData.baseUrl` ([#12704](https://github.com/diegosouzapw/OmniRoute/issues/12704)) ([#12736](https://github.com/diegosouzapw/OmniRoute/pull/12736)) — thanks @gonisulaimann
- **fix(cursor):** Kimi-k3 / kimi-k3-high on the Cursor provider sometimes emit tool calls by imitating the executor's own history narration ("Assistant called tool … with arguments: …") instead of using structured tool calls, so clients received raw narration text plus native generation delimiters with `finish_reason: "stop"` — and the leaked turn compounded on every subsequent request via history re-send; the cursor executor now detects this shape and reassembles it into a structured `tool_calls` entry in both streaming and non-streaming finalization paths, gated on "no structured tool calls yet" so healthy turns are untouched ([#12723](https://github.com/diegosouzapw/OmniRoute/pull/12723)) — thanks @patrykkopycinski
- **fix(sse):** route the `dario` and `9router` request bodies through the internal-marker strip before they are serialized upstream — both executors override `transformRequest()` without calling the base implementation, so the internal context-relay / universal-handoff markers (`_omnirouteSkipContextRelay`, `_omnirouteInternalRequest`, `_omnirouteSkipUniversalHandoff`) reached strict OpenAI-compatible gateways and got the call rejected with HTTP 400 "Unsupported parameter(s)" ([#12729](https://github.com/diegosouzapw/OmniRoute/issues/12729)) ([#12735](https://github.com/diegosouzapw/OmniRoute/pull/12735)) — thanks @gonisulaimann
- **fix(providers):** OpenAI-compatible model discovery now parses per-vendor-route `effort_values` (nested under `vendors.<vendor>.capabilities.reasoning` in `/v1/models`), intersected across vendor routes so a synced level is always honored on every route the model can land on; re-syncing a connection whose catalog declares this shape no longer silently resets the synced `supportedThinkingEfforts`/`defaultThinkingEffort` data ([#12730](https://github.com/diegosouzapw/OmniRoute/pull/12730)) — thanks @woodsonl
- **fix(models):** a cold `GET /v1/models` on a large deployment no longer blocks the event loop for about a second at a time or overruns the 8s cold-build bound: since #12046 the built-in `auto/*` combos resolved catalog metadata for every target of every combo without memoizing or yielding, and they all draw on the same candidate pool, so 720 synced models took the build from ~4s to ~18s. Each distinct target is now resolved once per build, with a yield between misses ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) ([#13678](https://github.com/diegosouzapw/OmniRoute/pull/13678))
- **fix(combo):** a `quota-share` combo whose steps carry no weight now rotates across its targets again instead of sending every request to the first one — the resolver turns an unset weight into 0 and #10881 made 0 mean "disabled", so an all-unweighted combo had no quanta and fell back to definition order; an explicit 0 still disables a target next to weighted siblings ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) ([#13678](https://github.com/diegosouzapw/OmniRoute/pull/13678))
- **fix(codex):** the Codex WebSocket transport now emits a terminal `response.failed` (code `upstream_websocket_closed`) when the upstream socket closes before a terminal response event, instead of ending the client stream as if it had completed normally — preventing silent output truncation and allowing fallback/retry to trigger ([#12737](https://github.com/diegosouzapw/OmniRoute/pull/12737)). — thanks @insoln
- **fix(models):** preserve free-model metadata (`isFree`) discovered live from a provider through synced-model normalization, so free models no longer lose that flag before reaching the UI/consumers ([#12763](https://github.com/diegosouzapw/OmniRoute/pull/12763)) — thanks @keeltrace
- `/v1/models` combos whose merged `capabilities.vision` is `true` now also advertise `input_modalities: ["text","image"]` / `output_modalities: ["text"]` (synced modality intersections keep precedence), so models.dev-shaped clients no longer see a vision combo as text-only. (#12799 — thanks @aref-alapour)
- **fix(db):** the background cleanup scheduler no longer runs a blocking full `VACUUM` after pruning rows (it froze every route, `/healthz` included, for minutes on large databases — 30 s after every start and every 6 h); freed pages are now reclaimed with paced `PRAGMA incremental_vacuum` batches plus a WAL checkpoint, and on `auto_vacuum=NONE` a full VACUUM is deferred to the Storage page's scheduled window via `vacuumScheduler.requestFullVacuum()` ([#12821](https://github.com/diegosouzapw/OmniRoute/issues/12821)) ([#12830](https://github.com/diegosouzapw/OmniRoute/pull/12830)) — thanks @insoln
- **fix(sse):** stop over-escaped tabs from `gpt-5.6-luna-xhigh` corrupting Codex tool-call arguments — `\\t` is now collapsed back to a real tab instead of a literal `\t` text ([#12841](https://github.com/diegosouzapw/OmniRoute/pull/12841)) — thanks @rafacpti23
- **fix(resilience):** a recoverable direct-fetch response-start timeout (`DIRECT_RESPONSE_START_TIMEOUT`) could, in a narrow timer/promise-settlement race, escape as an `unhandledRejection``uncaughtException` and kill the server process — even though `proxyFetch` already retries this exact condition on a fresh socket. Guarded the timer callback so it can no longer fire against an already-settled attempt, and extended the process-level crash guard (already used by the WS/API-bridge servers) to recognize and swallow this code if it ever escapes anyway. Also installs that same guard in the production server entrypoint (`dist/server-ws.mjs`), which never had it even though the dev server already did ([#12861](https://github.com/diegosouzapw/OmniRoute/issues/12861)) ([#13636](https://github.com/diegosouzapw/OmniRoute/pull/13636)) — thanks @insoln / @HouMinXi
- **fix(translator):** Gemini to Claude usage no longer double-counts the cached prompt prefix — `input_tokens` now excludes `cache_read_input_tokens`, matching the Anthropic Messages semantics ([#12863](https://github.com/diegosouzapw/OmniRoute/pull/12863)) — thanks @ThiagoMafra-Integrare
- **fix(sse):** An Anthropic OAuth `403 "Request not allowed"` no longer bans the Claude connection on the first response — it is a per-request refusal on an otherwise healthy token, so it is now classified as the non-terminal `request_rejected` type, the connection is excluded for a growing cooldown (5 min, then 15 min) and only three consecutive refusals with no success in between escalate to `banned`; previously a single such response flipped the only Claude connection to `banned` and every later request was short-circuited with "All 1 connection(s) banned by upstream" until an operator reconnected ([#12859](https://github.com/diegosouzapw/OmniRoute/issues/12859), [#12864](https://github.com/diegosouzapw/OmniRoute/pull/12864) — thanks @insoln)
- fix(cache): never write a truncated completion (`finish_reason: "length"`/`max_tokens`) into the semantic cache — a partial answer cached under a temperature:0 signature was served to every later identical request, permanently returning a mid-sentence reply that no retry cleared (#12885) ([#12885](https://github.com/diegosouzapw/OmniRoute/pull/12885)) — thanks @patrykkopycinski
- **fix(providers):** vLLM connections now advertise the real context window: model discovery reads `max_model_len` instead of falling back to the 128K default ([#12897](https://github.com/diegosouzapw/OmniRoute/pull/12897), closes [#12858](https://github.com/diegosouzapw/OmniRoute/issues/12858)) — thanks @ntdat812
- **fix(combos):** A combo's visibility can be changed through the API again: `updateComboSchema` accepts `isHidden`, so a visibility-only update is no longer rejected as empty and a mixed update no longer drops it ([#12898](https://github.com/diegosouzapw/OmniRoute/pull/12898), closes [#12836](https://github.com/diegosouzapw/OmniRoute/issues/12836)) — thanks @ntdat812
- fix(guardrails): Vision Bridge now extracts and replaces base64 images nested inside a `tool_result.content` array (the shape Claude Code uses), not just top-level content parts — previously these requests silently reached a vision-incapable provider and returned a 400. Also resolves a provider prefix that has no known alias (e.g. a custom OpenAI-compatible connection's model prefix) to the node id its credentials are actually stored under, instead of discarding the operator's fixed model and falling back to a no-auth candidate that fails (#12903) — thanks @initguru
- fix(sse): inject the operator's global system prompt once, after request translation, for every target shape (Claude, Gemini, OpenAI Responses, OpenAI/Codex messages) instead of before translation — the pre-translation injection could be lost, repositioned, or duplicated 2-3× depending on the target format, and never reached the Responses API path at all. The new `injectSystemPromptPostTranslation()` is idempotent per request via a non-enumerable marker (#12904) — thanks @initguru
- **fix(sse):** strip echoed system/directive preamble on /v1/messages responses and preserve large analysis/summary blocks in systemPreambleStripper to stop autocompact empty-response ([#12905](https://github.com/diegosouzapw/OmniRoute/pull/12905)) — thanks @initguru
- **fix(sse):** thread the client's thinking intent into the non-streaming translation path so the same request answered with `stream:false` no longer leaks a thinking block that `stream:true` withholds ([#12905](https://github.com/diegosouzapw/OmniRoute/pull/12905)) — thanks @initguru
- **fix(sse):** gate thinking block emission on requestedThinking (streaming + non-stream) and flush reasoning-only responses as text to stop reasoning leak, autocompact loops, and 502 ([#12905](https://github.com/diegosouzapw/OmniRoute/pull/12905)) — thanks @initguru
- **fix(sse):** make the system-preamble stripper opt-in (`OMNIROUTE_STRIP_SYSTEM_PREAMBLE=1`) and flush both preamble strippers at stream end, so English-prose heuristics no longer delete a legitimate section of every openai→claude reply and an unterminated echo block no longer reaches the client as an empty message ([#12905](https://github.com/diegosouzapw/OmniRoute/pull/12905)) — thanks @initguru
- **fix(sse):** make the direct response-start timeout reasoning-aware — detect reasoning_effort high/max in the body and raise the ceiling to 180s to stop 504 on high-effort TTFB ([#12906](https://github.com/diegosouzapw/OmniRoute/pull/12906)) — thanks @initguru
- **fix(sse):** retry 0-byte empty_response 502 like STREAM_EARLY_EOF to stop autocompact 502 — RETRYABLE_STREAM_EMPTY_CODES + shouldRetryStreamEarlyEof wiring ([#12906](https://github.com/diegosouzapw/OmniRoute/pull/12906)) — thanks @initguru
- **fix(compat):** preserve GPT and Claude Code tool-call history when translating OpenAI Responses API requests to Chat Completions — `role:"tool"` items and role-based assistant `tool_calls` are no longer dropped, fixing incomplete multi-turn history ([#12909](https://github.com/diegosouzapw/OmniRoute/pull/12909)) — thanks @initguru
- **fix(usage):** finalize semantic cache hits by exact request id — `checkSemanticCache` now uses `finalizePendingScope(pendingScope, ...)` instead of an ambiguous (model, provider, connectionId) tuple, fixing wrong-request finalization when connectionId is null or multiple requests are in flight on the same connection ([#12910](https://github.com/diegosouzapw/OmniRoute/pull/12910)) — thanks @initguru
- **fix(sse):** the Codex Responses WebSocket bridge now fails fast to another eligible account instead of queueing behind a saturated one, releasing its per-account lease exactly once when the session ends ([#12911](https://github.com/diegosouzapw/OmniRoute/pull/12911) — thanks @initguru). This also fixes `accountSemaphore`'s `maxQueueSize: 0` handling, which previously behaved as an unbounded queue instead of failing over immediately — benefiting every caller that configures `queueDepth: 0` (for example combo routing), not just the Codex WS bridge.
- **fix(chatcore):** block a client's own duplicate retry (same idempotency key) from opening a second upstream turn while the first is still in flight, returning `409 turn_in_progress` instead of wasting quota on a redundant execution ([#12912](https://github.com/diegosouzapw/OmniRoute/pull/12912)) — thanks @initguru
- fix(sse): bound streams that keep sending raw upstream bytes forever without ever emitting a terminal event — a new `STREAM_ACTIVE_TIMEOUT_MS` watchdog (default 1260000ms/21min — the largest per-model `timeoutMs` in the registry plus a one-minute margin, `0` disables) tracks the stream's total lifetime independently of the existing byte-stall watchdog, so a continuously-active non-terminal stream can no longer occupy a connection indefinitely (#12913) — thanks @initguru
- **fix(providers):** correct Magnific API key validation, which reported every valid key as invalid due to a GET probe against a POST-only endpoint (#12927) ([#13754](https://github.com/diegosouzapw/OmniRoute/pull/13754)) — thanks @hubo1989
- **fix(routing):** Auggie now fails over to the next combo model instead of returning the quota-exhausted CLI warning as a successful reply (#12949) ([#13751](https://github.com/diegosouzapw/OmniRoute/pull/13751)) — thanks @honeypot55
- **fix(combo):** A weighted combo whose every target was excluded before dispatch by a resilience timer (model lockout, open circuit breaker, provider cooldown) now answers `503` `all_targets_cooling_down` with `Retry-After` set to the earliest exclusion to lapse, the excluded targets and reasons in `diagnostics.excluded`, a `wait` recovery hint, and a `[COMBO]` warning naming the reasons; previously the pool was dropped silently and the host answered `404 "Combo has no executable targets"` (recovery hint "switch combo / reconnect the missing providers") for a pool that was configured, connected and merely cooling down — which clients such as Claude Code render as "this model may not exist". A pool with nothing to run keeps its `404` ([#12954](https://github.com/diegosouzapw/OmniRoute/issues/12954), [#12956](https://github.com/diegosouzapw/OmniRoute/pull/12956) — thanks @insoln)
- **fix(resilience):** A `5xx` model-lockout failure — a transport error (`terminated`, `EHOSTUNREACH`, connect timeout), an upstream server error, or OmniRoute's own synthesized `502` from quality validation — now locks only the exact provider/connection/model tuple instead of the quota family; previously one empty response on a single `gpt-5.6-*` model removed every `gpt-5*` model of the codex connection from routing for 230 min (escalating) while its quota was untouched. Quota statuses (`429`/`403`/`402`) keep the family scope; success-decay and the Model Cooldowns card now handle exact-scope locks too ([#12955](https://github.com/diegosouzapw/OmniRoute/issues/12955), [#12957](https://github.com/diegosouzapw/OmniRoute/pull/12957) — thanks @insoln)
- **fix(providers):** GitLab Duo Retest and chat requests now fall back to the public Code Suggestions endpoint for ANY `direct_access` 403 (not only the "direct connections are disabled" tenant-config message), and surface the real upstream error body instead of a generic "Access denied" when both endpoints reject the token (#12958) ([#13758](https://github.com/diegosouzapw/OmniRoute/pull/13758)) — thanks @Rahulsharma0810
- **fix(sse):** stop misclassifying a truncated Anthropic-compatible `max_tokens` probe response (`content:[{type:"text",text:""}]`) as an empty upstream response (#12968) ([#13771](https://github.com/diegosouzapw/OmniRoute/pull/13771)) — thanks @pranay-gpt
- **fix(images):** image-combo legs now fall back when an upstream provider returns HTTP 2xx with an empty or malformed image payload. `fetchImageEndpoint` previously normalized any successful HTTP response to `success: true` (`data.data || []`), so `executeImageCombo` stopped on the first leg and handed the client an image-less 200. The OpenAI-compatible normalization now requires at least one usable item (non-empty `b64_json` or `url`) in `data[]`; an empty/malformed 2xx becomes a retryable 502 with a sanitized error, so priority image combos advance to the next leg. Valid payloads and direct image-model requests are unchanged. [#12982](https://github.com/diegosouzapw/OmniRoute/pull/12982) — thanks @tiangao88
- **fix(claude):** forward client-negotiated `thinking-binding-controls-2026-08-01` and `thinking-display-updates-2026-08-18` betas so Fable 5.1 `thinking.block_binding` / `thinking.display` requests are no longer rejected upstream with `Extra inputs are not permitted` ([#12989](https://github.com/diegosouzapw/OmniRoute/pull/12989)) — thanks @fidelix
- **fix(cli):** skip the POSIX CLI path lookup during Windows autostart setup, preventing a bogus path error before successful enablement ([#12993](https://github.com/diegosouzapw/OmniRoute/pull/12993)) — thanks @zachary-frederich
- **fix(i18n):** stop the "Saving..." hang on `/dashboard/combos``BuilderIntelligentStep.tsx`'s exploration-rate hint called `t("explorationRateHint")` with no ICU values even though the key requires `{percent}`, and next-intl's default error handling throws `FORMATTING_ERROR` for that call, unmounting the whole builder step and looking like a silent save hang. Fixed across all three affected call sites (`BuilderIntelligentStep.tsx`, `AgentBridgeMaintenanceCard.tsx`, `RawJsonPanel.tsx`), not just the one that was reported ([#12995](https://github.com/diegosouzapw/OmniRoute/pull/12995)). — thanks @hartmark
- **fix(api):** restore the MCP `namespace` field on streamed and non-streamed Responses tool calls in follow-up turns of a session that don't re-declare their `type:"namespace"` tools (#12996) ([#13769](https://github.com/diegosouzapw/OmniRoute/pull/13769)) — thanks @rolemiaster
- **fix(db):** add an opt-in automatic sweep for terminal batch checkpoints and expired file content, behind the new `BATCH_AND_FILE_AUTO_CLEANUP_ENABLED` feature flag (default off) — `batch_item_checkpoints` had grown to 182K rows / 5.25 GB with no batch ever explicitly deleted by an operator: the manual `delete-completed` route existed but nothing called it automatically, and it never covered failed/cancelled/expired batches either. Extracts a shared `deleteBatchesMatching()` (age-gated, every terminal status, keeping `deleteCompletedBatches()`'s exact existing contract) and adds `pruneExpiredFiles()` for uploaded file content past its own `expires_at` (1,874 rows / 5.19 GB observed live, most long past expiry). With the flag off (the default), every existing install keeps this data exactly as before; an operator must opt in via the dashboard or `BATCH_AND_FILE_AUTO_CLEANUP_ENABLED=true` before the sweep deletes anything ([#12999](https://github.com/diegosouzapw/OmniRoute/pull/12999)). — thanks @hartmark
- fix(providers): stop `@omniroute/opencode-plugin` combo context limits from downgrading to the raw `Math.min(member)` lower bound after a restart — the static catalog now honors the server-computed `computed_context_length` (mirroring the dynamic hook), and a background refresh with a degraded `/api/combos` response backfills the field from the last-known-good disk snapshot instead of overwriting it (#13000) ([#13759](https://github.com/diegosouzapw/OmniRoute/pull/13759)) — thanks @morpheus9393
- **fix(streaming):** allow a per-provider override of the fetch-start (headers-wait) timeout cap so providers that buffer the full generation before the first byte (e.g. `command-code`, `opencode-go`) are not cut off at the global 110s cap; the same two entries also gain a reasoning-safe `requestDefaults.maxTokens` of 16384 so thinking models such as `z-ai/glm-5.3-flash` are not cut off mid-reasoning ([#13002](https://github.com/diegosouzapw/OmniRoute/pull/13002)) — thanks @alvinveroy
- **fix(resilience):** An apikey-category 429 whose body explicitly says a long-window quota was exhausted no longer skips the quota cache — `shouldPreserveQuotaSignals()` (`open-sse/services/quotaResetParsing.ts`) gained an `errorText` parameter in the #6638 fix, but only one of its two call sites was updated: `checkFallbackError()` passes the upstream body while `shouldMarkAccountExhaustedFrom429()` (`open-sse/services/accountFallback.ts`) still called it with the provider alone. With `errorText` undefined the helper's `Boolean(errorText) && looksLikeQuotaExhausted(errorText)` branch can never be true, so for every apikey-category provider without per-model quotas the connection was never marked quota-exhausted. `errorText` is now threaded through the helper and passed at the `src/sse/handlers/chat.ts` call site. Plain rate limits (`Rate limit exceeded, retry in 20s`, `Too Many Requests`) still fall through to the short generic cooldown. Regression guard: `tests/unit/quota-signal-errortext-threading.test.ts`. ([#13008](https://github.com/diegosouzapw/OmniRoute/pull/13008)) — thanks @Rick7C2
- **fix(cli):** `omniroute mcp restart` no longer 404s — the missing `POST /api/mcp/restart` route now exists — and new `omniroute mcp enable`/`mcp disable [--transport]` subcommands give the CLI a way to turn the MCP server on without the dashboard ([#13012](https://github.com/diegosouzapw/OmniRoute/issues/13012)) ([#13770](https://github.com/diegosouzapw/OmniRoute/pull/13770)) — thanks @ricardusx
- **fix(mitm):** add catch-all (*) model mapping fallback for Agent Bridge ([#13013](https://github.com/diegosouzapw/OmniRoute/pull/13013)) — thanks @tuandinh0801
- **fix(providers):** Antigravity connection Retest probes Cloud Code envelope ([#13015](https://github.com/diegosouzapw/OmniRoute/pull/13015)) — thanks @tuandinh0801
- **fix(dev):** allow Ctrl+C to promptly kill dev server by closing active connections ([#13020](https://github.com/diegosouzapw/OmniRoute/pull/13020)) — thanks @tuandinh0801
- **fix(skills):** repair nested malformed skill-tool schemas (bare property maps, boolean `required: true`) for OpenAI-compatible providers, not just the schema root (#13022) ([#13772](https://github.com/diegosouzapw/OmniRoute/pull/13772)) — thanks @ftevxk
- **fix(sse):** reasoning replay now works for Chat Completions and Anthropic Messages clients on Responses-API reasoning targets such as `opencode-go/deepseek-v4-flash`: plain (non-tool-call) assistant turns are captured against the same normalized transcript the read side digests (the Responses body carries `input`, not `messages`, so the write side digested only the assistant message instead of the full transcript and every replay missed), and the replay pass runs on the OpenAI pivot for every source format, so Anthropic Messages clients are replayed too. Fixes the intermittent `400 The reasoning_text in the thinking mode must be passed back to the API` from Console Go for clients that drop `reasoning_content` ([#13031](https://github.com/diegosouzapw/OmniRoute/pull/13031)) — thanks @jmche
- **fix(friendliai):** FriendliAI's free-tier credit-exhaustion 403 (`{"detail":"You've exhausted all your credits..."}`) is now classified as `QUOTA_EXHAUSTED` instead of `AUTH_ERROR`, so omniroute treats it as depleted credits rather than a credential problem ([#13040](https://github.com/diegosouzapw/OmniRoute/pull/13040)) — thanks @turbolego
- fix(oauth): kimi-coding/github device-flow `pollToken` no longer rejects with `TypeError: Body is unusable` when the token endpoint returns a non-JSON error page (CDN/anti-bot/proxy interstitial) — the body is now read once and parsed, preserving the graceful `invalid_response` fallback instead of a generic 500 (#13046 — thanks @ysntony)
- **fix(providers):** lazily load `chatgpt-web-codex` admin helpers in `PUT /api/providers/[id]`, mirroring #12355's exact pattern for the sibling `POST` route — a source-inspection test pins the import-graph invariant, since Turbopack's client-bundle boundary can't be exercised from the test harness directly ([#13071](https://github.com/diegosouzapw/OmniRoute/pull/13071)). — thanks @hartmark
- **fix(providers):** honor an operator-set endpoint override (`PUT /api/provider-models`) for a local model whose own `/v1/models` response carries no capability data of its own (llama.cpp included) — the override previously only took effect when a matching `customModels` entry already existed, so declaring a brand-new local model as embeddings-capable silently did nothing. `updateCustomModel()` gains an opt-in `createIfMissing` mode; every other caller's existing contract is unchanged. Also accepts a collapsed single-slash id for path-based local models ([#13078](https://github.com/diegosouzapw/OmniRoute/pull/13078)). — thanks @hartmark
- fix(quota): keep Kiro active while any _freetrial pool has quota (#13088) ([#13324](https://github.com/diegosouzapw/OmniRoute/pull/13324)) — thanks @giauphan
- **fix(routing):** round-robin combos now show up in Combo Studio's Live dashboard — they were completing successfully but never publishing the attempt/success/failure events the dashboard listens for (#13089) ([#13776](https://github.com/diegosouzapw/OmniRoute/pull/13776)) — thanks @adityadwi21
- **fix(sse):** stop rejecting a Responses API `tool_choice.type: "custom"` (e.g. Codex CLI forcing `functions__exec`) with a 400 `unsupported_feature` error (#13122) ([#13775](https://github.com/diegosouzapw/OmniRoute/pull/13775)) — thanks @phamtienduceng-eng
- **fix(translator):** recognize `tool_choice.type: "custom"` in Responses→Chat translation and propagate custom tool names (including namespace-flattened ones) across both the streaming and non-streaming provider legs, so non-streaming Responses clients get `custom_tool_call`/raw `input` instead of `function_call`/JSON arguments ([#13128](https://github.com/diegosouzapw/OmniRoute/pull/13128)) — thanks @ducphamtien-fonos
- **fix(providers):** remove the `chipotle`/`pepper` provider — its upstream (`amelia.chipotle.com`) now 404s on every route and is fully decommissioned (#13131, #4037) ([#13913](https://github.com/diegosouzapw/OmniRoute/pull/13913)) — thanks @Falco20100
- **fix(db):** authenticated `GET /api/db/health` polls no longer run a SQLite `quick_check`. The health dashboard polls every 15 seconds, and that scan ran synchronously on the request-serving event loop, blocking it for the length of the scan. Reference and state checks still run, and explicit repair requests keep integrity checks unless `OMNIROUTE_SKIP_DB_HEALTHCHECK=1` is set ([#13149](https://github.com/diegosouzapw/OmniRoute/pull/13149)) — thanks @cryptiklemur
- **fix(sse):** fail over once to a sibling connection on stream early EOF (the original `STREAM_EARLY_EOF` 502 is kept when no sibling can serve the request), gated behind `STREAM_EARLY_EOF_SIBLING_FAILOVER_ENABLED` (default off) ([#13153](https://github.com/diegosouzapw/OmniRoute/pull/13153)) — thanks @maxmad64bis
- **resilience:** a Cloudflare managed challenge (`cf-mitigated: challenge` / challenge HTML on 403) is classified as a fingerprint rejection and retried on another account/transport instead of banning the connection (#13161 ([#14090](https://github.com/diegosouzapw/OmniRoute/pull/14090)) — thanks @anhtran-ai)
- **fix(cli):** OpenCode config generator preserves catalog display names — custom names win, then `display_name`/native `name` (with the `owned_by/` prefix stripped once), then a readable label for `auto/*` ids, instead of always showing the raw model id ([#13168](https://github.com/diegosouzapw/OmniRoute/pull/13168)) — thanks @domenicomassafra
- **fix(sse):** Fable 5 and 5.1 keep their prompt-cache prefix across mid-conversation system messages. OmniRoute treated only Opus as capable, so every Fable system turn was hoisted into the top-level system prompt and moved the cached prefix, which reported `system_changed` from turn 2 on. Fable now has its own mid-conversation-system capability path, and `context-1m` stays limited to Opus so Fable is never sent an unrelated beta header ([#13173](https://github.com/diegosouzapw/OmniRoute/pull/13173)) — thanks @cryptiklemur
- **fix(combo):** auto-resume a pinned native Codex turn on a healthy sibling connection or model when the pinned provider becomes unavailable for a model-scoped reason (quota, model lockout) instead of failing the turn outright — provider-wide circuit-breaker/cooldown state, pending tool calls, opaque continuation state, and partial streams still block resume, and at most one auto-resume happens per logical turn ([#13180](https://github.com/diegosouzapw/OmniRoute/pull/13180)) ([#14162](https://github.com/diegosouzapw/OmniRoute/pull/14162)) — thanks @mdigitalbh81
- **fix(evals):** an eval case whose model call errored is no longer scored as passed — a case that never reached a model has no measured behaviour to grade ([#13201](https://github.com/diegosouzapw/OmniRoute/pull/13201)) — thanks @aaustinhuang / @dajiaohuang
- **fix(evals):** the eval runner now sends `x-omniroute-compression: off` and `x-omniroute-no-memory: true` on every case, so a graded case measures the model instead of the operator's injected output style, retrieved memory and `memory_*` tools ([#13139](https://github.com/diegosouzapw/OmniRoute/issues/13139), [#13206](https://github.com/diegosouzapw/OmniRoute/pull/13206)) — thanks @aaustinhuang / @dajiaohuang
- **fix(combos):** stop dropping live keys and persisting dead ones ([#13217](https://github.com/diegosouzapw/OmniRoute/pull/13217)) — thanks @maxmad64bis
- **fix(db):** the WAL checkpoint busy counter reported by `/api/monitoring/health` now survives restarts — busy checkpoints are counted in memory and persisted from the next clean maintenance tick or at shutdown, never with a write while the database is contended ([#13218](https://github.com/diegosouzapw/OmniRoute/pull/13218)) — thanks @maxmad64bis
- **fix(vertex):** preserve Claude prompt-cache breakpoints for Vertex and Vertex Partner, use the documented five-minute ephemeral TTL by default, and forward cache usage metadata through streaming responses ([#13220](https://github.com/diegosouzapw/OmniRoute/pull/13220)) — fixes #13219 — thanks @SIGTERM-015
- **fix(mcp):** load the audit `better-sqlite3` driver via the shared `runtimeRequire()` helper instead of `createRequire(import.meta.url)`, which broke when the Next.js standalone build emits the module as a CommonJS chunk ([#13223](https://github.com/diegosouzapw/OmniRoute/pull/13223)) — thanks @chatchawan-simplewish
- **fix(sse):** classify a missing Playwright Chromium install on the Z.ai web transport as an actionable 503 host/config cooldown instead of a generic 502 that trips the provider circuit breaker (#13232) ([#13777](https://github.com/diegosouzapw/OmniRoute/pull/13777)) — thanks @oleksandr1811
- **fix(combos):** testing a combo aborts in-flight probes when the client disconnects instead of probing on after the dashboard navigates away ([#13279](https://github.com/diegosouzapw/OmniRoute/pull/13279)) — thanks @maxmad64bis
- **fix(quota):** in-process routing and quota caches (quality tracker, saturation and rate-limit header caches, quota-fetcher cache, learned rate limits, account buckets) are now size-bounded through one shared `boundedMap` — caps sit far above normal deployments, evictions are logged once per minute per cache instead of per entry, and state whose loss would change routing (live saturated quota buckets, evaluator quality scores) is never evicted ([#13280](https://github.com/diegosouzapw/OmniRoute/pull/13280)) — thanks @maxmad64bis
- **fix(call-logs):** call-log error types are now a versioned vocabulary (`ERROR_TYPE_CONTRACT v1`) with explicit `unknown` instead of ambiguous `null`, and free-text history reads back as `unclassified` ([#13281](https://github.com/diegosouzapw/OmniRoute/pull/13281)) — thanks @maxmad64bis
- Fixed tests leaving temp `DATA_DIR` folders behind on Windows by closing the SQLite handle before removing the directory (#13290). ([#13292](https://github.com/diegosouzapw/OmniRoute/pull/13292)) — thanks @anhtahaylove
- **fix(providers):** honor the selected Alibaba workspace and region endpoints for custom embedding and `qwen3-rerank` requests ([#13293](https://github.com/diegosouzapw/OmniRoute/pull/13293)) — thanks @xiaoyaner0201
- **fix(backend):** error messages are no longer truncated after a path — `redactErrorPaths` treated any slash-bearing span as an unequivocal filesystem path and swallowed the rest of the line, so the image-model 400 lost the `Use POST /v1/images/generations instead.` hint it exists to give, and a redacted diagnostic lost its ` with api_key='[REDACTED]'` tail. Only a Windows path, file URI or known POSIX root with no determinable end swallows the line now ([#13144](https://github.com/diegosouzapw/OmniRoute/issues/13144)) ([#13295](https://github.com/diegosouzapw/OmniRoute/pull/13295)) — thanks @abhisheksharma2411 / @ggiak
- **fix(providers):** Fetch Qwen and Alibaba Token Plan model catalogs through their authenticated console gateways, with public-only URL validation and local-catalog fallback when discovery is unavailable. ([#13299](https://github.com/diegosouzapw/OmniRoute/pull/13299)) — thanks @JxnLexn
- **fix(db):** defer `process.exit(0)` on graceful shutdown by one macrotask, avoiding a Windows-only libuv abort when the sql.js fallback driver has a statement in flight (#13306) ([#13778](https://github.com/diegosouzapw/OmniRoute/pull/13778)) — thanks @anhtahaylove
- **fix(cli):** `omniroute serve` now surfaces a fatal `[STARTUP] Fatal: ...` boot diagnostic (e.g. a DB driver init failure) to the console immediately, even without `--log`, instead of only when the process later crashes or restarts (#13314) ([#13779](https://github.com/diegosouzapw/OmniRoute/pull/13779)) — thanks @Orion1943
- **fix(sse):** A transient error on a round-robin combo target no longer resets its concurrency limit to 3, so a target capped at 1 is not sent three queued requests at once when its cooldown ends ([#13320](https://github.com/diegosouzapw/OmniRoute/pull/13320)) — thanks @datrixlab
- **fix(resilience):** Rate-limit reset headers in RFC 3339 form (Anthropic) and with fractional seconds (`2m59.56s`) are parsed correctly, instead of an Anthropic reset seconds away throttling the connection for about 34 minutes ([#13321](https://github.com/diegosouzapw/OmniRoute/pull/13321)) — thanks @datrixlab
- **fix(cli):** `contexts export --no-secrets` now leaves the access tokens and API keys out, and `chat --no-history` and `serve --no-recovery` take effect; all three flags were accepted and ignored ([#13322](https://github.com/diegosouzapw/OmniRoute/pull/13322)) — thanks @datrixlab
- **fix(providers):** Switching "Allow Private Provider URLs" off in the dashboard now takes effect when `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS=true` is set in the environment ([#13323](https://github.com/diegosouzapw/OmniRoute/pull/13323)) — thanks @datrixlab
- **fix(cli):** `omniroute restart` now comes back on the port set by `PORT` (shell or `<DATA_DIR>/.env`) instead of always 20128, like `serve` and `dashboard` ([#13327](https://github.com/diegosouzapw/OmniRoute/pull/13327)) — thanks @datrixlab
- **fix(redis):** Warmup circuit-breaker keys now honor `REDIS_KEY_PREFIX` like every other OmniRoute Redis key, instead of always using `omniroute:warmup:cb:` ([#13328](https://github.com/diegosouzapw/OmniRoute/pull/13328)) — thanks @datrixlab
- **fix(api):** Setting `DISABLE_SQLITE_AUTO_BACKUP=true` no longer makes the API-key rate limiter and auth cache skip Redis, which let every replica enforce the full per-key limit on its own ([#13329](https://github.com/diegosouzapw/OmniRoute/pull/13329)) — thanks @datrixlab
- **fix(translator):** `tool_choice: "none"` is now sent to Claude-format providers as `{ type: "none" }` (and back to OpenAI as `"none"`) instead of `auto`, so the model can no longer call tools the client switched off ([#13333](https://github.com/diegosouzapw/OmniRoute/pull/13333)) — thanks @datrixlab
- **fix(translator):** Gemini tool results sent without an `id` (the usual case, since OmniRoute's own Gemini responses never emit one) now reach the model instead of being replaced by an empty result, on the Gemini, Antigravity and `/v1beta` request paths ([#13334](https://github.com/diegosouzapw/OmniRoute/pull/13334)) — thanks @datrixlab
- **fix(translator):** An image returned inside a Claude `tool_result` (Read on a PNG, an MCP screenshot) is now sent to Gemini as an image part instead of base64 text ([#13335](https://github.com/diegosouzapw/OmniRoute/pull/13335)) — thanks @datrixlab
- **fix(combo):** stop retrying a malformed-request-shape error across the entire fallback chain — a 400/422 that fails one target for a `kind: "model"` reason (exact status + error message match) will fail identically on every other target too, so a bad payload previously burned the full `MAX_GLOBAL_ATTEMPTS` budget instead of failing fast. Observed live: 41-44 identical combo decisions over 13+ minutes for a single request. Trips only on 3 consecutive identical `kind: "model"` failures, leaving transient and provider-side errors untouched ([#13338](https://github.com/diegosouzapw/OmniRoute/pull/13338)). — thanks @hartmark
- **fix(db):** `getDbInstance()` now closes the probe and primary SQLite connections on every failed initialization path, not just the happy path, fixing a handle leak that caused `EPERM` on Windows teardown. ([#13303](https://github.com/diegosouzapw/OmniRoute/issues/13303)) ([#13342](https://github.com/diegosouzapw/OmniRoute/pull/13342)) — thanks @voidstackloop
- **fix(dispatch):** strip every `_omniroute*` internal marker at the shared pre-serialization chokepoint (`cliFingerprints.ts`) instead of a hand-maintained per-key allowlist, so internal routing/handoff markers (e.g. `_omnirouteSkipContextRelay`, `_omnirouteResponsesStore`) can no longer leak into serialized upstream request bodies and draw `400 Extra inputs are not permitted` from strict Anthropic-compatible gateways ([#12729](https://github.com/diegosouzapw/OmniRoute/issues/12729), fixed in [#13355](https://github.com/diegosouzapw/OmniRoute/pull/13355)) — thanks @patrykkopycinski
- **fix(providers):** stop zed-hosted `claude-haiku-4-5` extended-thinking requests from inflating `max_tokens` past the model's real 64000 output cap (#13364) ([#13780](https://github.com/diegosouzapw/OmniRoute/pull/13780)) — thanks @ThiagoMafra-Integrare
- **fix(providers):** gemini-web no longer drops the system instruction on single-turn requests or the tool contract when a client system message is present, and switches to an atomic composer insert so embedded newlines can't submit the message early (#13380) ([#13784](https://github.com/diegosouzapw/OmniRoute/pull/13784)) — thanks @formilw
- **fix(providers):** `gemini-web` now attempts to select and verify the requested Gemini UI mode (and Extended Thinking) before answering, and fails closed with a clear 400 instead of silently running the account default under a mismatched model label (#13381) ([#13919](https://github.com/diegosouzapw/OmniRoute/pull/13919)) — thanks @formilw
- **fix(db):** stop routine connection-backoff auto-recovery from busting the entire `/v1/models` response cache, which was causing intermittent 75-120s/502 responses on deployments routing many providers (#13389) ([#13783](https://github.com/diegosouzapw/OmniRoute/pull/13783)) — thanks @RaviTharuma
- **fix(mitm):** bound per-request SSE transcript retention to 1 MiB and stop the upstream read when the downstream disconnects — handler-side `collected` strings grew without bound before the inspector clamp, and abandoned streams kept the reader alive for the full upstream lifetime ([#13395](https://github.com/diegosouzapw/OmniRoute/issues/13395)) ([#13702](https://github.com/diegosouzapw/OmniRoute/pull/13702)) — thanks @oyi77
- **fix(opencode-plugin):** The stale disk-cache fallback warning now reports the snapshot's age (`using stale disk cache (N models, age 168h)`), matching the existing warm-startup log. Previously a week-old catalog was indistinguishable from a five-minute-old one, so silent model drift went unnoticed. (#13426) — thanks @RaviTharuma
- **fix(api):** `GET /api/logs/export` now streams rows from the database itself (a cursor for `proxy-logs`, a LIMIT-bounded generator for `call-logs`/`request-logs`) instead of buffering every matching row in memory before serializing, fixing a V8 heap OOM on large tables ([#13428](https://github.com/diegosouzapw/OmniRoute/pull/13428), [#13123](https://github.com/diegosouzapw/OmniRoute/issues/13123)) — thanks @KooshaPari. **Breaking:** the `limit` query param now defaults to 10,000 rows (max 50,000) — exports that previously returned every matching row are silently truncated (with `"capped":true,"totalAvailable":<n>` in the response) unless the caller passes a larger explicit `limit`.
- **fix(compression):** stop lite compression from dropping a `role:"tool"` message when it is byte-identical to the previous message, which orphaned a `tool_call_id` and triggered upstream 400 errors on parallel tool calls (#13429) ([#13787](https://github.com/diegosouzapw/OmniRoute/pull/13787)) — thanks @tolgaaksoy
- **fix(sse):** frame post-keepalive `/v1/responses` stream errors with a top-level `type` field so Responses clients (Codex) surface the real upstream error instead of reporting "stream disconnected before completion" (#13431) ([#13785](https://github.com/diegosouzapw/OmniRoute/pull/13785)) — thanks @andrea-kingautomation
- **fix(db):** reconcile `auto_vacuum` drift between the configured INCREMENTAL mode and the live SQLite pragma — detected at startup and reconciled out-of-request by the vacuum scheduler, which now also runs a bounded `PRAGMA incremental_vacuum` reclaim instead of an unconditional full `VACUUM` once INCREMENTAL is actually in effect (#13432) ([#13786](https://github.com/diegosouzapw/OmniRoute/pull/13786)) — thanks @tolgaaksoy
- **fix(models):** Reconcile provider dashboards with confirmed authoritative live catalogs, excluding retired built-in/imported rows while preserving manual custom models and partial-catalog fallbacks. ([#13434](https://github.com/diegosouzapw/OmniRoute/pull/13434)) — thanks @JxnLexn
- **fix(build):** the production build no longer breaks when a client component reaches a server-only module, and the client-bundle guard now discovers server-only modules instead of matching a fixed list ([#13436](https://github.com/diegosouzapw/OmniRoute/pull/13436)) — thanks @maxmad64bis
- **fix(models):** return a retryable 503 with Retry-After instead of a 500 when the first catalog build outlasts its time bound ([#13438](https://github.com/diegosouzapw/OmniRoute/pull/13438)) — thanks @maxmad64bis
- **fix(combo):** new opt-in flag `PROTECTED_PRIORITY_INFRA_502_ENABLED` (default off): when a priority target marked fallback-only-on-quota-exhaustion stops the combo because its provider circuit breaker is open or a predictive latency check rejected it — causes that are provably not quota — the response is 502 instead of a quota-looking 503; lockout, cooldown, unavailable, exhaustion, credential-gate and concurrency-cap stops keep 503 ([#13439](https://github.com/diegosouzapw/OmniRoute/pull/13439)) — thanks @maxmad64bis
- **fix(resilience):** non-TPD daily-quota cooldowns honor the provider node's configured daily-reset clock (timezone + hour) instead of server midnight, on single-model and combo (priority and round-robin) paths; timezone edits apply without a restart ([#13440](https://github.com/diegosouzapw/OmniRoute/pull/13440)) — thanks @maxmad64bis
- **fix(call-logs):** the call-log write point validates `error_type` against the versioned vocabulary with a Zod schema and stores `unknown` for any value outside it, so a classifier family that drifts from `ERROR_TYPE_CONTRACT` can never persist free text ([#13441](https://github.com/diegosouzapw/OmniRoute/pull/13441)) — thanks @maxmad64bis
- **fix(oauth):** token health check now parses a numeric epoch `expires_at` (number or string, seconds or milliseconds), so connections synced by external tools keep their expiry-driven refresh instead of being skipped forever — or refreshed on every sweep ([#13444](https://github.com/diegosouzapw/OmniRoute/pull/13444)) — thanks @elielsousa-pathbit
- **fix(db):** Arena ELO sync now fetches and validates the leaderboards before touching `model_intelligence`, and applies the upsert + prune of expired rows inside a single atomic transaction. Previously, an unavailable/rate-limited Arena API left the table pruned with nothing written back, and since the sync runs on every boot, repeated restarts against a rate-limited upstream permanently drained the table to zero ([#13446](https://github.com/diegosouzapw/OmniRoute/pull/13446)) — thanks @CrashCartCapital
- **fix(analytics):** the compression analytics writer now passes `flatRateAsZero: true` to `calculateCost`, matching `/api/usage/analytics`. Flat-rate subscription lanes (minimax, glm, kimi, bailian, xiaomi, web-cookie) no longer report a dollar "savings" figure that was never actually payable ([#13446](https://github.com/diegosouzapw/OmniRoute/pull/13446)) — thanks @CrashCartCapital
- **fix(sse):** stop an unhydrated `openai-compatible-*`/`anthropic-compatible-*` connection from silently routing chat requests (and its stored credential) to the real OpenAI/Anthropic API instead of the operator's configured provider-node endpoint (#13452) ([#13798](https://github.com/diegosouzapw/OmniRoute/pull/13798)) — thanks @DenXio101
- **fix(sse):** Pollinations and Perplexity-web requests now fail over instead of returning the provider's own "out of credits"/"account suspended" text as if it were a real answer — an HTTP 200 body whose short assistant message is dominated by a known credits-exhausted or account-deactivated phrase is now classified as a malformed response and triggers the existing combo/auto-fallback path (#13461) ([#13910](https://github.com/diegosouzapw/OmniRoute/pull/13910)) — thanks @arjav1181
- **fix(resilience):** background OAuth token refresh (proactive health-check sweep and the shared refresh helper behind `refreshAccessToken`/`refreshClaudeOAuthToken`/etc.) now fails closed like the interactive chat path when a connection's assigned proxy pool is entirely dead, instead of silently sending the refresh-token exchange out direct or via a stray `HTTPS_PROXY` (#13470) ([#13793](https://github.com/diegosouzapw/OmniRoute/pull/13793)) — thanks @elielsousa-pathbit
- **fix(providers):** Muse Spark 1.3 works on OpenCode Zen, OpenCode and OpenCode Go instead of failing with a 500, and gets its real 1M context window ([#13471](https://github.com/diegosouzapw/OmniRoute/pull/13471)) — thanks @maxmad64bis (with thanks to @bacnh85, @shermzy and @atakhadiviom for #12675, #12973 and #13111)
- **fix(sse):** forward Anthropic prompt-cache-creation tokens through the `/v1/responses` usage hop so cache-write counts stop logging as zero (#13472) ([#13790](https://github.com/diegosouzapw/OmniRoute/pull/13790)) — thanks @fidelix
- **fix(opencode):** opt-in `OPENCODE_RESPONSES_STALL_ROTATION` flag (default off): a streamed Responses reply that sends headers and then nothing is cut after `RESPONSES_FIRST_BYTE_TIMEOUT_MS` (default 15 s) instead of waiting for the stream readiness timeout — the account is cooled down and the request rotates to the next account once (proxied or proxy-less), a second stall fails fast; with the flag off nothing changes ([#13484](https://github.com/diegosouzapw/OmniRoute/pull/13484)) — thanks @maxmad64bis
- **fix(sse):** stop the streaming PII sanitizer from splicing OpenRouter metadata (`provider`, `native_finish_reason`, `reasoning_details[].format`) into the answer text buffer (#13488) ([#13792](https://github.com/diegosouzapw/OmniRoute/pull/13792)) — thanks @Xore
- **fix(sse):** opt-in `OPENCODE_USER_BLOCKED_ROTATION` flag (default off): an opencode 403 or 451 carrying a `user_blocked` refusal cools the refused account down and fails over to the next account at most once per request, cancelling the abandoned response body; with the flag off the refusal is returned unchanged ([#13498](https://github.com/diegosouzapw/OmniRoute/pull/13498)) — thanks @maxmad64bis
- **fix(runtime):** eliminate hardcoded 20128 port remnants and make loopback URLs dynamic ([#13533](https://github.com/diegosouzapw/OmniRoute/pull/13533)) — thanks @ggdayup
- **fix(cli):** redraw the CLI/Electron system tray icon with a dark outline and ship a native multi-res `icon.ico` so it is no longer a pure-white, nearly invisible glyph on the Windows light-theme taskbar and hidden-icons flyout (#13535) ([#13797](https://github.com/diegosouzapw/OmniRoute/pull/13797)) — thanks @ProphetOfDoom-PoD
- **fix(cli):** persist the supervisor's give-up crash record to `<DATA_DIR>/server/crash.log` (surfaced by `omniroute doctor`) instead of only printing it — the console output was discarded when `--tray` mode's detached worker exited, leaving no trace of why the gateway/tray disappeared (#13538) ([#13908](https://github.com/diegosouzapw/OmniRoute/pull/13908)) — thanks @ProphetOfDoom-PoD
- **fix(api):** `/v1/audio/transcriptions`, `/v1/audio/translations` and `/v1/audio/speech` requests now show up in Dashboard → Request Logs — the three routes never called the shared call-log pipeline, so every successful (and failed) transcription/translation/speech request was silently dropped from `call_logs` ([#13544](https://github.com/diegosouzapw/OmniRoute/issues/13544)) ([#13803](https://github.com/diegosouzapw/OmniRoute/pull/13803)) — thanks @delafu
- **fix(translator):** Claude tool `input_schema` with a root-level `anyOf` / `oneOf` / `allOf` is flattened into a plain object schema instead of being forwarded verbatim. Anthropic refuses such a tool before inference (`tools.N.custom.input_schema: input_schema does not support oneOf, allOf, or anyOf at the top level`), so a single MCP/agent tool carrying one made every request fail with no combo failover possible ([#13552](https://github.com/diegosouzapw/OmniRoute/issues/13552)) ([#13561](https://github.com/diegosouzapw/OmniRoute/pull/13561)) — thanks @sprintberlin
- **fix(routing):** Preserve forced reasoning effort across native requests, account defaults and combo fallbacks while keeping internal routing directives out of upstream payloads. ([#13556](https://github.com/diegosouzapw/OmniRoute/pull/13556)) — thanks @JxnLexn
- **fix(providers):** MiniMax-M3's inline `<think>...</think>` reasoning no longer leaks into `message.content`/`delta.content` on the `minimax`/`minimax-cn` routes — it is now stripped and surfaced as `reasoning_content`, in both streaming and non-streaming responses (#13558) ([#13799](https://github.com/diegosouzapw/OmniRoute/pull/13799)) — thanks @pan17
- **fix(api):** `PATCH /api/settings` now persists `hideAutoCombos` and `hideNoThinkVariants` instead of silently dropping them (#13562) ([#13800](https://github.com/diegosouzapw/OmniRoute/pull/13800)) — thanks @texastoland
- fix(api): resolve the codex-settings `apiKey` through the canonical key resolver instead of an inline 400 guard, so the dashboard Apply flow no longer fails with `baseUrl, apiKey and model are required` in cloud mode when no management key is selected (#13563) ([#13566](https://github.com/diegosouzapw/OmniRoute/pull/13566)) — thanks @opensource-elearning
- fix(sse): release the native Codex turn pin when the pinned model becomes model-scoped unusable, so a long-running Codex session falls back to the next healthy combo model instead of dying to a terminal `400 NATIVE_CODEX_PINNED_MODEL_UNAVAILABLE` (#13564) ([#13566](https://github.com/diegosouzapw/OmniRoute/pull/13566)) — thanks @opensource-elearning
- **fix(proxy):** ordinary SOCKS5 data-plane requests no longer trigger the T14 speculative bare-TCP reachability probe — that probe opened and immediately closed a raw TCP connection, which a SOCKS5 listener (e.g. GOST) sees as an incomplete handshake and logs as `unexpected EOF`; HTTP/HTTPS fast-fail and the explicit `directFallbackOnUnreachable` control-plane probe are unchanged ([#13571](https://github.com/diegosouzapw/OmniRoute/pull/13571)) — thanks @mdigitalbh81
- **fix(gemini):** stop sending Gemini a `400` on combos ("function call turn comes immediately after a user turn") when conversation history *opens* on a functionCall turn — after `mergeConsecutiveSameRoleContents`, `contents[]` alternation is guaranteed for every index ≥ 1, but the one remaining violation was a history whose very first turn is a functionCall. Prepends a synthetic leading user turn in that case ([#13573](https://github.com/diegosouzapw/OmniRoute/pull/13573)). — thanks @hartmark
- fix(db): make the chat-path proxy resolver (`resolveProxyForConnection`) rotate a multi-member pool the same way the registry resolver already does — its per-connection cache was freezing on the first pool member forever instead of re-running the scope's round-robin/sticky/random strategy on each request, unless the connection needs a stable egress (opencode's egress-bucketed quota, grok-web's IP-pinned `cf_clearance`) (#13575) ([#14044](https://github.com/diegosouzapw/OmniRoute/pull/14044))
- **fix(proxies):** a subscription refresh, a bulk re-import or an API update that omits the status no longer turns a disabled proxy back on, and a refresh no longer rewrites a manual proxy that shares a subscription node's address ([#13577](https://github.com/diegosouzapw/OmniRoute/pull/13577)) — thanks @maxmad64bis
- **fix(cli):** `omniroute update` now passes `--legacy-peer-deps` to `npm install -g`, suppressing the `ERESOLVE` / peer-dependency wall seen on fresh global installs; dry-run output reflects the same flag; troubleshooting guide documents the supported install form (#13579 — thanks @prabhu-omkar)
- **fix(api):** a partial update no longer resets the fields the client did not send: renaming a disabled reasoning routing rule keeps it disabled with its priority, description and tags, renaming a playground preset keeps its params, and renaming or re-importing a proxy keeps its address family ([#13582](https://github.com/diegosouzapw/OmniRoute/pull/13582)) — thanks @maxmad64bis
- **fix(providers):** Antigravity error responses and logs now surface the real upstream message (e.g. Gemini field-path rejections) instead of the generic "Antigravity upstream error (400)" placeholder (#13591) ([#13801](https://github.com/diegosouzapw/OmniRoute/pull/13801)) — thanks @afonsoft
- **fix(usage):** the call-logs artifact worker's failure warning now includes the underlying error's message/code instead of the generic "detail omitted" — a crashed or non-zero-exit worker was previously undiagnosable in the logs (#13597) ([#13802](https://github.com/diegosouzapw/OmniRoute/pull/13802)) — thanks @afonsoft
- **fix(providers):** echo back `reasoning_content` on `bai` DeepSeek thinking-mode follow-up turns, fixing the upstream 400 "reasoning_content must be passed back" (#13599) ([#13807](https://github.com/diegosouzapw/OmniRoute/pull/13807)) — thanks @afonsoft
- **fix(i18n):** drop the second copy of `featureFlagProxySkipRecentlyFailedDescription` that the 2026-09-15 batch merges left in 59 dashboard catalogs (a scripted keep-both conflict resolution concatenated the key both PRs carried; `JSON.parse` silently kept the last copy) and the duplicated `ERROR_TYPE_CONTRACT` import in `src/lib/db/callLogStats.ts` (TS2300); adds `tests/unit/i18n-catalogs-no-duplicate-keys.test.ts`, a raw-text guard that fails on any key declared twice in one object of `src/i18n/messages/*.json` or `bin/cli/locales/*.json` ([#13602](https://github.com/diegosouzapw/OmniRoute/pull/13602), [#13641](https://github.com/diegosouzapw/OmniRoute/pull/13641)) ([#13816](https://github.com/diegosouzapw/OmniRoute/pull/13816))
- **fix(proxy):** proxy credentials holding a literal `%` (e.g. `pa%ss`) no longer break the proxy — HTTP(S) proxies now receive a correctly built `Proxy-Authorization` header instead of undici throwing `URIError`, SOCKS5 proxies get the raw credential, and the proxy registry, subscription import and legacy settings parsers keep the value instead of dropping the entry; correctly percent-encoded credentials decode exactly as before ([#13605](https://github.com/diegosouzapw/OmniRoute/pull/13605)) — thanks @maxmad64bis
- **fix(connection-cooldown):** skip connection cooldown for locally rejected token-budget 429s so a per-key limit never cools a healthy connection ([#13606](https://github.com/diegosouzapw/OmniRoute/pull/13606)) — thanks @maxmad64bis
- **fix(opencode-plugin-v2):** write the catalog snapshot to a temp file and rename it into place, ignore newer snapshot versions, and warn when a write is skipped or fails ([#13607](https://github.com/diegosouzapw/OmniRoute/pull/13607)) — thanks @maxmad64bis
- **fix(proxy-health):** a target-refused probe (401/403/429) can reset the consecutive-failure streak instead of staying neutral, behind the opt-in `PROXY_HEALTH_BLOCKED_RESETS_STREAK` feature flag (default off: refusals keep the #10654 neutral policy); a relayed 5xx stays inconclusive and a refusal never removes or disables a proxy ([#13608](https://github.com/diegosouzapw/OmniRoute/pull/13608)) — thanks @maxmad64bis
- **fix(providers):** opt-in `MISTRAL_AMBIGUOUS_401_SOFT_LOCKOUT` flag (default off): a bare Mistral 401 with no explicit auth signal (identical for a revoked key and an exhausted quota) cools the connection down instead of parking it as expired, at most 3 times per hour per connection before it parks, so a revoked key still converges; the ambiguity check is now one implementation shared by the connection test and the runtime ([#13609](https://github.com/diegosouzapw/OmniRoute/pull/13609)) — thanks @maxmad64bis
- **fix(proxies):** pool validation no longer rewrites proxies set to inactive or dead; only active and error statuses are updated ([#13612](https://github.com/diegosouzapw/OmniRoute/pull/13612)) — thanks @maxmad64bis
- **fix(opencode):** the v2 plugin reads the management token from OMNIROUTE_MANAGEMENT_API_KEY (plugin option wins) and warns once at startup when management calls fall back to the inference key ([#13613](https://github.com/diegosouzapw/OmniRoute/pull/13613)) — thanks @maxmad64bis
- **fix(routing):** a failed stale-pin (LKGP) clear on the combo fallback path now logs the combo and execution key while staying non-blocking, and a new opt-in `npm run check:routing-error-guard` script (not wired into CI) flags new swallowed catches and unanchored fire-and-forget async on routing paths, with frozen entries keyed by file and catch body instead of line numbers ([#13614](https://github.com/diegosouzapw/OmniRoute/pull/13614)) — thanks @maxmad64bis
- **fix(opencode):** opt-in `OPENCODE_TRANSIENT_FAILOVER_BACKOFF` flag (default off): once two consecutive opencode accounts fail with a transient upstream error, the rotation pauses before the next account (1.5 s doubling, capped at 6 s per pause and 10 s per request), releases the failed response body first and stops dispatching if the client disconnects during the pause; with the flag off failover stays immediate ([#13615](https://github.com/diegosouzapw/OmniRoute/pull/13615)) — thanks @maxmad64bis
- **fix(devin):** Fall back to the CLI probe (`devin acp --agent-type summarizer`) when the connection-test HTTP API rejects a CLI-format key, since routing authenticates against the local Devin CLI, not `api.devin.ai` ([#13617](https://github.com/diegosouzapw/OmniRoute/pull/13617)) — thanks @patrykkopycinski
- **fix(sse):** stream reasoning deltas from combo targets incrementally instead of buffering them into a single burst, and stop rejecting reasoning-only streams as an empty completion (#13620) ([#13806](https://github.com/diegosouzapw/OmniRoute/pull/13806)) — thanks @NaNomicon
- **fix(models):** keep OpenRouter's Batch-API-only `:batch` variants out of chat routing — ModelSync imported all 77 of them into the chat catalogue, where every request that landed on one was rejected with `404 This model is only available through the Batch API` (#13622) — thanks @L4XB
- **fix(cursor):** `kv_after_text` no longer settles away a trailing `exec_mcp` tool call in the same buffer, preventing Composer from dropping in-flight MCP tool invocations during KV checkpoint settling ([#13627](https://github.com/diegosouzapw/OmniRoute/pull/13627)) — thanks @patrykkopycinski
- fix(providers): restore grok-4.6/4.5 default reasoning effort so requests without an explicit effort keep reasoning enabled (#13628) — thanks @HouMinXi
- fix(registry): declare supportedThinkingEfforts on claude-opus-5 and claude-fable-5 across the anthropic/claude/claude-web/ghe-copilot/github registries (#13628) — thanks @HouMinXi
- **fix(stream-recovery):** opt-in `STREAM_RECOVERY_TOOLCALL_ORDER_FIX` (default off) makes mid-stream continuation tool-call safe — a cut stream is never resumed once a tool call was emitted, whether still in flight or already finished with `finish_reason: "tool_calls"` — and closes after one empty continuation instead of spending the whole budget ([#13633](https://github.com/diegosouzapw/OmniRoute/pull/13633)) — thanks @maxmad64bis
- **fix(ci):** clear the `release/v3.8.51` base-reds on the PR fast path. The provider pipeline keeps the upstream error code and type again, so an Antigravity missing-project 422 stays fail-closed. The glued-prefix `sk-` credential pattern scans error text in linear time instead of quadratic. The provider detail page no longer bundles `node:fs`. `/v1/models` stops listing custom Jina models twice. The `models`/`providers` import cycle is gone, and `opencode-plugin-v2` passes the pack policy. Stale guards, fixtures, locale keys and docs counts are aligned with their merged changes ([#13635](https://github.com/diegosouzapw/OmniRoute/pull/13635)) — thanks @dpozimski
- **fix(tests):** add `dist/httpClientAbortGuard.mjs` to the expected missing-paths list in `tests/unit/pack-artifact-policy.test.ts` — [#13636](https://github.com/diegosouzapw/OmniRoute/pull/13636) registered the file in `PACK_ARTIFACT_REQUIRED_PATHS` without updating the assertion, leaving the test red on the release tip for every PR that runs it ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) ([#13872](https://github.com/diegosouzapw/OmniRoute/pull/13872))
- **fix(compression):** report compression-worker faults instead of silently sending the uncompressed body, and fall back to the in-process pipeline for fast faults (thread error, exit, engine throw); a dispatch timeout still degrades to uncompressed, but is now logged ([#13637](https://github.com/diegosouzapw/OmniRoute/pull/13637)) — thanks @marcs7
- **fix(db):** search stats and analytics no longer surface "ghost" rows — a NULL/`-` provider or a keyed search provider whose connection was deleted — while keyless providers (`duckduckgo-free`, `searxng-search`, anonymous `context7`) and credential-fallback providers (`perplexity-search` on a `perplexity` key) stay visible; the analytics totals apply the same filter, so `total` always matches the per-provider breakdown ([#13641](https://github.com/diegosouzapw/OmniRoute/pull/13641)) — thanks @maxmad64bis
- **fix(sse):** recognize `reasoning_effort` in the reactive 400 field-strip retry — strict OpenAI-compatible upstreams that reject the field are retried once without it instead of surfacing the 400 ([#13642](https://github.com/diegosouzapw/OmniRoute/pull/13642)) — thanks @Moseyuh333
- **fix(dashboard):** new opt-in flag `FREE_BADGE_REQUIRES_PROVIDER_FREE_TIER` (default off) makes the provider-page Free badge strict — it drops the display-name heuristic, non-boolean `free` fields and `:free` suffixes on registered providers without a documented free tier, while keeping catalogued free models, explicit `free: true` and `:free` on free-tier providers and compatible nodes; with the flag off the badges are unchanged ([#13645](https://github.com/diegosouzapw/OmniRoute/pull/13645)) — thanks @maxmad64bis
- **fix(api):** share the single SOCKS5 flag reader across the settings proxy routes so the dashboard and the dispatcher stay consistent ([#13646](https://github.com/diegosouzapw/OmniRoute/pull/13646)) — thanks @maxmad64bis
- **fix(stream-recovery):** log every mid-stream continuation outcome with its `attempt N/MAX` token — the stitched suffix, overlap rejection, terminal/empty continuation and tool-call refusals at debug, and a recovery that gives up (budget spent, or the continuation request returned no stream) at warn — without adding any warn line to a healthy or tool-call stream; the existing `mid-stream continuation attempt N/MAX` line is unchanged ([#13650](https://github.com/diegosouzapw/OmniRoute/pull/13650)) — thanks @maxmad64bis
- **fix(sse):** Kiro translator no longer re-prepends the full relocated tool-documentation block onto every subsequent turn of a multi-turn conversation; it now stays anchored to the turn that originally carried it. (#13652) ([#13808](https://github.com/diegosouzapw/OmniRoute/pull/13808)) — thanks @KelvinKSPS
- **fix(sse):** opt-in `OPENCODE_RATE_LIMITED_429_EARLY_STOP` flag (default off): an opencode 429 classified as a real rate limit (parseable `Retry-After`, or a body naming a rate/usage limit) stops the cross-account wave and returns that upstream 429 unchanged — body, `Retry-After` and quota headers intact, so the opencode quota error rules still apply; unclassified 429s keep rotating, and with the flag off every 429 rotates as before (#9611) ([#13657](https://github.com/diegosouzapw/OmniRoute/pull/13657)) — thanks @maxmad64bis
- **fix(sse):** a configured daily-quota reset hour that falls inside a daylight-saving gap (New York 02:00 on spring-forward, Havana/Santiago midnight) now resolves to the first wall-clock time that exists instead of landing an hour early, sometimes on the previous day ([#13671](https://github.com/diegosouzapw/OmniRoute/pull/13671)) — thanks @maxmad64bis
- **fix(sse):** new opt-in flag `RETRY_AFTER_PROVENANCE_ENABLED` (default off): aggregated 429/503 unavailable responses omit `Retry-After` when no concrete future retry time is known instead of sending a synthetic 1s, carry `error.retry_after_provenance` (`signal` | `none`), and combo drain paths read prose retry hints from JSON and plain-text upstream bodies; non-JSON upstream error pages no longer log at warn ([#13672](https://github.com/diegosouzapw/OmniRoute/pull/13672)) — thanks @maxmad64bis
- **fix(docker):** isolate the ChatGPT Web (Codex) CDP proxy sidecar onto its own Compose network, add an opt-in `CDP_PROXY_TOKEN` auth gate to `cdp-proxy.mjs`, and stop the VNC browser-login CDP bridge from starting when no token is configured (#13679) ([#13811](https://github.com/diegosouzapw/OmniRoute/pull/13811))
- **fix(security):** the CLI/management bearer token is now derived from a random per-install salt persisted under `DATA_DIR` instead of the checked-in literal `omniroute-cli-auth-v1` — since `/etc/machine-id` is commonly world-readable, any local user could previously derive the same token as every install that never set `OMNIROUTE_CLI_SALT`; the explicit env override still takes priority and rotation still works the same way (#13679) ([#13909](https://github.com/diegosouzapw/OmniRoute/pull/13909))
- **fix(auth):** `verifyCloudSignature()` no longer accepts an unverifiable `X-Cloud-Sig` when `OMNIROUTE_CLOUD_SYNC_SECRET` is unset — a forged/garbage signature is rejected outright, and the new opt-in `OMNIROUTE_CLOUD_SYNC_ENFORCE_SIGNATURE=true` flag rejects unsigned Cloud-sync payloads too (default stays legacy pass-through for v3.8.x; the default flips in v3.9) ([#13679](https://github.com/diegosouzapw/OmniRoute/issues/13679)) ([#13804](https://github.com/diegosouzapw/OmniRoute/pull/13804))
- **fix(security):** default the published Docker image and `fly.toml` deployment to `REQUIRE_API_KEY=true` (npm/CLI local-dev default unchanged), and stop `/api/free-tier/summary`'s wildcard CORS from leaking the operator's local token usage to unauthenticated callers (#13679) ([#13911](https://github.com/diegosouzapw/OmniRoute/pull/13911))
- **fix(security):** removed the copy-pasteable placeholder `JWT_SECRET`/`API_KEY_SECRET`/`INITIAL_PASSWORD` values from the Podman Quadlet deploy manifest, and blocked remote dashboard logins with the well-known default `INITIAL_PASSWORD=CHANGEME` (#13679) ([#13812](https://github.com/diegosouzapw/OmniRoute/pull/13812))
- **fix(security):** the internal self-loop admission-bypass bearer is now a random per-process secret instead of the checked-in literal `"sk_omniroute"` when no `OMNIROUTE_API_KEY`/`ROUTER_API_KEY` is configured (#13679) ([#13813](https://github.com/diegosouzapw/OmniRoute/pull/13813))
- **fix(db):** `DELETE /v1/batches/delete-completed` now caps the work it does per request and reports `hasMore` so a caller can resume, and the sweep no longer deletes a file that another batch still references (#13680, #13681) ([#13805](https://github.com/diegosouzapw/OmniRoute/pull/13805))
- **fix(security):** a restricted API key is now enforced on the alias spellings `next.config.mjs` rewrites onto `/api/v1/…` — a route handler sees the client's original URL, so `POST /chat/completions`, `/responses`, `/responses/*`, `/models`, `/codex/*` and the doubled `/v1/v1/*` prefix all skipped the endpoint-category lookup and let a key allowed only on `search` reach chat or any other endpoint ([#13685](https://github.com/diegosouzapw/OmniRoute/issues/13685)) ([#13741](https://github.com/diegosouzapw/OmniRoute/pull/13741)) — thanks @gonisulaimann
- **fix(usage):** mark locally estimated token usage in the call log (`_omniroute.usageEstimated` on the logged response) so operators can tell estimated counts and costs from provider-reported ones — covers OmniRoute's own estimate for streams without upstream usage and web executors that report `estimated: true`; billing, API-key budgets, quota-share and client payloads are unchanged ([#13686](https://github.com/diegosouzapw/OmniRoute/pull/13686)) — thanks @maxmad64bis
- **fix(openrouter):** sync the `:free` 1000/day tier from `/credits` lifetime purchases instead of staying stuck at 50/day for $10+ accounts ([#13689](https://github.com/diegosouzapw/OmniRoute/pull/13689)) — thanks @Notaloop763
- **fix(translator):** prevent schema property name collisions (e.g. `properties`, `required`) in Gemini schema sanitizer ([#13690](https://github.com/diegosouzapw/OmniRoute/pull/13690)) — thanks @zcrew0x
- fix(providers): map `thinking.type: "adaptive"` to `"enabled"` for AgentRouter GLM models instead of forwarding it unhandled, fixing a 400 from AgentRouter's upstream GLM endpoint (#13696) ([#14043](https://github.com/diegosouzapw/OmniRoute/pull/14043))
- **fix(providers):** the shared Responses-API input sanitizer now converts Codex's proprietary `agent_message` input items (used for multi-agent task/reply passing) into a plain `message` item before forwarding to any non-Codex-native Responses upstream. Previously such items reached third-party Responses endpoints untouched, and OpenCode Go Muse Spark 1.3 rejected the request with `input[N] did not match any supported type` (#13698). The real Codex/ChatGPT native passthrough path is unaffected and continues to receive `agent_message` items as-is. ([#14041](https://github.com/diegosouzapw/OmniRoute/pull/14041))
- fix(sse): stop the direct (no-proxy) fresh-socket retry from reusing the pooled attempt's flat `OMNIROUTE_DIRECT_HEADERS_TIMEOUT_MS` response-start watchdog — the retry is a brand-new socket with no zombie to detect (#10214's rationale only applies to the pooled attempt), so when the caller already attached its own deadline signal (the resolved connection/model/provider/`FETCH_TIMEOUT_MS` cascade) the retry now defers to a generous, `OMNIROUTE_DIRECT_RESPONSE_RETRY_TIMEOUT_MS`-configurable backstop instead of an identical short flat window, fixing spurious 504s on healthy slow-TTFB reasoning models (#13703) ([#14047](https://github.com/diegosouzapw/OmniRoute/pull/14047))
- **fix(copilot):** fall back to the `copilot-chat` identity once when a standard GitHub Copilot account rejects the CLI identity with a 403, without breaking Enterprise Copilot ([#13705](https://github.com/diegosouzapw/OmniRoute/pull/13705)) — thanks @tuandinh0801
- **fix(dashboard):** Saving or clearing a proxy on a provider page now refreshes the per-connection proxy badges immediately instead of leaving them stale until a manual reload ([#13711](https://github.com/diegosouzapw/OmniRoute/pull/13711)) — thanks @xiaoyaner0201
- fix(db): bound health scans and isolate native diagnostics so large quota histories no longer exhaust memory or block request handling; diagnostics now run in a cancellable child and are awaited at HTTP and MCP callers (#13717) — thanks @HouMinXi
- fix(chat): preserve suffix-model reasoning effort across model attempts so a replacement model no longer inherits or drops the original suffix, and keep explicit reasoning choices in request dedup hashes (#13720) — thanks @HouMinXi
- **fix(api):** `POST /v1/rerank` now actually works against native TEI / Infinity provider nodes: the `/rerank` fallback sends `texts` + `return_text` alongside `documents`, and bare-array or `score`-only upstream responses are normalized to the Cohere `{results: [{index, relevance_score, document?}]}` envelope (sorted, `top_n`-capped) so clients and the memory engine's rerank step see real scores ([#13733](https://github.com/diegosouzapw/OmniRoute/pull/13733)) — thanks @seanford
- **fix(api):** `GET /v1/models` types compatible-provider-node rows by the node's `apiType` when a discovered/added model carries no endpoint metadata — an `embeddings` node's models are `type: "embedding"` and a `rerank` node's models are `type: "rerank"` instead of surfacing as untyped chat models; a manual overlay's `supportedEndpoints` also re-types the merged row ([#13734](https://github.com/diegosouzapw/OmniRoute/pull/13734)) ([#13740](https://github.com/diegosouzapw/OmniRoute/pull/13740)) — thanks @seanford
- **fix(memory):** the Embedding and Rerank selectors on Memory → Engine now list local provider nodes with the models they actually expose for that modality (synced + custom rows, typed the same way `GET /v1/models` types them) instead of by node `apiType` alone with an empty model list — a node typed `embeddings` that also serves a reranker now appears under Rerank with that model, and under Embeddings with its embedding model, so `prefix/model` no longer has to be typed by hand ([#13740](https://github.com/diegosouzapw/OmniRoute/pull/13740)) — thanks @seanford
- **fix(usage):** preserve nested prompt cache-read counters before cost calculation ([#13760](https://github.com/diegosouzapw/OmniRoute/pull/13760)) — fixes [#13746](https://github.com/diegosouzapw/OmniRoute/issues/13746) — thanks @xiaoyaner0201
- **fix(db):** Health-check-repair backup pruning now resolves `maxFiles`/`retentionDays` from the persisted Storage-page setting (not just env vars), matching manual/API/auto backups. ([#13308](https://github.com/diegosouzapw/OmniRoute/issues/13308)) ([#13773](https://github.com/diegosouzapw/OmniRoute/pull/13773)) — thanks @voidstackloop
- **fix(sse):** CCR retrieve-tool detection now recognizes an MCP-gateway-namespaced tool name (e.g. Docker MCP Toolkit's `mcp__docker__omniroute__omniroute_ccr_retrieve` or a single `mcp__<server>__omniroute_ccr_retrieve` prefix) via a separator-bounded trailing-segment match instead of requiring an exact `omniroute_ccr_retrieve` string — previously an MCP-capable caller reachable only under a gateway-assigned name was treated as unable to retrieve, so the entire CCR compression engine was skipped for it (#13781). ([#14028](https://github.com/diegosouzapw/OmniRoute/pull/14028))
- **fix(i18n):** retranslate the English strings that had been copied verbatim into the locale catalogs (Spanish alone carried 7,142) and turn the real-translation ratio gate into a blocking ratchet. (#13782)
- fix(resilience): the chat admission gate's pressure check now actively re-samples instead of reading a passive cache, so the `resource_pressure` guard can observe recovery and stop shedding once real pressure clears, instead of requiring a full process restart ([#13823](https://github.com/diegosouzapw/OmniRoute/pull/13823)) — thanks @pandudpn
- **fix(providers):** the Zylo API key check now probes the authenticated chat route instead of the open catalog — Zylo serves `GET /v1/models` without authentication, so the account-setup dialog accepted any string as valid and the key was only rejected later, when a model test returned Zylo's own `401 "Key not found: zk-…"` ([#13828](https://github.com/diegosouzapw/OmniRoute/issues/13828)) ([#13877](https://github.com/diegosouzapw/OmniRoute/pull/13877))
- **fix(sse):** when a gateway API key's `allowed_connections` / quota scope hides every connection of a provider, chat now answers `403` naming that scope instead of the generic `No active credentials for provider: X` — which was indistinguishable from "never configured" even though `/test` and `/sync-models` kept working on the same connection ([#13832](https://github.com/diegosouzapw/OmniRoute/issues/13832)) ([#13879](https://github.com/diegosouzapw/OmniRoute/pull/13879))
- **fix(combos):** Keep vision capability consistent for MiMo V2.5 and Step 3.7 Flash provider/free variants so `/v1/combos` no longer under-reports multimodal combos whose members are already advertised as vision-capable by `/v1/models` ([#13847](https://github.com/diegosouzapw/OmniRoute/issues/13847)). ([#13863](https://github.com/diegosouzapw/OmniRoute/pull/13863)) — thanks @smshagor-dev
- **fix(translator):** Third-party tool names (e.g. GitHub Copilot's own `web_fetch` function tool) are no longer sent unprefixed to Claude-wire-format providers outside genuine first-party Anthropic traffic, fixing a `rejected tool(s): web_fetch` 400 for any `gh/claude-*` model ([#13856](https://github.com/diegosouzapw/OmniRoute/pull/13856)) — thanks @dylanhaskins
- fix(build): externalize @modelcontextprotocol/sdk in standalone server webpack config to prevent TDZ ReferenceError on MCP initialize (#13859) — thanks @HouMinXi
- fix(combo): stop the chars/4 context estimate from demoting an operator-verified `model_context_override` behind an unconfirmed catalog "emergency" fallback in combo priority ordering (#13870) ([#14046](https://github.com/diegosouzapw/OmniRoute/pull/14046))
- fix(quota): resolve Quota Sharing plan/limits from the pool's canonical primary connection in `enforceQuotaShare` and `recordConsumption`, not the serving connection — a multi-connection pool whose "Limite" wizard override only ever lands on the primary connection was writing/checking consumption under a different dimension key when a request was actually served via a non-primary pool member, so the dashboard's "consumed" amount never reflected real traffic (#13876) ([#14042](https://github.com/diegosouzapw/OmniRoute/pull/14042))
- Fixed a security issue where a revoked, expired, or banned API key could still resolve an owner scope in `getApiKeyRequestScope()` and keep accessing its own `/v1/files` and `/v1/batches` records instead of being rejected with 401 (#13881). ([#14024](https://github.com/diegosouzapw/OmniRoute/pull/14024))
- fix(security): scope `/api/files` and `/api/batches` management siblings to the caller's own API key (session stays instance-wide), closing a cross-tenant read that let an anonymous or foreign-key caller enumerate and download other tenants' files/batches (#13882) ([#14027](https://github.com/diegosouzapw/OmniRoute/pull/14027))
- **security(images):** close a DNS-rebinding TOCTOU (#13883) at the three newer public-only image download sites — `resolveImageSource` and the NanoBanana result-URL conversion in `imageGeneration.ts`, and `resolveUpscaleImageSource` in `imageUpscale/shared.ts`. All three validated a caller-supplied URL's DNS answer as public but then let the download perform an independent, un-pinned second resolution at connect time, so a host that answered differently between the two lookups (public, then loopback/LAN) could reach an internal address; they now set `pinDns: true` (reusing the existing `createPinnedFetch` helper already used by embeddings and the vision/audio/video bridges), binding the connection to the exact validated address. ([#14032](https://github.com/diegosouzapw/OmniRoute/pull/14032))
- **fix(i18n):** reviewer pass over the 1,865 pt-BR leaves retranslated in #13782 (172 corrections) via the new `review-locale` script. (#13885)
- **fix(db):** `getPricingForModel()` now reads through the existing 30s TTL `getCachedPricing()` helper instead of rebuilding pricing from scratch (3 SELECTs + JSON.parse + merge) on every call, eliminating the multi-second event-loop stall `/api/usage/history` hit when `calculateAggregateCost()` invoked it once per GROUP BY row (up to 531 times per request) (#13891). Every known pricing writer already invalidates this cache via `touchPricing()`, so writes remain immediately visible. ([#14040](https://github.com/diegosouzapw/OmniRoute/pull/14040))
- **fix(cli):** translate the CLI for every locale — 38 catalogs carried 124 of 830 keys and fell back to English; `sync-ui-keys --catalog=cli` fills them (52,000 strings) and a completeness gate keeps them full. (#13892)
- **fix(dashboard):** the request-log detail view now shows an explicit "payload omitted — exceeded the call log size limit" notice for a pipeline/request/response section that was replaced by the size-limit marker (`_omniroute_truncated` / `[omitted: call log artifact size limit exceeded]`), instead of silently rendering the marker verbatim under a generic "Pipeline Error" title as if it were a real upstream error. Also fixed `.env.example` documenting stale `CHAT_LOG_ARRAY_TAIL_ITEMS=128`/`CHAT_LOG_MAX_DEPTH=6` defaults that no longer match the code's actual `1000`/`20`. ([#14045](https://github.com/diegosouzapw/OmniRoute/pull/14045))
- **fix(sse):** the CCR protocol instruction (the system note teaching a model how to call `omniroute_ccr_retrieve`) is now injected for callers reaching OmniRoute's MCP server through a namespacing gateway (Claude Code / Docker MCP style `mcp__<gateway>__<server>__omniroute_ccr_retrieve`, or a dotted/slashed prefix) — `callerSupportsCcrRetrieve()` previously matched by exact string equality only, so a genuinely reachable but gateway-namespaced tool name was never recognized and the instruction (and CCR compression itself) was silently skipped (#13897). ([#14028](https://github.com/diegosouzapw/OmniRoute/pull/14028))
- fix(tests): replace the flaky 250ms wall-clock ReDoS guard in `sanitizeErrorMessage`'s property test with a deterministic cost-scaling check, so the test proves bounded-backtracking instead of failing on machine load (#13907) ([#14039](https://github.com/diegosouzapw/OmniRoute/pull/14039))
- fix(providers): `detectVisionInput` now recognizes Lemonade Server's `labels[]` vision capability, so Lemonade vision models import with `supportsVision` set instead of being treated as text-only (#13918) ([#14023](https://github.com/diegosouzapw/OmniRoute/pull/14023))
- fix(sse): deepseek provider registry now declares `defaultContextLength: 1_000_000` and an explicit `deepseek-flash` model entry, so unlisted/new DeepSeek models (like the new DeepSeek V4.1 Flash) no longer fall back to the generic 128k context limit (#13922) ([#14026](https://github.com/diegosouzapw/OmniRoute/pull/14026))
- **fix(sse):** opt-in `OPENCODE_PARK_AND_RESUME` flag (default off): after repeated transient 429s the opencode rotation parks the request with a heartbeat and replays one capped leg of up to 3 sequential accounts instead of fanning out the whole fleet; with the flag off every 429 rotates as before ([#13924](https://github.com/diegosouzapw/OmniRoute/pull/13924)) — thanks @maxmad64bis
- fix(guardrails): resolve nested `combo-ref` steps to their real leaf models when deciding vision-bridge behavior, so a pass-through combo pointing at an all-vision-capable inner combo skips the describe-and-replace path instead of stripping raw images (#13927) ([#14038](https://github.com/diegosouzapw/OmniRoute/pull/14038))
- **fix(docs):** re-sync the 65 documentation mirror sets — 817 mirrors rewritten: the 14 core sources edited since their translation, the 322 mirrors that were still English copies, and the frontmatter the old extractor had leaked into the newer locales' bodies. The docs pipeline now retranslates only the `## ` sections whose text changed, and the drift gate is blocking. (#13940)
- fix(combos): synchronize allowedProviders and allow invariant override when updating combos from dashboard ([#13951](https://github.com/diegosouzapw/OmniRoute/pull/13951)) — thanks @fouadSalkini
- **fix(i18n):** translate the 3,719 `__MISSING__` markers (61 keys × 61 locales) that eight base PRs stamped into the catalogs on 2026-09-16, restoring the real-translation ratio gate on the release tip. (#13974)
- **fix(providers):** OpenRouter model discovery honors the per-connection base URL override instead of always importing the global catalog, so a connection pointed at a regional endpoint (e.g. the EU in-region host) no longer advertises model ids that endpoint cannot serve ([#14001](https://github.com/diegosouzapw/OmniRoute/pull/14001)) — thanks @tiangao88
- **fix(providers):** Perplexity Web no longer collapses runs of spaces in non-streaming answers (the path tool mode always takes), which flattened code indentation in `write_file` arguments and plain code blocks; citation markers are still removed with single spacing left behind ([#13968](https://github.com/diegosouzapw/OmniRoute/issues/13968)) ([#14009](https://github.com/diegosouzapw/OmniRoute/pull/14009)) — thanks @costajohnt
- **fix(docker):** copy the app into the runtime image with `--chown=node:node` instead of a second `chown -R` layer, so the image no longer stores the ~2 GB standalone build twice ([#13990](https://github.com/diegosouzapw/OmniRoute/issues/13990)) ([#14010](https://github.com/diegosouzapw/OmniRoute/pull/14010)) — thanks @costajohnt
- **fix(opencode):** an OpenCode free-tier refusal no longer counts as a healthy response and no longer takes the account it landed on out of rotation for that model: the 403 is recorded on the connection instead of staying unclassified, it stops clearing the refused account's failure history, and the request comes back without a pointless hop across accounts that would all get the same verdict. Every sibling account returns the same answer to the same request, so one refusal per account would otherwise empty the pool and leave later requests answered "no active credentials" ([#14011](https://github.com/diegosouzapw/OmniRoute/pull/14011)) — thanks @maxmad64bis
- **fix(opencode):** keyless OpenCode models answer again instead of returning `403` — requests now carry a versioned OpenCode user-agent, canonical session and request ids derived from the existing conversation fingerprint, a streamed upstream request, and a tool list. The upstream inspects which tool names a request declares, so rather than pinning a list, OmniRoute reuses the one a request of the same conversation was last seen getting through: a title or a summary, which its client sends without tools, goes out with the list that client already declared. Two opt-outs (`OPENCODE_FREE_TIER_REQUEST_CONTRACT`, `OPENCODE_FREE_TIER_PLACEHOLDER_TOOLS`) ([#14013](https://github.com/diegosouzapw/OmniRoute/pull/14013)) — thanks @maxmad64bis (with thanks to @AStupidBear for the identity-header work in #13937)
- **fix(build):** drop the orphaned `httpClientAbortGuard.mjs` entries from the pack-artifact allowlists — the #13636 crash-guard wiring was removed, so no producer or consumer ships the file anymore ([#14029](https://github.com/diegosouzapw/OmniRoute/pull/14029)) — thanks @maxmad64bis
- **feat(sse):** multi-account rotation now spreads sends per network egress and eases off fleet-wide when throttled, opt-in via `OPENCODE_EGRESS_THROTTLE_ENABLED=1` ([#14290](https://github.com/diegosouzapw/OmniRoute/pull/14290)) — thanks @maxmad64bis
- Fix Antigravity image generation not rotating to another account when the upstream returns an explicit quota-exhausted 429, so a second configured account with available quota is no longer stuck behind the first account's terminal quota error. ([#9908](https://github.com/diegosouzapw/OmniRoute/pull/9908)) — thanks @Ardem2025
- **fix(sse):** `requestQueue.maxQueueDepth = 0` (the documented default, "disabled") once again means an unbounded account queue. #12911 redefined `0` inside `accountSemaphore` as "reject when busy" for its Codex WS leases, and because `chatCore` forwards `maxQueueDepth` straight into that option, every request that found its account slot busy under default settings was answered 429 `Semaphore queue full (0)` instead of waiting. The lease keeps its refuse-don't-wait behaviour through an explicit `failFast` option. ([#14101](https://github.com/diegosouzapw/OmniRoute/pull/14101))
- **fix(sse):** the streaming OpenAI→Claude translator relays reasoning for legacy callers that never pass `requestedThinking` (`undefined`), matching the non-streaming path and the pre-#12905 contract; only an explicit opt-out (`false`) suppresses it, and only that case synthesizes the reasoning into a text block. ([#14101](https://github.com/diegosouzapw/OmniRoute/pull/14101))
- **fix(security):** `requestRejectedFailure.ts` (#12864) sanitizes the upstream message at its own `lastError` writes instead of trusting the caller to have done so, and the public-boundary guard now covers the extracted module. ([#14101](https://github.com/diegosouzapw/OmniRoute/pull/14101))
- **fix(adobe-firefly):** never spawn a real Chrome for CDP session warm under a unit-test runner, so tests stop leaking a browser process that holds an OS handle on its DATA_DIR profile directory ([#13289](https://github.com/diegosouzapw/OmniRoute/pull/13289)) — thanks @anhtahaylove
- fix(providers): **declare Agnes CN chat models' live `reasoning_effort` vocabulary** so the catalog and sanitizer stop inventing tiers the CN API rejects. Probes on api.agnes-ai.cn (2026-09-14) match the international endpoint: 2.0/2.5 accept `none/low/medium/high/max`, 3.0 also accepts `minimal` and `xhigh`; `off`/`ultra` clamp off the wire and Hermes' default `xhigh` clamps to `max` on 2.x. ([#13399](https://github.com/diegosouzapw/OmniRoute/pull/13399)) — thanks @HouMinXi
- fix(providers): **declare Agnes chat models' live `reasoning_effort` vocabulary so catalog/builder/sanitizer stop inventing aliases the API 400s.** 2.0/2.5 accept `none/low/medium/high/max`; 3.0 also accepts `minimal` and `xhigh`. `off`/`ultra` still clamp off the wire. ([#13655](https://github.com/diegosouzapw/OmniRoute/pull/13655)) — thanks @HouMinXi
- Fix Antigravity quota parsing treating an unreported `remainingFraction` as 0% remaining instead of unknown, which made a genuinely exhausted quota indistinguishable from one the upstream simply didn't report. ([#7138](https://github.com/diegosouzapw/OmniRoute/pull/7138)) — thanks @Ardem2025
- **fix(api):** accept `blockedModels` in the key permissions update schema so the deny-list half of per-key model policy is no longer silently stripped before it reaches the route ([#13666](https://github.com/diegosouzapw/OmniRoute/pull/13666)) — thanks @fouadSalkini
- **fix(tests):** bump the `APIKEY_PROVIDERS` tripwire count to 241 — Agnes AI China (#13399) added one `apikey/regional` entry, and the stale 240 was failing a unit-test shard on every open PR against `release/v3.8.51` ([#13905](https://github.com/diegosouzapw/OmniRoute/pull/13905))
- **fix(ci):** the advisory `forgotten-sibling-tests` step no longer fails "Fast Quality Gates" when a PR touches a hub module — the cross-product of consumers × candidate tests reached millions of rows and rendering them exceeded V8's maximum string length, so the throw hit `main()`'s catch and exited 1. The report now lists at most 200 rows per section (and 5 000 per array in the JSON artifact) while the header keeps the exact totals ([#13889](https://github.com/diegosouzapw/OmniRoute/pull/13889))
- **fix(compression):** Caveman's `leader_phrases` rule (and any other anchored file-pack rule) works again. #12825 replaced the keyword prefilter for file-based packs with a test of the rule's own regex, but ran it against a lower-cased snapshot of the _original_ text — so `^(?:i will|…)` never matched once the leading `Sure, ` was still there, and the rule was skipped before it could see the text pleasantries had already cleaned. The prefilter now sees the text as earlier rules left it. ([#14164](https://github.com/diegosouzapw/OmniRoute/pull/14164)) — thanks @gonisulaimann / @prabhtheone / @xiaoyaner0201 / @xiechimon
- **Dashboard:** renamed the Claude connection field helpers to `claudeConnectionFieldValues.ts` — the `.ts`/`.tsx` pair from #13074 differed only by casing, which breaks webpack on case-insensitive filesystems and made esbuild resolve the wrong module in the browser-bundle guard. ([#14164](https://github.com/diegosouzapw/OmniRoute/pull/14164)) — thanks @gonisulaimann / @prabhtheone / @xiaoyaner0201 / @xiechimon
- **fix(oauth):** stop posting a Claude refresh token that another in-process refresh already consumed. Re-check the rotation map and DB inside `serializeRefresh` (both Layer 1 and Layer 2), record Layer 2 rotations, re-read the connection uncached on `invalid_grant`, and keep the Claude `refreshToken` instead of nulling it into sticky `no_refresh_token`. ([#13874](https://github.com/diegosouzapw/OmniRoute/pull/13874)) — thanks @ai-jeremi-esky
- **fix(oauth):** stop nulling the Claude refresh token on the first unrecoverable refresh failure so `CredentialHealth` no longer gets stuck sticky-dead — the retry budget from #11414 can now actually spend its second attempt instead of finding an already-cleared token (#13183) ([#13185](https://github.com/diegosouzapw/OmniRoute/pull/13185)) — thanks @RaviTharuma
- **fix(stream):** restore Claude SSE passthrough `tool_use` names to the casing the client actually declared instead of "upgrading" them to the canonical Claude Code spelling (`bash``Bash`), which broke third-party Anthropic-format clients (pi/OpenCode on claude-format executors like devin-cli-agentic) while leaving the JSON path correct; the pre-existing Claude Code protection (upstream downcase restored to declared PascalCase, #7926) is preserved ([#12855](https://github.com/diegosouzapw/OmniRoute/pull/12855)) — thanks @Neuron-Mr-White
- **fix(oauth):** classify embedded `invalid_grant` in Cline token refresh error bodies so permanently consumed refresh tokens trigger re-authentication instead of indefinite transient retry loops, and add `cline` to `ROTATION_LOCK_GROUP` to serialize concurrent sibling refreshes ([#13466](https://github.com/diegosouzapw/OmniRoute/pull/13466)) — thanks @fouadSalkini
- Add a visible manual callback-entry action to the Codex loopback warning so remote users can paste the authorization result instead of setting up an SSH tunnel. ([#9944](https://github.com/diegosouzapw/OmniRoute/pull/9944)) — thanks @Ardem2025
- Fixed Codex executor forwarding client `reasoning` sub-fields (`enabled`, `max_tokens`, `exclude`) that the Codex Responses API rejects with HTTP 400, taking down every combo target with a deterministic client error. The reasoning object is now whitelisted to `effort`/`summary`, and `enabled: false` maps to effort `none` when no more specific effort was requested. ([#13643](https://github.com/diegosouzapw/OmniRoute/pull/13643)) — thanks @HouMinXi
- **fix(combo):** retry the same target once when a streaming response fails before any content reaches the client (`streaming upstream error`), including native-pinned Codex turns whose set retries stay disabled — previously the caller returned a 502 immediately instead of using the existing transient-retry loop ([#13630](https://github.com/diegosouzapw/OmniRoute/pull/13630)) — thanks @anhtahaylove
- **Emergency fallback:** the budget-exhaustion reroute targets `nvidia/openai/gpt-oss-120b` again, as documented in `ENVIRONMENT.md` and the NVIDIA hosted-model snapshot; #14006 had switched the provider to `groq` inside an unrelated MITM change, so operators without a Groq connection got the original 402 back. ([#14164](https://github.com/diegosouzapw/OmniRoute/pull/14164)) — thanks @gonisulaimann / @prabhtheone / @xiaoyaner0201 / @xiechimon
- **fix(security):** client-supplied image URLs (`image_url` / `mask_url` / message parts on image generation and upscale, chat `image_url` parts inlined by the vision bridge) and the NanoBanana result download now pin the `public-only` outbound guard with DNS validation, instead of inheriting the operator provider policy — `block-metadata` on a default install let a request body make the server fetch loopback/LAN URLs and forward the bytes upstream (GHSA-34rg-3pqj-35g9) ([#13748](https://github.com/diegosouzapw/OmniRoute/pull/13748))
- **fix(authz):** classify the 14 remaining spawn-capable `/api/cli-tools/*` routes (`all-statuses`, `status`, `detect` and the `claude/cline/codewhale/codex/crush/deepseek-tui/droid/kilo/openclaw/pi/smelt-settings` writers) and the `/api/skills/install` + `/api/skills/executions` pair as LOCAL_ONLY — they reach `child_process.spawn` transitively (`getCliRuntimeStatus()` / `detectAllTools()` / the skills sandbox) but only sat behind Tier 3 MANAGEMENT auth, which `requireLogin=false` waives; loopback/LAN enforcement now runs before any auth check, matching their already-gated siblings (GHSA-35fw-cv32-2373 — thanks Parth Narula; GHSA-jx89-f37j-pq89 — thanks Aeon). Tunnel-served dashboards lose the CLI Tools status badges, the same trade-off already accepted for grok/forge/jcode/qwen. ([#13745](https://github.com/diegosouzapw/OmniRoute/pull/13745))
- **fix(security):** redact Groq (`gsk_…`), xAI (`xai-…`) and every OpenAI-compatible `sk-…` key shape (DeepSeek 32-hex, Moonshot/Kimi, Together, …) in error bodies and the opt-in credential-masker guardrail — the catalog only knew the exact 48-char OpenAI form, so those keys passed through the guardrail verbatim and `gsk_`/`xai-` also reached public error responses (GHSA-r4q7-7f24-m29p) ([#13744](https://github.com/diegosouzapw/OmniRoute/pull/13744))
- **fix(grok-cli):** a 429 "used all the included free usage … rolling 24-hour window" on Grok Build is quota exhaustion for that model, not a 30s rate-limit wait. Combo skips the drained grok-4.6 login and tries the next account ([#13984](https://github.com/diegosouzapw/OmniRoute/pull/13984)) — thanks @stormsia
- **Rate limit:** `requestQueue.maxWaitMs=0` (the disable sentinel from #12902) no longer trips the #12715 queue-budget gate — every request on a protected connection was rejected with an immediate `503 queue budget` instead of waiting without a queue deadline. Execution stays bounded by `executionMaxWaitMs` and the upstream fetch-start timeout. ([#14164](https://github.com/diegosouzapw/OmniRoute/pull/14164)) — thanks @gonisulaimann / @prabhtheone / @xiaoyaner0201 / @xiechimon
- **fix(mcp):** MCP audit treats a non-callable better-sqlite3 export (`better-sqlite3 export is not a function`) as a native load failure, falls back to `node:sqlite`, and caches a failed driver load so dashboard polls stop reprinting (a database file that does not exist yet is never cached, so the connection recovers once the app creates it). Docker now refuses to ship without `better_sqlite3.node`. Native-load classification lives in `sqliteLoadError.ts` so `core.ts` stays under its frozen line cap. The build bootstrap keeps a deliberately narrower classifier: a corrupt binding there must not be read as "no encrypted credentials", or a fresh `STORAGE_ENCRYPTION_KEY` would be generated over an existing encrypted database. ([#13903](https://github.com/diegosouzapw/OmniRoute/pull/13903)) — thanks @HouMinXi
- **fix(api):** the model-test skip for image/music/video-only models (#13376) returned a result with no `httpStatus`, and the `/api/models/test` route hands that field straight to `NextResponse` — so a skipped test reached the client as HTTP 200 carrying `status: "error"` in the body. It now answers 422: the request is valid, but that model's modality cannot be exercised by a chat test. Also clears the `TS2741` that was failing `API Route Typecheck` on the release branch. ([#13730](https://github.com/diegosouzapw/OmniRoute/pull/13730))
- **fix(sse):** `detectMalformedNonStream` no longer flags a Claude-format message as malformed when the content array only contains empty text blocks or the `(empty response)` sentinel and `stop_reason` is `"length"` — a legitimate truncation (e.g. `ollama/qwen3:1.7b` exhausting its reasoning budget before producing visible text), not a real 502-worthy empty response. ([#12935](https://github.com/diegosouzapw/OmniRoute/pull/12935)) — thanks @jasminsehic
- **fix(compression):** output styles and the caveman output mode now place their injected instruction in the top-level `system` field instead of a synthetic `messages[0]` entry for Anthropic-shaped requests, fixing the upstream 400 ("use the top-level 'system' parameter for the initial system prompt") ([#12584](https://github.com/diegosouzapw/OmniRoute/issues/12584)) ([#13383](https://github.com/diegosouzapw/OmniRoute/pull/13383)) — thanks @Xore
- Add a shadow release-acceptance report next to release-green.json. It does not close #12732 and is not a Mergify required check. ([#13701](https://github.com/diegosouzapw/OmniRoute/pull/13701)) — thanks @HouMinXi
- **fix(sse):** chat admission PSI uses the container cgroup `memory.pressure` file instead of host-wide `/proc/pressure/memory`, so a swapping host no longer 503s an idle Docker/cgroup OmniRoute with `resource_pressure`; the host file remains the fallback when the cgroup sample is missing ([#12562](https://github.com/diegosouzapw/OmniRoute/pull/12562)) — thanks @SCys
- **fix(responses):** ensure full compliance with the OpenAI Responses API streaming schema for strict deserializers (e.g. OpenAI Responses SDK, Grok CLI / pager): - Include `output: []`, `background: false`, and `error: null` in the `response.in_progress` lifecycle event across both the Responses transformer and response translator. - Include `status` (`in_progress` or `completed`) on all emitted output items (`message`, `reasoning`, `function_call`, `custom_tool_call`) in `response.output_item.added`, `response.output_item.done`, and `response.output[]`. - Include `sequence_number: 0` in in-band Responses stream error frames (`OPENAI_RESPONSES_ERROR_FRAME` and `buildResponsesErrorDataLine`) emitted after early keepalive streams commit. - Ensure `input_tokens_details` (with `cached_tokens: 0`) and `output_tokens_details` (with `reasoning_tokens: 0`) are always populated in `response.usage` even when upstreams (e.g. Gemini) omit reasoning or caching tokens. ([#13956](https://github.com/diegosouzapw/OmniRoute/pull/13956)) — thanks @TheDemonTuan
- **responses:** Responses-to-Chat fallback now skips replayed `web_search_call` metadata while preserving the paired function result, preventing deterministic HTTP 400 failures on follow-up turns routed to Chat Completions providers (#13304 ([#14090](https://github.com/diegosouzapw/OmniRoute/pull/14090)) — thanks @anhtran-ai)
- **fix(security):** bump the `adm-zip` override to `^0.6.1` — 0.6.0 followed a symlink already present inside the extraction root and could write outside it (GHSA-vwc7-r8mq-g2x9 / CVE-2026-76845); 0.6.1 walks every path component with `lstat` and refuses symlinks. Reached only through `onnxruntime-node`'s install script, which unpacks the vendor's own binary — no request-path exposure. ([#13737](https://github.com/diegosouzapw/OmniRoute/pull/13737))
- **fix(ci):** register the four unit tests whose mutant kills were not counting — `daily-reset-tz-threading`, `noauth-model-lockout`, `free-badge-provider-gate` and `local-token-budget-429-skips-cooldown` — in `stryker.conf.json` `tap.testFiles`. `check-mutation-test-coverage --strict` reported 6 missing coverings across 4 mutated modules (`accountFallback`, `sse/services/auth`, `combo/comboPredicates`, `combo/rrState`) on the release line itself with no PR diff involved, so the `mutation-test-coverage` gate was red on every open PR regardless of its contents; the gate now reports no drift ([#13814](https://github.com/diegosouzapw/OmniRoute/pull/13814)) — thanks @abhisheksharma2411
- fix(ci): drop a `tap.testFiles` entry naming a deleted test file, and guard the direction the existing drift check never covered — an entry left behind after its test is removed or renamed costs mutation coverage silently, because Stryker resolves the list into its sandbox without failing on a dangling path ([#13814](https://github.com/diegosouzapw/OmniRoute/pull/13814)) — thanks @abhisheksharma2411
- fix(providers): give the TinyCMS wasm-bindgen Node DOM stub a dedicated `window` with a Location-shaped object (never `window = global` without `location`), so Next.js SSR `getLocationOrigin` cannot crash every route after TinyCMS is used once ([#13957](https://github.com/diegosouzapw/OmniRoute/pull/13957)) — thanks @aref-alapour
- **fix(analytics):** resolve account email/name in Utilization Account Split chart and fix legend bleeding through tooltip ([#13029](https://github.com/diegosouzapw/OmniRoute/pull/13029)) — thanks @ZaimMarzuki
- Electron release: `electron/package-lock.json` regained the optional `electron-builder-squirrel-windows` subtree (13 entries) that `npm ci` had been refusing as out of sync — the Linux desktop leg died on it — and `electron-release.yml` gained a `build_ref` dispatch input so a release whose tag was cut with the broken lock can have its assets rebuilt from the repaired line ([#13140](https://github.com/diegosouzapw/OmniRoute/pull/13140)) — thanks @ozeas
- npm publish workflow: the CycloneDX SBOM is attached to the GitHub Release on `workflow_dispatch` publishes too (when a release for the tag exists), not only on the `release` event — v3.8.50 shipped through a staged dispatch and its release carried no SBOM until it was attached by hand from the run's `sbom-npm` artifact ([#13140](https://github.com/diegosouzapw/OmniRoute/pull/13140)) — thanks @ozeas
- **fix(providers):** strip `<script>`/`<style>` blocks from the Vertex model-docs HTML even when the end tag carries junk before the `>` (`</script foo>`, which the HTML spec still treats as a close). The old regexp required `</script\s*>`, so such a block survived; the generic tag-stripping pass then removed both tags and kept the script BODY, letting its text reach the table cells the context-window/token-limit parser reads (CodeQL `js/bad-tag-filter`, alert #1007). ([#13936](https://github.com/diegosouzapw/OmniRoute/pull/13936))
- **fix(vertex):** prefer live model discovery for OAuth, Service Account, and service-account-bound authorization-key credentials while falling back cleanly to the Gemini-only Express catalog for standard API keys. Model Garden resources now route by publisher protocol: Claude and Mistral use their native `rawPredict` APIs, and Grok plus current or future open MaaS publishers use Vertex's OpenAI-compatible endpoint with normalized request IDs such as `xai/grok-4.6`. Distinguish intentional API-key catalog rejection from transient HTTP or network failures. ([#12471](https://github.com/diegosouzapw/OmniRoute/pull/12471)) — thanks @JxnLexn
- **fix(db):** remove the periodic `wal_checkpoint(TRUNCATE)` scheduler. Truncating the WAL rewrites the shared wal-index (`storage.sqlite-shm`) while other connections and in-flight statements still hold it mapped, which crashed long-running servers with SIGBUS roughly every six hours (#13973). WAL hygiene is unchanged: PASSIVE checkpoints still run every five minutes, now count busy contention and retry after 60s instead of silently logging, and a WAL above `OMNIROUTE_WAL_GUARD_MAX_MB` runs `wal_checkpoint(RESTART)` so the file stays bounded without rewriting the mapped index. The shutdown checkpoint still truncates. `OMNIROUTE_WAL_TRUNCATE_INTERVAL_MS` is ignored and logs a deprecation warning. ([#14005](https://github.com/diegosouzapw/OmniRoute/pull/14005)) — thanks @HouMinXi
- **fix(antigravity):** surface image quotas in Provider Limits ([#12311](https://github.com/diegosouzapw/OmniRoute/pull/12311)) — thanks @Bl0ck154
- **fix(perplexity-web):** preserve system contract on follow-up requests ([#12443](https://github.com/diegosouzapw/OmniRoute/pull/12443)) — thanks @tanveer-arch
- **fix(translator):** map Claude stop_sequences and stop to Gemini stopSequences ([#12785](https://github.com/diegosouzapw/OmniRoute/pull/12785)) — thanks @Siva010
- **fix(cursor):** preserve native Claude effort model IDs before executor dispatch ([#12838](https://github.com/diegosouzapw/OmniRoute/pull/12838)) — thanks @Pllutonyy
- **fix(sse):** guard empty tool_calls[] and strip tool_choice without tools ([#12901](https://github.com/diegosouzapw/OmniRoute/pull/12901)) — thanks @initguru
- **fix(resilience):** allow maxWaitMs=0 as disable sentinel for execution expiration ([#12902](https://github.com/diegosouzapw/OmniRoute/pull/12902)) — thanks @initguru
- **fix(sse):** demote mid-conversation system roles to user in claude-to-openai translation ([#12908](https://github.com/diegosouzapw/OmniRoute/pull/12908)) — thanks @initguru
- **fix(soniox):** pass client parameters through and surface speaker diarization ([#12948](https://github.com/diegosouzapw/OmniRoute/pull/12948)) — thanks @amirrezakm
- **fix(tests):** drain call-log saves before chat-pipeline DB resets (#12780) ([#12966](https://github.com/diegosouzapw/OmniRoute/pull/12966)) — thanks @visheshgubrani
- **fix(providers):** resolve unsupportedParams via model aliases so K3 stops 400ing on temperature ([#13037](https://github.com/diegosouzapw/OmniRoute/pull/13037)) — thanks @patrykkopycinski
- **fix(db):** gate the auto-cleanup VACUUM on reclaimable space, not row count ([#13079](https://github.com/diegosouzapw/OmniRoute/pull/13079)) — thanks @hartmark
- **fix(logs):** recover concatenated JSON objects in the Provider Event Stream viewer ([#13115](https://github.com/diegosouzapw/OmniRoute/pull/13115)) — thanks @hartmark
- **fix(quota):** apply the equal-split fallback in the pool usage snapshot ([#13159](https://github.com/diegosouzapw/OmniRoute/pull/13159)) — thanks @datrixlab
- **fix(deps):** declare remark-gfm as a direct dependency ([#13162](https://github.com/diegosouzapw/OmniRoute/pull/13162)) — thanks @marioschoenert-code
- **fix(responses):** count input tokens locally for Codex OAuth ([#13167](https://github.com/diegosouzapw/OmniRoute/pull/13167)) — thanks @anhtran-ai
- **fix(sse):** deepseek-web resilience — premature session close + malformed tool-call recovery ([#13226](https://github.com/diegosouzapw/OmniRoute/pull/13226)) — thanks @VictorRP7
- **fix(memory):** skip FTS rewrite on access-count updates ([#13331](https://github.com/diegosouzapw/OmniRoute/pull/13331)) — thanks @HouMinXi
- **fix(security,resilience):** block origin-IP header forwarding and treat 413 as retryable TPM ([#13350](https://github.com/diegosouzapw/OmniRoute/pull/13350)) — thanks @themedexperiencesusa
- **fix(build):** copy ioredis and bcryptjs into the standalone bundle ([#13352](https://github.com/diegosouzapw/OmniRoute/pull/13352)) — thanks @sistemabritto
- **fix(embeddings):** send stored API key on private-host embeddings nodes ([#13398](https://github.com/diegosouzapw/OmniRoute/pull/13398)) — thanks @HouMinXi
- **fix(claude-web):** add charset=utf-8 to Content-Type headers to fix Arabic/Persian UTF-8 mojibake (#13416) ([#13419](https://github.com/diegosouzapw/OmniRoute/pull/13419)) — thanks @KooshaPari
- **fix(compression):** track only the recursion path in isStrictlySerializable (#13154) ([#13423](https://github.com/diegosouzapw/OmniRoute/pull/13423)) — thanks @KooshaPari
- **fix(network):** skip Chrome TLS impersonation for Groq ([#13445](https://github.com/diegosouzapw/OmniRoute/pull/13445)) — thanks @HouMinXi
- **fix(gemini):** send thinkingLevel for 3.8 Flash so thoughts stop eating the output cap ([#13463](https://github.com/diegosouzapw/OmniRoute/pull/13463)) — thanks @HouMinXi
- **fix(compression):** log warnings for unreadable settings rows ([#13522](https://github.com/diegosouzapw/OmniRoute/pull/13522)) — thanks @KooshaPari
- **fix(proxy):** add combo scope to fail-closed proxy guard ([#13551](https://github.com/diegosouzapw/OmniRoute/pull/13551)) — thanks @KooshaPari
- **fix(combos):** stop Gemini thinking from failing dashboard combo tests ([#13560](https://github.com/diegosouzapw/OmniRoute/pull/13560)) — thanks @HouMinXi
- **fix(sse):** strip trailing assistant prefill on official Claude OAuth ([#13572](https://github.com/diegosouzapw/OmniRoute/pull/13572)) — thanks @HouMinXi
- **fix(usage):** detach completed request previews ([#13623](https://github.com/diegosouzapw/OmniRoute/pull/13623)) — thanks @jbovard2016
- **fix(providers):** clamp SenseNova DeepSeek V4 Flash effort to high ([#13626](https://github.com/diegosouzapw/OmniRoute/pull/13626)) — thanks @HouMinXi
- **fix(routing):** skip redundant parseAutoPrefix for recognized built-in auto variants ([#13647](https://github.com/diegosouzapw/OmniRoute/pull/13647)) — thanks @hummern
- **fix(embeddings):** log server-side when a provider can't be resolved ([#13687](https://github.com/diegosouzapw/OmniRoute/pull/13687)) — thanks @hartmark
- **fix(codex):** forward the caller client version upstream instead of a pinned default ([#13708](https://github.com/diegosouzapw/OmniRoute/pull/13708)) — thanks @zeeshanhaque21
- **fix(gemini):** a tool name starting with a digit no longer fails the request ([#13738](https://github.com/diegosouzapw/OmniRoute/pull/13738)) — thanks @L4XB
- **fix(docker):** smoke-test images before promoting latest ([#13761](https://github.com/diegosouzapw/OmniRoute/pull/13761)) — thanks @lorenzozanee
- **fix(admission):** measure request bodies with the active cost budget ([#13762](https://github.com/diegosouzapw/OmniRoute/pull/13762)) — thanks @lorenzozanee
- **fix(providers):** add missing modelsUrl for qwen-cloud-token-plan ([#13764](https://github.com/diegosouzapw/OmniRoute/pull/13764)) — thanks @lorenzozanee
- **fix(providers):** bypass web-search fallback for Antigravity target (#13447) ([#13765](https://github.com/diegosouzapw/OmniRoute/pull/13765)) — thanks @lorenzozanee
- **fix(resilience):** name collision keys, surface header drops, retry embeds, skip far-reset pings ([#13766](https://github.com/diegosouzapw/OmniRoute/pull/13766)) — thanks @lorenzozanee
- **fix(compression):** preserve case, tags and negations through llmlingua engine ([#13768](https://github.com/diegosouzapw/OmniRoute/pull/13768)) — thanks @lorenzozanee
- **fix(sse):** skip already-refused route per request on 429 ([#13795](https://github.com/diegosouzapw/OmniRoute/pull/13795)) — thanks @maxmad64bis
- **fix(antigravity):** canonicalize tiered flash quota ([#13809](https://github.com/diegosouzapw/OmniRoute/pull/13809)) — thanks @domenicomassafra
- **fix(executors):** strip invalid OpenCode stream options ([#13819](https://github.com/diegosouzapw/OmniRoute/pull/13819)) — thanks @qinghuanandejiangshi
- **fix(providers):** refresh uncloseai free-model roster after upstream rotation ([#13825](https://github.com/diegosouzapw/OmniRoute/pull/13825)) — thanks @legas888Oleg
- **fix:** match compatible-provider models owned by public prefix ([#13831](https://github.com/diegosouzapw/OmniRoute/pull/13831)) — thanks @sahildaswani
- **fix(ci):** drain three base reds blocking every PR — generated SKILL.md, stryker registry, env/docs contract ([#13834](https://github.com/diegosouzapw/OmniRoute/pull/13834))
- **fix(sre):** redact secrets split across stream chunks ([#13837](https://github.com/diegosouzapw/OmniRoute/pull/13837)) — thanks @pacocartones
- **fix(qoder):** unwrap split SSE error envelopes ([#13838](https://github.com/diegosouzapw/OmniRoute/pull/13838)) — thanks @pacocartones
- **fix(streaming):** track progress across chunk boundaries ([#13839](https://github.com/diegosouzapw/OmniRoute/pull/13839)) — thanks @pacocartones
- **fix(nlpcloud):** restore chatbot endpoint coverage ([#13845](https://github.com/diegosouzapw/OmniRoute/pull/13845)) — thanks @pacocartones
- **fix(translator):** pair Gemini tool responses per turn to prevent cross-turn ID collision ([#13848](https://github.com/diegosouzapw/OmniRoute/pull/13848)) — thanks @TheDemonTuan
- **fix:** reclaim expired half-open probe lease ([#13849](https://github.com/diegosouzapw/OmniRoute/pull/13849)) — thanks @luw2007
- **fix(providers):** normalize non-function tools for allowlisted built-in OpenAI-format providers ([#13855](https://github.com/diegosouzapw/OmniRoute/pull/13855)) — thanks @xiechimon
- **fix(auth):** enforce blocked models in all-access mode ([#13861](https://github.com/diegosouzapw/OmniRoute/pull/13861)) — thanks @fewensa
- **fix(codex):** preserve native custom tools in Responses WebSocket requests ([#13864](https://github.com/diegosouzapw/OmniRoute/pull/13864)) — thanks @trycohn
- **fix(docs):** restore the env/docs contract broken by the #13679 vars ([#13875](https://github.com/diegosouzapw/OmniRoute/pull/13875))
- **fix(chatCore):** only preserve tool_result blocks for Claude-native targets ([#13972](https://github.com/diegosouzapw/OmniRoute/pull/13972)) — thanks @phs1997
- **fix(windows):** hide supervised server console ([#13992](https://github.com/diegosouzapw/OmniRoute/pull/13992)) — thanks @prabhtheone
- **fix(ci):** document OMNIROUTE_STRIP_SYSTEM_PREAMBLE — the env/docs base red blocking every PR ([#14022](https://github.com/diegosouzapw/OmniRoute/pull/14022))
- **fix(providers):** declare Agnes official thinking effort tiers (#13655) ([#14063](https://github.com/diegosouzapw/OmniRoute/pull/14063)) — thanks @HouMinXi
- **fix(resilience):** extend process crash guard to combo hedge cancels and upstream fetch failures (#13636) ([#14064](https://github.com/diegosouzapw/OmniRoute/pull/14064)) — thanks @HouMinXi
- **fix(codex):** whitelist reasoning object keys before the wire (#13643) ([#14065](https://github.com/diegosouzapw/OmniRoute/pull/14065)) — thanks @HouMinXi
- **fix(sse):** aggregate the `findInsensitive` collision warnings into one line per catalog build instead of one per colliding key ([#12972](https://github.com/diegosouzapw/OmniRoute/pull/12972)) — thanks @IAMBOBJIM
### 📝 Maintenance
@@ -1083,6 +1502,62 @@ _By commits in `091589089c..c0f92ec98a`, author identities consolidated via `.ma
- **chore(deps):** bump hono from ^4.12.34 to ^4.13.7 (#13148) ([#13301](https://github.com/diegosouzapw/OmniRoute/pull/13301)) — thanks @KooshaPari
- **chore(deps):** pin joi to ^18.2.8 via overrides (#13085) ([#13302](https://github.com/diegosouzapw/OmniRoute/pull/13302)) — thanks @KooshaPari
- **docs(providers):** add Microsoft 365 Copilot (BizChat) provider guide (#12779) ([#13340](https://github.com/diegosouzapw/OmniRoute/pull/13340)) — thanks @KooshaPari
- `Coverage` job on `ci.yml`: the informational Codecov upload gets its own 5-minute ceiling and `continue-on-error`, and the job budget grows from 20 to 30 minutes (the 8-shard c8 merge alone takes ~10) — a stalled upload no longer ends the job `cancelled` and drags a fully green `main` run's conclusion down with it ([#11972](https://github.com/diegosouzapw/OmniRoute/pull/11972))
- **docs(dependencies):** clarify that `socket.yml` only shapes Socket.dev's registry-side post-publish scan of the published npm artifact — it is not an enforced CI/PR merge gate ([#12664](https://github.com/diegosouzapw/OmniRoute/pull/12664) — thanks @toor11).
- **chore(skills):** regenerate the `omni-settings` agent skill after the pool egress-observation route landed (#13581), clearing the `check:agent-skills-sync` base-red (#12732) ([#13826](https://github.com/diegosouzapw/OmniRoute/pull/13826))
- **test(call-logs):** the early-keepalive merge and video-bridge redaction tests pass a `traceId` (defaulting to `pendingRequestId`) now that #13546 keys each attempt's call-log row on it, the dashboard `request.failed` redaction probe reads the persisted row by `traceId`, and the keepalive test polls against a 30s wall-clock deadline like the video-bridge test instead of a 2.4s try count ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) ([#13747](https://github.com/diegosouzapw/OmniRoute/pull/13747))
- **fix(db):** drop the duplicated `ERROR_TYPE_CONTRACT` import in `src/lib/db/callLogStats.ts` left by the #13641 merge; the TS2300 duplicate-identifier error failed the API-route and dashboard typecheck gates on every PR ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) ([#13747](https://github.com/diegosouzapw/OmniRoute/pull/13747))
- **fix(cli):** add the `serve.ready_timeout` string to the `en`, `zh-CN` and `zh-TW` CLI catalogs; `--ready-timeout` shipped calling `t("serve.ready_timeout")` without a catalog entry, which the CLI i18n key-coverage and parity tests report ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) ([#13747](https://github.com/diegosouzapw/OmniRoute/pull/13747))
- **docs(env):** document `OMNIROUTE_SYNCED_CATALOG_STALE_AFTER_MS` (#12849, default 30 days) in `.env.example` and `ENVIRONMENT.md`; the stale-synced-catalog fail-open shipped the override without either, which the env/docs contract gate reports as code-only ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) ([#13678](https://github.com/diegosouzapw/OmniRoute/pull/13678))
- **fix(ci):** re-freeze `tests/unit/image-generation-handler.test.ts` (2133→2235, #13748) and `tests/unit/batch_api.test.ts` (1345→1348, #13749) at their merged size; PR-mode `check:file-size` does not relax `testFrozen` against the base, so that regression coverage turned the gate red on every PR into the release line ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) ([#13747](https://github.com/diegosouzapw/OmniRoute/pull/13747))
- **fix(ci):** allowlist the Uzbek `outputTokenDesc` translation ("Yakunlash/javob tokenlari") and the `PROTECTED_PRIORITY_INFRA_502_ENABLED` feature-flag id (#13439) in `.gitleaks.toml`; the `generic-api-key` rule reads the `...TokenDesc` key and the flag `key:` as token assignments, which the secrets ratchet reported as new findings ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) ([#13747](https://github.com/diegosouzapw/OmniRoute/pull/13747))
- **test(models):** the custom Jina specialty-model catalog test expects the `jina-ai/` prefix again: custom rows keep the connection provider id, only synced rows resolve through the `jina` alias, and #13403 had switched the custom assertion to `jina/` ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) ([#13747](https://github.com/diegosouzapw/OmniRoute/pull/13747))
- **chore(build):** ship `httpClientAbortGuard.mjs` in the published tarball — the #13636 crash guard was a new `server-ws.mjs` import missing from both pack-artifact allowlists (#12732) ([#13826](https://github.com/diegosouzapw/OmniRoute/pull/13826))
- **test(settings):** the #6540 paid-target tests now use `gemini/gemini-3.1-pro-preview` as the paid fixture and assert the fixtures still classify as paid/free/unknown; the old Together target became "unknown" once #13407 removed Together's one-time signup credit from the free catalog, so the three save-time blocking tests read a correct 200 as a missing guard ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) ([#13747](https://github.com/diegosouzapw/OmniRoute/pull/13747))
- **fix(ci):** the release-green validator now runs its package-artifact gate the way `ci.yml` does — build, stamp `dist/BUILD_SHA`, then validate against the tree under test. `build:cli` never writes the stamp, so even with the provenance ref pointed at `HEAD` the gate could only ever report "dist/BUILD_SHA is missing" once the build itself compiled ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) ([#13678](https://github.com/diegosouzapw/OmniRoute/pull/13678))
- **test(resilience):** the `/api/resilience` configuration-only key-set assertion now lists `credentialHealthCheck`, the sweep-interval setting #12043 added to the projection, so the integration suite stops reading a documented configuration key as leaked runtime state ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) ([#13678](https://github.com/diegosouzapw/OmniRoute/pull/13678))
- **fix(ci):** register `noauth-model-lockout`, `local-token-budget-429-skips-cooldown`, `free-badge-provider-gate` (#13645) and `daily-reset-tz-threading` (#13440) in `stryker.conf.json` `tap.testFiles`; they cover `accountFallback.ts`/`auth.ts`/`comboPredicates.ts`/`rrState.ts`, so the strict `mutation-test-coverage` gate failed Fast Quality Gates on every PR into the release line ([#12732](https://github.com/diegosouzapw/OmniRoute/issues/12732)) ([#13747](https://github.com/diegosouzapw/OmniRoute/pull/13747))
- **chore(ci):** register the #13609 ambiguous-401 regression test in the mutation-coverage config, clearing the second `check:agent-skills-sync`/`mutation-test-coverage` base-red (#12732) ([#13826](https://github.com/diegosouzapw/OmniRoute/pull/13826))
- **fix(tests):** eight combo integration suites still targeted the retired `claude-3-5-sonnet-20241022`, which the lifecycle registry rejects with 410; they now use its successor `claude-sonnet-4-6`, un-hiding 23 routing cases ([#13056](https://github.com/diegosouzapw/OmniRoute/pull/13056)) — thanks @doramirdor
- **test(coverage):** clean stale `coverage/` output before `test:coverage` runs, stopping unbounded accumulation of c8 raw snapshots and reports ([#13408](https://github.com/diegosouzapw/OmniRoute/pull/13408)) — thanks @MumuTW
- **docs(resilience):** correct `requestQueue.maxWaitMs` in the resilience guide and the environment reference — it bounds **queue wait**, not limiter-managed execution (that is `executionMaxWaitMs`), and the env vars only supply defaults that a persisted or per-connection value overrides (#13624) — thanks @L4XB
- **test(batches):** the two seeded-batch labels of the delete-completed route-scope suite that sat right after a `key*.id` argument are renamed to short literals (`route401`/`route500`), so a gitleaks scan that reads those lines (full-tree, or git-mode on a branch that adds them) no longer reports them as `generic-api-key` hits ([#13729](https://github.com/diegosouzapw/OmniRoute/pull/13729)) — no gate changes: the CI secret ratchet scans `src`/`open-sse`/`bin`/`electron`/`scripts`, never `tests/`
- **credit:** three squash merges on `release/v3.8.51` dropped the contributor attribution the review pipeline had preserved on the branches — the dual-layer semantic cache ([#14159](https://github.com/diegosouzapw/OmniRoute/pull/14159), re-land of [#12630](https://github.com/diegosouzapw/OmniRoute/pull/12630)) is @BillyOutlast's work, the native Codex auto-resume fix ([#14162](https://github.com/diegosouzapw/OmniRoute/pull/14162), re-land of [#13180](https://github.com/diegosouzapw/OmniRoute/pull/13180)) is @mdigitalbh81's, and the `resolvedExtensionEnd` reorder in [#13295](https://github.com/diegosouzapw/OmniRoute/pull/13295) landed first in @ggiak's [#13036](https://github.com/diegosouzapw/OmniRoute/pull/13036). This entry and its commit trailers record that credit ([#14361](https://github.com/diegosouzapw/OmniRoute/pull/14361)) — thanks @BillyOutlast, @mdigitalbh81 and @ggiak
- **chore(quality):** clear the `release/v3.8.51` base-reds — 19 failing unit tests plus the `API Route Typecheck` and `mutation-test-coverage` gates. Three fixtures still built `*-compatible-*` connections with no `baseUrl` and so tripped the #13452/#13798 guard that now refuses to fall back to the real OpenAI/Anthropic API; `modelDiscovery.ts` missed the `VertexModelMetadataProvenance` cast its read-path twin already had (#12471); a raw NUL byte in a provider-test regexp made git treat the file as binary; and the reserved-prefix count, the budget-card SVG and the Stryker `tap.testFiles` list had drifted. The #2331, OAuth-loopback and i18n guards were re-expressed as the invariants they protect — each re-verified by mutating the source back and watching it fail. ([#13947](https://github.com/diegosouzapw/OmniRoute/pull/13947))
- **ci:** repair the `API Route Typecheck` base-red on `release/v3.8.51` — type the awaited-callback contract of `runWithConnectionFetch` (glmResetCards), annotate `handleSingleModelChat(): Promise<Response>`, and give the codex-responses-ws bridge helpers real `{ error } | payload` discriminants so `"error" in x` narrows again; baseline ratcheted 294 → 283 (no widening). ([#14079](https://github.com/diegosouzapw/OmniRoute/pull/14079))
- **quality:** rebaseline `open-sse/executors/codex.ts` 1552 → 1553 — the +1 landed with #14065 (#13643) without a baseline entry and turned `check:file-size` red on the release tip.
- **compression/tests:** repair the unit base-reds on `release/v3.8.51` left by the 09-17 merge wave — RTK dedup was skipped for every unknown-type text (#13521 widened `skipFilters` to `isDocumentLikeRead`), so repeated tool output stopped compressing (12 tests); the translate-path golden, the `APIKEY_PROVIDERS` count and the G13 golden/SSE `Content-Type` assertions were stale after #12648 (xKiro) and #13419 (`charset=utf-8`). ([#14082](https://github.com/diegosouzapw/OmniRoute/pull/14082))
- **quality:** owner-approved file-size rebaseline for the 2026-09-18 merge-train 8 (32 contributor PRs whose irreducible growth lands in already-frozen files — 28 ceilings raised to the measured combined sizes; per-PR attribution in `config/quality/file-size-baseline.json`).
- **chore(quality):** adjust the train-8 accountFallback ceiling to the re-measured 2517 (direct commit `271ec25f12`)
- **test(usage):** pin fetcherProviders against supportedProviders ([#13134](https://github.com/diegosouzapw/OmniRoute/pull/13134)) — thanks @abhisheksharma2411
- **chore:** add Windows helpers to run OmniRoute and Claude Code from a source checkout ([#13312](https://github.com/diegosouzapw/OmniRoute/pull/13312)) — thanks @easypathuni
- **test:** realign two stale assertions with product behavior (#13313) ([#13315](https://github.com/diegosouzapw/OmniRoute/pull/13315)) — thanks @anhtahaylove
- **chore(stryker):** register 3 covering unit tests missing from tap.testFiles ([#13357](https://github.com/diegosouzapw/OmniRoute/pull/13357)) — thanks @patrykkopycinski
- **test(adobe-firefly):** lock in the browser-spawn guard with a regression test ([#13358](https://github.com/diegosouzapw/OmniRoute/pull/13358)) — thanks @patrykkopycinski
- **chore(lint):** prune 3 obsolete eslint suppressions ([#13359](https://github.com/diegosouzapw/OmniRoute/pull/13359)) — thanks @patrykkopycinski
- **chore(skills):** regenerate cli-serve after --ready-timeout was added ([#13722](https://github.com/diegosouzapw/OmniRoute/pull/13722))
- **docs(changelog):** reconcile the v3.8.51 living section — round 2 (2026-09-15) ([#13731](https://github.com/diegosouzapw/OmniRoute/pull/13731))
- **ci(radar-export):** publish on release-branch catalog pushes; daily schedule ([#13736](https://github.com/diegosouzapw/OmniRoute/pull/13736))
- **docs:** prefill issue titles with Conventional Commits format ([#13750](https://github.com/diegosouzapw/OmniRoute/pull/13750)) — thanks @xiechimon
- **test(embeddings):** cover custom embeddings authorization ([#13763](https://github.com/diegosouzapw/OmniRoute/pull/13763)) — thanks @lorenzozanee
- **test(combo):** gate-level regression for stale persisted-cooldown skip (#13694) ([#13767](https://github.com/diegosouzapw/OmniRoute/pull/13767)) — thanks @lorenzozanee
- **test(routing):** guard Astra vision cutover ([#13810](https://github.com/diegosouzapw/OmniRoute/pull/13810)) — thanks @domenicomassafra
- **test(dashboard):** reactivate API endpoints coverage ([#13841](https://github.com/diegosouzapw/OmniRoute/pull/13841)) — thanks @pacocartones
- **test(dashboard):** reactivate discovery page coverage ([#13842](https://github.com/diegosouzapw/OmniRoute/pull/13842)) — thanks @pacocartones
- **test(dashboard):** reactivate request logger coverage ([#13843](https://github.com/diegosouzapw/OmniRoute/pull/13843)) — thanks @pacocartones
- **test(dashboard):** reactivate webhook wizard coverage ([#13844](https://github.com/diegosouzapw/OmniRoute/pull/13844)) — thanks @pacocartones
- **chore:** reconcile the 2026-09-16 merge wave with the release tip ([#13904](https://github.com/diegosouzapw/OmniRoute/pull/13904))
- **test(catalog):** stop the yield guard from dying on production's build budget ([#13906](https://github.com/diegosouzapw/OmniRoute/pull/13906))
- **chore:** reconcile the JxnLexn merge wave with the release tip ([#13921](https://github.com/diegosouzapw/OmniRoute/pull/13921))
- **docs(agents):** advance the documented Bun pin to 1.4.2 ([#13946](https://github.com/diegosouzapw/OmniRoute/pull/13946))
- **chore(quality):** rebaseline the four ceilings the 2026-09-17 merge wave moved ([#14002](https://github.com/diegosouzapw/OmniRoute/pull/14002))
- **chore(quality):** rebaseline the last ceiling the 09-17 merge wave moved ([#14016](https://github.com/diegosouzapw/OmniRoute/pull/14016))
- **test(dashboard):** reactivate logs modal coverage ([#14048](https://github.com/diegosouzapw/OmniRoute/pull/14048)) — thanks @pacocartones
- **chore(deps):** bump better-sqlite3 to ^13.0.3 ([#14049](https://github.com/diegosouzapw/OmniRoute/pull/14049)) — thanks @Hakarioz
- **ci(acceptance):** emit a shadow release-acceptance report next to release-green (#13701) ([#14066](https://github.com/diegosouzapw/OmniRoute/pull/14066)) — thanks @HouMinXi
- **docs(i18n):** refresh the mirrors and keys the base left behind; warn on stale mirrors ([#14166](https://github.com/diegosouzapw/OmniRoute/pull/14166))
- **deps:** 4 Dependabot bumps — bump oven/bun from 1.4.0-slim to 1.4.2-slim ([#12977](https://github.com/diegosouzapw/OmniRoute/pull/12977)); bump js-yaml ([#13212](https://github.com/diegosouzapw/OmniRoute/pull/13212)); bump the development group across 1 directory with 15 updates ([#13661](https://github.com/diegosouzapw/OmniRoute/pull/13661)); bump electron from 44.0.0 to 44.3.0 in /electron ([#13664](https://github.com/diegosouzapw/OmniRoute/pull/13664))
### 🙌 Contributors
@@ -1091,120 +1566,235 @@ Thanks to everyone whose work landed in v3.8.51:
| Contributor | PRs / Issues |
| --- | --- |
| [@5dive-bot](https://github.com/5dive-bot) | #11852 |
| [@abhisheksharma2411](https://github.com/abhisheksharma2411) | #11547, #11684, #11959, #12209 |
| [@aaustinhuang](https://github.com/aaustinhuang) | #13201, #13206 |
| [@abhisheksharma2411](https://github.com/abhisheksharma2411) | #11547, #11684, #11959, #12209, #12235, #13134, #13295, #13673, #13814, #13820 |
| [@adevwithpurpose](https://github.com/adevwithpurpose) | #11464 |
| [@adityadwi21](https://github.com/adityadwi21) | #13776 |
| [@adivekar-utexas](https://github.com/adivekar-utexas) | #12015, #12043, #12090, #12138 |
| [@afonsoft](https://github.com/afonsoft) | #13801, #13802, #13807 |
| [@ai-jeremi-esky](https://github.com/ai-jeremi-esky) | #13874 |
| [@AIB1TAL0S](https://github.com/AIB1TAL0S) | #12103 |
| [@alltomatos](https://github.com/alltomatos) | #11448 |
| [@alvinveroy](https://github.com/alvinveroy) | #11857, #12027, #12165 |
| [@alvinveroy](https://github.com/alvinveroy) | #11857, #12027, #12165, #13002 |
| [@amaleta](https://github.com/amaleta) | #11906, #11934 |
| [@amartinawi](https://github.com/amartinawi) | #11575 |
| [@amirrezakm](https://github.com/amirrezakm) | #12309, #12310, #12948 |
| [@andrea-kingautomation](https://github.com/andrea-kingautomation) | #13785 |
| [@AndrianBalanescu](https://github.com/AndrianBalanescu) | #11083, #11830, #11834, #11844 |
| [@anhtahaylove](https://github.com/anhtahaylove) | #13091, #13092, #13093, #13096, #13097, #13100, #13106, #13109, #13114, #13155, #13166, #13171, #13175, #13187, #13196 |
| [@AnhLead](https://github.com/AnhLead) | #13378 |
| [@anhtahaylove](https://github.com/anhtahaylove) | #13091, #13092, #13093, #13096, #13097, #13100, #13106, #13109, #13114, #13155, #13166, #13171, #13175, #13187, #13196, #13289, #13292, #13315, #13630, #13778 |
| [@anhtran-ai](https://github.com/anhtran-ai) | #13167, #14090 |
| [@aniruddhaadak80](https://github.com/aniruddhaadak80) | #11760 |
| [@app](https://github.com/app) | #12554 |
| [@Ardem2025](https://github.com/Ardem2025) | #7138, #9908, #9944, #13929 |
| [@aref-alapour](https://github.com/aref-alapour) | #12799, #13957 |
| [@arjav1181](https://github.com/arjav1181) | #13910 |
| [@arminanton](https://github.com/arminanton) | #11461, #11513 |
| [@AStupidBear](https://github.com/AStupidBear) | #11584 |
| [@atescivitci-cmd](https://github.com/atescivitci-cmd) | #12105 |
| [@b3nw](https://github.com/b3nw) | #11970, #11971, #12147 |
| [@backryun](https://github.com/backryun) | #11259, #11950, #12075, #12076, #12078, #12079, #12081, #12082, #12181, #12228, #12239, #12255, #12258, #12277, #12367, #12423, #12524, #12538 |
| [@Beexly](https://github.com/Beexly) | #12406 |
| [@benjaminkitt](https://github.com/benjaminkitt) | #11747 |
| [@benzntech](https://github.com/benzntech) | #11614, #11615 |
| [@Bl0ck154](https://github.com/Bl0ck154) | #11948, #11951, #11952, #11953, #11954 |
| [@brick30llc-ctrl](https://github.com/brick30llc-ctrl) | #11752, #12242, #12262 |
| [@BillyOutlast](https://github.com/BillyOutlast) | #14159, #14361 |
| [@birdleandro-bit](https://github.com/birdleandro-bit) | #13222 |
| [@Bl0ck154](https://github.com/Bl0ck154) | #11948, #11951, #11952, #11953, #11954, #12311, #13090 |
| [@botii16](https://github.com/botii16) | #12825 |
| [@brick30llc-ctrl](https://github.com/brick30llc-ctrl) | #11752, #12242, #12252, #12262 |
| [@caniko](https://github.com/caniko) | #12952 |
| [@chatchawan-simplewish](https://github.com/chatchawan-simplewish) | #13223 |
| [@Chewji9875](https://github.com/Chewji9875) | #11455, #11875, #12028 |
| [@cheynetom](https://github.com/cheynetom) | #12058 |
| [@davidebaraldo](https://github.com/davidebaraldo) | #12222, #12304 |
| [@costajohnt](https://github.com/costajohnt) | #13895, #14009, #14010 |
| [@CrashCartCapital](https://github.com/CrashCartCapital) | #13446 |
| [@cryptiklemur](https://github.com/cryptiklemur) | #13149, #13150, #13173, #13266 |
| [@dajiaohuang](https://github.com/dajiaohuang) | #13201, #13206 |
| [@datrixlab](https://github.com/datrixlab) | #13159, #13320, #13321, #13322, #13323, #13327, #13328, #13329, #13333, #13334, #13335 |
| [@davidebaraldo](https://github.com/davidebaraldo) | #12222, #12304, #13074 |
| [@davidlinfr](https://github.com/davidlinfr) | #6390 |
| [@ddarkr](https://github.com/ddarkr) | #12124 |
| [@Deftera186](https://github.com/Deftera186) | #11809 |
| [@dpozimski](https://github.com/dpozimski) | #11945 |
| [@delafu](https://github.com/delafu) | #13803 |
| [@DenXio101](https://github.com/DenXio101) | #13798 |
| [@dmlanday](https://github.com/dmlanday) | #12484, #12485 |
| [@domenicomassafra](https://github.com/domenicomassafra) | #13168, #13659, #13809, #13810 |
| [@doramirdor](https://github.com/doramirdor) | #13056 |
| [@dpozimski](https://github.com/dpozimski) | #11945, #13635 |
| [@drmikecrypto](https://github.com/drmikecrypto) | #12565 |
| [@ducphamtien-fonos](https://github.com/ducphamtien-fonos) | #13128 |
| [@DW-MediaLab](https://github.com/DW-MediaLab) | #12229 |
| [@dylanhaskins](https://github.com/dylanhaskins) | #13856 |
| [@easypathuni](https://github.com/easypathuni) | #13312 |
| [@echel0nn](https://github.com/echel0nn) | #11923 |
| [@elielsousa-pathbit](https://github.com/elielsousa-pathbit) | #13444, #13793 |
| [@f9td56dbgh-hub](https://github.com/f9td56dbgh-hub) | #11660 |
| [@fabioluissilva](https://github.com/fabioluissilva) | #11991 |
| [@Falco20100](https://github.com/Falco20100) | #13913 |
| [@fewensa](https://github.com/fewensa) | #13861 |
| [@fidelix](https://github.com/fidelix) | #12989, #13790 |
| [@foreveryh](https://github.com/foreveryh) | #12177 |
| [@formilw](https://github.com/formilw) | #13784, #13919 |
| [@fouadSalkini](https://github.com/fouadSalkini) | #12585, #13466, #13666, #13670, #13951 |
| [@ftevxk](https://github.com/ftevxk) | #13772 |
| [@Gaulnews](https://github.com/Gaulnews) | #12520 |
| [@geek007git](https://github.com/geek007git) | #12115, #12116, #12117, #12120, #12122 |
| [@geekyNads](https://github.com/geekyNads) | #11784, #12266 |
| [@ggiak](https://github.com/ggiak) | #11561, #12276, #12350, #12402 |
| [@gonisulaimann](https://github.com/gonisulaimann) | #12368, #12369, #12371 |
| [@ggdayup](https://github.com/ggdayup) | #13533 |
| [@ggiak](https://github.com/ggiak) | #11561, #12276, #12350, #12402, #13295 |
| [@giauphan](https://github.com/giauphan) | #13324 |
| [@gonisulaimann](https://github.com/gonisulaimann) | #12368, #12369, #12371, #12735, #12736, #13741, #14164 |
| [@Gorillaz322](https://github.com/Gorillaz322) | #12207 |
| [@groovecityJO](https://github.com/groovecityJO) | #12682 |
| [@hartmark](https://github.com/hartmark) | #11434, #11452, #11473, #11499, #11703, #11983, #11984, #11985, #11986, #11988, #11989, #11990, #11994, #12221, #12293, #12445, #12446, #12447, #12448, #12460, #12461, #12623, #12646, #12650, #12680, #12717, #12718, #12727, #12741, #12854 |
| [@Hakarioz](https://github.com/Hakarioz) | #14049 |
| [@hartmark](https://github.com/hartmark) | #11434, #11452, #11473, #11499, #11703, #11983, #11984, #11985, #11986, #11988, #11989, #11990, #11994, #12221, #12293, #12445, #12446, #12447, #12448, #12460, #12461, #12623, #12646, #12650, #12680, #12717, #12718, #12727, #12741, #12854, #12995, #12999, #13071, #13078, #13079, #13115, #13338, #13573, #13687, #13749 |
| [@hizzt](https://github.com/hizzt) | #11894 |
| [@honeypot55](https://github.com/honeypot55) | #13751 |
| [@hongnoul](https://github.com/hongnoul) | #11484 |
| [@HouMinXi](https://github.com/HouMinXi) | #11411, #11414, #11512, #11518, #11520, #11641, #11642, #11643, #11687, #11779, #11849, #11850, #11851, #11915, #11916, #11918, #11919, #11920, #12013, #12017, #12033, #12042, #12106, #12139, #12166, #12169, #12171, #12205, #12213, #12312, #12325, #12487, #12488, #12495, #12504, #12557, #12566, #12590, #12591, #12624, #12626, #12632, #12637, #12678, #12696, #12697, #12711, #12733, #12746, #12767, #12770, #12789, #12803, #12805, #12811, #12866, #12868, #12899, #12926, #12934, #12950, #12951, #12974, #13001, #13006, #13011, #13017, #13026, #13027, #13034, #13035, #13038, #13042, #13050, #13060, #13061, #13069, #13107, #13120, #13136, #13195, #13197 |
| [@HouMinXi](https://github.com/HouMinXi) | #11411, #11414, #11512, #11518, #11520, #11641, #11642, #11643, #11687, #11779, #11849, #11850, #11851, #11915, #11916, #11918, #11919, #11920, #12013, #12017, #12033, #12042, #12106, #12139, #12166, #12169, #12171, #12205, #12213, #12312, #12325, #12487, #12488, #12495, #12504, #12557, #12566, #12590, #12591, #12624, #12626, #12632, #12637, #12678, #12696, #12697, #12711, #12733, #12746, #12767, #12770, #12789, #12803, #12805, #12811, #12866, #12868, #12899, #12926, #12934, #12950, #12951, #12974, #13001, #13006, #13011, #13017, #13026, #13027, #13034, #13035, #13038, #13042, #13050, #13060, #13061, #13069, #13107, #13120, #13136, #13178, #13195, #13197, #13331, #13344, #13398, #13399, #13445, #13463, #13518, #13560, #13572, #13626, #13628, #13636, #13643, #13655, #13701, #13717, #13720, #13857, #13859, #13903, #14005, #14063, #14064, #14065, #14066 |
| [@Hsia97](https://github.com/Hsia97) | #11624 |
| [@hubo1989](https://github.com/hubo1989) | #13754 |
| [@hummern](https://github.com/hummern) | #13647 |
| [@IAMBOBJIM](https://github.com/IAMBOBJIM) | #12972 |
| [@initguru](https://github.com/initguru) | #12901, #12902, #12903, #12904, #12905, #12906, #12908, #12909, #12910, #12911, #12912, #12913 |
| [@insoln](https://github.com/insoln) | #12668, #12737, #12754, #12830, #12859, #12864, #12954, #12955, #12956, #12957, #13636 |
| [@jacobsparts](https://github.com/jacobsparts) | #11854, #12155, #12167 |
| [@jasminsehic](https://github.com/jasminsehic) | #12935 |
| [@jbovard2016](https://github.com/jbovard2016) | #13623 |
| [@jmche](https://github.com/jmche) | #13031 |
| [@joglomedia](https://github.com/joglomedia) | #11980 |
| [@jonlwheat2-gif](https://github.com/jonlwheat2-gif) | #11443, #11450, #11549, #11567, #11608, #11633, #11635, #11640, #11644, #11671, #11672, #11673, #11674, #11675, #11676, #11677, #11706, #11783, #12051, #12052, #12053, #12055 |
| [@JxnLexn](https://github.com/JxnLexn) | #12471, #13299, #13434, #13555, #13556 |
| [@kanade-hoshino](https://github.com/kanade-hoshino) | #12180 |
| [@Karan825](https://github.com/Karan825) | #11861 |
| [@KaspaPulse](https://github.com/KaspaPulse) | #11389, #11469, #11628, #11666, #11910 |
| [@keeltrace](https://github.com/keeltrace) | #12080, #12223 |
| [@killer30001000](https://github.com/killer30001000) | #12178 |
| [@KooshaPari](https://github.com/KooshaPari) | #12470, #12592, #12667, #12699, #12703, #12706, #12764, #12769, #12771, #13300, #13301, #13302, #13340, #13401, #13403, #13404, #13405, #13406, #13407, #13409, #13410, #13411, #13412, #13413, #13414, #13418, #13424, #13427, #13433, #13523, #13524, #13525, #13528, #13531, #13532, #13534, #13539, #13543, #13545, #13546, #13547, #13550, #13553 |
| [@keeltrace](https://github.com/keeltrace) | #12080, #12223, #12763, #12818 |
| [@keii-2596](https://github.com/keii-2596) | #13709 |
| [@KelvinKSPS](https://github.com/KelvinKSPS) | #13808 |
| [@killer30001000](https://github.com/killer30001000) | #12178, #12468 |
| [@Kizuno18](https://github.com/Kizuno18) | #12340 |
| [@KooshaPari](https://github.com/KooshaPari) | #12470, #12592, #12667, #12699, #12703, #12706, #12764, #12769, #12771, #13123, #13143, #13300, #13301, #13302, #13340, #13401, #13403, #13404, #13405, #13406, #13407, #13409, #13410, #13411, #13412, #13413, #13414, #13418, #13419, #13423, #13424, #13427, #13433, #13522, #13523, #13524, #13525, #13528, #13531, #13532, #13534, #13539, #13543, #13545, #13546, #13547, #13550, #13551, #13553 |
| [@kriptoburak](https://github.com/kriptoburak) | #11370 |
| [@KrzysiekSko](https://github.com/KrzysiekSko) | #12425, #12432, #12673 |
| [@L4XB](https://github.com/L4XB) | #13622, #13624, #13738 |
| [@legas888Oleg](https://github.com/legas888Oleg) | #13825 |
| [@LeMonBLOCK](https://github.com/LeMonBLOCK) | #8169 |
| [@lorenzozanee](https://github.com/lorenzozanee) | #13761, #13762, #13763, #13764, #13765, #13766, #13767, #13768 |
| [@luw2007](https://github.com/luw2007) | #13849 |
| [@luyuehm](https://github.com/luyuehm) | #13611, #13639 |
| [@marcelokarval](https://github.com/marcelokarval) | direct commit / report |
| [@maxmad64bis](https://github.com/maxmad64bis) | #1622, #11435, #11437, #11441, #11537, #11550, #11553, #11555, #11557, #11812, #11842, #11843, #11903, #12151, #12214, #12215, #12218, #12226, #12314, #12316, #12317, #12318, #12319, #12320, #12321, #12507, #12715, #12731, #12744, #12786, #12787, #12788, #12790, #12792, #12794, #12795, #12828, #12832, #12853, #12857, #12870, #12937, #12941, #12975, #13141, #13142, #13146, #13147 |
| [@mdigitalbh81](https://github.com/mdigitalbh81) | #12240 |
| [@marcs7](https://github.com/marcs7) | #13637 |
| [@marioschoenert-code](https://github.com/marioschoenert-code) | #13162 |
| [@marshalfevzi](https://github.com/marshalfevzi) | #13756 |
| [@maxmad64bis](https://github.com/maxmad64bis) | #1622, #11435, #11437, #11441, #11537, #11550, #11553, #11555, #11557, #11812, #11842, #11843, #11903, #12151, #12214, #12215, #12218, #12226, #12314, #12316, #12317, #12318, #12319, #12320, #12321, #12507, #12715, #12731, #12744, #12786, #12787, #12788, #12790, #12792, #12794, #12795, #12828, #12832, #12853, #12857, #12870, #12937, #12941, #12975, #13141, #13142, #13146, #13147, #13153, #13217, #13218, #13279, #13280, #13281, #13436, #13438, #13439, #13440, #13441, #13471, #13484, #13498, #13577, #13578, #13580, #13581, #13582, #13602, #13605, #13606, #13607, #13608, #13609, #13612, #13613, #13614, #13615, #13633, #13641, #13645, #13646, #13650, #13657, #13671, #13672, #13686, #13795, #13923, #13924, #14011, #14013, #14029, #14290 |
| [@mdigitalbh81](https://github.com/mdigitalbh81) | #12240, #13571, #14162 |
| [@Meet6338-X](https://github.com/Meet6338-X) | #11598, #11609, #12110 |
| [@morpheus9393](https://github.com/morpheus9393) | #11943 |
| [@MumuTW](https://github.com/MumuTW) | #11492, #11502, #11506, #11507, #11626, #11675, #11685, #11728, #11746, #11888, #11889, #11890, #11892 |
| [@Neuron-Mr-White](https://github.com/Neuron-Mr-White) | #11622, #11801, #12256 |
| [@morpheus9393](https://github.com/morpheus9393) | #11943, #13759 |
| [@Moseyuh333](https://github.com/Moseyuh333) | #13642 |
| [@MumuTW](https://github.com/MumuTW) | #11492, #11502, #11506, #11507, #11626, #11675, #11685, #11728, #11746, #11888, #11889, #11890, #11892, #13408 |
| [@NaNomicon](https://github.com/NaNomicon) | #13806 |
| [@Neuron-Mr-White](https://github.com/Neuron-Mr-White) | #11622, #11801, #12256, #12492, #12855 |
| [@NightStalker-87](https://github.com/NightStalker-87) | #12183 |
| [@Notaloop763](https://github.com/Notaloop763) | #13689 |
| [@NoxzRCW](https://github.com/NoxzRCW) | #11879, #11880, #11881, #11882, #11883, #11935 |
| [@ntdat812](https://github.com/ntdat812) | #11585, #12095 |
| [@ntdat812](https://github.com/ntdat812) | #11585, #12095, #12836, #12858 |
| [@ntdatt812](https://github.com/ntdatt812) | #11368, #11573, #11574, #11576, #11577, #11580, #11582, #11583, #11588, #11589, #11590, #11591, #11592, #11593, #11672, #12177, #12180, #12873, #12918, #12920, #12921, #12925, #12930, #13007, #13009, #13024, #13025, #13083, #13087, #13101, #13104, #13110 |
| [@official-burak](https://github.com/official-burak) | #11542 |
| [@opensource-elearning](https://github.com/opensource-elearning) | #12179, #12189, #12278, #12286 |
| [@oyi77](https://github.com/oyi77) | #11408, #11409, #11421, #11505, #11677, #12036, #12110 |
| [@pacocartones](https://github.com/pacocartones) | #11521, #11522, #11527, #11528, #11529, #11530, #11531, #11532, #11533, #11534, #11595, #11599, #11603, #11604, #11605, #11607, #11610, #11676, #11714, #11716, #11718, #11767, #11838, #11860, #11862, #11869, #11871, #11872, #11873, #11903, #11906, #11921, #11934, #12356, #12358, #12359, #12360, #12361, #12362, #12364, #12365, #12373, #12374, #12375, #12376, #12377, #12379, #12380, #12381, #12386, #12387, #12389, #12390, #12394, #12395, #12397, #12401, #12403, #12404, #12522, #12523, #12535, #12536, #12540, #12541, #12543, #12545, #12548, #12549, #12550, #12551, #12552, #12647, #12651, #12653, #13055 |
| [@patrykkopycinski](https://github.com/patrykkopycinski) | #11936, #11937, #12224 |
| [@oleksandr1811](https://github.com/oleksandr1811) | #13777 |
| [@opensource-elearning](https://github.com/opensource-elearning) | #12179, #12189, #12278, #12286, #13566 |
| [@Orion1943](https://github.com/Orion1943) | #13779 |
| [@oyi77](https://github.com/oyi77) | #11408, #11409, #11421, #11505, #11677, #12036, #12038, #12110, #13702 |
| [@ozeas](https://github.com/ozeas) | #13140 |
| [@pacocartones](https://github.com/pacocartones) | #11521, #11522, #11527, #11528, #11529, #11530, #11531, #11532, #11533, #11534, #11595, #11599, #11603, #11604, #11605, #11607, #11610, #11676, #11714, #11716, #11718, #11767, #11838, #11860, #11862, #11869, #11871, #11872, #11873, #11903, #11906, #11921, #11934, #12356, #12358, #12359, #12360, #12361, #12362, #12364, #12365, #12373, #12374, #12375, #12376, #12377, #12379, #12380, #12381, #12386, #12387, #12389, #12390, #12394, #12395, #12397, #12401, #12403, #12404, #12522, #12523, #12535, #12536, #12540, #12541, #12543, #12545, #12548, #12549, #12550, #12551, #12552, #12647, #12651, #12653, #13055, #13837, #13838, #13839, #13841, #13842, #13843, #13844, #13845, #14048 |
| [@pan17](https://github.com/pan17) | #13799 |
| [@pandudpn](https://github.com/pandudpn) | #13823 |
| [@patrykkopycinski](https://github.com/patrykkopycinski) | #11936, #11937, #12224, #12723, #12742, #12885, #13037, #13355, #13357, #13358, #13359, #13448, #13617, #13627 |
| [@PauloFH](https://github.com/PauloFH) | #11509 |
| [@PauloHSOliveira](https://github.com/PauloHSOliveira) | #12241 |
| [@phamtienduceng-eng](https://github.com/phamtienduceng-eng) | #13775 |
| [@phs1997](https://github.com/phs1997) | #13972 |
| [@PixmaNts](https://github.com/PixmaNts) | #12462 |
| [@Pllutonyy](https://github.com/Pllutonyy) | #12838 |
| [@ponkcore](https://github.com/ponkcore) | #12054 |
| [@prabhtheone](https://github.com/prabhtheone) | #13992, #14164 |
| [@prabhu-omkar](https://github.com/prabhu-omkar) | #13579 |
| [@Prajeeth-12](https://github.com/Prajeeth-12) | #11634, #12046 |
| [@pranay-gpt](https://github.com/pranay-gpt) | #13771 |
| [@ProphetOfDoom-PoD](https://github.com/ProphetOfDoom-PoD) | #13797, #13908 |
| [@qinghuanandejiangshi](https://github.com/qinghuanandejiangshi) | #13819 |
| [@quiterunner-commits](https://github.com/quiterunner-commits) | #12131, #12143 |
| [@rafacpti23](https://github.com/rafacpti23) | #11554, #11558, #12192, #12197, #12198, #12202, #12203, #12204 |
| [@rafacpti23](https://github.com/rafacpti23) | #11554, #11558, #12192, #12197, #12198, #12202, #12203, #12204, #12841 |
| [@ragnar-claude](https://github.com/ragnar-claude) | #11564 |
| [@raheemuddin786](https://github.com/raheemuddin786) | #11491, #11839, #11840, #11841, #12003, #12230, #12231, #12232, #12233, #12234 |
| [@raheemuddin786](https://github.com/raheemuddin786) | #11491, #11828, #11839, #11840, #11841, #12003, #12230, #12231, #12232, #12233, #12234 |
| [@rahilmavani](https://github.com/rahilmavani) | #11761 |
| [@Rahulsharma0810](https://github.com/Rahulsharma0810) | #11771 |
| [@RaviTharuma](https://github.com/RaviTharuma) | #11710, #11727, #11797, #11798, #11802, #11805, #11806, #11811, #12098, #12099, #12101, #12449, #12452, #12472, #12473, #12493, #12533, #12607, #12628, #12631, #12636, #12875, #12876, #12880, #12882, #12884 |
| [@Rahulsharma0810](https://github.com/Rahulsharma0810) | #11771, #13758 |
| [@RaviTharuma](https://github.com/RaviTharuma) | #11710, #11727, #11797, #11798, #11802, #11805, #11806, #11811, #12098, #12099, #12101, #12449, #12452, #12472, #12473, #12493, #12533, #12607, #12628, #12631, #12636, #12875, #12876, #12880, #12882, #12884, #13185, #13426, #13783 |
| [@rezjalibd](https://github.com/rezjalibd) | #12186 |
| [@RhianB14](https://github.com/RhianB14) | #11692 |
| [@ricardusx](https://github.com/ricardusx) | #13770 |
| [@Rick7C2](https://github.com/Rick7C2) | #13008 |
| [@rifqiawl](https://github.com/rifqiawl) | #11517, #11519, #11969 |
| [@rolemiaster](https://github.com/rolemiaster) | #13769 |
| [@rqzbeh](https://github.com/rqzbeh) | #11390 |
| [@Sabeekhann](https://github.com/Sabeekhann) | #11699, #11705 |
| [@sahildaswani](https://github.com/sahildaswani) | #13831 |
| [@santosraju99-hub](https://github.com/santosraju99-hub) | #11755, #11770 |
| [@SCys](https://github.com/SCys) | #12562 |
| [@seanford](https://github.com/seanford) | #13733, #13740 |
| [@Seramicx](https://github.com/Seramicx) | #11568 |
| [@Siva010](https://github.com/Siva010) | #12191 |
| [@SIGTERM-015](https://github.com/SIGTERM-015) | #13219 |
| [@sistemabritto](https://github.com/sistemabritto) | #13352 |
| [@Siva010](https://github.com/Siva010) | #12191, #12785 |
| [@smshagor-dev](https://github.com/smshagor-dev) | #13863 |
| [@solstxce](https://github.com/solstxce) | #11597 |
| [@soroush5](https://github.com/soroush5) | #12691 |
| [@soroush5](https://github.com/soroush5) | #12691, #13753, #13755 |
| [@sprintberlin](https://github.com/sprintberlin) | #13561 |
| [@Stazyu](https://github.com/Stazyu) | #12497 |
| [@steve25060](https://github.com/steve25060) | #14006 |
| [@stormsia](https://github.com/stormsia) | #13984 |
| [@tanveer-arch](https://github.com/tanveer-arch) | #12443 |
| [@tenshiak](https://github.com/tenshiak) | #12279 |
| [@TheDemonTuan](https://github.com/TheDemonTuan) | #11468, #11470, #11471, #11482, #11548, #11758, #11775, #11814 |
| [@thomasmaerz](https://github.com/thomasmaerz) | #12834 |
| [@tuandinh0801](https://github.com/tuandinh0801) | #11454 |
| [@turbolego](https://github.com/turbolego) | #11621, #11762, #11772, #11774, #11781, #12216 |
| [@texastoland](https://github.com/texastoland) | #13800 |
| [@TheDemonTuan](https://github.com/TheDemonTuan) | #11468, #11470, #11471, #11482, #11548, #11758, #11775, #11814, #12933, #13848, #13956 |
| [@themedexperiencesusa](https://github.com/themedexperiencesusa) | #13350 |
| [@ThiagoMafra-Integrare](https://github.com/ThiagoMafra-Integrare) | #12863, #13780 |
| [@thomasmaerz](https://github.com/thomasmaerz) | #12688, #12834 |
| [@tiangao88](https://github.com/tiangao88) | #12814, #12982, #14001 |
| [@tolgaaksoy](https://github.com/tolgaaksoy) | #13786, #13787 |
| [@toor11](https://github.com/toor11) | #12663, #12664 |
| [@trycohn](https://github.com/trycohn) | #13864 |
| [@tuandinh0801](https://github.com/tuandinh0801) | #11454, #13013, #13015, #13020, #13021, #13318, #13705 |
| [@turbolego](https://github.com/turbolego) | #11621, #11762, #11772, #11774, #11781, #12216, #13040 |
| [@Tushar49](https://github.com/Tushar49) | #11392 |
| [@ujjawalkaushik1110](https://github.com/ujjawalkaushik1110) | #11863 |
| [@ventulus95](https://github.com/ventulus95) | #13317 |
| [@vermasomesh835](https://github.com/vermasomesh835) | #11794 |
| [@VictorRP7](https://github.com/VictorRP7) | #13226 |
| [@visheshgubrani](https://github.com/visheshgubrani) | #12966 |
| [@voidstackloop](https://github.com/voidstackloop) | #13342, #13773 |
| [@vsd2807](https://github.com/vsd2807) | #11565, #11619 |
| [@wahidsadik371-coder](https://github.com/wahidsadik371-coder) | #12126 |
| [@watchingdogs](https://github.com/watchingdogs) | #12031 |
| [@wildcard](https://github.com/wildcard) | #11620 |
| [@xiaoyaner0201](https://github.com/xiaoyaner0201) | #11460, #11662, #12083 |
| [@wofiporia](https://github.com/wofiporia) | #12629 |
| [@woodsonl](https://github.com/woodsonl) | #12686, #12730 |
| [@xiaoyaner0201](https://github.com/xiaoyaner0201) | #11460, #11662, #12083, #13293, #13711, #13746, #14164 |
| [@xiechimon](https://github.com/xiechimon) | #13750, #13855, #14164 |
| [@Xore](https://github.com/Xore) | #13383, #13792 |
| [@Xxx91n](https://github.com/Xxx91n) | #11690 |
| [@yourspraveen](https://github.com/yourspraveen) | #11146 |
| [@ysntony](https://github.com/ysntony) | #13046 |
| [@yxyxy](https://github.com/yxyxy) | #11671 |
| [@ZaimMarzuki](https://github.com/ZaimMarzuki) | #11960 |
| [@zachary-frederich](https://github.com/zachary-frederich) | #12993 |
| [@ZaimMarzuki](https://github.com/ZaimMarzuki) | #11960, #12553, #12891, #13029 |
| [@Zartharas](https://github.com/Zartharas) | #11340 |
| [@zcrew0x](https://github.com/zcrew0x) | #13690 |
| [@zeeshanhaque21](https://github.com/zeeshanhaque21) | #13708 |
| [@zero-executioner](https://github.com/zero-executioner) | #11631 |
| [@diegosouzapw](https://github.com/diegosouzapw) | maintainer |
---
## [3.8.50] — 2026-08-25
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -354,7 +354,7 @@ RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,targe
# build, not the floating `@latest`.
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-npm-cache,target=/root/.npm \
npm install -g --no-audit --no-fund \
@openai/codex@0.153.4 \
@openai/codex@0.155.0 \
@anthropic-ai/claude-code@2.1.260 \
droid@0.212.0 \
openclaw@2026.9.1

View File

@@ -7,7 +7,7 @@
# 🚀 OmniRoute — The Free AI Gateway
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 359 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 1595% tokens (~89% avg) — never hit limits. 359 AI providers · 150+ free tiers · ~1.62B free tokens/mo · 19 routing strategies · $0 to start."/>
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 360 providers — 150+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 1595% tokens (~89% avg) — never hit limits. 360 AI providers · 150+ free tiers · ~1.62B free tokens/mo · 19 routing strategies · $0 to start."/>
</div>
@@ -133,7 +133,7 @@
</div>
<div align="center">
<b>🌐 In 66 languages</b>
<b>🌐 In 67 languages</b>
<br/><br/>
<a href="README.md"><img src="docs/assets/flags/us.svg" width="30" alt="English (en)" title="English (en)"></a>
<a href="docs/i18n/pt-BR/README.md"><img src="docs/assets/flags/br.svg" width="30" alt="Português — Brasil (pt-BR)" title="Português — Brasil (pt-BR)"></a>
@@ -201,6 +201,7 @@
<a href="docs/i18n/uz/README.md"><img src="docs/assets/flags/uz.svg" width="30" alt="Oʻzbekcha (uz)" title="Oʻzbekcha (uz)"></a>
<a href="docs/i18n/ka/README.md"><img src="docs/assets/flags/ge.svg" width="30" alt="ქართული (ka)" title="ქართული (ka)"></a>
<a href="docs/i18n/hy/README.md"><img src="docs/assets/flags/am.svg" width="30" alt="Հայերեն (hy)" title="Հայերեն (hy)"></a>
<a href="docs/i18n/bs/README.md"><img src="docs/assets/flags/ba.svg" width="30" alt="Bosanski (bs)" title="Bosanski (bs)"></a>
</div>
<br/>
@@ -233,7 +234,7 @@ curl http://localhost:20128/v1/chat/completions \
</div>
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint and 359 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 359 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 54 recurring/keyless free-forever providers · 36 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files."/>
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint and 360 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 360 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 54 recurring/keyless free-forever providers · 36 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files."/>
<br/>
<br/>
@@ -283,7 +284,7 @@ curl http://localhost:20128/v1/chat/completions \
<td>
Thanks to <b>Kimi (Moonshot AI)</b>, our founding Open Source Friend, for backing this project! Kimi is the AI lab behind the open-weight K2 and K3 model families — <b>Kimi K3</b> delivers a 1M-token context window, native vision and frontier-level coding at a fraction of closed-model prices, and works out of the box with Claude Code, Codex and every coding tool OmniRoute serves.
<br/><br/>
<b>What Kimi's support powers:</b> Kimi's API credits power OmniRoute's AI-validated release pipeline — the <i>merge validation powered by Kimi K3</i> stage that reviews every pull request before it ships — plus day-to-day feature development. First-class Kimi support ships on both rails: the direct <a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute">Kimi API</a> (<code>kimi-k3</code>) and the <a href="https://www.kimi.com/code?aff=omniroute">Kimi Code coding plan</a> (OAuth and API key). OmniRoute is also the first Brazilian open-source project in Kimi's support program. <a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute"><b>Get a Kimi API key with 15% extra credits →</b></a>
<b>What Kimi's support powers:</b> Kimi's API credits power OmniRoute's AI-validated release pipeline — the <i>merge validation powered by Kimi K3</i> stage that reviews every pull request before it ships — plus day-to-day feature development. First-class Kimi support ships on both rails: the direct <a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute">Kimi API</a> (<code>kimi-k3</code>) and the <a href="https://www.kimi.ai/code?aff=omniroute">Kimi Code coding plan</a> (OAuth and API key). OmniRoute is also the first Brazilian open-source project in Kimi's support program. <a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute"><b>Get a Kimi API key with 15% extra credits →</b></a>
</td>
</tr>
<tr>
@@ -486,7 +487,7 @@ All **19** strategies — mix & match per combo step:
</div>
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — a dated feature snapshot vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 359 providers, 150+ free tiers built in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 42 i18n UI locales. OmniRoute is MIT-licensed and self-hostable. Competitor capabilities and counts may change; see the linked methodology."/>
<img src="./docs/diagrams/comparison-table.svg" width="100%" alt="What sets OmniRoute apart — a dated feature snapshot vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 360 providers, 150+ free tiers built in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 42 i18n UI locales. OmniRoute is MIT-licensed and self-hostable. Competitor capabilities and counts may change; see the linked methodology."/>
<sub>📊 Full methodology &amp; per-feature detail vs 9router, OpenRouter, CLIProxyAPI &amp; LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md)</sub>
@@ -862,7 +863,7 @@ with a scoped access token; every command then targets the remote.
```bash
omniroute connect 192.168.0.15 # password → scoped token, saved as a context
omniroute models list # ← runs against the REMOTE server
omniroute models # ← runs against the REMOTE server
omniroute configure codex # ← picks a remote model, writes a local Codex profile
omniroute tokens create --name ci --scope read # mint narrower tokens for other machines
omniroute contexts use default # ← switch back to the local server
@@ -1006,6 +1007,10 @@ omniroute
```
> 💡 See `npm warn ERESOLVE` or peer-dep warnings? [They're harmless](docs/guides/TROUBLESHOOTING.md#npm-install-warnings-eresolve--peer--deprecated).
> **Using Gemini Web or another web-cookie provider?** The npm package includes
> Playwright but not its Chromium binary. See the
> [Playwright Chromium setup](docs/guides/TROUBLESHOOTING.md#gemini-web-and-playwright-chromium)
> note before making the first web-provider request.
Dashboard at `http://localhost:20128` · API at `http://localhost:20128/v1`.
@@ -1148,7 +1153,9 @@ install never blocks on compiling from source: it uses a prebuilt binary when on
your platform/Node, and otherwise falls back transparently to a pure-JS engine
(`node:sqlite` on Node 22+, else the bundled `sql.js` WASM) — no build tools required.
To skip the post-install native warm-up entirely (CI, headless, or slow machines):
To skip the post-install **native warm-up** entirely (CI, headless, or slow machines).
Note: this only skips the native SQLite warm-up step (`scripts/postinstall.mjs`); the
binary-copy/repair hook (`scripts/build/postinstall.mjs`) still runs normally:
```bash
OMNIROUTE_SKIP_POSTINSTALL=1 npm install -g omniroute # CI=1 also skips it
@@ -1268,7 +1275,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
<tr><td nowrap><b>Runtime</b></td><td>Node.js 22.x / 24.x LTS — <code>&gt;=22.22.2 &lt;23 || &gt;=24.0.0 &lt;27</code></td></tr>
<tr><td nowrap><b>Language</b></td><td>TypeScript 6.0 — <b>100% TypeScript</b> across <code>src/</code> and <code>open-sse/</code> (zero <code>any</code> in core since v2.0)</td></tr>
<tr><td nowrap><b>Framework</b></td><td>Next.js 16 + React 19 + Tailwind CSS 4</td></tr>
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 178 migrations</td></tr>
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 182 migrations</td></tr>
<tr><td nowrap><b>Memory</b></td><td>SQLite FTS5 full-text + int8-quantized vector embeddings, typed decay</td></tr>
<tr><td nowrap><b>Schemas</b></td><td>Zod 4 — MCP tool I/O validation + API contracts</td></tr>
<tr><td nowrap><b>Protocols</b></td><td>MCP (stdio / HTTP / SSE) + A2A v0.3 (JSON-RPC 2.0 + SSE)</td></tr>

View File

@@ -23,8 +23,13 @@ const PROVIDERS_WITH_OAUTH = [
// the device-flow request to /api/providers/command-code/auth/start, which is
// gated by requireManagementAuth and returned 401 for a fresh CLI context
// (issue #9474). Map the alias to the real backend key instead.
//
// `copilot` has the same mismatch (#14298): the GitHub Copilot device flow is
// registered under the backend key `github`, so posting to
// /api/oauth/copilot/device-code failed for an unknown provider.
const BACKEND_OAUTH_KEY = {
"claude-code": "claude",
copilot: "github",
};
function resolveBackendKey(id) {
@@ -70,7 +75,7 @@ function printLoopbackRedirectWarning(providerId, redirectUri) {
process.stdout.write(
`Note: the authorize URL below advertises ${redirectUri}, but this CLI does not\n` +
"listen on that port. Right after you approve, the browser is expected to\n" +
"show a connection error (e.g. \"This site can't be reached\" / \n" +
'show a connection error (e.g. "This site can\'t be reached" / \n' +
"ERR_CONNECTION_REFUSED) — that is normal, not a failure. Copy the full URL\n" +
"from the address bar anyway and paste it below.\n"
);
@@ -292,27 +297,51 @@ async function runDeviceFlow(def, opts) {
if (opts.browser !== false && verificationUri) await openBrowser(verificationUri);
process.stderr.write("Waiting for device authorization...\n");
// Poll the real device-flow route: POST /api/oauth/{key}/poll with the device
// code (#14298). The previous implementation polled
// GET /api/providers/{key}/auth/status?state=… and then POST …/auth/apply,
// but neither route exists on the server, and the device-code response has no
// `state` field at all — so the CLI looped until its timeout even after the
// user authorized. /api/oauth/{key}/poll is the same route the dashboard
// polls (src/shared/components/OAuthModal.tsx::pollDeviceCodeOnce) and it
// persists the connection server-side on success, so no separate apply step
// is needed.
const deviceCode = start.deviceCode ?? start.device_code ?? "";
if (!deviceCode) {
process.stderr.write("Server did not return a device code; cannot poll for authorization.\n");
process.exit(1);
}
const codeVerifier = start.codeVerifier ?? undefined;
const deadline = Date.now() + (opts.timeout ?? 300000);
const intervalMs = (start.intervalMs ?? start.interval ?? 5) * 1000;
let intervalMs = (start.intervalMs ?? start.interval ?? 5) * 1000;
while (Date.now() < deadline) {
await sleep(intervalMs);
const statusRes = await apiFetch(
`/api/providers/${providerKey}/auth/status?state=${encodeURIComponent(start.state ?? "")}`,
targetApiOptions(opts)
);
if (!statusRes.ok) continue;
const status = await statusRes.json();
if (status.status === "complete" || status.status === "authorized") {
await apiFetch(`/api/providers/${providerKey}/auth/apply`, {
...targetApiOptions(opts),
method: "POST",
body: { state: start.state },
});
process.stdout.write(`Authorized: ${status.account ?? status.email ?? "connected"}\n`);
const pollRes = await apiFetch(`/api/oauth/${providerKey}/poll`, {
...targetApiOptions(opts),
method: "POST",
body: { deviceCode, ...(codeVerifier ? { codeVerifier } : {}) },
});
if (!pollRes.ok) continue;
let poll;
try {
poll = await pollRes.json();
} catch {
continue;
}
if (poll.success) {
const conn = poll.connection ?? {};
process.stdout.write(
`Authorized: ${conn.email ?? conn.displayName ?? conn.id ?? "connected"}\n`
);
return;
}
if (status.status === "error") {
process.stderr.write(`Device auth failed: ${status.error}\n`);
if (poll.error === "slow_down") {
// OAuth device-flow spec: back off by 5s on slow_down.
intervalMs += 5000;
continue;
}
if (poll.error && !poll.pending) {
process.stderr.write(`Device auth failed: ${poll.errorDescription ?? poll.error}\n`);
process.exit(1);
}
}

1340
bin/cli/locales/bs.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -23,8 +23,8 @@ export function computeRestartDelayMs(restartCount) {
return Math.min(1000 * 2 ** (Math.max(1, restartCount) - 1), 10_000);
}
/** Resolve true when nothing is listening on `port` (so a restart won't hit EADDRINUSE). */
export function isPortFree(port, host = "127.0.0.1") {
/** Resolve true when `host:port` can be bound right now. */
function canBind(port, host) {
return new Promise((resolve) => {
const tester = net.createServer();
tester.once("error", (err) => {
@@ -38,6 +38,28 @@ export function isPortFree(port, host = "127.0.0.1") {
});
}
/**
* Resolve true when nothing is listening on `port` (so a restart won't hit EADDRINUSE).
*
* Probing a single address is not enough. Node sets `SO_REUSEADDR` on every listener it
* creates, and on macOS/BSD that lets a specific-address bind coexist with an existing
* wildcard bind (and vice versa) — unlike Linux, which keeps rejecting the overlap in
* LISTEN state. A gateway listening on `0.0.0.0:20128`, which is what `omniroute serve`
* binds by default, was therefore reported as "free" by the old loopback-only probe: the
* supervisor skipped its wait, the respawned child hit EADDRINUSE, and the crash loop
* that #4425 set out to fix kept running. Probe every address the server may have bound.
*
* @param port Port to test.
* @param host Additional address to test; wildcard and loopback are always included.
* @returns False as soon as any candidate address is occupied.
*/
export async function isPortFree(port, host = "127.0.0.1") {
for (const candidate of new Set([host, "0.0.0.0", "127.0.0.1"])) {
if (!(await canBind(port, candidate))) return false;
}
return true;
}
/**
* #4425: wait until `port` is free before respawning. After a crash the OS may not have
* released the listen socket yet; restarting immediately produced the EADDRINUSE cascade

View File

@@ -1 +0,0 @@
- **feat(docs):** every Markdown page under `docs/` is now mirrored in all 65 dashboard locales, not only the 22-page core set — 152 sources × 65 locales = 9,880 mirrors (6,208 new), with the 🌐 language bar of every mirror rewritten for the full locale list. The docs drift gate (`npm run i18n:check`, blocking in CI) derives its scope from the tree, so it now guards all 152 pages. Found and fixed by the run in `scripts/i18n/run-translation.mjs`: a markdown table or tight bullet list with no blank line inside it (PROVIDER_REFERENCE.md's 244-row table, FREE_TIERS.md's 71-item list) was sent as one 1640 KB request that outlived the backend socket for verbose scripts (Greek, Amharic); oversized runs of table rows or list items are now cut at item boundaries and rejoined without a blank line, so no chunk exceeds 6 KB across the docs tree. 48 older mirrors whose tables had lost rows were retranslated with the fixed chunker.

View File

@@ -1 +0,0 @@
- **feat(usage):** `openai-compatible-*` connections can now report billing/quota in Provider Limits. The connection declares its own quota endpoint, auth mode and a dot-path mapping onto `UsageQuota` in `providerSpecificData.quotaEndpoint`, so no upstream-specific code is needed per service — a mapping that resolves nothing reports no quota rather than an exhausted-looking 0/0 ([#13616](https://github.com/diegosouzapw/OmniRoute/issues/13616))

View File

@@ -1 +0,0 @@
- **feat(sse):** track LLM Gateway DevPass quota — the `llmgateway` provider now reads its monthly plan-credit and weekly premium-model allowance from `GET /v1/key` and surfaces both windows in Dashboard Limits and quota-aware preflight ([#12462](https://github.com/diegosouzapw/OmniRoute/pull/12462)).

View File

@@ -0,0 +1 @@
- **feat(providers):** add Lyceum (lyceum.technology) as an OpenAI-compatible, pay-per-use provider — chat, embeddings, and live `/models` discovery through `https://api.lyceum.technology/openai/v1`, plus a credit-balance quota fetcher (`GET /api/v2/external/billing/credits`) surfaced in Dashboard Limits and quota-aware preflight ([#12470](https://github.com/diegosouzapw/OmniRoute/pull/12470)).

View File

@@ -1 +0,0 @@
- **feat(providers):** register `gemini-3.8-flash` ([#12638](https://github.com/diegosouzapw/OmniRoute/issues/12638)) — Gemini 3.8 Flash (DeepMind 2026-09-02) with tool calling and vision support

View File

@@ -1 +0,0 @@
- **feat(proxylogs):** proxy log columns and detail pane now show the registry proxy name instead of a bare `host:port` when several registry entries share the same gateway ([#12814](https://github.com/diegosouzapw/OmniRoute/pull/12814)) — thanks @tiangao88

View File

@@ -1 +0,0 @@
- **compression:** add Hungarian Caveman language pack with Hungarian-specific rules, language detection, localized output instructions, and language-pack tests. (#12825 - thanks @botii16)

View File

@@ -1 +0,0 @@
- **feat(sse):** parse/scrub DSML tool-call markers embedded in reasoning and recognize adaptive thinking on the response side — `dsmlToolCalls.ts` module + translator/stream/handler wiring ([#12905](https://github.com/diegosouzapw/OmniRoute/pull/12905)) — thanks @initguru

View File

@@ -1 +0,0 @@
- **feat(codex):** safely discover compatible models by classifying upstream models before activation to keep hidden, unsupported, retired, or newer-client models out of the active catalog, exposing candidate diagnostics while persisting only active models, adding GPT-6 Astra fallback definitions, and bumping the tested Codex CLI version to 0.153.4 ([#12933](https://github.com/diegosouzapw/OmniRoute/pull/12933)) — thanks @TheDemonTuan

View File

@@ -1 +0,0 @@
- **feat(api):** `POST /api/keys` accepts `expiresAt` (ISO datetime, nullable) with the same semantics as the key-update path, so automation can create an expiring key in one operation instead of create-then-update. Omitted/null preserves the current non-expiring behavior; enforcement reuses the existing expiry policy ([#12952](https://github.com/diegosouzapw/OmniRoute/pull/12952)) — thanks @caniko

View File

@@ -1 +0,0 @@
- **feat(build):** add build:fast and start:fast to bypass standalone tracing ([#13021](https://github.com/diegosouzapw/OmniRoute/pull/13021)) — thanks @tuandinh0801

View File

@@ -1 +0,0 @@
- **feat(sse):** `OMNIROUTE_DISABLE_CONVERSATION_TRACKING=1` turns off conversation-history collection for operators who do not use the dashboard's conversation view. `resolveConversationId()` returns an untracked result before it reads SQLite or parses message history, and the switch also covers client-supplied session IDs. Routing-session handling is unchanged, tracking stays on by default, and existing records are not deleted. One reporting install held 5.97 million turn records at about 4.26 GB ([#13150](https://github.com/diegosouzapw/OmniRoute/pull/13150))

View File

@@ -1 +0,0 @@
- **feat(providers):** Add `auto/kimi`, `auto/qwen`, `auto/deepseek`, `auto/gpt`, and the `auto/claude-haiku` fast variant to the built-in routing catalog, including bare `k3` models on Kimi coding and web backends (issue #13214).

View File

@@ -1 +0,0 @@
- **feat(usage):** Claude OAuth usage now shows the separate weekly Fable limit next to the shared five-hour and weekly meters. Anthropic reports it as a `weekly_scoped` entry in `limits[]`, which OmniRoute ignored, so the pool was invisible. The provider-limits cache keeps `modelQuotas` and restores it on stale-data fallback. The Fable meter is display-only and does not affect routing, account selection, or cooldowns ([#13266](https://github.com/diegosouzapw/OmniRoute/pull/13266))

View File

@@ -1,5 +0,0 @@
- **feat(playground): copy an individual Compare column's response.** Each column in the Compare
tab now has a copy button beside the remove button, reusing the existing `useCopyToClipboard`
hook to copy that column's response text and show a checkmark while `disabled` on an empty
response. (The independent-scrolling half of this PR was already fixed separately in #13532.)
(#13317 — thanks @ventulus95)

View File

@@ -1 +0,0 @@
- **feat(models):** add Gemini 3.8 Flash tiers to Antigravity and AGY catalogs ([#13318](https://github.com/diegosouzapw/OmniRoute/pull/13318)) — thanks @tuandinh0801, with credit to #12499 (@Abhishekchhetri020)

View File

@@ -1 +0,0 @@
- **feat(reasoning):** adaptive reasoning effort (`auto`) — the gateway resolves the thinking budget per user turn from deterministic request-shape signals (stateless per-turn pin) instead of forwarding a literal `auto`, applied at the gateway pre-translation for any harness (Claude Code, Cursor, Codex, opencode, Hermes) whose request dispatches to an OpenAI Chat-Completions-shaped upstream (`targetFormat === FORMATS.OPENAI``reasoning_effort` is an OpenAI-shaped field, so a Claude- or Gemini-targeted request is unaffected). Opt in via `X-OmniRoute-Effort: auto` or a model's `defaultReasoningEffort: "auto"` (now a valid `ModelSpec` value); any explicit client reasoning field always wins ([#13448](https://github.com/diegosouzapw/OmniRoute/pull/13448))

View File

@@ -1 +0,0 @@
- **feat(dashboard):** Add a dedicated, full-width API-key routing editor with explicit model/combo choices, searchable selectors and protection for unsaved rule drafts. ([#13555](https://github.com/diegosouzapw/OmniRoute/pull/13555)) — thanks @JxnLexn

View File

@@ -1 +0,0 @@
- **feat(proxies):** proxy pools and opencode's per-account rotation stop re-serving a proxy that just failed (refused TCP probe, or a 429 through it) for a period that doubles on each repeat up to a cap, without writing any proxy status; with every candidate set aside the choice is unchanged. Opt-in via the `PROXY_SKIP_RECENTLY_FAILED` feature flag (default off: selection unchanged) ([#13578](https://github.com/diegosouzapw/OmniRoute/pull/13578)) — thanks @maxmad64bis

View File

@@ -1 +0,0 @@
- **feat(proxy-logs):** proxy log rows keep the HTTP status the provider actually returned (`upstream_status`, null when no response arrived), so a throttled egress IP (429), a refused one (403) and a provider outage (500) are no longer the same "error" line, and a 429 generated locally is no longer mistaken for one from the provider ([#13580](https://github.com/diegosouzapw/OmniRoute/pull/13580)) — thanks @maxmad64bis

View File

@@ -1 +0,0 @@
- **feat(proxies):** the proxy pool editor shows, for the last 24 h, how many distinct egress IPs actually served the pool's members, how many connections went through them and the most seen behind one IP, read from the proxy log through a separate route so it can never break the pool screen; opt-in with the `PROXY_POOL_EGRESS_OBSERVATION` feature flag (default off) ([#13581](https://github.com/diegosouzapw/OmniRoute/pull/13581)) — thanks @maxmad64bis

View File

@@ -1 +0,0 @@
- **feat(sse): learn hard request caps stated in 429 bodies and pace under them.** Providers such as TokenRouter reject bursts with prose like `Maximum 5 requests within 1 minutes` and no rate-limit headers, so the limiter never learned the ceiling and kept racing into it; every 429 also tore the limiter down and rebuilt it with no pacing. `updateFromResponseBody` now parses that phrasing (and `N requests per minute`, `N requests per M seconds`, `N RPM`) into a per-window cap, applies it to the limiter as an empty reservoir that refills `N` every window with calls spread `window / N` apart, and records it in `learnedRateLimits`. A learned cap is reapplied whenever the limiter is rebuilt after a 429 and when limits are restored at startup, unless the connection has an explicit RPM override. Fixes [#13594](https://github.com/diegosouzapw/OmniRoute/issues/13594).

View File

@@ -1 +0,0 @@
- **feat(proxies):** a proxy pool stops re-serving a member the provider just refused through it and tries another member instead, reusing the existing skip cooldown; a later success through the member clears it. Opt-in with the `PROXY_SKIP_RECENTLY_FAILED` feature flag (default off: pool selection unchanged) ([#13602](https://github.com/diegosouzapw/OmniRoute/pull/13602)) — thanks @maxmad64bis

View File

@@ -1 +0,0 @@
- **feat(flags):** add `DB_HEALTHCHECK_STARTUP_DEFERRED_ENABLED` (default off) — opt-in deferral of the startup DB health/integrity check past process boot via `setImmediate`; off keeps the pre-#13717 behavior of blocking `getDbInstance()` until the check has already run (#13717).

View File

@@ -1 +0,0 @@
- **feat(i18n):** 7 new locales — Hausa (`ha`), Yoruba (`yo`), Igbo (`ig`), Amharic (`am`), Uzbek (`uz`), Georgian (`ka`), Armenian (`hy`) — across the dashboard, docs mirrors, CLI, README and the site (66 locales, the full planned expansion from 43). (#13727)

View File

@@ -1 +0,0 @@
- **feat(api):** `POST /v1/rerank` (and the memory engine's loopback rerank step) can route to OpenAI-compatible provider nodes on a LAN/Tailscale host — not only loopback — behind the new `RERANK_REMOTE_PROVIDER_NODES` feature flag (default off), subject to the provider outbound URL policy; the loopback host check is consolidated into `@/shared/network/loopbackNodeHost` shared by rerank, audio, and the local health checker ([#13732](https://github.com/diegosouzapw/OmniRoute/pull/13732)) — thanks @seanford

View File

@@ -1 +0,0 @@
- **feat(security):** OmniRoute now warns at boot when the server that answers `/v1` inference is bound to a non-loopback interface while `REQUIRE_API_KEY` is disabled. The guard added in [#12568](https://github.com/diegosouzapw/OmniRoute/pull/12568) covered the API bridge (`API_HOST`, default loopback) and the live dashboard WebSocket, but not the Next server that actually serves `/v1/chat/completions` and `/v1/responses` — which binds `HOST || 0.0.0.0`, every interface by default. That matters because `GET /v1/models` follows the dashboard login posture (`requireAuthForModels`) while inference follows `REQUIRE_API_KEY`, so an instance with an admin password and `REQUIRE_API_KEY=false` answers `401` to the probe an operator naturally runs while inference stays open to anyone who can reach the port. The bound host is resolved from `OMNIROUTE_BOUND_HOST` (published by `scripts/dev/run-next.mjs`) then Next's own `HOSTNAME` (the Docker path); `HOST` is deliberately excluded because the standalone server ignores it and a warning naming the wrong interface is worse than none. New `docs/security/INFERENCE_AUTH_POSTURE.md` documents the split, how to actually probe inference, and the [#2257](https://github.com/diegosouzapw/OmniRoute/issues/2257) caveat that an invalid bearer degrades to anonymous. ([#13820](https://github.com/diegosouzapw/OmniRoute/pull/13820))

View File

@@ -1 +0,0 @@
- Expose combo wall-clock timeout (`comboTimeoutMs`) next to Target timeout in the combo editor and Combo defaults. Empty keeps the 10-minute hang-stop; a positive value replaces it. ([#13857](https://github.com/diegosouzapw/OmniRoute/pull/13857))

View File

@@ -1 +0,0 @@
- **feat(i18n):** `retranslate-site` rewrites the site catalogs' verbatim-English leaves (2,059 across 63 catalogs; mean English residue 10.3 % → 6.3 %, the rest being brand names kept on purpose). (#13886)

View File

@@ -1 +0,0 @@
- **feat(compression):** Lite tool-result truncation length is configurable (`lite.maxToolLength`, env `OMNIROUTE_LITE_MAX_TOOL_LENGTH`). Default stays 2000. An out-of-range step cap no longer hides a valid global cap; a toggle-only settings write keeps a stored cap; `maxToolLength: null` clears it. Dashboard copy no longer hard-codes 2,000 characters. ([#13915](https://github.com/diegosouzapw/OmniRoute/pull/13915) — refs [#13178](https://github.com/diegosouzapw/OmniRoute/issues/13178))

View File

@@ -1 +0,0 @@
- **feat(proxy):** support multiple local core endpoints, one per line ([#13923](https://github.com/diegosouzapw/OmniRoute/pull/13923) — thanks @maxmad64bis)

View File

@@ -0,0 +1 @@
- **feat(plugins):** the editor plugin registers the host `sdk` telemetry hook behind an opt-in flag that defaults to off, marking matching inference calls without touching anything else. ([#14235](https://github.com/diegosouzapw/OmniRoute/pull/14235)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **feat(kiro):** expose Kiro's provider-native Opus 5 Max effort tier — `<base>-max` in the Claude effort catalog, `max` in the Kiro effort values, and the adaptive-thinking envelope for `claude-opus-5` ([#14284](https://github.com/diegosouzapw/OmniRoute/pull/14284)) — original change by tarciorick, thanks @bufftop25

View File

@@ -0,0 +1 @@
- **feat(sse):** forward the auto mode classifier beta (`dangerous-tool-use-2026-09-03`) to Anthropic-format upstreams, and let `anthropic-compatible-*` providers forward client-negotiated betas at all, so Claude Code sessions behind the gateway stay eligible for server-side auto mode classification ([#14312](https://github.com/diegosouzapw/OmniRoute/pull/14312)) — thanks @dpozimski

View File

@@ -0,0 +1 @@
- **feat(oauth):** Muse Code (Meta) device-authorization login with CLIProxyAPI-parity credential lifecycle — RFC 8628 against auth.meta.com, mint the subscription inference key at `/muse-code/key`, persist the durable `dca:` token, remint on 401 / DCA-only records, honor `error.resets_at` quota cooldowns, and import CLIProxyAPI `type: meta` auth files. Dual-auth keeps pasted `META_API_KEY` on the same card. (#14329)

View File

@@ -0,0 +1 @@
- **feat(proxies):** show each pool member's last seen egress IP next to the pool totals ([#14364](https://github.com/diegosouzapw/OmniRoute/pull/14364)) — thanks @maxmad64bis

View File

@@ -0,0 +1 @@
- **fix(compression):** keep lossy compression off the default path, following HouMinXi's plan (dedup and whitespace stay on; summaries, relevance filters, and style rewrites are per-request) and KooshaPari's #8251 plan (combo 400s are a decision table, so model-scoped wrappers advance). Opt-in header: `x-omniroute-compression: allow-lossy`, `engine:<id>`, or a named combo ([#14529](https://github.com/diegosouzapw/OmniRoute/pull/14529)) — thanks @HouMinXi @KooshaPari

View File

@@ -0,0 +1 @@
- **feat(opencode-plugin):** `features.diskCacheMaxAgeMs` is an opt-in bound for the disk-cache fallback. Unset or `0` stays unbounded. Past a positive bound the snapshot is still served and the fallback log escalates from warn to error ([#14540](https://github.com/diegosouzapw/OmniRoute/pull/14540)) — thanks @RaviTharuma

View File

@@ -0,0 +1 @@
- **feat(providers):** correlate CLIProxyAPI `X-CPA-TRACE-ID` `auth_index` with usage history and the sanitized account-health label ([#14544](https://github.com/diegosouzapw/OmniRoute/pull/14544)) — thanks @RaviTharuma

Some files were not shown because too many files have changed in this diff Show More