Compare commits

...

147 Commits

Author SHA1 Message Date
diegosouzapw
2aef14e40d docs(changelog): point the batch-1 fragment at #13044
Also re-syncs the nine new llm.txt mirrors with the root file, which moved
with the base merge. docs-sync keeps the body byte-identical to the root
minus its top heading; the mirrors carry their own heading plus the language
bar above the --- separator.
2026-09-08 09:22:36 -03:00
diegosouzapw
885e17fd52 Merge remote-tracking branch 'origin/release/v3.8.51' into feat/i18n-batch-eu 2026-09-08 09:19:28 -03:00
diegosouzapw
c1ea96e03f feat(i18n): add 9 European locales (el hr sr lt et lv sl mt ga)
Batch 1 of the locale-expansion plan: Greek, Croatian, Serbian, Lithuanian,
Estonian, Latvian, Slovenian, Maltese and Irish across every surface —
dashboard catalog, docs mirrors, CLI catalog, README, locale index and the
marketing site. OmniRoute now ships all 24 official EU languages (51 locales).

Also fixes two defects the batch exposed:

- The placeholder-parity gate matched every "{…}" pair, so an ICU plural branch
  body (other {s}) counted as an argument named "s" and any correct plural
  translation was reported as drift. The scanner now follows the ICU grammar.
  Three translations that invented a {count} argument the English source never
  defines were corrected, as was one Irish string that translated the argument
  name itself.
- Language bars linked to mirrors that do not exist: docs/guides/I18N.md is
  English-only by design yet keeps legacy mirrors, so every new locale got a
  dead link. Bars now skip locales without a mirror on disk.
2026-09-08 09:15:01 -03:00
Diego Rodrigues de Sa e Souza
86b1cb84fe feat(release): reconcile-changelog tool + version-anchored fragment aggregation (#12987)
* feat(release): reconcile-changelog tool + version-anchored fragment aggregation

`npm run release:reconcile` (scripts/release/reconcile-changelog.mjs) turns the
v3.8.51 reconciliation pass (#12971) into a repeatable Phase 0a step:

- folds `changelog.d/` fragments under `## [<version>]` — never under the first
  matching heading — and credits each one with the PR of the commit that ADDED
  it (`git log --diff-filter=A`), because the filename prefix is not reliable
  (issue numbers, closed/recreated PRs, literal `#PR_NUMBER`); `--carrier N`
  marks a PR that only back-filled fragments for other people's PRs
- drops fragments whose text already ships in another version section
  (phantom fragments re-added by branches cut before the previous aggregation)
- covers a commit only when its OWN PR is a primary ref of a bullet (a
  `/pull/N` link, the trailing `(#N)`, or an explicit `(#N …)` group) so an
  incidental mention cannot hide a PR's own bullet; generates
  `**type(scope):** subject (#PR) — thanks @author` for the rest, rolls
  Dependabot bumps into one line, documents direct pushes by hash
- `--credit N=handle` carries the closed-PR / co-author / deleted-PR audit
- keeps pre-existing section bullets verbatim (changelog-integrity compares
  bullet lines), never touches `[Unreleased]` or older sections
- opens the section with "📊 Release by the numbers" + "🏆 Top 25" (mailmap +
  merged-PR login), the v3.8.50 format

`aggregate-changelog.mjs` gains the same anchoring: `insertBullets(text,
bullets, version)` searches the heading inside `## [version]` only (with
`[Unreleased]` still carrying `###  New Features`, every feature fragment was
landing there); `aggregate()` reads the version from package.json.

Tests: tests/unit/reconcile-changelog.test.ts (helpers + an end-to-end
reconcile fixture) and two new cases in tests/unit/changelog-fragments.test.ts.

* fix(release): escape every regex metacharacter before building the mention regex

CodeQL js/incomplete-sanitization on reconcile-changelog.mjs: the handle was only
escaping '-' before being interpolated into a RegExp. Use a full escapeRegExp
helper instead; handles are [A-Za-z0-9_-] in practice, so behaviour is unchanged
for real input and the addCredit tests still pass.
2026-09-08 09:11:13 -03:00
Diego Rodrigues de Sa e Souza
10fef01d20 docs: sync migration and strategy counts with the code (#12970)
Contadores de docs fora de sincronia com o código, aprovado pelo dono em chat por tocar `AGENTS.md` e `skills/cli-tunnel/SKILL.md` (Hard Rule — superfície de instrução de agente). Nenhuma instrução mudou.

`Docs Gates` acusava 6 drifts STRICT. Dois vieram da minha leva de 16 PRs: migrations 169 → **171** (#12707 trouxe a 173, #12867 a 174) e estratégias de roteamento 19 → **20** (#12789 registrou a `quota-weighted`). Contei os arquivos em vez de confiar na memória: `ls src/lib/db/migrations/*.sql | wc -l` → 171.

Os 41 mirrors de `docs/i18n/*/llm.txt` foram regenerados com `scripts/i18n/sync-llm-mirrors.mjs` — o gate exige cópia exata da raiz.

O outro braço, `check:agent-skills-sync` acusando `GENERATED: + cli-tunnel`, era herdado (o corpo do #12866 já o registrava). O `SKILL.md` commitado documentava `tunnel create [type]`, um argumento que a CLI **não aceita** — conferido em `bin/cli/commands/tunnel.mjs:21`, que declara `.command("create")` puro. Saída do gerador, não escrita à mão.

| gate | antes | depois |
|---|---|---|
| `check:docs-counts` | 6 drifts STRICT | **0** |
| `check:docs-sync` | FAIL — 41 mirrors divergentes | **PASS** |
| `check:agent-skills-sync` | `+ cli-tunnel` | **UNCHANGED: 46 skills** |
2026-09-08 09:10:40 -03:00
Diego Rodrigues de Sa e Souza
89c42d36df fix(security): redact AIza credentials of any length in error bodies (#12964)
Vazamento de credencial em corpo de erro. `tests/unit/error-sanitizer-sk-key-qv45.test.ts` falhava no tip em 8ms:

```
AssertionError: Google key survived: Bad credentials for AIzaSyA1B2C3D4E5F6G7H8I9J0KaLbMcNdOeP
```

O padrão era `/AIza[0-9A-Za-z_-]{35}/` — comprimento **exato**. Uma chave Google padrão tem 39 caracteres e casa; qualquer credencial `AIza…` mais curta ou mais longa passava direto para o corpo do erro.

Os dois lados divergiram na reconciliação de dois PRs do mesmo GHSA: o padrão com `{35}` veio do #12506, o teste anti-drift que cobra `/\\bAIza[A-Za-z0-9_-]{20,}/` veio do #12620. Está vermelho desde que os dois entraram em sequência.

`{20,}` no lugar de `{35}`. Numa mensagem de erro, redigir demais uma string que apenas começa com `AIza` não custa nada; redigir de menos vaza credencial — o lado errado para errar é claro.

Evidência: o arquivo vai de 7/9 para **9/9**. Bateria de sanitização com 538 testes: 533 passam, e as 5 restantes são pré-existentes no tip, não desta mudança (4 levam 21–25s por spawn de processo isolado sob carga; `tunnel-routes-error-sanitization` falha igual no tip puro, verificado). Nenhum teste foi enfraquecido — o padrão foi ampliado para satisfazer uma asserção que já existia.
2026-09-08 09:10:36 -03:00
Diego Rodrigues de Sa e Souza
3ab53d188c fix(sse): restore abort mapping, cache telemetry and fence safety on the non-streaming leg (#12990)
Consertadas 5 das 7 regressões que o #12867 introduziu em `tests/unit/chatcore-translation-paths.test.ts` — arquivo que ele não toca, e por isso fora da minha validação focada quando o mergeei. Medido: **74/74** em `ce49d96` (antes), **67/74** em `d6f3150` (depois), **72/74** agora.

**Abort de cliente perdeu o mapeamento (3 testes).** O leg classificava por `error.name === "AbortError"`, mas `abort(reason)` pode rejeitar com string crua sem `name` — essa forma caía em 502 em vez de 499, o que o #7907 fixou. E a mensagem passava por `formatProviderError`, entregando `[499]: request aborted by client` ao cliente. O `chatCore` sempre usou `isLocalStreamLifecycleError` e o literal `"Request aborted"`; espelhado.

**`clientResponse` sintético em abort (1 teste).** O caminho antigo omitia o campo porque o cliente já tinha desconectado — esse corpo é o que teríamos enviado, e o dashboard lê o campo como "o que o cliente recebeu". O caminho novo gravava sempre.

**Telemetria de prompt cache sumiu do call log (1 teste).** `claudePromptCacheLogMeta` só era construído dentro do `executeProviderRequest`; o leg virou dono do primeiro send e a variável ficou `null`, então `_omniroute.claudePromptCache` desapareceu **em silêncio** de todo call log desse caminho. Não é teste chato: é observabilidade perdida em produção.

**Corpo não canonicalizável derrubava a request (1 teste).** `derivePostInjectionRequestIdentity` era chamado antes de qualquer checagem de flag; ele canonicaliza o corpo e o `canonicalStringify` rejeita `Date`, `Map` e instâncias de classe por desenho. Um corpo com essas formas lançava `TypeError` em **toda** request não-streaming, inclusive com `SERVER_OWNED_TOOL_LOOP_ENABLED` desligada, que é o default. Agora deriva só quando o loop pode rodar e falha fechada.

Evidência: 74 testes do arquivo 72/74; 138 nas 5 suítes vizinhas com 136 passando; `typecheck:core` limpo; `check-api-typecheck` OK 289; ESLint 0.

**As 2 restantes ficam abertas de propósito** — `refreshes GitHub credentials after 401` e `locks per-model quota failures`. Mesma causa: o leg encerra num não-2xx sem passar pela classificação de falha do `chatCore`. `nonStreamingProviderLeg.ts` não tem uma ocorrência de `lockModel`, `refreshCredentials` ou `markAccountUnavailable`; o `chatCore` tem ~170 linhas disso mais o bloco de refresh 401. Em produção: token Copilot não renova no 401, e 402/429 por quota não trava o modelo naquela conexão. Não consertei porque devolver a `Response` ao `chatCore` é impossível (já consumida por `.text()`) e reimplementar a classificação no leg é decisão de desenho do refactor — @HouMinXi tem o contexto.
2026-09-08 09:10:03 -03:00
Diego Rodrigues de Sa e Souza
99282e1054 fix(sse): pin the ok variant of the non-streaming leg result in chatCore (#12963)
Base-red: `API Route Typecheck` falhava no tip com 13 TS2339 novos em `chatCore.ts`, vindos do #12867 — que eu mergeei validando só com `typecheck:core`, que não cobre esse arquivo.

Causa: `legResult` é a união `NonStreamingProviderLegResult`; o guard de erro estreita para a variante `ok`, mas a reatribuição condicional do tool loop devolve o tipo declarado e as 13 leituras seguintes perdem a narrowing. Corrigido fixando a variante num binding próprio — `loopApply.leg` já é `& { kind: "ok" }`, então sem cast.

Gate: 302 erros com 13 novos → **289, todos dentro da baseline congelada**. `typecheck:core` limpo, ESLint 0 no arquivo.

Dois commits: o primeiro é Prettier puro sobre o arquivo do tip (que chegou fora do padrão pelo #12867), verificado byte a byte contra `prettier(tip)`; o segundo é a mudança semântica, 39 linhas.

Os demais vermelhos deste PR são herdados e cobertos por #12990, #12964 e #12970.
2026-09-08 09:05:16 -03:00
Diego Rodrigues de Sa e Souza
fcc2dcd1a6 docs(changelog): reconcile the v3.8.51 living section — fold 366 fragments, cover every cycle commit, credit every contributor (#12971)
Mid-cycle reconciliation of `## [3.8.51]` against the full cycle range
`release/v3.8.50..release/v3.8.51` (091589089c..d6f315018a, 696 non-merge commits):

- fold the 366 `changelog.d/` fragments into the section (features had been
  landing under `[Unreleased]` because the aggregator appends at the FIRST
  matching heading) and delete them
- drop 25 fragments that duplicate bullets already shipped in `[3.8.50]` /
  `[Unreleased]` (six phantom fragments re-added by branches cut before the
  v3.8.50 aggregation; 19 pre-cycle PR fragments)
- generate one bullet per cycle commit that had no fragment (350 commits:
  44 features / 196 fixes / 91 maintenance, 17 Dependabot bumps rolled up),
  carrying the merged PR link and `— thanks @author`
- credit audit: link every fragment to the PR that actually landed it
  (`git log --diff-filter=A`), fix three `#PR_NUMBER` placeholders and two
  misnumbered fragments (#11845, #11864), credit the recreated PRs to their
  original authors (#11887–#11892 → @MumuTW, #12255 → @backryun,
  #11771 → @Rahulsharma0810), the nine hartmark co-authored fixes, and the
  deleted PR #11370 to @kriptoburak
- add the "Release by the numbers" block, the Top-25 ranking (mailmap +
  GitHub login) and the mandatory `### 🙌 Contributors` hall
  (111 external contributors + maintainer, generated by
  `release:contributors --inject`)
- resync the 41 i18n CHANGELOG mirrors

Gates: check:changelog-integrity OK, check:docs-sync PASS.
2026-09-07 16:20:57 -03:00
Bob.Hou
d6f315018a fix(chat): continue after a server-owned tool on Chat Completions (#12867)
Validado numa worktree combinada com as 16 PRs desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-file-size e check-changelog-integrity OK, complexity 2788/3218 e cognitive 1261/1437, ESLint 0 erros nos 152 arquivos alterados, e a suíte vitest:ui completa (2149) verde.

Sobre esta PR especificamente: rodei os **23 arquivos de teste** que ela toca sobre o tip final, depois do merge da base — **392/392**. A migration `174_server_tool_executions.sql` não colide (o tip está em 173, e você já a renumerou em `c35f0fd7`).

O dono foi consultado antes do merge, porque o loop está atrás da flag `SERVER_OWNED_TOOL_LOOP_ENABLED` mas o primeiro send não-streaming mudou de dono sem flag, e a verificação manual em combo com Memory continuava desmarcada. A condição dele foi: entra se os testes focados passarem aqui. Passaram.

O lock de passthrough (`fetchCalls.length === 1`) é a parte que mais me convenceu — o double-dispatch que um `if (stream)` em volta do send existente causaria é exatamente o tipo de regressão que não aparece em teste de comportamento, só em contagem de chamada.

**Três ajustes meus na sua branch:**

1. `tests/unit/chatcore-stream-error-result.test.ts` procurava `"const legResult = await runNonStreamingProviderLeg"`, mas o seu commit final `6077b9dd` passou a reatribuir `legResult` e trocou para `let`. O guard falhava na sua própria branch (confirmei que o arquivo e o `chatCore.ts` eram byte-idênticos ao head da PR, então não era efeito da leva). Passou a aceitar `const|let` — a intenção do guard é o try/catch em volta da chamada, não a palavra-chave.

2. `tests/integration/skills-pipeline.test.ts` foi de 1156 para 1338 linhas e estourou o `testCap` de 1200. Segui o mesmo caminho que você já tinha tomado em `a1d2d20d` para os testes unitários: extraí os três casos do server-owned tool loop para `tests/integration/server-owned-tool-loop-pipeline.test.ts` (259 linhas), com instância própria do harness. O glob `tests/integration/*.test.ts` pega o arquivo novo sem registro adicional. 3/3 verdes isolados.

3. O arquivo novo herdou cinco `any` do original — que só passavam por estarem congelados no `eslint-suppressions.json` sob o nome antigo. Tipei como `Record<string, unknown>`. E `tests/unit/non-streaming-finalization.test.ts` tinha dois argumentos não usados em `trackPendingRequest`, agora prefixados com `_`.

Nada disso toca produção nem enfraquece asserção.
2026-09-07 09:15:00 -03:00
Bob.Hou
ce49d969ca refactor(combo): move handleRoundRobinCombo into roundRobinCombo.ts (#12811)
Validado numa worktree combinada com as 16 PRs desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-file-size e check-changelog-integrity OK, complexity 2788/3218 e cognitive 1261/1437, ESLint 0 erros nos 152 arquivos alterados, 771 testes unitários focados, 49 de integração e a suíte vitest:ui completa (2149) verdes.

**Sobre a reconstrução da branch.** Esta PR continha os 7 commits do #12746 mais os 3 do round-robin. O dono escolheu mergear os dois em sequência em vez de fechar um como subsumido, então depois que o squash do #12746 entrou eu reconstruí esta branch: cherry-pick de `05059880`, `54168238` e `ddd6bcbf` sobre o tip novo, e force-push. Autoria preservada — os três commits continuam seus (`Minxi Hou <houminxi@gmail.com>`), verificado com `git log --format=%an` antes do push. A PR foi de +4167/−3187 em 14 arquivos para +1281/−1182 em 5, que é o delta real do round-robin.

O `05059880` ("guard round-robin extract before the lift") é o commit que faz esse tipo de extract ser revisável: sem um teste que fixe o contrato antes do movimento, mover 1198 linhas é indistinguível de reescrever 1198 linhas.

Revalidei sobre o tip reconstruído: `round-robin-combo`, `combo-attempt-loop`, `execute-target-attempt`, `execute-target-gates` e `combo-loop-safety-timer-leak-11804` — 24/24 — com typecheck:core limpo e o cap de arquivo OK.
2026-09-07 09:09:51 -03:00
Bob.Hou
6b587d0046 refactor(combo): split executeTarget into gates, attempt, and loop (#12746)
Validado numa worktree combinada com as 16 PRs desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-file-size e check-changelog-integrity OK, complexity 2788/3218 e cognitive 1261/1437, ESLint 0 erros nos 152 arquivos alterados, 771 testes unitários focados, 49 de integração e a suíte vitest:ui completa (2149) verdes.

Guardar os contratos com testes ANTES de levantar o bloco (`05059880` no irmão, e o `287658f5` marcando os `executeTargetGates` como lift-as-is) é o que torna um refactor deste tamanho auditável. Sem essa ordem, um extract de 3 mil linhas é indistinguível de uma reescrita.

Revalidei depois do merge da base: `combo-attempt-loop`, `execute-target-attempt`, `execute-target-gates` e `combo-loop-safety-timer-leak-11804` — 20/20 — mais typecheck:core limpo e o cap de arquivo OK.

O #12811 entra na sequência logo em seguida, com os três commits do round-robin sobre este.
2026-09-07 09:07:19 -03:00
Bob.Hou
c1b34db50d feat(combo): quota-weighted routing — skip empty accounts, draw by leftover (#12789)
Validado numa worktree combinada com as 16 PRs desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-file-size e check-changelog-integrity OK, complexity 2788/3218 e cognitive 1261/1437, ESLint 0 erros nos 152 arquivos alterados, 771 testes unitários focados, 49 de integração e a suíte vitest:ui completa (2149) verdes.

Reservar o sorteio antes do próximo `await` (`2cf74acc`) é a parte não-óbvia e a que mais importa: sem isso dois pipelines no mesmo processo observam `inflight=0` na mesma conta e convergem para ela. O comentário no código explica isso melhor do que o commit message.

**Um ajuste meu na sua branch.** O `tests/unit/combo/quota-weighted-strategy.test.ts` era intermitente — falhava em cerca de 1 a cada 5 execuções, alternando entre `A/B isolation: 7 hard-empty…` e `floor=0 puts 0.5% in the main pool`, sempre com dois pares de mesma faixa trocando de posição. A causa é o helper de fixture:

```ts
const iso = (ms = 86_400_000) => new Date(Date.now() + ms).toISOString();
```

Como `iso()` é chamado a cada invocação do fetcher, dois peers que deveriam empatar recebiam `resetAt` com um milissegundo de diferença sempre que o relógio virava entre as duas chamadas. Pressão de reset entra no score, então esse epsilon quebrava o empate e `sortByScoreThenIndex` nunca chegava ao fallback por índice de inserção.

Fixei a base do relógio uma vez só (`CLOCK_BASE`). Nenhuma asserção foi tocada — as garantias de ordem, tamanho e exclusão continuam idênticas. 10/10 execuções verdes depois, e mais 6/6 após o merge da base nesta branch.

Também mergeei a base para resolver `file-size-baseline.json` (aditivo) e `src/domain/quotaCache.ts`, onde o seu placeholder `_providerSpecificData` cedeu lugar à implementação do #12803, que usa o parâmetro de fato.
2026-09-07 09:05:29 -03:00
Bob.Hou
ebdbd2c67d feat(models): live account catalog for Claude, Codex, Copilot, AGY (#12866)
Validado numa worktree combinada com as 16 PRs desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-file-size e check-changelog-integrity OK, complexity 2788/3218 e cognitive 1261/1437, ESLint 0 erros nos 152 arquivos alterados, 771 testes unitários focados, 49 de integração e a suíte vitest:ui completa (2149) verdes.

O ponto que sustenta a PR é o `models.dev` virar overlay de preço em vez de fonte de catálogo. Um catálogo estático que sobrevive à conta já ter listado ids mais novos é o tipo de defeito que só aparece quando o modelo novo é justamente o que se quer usar.

Nota de integração: `activeSyncedCatalog.ts` colidiu com o #12934 (união dos `customModels` do picker no catálogo de despacho). Como você extraiu o bloco original para `loadConnectionCatalog`, os dois se compõem: a união dos irmãos agy/antigravity primeiro, o `unionCustomModels` por cima. Revalidei com `custom-models-live-catalog-12597`, `live-model-catalog-reconciliation-8926`, `sync-models-degraded-cached-catalog-9683`, `models-dev-catalog-read-gate`, `discovery-class`, `reactive-model-sync` e `l1-oauth-autosync-default` juntos — 48/48 — mais typecheck:core limpo.

Sobre o `autoSync` padrão em Claude/Codex/Copilot com scheduler de 6h: passei isso pelo dono antes de mergear e a decisão foi manter como está.
2026-09-07 09:02:59 -03:00
Bob.Hou
aa35d460dc fix(catalog): union picker customModels into the dispatch-time live catalog (#12597) (#12934)
Validado numa worktree combinada com as 16 PRs desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-file-size e check-changelog-integrity OK, complexity 2788/3218 e cognitive 1261/1437 (ambos sob a baseline), ESLint 0 erros nos 152 arquivos alterados, 771 testes unitários focados, 49 de integração e a suíte vitest:ui completa (2149) verdes.

A assimetria era exatamente o defeito: o REST do picker já mesclava `customModels`, o despacho não, e o operador via o modelo na tela e tomava 400 na inferência. O overlay só de campos definidos é o detalhe que impede uma escrita esparsa do picker de apagar metadata de capacidade que veio do sync.

Nota de integração: este arquivo colidiu com o #12866, que extraiu o mesmo bloco para `loadConnectionCatalog` e uniu os catálogos irmãos agy/antigravity. Integrei os dois na worktree combinada — a união de irmãos primeiro, o `unionCustomModels` por cima — e a resolução vai junto no merge do #12866.
2026-09-07 09:00:58 -03:00
Bob.Hou
1b97f42ba3 fix(combo): treat a pin-only step as implicit connection allowlist (#12697)
Validado numa worktree combinada com as 16 PRs desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-file-size e check-changelog-integrity OK, complexity 2788/3218 e cognitive 1261/1437 (ambos sob a baseline), ESLint 0 erros nos 152 arquivos alterados, 771 testes unitários focados, 49 de integração e a suíte vitest:ui completa (2149) verdes. Após o merge da base nesta branch, os 11/11 do `combo-pin-implicit-allowlist` foram revalidados.

A distinção entre pin de step de combo e pin forçado por header (`x-omniroute-connection`) é o que salva a PR de virar uma restrição ampla demais — o header continua permitindo fallback para conexões irmãs, o step não.

Apontar que o `a11930ec4` para a rotação dentro do `handleSingleModel` mas não popula `allowedConnectionIds` no resolve foi a peça que explicou por que os dois são complementares e não redundantes. Sem isso a PR pareceria duplicar um gate que já existia.
2026-09-07 09:00:37 -03:00
Bob.Hou
f12b87c80b fix(claude): extra-usage switch does not skip 5h preflight (#12803)
Validado numa worktree combinada com as 16 PRs desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-file-size e check-changelog-integrity OK, complexity 2788/3218 e cognitive 1261/1437 (ambos sob a baseline), ESLint 0 erros nos 152 arquivos alterados, 771 testes unitários focados, 49 de integração e a suíte vitest:ui completa (2149) verdes.

`blockExtraUsage: false` significa "pode usar crédito extra", nunca "esconda a conta antes de despachar" — a conta saía da rota justamente quando o crédito extra existia para ser usado. Os 32/32 cobrem os quatro pontos onde a mesma decisão era tomada, e revalidei após o merge da base (32/32 de novo).

Nota de integração: o seu `isQuotaExhaustedForRequest` colidiu com o placeholder `_providerSpecificData` do #12789 na worktree combinada. Ficou a sua implementação, que é a que de fato usa o parâmetro. A base foi mergeada na branch para resolver o `file-size-baseline.json` (aditivo, JSON revalidado).
2026-09-07 08:59:39 -03:00
Bob.Hou
c042a51884 feat(grok-cli): show and redeem banked reset credits on Provider Limits (#12805)
Validado numa worktree combinada com as 16 PRs desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-file-size e check-changelog-integrity OK, complexity 2788/3218 e cognitive 1261/1437 (ambos sob a baseline), ESLint 0 erros nos 152 arquivos alterados, 771 testes unitários focados, 49 de integração e a suíte vitest:ui completa (2149) verdes.

A decodificação dos campos aninhados 10/20/30 do `GetRemainingResets` ao vivo (último commit) é o que separa isto de um palpite sobre o formato do frame. Mostrar zero em vez de esconder a linha é a escolha certa: crédito zerado é informação, ausência de linha é ambiguidade.
2026-09-07 08:58:12 -03:00
Bob.Hou
d7721559a0 fix(combo): restricted keys listing a combo name no longer skip every member (#12899)
Validado numa worktree combinada com as 16 PRs desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-file-size e check-changelog-integrity OK, complexity 2788/3218 e cognitive 1261/1437 (ambos sob a baseline), ESLint 0 erros nos 152 arquivos alterados, 771 testes unitários focados, 49 de integração e a suíte vitest:ui completa (2149) verdes.

Regressão de v3.8.50 vinda do #9057, com o sintoma mais enganoso possível: `attempted: 0`. A política já tinha admitido o combo e a checagem era refeita em cada membro interno.

Manter o filtro por prefixo de provider e o `disableNonPublicModels` intactos é o que impede o short-circuit de virar um buraco na allow-list.
2026-09-07 08:57:41 -03:00
Bob.Hou
e8a91173de fix(combos): inherit model_context_overrides onto effort-suffixed targets (#12475) (#12926)
Validado numa worktree combinada com as 16 PRs desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-file-size e check-changelog-integrity OK, complexity 2788/3218 e cognitive 1261/1437 (ambos sob a baseline), ESLint 0 erros nos 152 arquivos alterados, 771 testes unitários focados, 49 de integração e a suíte vitest:ui completa (2149) verdes.

A busca do sufixo mais longo primeiro (`-xhigh` antes de `-high`) é o detalhe que faz a herança funcionar em vez de quase-funcionar. Manter `getResolvedModelContextOverride` fora do escopo, com o teste existente registrando que aquele caminho continua sem herança, deixa a fronteira explícita.
2026-09-07 08:57:27 -03:00
Bob.Hou
6d6b6027c5 fix(pwa): do not intercept navigations so Chrome can retry HTTP/2 (#12767)
Validado numa worktree combinada com as 16 PRs desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-file-size e check-changelog-integrity OK, complexity 2788/3218 e cognitive 1261/1437 (ambos sob a baseline), ESLint 0 erros nos 152 arquivos alterados, 771 testes unitários focados, 49 de integração e a suíte vitest:ui completa (2149) verdes.

A separação entre o que é do service worker e o que é do Caddy está certa e é o que torna a PR mergeável: o `respondWith` em navegação é defeito nosso, o `Alt-Svc` mentindo h3 é config de proxy reverso e não tem o que fazer aqui.

O `/dashboardfoo` casando com `startsWith("/dashboard")` é um achado à parte, e o bump de cache v2→v3 é o que faz o worker antigo sair do ar nos clientes que já estão presos.
2026-09-07 08:57:15 -03:00
Bob.Hou
600abe68d0 fix(dashboard): moonshot voucher/cash leftover follows bucket balance (#12733)
Validado numa worktree combinada com as 16 PRs desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-file-size e check-changelog-integrity OK, complexity 2788/3218 e cognitive 1261/1437 (ambos sob a baseline), ESLint 0 erros nos 152 arquivos alterados, 771 testes unitários focados, 49 de integração e a suíte vitest:ui completa (2149) verdes.

Available 0% com Voucher 100% e Cash 100% não é estado de carteira que exista — foi o sinal certo para puxar o fio. Tratar leftover como booleano só para Available e cravar 100% nos outros dois buckets é o tipo de defeito que passa despercebido enquanto a conta tem saldo.

Os dois testes cobrem os dois lados: o produtor e o caminho até `getQuotaRemainingPercentage` com `isCredits` + CNY.
2026-09-07 08:57:02 -03:00
Bob.Hou
d4d2e68a1f fix(dashboard): pass nodeMap into Runtime QuotaGroup (#12868)
Validado numa worktree combinada com as 16 PRs desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-file-size e check-changelog-integrity OK, complexity 2788/3218 e cognitive 1261/1437 (ambos sob a baseline), ESLint 0 erros nos 152 arquivos alterados, 771 testes unitários focados, 49 de integração e a suíte vitest:ui completa (2149) verdes.

O `nodeMap` lido do closure de `RuntimePageClient` por uma função de nível de módulo é uma bomba-relógio silenciosa: só explode quando um monitor entra em error/exhausted/alerting, e o teste existente só alimentava listas vazias. Tirar o arquivo do exclude do vitest vale tanto quanto o fix — confirmei aqui que `tests/unit/ui/runtime-page-client.test.tsx` agora roda na `test:vitest:ui` e passa.

A anotação sobre o "内部服务器错误" ser o catálogo RSC da página, e não o crash, poupou o próximo a caçar fantasma.
2026-09-07 08:56:49 -03:00
Bob.Hou
25bc16d87e fix(dashboard): batch delete no longer toasts failure after success (#12711)
Validado numa worktree combinada com as 16 PRs desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-file-size e check-changelog-integrity OK, complexity 2788/3218 e cognitive 1261/1437 (ambos sob a baseline), ESLint 0 erros nos 152 arquivos alterados, 771 testes unitários focados, 49 de integração e a suíte vitest:ui completa (2149) verdes.

Além do bug do toast, esta PR foi a que derrubou os três base-reds vivos do tip: o fragmento `changelog.d/fixes/reset-aware-model-family.md` sem o `- ` inicial, o registro do `tests/unit/reset-aware-request-scope-12600.test.ts` no `stryker.conf.json` e o `TS2554` do glm. O `check-changelog-integrity` voltou a passar aqui por causa dela.

O diagnóstico do MouseEvent é o que dá o valor: `onConfirm` chegava como handler de clique nativo e `handleBatchDeleteConfirm` tratava qualquer primeiro argumento truthy como callback. O cinto (`typeof`) e o suspensório (o wrap no ConfirmModal) juntos estão certos — só um dos dois deixaria a porta aberta para o próximo caller.
2026-09-07 08:56:36 -03:00
Bob.Hou
d857bd053a fix(glm): drop extra 16th arg to SSE transform helper (#12770)
Validado numa worktree combinada com as 16 PRs desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-file-size e check-changelog-integrity OK, complexity 2788/3218 e cognitive 1261/1437 (ambos sob a baseline), ESLint 0 erros nos 152 arquivos alterados, 771 testes unitários focados, 49 de integração e a suíte vitest:ui completa (2149) verdes.

O `65536` era o 16º posicional de um helper com 15 parâmetros — `TS2554` vivo no tip (`open-sse/executors/glm.ts:244`, confirmado aqui antes do board). O teste de guarda de aridade é o que impede a reincidência: ele checa a assinatura do helper e o call site, não o comportamento, que é exatamente onde o erro morava.

Obrigado por isolar isso do #12711 em vez de deixar o `glm.ts` viajar junto com pin/combo-split/moonshot.
2026-09-07 08:55:45 -03:00
Diego Rodrigues de Sa e Souza
ce55151ca5 chore(ci): guard commit identity in pre-commit to stop author misattribution (#12772)
* chore(ci): guard commit identity in pre-commit to stop author misattribution

Two windows of commits in this checkout were signed with the wrong identity,
both caused by an identity override left behind by an automated session:
2026-08-13..26 (name "Xiangzhe" + @backryun's e-mail, 237 commits) and
2026-08-29..09-02 (name "Markus Hartung" + the maintainer's e-mail, 59 commits).
The .mailmap repairs the record after the fact; this gate stops the next window.

The gate is opt-in per machine via omniroute.expectedName / expectedEmail — with
no config it exits 0, so contributors who clone the repo are never affected. It
blocks three things: a committer that is not this machine's identity (which is
what BOTH windows looked like — in August neither the name nor the e-mail was
the maintainer's, so checking only their e-mail would have missed it), an author
carrying the maintainer's e-mail under someone else's name, and any address
listed in omniroute.legacyEmail.

Crediting a contributor with `git commit --author="Name <their@email>"` keeps
working, since the rule targets the committer and the maintainer's own address.

* test(ci): isolate the identity gate's test from the ambient git config

The "stays inert when the machine has not opted in" case read the real
global config, so on a machine that HAS opted in (omniroute.expectedEmail
set — the maintainer's own boxes, where this gate matters most) the gate
correctly refused a synthetic contributor identity and the test failed.
It only passed on a clean CI runner.

Neutralising GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM makes the opt-in state
come solely from what the test injects, so the suite is deterministic on
both an opted-in and a clean machine.
2026-09-07 08:35:29 -03:00
Dizzle
b345c7f6cd feat(opencode): opencode v2 plugin publishing the OmniRoute catalog (#12870)
opencode v2 loads plugins through a contract the existing
@omniroute/opencode-plugin cannot satisfy: v1 exports plugin factories with an
auth/provider/config/tool hook object, v2 expects a default define({id, setup})
carrying catalog and integration domains. One package would have to satisfy
both loaders from a single entrypoint. An opencode v2 install therefore has no
route to an OmniRoute gateway at all: no model discovery, no combos, no
enrichment.

This adds @omniroute/opencode-plugin-v2, a self-contained package. The v1
plugin is untouched, so v1 users see no move, no migration and no breaking
version. The two packages deliberately share no code and no release: the
mapping logic here began as a port of v1's and now lives in this package, which
keeps either one free to change without a coordinated publish.

The plugin publishes models, combos and auto-combos into the host catalog,
refreshes them lazily behind a 300s TTL, and keeps serving the last known
catalog from an on-disk snapshot when the gateway is unreachable. Publishing is
staged: models and combos are what a catalog is, so they go out as soon as they
are known, while auto-combos, the provider list and the enrichment overlay fold
into the snapshot when they land. Gating the publish on all of them made the
catalog hostage to the slowest source — a gateway that accepts the connection
and never answers /api/combos/auto left everything unpublished until that fetch
timed out, which is longer than a short-lived host stays alive.

Display names carry what the gateway knows about a model: the upstream provider
it routes to, whether it is free, and the budget that comes with it. Those parts
were already fetched and then dropped, so two connections selling the same model
looked identical in the picker. The provider prefix can be turned off with
`providerTag: false`.

The on-disk snapshot carries that overlay too, under a size cap, so a cold start
opens on named models rather than raw ids. The host is asked to reload only when
the catalog or the overlay actually moved, never once per refresh window.

The gateway key comes from the host credential store when one is connected, so
connecting the integration from opencode is enough and no secret needs to sit
in opencode.json; a plugin option and an environment variable remain as
fallbacks, and a host too old to expose a credential store still loads. Nothing
is silent when a key is missing or refused: an absent key is named once at
startup with the three ways to supply one, and an enrichment source the gateway
rejects is reported per endpoint with what the catalog loses. Those three
failures used to be empty catch blocks, which turned a management token the
gateway refuses into a catalog of raw model ids with no explanation.

Tool calling to Gemini keeps working. Gemini answers 400 INVALID_ARGUMENT for
an entire request whose tool declarations carry $schema, $ref or
additionalProperties. The v1 plugin handled it by wrapping fetch and rewriting
the JSON body; v2 does it on the language model, where the tools are still
structured data, and only for Gemini models of this provider. It can be turned
off with geminiSanitization: false, and a host exposing no aisdk domain loads
without it.

The catalog contract itself is a moving target, so the plugin adapts to the
host instead of assuming one shape. The released CLI keeps the aisdk package,
the endpoint (as settings.baseURL), the request headers and the variant options
directly on the model and provider; the current SDK types keep the same
information inside an api block. Writing only the api block yields a catalog
the released CLI lists but cannot route. Rather than key off a version list
that goes stale on the next release, the plugin reads the shape the host seeds
into the catalog draft and publishes accordingly: a seed with a top-level
package and no api block gets both field sets, a seed with an api block gets
that block alone, and an undisclosed seed gets both. None of the legacy keys
collide with a key of the current types, so the two shapes coexist on one
object, variants included.

Four v1 behaviours are deliberately not carried over, because v2 either owns
them or no longer needs them: the plugin-side debug log (the host has its own
logging), the compression-metadata suffix on combo names, the MCP auto-emit
(the v2 host owns MCP), and the omni-sync command plus its background timer
(the TTL and a content fingerprint drive catalog.reload instead).

A refresh never downgrades what is already published: the previous overlay is
carried forward until the new one lands, so names, pricing and the usable
filter no longer drop out for the length of every TTL window. The disk snapshot
is read after the credential is resolved, because it is keyed by that
credential — reading it earlier looked up the identity the options carry rather
than the one in use, and rejected a perfectly good catalog exactly when the
gateway was down.

The tool-schema cleaner now knows where a schema ends and a property name
begins. Stripping keywords by name anywhere in the tree deleted a tool
parameter called `ref` while leaving it in `required`, handing the model a
schema it could not satisfy; a `$ref` it cannot resolve now forwards the tool
untouched instead of widening it to accept anything. Gemini detection is
anchored on the model family, so `gemini-compatible-proxy` is no longer treated
as a Gemini model.

A source the gateway refuses is reported on the library entry point as well,
not only through the plugin, so the usable-provider filter can no longer disable
itself in silence. `providerId` is bounded to a safe character set because it
reaches a filesystem path, `hiddenModels` covers combos as it already covered
models, the Anthropic block gets the gateway root rather than a doubled `/v1`, an unparseable tool schema forwards the tool instead
of failing the request, and the package typechecks under the same settings as
the v1 plugin.

CI mirrors the existing plugin workflow: install, build and test on Node 22 and
24, for both packages. The plugin SDK stays pinned, and the host-shape assertions carry the risk of
a contract move rather than a check against a rolling upstream tag.

Co-authored-by: Max <maxmad64@gmail.com>
2026-09-06 18:36:27 -03:00
groovecityJO
f9a1cc8a9b fix: resolve SqliteError no such table compression_run_telemetry during cleanup (#12682) 2026-09-05 23:32:26 -03:00
Soroush Ahmadi
2b2d34eb53 fix(cursor): guard non-array tool_calls in request translator (#12691) 2026-09-05 23:32:02 -03:00
tom
92a617c23f fix(sse): re-enable prompt compression for native Codex passthrough (#12834)
Native Codex passthrough (POST /v1/responses, provider=codex) was unconditionally
excluded from prompt compression, writing only skip_reason='excluded' analytics
rows. Prompt compression now depends only on the operator exclusions list;
reactive compaction and combo overflow fail-fast intentionally still bypass
(prompt-only scope).

Closes #12793

Regression guard: tests/unit/codex-prompt-compression-passthrough.test.ts
2026-09-05 23:30:48 -03:00
Diego Rodrigues de Sa e Souza
9d1a896c60 fix(tests): retire dead model ids from the chat-pipeline integration suite (base-red #12581) (#12670)
Merged. It does what it says, and it also uncovered something — details below so the follow-up is not mistaken for a regression from this PR.

Measured on `release/v3.8.51`, `tests/integration/chat-pipeline.test.ts`:

| | line 580 | line 994 | line 1599 |
|---|---|---|---|
| tip | `410 !== 200` | `410 !== 200` | `502 !== 200` |
| tip + this PR | passes | passes | passes |

All three were retired model ids reaching the router and coming back 410/502. Swapping them for live ones is exactly the right fix and takes the suite from 25/28 to 27/28.

**The one that remains, and why it is not yours:** with the 410 gone, `chat pipeline persists Codex responses cache and reasoning tokens to call logs` now runs past `assert.equal(response.status, 200)` and reaches line 592, where `callLog.provider` is `openai` and the test expects `codex`. That assertion was simply never reached before — the 410 short-circuited the test at line 580.

I checked whether the model id chosen here was the cause, since `gpt-5.6-sol` is declared by 12 providers (`openai`, `github`, `cursor`, `kiro`, …). It is not: re-running with `gpt-5.3-codex-spark`, which only the `codex` provider declares, produces the identical `openai !== codex`. So it is provider resolution or the `seedConnection("codex")` harness, not catalog ambiguity. I reverted that experiment — this merged exactly as you wrote it.

Filing that as its own issue with the trace.
2026-09-05 03:15:30 -03:00
Diego Rodrigues de Sa e Souza
a9f7598c60 feat(db): fail-closed previous_response_id continuation for redacted video turns (#12150 P2b) (#12707)
Merged, with one column-reconciliation gap closed.

The fail-closed reasoning is right and the comments carry it well: a stored snapshot whose cues were replaced by `[redacted-video-transcript]` must not be rehydrated as continuation history, because forwarding placeholder text upstream as if it were the client's real turn is worse than making the client resend. Treating it exactly like `previous_response_not_found` means no new client-visible behaviour to document. Migration 173 does not collide — the tip runs to 172.

**What I added:** `video_content_removed` to `ensureCallLogsColumns` in `src/lib/db/schemaColumns.ts`, plus a case in `tests/unit/db-schema-columns-split.test.ts`.

`resolvePreviousResponseState` now SELECTs that column on every `previous_response_id` lookup. Migration 173 creates it, but this repo carries a separate reconciliation path for lineages that skipped a migration — and on such a database the SELECT would throw `no such column: video_content_removed` instead of failing closed. That is the same hole #12470 closed for `provider_connections.last_ping_at` earlier today, so the pattern was fresh. Verified red-then-green: stubbing the new reconciliation out drops the suite to 8/9; restored, 9/9.

Validated on `release/v3.8.51`: `responses-continuation-store`, `save-call-log-persistence`, `video-bridge-log-redaction` and `db-schema-columns-split` all green (54 focused tests, 0 failures). `typecheck:core` and `lint` clean. The integration run logs `[DB] Added call_logs.video_content_removed column`, which is the reconciliation firing on a fresh test database.
2026-09-05 03:15:25 -03:00
Diego Rodrigues de Sa e Souza
d345520d72 fix(dashboard): read the combos usage-guide dismissal from an external store (base-red #12581) (#12671)
Merged. This removes the cause that #12607 had to freeze.

`react-hooks/set-state-in-effect` on this file was living in `config/quality/eslint-suppressions.json` as a frozen count of 1 — the lint was green because the violation was suppressed, not because it was gone. `useSyncExternalStore` is the sanctioned shape for exactly this problem: `getServerSnapshot` supplies the SSR-safe default, `getSnapshot` reads localStorage after hydration, and the tree commits once instead of twice. The `storage` listener keeping other tabs in sync is a real bonus.

The detail that makes this correct rather than merely lint-clean: you kept "hide for now" and "hide forever" as separate concepts — `usageGuideHiddenForNow` stays per-mount local state while only the persisted dismissal goes through the store. A naive conversion would have collapsed them and made the temporary hide survive a reload.

Three things I added before merging:

1. **Dropped the `react-hooks/set-state-in-effect` entry from the suppressions file.** With the cause gone it becomes a stale allowlist entry, which is what the Fase 6A.3 stale-enforcement is built to flag. Verified: `eslint` on the file now reports only the 6 pre-existing `no-unused-vars`, which stay frozen.
2. **Updated the rationale comment above the hook** — it still described "correct it client-only, after hydration, in an effect", which is the shape you just removed.
3. **Rebaselined `combos/page.tsx` 5018 → 5066** in `file-size-baseline.json` with a dated annotation. The +48 lines are the module-scope store helpers; the cap is pre-authorized for legitimate growth and this is as legitimate as it gets.

Validated on `release/v3.8.51`: `check-file-size` OK, `lint` clean, `typecheck:core` and `check:dashboard-typecheck` clean (207 pre-existing, all within baseline).
2026-09-05 03:15:03 -03:00
Diego Rodrigues de Sa e Souza
7b2c9b5548 fix(sse): redact video transcript in pre-guardrail rejected-request logs (#12150 P2 item 7) (#12710)
Merged. Focused, correct, and tested.

`recordRejectedRequestUsage` runs on the path where the request never reached the guardrail chain — circuit-breaker-open and combo-exhausted rejections — so the video-bridge guardrail never got the chance to rewrite the transcript, and the raw cues went straight into `call_logs`. Routing the body through `redactVideoTranscriptFieldsForLog` at the persistence boundary is the right place: a no-op clone for non-video bodies, structured field substitution for video ones, and not bypassable by cue content.

The `requestBody == null ? requestBody : …` guard keeps the existing "no body available" case behaving exactly as before, which the neighbouring test still covers.

Validated on `release/v3.8.51`: `tests/unit/rejected-request-usage.test.ts` green, including the new case asserting the secret cue text does not survive into the persisted detail and that the field reads `[redacted-video-transcript]`. `typecheck:core` and `lint` clean.
2026-09-05 03:14:49 -03:00
Diego Rodrigues de Sa e Souza
ec4f951e39 test(ci): pin the openapi-security-tiers two-arm contract with an executing gate test (#12581) (#12652)
Merged as a reduced diff, and worth recording why.

The two-arm `ALWAYS_PROTECTED` read this PR proposed had already landed in #12605 while this branch was open — the tip carries `coveredByAlwaysProtected()` with both arms and the new error wording. I ran the gate on the current tip to be sure: `PASS — all security tier annotations match routeGuard.ts`. Merging the whole branch would have reintroduced the same logic under a different comment.

What was genuinely missing, and is what merged:

- **`tests/unit/openapi-security-tiers-gate.test.ts`** — executes the real gate and asserts exit 0 with no "NOT covered" line. #12605 fixed the defect but left no guard, so the LOCAL_ONLY-arm bug (#12350) could reappear on the ALWAYS_PROTECTED arm exactly as it did the first time. 1/1 green.
- **The parse guard** — `ALWAYS_PROTECTED_PATTERNS.length === 0` now fails the constant-parse check with its own count in the message. Without it, a regex array that stops parsing degrades into "every pattern-covered route is an annotation mismatch" instead of saying so.

A note for the record: my first read of this PR was wrong. I ran the gate in the main checkout, which was 11 commits behind `origin/release/v3.8.51`, saw the pre-#12605 failure, and classified this as fixing a live red. It was not — the checkout was stale. Corrected before anything was merged.
2026-09-05 03:14:46 -03:00
Koosha Paridehpour
3858923f68 fix(ci): ship .npmrc in published package so legacy-peer-deps applies to consumers (#11544) (#12699)
Merged — one line, zero risk, and it costs nothing to have.

One caveat recorded so nobody later reads this as "#11544 is solved": npm resolves config from the *installing* project's directory, the user config and the global config — it does not read the `.npmrc` shipped inside a dependency's tarball. So `legacy-peer-deps=true` traveling in the package will not change how `npm install -g omniroute` resolves peers on the consumer side. Our own `scripts/build/postinstall.mjs` does shell out to `npm rebuild` / `npm install better-sqlite3`, but with cwd set to `dist/`, so the package-root `.npmrc` is not in scope there either.

Keeping it anyway: it makes the published tree self-documenting, and someone debugging inside an extracted package gets the same retry budget we use in CI. But #11544 (`npm install -g omniroute` failing on Windows, "root cause unclear from log") still needs the actual `npm-debug.log` from the reporter before it can be closed.

Rebased onto `release/v3.8.51`; `package.json` re-parses and the `files` array kept both `config/i18n.json` and the new entry. Thanks.
2026-09-05 02:34:41 -03:00
Koosha Paridehpour
8c4fb8faf2 chore(deps): pin browserslist override to ^4.28.8 (#12592)
Merged. One line in `overrides`, low blast radius, and pinning a transitive that every build tool reads is defensible on its own.

Validated on `release/v3.8.51`: `package.json` re-parses, `typecheck:core` clean, `check-file-size` OK.

For future dependency pins, a line in the body about what the floating range actually broke (a specific build failure, a CVE, a resolution conflict) makes these reviewable without guessing. Thanks.
2026-09-05 02:34:23 -03:00
Koosha Paridehpour
7da6e10c4e fix(docker): pin 4 CLI tools to exact versions (#12576) (#12703)
Merged. Verified all four pins resolve on npm before landing:

```
@openai/codex@0.153.2              0.153.2
@anthropic-ai/claude-code@2.1.260  2.1.260
droid@0.212.0                      0.212.0
openclaw@2026.9.1                  2026.9.1
```

The reproducibility argument holds — a floating `@latest` in a cached Docker layer means two builds of the same commit can ship different toolchains, and that is exactly the class of drift that makes a CI failure unattributable.

Worth flagging for whoever maintains this next: pinning trades drift for staleness, so these four now need a periodic bump or the image ships increasingly old CLIs. The comment block you added explains the why, which makes that bump a safe mechanical change instead of a judgment call.

Rebased onto `release/v3.8.51` (the PR was cut from `main`, ~3695 commits behind). Thanks.
2026-09-05 02:34:20 -03:00
Koosha Paridehpour
0df5be5b09 docs(gamification): align XP Rewards table with code (#12501) (#12667)
Merged, with the markdown repaired.

Checked every row against `src/lib/gamification/xp.ts:138` — the table now matches `XP_REWARDS` exactly, keys and values, and the descriptions are the JSDoc lines verbatim. The old table was documenting actions that do not exist (`badge_earned`, `streak_milestone`, `referral`, `model_diversity`, `compression_use`, `skill_use`) and missing the three that do (`model_switch`, `invite_redeem`, `streak_bonus`). Good catch.

Two formatting fixes before merge: the action names were padded inside the code spans (`` `request          ` ``), which renders the trailing spaces as part of the identifier; and the unrelated MCP-tools table below had its header row flattened, losing the column alignment. Restored both and ran Prettier — the file is clean now.

Thank you for reconciling this against the source instead of guessing.
2026-09-05 02:34:17 -03:00
Koosha Paridehpour
f40c77e837 fix(docker): document and harden cli profile trust boundary (#12570) (#12706)
Merged, with the threat model kept and two unverifiable claims dropped.

The core warning is correct and worth having in both files: `/var/run/docker.sock` is a host-root trust boundary, the `cli` profile must not be published beyond `127.0.0.1`, and no extra host mounts belong in it. That is now in `docker-compose.yml` next to the mount and in the DOCKER_GUIDE.

Two things I changed before merging, both `AGENTS.md` documentation-accuracy calls:

1. **The stated purpose.** The socket is not mounted so OmniRoute can "launch short-lived codex/claude-code/droid/openclaw containers" — I could not find any container-spawn path. It is there for the in-container auto-updater: `src/lib/system/autoUpdate.ts:236` probes for `/var/run/docker.sock` and skips the Docker path when it is absent, and the mount sits right beside `AUTO_UPDATE_HOST_REPO_DIR`. Rewrote the sentence around that and cited the file.
2. **Item 3, the audit log.** "recorded in the server log with the called tool, the prompt digest (not content), and the spawned image SHA" — no such logging exists (`grep -rn "prompt digest\|promptDigest\|imageSha" src/ open-sse/` is empty). A security doc promising forensics that are not implemented is worse than one that stays quiet, so I removed the item rather than soften it.

The `MITM-TPROXY-DECRYPT.md` and `SUPPLY_CHAIN.md` cross-references both resolve and stayed.

Thanks — the docker.sock boundary genuinely was undocumented.
2026-09-05 02:33:59 -03:00
Koosha Paridehpour
c5d47dad8a docs(security): document socket.yml scanner config + CI workflow link (#12575) (#12764)
Merged, with one sentence removed.

The `socket.yml` half checks out: the file exists at the repo root, is `version: 2`, and its `projectIgnorePaths` really do list `tests/`, `_tasks/`, `_references/`, `_ideia/`, `_mono_repo/`, `docs/` — so the paragraph describes the config accurately.

The closing sentence did not: there is no `.github/workflows/socket-dev.yml` in this repo (`ls .github/workflows | grep -i socket` is empty), and nothing auto-opens `supply-chain-review/` issues. Per the documentation-accuracy rule in `AGENTS.md` — every path and workflow named in docs has to survive an `rg`/`ls` — I replaced it with what is actually true: the scan is driven by the Socket GitHub App reading `socket.yml`, not by a workflow here.

Everything else merged as written. Thanks — pointing readers of SECURITY.md at the scanner config was a real gap.
2026-09-05 02:33:56 -03:00
Koosha Paridehpour
366099a08c fix(i18n): quote <name> placeholder in OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES description (#12505) (#12769)
Merged, with the fix moved to where the bug actually lives — and thank you, because the issue analysis in #12505 is what made that possible.

The diagnosis was right: `FeatureFlagsGrid.tsx:422-428` renders descriptions through a plain `t()`, so next-intl compiles the value as ICU and a bare `<name>` parses as an unknown rich-text tag. But the branch changed `src/shared/constants/featureFlagDefinitions.ts` — the TS default, which is the `flag.description` **fallback** rendered raw, never through ICU. Two consequences: the reported bug stayed live (all 42 locale files still carried the raw tag — `grep -l "profiles/<name>/settings.json" src/i18n/messages/*.json` returned 42, and 0 for the escaped form), and the quotes would have shown up literally in the one place that string does render.

So this merge reverts the TS default to the raw path and applies the ICU escape to the 42 locale files instead — follow-up 1 from your issue, inverted to hit the file that matters.

I also added follow-up 2 as a real guard: `tests/unit/feature-flag-description-icu-parse-12505.test.ts` compiles every `featureFlags.definitions.*` message in every locale through `intl-messageformat` (the parser next-intl uses) and asserts the placeholder renders as a literal `<name>`. Verified red-then-green — reverting `en.json` alone fails both cases; restored, 2/2 pass.

Validated on `release/v3.8.51`: all locale files re-parse as valid JSON, `typecheck:core` clean, `check-file-size` OK. `i18n:check` drift is pre-existing on the tip, unrelated.

Closes #12505.
2026-09-05 02:33:37 -03:00
Koosha Paridehpour
82f78b3b3b fix(api/pricing): surface validation error message as string, not raw object (#12494) (#12771)
Merged, with one adjustment.

Confirmed the bug end to end: `PricingTab.tsx:369` types the payload as `{ error?: string }` and feeds it to `new Error(errorPayload.error || ...)`, so the `{ message, details }` object landed in the toast as `[object Object]` — exactly what #12494 reported.

The one change I made before merging: `validation.error.message` is the fixed constant `"Invalid request"` (see `validateBody` in `src/shared/validation/helpers.ts:44`), so it would have swapped an unreadable toast for an uninformative one. The repo already has `formatValidationMessage()`, added in #10849 for precisely this case — it returns `"field: reason"` naming the first offending field. Merged with that instead, so a bad pricing value now says which field it was.

Validated on `release/v3.8.51`: `typecheck:core` clean, `check-file-size` OK. Rebased onto the release branch — the PR was cut from `main`, which is ~3695 commits behind the active branch.

Thank you for the report and the fix.
2026-09-05 02:33:33 -03:00
Koosha Paridehpour
891cb26b2c fix(db): back-fill last_ping_at + last_pinged_reset_key on provider_connections (#12470)
Merged. Clean, surgical fix with its own regression guard.

`ensureProviderConnectionsColumns()` reconciles the base columns that later data migrations assume, but `last_ping_at` / `last_pinged_reset_key` were only ever created by `123_quota_auto_ping` — so a lineage that skipped it kept a table that the quota auto-ping writes cannot target. Adding them to the reconciliation list is exactly the right place.

Validated on `release/v3.8.51`: `tests/unit/db-schema-columns-split.test.ts` 10/10, including your new `back-fills last_ping columns on a pre-123 lineage` case and the idempotency re-run. `typecheck:core` clean, `check-file-size` OK. The `changelog.d/fixes/` fragment was already correct.

Thank you — this is the shape a fix should have: root cause named, minimal diff, test that fails without it.
2026-09-05 02:33:16 -03:00
Diego Rodrigues de Sa e Souza
c3945a724c fix(ci): security-tier gate must honor ALWAYS_PROTECTED_API_PATTERNS too (+ file-size rebaseline) (#12605)
* fix(ci): mirror isLocalOnlyPath in the security-tier gate and rebaseline four merged-growth file caps

Two base-reds on release/v3.8.51 (#12581), both drained at the source.

1) check:openapi-security-tiers reported six CORRECTLY annotated routes as
   unprotected and demanded the removal of their x-loopback-only annotation —
   pushing the fix in the unsafe direction. The gate re-reads routeGuard.ts as
   text (it cannot import the module: routeGuard pulls the server runtime and
   the gate runs on plain node), but it only read the FIRST half of
   isLocalOnlyPath():

     LOCAL_ONLY_API_PREFIXES.some(...) || LOCAL_ONLY_API_PATTERNS.some(...)

   so every route gated by a regex (/api/providers/volcengine-plan/connect/*)
   or by an imported constant (VNC_ROUTE_PREFIX, which the text parse turned
   into the literal string "VNC_ROUTE_PREFIX") looked open. Proven with
   isLocalOnlyPath() at runtime: all six return true; the control
   /api/providers/{id}/refresh stays false.

   New scripts/check/routeGuardConstants.mjs reads BOTH arrays, resolves
   imported identifiers by following the import, and THROWS on an unresolvable
   token instead of silently degrading it into a literal. Its array scanner is
   hand-rolled because regex literals carry the brackets and commas a
   \[([^\]]+)\] capture plus a naive comma split break on ([^/] and {1,3}).
   The reverse pass (missing-annotation warnings) now uses the same predicate.

2) check:file-size: four frozen files grew past their cap through merged PRs —
   chat.ts +10 (#12427/#12503 video-transcript redaction, derived from the
   post-guardrail payload at the single dispatch point) and stream.ts /
   accountFallback.ts / codex.ts +17 total (#12179 hot-path regex hoisting,
   bounded caches, quadratic-buffering fix). All cohesive at existing
   chokepoints; rebaselined with the rationale recorded in the baseline file.

Refs #12581

* fix(ci): security-tier gate must honor ALWAYS_PROTECTED_API_PATTERNS too

#12350 fixed the LOCAL_ONLY half of the checker (prefixes + patterns +
imported consts). isAlwaysProtectedPath() is two-armed the same way:

  ALWAYS_PROTECTED_API_PATHS.some(...) || ALWAYS_PROTECTED_API_PATTERNS.some(...)

but the checker still read only the path array, so the four credential
routes gated by the GHSA-5926-2w35-7h4q pattern (#12600) —
/api/providers/{id}/{claude,codex}-auth/{export,apply-local} — reported as
'has x-always-protected but is NOT in ALWAYS_PROTECTED_API_PATHS', asking for
the removal of a CORRECT annotation on a credential-export route.

Verified with the real predicate: all four isAlwaysProtectedPath() → true;
control /api/providers/{id}/models → false.

tests/unit/openapi-security-tiers.test.ts already checks BOTH arrays (#12600
updated the test but not the gate script) and stays green — this commit makes
the gate agree with the test and with the runtime.

Also carries the file-size rebaseline for four caps grown by merged PRs
(chat.ts +10 from #12427/#12503; stream.ts / accountFallback.ts / codex.ts
+17 from #12179), rationale recorded in the baseline file.

Refs #12581

* fix(ci): re-anchor the zcodeProtocol public-creds allowlist entry (302 -> 313)

The check:public-creds allowlist pins each frozen literal by FILE:LINE, so
#12179 (hot-path regex hoisting in the same file) shifted the ZCode handshake
id from L302 to L313 and broke the gate twice over: the old entry went stale
('a violação foi corrigida; REMOVA a entrada') while the literal itself, now
at L313, was no longer covered.

The literal is unchanged and still not a credential: `omniroute-${process.pid}`
is a per-process handshake id for the local ZCode app-server, already audited
and frozen with that justification. Only the anchor moves.

Refs #12581

* test(ci): re-anchor the ZCode allowlist test to L313 alongside the gate entry

The allowlist key is file:LINE:value, so the synthetic source in this test
pads to the exact line the entry pins. Re-anchoring the entry 302 -> 313
(previous commit) without moving the padding left the test asserting the old
line — caught by Unit Tests fast-path (4/4) on #12605.

Both halves now sit at 313, and the test still proves the allowlist does NOT
weaken detection: swapping the value for 'upstream-client-' is still flagged.

Refs #12581

* docs(ci): changelog fragment for #12605

* chore(ci): trim #12605 to the one fix the base still needs

The base drained fast while this PR was open. Re-verified on 008da6d19a and
dropped everything already covered there:

- check-public-creds.mjs: the base already re-anchors the ZCode entry to L313
  (my commit only added a comment on top) -> reverted to the base version.
- file-size-baseline.json: the base rebaselined chat.ts/codex.ts/
  accountFallback.ts to HIGHER caps than mine, and stream.ts measures 3064
  against the base cap of 3072 — my 3078 bump would have loosened a cap for
  no reason -> reverted to the base version.

What the base still does NOT have, verified on its current tip:
  node scripts/check/check-openapi-security-tiers.mjs -> EXIT=1, 4 mismatches
so the ALWAYS_PROTECTED_API_PATTERNS half stays, plus its changelog entry.

Refs #12581
2026-09-04 04:07:43 -03:00
Markus Hartung
008da6d19a feat(dashboard): link a log entry's Conversation Context to its owning conversation (#12646)
Validado sobre o tip de `release/v3.8.51`, com duas coisas resolvidas antes do merge.

**A falha de CI era stale.** O job `No new ESLint warnings` deste PR apontava `react-hooks/set-state-in-effect` em `src/app/(dashboard)/dashboard/combos/page.tsx:774` — arquivo que este PR não toca, e o mesmo erro aparecia em #12668 e #12672, que também não o tocam. A linha do tempo: o #12355 introduziu a violação de manhã, os CIs rodaram nessa janela, e o #12607 acrescentou a entrada de supressão à tarde. Medido no tip atual com o comando exato do job: **0 ocorrências não suprimidas**. A supressão sobrevivente é "unpruned", e o script passa `--pass-on-unpruned-suppressions` justamente para isso não bloquear.

**Faltava o teste que a regra do projeto exige** para mudanças em `src/`. Acrescentei `tests/unit/ui/log-detail-conversation-link-12646.test.tsx`, verificado **RED-then-GREEN** em vez de escrito contra o código pronto: revertendo `RequestLoggerDetail.sections.tsx` para o tip, 2 dos 3 casos falham; com a mudança deste PR, 3/3 passam.

Detalhe que valeu a pena descobrir: a seção curto-circuita em `allTurns.length === 0`, então o fixture precisa de um `requestBody` que normalize em pelo menos um turno — sem isso o cabeçalho inteiro nunca monta e as asserções passariam pelo motivo errado. O teste fixa três coisas: o href para um `sessionTag` simples, o percent-encoding para um que não é URL-safe, e a ausência de link quando não há `sessionTag`.

Obrigado, @hartmark.
2026-09-04 03:39:09 -03:00
Diego Rodrigues de Sa e Souza
488f57e9d3 feat(catalog): eligibility-gated free-tier bucket (#12669)
* test(catalog): pin the 2026-09-02 free-tier re-audit facts for gemini, ollama-cloud, groq, nara and mistral

* fix(catalog): re-audit gemini, ollama-cloud, groq, nara and mistral against official pages

* fix(catalog): restore the console-verified Mistral 1B pool and harden its regression test

* docs(free-tiers): move headline to the re-audited ~1.50B and refresh pool counts

* chore(free-tiers): retire stale Groq free-tier text and preset model; fix catalog header

* feat(catalog): eligibilityGate field and gatedRecurringTokens total

* docs(free-tiers): state the evidence-comment rule honestly and retire the last "14.4K RPD" Groq texts

* feat(check): docs-counts gate validates the eligibility-gated free-tier figure

* fix(docs): budget card reads computeFreeModelTotals() instead of regex-parsing the catalog

* docs(free-tiers): retire the stale Gemini onboarding quota text

* feat(radar): carry eligibilityGate through the feed schema, the merge and the summary API

* feat(dashboard): show the eligibility-gated free-tier figure apart from the headline

* docs(free-tier): refresh catalog-entry counts to 442 after base sync

* feat(catalog): ModelScope as the first eligibility-gated pool; document the gated bucket and how we count

* docs(free-tiers): restore README spacing lost in the merge and re-sync the guide counts

* docs(free-tiers): correct the unsummed-catalog comparison figure to the current catalog

* docs(free-tiers): re-sync numbers after merging release/v3.8.51 (Cerebras reclassified upstream)

* docs(free-tiers): re-sync numbers after merging PR1 (Cerebras reclassified upstream)

* fix(docs): keep the NaraRouter plans endpoint out of the API-path checker; rebaseline gateways.ts (+3)

* fix(catalog): keep eligibility-gated rows out of every headline-adjacent figure

The eligibility gate was honored by the steady headline and the pool count, but
three adjacent figures still counted gated rows: the credit reductions feeding
steadyWithRecurringCreditsTokens/firstMonthRealisticTokens, the uncappedProviders
list ("permanently free, no cap"), and the docs gate's free-forever provider set,
which was built from freeType alone.

- computeFreeModelTotals: filter !isGated in the recurring-credit, one-time-credit
  and uncapped predicates; gatedProviders semantics unchanged (steady rows only).
- check-docs-counts-sync: exclude eligibility-gated rows from the FOREVER set,
  which moves the live free-forever count 53 -> 52 (the base's value). README,
  promise-pillars.svg and FREE-TIERS-GUIDE re-synced.
- gen-budget-card-svg: skip gated one-time credits like the totals do, and fail
  loudly on `--out` without a path.
- Tests: gated one-time credit does not move firstMonthRealisticTokens; a gated
  uncapped row is not in uncappedProviders; shipped gated rows carry no credit
  tokens; the committed budget card is byte-identical to a fresh generation.

* test(catalog): allow eligibilityGate in the no-per-row-rating key allowlist

The allowlist landed on the base with #12318, after this branch's field was designed;
eligibilityGate says who may claim a quota, not how much a row can be trusted.

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouzapw24@gmail.com>
2026-09-04 00:45:38 -03:00
Diego Rodrigues de Sa e Souza
c41ec7f862 chore(quality): rebaseline chat.ts for #12641's effective-input persistence (#12680)
Rebaseline medido no tip com o #12641 mergeado.
2026-09-04 00:04:36 -03:00
Markus Hartung
6ff7b26277 fix(dashboard): keep a request's pending-tracking id stable across combo target retries (#12650)
Validado em lote numa worktree combinada com os 3 PRs desta leva sobre o tip de `release/v3.8.51`: os três boardaram sem conflito, `typecheck:core` limpo e **22/22** nos arquivos de teste que trazem.

O crescimento de `src/sse/handlers/chat.ts` (2450 → 2454) é do #12641 e vai num PR de rebaseline próprio.

Obrigado, @hartmark.
2026-09-04 00:03:34 -03:00
Markus Hartung
74c2d26393 fix(responses-continuation): chain off the effective post-reconstruction input, not the pre-reconstruction client bytes (#12641)
Validado em lote numa worktree combinada com os 3 PRs desta leva sobre o tip de `release/v3.8.51`: os três boardaram sem conflito, `typecheck:core` limpo e **22/22** nos arquivos de teste que trazem.

O crescimento de `src/sse/handlers/chat.ts` (2450 → 2454) é do #12641 e vai num PR de rebaseline próprio.

Obrigado, @hartmark.
2026-09-04 00:03:16 -03:00
Diego Rodrigues de Sa e Souza
f8a0f9c1f8 chore(quality): rebaseline combo.ts for the stacked reset-aware scoring (#12678)
Rebaseline medido no tip com os 4 PRs da leva mergeados.
2026-09-03 23:48:48 -03:00
Bob.Hou
2a6eff0aec fix(combo): keep Antigravity Gemini usable when Claude weekly is empty (#12637)
Validado em lote numa worktree combinada com os 4 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo e **119/119** nos 9 arquivos de teste que trazem.

Três dos quatro conflitavam apenas no `config/quality/file-size-baseline.json`, todos de forma aditiva (chaves `_rebaseline_` distintas que devem coexistir); resolvidos com validação de JSON a cada passo.

Registro que o **#12637 não é duplicata do #12566**, apesar do título quase idêntico: o autor documenta que aquele escopou o cooldown de preflight por família e este cobre o `genericQuotaFetcher`, que é o que o roteamento reset-aware efetivamente chama. Traz também validação ao vivo em VPS (imagem X500, `onmi-gemini3.6` → HTTP 200), satisfazendo a Hard Rule #18.

Obrigado, @HouMinXi.
2026-09-03 23:47:28 -03:00
Bob.Hou
d36d077a4d fix(resilience): keep Overloaded STREAM_EARLY_EOF off the provider breaker (#12626)
Validado em lote numa worktree combinada com os 4 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo e **119/119** nos 9 arquivos de teste que trazem.

Três dos quatro conflitavam apenas no `config/quality/file-size-baseline.json`, todos de forma aditiva (chaves `_rebaseline_` distintas que devem coexistir); resolvidos com validação de JSON a cada passo.

Registro que o **#12637 não é duplicata do #12566**, apesar do título quase idêntico: o autor documenta que aquele escopou o cooldown de preflight por família e este cobre o `genericQuotaFetcher`, que é o que o roteamento reset-aware efetivamente chama. Traz também validação ao vivo em VPS (imagem X500, `onmi-gemini3.6` → HTTP 200), satisfazendo a Hard Rule #18.

Obrigado, @HouMinXi.
2026-09-03 23:45:47 -03:00
Bob.Hou
36be267a17 fix(providers): add CLAUDE_CODE_CLIENT_VERSION and GITHUB_COPILOT_CLI_VERSION env overrides (#12632)
Validado em lote numa worktree combinada com os 4 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo e **119/119** nos 9 arquivos de teste que trazem.

Três dos quatro conflitavam apenas no `config/quality/file-size-baseline.json`, todos de forma aditiva (chaves `_rebaseline_` distintas que devem coexistir); resolvidos com validação de JSON a cada passo.

Registro que o **#12637 não é duplicata do #12566**, apesar do título quase idêntico: o autor documenta que aquele escopou o cooldown de preflight por família e este cobre o `genericQuotaFetcher`, que é o que o roteamento reset-aware efetivamente chama. Traz também validação ao vivo em VPS (imagem X500, `onmi-gemini3.6` → HTTP 200), satisfazendo a Hard Rule #18.

Obrigado, @HouMinXi.
2026-09-03 23:43:38 -03:00
Bob.Hou
57d7c8bc88 fix(providers): sanitize boolean required and nested bare maps for Gemini (#12269) (#12624)
Validado em lote numa worktree combinada com os 4 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo e **119/119** nos 9 arquivos de teste que trazem.

Três dos quatro conflitavam apenas no `config/quality/file-size-baseline.json`, todos de forma aditiva (chaves `_rebaseline_` distintas que devem coexistir); resolvidos com validação de JSON a cada passo.

Registro que o **#12637 não é duplicata do #12566**, apesar do título quase idêntico: o autor documenta que aquele escopou o cooldown de preflight por família e este cobre o `genericQuotaFetcher`, que é o que o roteamento reset-aware efetivamente chama. Traz também validação ao vivo em VPS (imagem X500, `onmi-gemini3.6` → HTTP 200), satisfazendo a Hard Rule #18.

Obrigado, @HouMinXi.
2026-09-03 23:42:40 -03:00
Ravi Tharuma
3b7c541f72 feat(opencode-plugin): map gateway cost/usage/tok/s onto OpenCode payloads (#12636)
Validado em lote numa worktree combinada com os 10 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check-file-size` OK e **241/242** nos 29 arquivos de teste que os PRs tocam.

A única "falha" não é falha: `tests/unit/autoCombo/strict-zero-cost-filter.test.ts` é um teste em estilo Vitest que eu incluí por engano na invocação do runner nativo do Node — ele quebra no import (`@vitest/runner`), não numa asserção. Ao investigar, descobri que esse arquivo não roda em nenhum dos dois runners hoje (o glob do `test:unit` não lista `autoCombo` e o `include` do Vitest só pega `.tsx` nessa pasta); é um problema pré-existente do repositório, sem relação com esta leva, e vou registrá-lo separadamente.

O #12636 conflitava apenas na lista de testes do `@omniroute/opencode-plugin/package.json`, de forma aditiva: o tip já tinha `models-fetcher.test.ts` (do #12607, irmão desta mesma leva) e o #12636 acrescenta `telemetry.test.ts`. Fiz a união dos dois lados (25 arquivos contra 24 de cada) em vez de escolher um, o que teria removido um arquivo da suíte do plugin em silêncio.

Obrigado, @RaviTharuma.
2026-09-03 23:42:09 -03:00
Ravi Tharuma
5f9c358e9b fix(monitoring): serve cached credentialHealth off the request path (#12533)
Validado em lote numa worktree combinada com os 10 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check-file-size` OK e **241/242** nos 29 arquivos de teste que os PRs tocam.

A única "falha" não é falha: `tests/unit/autoCombo/strict-zero-cost-filter.test.ts` é um teste em estilo Vitest que eu incluí por engano na invocação do runner nativo do Node — ele quebra no import (`@vitest/runner`), não numa asserção. Ao investigar, descobri que esse arquivo não roda em nenhum dos dois runners hoje (o glob do `test:unit` não lista `autoCombo` e o `include` do Vitest só pega `.tsx` nessa pasta); é um problema pré-existente do repositório, sem relação com esta leva, e vou registrá-lo separadamente.

O #12636 conflitava apenas na lista de testes do `@omniroute/opencode-plugin/package.json`, de forma aditiva: o tip já tinha `models-fetcher.test.ts` (do #12607, irmão desta mesma leva) e o #12636 acrescenta `telemetry.test.ts`. Fiz a união dos dois lados (25 arquivos contra 24 de cada) em vez de escolher um, o que teria removido um arquivo da suíte do plugin em silêncio.

Obrigado, @RaviTharuma.
2026-09-03 23:40:22 -03:00
Ravi Tharuma
ac94dd9bcf docs(arch): one-process recipe for tens of long /v1/responses (#12493)
Validado em lote numa worktree combinada com os 10 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check-file-size` OK e **241/242** nos 29 arquivos de teste que os PRs tocam.

A única "falha" não é falha: `tests/unit/autoCombo/strict-zero-cost-filter.test.ts` é um teste em estilo Vitest que eu incluí por engano na invocação do runner nativo do Node — ele quebra no import (`@vitest/runner`), não numa asserção. Ao investigar, descobri que esse arquivo não roda em nenhum dos dois runners hoje (o glob do `test:unit` não lista `autoCombo` e o `include` do Vitest só pega `.tsx` nessa pasta); é um problema pré-existente do repositório, sem relação com esta leva, e vou registrá-lo separadamente.

O #12636 conflitava apenas na lista de testes do `@omniroute/opencode-plugin/package.json`, de forma aditiva: o tip já tinha `models-fetcher.test.ts` (do #12607, irmão desta mesma leva) e o #12636 acrescenta `telemetry.test.ts`. Fiz a união dos dois lados (25 arquivos contra 24 de cada) em vez de escolher um, o que teria removido um arquivo da suíte do plugin em silêncio.

Obrigado, @RaviTharuma.
2026-09-03 23:40:03 -03:00
Ravi Tharuma
6aa3690dea feat(providers): filter GitHub combo members against live catalog (#12473)
Validado em lote numa worktree combinada com os 10 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check-file-size` OK e **241/242** nos 29 arquivos de teste que os PRs tocam.

A única "falha" não é falha: `tests/unit/autoCombo/strict-zero-cost-filter.test.ts` é um teste em estilo Vitest que eu incluí por engano na invocação do runner nativo do Node — ele quebra no import (`@vitest/runner`), não numa asserção. Ao investigar, descobri que esse arquivo não roda em nenhum dos dois runners hoje (o glob do `test:unit` não lista `autoCombo` e o `include` do Vitest só pega `.tsx` nessa pasta); é um problema pré-existente do repositório, sem relação com esta leva, e vou registrá-lo separadamente.

O #12636 conflitava apenas na lista de testes do `@omniroute/opencode-plugin/package.json`, de forma aditiva: o tip já tinha `models-fetcher.test.ts` (do #12607, irmão desta mesma leva) e o #12636 acrescenta `telemetry.test.ts`. Fiz a união dos dois lados (25 arquivos contra 24 de cada) em vez de escolher um, o que teria removido um arquivo da suíte do plugin em silêncio.

Obrigado, @RaviTharuma.
2026-09-03 23:39:43 -03:00
Ravi Tharuma
85b8d128eb fix(auth): do not park healthy quota accounts as expired (#12452)
Validado em lote numa worktree combinada com os 10 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check-file-size` OK e **241/242** nos 29 arquivos de teste que os PRs tocam.

A única "falha" não é falha: `tests/unit/autoCombo/strict-zero-cost-filter.test.ts` é um teste em estilo Vitest que eu incluí por engano na invocação do runner nativo do Node — ele quebra no import (`@vitest/runner`), não numa asserção. Ao investigar, descobri que esse arquivo não roda em nenhum dos dois runners hoje (o glob do `test:unit` não lista `autoCombo` e o `include` do Vitest só pega `.tsx` nessa pasta); é um problema pré-existente do repositório, sem relação com esta leva, e vou registrá-lo separadamente.

O #12636 conflitava apenas na lista de testes do `@omniroute/opencode-plugin/package.json`, de forma aditiva: o tip já tinha `models-fetcher.test.ts` (do #12607, irmão desta mesma leva) e o #12636 acrescenta `telemetry.test.ts`. Fiz a união dos dois lados (25 arquivos contra 24 de cada) em vez de escolher um, o que teria removido um arquivo da suíte do plugin em silêncio.

Obrigado, @RaviTharuma.
2026-09-03 23:39:22 -03:00
Ravi Tharuma
8c1dfc416d fix(combo): do not treat credits-exhausted 401 as auth skip (#12449)
Validado em lote numa worktree combinada com os 10 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check-file-size` OK e **241/242** nos 29 arquivos de teste que os PRs tocam.

A única "falha" não é falha: `tests/unit/autoCombo/strict-zero-cost-filter.test.ts` é um teste em estilo Vitest que eu incluí por engano na invocação do runner nativo do Node — ele quebra no import (`@vitest/runner`), não numa asserção. Ao investigar, descobri que esse arquivo não roda em nenhum dos dois runners hoje (o glob do `test:unit` não lista `autoCombo` e o `include` do Vitest só pega `.tsx` nessa pasta); é um problema pré-existente do repositório, sem relação com esta leva, e vou registrá-lo separadamente.

O #12636 conflitava apenas na lista de testes do `@omniroute/opencode-plugin/package.json`, de forma aditiva: o tip já tinha `models-fetcher.test.ts` (do #12607, irmão desta mesma leva) e o #12636 acrescenta `telemetry.test.ts`. Fiz a união dos dois lados (25 arquivos contra 24 de cada) em vez de escolher um, o que teria removido um arquivo da suíte do plugin em silêncio.

Obrigado, @RaviTharuma.
2026-09-03 23:38:57 -03:00
Ravi Tharuma
3d2bcc9f12 feat(api): emit gateway-measured tokens-per-second excluding TTFT (#12631)
Validado em lote numa worktree combinada com os 10 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check-file-size` OK e **241/242** nos 29 arquivos de teste que os PRs tocam.

A única "falha" não é falha: `tests/unit/autoCombo/strict-zero-cost-filter.test.ts` é um teste em estilo Vitest que eu incluí por engano na invocação do runner nativo do Node — ele quebra no import (`@vitest/runner`), não numa asserção. Ao investigar, descobri que esse arquivo não roda em nenhum dos dois runners hoje (o glob do `test:unit` não lista `autoCombo` e o `include` do Vitest só pega `.tsx` nessa pasta); é um problema pré-existente do repositório, sem relação com esta leva, e vou registrá-lo separadamente.

O #12636 conflitava apenas na lista de testes do `@omniroute/opencode-plugin/package.json`, de forma aditiva: o tip já tinha `models-fetcher.test.ts` (do #12607, irmão desta mesma leva) e o #12636 acrescenta `telemetry.test.ts`. Fiz a união dos dois lados (25 arquivos contra 24 de cada) em vez de escolher um, o que teria removido um arquivo da suíte do plugin em silêncio.

Obrigado, @RaviTharuma.
2026-09-03 23:38:37 -03:00
Ravi Tharuma
8c8d23a98f fix(api): bound hung GET /v1/models catalog rebuilds (#12628)
Validado em lote numa worktree combinada com os 10 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check-file-size` OK e **241/242** nos 29 arquivos de teste que os PRs tocam.

A única "falha" não é falha: `tests/unit/autoCombo/strict-zero-cost-filter.test.ts` é um teste em estilo Vitest que eu incluí por engano na invocação do runner nativo do Node — ele quebra no import (`@vitest/runner`), não numa asserção. Ao investigar, descobri que esse arquivo não roda em nenhum dos dois runners hoje (o glob do `test:unit` não lista `autoCombo` e o `include` do Vitest só pega `.tsx` nessa pasta); é um problema pré-existente do repositório, sem relação com esta leva, e vou registrá-lo separadamente.

O #12636 conflitava apenas na lista de testes do `@omniroute/opencode-plugin/package.json`, de forma aditiva: o tip já tinha `models-fetcher.test.ts` (do #12607, irmão desta mesma leva) e o #12636 acrescenta `telemetry.test.ts`. Fiz a união dos dois lados (25 arquivos contra 24 de cada) em vez de escolher um, o que teria removido um arquivo da suíte do plugin em silêncio.

Obrigado, @RaviTharuma.
2026-09-03 23:38:20 -03:00
Ravi Tharuma
b0557543b8 fix(opencode-plugin): lengthen /v1/models timeout and attach HTTP status (#12607)
Validado em lote numa worktree combinada com os 10 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check-file-size` OK e **241/242** nos 29 arquivos de teste que os PRs tocam.

A única "falha" não é falha: `tests/unit/autoCombo/strict-zero-cost-filter.test.ts` é um teste em estilo Vitest que eu incluí por engano na invocação do runner nativo do Node — ele quebra no import (`@vitest/runner`), não numa asserção. Ao investigar, descobri que esse arquivo não roda em nenhum dos dois runners hoje (o glob do `test:unit` não lista `autoCombo` e o `include` do Vitest só pega `.tsx` nessa pasta); é um problema pré-existente do repositório, sem relação com esta leva, e vou registrá-lo separadamente.

O #12636 conflitava apenas na lista de testes do `@omniroute/opencode-plugin/package.json`, de forma aditiva: o tip já tinha `models-fetcher.test.ts` (do #12607, irmão desta mesma leva) e o #12636 acrescenta `telemetry.test.ts`. Fiz a união dos dois lados (25 arquivos contra 24 de cada) em vez de escolher um, o que teria removido um arquivo da suíte do plugin em silêncio.

Obrigado, @RaviTharuma.
2026-09-03 23:38:04 -03:00
Ravi Tharuma
bb8e75a00d fix(resilience): surface Responses failed.error.message in 502s (#12472)
Validado em lote numa worktree combinada com os 10 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check-file-size` OK e **241/242** nos 29 arquivos de teste que os PRs tocam.

A única "falha" não é falha: `tests/unit/autoCombo/strict-zero-cost-filter.test.ts` é um teste em estilo Vitest que eu incluí por engano na invocação do runner nativo do Node — ele quebra no import (`@vitest/runner`), não numa asserção. Ao investigar, descobri que esse arquivo não roda em nenhum dos dois runners hoje (o glob do `test:unit` não lista `autoCombo` e o `include` do Vitest só pega `.tsx` nessa pasta); é um problema pré-existente do repositório, sem relação com esta leva, e vou registrá-lo separadamente.

O #12636 conflitava apenas na lista de testes do `@omniroute/opencode-plugin/package.json`, de forma aditiva: o tip já tinha `models-fetcher.test.ts` (do #12607, irmão desta mesma leva) e o #12636 acrescenta `telemetry.test.ts`. Fiz a união dos dois lados (25 arquivos contra 24 de cada) em vez de escolher um, o que teria removido um arquivo da suíte do plugin em silêncio.

Obrigado, @RaviTharuma.
2026-09-03 23:37:47 -03:00
Diego Rodrigues de Sa e Souza
04ba19fa62 chore(quality): rebaseline apiKeys.ts for #12352's preserved ACL (#12673)
Rebaseline medido no tip com o #12352 mergeado. Única violação de file-size do tip e inteiramente crescimento próprio daquele PR.
2026-09-03 23:29:56 -03:00
Goni Sulaiman
57d9357d88 fix(i18n): wrap ccOnboardingKeyPlaceholder in ICU single quotes across all 43 locales (#12369)
Validado em lote numa worktree combinada com os 6 PRs destas duas levas sobre o tip de `release/v3.8.51`: os seis boardaram **sem um único conflito**, `typecheck:core` limpo e **54/54** nos 7 arquivos de teste que trazem.

O drift de `i18n:check` (`docs/security/GUARDRAILS.md`, `STEALTH_GUIDE.md` — source-changed) foi medido também no tip puro e é idêntico: base-red pré-existente, não desta leva.
2026-09-03 23:28:33 -03:00
Goni Sulaiman
6e35ad01cc fix(cli): remove duplicate positional argument in tunnel create command (#12368)
Validado em lote numa worktree combinada com os 6 PRs destas duas levas sobre o tip de `release/v3.8.51`: os seis boardaram **sem um único conflito**, `typecheck:core` limpo e **54/54** nos 7 arquivos de teste que trazem.

O drift de `i18n:check` (`docs/security/GUARDRAILS.md`, `STEALTH_GUIDE.md` — source-changed) foi medido também no tip puro e é idêntico: base-red pré-existente, não desta leva.
2026-09-03 23:28:12 -03:00
Goni Sulaiman
9cbc4f118e fix(models): publish effort_tiers on Kimi K3 base models only (#12299) (#12371)
Validado em lote numa worktree combinada com os 6 PRs destas duas levas sobre o tip de `release/v3.8.51`: os seis boardaram **sem um único conflito**, `typecheck:core` limpo e **54/54** nos 7 arquivos de teste que trazem.

O drift de `i18n:check` (`docs/security/GUARDRAILS.md`, `STEALTH_GUIDE.md` — source-changed) foi medido também no tip puro e é idêntico: base-red pré-existente, não desta leva.
2026-09-03 23:27:55 -03:00
Krzysztof Skomra
9271a34ec1 fix(api): preserve API key ACL on creation (#12352)
Validado em lote numa worktree combinada com os 6 PRs destas duas levas sobre o tip de `release/v3.8.51`: os seis boardaram **sem um único conflito**, `typecheck:core` limpo e **54/54** nos 7 arquivos de teste que trazem.

O drift de `i18n:check` (`docs/security/GUARDRAILS.md`, `STEALTH_GUIDE.md` — source-changed) foi medido também no tip puro e é idêntico: base-red pré-existente, não desta leva.
2026-09-03 23:27:33 -03:00
Krzysztof Skomra
11e1c79e65 fix(combos): clear LKGP pins on delete (#12425)
Validado em lote numa worktree combinada com os 6 PRs destas duas levas sobre o tip de `release/v3.8.51`: os seis boardaram **sem um único conflito**, `typecheck:core` limpo e **54/54** nos 7 arquivos de teste que trazem.

O drift de `i18n:check` (`docs/security/GUARDRAILS.md`, `STEALTH_GUIDE.md` — source-changed) foi medido também no tip puro e é idêntico: base-red pré-existente, não desta leva.
2026-09-03 23:27:15 -03:00
Krzysztof Skomra
d6771779f7 fix(cli): preserve Claude settings on config set (#12432)
Validado em lote numa worktree combinada com os 6 PRs destas duas levas sobre o tip de `release/v3.8.51`: os seis boardaram **sem um único conflito**, `typecheck:core` limpo e **54/54** nos 7 arquivos de teste que trazem.

O drift de `i18n:check` (`docs/security/GUARDRAILS.md`, `STEALTH_GUIDE.md` — source-changed) foi medido também no tip puro e é idêntico: base-red pré-existente, não desta leva.
2026-09-03 23:26:59 -03:00
Diego Rodrigues de Sa e Souza
16b0d4e3ad fix(catalog): re-audit free-tier quotas against official pages (#12649)
* test(catalog): pin the 2026-09-02 free-tier re-audit facts for gemini, ollama-cloud, groq, nara and mistral

* fix(catalog): re-audit gemini, ollama-cloud, groq, nara and mistral against official pages

* fix(catalog): restore the console-verified Mistral 1B pool and harden its regression test

* docs(free-tiers): move headline to the re-audited ~1.50B and refresh pool counts

* chore(free-tiers): retire stale Groq free-tier text and preset model; fix catalog header

* docs(free-tiers): state the evidence-comment rule honestly and retire the last "14.4K RPD" Groq texts

* docs(free-tiers): retire the stale Gemini onboarding quota text

* docs(free-tier): refresh catalog-entry counts to 442 after base sync

* docs(free-tiers): restore README spacing lost in the merge and re-sync the guide counts

* docs(free-tiers): re-sync numbers after merging release/v3.8.51 (Cerebras reclassified upstream)

* fix(docs): keep the NaraRouter plans endpoint out of the API-path checker; rebaseline gateways.ts (+3)

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
2026-09-03 23:26:32 -03:00
Diego Rodrigues de Sa e Souza
2265ce761f fix(security): harden public error boundaries (#12506)
Validado sobre o tip de `release/v3.8.51` depois de reconciliar com o #12620, que entrou primeiro nesta mesma sessão e ataca a mesma classe de problema por outra arquitetura.

**A colisão e como foi resolvida.** O #12620 consertou o GHSA-qv45-56jc-4wmj adicionando `RAW_CREDENTIAL_PATTERNS` a `error.ts` e importando-os em `upstreamErrorPassthrough.ts`. Este PR resolve o mesmo problema quebrando `error.ts` em `errorSanitization.ts` + `errorPathRedaction.ts`. Mantive a divisão em módulos deste PR, porque ao comparar os dois vocabulários o dele já era mais amplo: o `STRONG_CREDENTIAL_TOKEN` daqui cobre `sk-`/`sk_` **com lookbehind e uma variante para a forma embutida** (que pega `sk-proj-…`), mais Slack `xox-`, AWS `AKIA`/`ASIA`, `github_pat_`/`ghp_`/`glpat-` e JWT de três segmentos.

A única forma que o #12620 carregava e este conjunto não tinha era a chave do Google (`AIza…`) — adicionada aqui, com o mesmo quantificador limitado que os irmãos usam (AGENTS.md → PII §1, já que isso roda sobre corpos upstream não confiáveis).

**A verificação não foi por inspeção.** Rodei as suítes do próprio #12620 contra esta estrutura: **48/48** em `error-sanitizer-sk-key-qv45`, `bifrost-relay-response-leak-9m72`, `search-baseurl-client-override-3f8g` e `search-baseurl-ssrf-guard` — incluindo a asserção anti-drift daquela suíte, que é o oráculo certo aqui: *para todo corpo que a camada de passthrough recusa como vazante, o sanitizador de fallback não pode devolvê-lo intacto*. Ela passa, então a propriedade de segurança dos três GHSAs sobrevive à troca de arquitetura.

Os 21 arquivos de teste deste PR: **259/259**. `typecheck:core` limpo.
2026-09-03 21:31:13 -03:00
Bob.Hou
109cf0f26c fix(providers): reclassify Cerebras as a one-time $5 signup credit (#12591)
Validado sobre o tip de release/v3.8.51 — e o PR ficou completo depois que o autor adicionou a tabela de preços.

O que fazia o teste falhar antes não era a lista free (que o PR já tinha corrigido em `LEGACY_FREE_PROVIDERS` e `tierDefaults.json`), e sim que `classifyTier` cai no ramo cost-based e todos os modelos Cerebras estavam declarados com `input: 0, output: 0` — $0/M ≤ threshold devolve 'free' de qualquer jeito. A tabela de preços resolve isso na raiz.

Confirmei o `gpt-oss-120b` a $0,35/$0,75 de forma independente contra a fonte pública, o que corrobora o resto da tabela. **4/4** no teste-guarda e **25/25** somando free-tier-catalog e free-models; typecheck:core limpo. Obrigado, @HouMinXi.
2026-09-03 21:30:32 -03:00
Diego Rodrigues de Sa e Souza
5ba4247670 chore(quality): rebaseline file-size caps the error-boundary campaign grew past (#12654)
Rebaseline medido no tip com os 14 PRs da campanha mergeados. A anotação registra que 4 das 6 linhas do codex.ts são drift anterior à campanha, não crescimento dela. Não toca stream.ts.
2026-09-03 21:17:17 -03:00
Diego Rodrigues de Sa e Souza
350ac8c12d fix(sse): preserve 1min.ai partial output before stream errors (#12466)
Validado após reconciliar com o #12465, que entrou primeiro e criou o mesmo arquivo novo `open-sse/utils/streamReadiness.ts` com desenho divergente de cancelamento.

Mantive a versão desta branch, que defere o release do lock para quando a leitura em voo termina e faz `reader.cancel()` fire-and-forget — assim uma promise de provider que nunca resolve não torna o cancelamento ilimitado. A escolha não foi por preferência: rodei as suítes dos **dois** PRs contra ela, 21/21 no readiness compartilhado e **22/22** incluindo o boundary do Perplexity do próprio #12465. typecheck:core limpo.
2026-09-03 21:06:28 -03:00
Diego Rodrigues de Sa e Souza
7ae8bf4e05 fix(db): harden migration recovery snapshots (#12435)
Validado em lote numa worktree combinada com os 14 PRs desta campanha de error-boundary sobre o tip de `release/v3.8.51`: `typecheck:core` limpo e **120/120** nos 23 arquivos de teste que os PRs trazem.

Um ponto que só apareceu no tree combinado: **#12465 e #12466 criam o mesmo arquivo novo** `open-sse/utils/streamReadiness.ts` (que não existe no tip) com desenhos divergentes de cancelamento — `cancelled` + `releaseLock` imediato num, `readInFlight`/`cancelRequested` com `cancelReader` fire-and-forget no outro. Adotei a versão do #12466, que difere e defere o release do lock para quando a leitura em voo termina, e validei a escolha rodando as suítes dos **dois** PRs contra ela: 21/21 no readiness compartilhado e 22/22 incluindo o boundary do Perplexity.
2026-09-03 21:01:09 -03:00
Diego Rodrigues de Sa e Souza
627fcba605 fix(sse): preserve Perplexity stream failures (#12465)
Validado em lote numa worktree combinada com os 14 PRs desta campanha de error-boundary sobre o tip de `release/v3.8.51`: `typecheck:core` limpo e **120/120** nos 23 arquivos de teste que os PRs trazem.

Um ponto que só apareceu no tree combinado: **#12465 e #12466 criam o mesmo arquivo novo** `open-sse/utils/streamReadiness.ts` (que não existe no tip) com desenhos divergentes de cancelamento — `cancelled` + `releaseLock` imediato num, `readInFlight`/`cancelRequested` com `cancelReader` fire-and-forget no outro. Adotei a versão do #12466, que difere e defere o release do lock para quando a leitura em voo termina, e validei a escolha rodando as suítes dos **dois** PRs contra ela: 21/21 no readiness compartilhado e 22/22 incluindo o boundary do Perplexity.
2026-09-03 21:00:14 -03:00
Diego Rodrigues de Sa e Souza
ca2edfdca8 fix(grok-web): stop streaming errors from reporting false success (#12458)
Validado em lote numa worktree combinada com os 14 PRs desta campanha de error-boundary sobre o tip de `release/v3.8.51`: `typecheck:core` limpo e **120/120** nos 23 arquivos de teste que os PRs trazem.

Um ponto que só apareceu no tree combinado: **#12465 e #12466 criam o mesmo arquivo novo** `open-sse/utils/streamReadiness.ts` (que não existe no tip) com desenhos divergentes de cancelamento — `cancelled` + `releaseLock` imediato num, `readInFlight`/`cancelRequested` com `cancelReader` fire-and-forget no outro. Adotei a versão do #12466, que difere e defere o release do lock para quando a leitura em voo termina, e validei a escolha rodando as suítes dos **dois** PRs contra ela: 21/21 no readiness compartilhado e 22/22 incluindo o boundary do Perplexity.
2026-09-03 20:59:56 -03:00
Diego Rodrigues de Sa e Souza
d63d25f96c fix(huggingchat): surface HTTP 200 JSONL failures (#12456)
Validado em lote numa worktree combinada com os 14 PRs desta campanha de error-boundary sobre o tip de `release/v3.8.51`: `typecheck:core` limpo e **120/120** nos 23 arquivos de teste que os PRs trazem.

Um ponto que só apareceu no tree combinado: **#12465 e #12466 criam o mesmo arquivo novo** `open-sse/utils/streamReadiness.ts` (que não existe no tip) com desenhos divergentes de cancelamento — `cancelled` + `releaseLock` imediato num, `readInFlight`/`cancelRequested` com `cancelReader` fire-and-forget no outro. Adotei a versão do #12466, que difere e defere o release do lock para quando a leitura em voo termina, e validei a escolha rodando as suítes dos **dois** PRs contra ela: 21/21 no readiness compartilhado e 22/22 incluindo o boundary do Perplexity.
2026-09-03 20:59:31 -03:00
Diego Rodrigues de Sa e Souza
2f6fdf16c7 fix(codex): close response failure boundary (#12444)
Validado em lote numa worktree combinada com os 14 PRs desta campanha de error-boundary sobre o tip de `release/v3.8.51`: `typecheck:core` limpo e **120/120** nos 23 arquivos de teste que os PRs trazem.

Um ponto que só apareceu no tree combinado: **#12465 e #12466 criam o mesmo arquivo novo** `open-sse/utils/streamReadiness.ts` (que não existe no tip) com desenhos divergentes de cancelamento — `cancelled` + `releaseLock` imediato num, `readInFlight`/`cancelRequested` com `cancelReader` fire-and-forget no outro. Adotei a versão do #12466, que difere e defere o release do lock para quando a leitura em voo termina, e validei a escolha rodando as suítes dos **dois** PRs contra ela: 21/21 no readiness compartilhado e 22/22 incluindo o boundary do Perplexity.
2026-09-03 20:59:14 -03:00
Diego Rodrigues de Sa e Souza
9469fa5f59 fix(streaming): sanitize generic stream failure boundaries (#12457)
Validado em lote numa worktree combinada com os 14 PRs desta campanha de error-boundary sobre o tip de `release/v3.8.51`: `typecheck:core` limpo e **120/120** nos 23 arquivos de teste que os PRs trazem.

Um ponto que só apareceu no tree combinado: **#12465 e #12466 criam o mesmo arquivo novo** `open-sse/utils/streamReadiness.ts` (que não existe no tip) com desenhos divergentes de cancelamento — `cancelled` + `releaseLock` imediato num, `readInFlight`/`cancelRequested` com `cancelReader` fire-and-forget no outro. Adotei a versão do #12466, que difere e defere o release do lock para quando a leitura em voo termina, e validei a escolha rodando as suítes dos **dois** PRs contra ela: 21/21 no readiness compartilhado e 22/22 incluindo o boundary do Perplexity.
2026-09-03 20:58:56 -03:00
Diego Rodrigues de Sa e Souza
9d92d71014 fix(sse): fail Zed streams without false success (#12455)
Validado em lote numa worktree combinada com os 14 PRs desta campanha de error-boundary sobre o tip de `release/v3.8.51`: `typecheck:core` limpo e **120/120** nos 23 arquivos de teste que os PRs trazem.

Um ponto que só apareceu no tree combinado: **#12465 e #12466 criam o mesmo arquivo novo** `open-sse/utils/streamReadiness.ts` (que não existe no tip) com desenhos divergentes de cancelamento — `cancelled` + `releaseLock` imediato num, `readInFlight`/`cancelRequested` com `cancelReader` fire-and-forget no outro. Adotei a versão do #12466, que difere e defere o release do lock para quando a leitura em voo termina, e validei a escolha rodando as suítes dos **dois** PRs contra ela: 21/21 no readiness compartilhado e 22/22 incluindo o boundary do Perplexity.
2026-09-03 20:58:39 -03:00
Diego Rodrigues de Sa e Souza
855eda16d3 fix(zai): treat HTTP 200 stream errors as failures (#12454)
Validado em lote numa worktree combinada com os 14 PRs desta campanha de error-boundary sobre o tip de `release/v3.8.51`: `typecheck:core` limpo e **120/120** nos 23 arquivos de teste que os PRs trazem.

Um ponto que só apareceu no tree combinado: **#12465 e #12466 criam o mesmo arquivo novo** `open-sse/utils/streamReadiness.ts` (que não existe no tip) com desenhos divergentes de cancelamento — `cancelled` + `releaseLock` imediato num, `readInFlight`/`cancelRequested` com `cancelReader` fire-and-forget no outro. Adotei a versão do #12466, que difere e defere o release do lock para quando a leitura em voo termina, e validei a escolha rodando as suítes dos **dois** PRs contra ela: 21/21 no readiness compartilhado e 22/22 incluindo o boundary do Perplexity.
2026-09-03 20:58:22 -03:00
Diego Rodrigues de Sa e Souza
406fbd3dcb fix(huggingchat): sanitize transport failures (#12467)
Validado em lote numa worktree combinada com os 14 PRs desta campanha de error-boundary sobre o tip de `release/v3.8.51`: `typecheck:core` limpo e **120/120** nos 23 arquivos de teste que os PRs trazem.

Um ponto que só apareceu no tree combinado: **#12465 e #12466 criam o mesmo arquivo novo** `open-sse/utils/streamReadiness.ts` (que não existe no tip) com desenhos divergentes de cancelamento — `cancelled` + `releaseLock` imediato num, `readInFlight`/`cancelRequested` com `cancelReader` fire-and-forget no outro. Adotei a versão do #12466, que difere e defere o release do lock para quando a leitura em voo termina, e validei a escolha rodando as suítes dos **dois** PRs contra ela: 21/21 no readiness compartilhado e 22/22 incluindo o boundary do Perplexity.
2026-09-03 20:57:58 -03:00
Diego Rodrigues de Sa e Souza
774e6db396 fix(security): redact dashboard failure events (#12469)
Validado em lote numa worktree combinada com os 14 PRs desta campanha de error-boundary sobre o tip de `release/v3.8.51`: `typecheck:core` limpo e **120/120** nos 23 arquivos de teste que os PRs trazem.

Um ponto que só apareceu no tree combinado: **#12465 e #12466 criam o mesmo arquivo novo** `open-sse/utils/streamReadiness.ts` (que não existe no tip) com desenhos divergentes de cancelamento — `cancelled` + `releaseLock` imediato num, `readInFlight`/`cancelRequested` com `cancelReader` fire-and-forget no outro. Adotei a versão do #12466, que difere e defere o release do lock para quando a leitura em voo termina, e validei a escolha rodando as suítes dos **dois** PRs contra ela: 21/21 no readiness compartilhado e 22/22 incluindo o boundary do Perplexity.
2026-09-03 20:57:39 -03:00
Diego Rodrigues de Sa e Souza
4ef4e25fa7 fix(sse): surface Adapta non-stream SSE errors (#12459)
Validado em lote numa worktree combinada com os 14 PRs desta campanha de error-boundary sobre o tip de `release/v3.8.51`: `typecheck:core` limpo e **120/120** nos 23 arquivos de teste que os PRs trazem.

Um ponto que só apareceu no tree combinado: **#12465 e #12466 criam o mesmo arquivo novo** `open-sse/utils/streamReadiness.ts` (que não existe no tip) com desenhos divergentes de cancelamento — `cancelled` + `releaseLock` imediato num, `readInFlight`/`cancelRequested` com `cancelReader` fire-and-forget no outro. Adotei a versão do #12466, que difere e defere o release do lock para quando a leitura em voo termina, e validei a escolha rodando as suítes dos **dois** PRs contra ela: 21/21 no readiness compartilhado e 22/22 incluindo o boundary do Perplexity.
2026-09-03 20:57:21 -03:00
Diego Rodrigues de Sa e Souza
a721fc7295 fix(adapta): redact streamed upstream errors (#12438)
Validado em lote numa worktree combinada com os 14 PRs desta campanha de error-boundary sobre o tip de `release/v3.8.51`: `typecheck:core` limpo e **120/120** nos 23 arquivos de teste que os PRs trazem.

Um ponto que só apareceu no tree combinado: **#12465 e #12466 criam o mesmo arquivo novo** `open-sse/utils/streamReadiness.ts` (que não existe no tip) com desenhos divergentes de cancelamento — `cancelled` + `releaseLock` imediato num, `readInFlight`/`cancelRequested` com `cancelReader` fire-and-forget no outro. Adotei a versão do #12466, que difere e defere o release do lock para quando a leitura em voo termina, e validei a escolha rodando as suítes dos **dois** PRs contra ela: 21/21 no readiness compartilhado e 22/22 incluindo o boundary do Perplexity.
2026-09-03 20:57:02 -03:00
Diego Rodrigues de Sa e Souza
239d8fc67d fix(providers): separate MaxAI and UC credential contracts (#12431)
Validado em lote numa worktree combinada com os 14 PRs desta campanha de error-boundary sobre o tip de `release/v3.8.51`: `typecheck:core` limpo e **120/120** nos 23 arquivos de teste que os PRs trazem.

Um ponto que só apareceu no tree combinado: **#12465 e #12466 criam o mesmo arquivo novo** `open-sse/utils/streamReadiness.ts` (que não existe no tip) com desenhos divergentes de cancelamento — `cancelled` + `releaseLock` imediato num, `readInFlight`/`cancelRequested` com `cancelReader` fire-and-forget no outro. Adotei a versão do #12466, que difere e defere o release do lock para quando a leitura em voo termina, e validei a escolha rodando as suítes dos **dois** PRs contra ela: 21/21 no readiness compartilhado e 22/22 incluindo o boundary do Perplexity.
2026-09-03 20:56:44 -03:00
Diego Rodrigues de Sa e Souza
4a37c7f46e fix(security): close 3 advisories — search baseUrl exfil, sk- in the error sanitizer, bifrost relay header leak (#12620)
Validado em worktree combinada sobre o tip de release/v3.8.51: os dois boardaram sem conflito, typecheck:core limpo, check-file-size sem violação nova (as duas restantes — codex.ts e stream.ts — são drift anterior) e 51/51 nos 5 arquivos de teste que os PRs trazem.
2026-09-03 20:48:58 -03:00
Davide Baraldo
8a95a2bced fix(settings): cache-config alwaysPreserveClientCache was a runtime no-op (#12304)
Validado em worktree combinada sobre o tip de release/v3.8.51: os dois boardaram sem conflito, typecheck:core limpo, check-file-size sem violação nova (as duas restantes — codex.ts e stream.ts — são drift anterior) e 51/51 nos 5 arquivos de teste que os PRs trazem.
2026-09-03 20:48:40 -03:00
Diego Rodrigues de Sa e Souza
910f58c5cc docs(quality): document how the CodeQL ratchet refreshes and how to tighten it (#12611)
Co-authored-by: Markus Hartung <diegosouzapw@users.noreply.github.com>
2026-09-03 17:26:52 -03:00
Diego Rodrigues de Sa e Souza
f51cd295c8 chore(providers): bump Claude Code wire identity + Devin bridge pin to 2.1.258 (#12604)
17 de 18 checks verdes, **zero falhas** — incluindo os quatro shards de unit, Vitest, CodeQL, semgrep, Docs Gates, Merge integrity e o **Fast Quality Gates**, que era exatamente o que o rebaseline do `RoutingTab.tsx` (1606→1607, a linha do seletor que acompanha a nova versão de identidade) veio consertar.

O único check restante, `No new ESLint warnings`, ficou enfileirado sem iniciar (duração 0) atrás da saturação de runners de hoje. Cobri os dois comandos que ele executa, localmente e sobre este HEAD:

- `npm run check:codeql-ratchet` → **0 alertas abertos** contra baseline 6 (sem regressão).
- `npx eslint --max-warnings 0` nos 9 arquivos de código que esta branch altera → **exit 0**, nenhum warning.

Conteúdo: os dois commits do #12402 que não estavam subsumidos, com autoria do @ggiak preservada pelo cherry-pick `-x`, mais o rebaseline e o fragmento de changelog que faltava. `typecheck:core` limpo e **138/138** nos testes focados. As três versões (`claudeCodeClient.ts`, `Dockerfile`, `compose.yml`) conferem em 2.1.258, e `npm view @anthropic-ai/claude-code@2.1.258` resolve — o Dockerfile instala esse pin exato.
2026-09-03 13:49:46 -03:00
Diego Rodrigues de Sa e Souza
ad500de9e3 chore(quality): rebaseline file-size caps the hartmark batch grew past (#12623)
Rebaseline medido no tip com os 9 PRs da leva hartmark mergeados. Não toca codex.ts nem stream.ts, drift anterior à leva.
2026-09-03 13:04:34 -03:00
Diego Rodrigues de Sa e Souza
1baa8c3630 fix(quality): re-point the zcodeProtocol public-creds allowlist to line 313 (#12615)
`check:public-creds` has been failing on every open PR against release/v3.8.51,
twice over for the same literal:

  ✗ 1 entrada(s) obsoleta(s) na allowlist — zcodeProtocol.ts:302
  ✗ 1 credencial(is) pública(s) como string literal — zcodeProtocol.ts L313

Both are the same `clientId: \`omniroute-${process.pid}\`` in the local ZCode
handshake. Nothing regressed: the allowlist key is `file:line:value`, so an edit
that shifted the statement from 302 to 313 invalidated the frozen key and the
gate reported the entry as stale AND the literal as new.

Re-pointed the key and its comment. The literal itself is unchanged and still
frozen — the entry is not removed and the detector is not weakened (the gate's
own test still asserts that renaming the value to `upstream-client-` is flagged).

`tests/unit/check-public-creds.test.ts` synthesizes the source with a newline
count to land the statement on the allowlisted line; that count moves with it,
302 -> 313, so the test keeps pinning the real contract instead of a stale one.

Also documented the sharp edge inline: keying by line number means any edit near
this statement breaks the gate in two places at once, and the fix is to re-point
the line, never to drop the entry. Tightening the key to `file:value` would
remove the trap but widens what the entry freezes, so it is left as a note
rather than folded into a base-red drain.

check:public-creds OK (3 frozen literals), check-public-creds tests 20/20,
check:tracked-artifacts OK, prettier clean.
2026-09-03 13:02:52 -03:00
Markus Hartung
ffdc736060 feat(dashboard): parent-link, genuine-continuation badge, and modal perf fixes (#12448)
Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51` (já com a leva anterior dentro): os nove boardaram **sem um único conflito**, `typecheck:core` limpo e **80/80** nos 6 arquivos de teste que os PRs trazem.

O crescimento de arquivo próprio da leva foi rebaselinado num registro datado (`_rebaseline_2026_09_03_hartmark_batch`): `combos/page.tsx` 5012→5018 (#12355, tratar o estado degradado quando o bundling de tiktoken de um provider sem relação falha) e `open-sse/services/combo.ts` 4023→4036 (#12338, os fixes do universal-handoff). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva.

Obrigado, @hartmark.
2026-09-03 13:02:43 -03:00
Markus Hartung
4866f927ad fix(combo): universal-handoff fixes — bare-fallback note, same-request scoping, silent-failure logging (#12338)
Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51` (já com a leva anterior dentro): os nove boardaram **sem um único conflito**, `typecheck:core` limpo e **80/80** nos 6 arquivos de teste que os PRs trazem.

O crescimento de arquivo próprio da leva foi rebaselinado num registro datado (`_rebaseline_2026_09_03_hartmark_batch`): `combos/page.tsx` 5012→5018 (#12355, tratar o estado degradado quando o bundling de tiktoken de um provider sem relação falha) e `open-sse/services/combo.ts` 4023→4036 (#12338, os fixes do universal-handoff). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva.

Obrigado, @hartmark.
2026-09-03 13:02:24 -03:00
Markus Hartung
8729909622 fix(logging): raise the SSE payload collector's default cap (#12461)
Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51` (já com a leva anterior dentro): os nove boardaram **sem um único conflito**, `typecheck:core` limpo e **80/80** nos 6 arquivos de teste que os PRs trazem.

O crescimento de arquivo próprio da leva foi rebaselinado num registro datado (`_rebaseline_2026_09_03_hartmark_batch`): `combos/page.tsx` 5012→5018 (#12355, tratar o estado degradado quando o bundling de tiktoken de um provider sem relação falha) e `open-sse/services/combo.ts` 4023→4036 (#12338, os fixes do universal-handoff). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva.

Obrigado, @hartmark.
2026-09-03 13:02:00 -03:00
Markus Hartung
0019a47f24 fix(responses-continuation): fail closed on a collector-truncated, empty output array (#12460)
Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51` (já com a leva anterior dentro): os nove boardaram **sem um único conflito**, `typecheck:core` limpo e **80/80** nos 6 arquivos de teste que os PRs trazem.

O crescimento de arquivo próprio da leva foi rebaselinado num registro datado (`_rebaseline_2026_09_03_hartmark_batch`): `combos/page.tsx` 5012→5018 (#12355, tratar o estado degradado quando o bundling de tiktoken de um provider sem relação falha) e `open-sse/services/combo.ts` 4023→4036 (#12338, os fixes do universal-handoff). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva.

Obrigado, @hartmark.
2026-09-03 13:01:43 -03:00
Markus Hartung
2e4a79ca50 fix(quality): detect duplicate tool_calls entries in one response (#12446)
Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51` (já com a leva anterior dentro): os nove boardaram **sem um único conflito**, `typecheck:core` limpo e **80/80** nos 6 arquivos de teste que os PRs trazem.

O crescimento de arquivo próprio da leva foi rebaselinado num registro datado (`_rebaseline_2026_09_03_hartmark_batch`): `combos/page.tsx` 5012→5018 (#12355, tratar o estado degradado quando o bundling de tiktoken de um provider sem relação falha) e `open-sse/services/combo.ts` 4023→4036 (#12338, os fixes do universal-handoff). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva.

Obrigado, @hartmark.
2026-09-03 13:01:25 -03:00
Markus Hartung
4ec4ce410e fix(sse): remap non-contiguous upstream tool_calls index to a gap-free output_index (#12445)
Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51` (já com a leva anterior dentro): os nove boardaram **sem um único conflito**, `typecheck:core` limpo e **80/80** nos 6 arquivos de teste que os PRs trazem.

O crescimento de arquivo próprio da leva foi rebaselinado num registro datado (`_rebaseline_2026_09_03_hartmark_batch`): `combos/page.tsx` 5012→5018 (#12355, tratar o estado degradado quando o bundling de tiktoken de um provider sem relação falha) e `open-sse/services/combo.ts` 4023→4036 (#12338, os fixes do universal-handoff). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva.

Obrigado, @hartmark.
2026-09-03 13:01:03 -03:00
Markus Hartung
7881e7eb72 fix(conversations): resolve turn content OmniRoute never sends back to the client (#12447)
Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51` (já com a leva anterior dentro): os nove boardaram **sem um único conflito**, `typecheck:core` limpo e **80/80** nos 6 arquivos de teste que os PRs trazem.

O crescimento de arquivo próprio da leva foi rebaselinado num registro datado (`_rebaseline_2026_09_03_hartmark_batch`): `combos/page.tsx` 5012→5018 (#12355, tratar o estado degradado quando o bundling de tiktoken de um provider sem relação falha) e `open-sse/services/combo.ts` 4023→4036 (#12338, os fixes do universal-handoff). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva.

Obrigado, @hartmark.
2026-09-03 13:00:44 -03:00
Markus Hartung
c091534ffc fix(providers): stop an unrelated-provider tiktoken bundling failure from crashing /api/providers (#12355)
Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51` (já com a leva anterior dentro): os nove boardaram **sem um único conflito**, `typecheck:core` limpo e **80/80** nos 6 arquivos de teste que os PRs trazem.

O crescimento de arquivo próprio da leva foi rebaselinado num registro datado (`_rebaseline_2026_09_03_hartmark_batch`): `combos/page.tsx` 5012→5018 (#12355, tratar o estado degradado quando o bundling de tiktoken de um provider sem relação falha) e `open-sse/services/combo.ts` 4023→4036 (#12338, os fixes do universal-handoff). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva.

Obrigado, @hartmark.
2026-09-03 13:00:27 -03:00
Markus Hartung
d353870342 fix(resourcePressure): log numeric detail on every rejection, recover faster (#12293)
Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51` (já com a leva anterior dentro): os nove boardaram **sem um único conflito**, `typecheck:core` limpo e **80/80** nos 6 arquivos de teste que os PRs trazem.

O crescimento de arquivo próprio da leva foi rebaselinado num registro datado (`_rebaseline_2026_09_03_hartmark_batch`): `combos/page.tsx` 5012→5018 (#12355, tratar o estado degradado quando o bundling de tiktoken de um provider sem relação falha) e `open-sse/services/combo.ts` 4023→4036 (#12338, os fixes do universal-handoff). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva.

Obrigado, @hartmark.
2026-09-03 13:00:08 -03:00
Diego Rodrigues de Sa e Souza
d9526cefea chore(quality): rebaseline file-size caps the HouMinXi batch grew past (#12619)
Rebaseline medido no tip com os 9 PRs da leva mergeados. Desfaz o vermelho de file-size que os PRs empilhados deixaram; não toca codex.ts nem stream.ts, que já violavam antes da leva.
2026-09-03 12:49:53 -03:00
Bob.Hou
831ea040c3 feat(quota): Moonshot Open Platform balance and TPD lock for custom nodes (#12590)
Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check:provider-consistency` OK (273 entradas REGISTRY, **356** providers canônicos), `check-docs-counts-sync` exit 0 e **300/300** nos testes que a leva toca.

O crescimento de arquivo que os PRs empilham uns sobre os outros foi rebaselinado num único registro datado (`_rebaseline_2026_09_03_houminxi_batch`), com a decomposição por arquivo: `providers/page.tsx` +18 (import CSV do #12504 + busca do #12495 no mesmo painel), `accountFallback.ts` +6 (o #12566 sobre o rebaseline que o #12590 já registrou — os dois tocam `checkFallbackError`) e `chatCore.ts` +3 (invalidez de cache de quota no 429 do #12325). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva.
2026-09-03 12:47:50 -03:00
Bob.Hou
a47d2e521e feat(providers): add SeekAi OpenAI-compatible New-API gateway (#12557)
Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check:provider-consistency` OK (273 entradas REGISTRY, **356** providers canônicos), `check-docs-counts-sync` exit 0 e **300/300** nos testes que a leva toca.

O crescimento de arquivo que os PRs empilham uns sobre os outros foi rebaselinado num único registro datado (`_rebaseline_2026_09_03_houminxi_batch`), com a decomposição por arquivo: `providers/page.tsx` +18 (import CSV do #12504 + busca do #12495 no mesmo painel), `accountFallback.ts` +6 (o #12566 sobre o rebaseline que o #12590 já registrou — os dois tocam `checkFallbackError`) e `chatCore.ts` +3 (invalidez de cache de quota no 429 do #12325). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva.
2026-09-03 12:46:06 -03:00
Bob.Hou
40c80756e4 fix(quota): keep Antigravity Gemini usable when Claude weekly is empty (#12566)
Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check:provider-consistency` OK (273 entradas REGISTRY, **356** providers canônicos), `check-docs-counts-sync` exit 0 e **300/300** nos testes que a leva toca.

O crescimento de arquivo que os PRs empilham uns sobre os outros foi rebaselinado num único registro datado (`_rebaseline_2026_09_03_houminxi_batch`), com a decomposição por arquivo: `providers/page.tsx` +18 (import CSV do #12504 + busca do #12495 no mesmo painel), `accountFallback.ts` +6 (o #12566 sobre o rebaseline que o #12590 já registrou — os dois tocam `checkFallbackError`) e `chatCore.ts` +3 (invalidez de cache de quota no 429 do #12325). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva.
2026-09-03 12:40:21 -03:00
Bob.Hou
f81ce2a23b feat(dashboard): adaptive context-budget dial on compression panel (#12488)
Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check:provider-consistency` OK (273 entradas REGISTRY, **356** providers canônicos), `check-docs-counts-sync` exit 0 e **300/300** nos testes que a leva toca.

O crescimento de arquivo que os PRs empilham uns sobre os outros foi rebaselinado num único registro datado (`_rebaseline_2026_09_03_houminxi_batch`), com a decomposição por arquivo: `providers/page.tsx` +18 (import CSV do #12504 + busca do #12495 no mesmo painel), `accountFallback.ts` +6 (o #12566 sobre o rebaseline que o #12590 já registrou — os dois tocam `checkFallbackError`) e `chatCore.ts` +3 (invalidez de cache de quota no 429 do #12325). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva.
2026-09-03 12:40:02 -03:00
Bob.Hou
35caeb31f2 feat(settings): persist headroomUrl for the Headroom proxy (#12487)
Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check:provider-consistency` OK (273 entradas REGISTRY, **356** providers canônicos), `check-docs-counts-sync` exit 0 e **300/300** nos testes que a leva toca.

O crescimento de arquivo que os PRs empilham uns sobre os outros foi rebaselinado num único registro datado (`_rebaseline_2026_09_03_houminxi_batch`), com a decomposição por arquivo: `providers/page.tsx` +18 (import CSV do #12504 + busca do #12495 no mesmo painel), `accountFallback.ts` +6 (o #12566 sobre o rebaseline que o #12590 já registrou — os dois tocam `checkFallbackError`) e `chatCore.ts` +3 (invalidez de cache de quota no 429 do #12325). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva.
2026-09-03 12:39:27 -03:00
Bob.Hou
c2d2b0ac14 feat(providers): surface CSV import row errors and ship a template (#12504)
Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check:provider-consistency` OK (273 entradas REGISTRY, **356** providers canônicos), `check-docs-counts-sync` exit 0 e **300/300** nos testes que a leva toca.

O crescimento de arquivo que os PRs empilham uns sobre os outros foi rebaselinado num único registro datado (`_rebaseline_2026_09_03_houminxi_batch`), com a decomposição por arquivo: `providers/page.tsx` +18 (import CSV do #12504 + busca do #12495 no mesmo painel), `accountFallback.ts` +6 (o #12566 sobre o rebaseline que o #12590 já registrou — os dois tocam `checkFallbackError`) e `chatCore.ts` +3 (invalidez de cache de quota no 429 do #12325). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva.
2026-09-03 12:39:03 -03:00
Bob.Hou
52456a1cea fix(quota): drop generic quota cache on upstream 429 (#12325)
Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check:provider-consistency` OK (273 entradas REGISTRY, **356** providers canônicos), `check-docs-counts-sync` exit 0 e **300/300** nos testes que a leva toca.

O crescimento de arquivo que os PRs empilham uns sobre os outros foi rebaselinado num único registro datado (`_rebaseline_2026_09_03_houminxi_batch`), com a decomposição por arquivo: `providers/page.tsx` +18 (import CSV do #12504 + busca do #12495 no mesmo painel), `accountFallback.ts` +6 (o #12566 sobre o rebaseline que o #12590 já registrou — os dois tocam `checkFallbackError`) e `chatCore.ts` +3 (invalidez de cache de quota no 429 do #12325). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva.
2026-09-03 12:38:39 -03:00
Bob.Hou
0f5fc78d8a feat(providers): search connections by name and baseUrl (#12495)
Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check:provider-consistency` OK (273 entradas REGISTRY, **356** providers canônicos), `check-docs-counts-sync` exit 0 e **300/300** nos testes que a leva toca.

O crescimento de arquivo que os PRs empilham uns sobre os outros foi rebaselinado num único registro datado (`_rebaseline_2026_09_03_houminxi_batch`), com a decomposição por arquivo: `providers/page.tsx` +18 (import CSV do #12504 + busca do #12495 no mesmo painel), `accountFallback.ts` +6 (o #12566 sobre o rebaseline que o #12590 já registrou — os dois tocam `checkFallbackError`) e `chatCore.ts` +3 (invalidez de cache de quota no 429 do #12325). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva.
2026-09-03 12:38:16 -03:00
Bob.Hou
c9fb06e26c fix(grok-cli): treat omitted SuperGrokPro creditUsagePercent as 0% (#12312)
Validado em lote numa worktree combinada com os 9 PRs desta leva sobre o tip de `release/v3.8.51`: `typecheck:core` limpo, `check:provider-consistency` OK (273 entradas REGISTRY, **356** providers canônicos), `check-docs-counts-sync` exit 0 e **300/300** nos testes que a leva toca.

O crescimento de arquivo que os PRs empilham uns sobre os outros foi rebaselinado num único registro datado (`_rebaseline_2026_09_03_houminxi_batch`), com a decomposição por arquivo: `providers/page.tsx` +18 (import CSV do #12504 + busca do #12495 no mesmo painel), `accountFallback.ts` +6 (o #12566 sobre o rebaseline que o #12590 já registrou — os dois tocam `checkFallbackError`) e `chatCore.ts` +3 (invalidez de cache de quota no 429 do #12325). As violações restantes (`codex.ts`, `stream.ts`) foram medidas também no tip puro e são drift da base, não desta leva.
2026-09-03 12:37:54 -03:00
Diego Rodrigues de Sa e Souza
9ddb8e0a93 fix(docs): restore the Next build — REMOVED_PROVIDERS.md had no frontmatter (base-red #12581) (#12610)
`source.config.ts` feeds `docs/reference/**/*.md` to fumadocs-mdx, whose default
schema requires a `title`. #12478 added `docs/reference/REMOVED_PROVIDERS.md`
with no frontmatter block at all, so every production build died with:

    [MDX] invalid frontmatter in docs/reference/REMOVED_PROVIDERS.md:
    - title: Invalid input: expected string, received undefined

That single missing block is what turns three release-green gates red at once —
`Package artifact (npm pack policy)` fails on the build, and both
`Tarball boot-smoke` and the packaged CLI checks are skipped for lack of a
valid `dist/`.

Fixes:
- add the frontmatter block, matching the convention of its sibling reference
  docs (`title` / `version` / `lastUpdated`).
- add `check:docs-frontmatter`, wired into `check:docs-all`, so the next doc
  added without a title fails in milliseconds instead of costing a full Next
  build and a red release branch. The gate reads its globs from
  `source.config.ts` rather than duplicating them, so a new docs directory
  cannot silently escape the check.

Verified: the gate reports OK across all 122 compiled docs, fails (exit 1) when
the frontmatter is removed, and `npm run check:docs-all` passes.
2026-09-03 11:33:07 -03:00
Diego Rodrigues de Sa e Souza
9af3ec5112 feat(video): redact transcript in the in-memory pending-request snapshot (#12430 item 6) (#12596)
* feat(video): redact transcript fields in the in-memory pending-request snapshot (#12430 item 6)

trackPendingRequest (open-sse/handlers/chatCore.ts) stored the raw client
body (with video transcript/audioTranscript cues) under `clientRequest`,
live-exposed via /api/usage/call-logs (pendingDetails), /api/logs/[id] and
/api/conversations while a request is in-flight. P2a redacted the persisted
detailed-log snapshot but not this in-memory copy.

Add redactPendingBody() to videoBridgeSnapshotRedaction.ts (sibling to
logClientRawRequestRedacted from P2a): when videoBridgeObserved, returns the
redacted clone from redactVideoTranscriptFieldsForLog; otherwise returns the
exact same reference. Wire it into the trackPendingRequest call site
(chatCore.ts:934), keeping the file within its frozen 5976-line budget
(5971 -> 5974).

* feat(video): substring-redact transcript in derived-prompt dispatch logs (#12430 item 4)

Extend applyVideoBridgeLogRedaction with a string-content branch:
pipeline-strategy stages, smart-auto-pipeline, and context-handoff
summaries embed the transcript as a substring of a rendered prompt
string rather than an exact array part, so the existing exact
part-array match silently skipped them. Adds a mutually-exclusive
string branch (Array.isArray vs typeof === "string") that does a
replaceAll of the trusted fullText literal against a lazily cloned
message, reusing the existing rootClone/clonedContainers/clonedMessages
clone-on-write pattern so siblings keep original references and the
input is never mutated.
2026-09-03 10:49:01 -03:00
Giorgos Giakoumettis
2c4ad3e557 docs(readme): introduce OmniRouteTray — the macOS menu-bar companion (#12276)
Aprovado pelo operador. Adiciona o OmniRouteTray (@zoispag) ao README — app de menu-bar para macOS, rotulado com honestidade como projeto da comunidade e não release oficial. Mudança só de markdown, sem tocar nada executável; inclui também dois ajustes de alinhamento na tabela de contatos. Obrigado, @ggiak.
2026-09-03 09:21:00 -03:00
Diego Rodrigues de Sa e Souza
49c4a620ca fix(authz): hard-gate every credential export and CLI-config write (GHSA-5926-2w35-7h4q) (#12600)
* fix(authz): hard-gate every credential export and CLI-config write

GHSA-5926-2w35-7h4q: `POST /api/providers/{id}/claude-auth/export` and
`.../codex-auth/export` gate on `requireManagementAuth(request)` with no
`alwaysRequireAuth`, and neither path was in ALWAYS_PROTECTED_API_PATHS. Under
`requireLogin=false` — the local-first default — both fail open, so anyone who
knows a connection id downloads the operator's raw Claude/Codex OAuth
access_token / refresh_token (plus the Codex id_token).

This is the third recurrence of one class. GHSA-mghq-58h3-qcqj added
/api/db-backups; GHSA-v7g9-7f55-5g46 added the /api/settings/*-json siblings
mghq had missed; these two are the siblings both missed. So the fix is written
against the class, not the two reported routes.

Sweeping every route that hands out stored credentials, dumps captured traffic,
or writes the operator's CLI config turned up four more on the fail-open tier:

- GET /api/logs/export — dumps call_logs (prompts and responses) and proxy_logs
  for up to 168h.
- /api/cli-tools/codex-profiles — GET leaks the operator's account label; PUT
  writes attacker-supplied auth.json and config.toml straight into the host's
  Codex CLI config. Its only guard is ensureCliConfigWriteAllowed() with no
  targetPath, which checks CLI_ALLOW_CONFIG_WRITES — default true. Paired with
  the POST that stores an arbitrary profile, that is: save a profile holding the
  attacker's auth.json, apply it, and the operator's CLI now runs on attacker
  credentials (or, via config.toml, an attacker base URL).
- {claude,codex}-auth/apply-local and providers/agy-auth/apply-local — write a
  stored credential into ~/.codex/auth.json and
  ~/.gemini/antigravity-cli/antigravity-oauth-token.

The traffic-inspector HAR exports were already covered by LOCAL_ONLY.

Routes with a dynamic segment cannot be expressed in the exact/prefix list — a
`/api/providers/` prefix would hard-gate the whole provider surface and break
every keyless install — so this adds ALWAYS_PROTECTED_API_PATTERNS, mirroring
the existing LOCAL_ONLY_API_PATTERNS, and `isAlwaysProtectedPath` consults both.

The apply-local routes get ALWAYS_PROTECTED rather than LOCAL_ONLY on purpose:
it closes the anonymous hole without breaking an operator driving the dashboard
through a tunnel.

Deliberately NOT adding `{ alwaysRequireAuth: true }` at the handlers. Tier 2 is
the architecture's designated mechanism and the guard runs before the handler; a
second copy of the same decision inside each route is exactly the kind of
duplicate that drifts out of sync (cf. the dashboardCsrf prefix scan that had to
be unified in #11417).

tests/unit/authz/credential-export-always-protected.test.ts — 5 tests, red
before the fix. Written as an inventory of the whole class rather than two more
assertions, plus negative cases: the neighbouring provider routes must stay on
MANAGEMENT, and a connection id containing a slash must not slip past `[^/]+`.

openapi.yaml marks the seven newly-gated operations `x-always-protected`, and
openapi-security-tiers.test.ts now resolves `{param}` placeholders so it can
validate the pattern entries too.

Reported by @skeletonsec.

Closes GHSA-5926-2w35-7h4q

* chore(quality): register the credential-export authz test in stryker tap.testFiles

The new tests/unit/authz/credential-export-always-protected.test.ts covers
src/server/authz/routeGuard.ts, so check:mutation-test-coverage --strict fails
until it is listed — its mutant kills would not count otherwise.

Inserted in place (no re-serialization: a JSON round-trip on this file reorders
~10 curated entries that are already out of alphabetical order, cf. #11438).
2026-09-03 09:19:44 -03:00
Giorgos Giakoumettis
3f3d27e264 fix(ci): openapi-security-tiers checker must honor routeGuard patterns + imported prefixes (#12350)
Validado numa worktree sobre o tip de `release/v3.8.51`, medindo o gate dos dois lados: **red no tip** (dezenas de rotas `volcengine-plan`/`vnc-session` reportadas como "has x-loopback-only but is NOT covered") e **PASS com este PR**, exit 0.

Como é um gate de segurança, confirmei que o fix torna o checker *preciso* e não *frouxo*. A afirmação central do PR — que uma rota é coberta se casar com um prefixo resolvido **ou** com um pattern — bate exatamente com o runtime (`src/server/authz/routeGuard.ts:252-255`):

```ts
return (
  LOCAL_ONLY_API_PREFIXES.some((p) => path === p || path.startsWith(p)) ||
  LOCAL_ONLY_API_PATTERNS.some((re) => re.test(path))
);
```

O checker antigo enxergava só o primeiro braço, e nem isso por completo: a captura `[^\]]+` quebrava no `]` dentro de classes de regex, então `LOCAL_ONLY_API_PATTERNS` não era parseado, e `VNC_ROUTE_PREFIX` (const importada, não literal) não era resolvido. Resultado: rotas efetivamente protegidas em runtime apareciam como desprotegidas. Nenhum achado real foi silenciado — as 95 linhas de `WARN — missing x-loopback-only annotation` continuam saindo, são explicitamente não-fatais e pré-existentes.

Fecha um dos HARDs do base-red #12335. Obrigado, @ggiak.
2026-09-03 09:10:45 -03:00
Diego Rodrigues de Sa e Souza
e1cf542378 chore(deps): bump fast-uri to 3.1.7 in the electron lockfile (#12601)
Dependabot alerts #196–#199 — four HIGH advisories on fast-uri
(GHSA-jqff-g426-hqxp, GHSA-fph4-wmhf-6fwf, GHSA-f65p-4m7j-42xc,
GHSA-5jgf-p345-68v8), all patched in 3.1.6.

The root package-lock.json was already on a patched fast-uri (3.1.7) — those
alerts close on their own with the next scan. `electron/package-lock.json` is a
second lockfile and was still pinning 3.1.5, which is what these four alerts are
actually reporting.

Transitive, one copy, pulled by ajv (`^3.0.1`), so a package-lock-only update
lifts it without touching any manifest. The diff is three lines: version,
resolved and integrity for that single entry.

check:lockfile and check:tracked-artifacts pass.

Not fixed here: extract-zip (#191, HIGH, <= 2.0.1) has no published patch. It
comes in through @openai/codex-security and is dev-scope; it needs either an
upstream release or a decision to drop/replace the dependency, neither of which
belongs in a lockfile bump.
2026-09-03 09:07:15 -03:00
Diego Rodrigues de Sa e Souza
c41d8755db fix(ci): document eloqnt MIT exceptions and isolate A2A vitest (#12595)
Validado numa worktree sobre o tip de `release/v3.8.51`. Verifiquei as premissas em vez de aceitar a justificativa:

**Exceções de licença** — todas as afirmações batem. Os três pacotes estão instalados exatamente nas versões travadas citadas (`@eloqnt/config@0.0.2`, `@eloqnt/format-json@0.0.3`, `@eloqnt/format-po@0.0.3`), os três **omitem `package.json#license` e não têm arquivo LICENSE**, e `npm ls` confirma que são transitivos de `next-intl@4.14.1` (MIT). No registry, o SPDX das três é MIT e o `@eloqnt/config@0.1.0` existe, como o texto diz. Uso de `exceptions` (com `risk`/`reviewAt`) em vez de `allowed: UNKNOWN` é o mecanismo certo.

`check:licenses` verde: **0 violações de política**, 948 permitidos, as três novas entradas aparecendo como exceções sinalizadas não bloqueantes ao lado das duas já existentes. Teste-guarda `tests/unit/build/check-licenses.test.ts`: **36/36**.

**Vitest do A2A lifecycle** — as duas seams usadas já existiam antes deste PR: `constructor(ttlMinutes = 5, persistence: A2APersistence = defaultPersistence)` e o 4º parâmetro opcional `deps?: MemoryHitsDeps` de `executeA2ATaskWithState`. O padrão é idêntico ao que `tests/unit/a2a-task-persistence.test.ts` já fazia. Nenhuma asserção foi removida ou enfraquecida — e como o comportamento de persistência tem suíte dedicada, injetar no-ops aqui tira acoplamento incidental, não cobertura.

Ganho medido nos dois lados: corpo dos testes **784ms no tip → 233ms com o PR**, sem nenhuma linha `[DB] SQLite database ready`. Registro honesto: **no tip o arquivo passa localmente** (4/4) — o red era do CI, sob o thread pool com as 167 migrações; localmente dá para comprovar o mecanismo e a aceleração, não a falha em si.

Suíte vitest completa: **51/51 arquivos, 465/465 testes**.
2026-09-03 08:39:46 -03:00
Diego Rodrigues de Sa e Souza
7d0b264eda chore(electron): drop openAsHidden/wasOpenedAsHidden, removed in Electron 44 (#12554)
CI verde: 18 checks passando (4 shards de unit, Vitest, CodeQL, Fast Quality Gates, No new ESLint warnings, semgrep). Local: 8/8 no teste atualizado e 165/165 nos 16 arquivos tests/unit/electron-*.test.ts.
2026-09-03 08:32:22 -03:00
opensource-elearning
e2e330a058 perf(stream): compile hot-path regexes once, bound token caches, fix quadratic buffering (#12179)
Validado sobre o tip de `release/v3.8.51` após reconciliar quatro arquivos que driftaram. Em parte o tip já tinha absorvido a intenção deste branch por abstrações melhores, então mantive a forma do tip e trouxe os ganhos que ainda eram reais:

- **`sseCollect.ts`** — o tip extraiu `stripObfuscationZeroWidth()` para `utils/zeroWidth.ts`, o que supera o `ZERO_WIDTH_RE` local (removido). A içada de `TEXTUAL_TOOL_CALL_RE` foi mantida: essa regex ainda estava inline num caminho quente.
- **`resultMemo.ts`** — o `memoStore()` do tip devolve o clone armazenado para que o idiom comum `memoStore(k, r); return memoLookup(k)!` evite um segundo deep-clone de vários MB. Esse contrato foi preservado (o branch o revertia para `void`), e o round-trip `JSON.parse(JSON.stringify())` virou `structuredClone()` nas duas pontas — que era o ponto de performance real do branch.
- **`browserPool.ts`** — o tip agora tem caminho headed e o engine Obscura (#12286). Ambos preservados, mais a varredura de TTL do `pendingContexts` deste branch, adaptada ao nome `poolKey` do tip.
- **`executeAttempt.ts`** — mantido o comentário explicativo do tip.

Também corrigi **quatro erros de typecheck que o branch introduzia**: `hasUnsupportedSignal` estava tipado `boolean` mas avaliava para `string | boolean`, e o fast-path de `extractUsage()` indexava `c.response`/`c.message` como `unknown`.

`typecheck:core` limpo e **482/482** nos testes de antigravity + compressão na própria branch. Obrigado, @opensource-elearning.
2026-09-03 08:29:14 -03:00
opensource-elearning
8df944cd46 feat(browser): adopt Obscura as primary headless browser engine with Chromium fallback (#12286)
Validado sobre o tip de release/v3.8.51 após reconciliar o base-drift.

O pool de browsers ganhou um caminho **headed** (`headedBrowser`/`headedLaunching`, `resolvePlainBrowserLaunchOptions`, estado de launch por modo) depois que esta branch forkou. O PR reescrevia `launchBrowser()` no modelo de browser único contra o qual foi escrito, o que teria **removido o suporte headed**. Em vez disso, reapliquei a preferência pelo Obscura dentro do ramo headless de `launchBrowserInstance()`, à frente do cloakbrowser e do Chromium puro — um browser headed precisa ser um Chromium com janela real, então a preferência de engine é só do caminho headless. `state.engine` alimenta `isStealth` e `getBrowserPoolStatus()`, e o shutdown zera o engine sem matar o servidor Obscura compartilhado (dono: `./obscura.ts`).

typecheck:core limpo e 3/3 em tests/unit/obscura-integration.test.ts. Obrigado, @opensource-elearning.
2026-09-03 08:24:32 -03:00
opensource-elearning
3740839e2a fix(combo): fall back to full pool when collapsed sole survivor is context-too-small (#12278)
Validado em lote sobre o tip de release/v3.8.51: boardou sem conflito, typecheck:core limpo e 17/17 em tests/unit/combo-context-window-filter.test.ts. Obrigado, @opensource-elearning.
2026-09-03 08:20:58 -03:00
Diego Rodrigues de Sa e Souza
cb38bfa6ee feat(video): redact raw client-snapshot transcript fields in the detailed log (#12150 P2) (#12528)
* feat(video): redact raw client-snapshot transcript fields in the detailed log (#12150 P2)

clientRawRequest.body is captured before the guardrail chain runs and persisted
verbatim by reqLogger.logClientRawRequest, so it retained the client's raw
transcript/audioTranscript cue text on video parts even after P1's description
redaction. Add redactVideoTranscriptFieldsForLog (new, dependency-light module)
and wire it at the logClientRawRequest call site, gated on videoBridgeObserved:
redacts the structured transcript fields in the LOGGED copy only, never the
body sent to the provider or returned to the client.

* refactor(video): extract the guarded client-snapshot log call to keep chatCore within its size budget

Fast Quality Gates check:file-size flagged chatCore.ts growing past its
frozen ceiling (5985 > 5976) from the P2a wiring. Move the guarded
logClientRawRequest call into logClientRawRequestRedacted (new export
in videoBridgeSnapshotRedaction.ts, which already owns the redaction),
collapsing the inline if-block at the chatCore.ts call site to a single
call. Net -4 lines vs the pre-P2a base. Behavior unchanged: non-observed
still logs the exact same clientRawRequest.body reference; observed
still logs the redacted clone.
2026-09-03 08:10:49 -03:00
backryun
6795783228 feat(providers): refresh Fable, Cursor, and Devin catalogs (#12367)
Validado em lote numa worktree combinada com #12524, #12538, #12277 e #12367 sobre o tip de release/v3.8.51: os quatro boardaram sem conflito (áreas disjuntas — zai-web, nvidia, clova, cursor/devin/fable). typecheck:core limpo, check:provider-consistency OK (272 entradas REGISTRY, 355 providers canônicos), check:known-symbols OK, e 305/305 nos testes tocados pelos quatro PRs. Os IDs de modelo adicionados foram conferidos individualmente. Obrigado, @backryun.
2026-09-03 07:50:41 -03:00
backryun
82c64d76d3 feat(providers): modernize CLOVA Studio chat and embeddings (#12277)
Validado em lote numa worktree combinada com #12524, #12538, #12277 e #12367 sobre o tip de release/v3.8.51: os quatro boardaram sem conflito (áreas disjuntas — zai-web, nvidia, clova, cursor/devin/fable). typecheck:core limpo, check:provider-consistency OK (272 entradas REGISTRY, 355 providers canônicos), check:known-symbols OK, e 305/305 nos testes tocados pelos quatro PRs. Os IDs de modelo adicionados foram conferidos individualmente. Obrigado, @backryun.
2026-09-03 07:50:22 -03:00
backryun
cdd07df700 feat(providers): refresh NVIDIA hosted models (#12538)
Validado em lote numa worktree combinada com #12524, #12538, #12277 e #12367 sobre o tip de release/v3.8.51: os quatro boardaram sem conflito (áreas disjuntas — zai-web, nvidia, clova, cursor/devin/fable). typecheck:core limpo, check:provider-consistency OK (272 entradas REGISTRY, 355 providers canônicos), check:known-symbols OK, e 305/305 nos testes tocados pelos quatro PRs. Os IDs de modelo adicionados foram conferidos individualmente. Obrigado, @backryun.
2026-09-03 07:50:02 -03:00
backryun
032adb0809 feat(providers): refresh Z.ai Web models and browser transport (#12524)
Validado em lote numa worktree combinada com #12524, #12538, #12277 e #12367 sobre o tip de release/v3.8.51: os quatro boardaram sem conflito (áreas disjuntas — zai-web, nvidia, clova, cursor/devin/fable). typecheck:core limpo, check:provider-consistency OK (272 entradas REGISTRY, 355 providers canônicos), check:known-symbols OK, e 305/305 nos testes tocados pelos quatro PRs. Os IDs de modelo adicionados foram conferidos individualmente. Obrigado, @backryun.
2026-09-03 07:49:42 -03:00
dependabot[bot]
2a6d45abea deps: bump electron from 43.4.1 to 44.0.0 in /electron (#12217)
Bump MAJOR electron 43.4.1 → 44.0.0 (Chromium 152, Node 24.18.1, V8 15.2), aprovado pelo operador após análise dos breaking changes contra o código real:

- **Remoção dos builds 32-bit (Windows ia32, Linux armv7l)** — sem impacto: `electron/package.json` só declara alvos x64 e arm64.
- **libEGL/libGLESv2 deixam de ser distribuídos (ANGLE estático)** — sem impacto: nenhuma referência em `electron/`, nos scripts de build ou no afterPack.
- **`clipboard` deixa de ser exposto ao renderer** — sem impacto: sem uso no projeto.
- **`net.request` passa a rejeitar `Sec-Fetch-Dest` document/frame/iframe/fencedframe sem `Sec-Fetch-Mode: navigate`** — sem impacto: `net.request`/`net.fetch` não são usados.
- **`openAsHidden` e `wasOpenedAsHidden` removidos de `app.set/getLoginItemSettings()`** — usados em `electron/main.js:1116` e `electron/lib/windowLifecycle.js:7`, mas sem regressão em plataforma suportada: o caminho vivo do autostart oculto é `args: ["--hidden"]` combinado com a checagem `argv.includes("--hidden")`, que `shouldStartHidden()` avalia primeiro; Linux nem chega nessa API (usa `enableLinuxDesktopAutostart`). `openAsHidden` só funcionava em macOS ≤ 12, que esta própria versão deixa de suportar.
- **macOS 12 (Monterey) sai do suporte** — é o único efeito voltado ao usuário. Registrado no CHANGELOG em PR de acompanhamento.

O smoke de empacotamento (`electron-package-smoke`) não roda em PR para branch de release — `ci.yml` dispara em `main` — então a validação de empacotamento acontece no merge → main, que é o modelo do repositório. Um PR de acompanhamento remove o código morto de `openAsHidden`/`wasOpenedAsHidden`. Obrigado, Dependabot.
2026-09-03 01:21:44 -03:00
dependabot[bot]
fdee0ec208 deps: bump the production group across 1 directory with 4 updates (#12399)
Validado em lote numa worktree combinada com os 7 bumps da raiz sobre o tip de release/v3.8.51: npm install reconciliou o lock sem diff (11/11 pacotes na versão alvo), typecheck:core e typecheck:noimplicit:core limpos, lint exit 0, e 149/149 testes nos 11 arquivos que exercitam zod diretamente. As falhas de CI do PR foram discriminadas como estado da base, não do bump. Obrigado, Dependabot.
2026-09-03 01:21:00 -03:00
dependabot[bot]
60ea5f8858 deps: bump the development group across 1 directory with 2 updates (#12347)
Validado em lote numa worktree combinada com os 7 bumps da raiz sobre o tip de release/v3.8.51: npm install reconciliou o lock sem diff (11/11 pacotes na versão alvo), typecheck:core e typecheck:noimplicit:core limpos, lint exit 0, e 149/149 testes nos 11 arquivos que exercitam zod diretamente. As falhas de CI do PR foram discriminadas como estado da base, não do bump. Obrigado, Dependabot.
2026-09-03 01:20:42 -03:00
dependabot[bot]
fa64266e32 deps: bump qs from 6.15.2 to 6.16.0 (#12512)
Validado em lote numa worktree combinada com os 7 bumps da raiz sobre o tip de release/v3.8.51: npm install reconciliou o lock sem diff (11/11 pacotes na versão alvo), typecheck:core e typecheck:noimplicit:core limpos, lint exit 0, e 149/149 testes nos 11 arquivos que exercitam zod diretamente. As falhas de CI do PR foram discriminadas como estado da base, não do bump. Obrigado, Dependabot.
2026-09-03 01:20:24 -03:00
dependabot[bot]
df97d46f48 deps: bump fast-uri from 3.1.5 to 3.1.7 (#12514)
Validado em lote numa worktree combinada com os 7 bumps da raiz sobre o tip de release/v3.8.51: npm install reconciliou o lock sem diff (11/11 pacotes na versão alvo), typecheck:core e typecheck:noimplicit:core limpos, lint exit 0, e 149/149 testes nos 11 arquivos que exercitam zod diretamente. As falhas de CI do PR foram discriminadas como estado da base, não do bump. Obrigado, Dependabot.
2026-09-03 01:20:07 -03:00
dependabot[bot]
a986ef2e2b deps: bump browserslist from 4.28.2 to 4.28.8 (#12396)
Validado em lote numa worktree combinada com os 7 bumps da raiz sobre o tip de release/v3.8.51: npm install reconciliou o lock sem diff (11/11 pacotes na versão alvo), typecheck:core e typecheck:noimplicit:core limpos, lint exit 0, e 149/149 testes nos 11 arquivos que exercitam zod diretamente. As falhas de CI do PR foram discriminadas como estado da base, não do bump. Obrigado, Dependabot.
2026-09-03 01:19:49 -03:00
dependabot[bot]
d6770bda0c deps: bump @xmldom/xmldom from 0.9.10 to 0.9.12 (#12513)
Validado em lote numa worktree combinada com os 7 bumps da raiz sobre o tip de release/v3.8.51: npm install reconciliou o lock sem diff (11/11 pacotes na versão alvo), typecheck:core e typecheck:noimplicit:core limpos, lint exit 0, e 149/149 testes nos 11 arquivos que exercitam zod diretamente. As falhas de CI do PR foram discriminadas como estado da base, não do bump. Obrigado, Dependabot.
2026-09-03 01:19:31 -03:00
dependabot[bot]
fcddea7898 deps: bump @humanfs/node from 0.16.7 to 0.16.8 (#12515)
Validado em lote numa worktree combinada com os 7 bumps da raiz sobre o tip de release/v3.8.51: npm install reconciliou o lock sem diff (11/11 pacotes na versão alvo), typecheck:core e typecheck:noimplicit:core limpos, lint exit 0, e 149/149 testes nos 11 arquivos que exercitam zod diretamente. As falhas de CI do PR foram discriminadas como estado da base, não do bump. Obrigado, Dependabot.
2026-09-03 01:18:59 -03:00
Diego Rodrigues de Sa e Souza
1a0375fba3 chore(quality): tighten the CodeQL ratchet baseline from 11 to 6 (#12530)
Co-authored-by: Markus Hartung <diegosouzapw@users.noreply.github.com>
2026-09-02 21:20:40 -03:00
Diego Rodrigues de Sa e Souza
a628d28898 feat(dashboard): orchestration canvas fase 2 — repeat action + A2A memory hits (2.6/2.7) (#12508)
* feat(api): conductor task creation route (repeat support)

* feat(a2a): record memoryHits consulted per task (observability, 2.7)

* feat(dashboard): repeat action in orchestration drawer (2.6 — repeat only)

* fix(dashboard): require every field each repeat contract needs before enabling the action

* feat(dashboard): memory-used drawer section + fase2 i18n/changelog (2.7)

* fix(dashboard): validate memoryHits shape before rendering + locale wording fixes

* fix(dashboard,a2a): stop memoryHits leaking into repeats, harden drawer guard + status clamp

Final whole-branch review fix wave for the Orchestration Canvas Fase 2 PR-C.

- a2a: `createTask` stores a COPY of `input.metadata` instead of aliasing it, so the
  observability `memoryHits` written by `executeA2ATaskWithState` no longer leak into
  `task.input.metadata`, into the persisted `a2a_tasks.input_json`, or into the drawer's
  "Repeat" body (a repeated task was born carrying the previous run's memory snippets,
  even with the `OMNIROUTE_A2A_MEMORY_HITS=0` kill-switch on).
- dashboard: `repeatReqFor` strips `memoryHits` from the a2a repeat metadata, so tasks
  persisted before the copy-fix do not propagate them either.
- dashboard: the "Memory used" section now requires all four rendered fields (id, key,
  type, snippet) to be strings — `{ id: "x", key: { a: 1 } }` used to throw "Objects are
  not valid as a React child" and take the whole drawer down.
- dashboard: an `/a2a` action answered with a JSON-RPC error under HTTP 200 is reported as
  a failure (`RPC <code>`, code only — never the upstream message) instead of a success
  toast; the secured-deployment rejection keeps surfacing the sanitized `HTTP 400`.
- api: the conductor task-creation route clamps a hub status outside 400-599 to 502, so an
  out-of-range status can no longer turn a hub refusal into a `RangeError`.
- dashboard: the History tab's `onActionDone` keeps the drawer mounted (and refreshes the
  range) instead of closing it, so the repeat/cancel confirmation is actually visible.
- a2a: documented the recall owner-id limitation — `task.owner` is a SHA-256 key prefix
  while memory rows are keyed by the DB api-key id, and no hash-to-id lookup exists today,
  so recall only resolves under the keyless posture.

* refactor(dashboard): split drawer repeat helpers and test file under the size/complexity gates

---------

Co-authored-by: Markus Hartung <diegosouzapw@users.noreply.github.com>
2026-09-02 20:37:02 -03:00
Diego Rodrigues de Sa e Souza
86e83d4138 fix(video): re-anchor transcript log-redaction so PII/credential maskers can't reopen the leak (#12150 P1 follow-up) (#12503)
* fix(video): re-anchor log-redaction fullText from the finished guardrail payload so PII/credential maskers can't reopen the transcript leak

* docs(video): clarify P1 transcript-retention scope and re-anchor; list P2 surfaces (#12430)
2026-09-02 18:32:51 -03:00
dependabot[bot]
7448590b81 deps: bump @xmldom/xmldom (#12500)
Bump do override `@xmldom/xmldom` 0.9.10 → 0.9.12 no workspace `electron/` (grupo npm_and_yarn). Escopo isolado: só `electron/package.json` + lock, sem interseção com o install da raiz; a redução de ~199 linhas no lock é dedupe da própria resolução. Obrigado, Dependabot.
2026-09-02 18:23:34 -03:00
dependabot[bot]
51cd154da1 build(deps): bump github/codeql-action from 4.37.8 to 4.37.9 (#12349)
Bump de action pinada por SHA para codeql-action v4.37.9. Validado: os pins de `init` e `analyze` (#12345/#12346) apontam para o mesmo commit `cdf488f595d80d6e07e03d4674febd5ab45fa938`, consistente com a tag v4.37.9; nenhum código de aplicação afetado. Obrigado, Dependabot.
2026-09-02 18:22:52 -03:00
dependabot[bot]
b1fd07df28 build(deps): bump github/codeql-action/analyze from 4.37.8 to 4.37.9 (#12346)
Bump de action pinada por SHA para codeql-action v4.37.9. Validado: os pins de `init` e `analyze` (#12345/#12346) apontam para o mesmo commit `cdf488f595d80d6e07e03d4674febd5ab45fa938`, consistente com a tag v4.37.9; nenhum código de aplicação afetado. Obrigado, Dependabot.
2026-09-02 18:22:47 -03:00
dependabot[bot]
a334c9b0ce build(deps): bump github/codeql-action/init from 4.37.8 to 4.37.9 (#12345)
Bump de action pinada por SHA para codeql-action v4.37.9. Validado: os pins de `init` e `analyze` (#12345/#12346) apontam para o mesmo commit `cdf488f595d80d6e07e03d4674febd5ab45fa938`, consistente com a tag v4.37.9; nenhum código de aplicação afetado. Obrigado, Dependabot.
2026-09-02 18:22:42 -03:00
Diego Rodrigues de Sa e Souza
143c087d47 feat(i18n): locale-expansion tooling — add-locale orchestrator, aliases, translation-ratio gate; retire duplicate 'in' locale (#12496)
Locale aliases (uk, fil/tl, zh-hant, legacy in→id) with fixed browser/CLI/cookie detection; config/i18n.json shipped in the npm package; duplicate 'in' locale retired (42 honest locales); real-translation ratio gate with ratchet baseline (advisory); add-locale orchestrator + shared libs (batched translation backend, config-generated language bars, run-translation --adopt/--targets-only, scaffold helpers); tests derive the locale count from config with surface parity guards; docs and every public locale-count claim aligned.
2026-09-02 17:45:50 -03:00
2565 changed files with 470171 additions and 42353 deletions

View File

@@ -55,10 +55,11 @@ INITIAL_PASSWORD=CHANGEME
# loader (bin/cli/plugins.mjs) at a package tree — this one drives the server-side scanner.
# OMNIROUTE_PLUGINS_DIR=/opt/omniroute/plugins
# Escape hatch for the test-context DATA_DIR guard (#10428). A test run that never
# chose a DATA_DIR is redirected to a throwaway temp dir so it cannot open the
# operator's real database. Set to 1 only for a deliberate run against the real
# DATA_DIR — never for CI. Used by: src/lib/dataPaths.ts
# Escape hatch for the test/eval DATA_DIR guard (#10428). A test or node eval/print
# probe (-e/--eval/-p/--print, including --eval=/--print=) that never chose a DATA_DIR
# is redirected to a throwaway temp dir so it cannot open the operator's real database.
# Set to 1 only for a deliberate run against the real DATA_DIR — never for CI.
# Used by: src/lib/dataPaths.ts
# OMNIROUTE_ALLOW_DEFAULT_DATA_DIR=1
# Build provenance (#10427). OMNIROUTE_BUILD_SHA lets a container inject the artifact's git
@@ -96,9 +97,11 @@ STORAGE_ENCRYPTION_KEY=
# Default: v1 | Increment when rotating STORAGE_ENCRYPTION_KEY.
STORAGE_ENCRYPTION_KEY_VERSION=v1
# Automatic SQLite backup on startup.
# Used by: src/lib/db/backup.ts — creates a timestamped backup before migrations.
# Default: false (backups enabled) | Set true to skip backup on every restart.
# Routine/pre-write SQLite backups.
# Used by: src/lib/db/backup.ts. Set true only when those backups are managed externally.
# This never disables the migration runner's mandatory, content-addressed safety snapshot
# or its mass-migration guard for an existing persistent database.
# Default: false (routine backups enabled).
DISABLE_SQLITE_AUTO_BACKUP=false
# ── Redis (Rate Limiting) ──
@@ -415,7 +418,9 @@ ALLOW_API_KEY_REVEAL=false
# provider dispatch. Heavyweight capacity is reserved before parsing; excess work
# receives 503 + Retry-After instead of overlapping until the process OOMs.
# Used by: src/shared/middleware/chatBodyAdmission.ts
# Actual bodies at or above this size require a heavyweight lease. Default 262144 (256 KB).
# Actual bodies at or above this size take the heavyweight lease (BYTE path,
# including POST /v1/responses) and use the same #10437 healthy-headroom escape
# as structure-heavy. Default 262144 (256 KB).
# OMNIROUTE_CHAT_LARGE_BODY_BYTES=262144
# Actual-byte hard cap enforced during bounded ingestion. Default 52428800 (50 MB).
# OMNIROUTE_CHAT_HARD_MAX_BODY_BYTES=52428800
@@ -423,6 +428,11 @@ ALLOW_API_KEY_REVEAL=false
# left unset, heavyweight admission is gated by OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES below
# instead (an auto-derived byte budget), fixing coding-agent fan-out (multiple
# subagents/CLIs) collapsing to an effective concurrency of ~1 and 503ing.
# Two overlapping ~750k-token /v1/responses abort ~12 Gi heaps (#7849) — a
# memory-budget warning, not a hard product max of 2. A healthy heap may admit
# more via HEALTHY_HEADROOM. Tens of long SSE clients (40-50) is heap +
# OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES / #10110. Multiply heaps with N independent
# DATA_DIRs (#11024); never replicas>1 on one SQLite.
# OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT=1
# Override for the auto-derived ingest byte budget (#503-fanout). Default: 25% of the
# process's effective memory ceiling (V8 heap limit, or the tighter cgroup/container
@@ -430,13 +440,15 @@ ALLOW_API_KEY_REVEAL=false
# 2 GiB; explicit overrides are clamped to the same safe range. Read
# chatAdmission.maxInflightBytes/budgetSource at /api/monitoring/health before overriding.
# OMNIROUTE_CHAT_MAX_INFLIGHT_BYTES=134217728
# Heap-pressure shed ratio (heapUsed/heap_size_limit) for the structural admission gate
# (#10183, #10268): a second concurrent heavyweight request past OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT
# is only shed with a retryable 503 when the heap is ALSO under this much pressure — on a
# healthy heap it is admitted instead. Range (0, 1]. Default 0.75.
# Heap-pressure shed ratio (heapUsed/heap_size_limit) for BYTE and STRUCTURE
# heavyweight admission (#10183, #10268, #10437): a concurrent heavyweight request
# past OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT is only shed with a retryable 503 when the
# heap is ALSO under this much pressure — on a healthy heap it is admitted via
# healthy-headroom instead. Range (0, 1]. Default 0.75.
# OMNIROUTE_CHAT_ADMISSION_HEAP_SHED_RATIO=0.75
# Bounded extra capacity for the healthy-heap fast path above OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT
# (#10437): once this many concurrent leases are active through the healthy-heap bypass,
# (#10437, BYTE + STRUCTURE, including bodies >= OMNIROUTE_CHAT_LARGE_BODY_BYTES):
# once this many concurrent leases are active through the healthy-heap bypass,
# further busy requests fall through to the same bounded-wait/shed path used under real heap
# pressure. 0 disables the bypass entirely. Default 1.
# OMNIROUTE_CHAT_ADMISSION_HEALTHY_HEADROOM=1
@@ -869,6 +881,12 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# or <= 0 falls back to the default.
# OMNIROUTE_A2A_HISTORY_RETENTION_DAYS=30
# Kill-switch for the A2A memory-hits observability feature (Orchestration Canvas
# Fase 2). Set to "0" to skip the memory recall lookup entirely; any other value
# (including unset) keeps it enabled.
# Used by: src/lib/a2a/taskExecution.ts (collectMemoryHits).
# OMNIROUTE_A2A_MEMORY_HITS=1
# Enable the offline/local Issue Agent recorded-triage endpoint.
# Used by: src/app/api/issue-agent/runs/route.ts. Default: disabled.
# OMNIROUTE_ISSUE_AGENT_ENABLED=false
@@ -1284,7 +1302,7 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
# Used by: open-sse/executors/base.ts — buildHeaders() dynamic lookup.
# Update these when providers release new CLI versions to avoid blocks.
CLAUDE_USER_AGENT="claude-cli/2.1.219 (external, cli)"
CLAUDE_USER_AGENT="claude-cli/2.1.258 (external, cli)"
# Disable the deterministic tool-name cloak applied on both Anthropic-bound paths
# (executors/base.ts native OAuth + executors/cliproxyapi.ts CLIProxyAPI) —
@@ -1314,6 +1332,16 @@ 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.144.1
#
# Override the advertised Claude Code client version independently of
# CLAUDE_USER_AGENT. Anthropic gates some models (Fable 5.1) on this
# value; a UA-only override is not enough (#12417). Used by:
# src/shared/constants/claudeCodeClient.ts.
# CLAUDE_CODE_CLIENT_VERSION=2.1.259
#
# Override the advertised GitHub Copilot CLI version independently of
# GITHUB_USER_AGENT. Used by: open-sse/config/providerHeaderProfiles.ts.
# GITHUB_COPILOT_CLI_VERSION=1.0.82
# Kill-switch to strip non-standard `codex.*` SSE events (e.g. codex.rate_limits)
# from the Codex Responses stream. These frames break the OpenAI SDK's
@@ -1518,6 +1546,15 @@ CURSOR_USER_AGENT="Cursor/3.4"
# request into the browser-backed path.
# OMNIROUTE_BROWSER_POOL=on
# WEB_COOKIE_USE_BROWSER=0
# Obscura (https://github.com/h4ckf0r0day/obscura) is the primary headless
# engine: a lightweight CDP server the pool and cloudflare-playground connect
# to before falling back to Chromium. Unset OBSCURA_BIN to auto-detect from
# PATH; set OBSCURA_CDP_ENDPOINT to reuse an already-running Obscura instead
# of spawning one; set OBSCURA_PORT to pin the spawned serve port.
# Used by: open-sse/services/obscura.ts
# OBSCURA_BIN=
# OBSCURA_CDP_ENDPOINT=
# OBSCURA_PORT=
# ── Kimi Web (international kimi.ai Connect-RPC) ──
# Used by: open-sse/executors/kimi-web.ts. Override the base/chat URLs only if
@@ -1896,6 +1933,11 @@ APP_LOG_TO_FILE=true
# Default: true
# MODEL_CATALOG_INCLUDE_NAMES=true
# Cold-path wait bound for a coalesced GET /v1/models catalog rebuild (#12627).
# Used by: src/app/api/v1/models/catalogCache.ts
# Default: 8000 (8 seconds). On timeout, a last-good 200 is served when available.
# CATALOG_BUILD_TIMEOUT_MS=8000
# ── NanoBanana (Image Generation) ──
# Polling config for async image generation jobs.
# Used by: open-sse/handlers/imageGeneration.ts

View File

@@ -502,6 +502,11 @@ jobs:
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: node scripts/i18n/check-ui-keys-coverage.mjs --threshold=65
# Real-translation ratchet: a leaf copied verbatim from en.json passes key
# parity above but is still English to the user (es shipped 55% English).
# Advisory in PR-0; flipped to blocking once the backlog is retranslated (PR-4).
- name: i18n real-translation ratio (advisory)
run: node scripts/i18n/check-translation-ratio.mjs --warn
# #8463: a rewritten English value used to leave its 39 translations behind
# silently (googleOAuthWarning shipped wrong copy in 39 locales for months).
# Key parity above cannot see it — a stale translation counts as covered.

View File

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

View File

@@ -535,7 +535,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.8
uses: github/codeql-action/upload-sarif@v4.37.9
with:
sarif_file: trivy-results.sarif
category: trivy-image

View File

@@ -573,3 +573,92 @@ jobs:
fi
npm publish --provenance --access public --ignore-scripts
echo "✅ Published ${PKG_NAME}@${PKG_VERSION}"
publish-opencode-plugin-v2:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # npm provenance
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
fetch-depth: 0
# Full history needed for auto-bump: git diff against previous release tag
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
registry-url: https://registry.npmjs.org
- name: Auto-bump plugin-v2 version if plugin-v2 changed since last release
id: bump
working-directory: "@omniroute/opencode-plugin-v2"
env:
CURRENT_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
PKG_VERSION=$(node -p "require('./package.json').version")
PKG_NAME=$(node -p "require('./package.json').name")
# 1) Skip if current version is not yet published (no bump needed)
PUBLISHED="$(npm view "${PKG_NAME}@${PKG_VERSION}" version 2>/dev/null || true)"
if [ "$PUBLISHED" != "$PKG_VERSION" ]; then
echo "✅ ${PKG_NAME}@${PKG_VERSION} is new — no bump needed."
echo "bumped=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# 2) Find the previous release tag (exclude the current one)
PREV_TAG=$(git tag -l 'v*' --sort=-version:refname \
| grep -v "^${CURRENT_TAG}$" | head -1 || echo "")
if [ -z "$PREV_TAG" ]; then
echo "No previous tag to compare — skipping bump."
echo "bumped=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# 3) Check if plugin-v2 dir actually changed since that tag
if git diff --quiet "$PREV_TAG" -- "@omniroute/opencode-plugin-v2/"; then
echo "⏭️ No plugin-v2 changes since $PREV_TAG — nothing to publish."
echo "bumped=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# 4) Auto-bump patch version
npm version patch --no-git-tag-version --allow-same-version
NEW_VERSION=$(node -p "require('./package.json').version")
echo "bumped=true" >> "$GITHUB_OUTPUT"
echo "📦 Auto-bumped ${PKG_NAME} from ${PKG_VERSION} to ${NEW_VERSION}"
- name: Install plugin-v2 dependencies
working-directory: "@omniroute/opencode-plugin-v2"
run: npm install --no-audit --no-fund
- name: Build plugin-v2
working-directory: "@omniroute/opencode-plugin-v2"
run: npm run clean && npm run build
- name: Test plugin-v2
working-directory: "@omniroute/opencode-plugin-v2"
run: npm test
- name: Publish @omniroute/opencode-plugin-v2 to npm
working-directory: "@omniroute/opencode-plugin-v2"
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
PKG_VERSION=$(node -p "require('./package.json').version")
PKG_NAME=$(node -p "require('./package.json').name")
# Same hardened skip-check as the main job (no --silent flag).
PUBLISHED="$(npm view "${PKG_NAME}@${PKG_VERSION}" version 2>/dev/null || true)"
if [ "$PUBLISHED" = "$PKG_VERSION" ]; then
echo "⚠️ ${PKG_NAME}@${PKG_VERSION} is already published on npm — skipping."
exit 0
fi
npm publish --provenance --access public --ignore-scripts
echo "✅ Published ${PKG_NAME}@${PKG_VERSION}"

View File

@@ -5,10 +5,12 @@ on:
branches: [main, "release/**"]
paths:
- "@omniroute/opencode-plugin/**"
- "@omniroute/opencode-plugin-v2/**"
pull_request:
branches: [main, "release/**"]
paths:
- "@omniroute/opencode-plugin/**"
- "@omniroute/opencode-plugin-v2/**"
types: [opened, synchronize, reopened, ready_for_review]
workflow_dispatch:
@@ -44,10 +46,33 @@ jobs:
- run: npm run build
- run: npm test
test-v2:
name: Test v2 (Node ${{ matrix.node }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ["22", "24"]
defaults:
run:
working-directory: "@omniroute/opencode-plugin-v2"
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}
cache: npm
cache-dependency-path: "@omniroute/opencode-plugin-v2/package-lock.json"
- run: npm ci --no-audit --no-fund
- run: npm run build
- run: npm test
build:
name: Build
runs-on: ubuntu-latest
needs: test
needs: [test, test-v2]
steps:
- uses: actions/checkout@v7
with:
@@ -55,12 +80,11 @@ jobs:
- uses: actions/setup-node@v7
with:
node-version: "22"
cache: npm
cache-dependency-path: "@omniroute/opencode-plugin/package-lock.json"
- run: npm install --no-audit --no-fund
- run: npm run build
- name: Build plugin-v2 artifact
working-directory: "@omniroute/opencode-plugin-v2"
run: npm ci --no-audit --no-fund && npm run build
- uses: actions/upload-artifact@v7
with:
name: opencode-plugin-dist
path: "@omniroute/opencode-plugin/dist"
name: opencode-plugin-v2-dist
path: "@omniroute/opencode-plugin-v2/dist"
retention-days: 7

View File

@@ -7,6 +7,7 @@ fi
# Cheap, deterministic local gates (re-enabled). Slower checks (i18n drift,
# openapi coverage/security-tiers, env-doc sync) run in CI to keep commits fast.
sh scripts/check/check-git-identity.sh
npx lint-staged
node scripts/check/check-docs-sync.mjs
npm run check:any-budget:t11

6857
.i18n-state.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
node_modules
dist
*.log
.DS_Store

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 OmniRoute contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,107 @@
# @omniroute/opencode-plugin-v2
OpenCode v2 plugin (`define({ id, setup })`, Promise API) that publishes the live OmniRoute catalog — models from `/v1/models`, combos from `/api/combos` (least-common-denominator join), auto-combos from `/api/combos/auto`, enrichment (names + pricing), and usable-provider filtering — into the v2 `catalog.transform`, with `key` + `env` auth via `integration.transform`.
Companion to `@omniroute/opencode-plugin` (OpenCode v1, same repo). The two packages are independent: this one carries its own catalog-mapping logic and the v1 plugin is left untouched.
## Install
```sh
npm install @omniroute/opencode-plugin-v2
```
`opencode.json`:
```json
{
"plugins": [
{
"package": "@omniroute/opencode-plugin-v2",
"options": {
"providerId": "omniroute",
"baseURL": "http://localhost:20128"
}
}
]
}
```
## Credentials
The plugin needs a gateway key to read the catalog, and looks for one in this
order:
1. **The credential you connected in OpenCode.** The plugin registers an
integration, so `opencode auth` (or the Connect action in the model picker)
can store a key for it. Nothing is written to `opencode.json` — this is the
recommended route.
2. **`apiKey` in the plugin options**, when you want a per-project override.
Remember that this puts the key in a config file you may be committing.
3. **`OMNIROUTE_API_KEY` in the environment.**
If none of the three yields a key, the catalog is empty and the plugin says so
once at startup rather than leaving you with a silent empty model list.
### The management token is a different key
Combos, provider health and enrichment (display names, pricing, free-tier
budgets) come from the gateway's `/api/*` endpoints, which most deployments
gate behind a **management** token rather than the inference key. Set it
explicitly:
```json
"options": {
"baseURL": "http://localhost:20128",
"managementReadToken": "<management read token>"
}
```
Left unset, `managementReadToken` falls back to `apiKey` for backwards
compatibility. When a gateway rejects that fallback, the catalog still
publishes — but with raw model ids instead of display names, no canonical
alias dedupe, no pricing and no combos. The plugin warns once per endpoint
when this happens, naming the endpoint and the consequence, so the degraded
catalog is never a mystery.
## Options
| Key | Default | Notes |
| -------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `providerId` | `"omniroute"` | Provider id and integration id; models publish under `<providerId>/…` |
| `baseURL` | required | OmniRoute gateway root (no `/v1` suffix needed) |
| `apiKey` | connected credential, then `OMNIROUTE_API_KEY` | Chat key for `/v1/*` — see [Credentials](#credentials) |
| `managementReadToken` | falls back to `apiKey` | Management key for `/api/*` (combos, providers, enrichment) — usually **not** the same key |
| `displayName` | `"OmniRoute"` | Provider display name |
| `timeoutMs` | `10000` | Per-endpoint fetch timeout (auto-combos use 5s) |
| `modelCacheTtlMs` | `300000` | Catalog cache TTL; disk snapshot warms cold starts |
| `timeouts` | per-endpoint override | `{ models, combos, autoCombos, enrichment }` in ms; falls back to `timeoutMs` |
| `enrichment` | `true` | Fetch names + pricing (`/api/pricing*`, `/api/free-tier/summary`) |
| `providerTag` | `true` | Prefix a display name with the upstream provider it routes to |
| `geminiSanitization` | `true` | Strip `$schema`/`additionalProperties` from tool schemas sent to Gemini models (`$ref` tools are forwarded untouched) |
| `usableOnly` | `false` | Filter to healthy provisioned providers (`/api/providers`) |
| `visibleModels` / `hiddenModels` | `[]` | Exact-or-suffix allowlists, deny wins |
| `apiFormat.allowAnthropic` | `false` | Route allowlisted ids to the Anthropic API block |
| `apiFormat.anthropicModels` | `[]` | Full model ids routed to Anthropic |
| `apiFormat.anthropicPrefixes` | v1 defaults | Deprecated, warns once — prefer `anthropicModels` |
| `logLevel` / `startupDebug` | `warn` / `false` | Logger verbosity |
## Tool calling on Gemini models
Gemini answers `400 INVALID_ARGUMENT` — for the whole request, not just the
offending tool — when a tool declaration carries `$schema` or
`additionalProperties`. Anything that emits standard JSON Schema therefore
breaks tool calling as soon as the chain routes to Gemini.
The plugin strips those keywords from tool schemas bound for a Gemini model of
this provider, and leaves every other request untouched. A tool carrying a
`$ref` is forwarded untouched instead of stripped: removing the reference
would widen the schema to "accept anything". Set
`"geminiSanitization": false` to turn it off.
## Migrating from the v1 plugin
The v2 plugin publishes provider id `X` bare. The v1 plugin published `opencode-X` (native-adapter gate). Sessions pinned to `opencode-X/...` must re-select the model under `X/...`.
## License
MIT

View File

@@ -0,0 +1,9 @@
# Release process — `@omniroute/opencode-plugin-v2`
## Publishing
One package, no ordering: bump `@omniroute/opencode-plugin-v2` (`npm version patch`) and publish it. The plugin carries its own copy of the mapping logic, so a release never has to be coordinated with another package.
## Migration note (`opencode-X` → `X`)
The v1 plugin published provider id `opencode-X` (native-adapter gate). The v2 plugin publishes `X` bare. Sessions pinned to `opencode-X/...` resolve `ModelUnavailableError` — users must re-select the model under `X/...`.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,67 @@
{
"name": "@omniroute/opencode-plugin-v2",
"version": "0.1.0",
"description": "OmniRoute OpenCode plugin (v2 Promise API): catalog transform with models, combos, enrichment, and naming.",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"scripts": {
"build": "tsup",
"clean": "rm -rf dist",
"test": "node --import tsx/esm --test tests/*.test.ts",
"prepublishOnly": "npm run clean && npm run build && npm test"
},
"dependencies": {
"zod": "^4.4.3"
},
"devDependencies": {
"@opencode-ai/plugin": "1.18.29",
"@types/node": "^22.19.19",
"tsup": "^8.5.1",
"tsx": "^4.22.3",
"typescript": "^5.9.3"
},
"engines": {
"node": ">=22.22.3"
},
"license": "MIT",
"author": "OmniRoute contributors",
"repository": {
"type": "git",
"url": "https://github.com/diegosouzapw/OmniRoute.git",
"directory": "@omniroute/opencode-plugin-v2"
},
"homepage": "https://github.com/diegosouzapw/OmniRoute/tree/main/%40omniroute/opencode-plugin-v2#readme",
"bugs": {
"url": "https://github.com/diegosouzapw/OmniRoute/issues"
},
"keywords": [
"omniroute",
"opencode",
"opencode-plugin",
"opencode-v2",
"ai-sdk",
"openai-compatible",
"provider",
"catalog",
"combos",
"gemini"
],
"publishConfig": {
"access": "public"
},
"peerDependencies": {
"@opencode-ai/plugin": ">=1.18.29 <2"
}
}

View File

@@ -0,0 +1,211 @@
import { createHash } from "node:crypto";
import { homedir } from "node:os";
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import type {
OmniRouteEnrichmentEntry,
OmniRouteEnrichmentMap,
OmniRouteProviderConnection,
OmniRouteRawAutoCombo,
OmniRouteRawCombo,
OmniRouteRawModelEntry,
} from "./shared/index.js";
export const DEFAULT_MODEL_CACHE_TTL_MS = 300_000 as const;
/**
* Breather after a refresh whose models fetch came back empty (gateway down
* or refusing). Transforms inside the window serve last-known-good without
* re-firing the fetch suite. Short on purpose: it only guards the
* pathological case, normal TTL expiry still refetches every window.
*/
export const UNREACHABLE_COOLDOWN_MS = 15_000 as const;
export interface CatalogSnapshot {
models: OmniRouteRawModelEntry[];
combos: OmniRouteRawCombo[];
autoCombos: OmniRouteRawAutoCombo[];
providers?: OmniRouteProviderConnection[];
enrichment?: OmniRouteEnrichmentMap;
fetchedAt: number;
}
export const SNAPSHOT_FORMAT_VERSION = 2 as const;
/**
* A raw snapshot entry is stale when it cannot be mapped to a publishable
* model: no string `id` (unroutable) or a pre-mapped `api` block without a
* valid `npm` package (the runner would reject it as `Unsupported package`).
* Plain `/v1/models` entries carry no `api` block -- it is synthesized at
* publish time -- so only a present-but-invalid block drops the entry.
*/
export function isStaleSnapshotModel(entry: unknown): boolean {
if (!entry || typeof entry !== "object") return true;
const id = (entry as { id?: unknown }).id;
if (typeof id !== "string" || id.length === 0) return true;
const api = (entry as { api?: unknown }).api;
if (api === undefined) return false;
if (!api || typeof api !== "object") return true;
const npm = (api as { npm?: unknown }).npm;
return typeof npm !== "string" || npm.length === 0;
}
interface DiskSnapshotV2 {
v: 2;
identityFingerprint: string;
models: OmniRouteRawModelEntry[];
combos: OmniRouteRawCombo[];
autoCombos?: OmniRouteRawAutoCombo[];
providers?: OmniRouteProviderConnection[];
/**
* Display names, provider labels, pricing and free-tier budgets, as
* `[key, entry]` pairs (a Map does not survive JSON). Persisted because a
* cold start otherwise publishes raw model ids until the first refresh
* completes — which is the moment the snapshot exists to cover.
*/
enrichment?: [string, OmniRouteEnrichmentEntry][];
writtenAt: number;
}
/**
* Ceiling on what one snapshot may occupy on disk. A gateway with thousands of
* models makes this file grow without bound otherwise; past the cap the
* enrichment overlay is dropped first (it is rebuilt on the next refresh)
* rather than losing the catalog itself.
*/
const MAX_SNAPSHOT_BYTES = 32 * 1024 * 1024;
function trimTrailingSlashes(value: string): string {
let i = value.length;
while (i > 0 && value.charCodeAt(i - 1) === 0x2f) i -= 1;
return i === value.length ? value : value.slice(0, i);
}
function normalizeBaseURL(baseURL: string): string {
try {
const parsed = new URL(baseURL);
parsed.hash = "";
parsed.pathname = trimTrailingSlashes(parsed.pathname) || "/";
return parsed.toString();
} catch {
return trimTrailingSlashes(baseURL);
}
}
export function memoryCacheKey(baseURL: string, credentialId: string): string {
return `${baseURL}::${createHash("sha256").update(credentialId).digest("hex")}`;
}
export function snapshotIdentityFingerprint(
baseURL: string,
apiKey: string,
managementReadToken: string
): string {
return createHash("sha256")
.update(JSON.stringify([normalizeBaseURL(baseURL), apiKey, managementReadToken]))
.digest("hex");
}
export function diskSnapshotPath(providerId: string): string {
// OPENCODE_DATA_DIR is honoured verbatim when set: whoever controls the
// process environment already chooses where the process writes, so
// resolving it further would only surprise. The providerId segment stays
// bounded by the options schema (letters, digits, '.', '_' and '-'; never
// "." or ".."), keeping the file inside <dir>/plugins/.
const dir = process.env.OPENCODE_DATA_DIR ?? join(homedir(), ".local", "share", "opencode");
return join(dir, "plugins", `omniroute-${providerId}.json`);
}
export async function readDiskSnapshot(
providerId: string,
identityFingerprint: string,
logger?: { warn: (message: string) => void }
): Promise<CatalogSnapshot | undefined> {
try {
const body = await readFile(diskSnapshotPath(providerId), "utf8");
const parsed = JSON.parse(body) as Partial<DiskSnapshotV2>;
if (
!parsed ||
typeof parsed.v !== "number" ||
parsed.v < SNAPSHOT_FORMAT_VERSION ||
typeof parsed.identityFingerprint !== "string" ||
parsed.identityFingerprint !== identityFingerprint
) {
return undefined;
}
if (
!Array.isArray(parsed.models) ||
parsed.models.length === 0 ||
!Array.isArray(parsed.combos)
) {
return undefined;
}
const stale = (parsed.models as unknown[]).filter(isStaleSnapshotModel).length;
const models = (parsed.models as OmniRouteRawModelEntry[]).filter(
(entry) => !isStaleSnapshotModel(entry)
);
if (stale > 0) {
logger?.warn(`[omniroute-v2] dropping ${stale} stale snapshot entries without api block`);
}
if (models.length === 0) return undefined;
return {
models,
combos: parsed.combos as OmniRouteRawCombo[],
autoCombos: Array.isArray(parsed.autoCombos)
? (parsed.autoCombos as OmniRouteRawAutoCombo[])
: [],
providers: Array.isArray(parsed.providers)
? (parsed.providers as OmniRouteProviderConnection[])
: [],
// A snapshot written before this field existed, or one whose overlay was
// dropped for size, simply starts unenriched and recovers on the first
// refresh — the same state as before it was persisted at all.
enrichment: Array.isArray(parsed.enrichment)
? new Map(parsed.enrichment as [string, OmniRouteEnrichmentEntry][])
: undefined,
fetchedAt: typeof parsed.writtenAt === "number" ? parsed.writtenAt : Date.now(),
};
} catch {
return undefined;
}
}
export async function writeDiskSnapshot(
providerId: string,
snapshot: CatalogSnapshot,
identityFingerprint: string
): Promise<void> {
try {
if (snapshot.models.length === 0) return;
const file = diskSnapshotPath(providerId);
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
const envelope: DiskSnapshotV2 = {
v: 2,
identityFingerprint,
models: snapshot.models,
combos: snapshot.combos,
autoCombos: snapshot.autoCombos,
providers: snapshot.providers ?? [],
enrichment: snapshot.enrichment ? [...snapshot.enrichment.entries()] : undefined,
writtenAt: Date.now(),
};
let payload = JSON.stringify(envelope);
if (payload.length > MAX_SNAPSHOT_BYTES && envelope.enrichment !== undefined) {
delete envelope.enrichment;
payload = JSON.stringify(envelope);
}
if (payload.length > MAX_SNAPSHOT_BYTES) return;
await writeFile(file, payload, { encoding: "utf8", mode: 0o600 });
} catch {
// Best-effort: callers already hold the in-memory entry.
}
}
export async function clearDiskSnapshot(providerId: string): Promise<boolean> {
try {
await unlink(diskSnapshotPath(providerId));
return true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,798 @@
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 ApiFormatV2,
type LogLevel,
type Logger,
type OmniRouteAutoCombosFetcher,
type OmniRouteCombosFetcher,
type OmniRouteEnrichmentFetcher,
type OmniRouteEnrichmentMap,
type OmniRouteModelsFetcher,
type OmniRouteProviderConnection,
type OmniRouteProvidersFetcher,
type OmniRouteRawAutoCombo,
type OmniRouteRawCombo,
type OmniRouteRawModelEntry,
applyEnrichment,
buildCanonicalToAliasMap,
canonicalDedupSet,
createLogger,
defaultOmniRouteEnrichmentFetcher,
defaultOmniRouteProvidersFetcher,
ensureV1Suffix,
isUsableCombo,
isUsableRawModelId,
lookupEnrichment,
mapAutoComboToModelV2,
mapComboToModelV2,
mapRawModelToModelV2,
usableProviderAliasSet,
} from "./shared/index.js";
export type ModelsFetcher = OmniRouteModelsFetcher;
export type CombosFetcher = OmniRouteCombosFetcher;
export type AutoCombosFetcher = OmniRouteAutoCombosFetcher;
export type ProvidersFetcher = OmniRouteProvidersFetcher;
export type EnrichmentFetcher = OmniRouteEnrichmentFetcher;
export interface EndpointTimeouts {
models?: number;
combos?: number;
autoCombos?: number;
enrichment?: number;
}
export interface ResolvedOptions {
providerId: string;
baseURL: string;
apiKey: string;
managementReadToken?: string;
timeoutMs: number;
timeouts?: EndpointTimeouts;
logger?: Logger;
logLevel?: LogLevel;
startupDebug?: boolean;
modelCacheTtlMs: number;
/** v1 parity: prefix the display name with the upstream provider label. */
providerTag?: boolean;
displayName?: string;
apiFormat?: ApiFormatV2;
visibleModels?: string[];
hiddenModels?: string[];
usableOnly: boolean;
enrichment?: OmniRouteEnrichmentMap | boolean;
/**
* Shared collision-warning dedupe set keyed `cacheKey::comboKey`. When
* omitted a fresh per-publish set is used. index.ts passes one setup-wide
* set so a repeated publish (stale replay + refresh) warns once per key.
*/
collisionWarned?: Set<string>;
}
export interface CatalogFetchers {
fetcher?: ModelsFetcher;
combosFetcher?: CombosFetcher;
autoCombosFetcher?: AutoCombosFetcher;
providersFetcher?: ProvidersFetcher;
enrichmentFetcher?: EnrichmentFetcher;
models?: ModelsFetcher;
combos?: CombosFetcher;
autoCombos?: AutoCombosFetcher;
providers?: ProvidersFetcher;
enrichment?: EnrichmentFetcher;
/**
* Called when a gateway source cannot be read. Without it this function
* degrades silently — the catalog publishes with raw ids and no combos and
* nothing says why, which is the failure the plugin path reports.
*/
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:";
/** Top-level `package` as the legacy contract expects it (`aisdk:<npm>`). */
export interface BinaryCompatPackage {
package: string;
}
/**
* 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.
*/
export interface BinaryCompatFields extends BinaryCompatPackage {
settings: Record<string, unknown>;
headers: Record<string, string>;
}
/** Legacy variants read their options from `settings`, not `headers`/`body`. */
export type BinaryCompatVariant = ModelV2Info["variants"][number] & {
settings: Record<string, unknown>;
};
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"] {
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)"
);
}
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 legacyCapabilitiesToInfoCapabilities(
caps: LegacyModelV2["capabilities"]
): ModelV2Info["capabilities"] {
const input: string[] = [];
if (caps.input.text) input.push("text");
if (caps.input.audio) input.push("audio");
if (caps.input.image) input.push("image");
if (caps.input.video) input.push("video");
if (caps.input.pdf) input.push("pdf");
const output: string[] = [];
if (caps.output.text) output.push("text");
if (caps.output.audio) output.push("audio");
if (caps.output.image) output.push("image");
if (caps.output.video) output.push("video");
if (caps.output.pdf) output.push("pdf");
return { tools: caps.toolcall, input, output };
}
function legacyToInfo(providerID: string, modelID: string, m: LegacyModelV2): ModelV2Info {
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 {
id: 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 } },
variants,
time: { released: Number.isNaN(parsed) ? 0 : parsed },
cost: legacyCostToInfoCost(m.cost),
status: m.status,
enabled: true,
limit: { ...m.limit },
};
}
export interface PublishCounts {
models: number;
combos: number;
autoCombos: number;
}
export interface ModelListFilter {
exact: Set<string>;
suffixes: Set<string>;
}
export function compileModelListFilter(list?: string[]): ModelListFilter | undefined {
if (!list || list.length === 0) return undefined;
const exact = new Set<string>();
const suffixes = new Set<string>();
for (const id of list) {
if (id.includes("/")) {
exact.add(id);
} else {
suffixes.add(id);
}
}
if (exact.size === 0 && suffixes.size === 0) return undefined;
return { exact, suffixes };
}
function matchesSuffix(id: string, suffixes: Set<string>): boolean {
if (suffixes.size === 0) return false;
const slash = id.indexOf("/");
const suffix = slash > 0 ? id.slice(slash + 1) : id;
return suffixes.has(suffix);
}
export function passesModelAllowlist(
id: string,
visible?: ModelListFilter,
hidden?: ModelListFilter
): boolean {
if (hidden) {
if (hidden.exact.has(id) || matchesSuffix(id, hidden.suffixes)) return false;
}
if (visible) {
if (!visible.exact.has(id) && !matchesSuffix(id, visible.suffixes)) return false;
}
return true;
}
export function passesComboAllowlist(combo: OmniRouteRawCombo, visible?: ModelListFilter): boolean {
if (!visible) return true;
const steps = Array.isArray(combo.models) ? combo.models : [];
if (steps.length === 0) return true;
let sawResolvableMember = false;
for (const step of steps) {
if (step?.kind === "combo-ref") continue;
const modelId = typeof step?.model === "string" ? step.model : "";
if (modelId.length === 0) continue;
sawResolvableMember = true;
if (visible.exact.has(modelId) || matchesSuffix(modelId, visible.suffixes)) return true;
}
if (!sawResolvableMember) return true;
return false;
}
/**
* 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).
*/
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
): 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);
}
}
function assignProviderFields(
target: ProviderV2Info,
source: { name: string; api: ProviderV2Info["api"]; integrationID: string },
contract: HostContract
): 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 });
}
/** A widened capability flag (`boolean | { field }`) read back as a plain flag. */
function isCapabilityEnabled(value: boolean | { field: string }): boolean {
return value !== false;
}
/**
* Combo steps reach us from the gateway with a shape the SDK types do not
* describe (`kind`, `comboName`, `model` appear per step kind). One reader
* keeps that single untyped boundary in one place instead of scattering casts.
*/
function readStepField(step: unknown, key: "kind" | "comboName" | "model"): unknown {
return (step as Record<string, unknown> | null | undefined)?.[key];
}
/**
* Resolve the display-name + pricing overlay. A caller may hand over a
* ready-made map (tests, pre-resolved overlays) or turn the fetch off; a
* failed fetch soft-fails to an empty map so the catalog still publishes,
* with mapper-default names and zeroed pricing rather than nothing at all.
*/
async function resolveEnrichmentOverlay(
opts: ResolvedOptions,
fetchers: CatalogFetchers | undefined,
log: Logger
): Promise<OmniRouteEnrichmentMap> {
if (opts.enrichment instanceof Map) return opts.enrichment;
if (opts.enrichment === false) return new Map();
const fetchEnrichment =
fetchers?.enrichmentFetcher ?? fetchers?.enrichment ?? defaultOmniRouteEnrichmentFetcher;
try {
return await fetchEnrichment(
opts.baseURL,
opts.managementReadToken ?? opts.apiKey,
opts.timeouts?.enrichment ?? opts.timeoutMs,
fetchers?.onSourceError
);
} catch (err) {
log.warn(
`[omniroute-v2] enrichment fetch failed, continuing without names/pricing: ${err instanceof Error ? err.message : String(err)}`
);
return new Map();
}
}
/**
* Resolve the provider aliases worth publishing when `usableOnly` is on.
* Gated on the flag, so the default configuration issues no request at all.
* The filter subtracts: a failed or empty connections fetch yields
* `undefined` and keeps the whole catalog, because only a prefix proven not
* provisioned may be dropped.
*/
async function resolveUsableAliases(
opts: ResolvedOptions,
providersFetcher: OmniRouteProvidersFetcher | undefined,
onSourceError: ((endpoint: string, reason: string) => void) | undefined,
enrichment: OmniRouteEnrichmentMap,
timeoutMs: number,
log: Logger
): Promise<ReturnType<typeof usableProviderAliasSet> | undefined> {
if (!opts.usableOnly) return undefined;
let rawConnections: OmniRouteProviderConnection[];
try {
const fetchProviders = providersFetcher ?? defaultOmniRouteProvidersFetcher;
rawConnections = await fetchProviders(
opts.baseURL,
opts.managementReadToken ?? opts.apiKey,
timeoutMs,
onSourceError
);
} catch (err) {
log.warn(
`[omniroute-v2] providers fetch failed, usableOnly filter disabled for this refresh: ${err instanceof Error ? err.message : String(err)}`
);
rawConnections = [];
}
return rawConnections.length > 0 ? usableProviderAliasSet(rawConnections, enrichment) : undefined;
}
/** Everything the combo publishing pass reads, passed as one value. */
interface PublishContext {
draft: CatalogDraft;
opts: ResolvedOptions;
log: Logger;
providerId: string;
hostContract: HostContract;
enrichment: OmniRouteEnrichmentMap;
rawModelById: Map<string, OmniRouteRawModelEntry>;
publishedKeys: Set<string>;
publishedModelIds: Map<string, string>;
visibleFilter: ReturnType<typeof compileModelListFilter>;
hiddenFilter: ReturnType<typeof compileModelListFilter>;
usable: ReturnType<typeof usableProviderAliasSet> | undefined;
canonicalToAlias: ReturnType<typeof buildCanonicalToAliasMap>;
combosFetcher: CatalogFetchers["combos"] | undefined;
combosTimeout: number;
/** Shared with the auto-combos pass: one collision warning per key, per run. */
warnedCombos: Set<string>;
cacheKey: string;
}
/**
* Fetch the gateway's combos and publish them, resolving nested combo-refs to
* a fixpoint first: a combo whose members are themselves combos only knows its
* lowest common denominator once those are known. Combos that never resolve
* are dropped rather than published with a fabricated capability set, and
* reported once.
*
* Returns the number published, or `undefined` when the combos fetch failed —
* the caller then publishes a models-only catalog instead of an empty one.
*/
async function publishCombos(ctx: PublishContext): Promise<number | undefined> {
const {
draft,
opts,
log,
providerId: X,
hostContract,
enrichment,
rawModelById,
publishedKeys,
publishedModelIds,
visibleFilter,
hiddenFilter,
usable,
canonicalToAlias,
combosFetcher,
combosTimeout,
warnedCombos,
cacheKey,
} = ctx;
let rawCombos: OmniRouteRawCombo[];
try {
rawCombos = combosFetcher
? await combosFetcher(opts.baseURL, opts.managementReadToken ?? opts.apiKey, combosTimeout)
: [];
} catch (err) {
log.warn(
`[omniroute-v2] combos fetch failed, falling back to models-only catalog: ${err instanceof Error ? err.message : String(err)}`
);
return undefined;
}
let comboCount = 0;
// Ported from v1 (fixpoint 8 passes + warn once per (cacheKey, comboKey)
// + intentional-dedup exception). Nested combo-refs resolve against the
// friendly combo name; unresolvable combos are dropped (never published
// with a fabricated empty LCD) and reported once.
const MAX_COMBO_PASSES = 8;
const pending = rawCombos.filter((combo) => {
if (!combo || !combo.id) return false;
if (combo.isHidden === true) return false;
if (usable && !isUsableCombo(combo, usable)) return false;
if (visibleFilter && !passesComboAllowlist(combo, visibleFilter)) return false;
// Deny wins for combos too: a user who hides an id expects it gone from
// the picker whether it is a model or a combo built on it.
if (hiddenFilter && passesComboAllowlist(combo, hiddenFilter)) return false;
return true;
});
const resolvedByName = new Map<string, LegacyModelV2>();
let unresolved: typeof pending = [];
for (let pass = 0; pass < MAX_COMBO_PASSES && pending.length > 0; pass++) {
const stillPending: typeof pending = [];
for (const combo of pending) {
const memberSteps = Array.isArray(combo.models) ? combo.models : [];
const memberEntries: OmniRouteRawModelEntry[] = [];
let deferred = false;
for (const step of memberSteps) {
const kind = readStepField(step, "kind");
if (kind === "combo-ref") {
const comboName = readStepField(step, "comboName");
if (typeof comboName !== "string" || comboName.length === 0) continue;
const nested = resolvedByName.get(comboName);
if (!nested) {
deferred = true;
break;
}
memberEntries.push(synthesizeNestedMember(comboName, nested));
continue;
}
const modelId = readStepField(step, "model");
if (typeof modelId !== "string" || modelId.length === 0) continue;
const member = rawModelById.get(modelId);
if (member) memberEntries.push(member);
}
if (deferred) {
stillPending.push(combo);
continue;
}
const mapped = mapComboToModelV2(combo, memberEntries, X, opts.baseURL, opts.apiFormat);
applyEnrichment(mapped, lookupEnrichment(combo.id, enrichment, canonicalToAlias), {
isCombo: true,
});
const mid = mapped.id.startsWith(X + "/") ? mapped.id.slice(X.length + 1) : mapped.id;
const key = X + "/" + mid;
if (publishedKeys.has(key)) {
// Intentional dedup (v1 parity): `/v1/models` pre-mirrors combos as
// raw entries, so the combo's friendly NAME matches the overwritten
// entry's model id (bare or provider-prefixed, endsWith to cover
// both). Only warn on a genuine accidental collision (name differs
// from the entry it overwrites).
const existingId = publishedModelIds.get(key) ?? "";
const friendly =
typeof combo.name === "string" && combo.name.trim().length > 0
? combo.name.trim()
: combo.id;
const isIntentionalDedup =
existingId === friendly ||
existingId === X + "/" + friendly ||
existingId.endsWith("/" + friendly);
if (!isIntentionalDedup) {
const dedupeKey = `${cacheKey}::${key}`;
if (!warnedCombos.has(dedupeKey)) {
warnedCombos.add(dedupeKey);
log.warn(`[omniroute-v2] combo key "${key}" collides with a model id; combo wins.`);
}
}
}
draft.model.update(X, mid, (m) => {
assignModelFields(m, mapped, hostContract);
});
publishedKeys.add(key);
publishedModelIds.set(key, mapped.id);
comboCount += 1;
const lookupName =
typeof combo.name === "string" && combo.name.trim().length > 0
? combo.name.trim()
: combo.id;
if (!resolvedByName.has(lookupName)) resolvedByName.set(lookupName, mapped);
}
if (stillPending.length === pending.length) {
unresolved = stillPending;
break;
}
unresolved = stillPending;
pending.length = 0;
pending.push(...stillPending);
}
if (unresolved.length > 0) {
log.warn(
`[omniroute-v2] ${unresolved.length} combo(s) could not resolve all nested combo-refs after ${MAX_COMBO_PASSES} passes; dropped to avoid over-claiming.`
);
}
return comboCount;
}
/**
* Synthesize a raw-model entry from an already-resolved nested combo so a
* parent combo's LCD folds the whole nested capability vector (context,
* 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 {
const inputModalities: string[] = [];
if (nested.capabilities.input.text) inputModalities.push("text");
if (nested.capabilities.input.audio) inputModalities.push("audio");
if (nested.capabilities.input.image) inputModalities.push("image");
if (nested.capabilities.input.video) inputModalities.push("video");
if (nested.capabilities.input.pdf) inputModalities.push("pdf");
const outputModalities: string[] = [];
if (nested.capabilities.output.text) outputModalities.push("text");
if (nested.capabilities.output.audio) outputModalities.push("audio");
if (nested.capabilities.output.image) outputModalities.push("image");
if (nested.capabilities.output.video) outputModalities.push("video");
if (nested.capabilities.output.pdf) outputModalities.push("pdf");
return {
id: `combo-ref:${name}`,
context_length: nested.limit.context,
max_output_tokens: nested.limit.output,
...(nested.limit.input !== undefined ? { max_input_tokens: nested.limit.input } : {}),
owned_by: "combo",
input_modalities: inputModalities,
output_modalities: outputModalities,
capabilities: {
temperature: nested.capabilities.temperature,
// A raw entry carries plain flags; the mapped model widens them to
// `boolean | { field }` (custom reasoning/thinking field). Every
// non-false form means the capability is present, which is all the
// LCD fold reads.
reasoning: isCapabilityEnabled(nested.capabilities.reasoning),
thinking: isCapabilityEnabled(nested.capabilities.interleaved),
attachment: nested.capabilities.attachment,
tool_calling: nested.capabilities.toolcall,
},
};
}
export async function publishCatalog(
draft: CatalogDraft,
opts: ResolvedOptions,
fetchers?: CatalogFetchers
): Promise<PublishCounts> {
const X = opts.providerId;
const log = opts.logger ?? createLogger(opts.startupDebug ? "debug" : (opts.logLevel ?? "warn"));
const modelsTimeout = opts.timeouts?.models ?? opts.timeoutMs;
const combosTimeout = opts.timeouts?.combos ?? opts.timeoutMs;
// v1 parity keeps the 5s auto-combos budget when no per-endpoint value is
// 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;
let rawModels: OmniRouteRawModelEntry[];
try {
rawModels = modelsFetcher ? await modelsFetcher(opts.baseURL, opts.apiKey, modelsTimeout) : [];
} catch (err) {
log.warn(
`[omniroute-v2] models fetch failed, publishing empty catalog: ${err instanceof Error ? err.message : String(err)}`
);
return { models: 0, combos: 0, autoCombos: 0 };
}
const visibleFilter = compileModelListFilter(opts.visibleModels);
const hiddenFilter = compileModelListFilter(opts.hiddenModels);
const enrichment = await resolveEnrichmentOverlay(opts, fetchers, log);
const canonicalToAlias = buildCanonicalToAliasMap(enrichment);
const canonicalDedup = canonicalDedupSet(rawModels, canonicalToAlias);
const usable = await resolveUsableAliases(
opts,
providersFetcher,
fetchers?.onSourceError,
enrichment,
modelsTimeout,
log
);
const rawModelById = new Map<string, OmniRouteRawModelEntry>();
for (const entry of rawModels) {
if (entry.id) rawModelById.set(entry.id, entry);
}
const publishedKeys = new Set<string>();
// Mapped model id per published key (models and combos alike). Mirrors
// 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>();
let modelCount = 0;
for (const entry of rawModels) {
if (!entry.id) continue;
if (canonicalDedup.has(entry.id)) continue;
if (usable && !isUsableRawModelId(entry.id, usable)) continue;
if (!passesModelAllowlist(entry.id, visibleFilter, hiddenFilter)) continue;
const mapped = mapRawModelToModelV2(entry, {
providerId: X,
baseURL: opts.baseURL,
apiFormat: opts.apiFormat,
});
applyEnrichment(mapped, lookupEnrichment(entry.id, enrichment, canonicalToAlias), {
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);
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,
publishedKeys,
publishedModelIds,
visibleFilter,
hiddenFilter,
usable,
canonicalToAlias,
combosFetcher,
combosTimeout,
warnedCombos,
cacheKey,
});
if (comboCount === undefined) return { 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.
// Re-publishing under "opencode-"+X here is FORBIDDEN: a double
// publish would double chat entries in the picker.
// Auto combos: virtual server-side entries from /api/combos/auto, keyed
// "auto" / "auto/<variant>" (v1 parity). Fail-open: a fetcher throw keeps
// models + combos and only warns - old gateways may not serve the
// endpoint at all (the default fetcher maps 404 to [] itself).
let rawAutoCombos: OmniRouteRawAutoCombo[];
try {
rawAutoCombos = autoCombosFetcher
? await autoCombosFetcher(
opts.baseURL,
opts.managementReadToken ?? opts.apiKey,
autoCombosTimeout
)
: [];
} catch (err) {
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 };
}
let autoComboCount = 0;
for (const autoCombo of rawAutoCombos) {
if (!autoCombo || !autoCombo.id) continue;
if (autoCombo.isHidden === true) continue;
// Auto combos are catalog entries like any other: an id a user asked to
// hide must stay hidden, and an allowlist that excludes it must exclude
// it. They used to skip both filters entirely.
if (!passesModelAllowlist(autoCombo.id, visibleFilter, hiddenFilter)) continue;
if (usable && !isUsableRawModelId(autoCombo.id, usable)) continue;
const mapped = mapAutoComboToModelV2(autoCombo, X, opts.baseURL, opts.apiFormat);
applyEnrichment(mapped, lookupEnrichment(autoCombo.id, enrichment, canonicalToAlias), {
isCombo: true,
isAutoCombo: true,
});
const key = X + "/" + mapped.id;
if (publishedKeys.has(key)) {
const dedupeKey = `${cacheKey}::${key}`;
if (!warnedCombos.has(dedupeKey)) {
warnedCombos.add(dedupeKey);
log.warn(
`[omniroute-v2] auto combo key "${key}" collides with a model id; auto combo wins.`
);
}
}
draft.model.update(X, mapped.id, (m) => {
assignModelFields(m, mapped, hostContract);
});
publishedKeys.add(key);
publishedModelIds.set(key, mapped.id);
autoComboCount += 1;
}
return { models: modelCount, combos: comboCount, autoCombos: autoComboCount };
}

View File

@@ -0,0 +1,66 @@
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
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

@@ -0,0 +1,101 @@
import type { PluginContext } from "@opencode-ai/plugin/v2/promise";
import type { Logger } from "./shared/index.js";
/** Where a resolved key came from, so the failure message can name the fix. */
export type ApiKeyOrigin = "connection" | "option" | "env" | "missing";
export interface ResolvedApiKey {
key: string;
origin: ApiKeyOrigin;
}
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.
*/
function connectionApi(ctx: PluginContext): PluginContext["integration"]["connection"] | undefined {
const connection = (ctx.integration as Partial<PluginContext["integration"]>).connection;
if (
connection === undefined ||
typeof connection.active !== "function" ||
typeof connection.resolve !== "function"
) {
return undefined;
}
return connection;
}
/**
* Read the credential the user stored through the host's own auth flow.
*
* The plugin advertises `key` and `env` methods on its integration, so a user
* can connect it from the UI; without this lookup that connection would only
* 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.
*/
async function keyFromConnection(
ctx: PluginContext,
integrationID: string,
log: Logger
): Promise<string | undefined> {
const connection = connectionApi(ctx);
if (connection === undefined) return undefined;
try {
const active = await connection.active(integrationID);
if (active === undefined) return undefined;
const credential = await connection.resolve(active);
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`
);
return undefined;
}
return 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)}`
);
return undefined;
}
}
/**
* Resolve the gateway key, preferring the credential the host holds over one
* written in config. A key in `opencode.json` still wins over the environment
* so an explicit per-project override keeps working.
*/
export async function resolveApiKey(
ctx: PluginContext,
integrationID: string,
optionKey: string | undefined,
log: Logger
): Promise<ResolvedApiKey> {
const stored = await keyFromConnection(ctx, integrationID, log);
if (stored !== undefined) return { key: stored, origin: "connection" };
if (optionKey !== undefined && optionKey.length > 0) return { key: optionKey, origin: "option" };
const fromEnv = process.env[ENV_VAR];
if (fromEnv !== undefined && fromEnv.length > 0) return { key: fromEnv, origin: "env" };
return { key: "", origin: "missing" };
}
/**
* A missing key produces an empty catalog and no error the user can see, so
* say it once, and name the three ways to supply one.
*/
export function warnIfMissing(resolved: ResolvedApiKey, integrationID: string, log: Logger): void {
if (resolved.origin !== "missing") return;
log.warn(
`[omniroute-v2] no API key for "${integrationID}": the catalog will be empty. ` +
`Connect the integration from opencode, set "apiKey" in the plugin options, ` +
`or export ${ENV_VAR}.`
);
}

View File

@@ -0,0 +1,41 @@
import type { Logger } from "./shared/index.js";
/** What the catalog loses when a given gateway source cannot be read. */
function consequenceOf(endpoint: string): string {
if (endpoint.includes("/api/providers")) {
return "the usable-provider filter is disabled for this refresh, so unprovisioned providers stay listed";
}
return "model names, provider tags, canonical dedupe and pricing are degraded";
}
/**
* A source the gateway refuses is not fatal — the catalog still publishes —
* but staying quiet about it is: the picker then shows raw ids, or lists
* providers that cannot serve, with nothing telling the user why. Say it once
* per endpoint so a refresh loop cannot spam the log.
*
* `usingFallbackToken` is true when no `managementReadToken` was configured and
* the inference key stands in for it, which is the usual reason a gateway
* answers 401/403 on `/api/*` — the advice differs from a token that was set
* and still got rejected.
*/
export function createSourceErrorReporter(
log: Logger,
usingFallbackToken: boolean
): (endpoint: string, reason: string) => void {
const warned = new Set<string>();
return (endpoint, reason) => {
if (warned.has(endpoint)) return;
warned.add(endpoint);
const unauthorized = reason.includes("401") || reason.includes("403");
const hint = !unauthorized
? ""
: usingFallbackToken
? ` These endpoints need a management token: set "managementReadToken" in the plugin options ` +
`(it currently falls back to "apiKey", which a gateway usually rejects here).`
: ` The configured "managementReadToken" was rejected — check it grants read access to /api/*.`;
log.warn(
`[omniroute-v2] gateway source ${endpoint} unavailable (${reason}): ${consequenceOf(endpoint)}.${hint}`
);
};
}

View File

@@ -0,0 +1,43 @@
import type { LanguageModelV3 } from "@ai-sdk/provider";
import { type Logger, isGeminiModelId, sanitizeToolInputSchemas } from "./shared/index.js";
type CallOptions = Parameters<LanguageModelV3["doGenerate"]>[0];
/**
* Gemini answers `400 INVALID_ARGUMENT` — for the entire request, not just the
* offending tool — when a tool declaration carries `$schema` or
* `additionalProperties`. Anything upstream that emits standard JSON Schema
* therefore breaks tool calling as soon as the chain routes to Gemini. A
* `$ref` is forwarded untouched instead: stripping it would widen the schema
* to "accept anything", which is worse than letting the gateway answer. The
* v1 plugin dealt with this by wrapping `fetch` and rewriting the JSON body; the
* v2 home for it is the language model, where the tools are still structured
* data and no re-parsing is needed.
*
* Returns the model untouched when it is not bound for Gemini, so the wrapper
* costs nothing on every other chain.
*/
export function sanitizeToolSchemasFor<T extends LanguageModelV3 | undefined>(
language: T,
modelId: string,
log: Logger
): T {
if (language === undefined) return language;
if (!isGeminiModelId(modelId)) return language;
const clean = (options: CallOptions): CallOptions => {
const tools = sanitizeToolInputSchemas(options.tools);
if (tools === undefined) return options;
log.debug(
`[omniroute-v2] stripped Gemini-incompatible schema keywords from ${tools.length} tool declaration(s) for ${modelId}`
);
return { ...options, tools } as CallOptions;
};
// Prototype-linked so every other member of the model — including accessors
// and anything a future SDK version adds — keeps working untouched.
const wrapped: LanguageModelV3 = Object.create(language as object) as LanguageModelV3;
wrapped.doGenerate = (options) => language.doGenerate(clean(options));
wrapped.doStream = (options) => language.doStream(clean(options));
return wrapped as T;
}

View File

@@ -0,0 +1,539 @@
import { define, type PluginContext } from "@opencode-ai/plugin/v2/promise";
import {
optionalTierFingerprint,
catalogContentFingerprint,
createLogger,
defaultOmniRouteAutoCombosFetcher,
defaultOmniRouteCombosFetcher,
defaultOmniRouteEnrichmentFetcher,
defaultOmniRouteModelsFetcher,
defaultOmniRouteProvidersFetcher,
type OmniRouteEnrichmentMap,
type OmniRouteProviderConnection,
} from "./shared/index.js";
import type {
OmniRouteRawAutoCombo,
OmniRouteRawCombo,
OmniRouteRawModelEntry,
} from "./shared/index.js";
import type { ResolvedOptions } from "./catalog.js";
import { publishCatalog } from "./catalog.js";
import {
DEFAULT_MODEL_CACHE_TTL_MS,
UNREACHABLE_COOLDOWN_MS,
memoryCacheKey,
readDiskSnapshot,
snapshotIdentityFingerprint,
writeDiskSnapshot,
type CatalogSnapshot,
} from "./cache.js";
import { assertContext } from "./compat.js";
import { type ApiKeyOrigin, resolveApiKey, warnIfMissing } from "./credentials.js";
import { createSourceErrorReporter } from "./enrichment-report.js";
import { sanitizeToolSchemasFor } from "./gemini-language.js";
import { PLUGIN_ID, parsePluginOptions, resolveTimeouts, type PluginOptions } from "./options.js";
/**
* A fetch result that says whether it succeeded. Returning a bare `[]` on
* failure makes an outage indistinguishable from a gateway that legitimately
* has no combos — and the difference decides whether the last known value
* should be kept or dropped.
*/
type SourceResult<T> = { ok: true; value: T } | { ok: false };
interface RefreshState {
entries: Map<string, CatalogSnapshot>;
inFlight: Map<string, Promise<CatalogSnapshot>>;
fingerprint: string | undefined;
/** Digest of the optional tier, so a reload only follows a real change. */
optionalFingerprint: string | undefined;
/**
* When the last refresh found the gateway unreachable, skip the network
* until this timestamp and serve last-known-good instead. Without it every
* transform past TTL re-fires the full fetch suite against a gateway that
* just proved it cannot answer — a self-inflicted retry storm.
*/
unreachableUntil: number;
}
function toResolvedOptions(parsed: PluginOptions): ResolvedOptions {
return {
providerId: parsed.providerId,
baseURL: parsed.baseURL,
apiKey: parsed.apiKey ?? process.env.OMNIROUTE_API_KEY ?? "",
managementReadToken: parsed.managementReadToken,
timeoutMs: parsed.timeoutMs,
timeouts: parsed.timeouts,
logLevel: parsed.logLevel,
startupDebug: parsed.startupDebug,
providerTag: parsed.providerTag,
modelCacheTtlMs:
typeof parsed.modelCacheTtlMs === "number" && parsed.modelCacheTtlMs > 0
? parsed.modelCacheTtlMs
: DEFAULT_MODEL_CACHE_TTL_MS,
displayName: parsed.displayName,
apiFormat: parsed.apiFormat,
visibleModels: parsed.visibleModels,
hiddenModels: parsed.hiddenModels,
usableOnly: parsed.usableOnly,
enrichment: parsed.enrichment,
};
}
export default define({
id: PLUGIN_ID,
setup: async (ctx: PluginContext) => {
assertContext(ctx);
const parsed = parsePluginOptions(ctx.options);
const X = parsed.providerId;
const resolved = toResolvedOptions(parsed);
const timeouts = resolveTimeouts(parsed);
const log = createLogger(parsed.startupDebug ? "debug" : (parsed.logLevel ?? "warn"));
resolved.logger = log;
resolved.logLevel = parsed.logLevel;
resolved.startupDebug = parsed.startupDebug;
log.info(`[omniroute-v2] init providerId=${X}`);
// v1 parity port: in-memory TTL + disk snapshot. The memory key
// `baseURL::sha256(creds)` isolates credential tuples (prod vs
// staging); the TTL is checked in the transform before any fetch;
// concurrent calls share the refresh promise in the setup closure keyed
// by (providerId, baseURL); the disk snapshot feeds warm-startup and
// the offline fallback. The existing in-memory keep-last-good is kept.
const state: RefreshState = {
entries: new Map(),
inFlight: new Map(),
fingerprint: undefined,
optionalFingerprint: undefined,
unreachableUntil: 0,
};
// The credential the host holds wins over one written in config, so a
// user who connected the integration from the UI never has to paste a
// key into `opencode.json`. Reading it is async and the transforms must
// register synchronously, so the lookup happens on the first publish;
// until then the option/env key resolved above stands in.
const credentialsOf = (): { cacheKey: string; identityFingerprint: string } => ({
cacheKey: memoryCacheKey(
resolved.baseURL,
`${resolved.apiKey}\0${resolved.managementReadToken ?? resolved.apiKey}`
),
identityFingerprint: snapshotIdentityFingerprint(
resolved.baseURL,
resolved.apiKey,
resolved.managementReadToken ?? resolved.apiKey
),
});
let { cacheKey, identityFingerprint } = credentialsOf();
// Both keys are derived from the credential: two credentials must never
// share a snapshot, so they are recomputed whenever the key moves.
let credentialChecked = false;
let apiKeyOrigin: ApiKeyOrigin = resolved.apiKey.length > 0 ? "option" : "missing";
const ensureCredential = async (): Promise<void> => {
// Settled once a key is in hand: re-reading on every refresh would let
// a mid-session change silently repoint the snapshot keys.
if (credentialChecked && apiKeyOrigin !== "missing") return;
const next = await resolveApiKey(ctx, X, parsed.apiKey, log);
const moved = next.key !== resolved.apiKey;
resolved.apiKey = next.key;
apiKeyOrigin = next.origin;
if (moved) ({ cacheKey, identityFingerprint } = credentialsOf());
if (!credentialChecked) warnIfMissing(next, X, log);
else if (moved) log.info(`[omniroute-v2] API key picked up from the ${next.origin} source`);
credentialChecked = true;
};
const fetchModelsSafe = async (): Promise<OmniRouteRawModelEntry[]> => {
try {
return await defaultOmniRouteModelsFetcher(
resolved.baseURL,
resolved.apiKey,
timeouts.models
);
} catch (err) {
log.warn(
`[omniroute-v2] models fetch failed, publishing empty catalog: ${err instanceof Error ? err.message : String(err)}`
);
return [];
}
};
// Failures are reported once per endpoint (with the management-token hint
// when the inference key stands in), so a gated `/api/*` degrades loudly
// rather than silently. Declared before the wrappers that use it.
const reportSourceError = createSourceErrorReporter(
log,
resolved.managementReadToken === undefined
);
const fetchCombosSafe = async (): Promise<SourceResult<OmniRouteRawCombo[]>> => {
try {
return {
ok: true,
value: await defaultOmniRouteCombosFetcher(
resolved.baseURL,
resolved.managementReadToken ?? resolved.apiKey,
timeouts.combos
),
};
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
reportSourceError("/api/combos", reason);
log.warn(`[omniroute-v2] combos fetch failed, keeping the last known combos: ${reason}`);
return { ok: false };
}
};
// Providers connections follow the same rule: gated on usableOnly (no
// request when false, v1 parity), soft-fail to [] so the filter degrades
// to keep-all instead of hiding the catalog.
const fetchProvidersSafe = async (): Promise<SourceResult<OmniRouteProviderConnection[]>> => {
if (!resolved.usableOnly) return { ok: true, value: [] };
try {
return {
ok: true,
value: await defaultOmniRouteProvidersFetcher(
resolved.baseURL,
resolved.managementReadToken ?? resolved.apiKey,
timeouts.models,
reportSourceError
),
};
} catch (err) {
log.warn(
`[omniroute-v2] providers fetch failed, keeping the last known provider list: ${err instanceof Error ? err.message : String(err)}`
);
return { ok: false };
}
};
// Enrichment follows the same rule: gated on the option (default on,
// v1 parity), soft-fail to an empty map so names/pricing degrade to
// mapper defaults instead of hiding the catalog.
const fetchEnrichmentSafe = async (): Promise<SourceResult<OmniRouteEnrichmentMap>> => {
if (resolved.enrichment === false) return { ok: true, value: new Map() };
try {
return {
ok: true,
value: await defaultOmniRouteEnrichmentFetcher(
resolved.baseURL,
resolved.managementReadToken ?? resolved.apiKey,
timeouts.enrichment,
reportSourceError
),
};
} catch (err) {
log.warn(
`[omniroute-v2] enrichment fetch failed, keeping the last known names/pricing: ${err instanceof Error ? err.message : String(err)}`
);
return { ok: false };
}
};
const fetchAutoCombosSafe = async (): Promise<SourceResult<OmniRouteRawAutoCombo[]>> => {
try {
return {
ok: true,
value: await defaultOmniRouteAutoCombosFetcher(
resolved.baseURL,
resolved.managementReadToken ?? resolved.apiKey,
timeouts.autoCombos,
log,
reportSourceError
),
};
} catch (err) {
// The default fetcher reports the refusal itself (with the
// management-token hint); this warn is the fallback for injected
// stubs that throw without reporting.
const reason = err instanceof Error ? err.message : String(err);
log.warn(`[omniroute-v2] auto combos fetch failed, keeping the last known ones: ${reason}`);
return { ok: false };
}
};
/**
* Fetch in two tiers. Models are what a catalog *is*: without them there
* is nothing to publish. Everything else — combos, auto-combos, the
* provider list, the enrichment overlay — improves an already usable
* catalog, so awaiting any of them before publishing makes the catalog
* hostage to the slowest source: a gateway that accepts the connection
* and never answers one endpoint kept everything unpublished until that
* fetch's own timeout fired, which is longer than some hosts stay alive.
*
* The optional tier therefore keeps running after the publish and upgrades
* the stored snapshot when it lands, so the next transform serves the
* complete catalog.
*/
async function refreshSnapshot(): Promise<CatalogSnapshot> {
// Models are what a catalog *is*; everything else improves one that
// already works. Combos used to sit here too, so a gateway slow to
// answer /api/combos held the whole picker back — the very thing the
// staged publish exists to prevent.
const essential = fetchModelsSafe();
const optional = Promise.all([
fetchCombosSafe(),
fetchAutoCombosSafe(),
fetchProvidersSafe(),
fetchEnrichmentSafe(),
]);
const models = await essential;
const previous = state.entries.get(cacheKey);
// A gateway that just failed everything gets a short breather: serving
// last-known-good for a few seconds beats hammering it on every
// transform while it is down. Arms whenever the models fetch comes back
// empty — with or without a prior entry to serve — so a totally dead
// gateway stops getting hit every window. Partial degradation (models
// healthy, an optional tier failed) still retries normally next window.
if (models.length === 0) {
state.unreachableUntil = Date.now() + UNREACHABLE_COOLDOWN_MS;
}
// Carry every source forward until its replacement lands, and keep the
// old value when a fetch FAILED — but honour a gateway that legitimately
// returns nothing, which is a different answer from "I could not ask".
const snapshot: CatalogSnapshot = {
models,
combos: previous?.combos ?? [],
autoCombos: previous?.autoCombos ?? [],
providers: previous?.providers ?? [],
enrichment: previous?.enrichment ?? new Map(),
fetchedAt: Date.now(),
};
if (models.length > 0) {
state.entries.set(cacheKey, snapshot);
await writeDiskSnapshot(X, snapshot, identityFingerprint);
}
void optional.then(
(parts) => upgradeWithOptional(snapshot, parts),
(err) => {
// The wrappers never reject; a throw here would be a bug in them, and
// an unhandled rejection is a worse way to learn about it.
log.warn(
`[omniroute-v2] optional catalog sources failed unexpectedly: ${err instanceof Error ? err.message : String(err)}`
);
}
);
return snapshot;
}
/**
* Fold late optional data into the snapshot that was published without it.
* Skipped when a newer refresh has already replaced that snapshot, so a
* slow tier can never resurrect a stale catalog.
*/
async function upgradeWithOptional(
base: CatalogSnapshot,
[combos, autoCombos, providers, enrichment]: [
SourceResult<OmniRouteRawCombo[]>,
SourceResult<OmniRouteRawAutoCombo[]>,
SourceResult<OmniRouteProviderConnection[]>,
SourceResult<OmniRouteEnrichmentMap>,
]
): Promise<void> {
if (state.entries.get(cacheKey) !== base) return;
// Per source: a success replaces (even with an empty answer — that is
// the gateway's answer), a failure keeps what we had.
const upgraded: CatalogSnapshot = {
...base,
combos: combos.ok ? combos.value : base.combos,
autoCombos: autoCombos.ok ? autoCombos.value : base.autoCombos,
providers: providers.ok ? providers.value : base.providers,
enrichment: enrichment.ok ? enrichment.value : base.enrichment,
};
const unchanged =
upgraded.combos === base.combos &&
upgraded.autoCombos === base.autoCombos &&
upgraded.providers === base.providers &&
upgraded.enrichment === base.enrichment;
if (unchanged) return;
state.entries.set(cacheKey, upgraded);
if (upgraded.models.length > 0) {
await writeDiskSnapshot(X, upgraded, identityFingerprint);
}
// Reload only when the optional tier actually moved: the catalog
// fingerprint covers ids alone, so without this the host would rebuild
// its catalog once per TTL window for an identical result.
const optionalFingerprint = optionalTierFingerprint(
upgraded.autoCombos ?? [],
upgraded.providers ?? [],
upgraded.enrichment,
upgraded.combos
);
const optionalChanged = state.optionalFingerprint !== optionalFingerprint;
state.optionalFingerprint = optionalFingerprint;
if (optionalChanged && typeof ctx.catalog.reload === "function") {
try {
await ctx.catalog.reload();
} catch (err) {
log.warn(
`[omniroute-v2] catalog reload after late sources failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
);
}
}
}
function loadSnapshot(): Promise<CatalogSnapshot> {
const now = Date.now();
const hit = state.entries.get(cacheKey);
if (hit && hit.fetchedAt + resolved.modelCacheTtlMs > now) return Promise.resolve(hit);
// Cooldown after a total models failure: skip the network until it
// lapses. Serves last-known-good when one exists; otherwise the refresh
// below still runs (nothing to serve, no point pretending).
if (now < state.unreachableUntil && hit) return Promise.resolve(hit);
if (now >= state.unreachableUntil) state.unreachableUntil = 0;
const inflight = state.inFlight.get(cacheKey);
if (inflight) return inflight;
const snapshot = refreshSnapshot();
state.inFlight.set(cacheKey, snapshot);
const clear = () => {
if (state.inFlight.get(cacheKey) === snapshot) state.inFlight.delete(cacheKey);
};
snapshot.then(clear, clear);
return snapshot;
}
// Warm-startup: the disk snapshot is read at boot (without blocking
// the synchronous transform registration) to publish the last-known
// catalog before the first successful fetch.
/**
* Warm start: publish the last known catalog from disk before the first
* fetch returns. Deliberately read *after* the credential is resolved —
* the snapshot is keyed by the credential tuple, and resolving the host
* credential changes that key, so reading at setup time would look up the
* wrong identity and reject a perfectly good snapshot.
*/
let warmLoadedFor: string | undefined;
const ensureWarmSnapshot = async (): Promise<void> => {
if (warmLoadedFor === identityFingerprint) return;
warmLoadedFor = identityFingerprint;
const warm = await readDiskSnapshot(X, identityFingerprint, log);
if (warm && !state.entries.has(cacheKey)) state.entries.set(cacheKey, warm);
};
// Fail-closed models (keep-last-good, validated): an empty models fetch
// (transient 500/timeout) must not wipe a known catalog. The latest
// non-empty entry (fresh fetch or warm disk snapshot) is replayed
// instead of publishing the empty set. `refreshSnapshot` never overwrites
// 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) => {
await ensureCredential();
await ensureWarmSnapshot();
const snapshot = await loadSnapshot();
let effective = snapshot;
if (snapshot.models.length === 0) {
const stale = state.entries.get(cacheKey);
if (stale !== undefined && stale.models.length > 0) {
log.warn(
`[omniroute-v2] models fetch returned empty, keeping last-known catalog (${stale.models.length} models, ${stale.combos.length} combos)`
);
effective = stale;
}
}
const counts = await (async (): Promise<{
models: number;
combos: number;
autoCombos: number;
}> => {
// fetcher-level fail-open covers fetches; this guard covers mapper/draft throws.
try {
return await publishCatalog(draft, resolved, {
onSourceError: reportSourceError,
models: async () => effective.models,
combos: async () => effective.combos,
autoCombos: async () => effective.autoCombos,
providers: async () => effective.providers ?? [],
enrichment: async () => effective.enrichment ?? new Map(),
});
} catch (err) {
log.warn(
`[omniroute-v2] catalog publish failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
);
return { models: 0, combos: 0, autoCombos: 0 };
}
})();
void counts;
const fingerprint = catalogContentFingerprint(
effective.models,
effective.combos,
effective.autoCombos
);
const changed = state.fingerprint !== undefined && state.fingerprint !== fingerprint;
state.fingerprint = fingerprint;
if (changed && typeof ctx.catalog.reload === "function") {
await Promise.resolve();
try {
await ctx.catalog.reload();
} catch (err) {
log.warn(
`[omniroute-v2] catalog reload failed, keeping current catalog: ${err instanceof Error ? err.message : String(err)}`
);
}
}
});
const integrationHook = (ctx.integration as Partial<PluginContext["integration"]> | 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
// any await, so only a call-site guard catches it (an await-guard alone
// would let a synchronous throw escape setup and kill the catalog).
let integrationRegistration: unknown;
if (typeof integrationHook === "function") {
try {
integrationRegistration = integrationHook((draft) => {
draft.update(X, (integration) => {
integration.name = parsed.displayName ?? "OmniRoute";
});
draft.method.update({ integrationID: X, method: { type: "key", label: "API key" } });
draft.method.update({
integrationID: X,
method: { type: "env", names: ["OMNIROUTE_API_KEY"] },
});
});
} catch (err) {
log.warn(
`[omniroute-v2] host refused the integration hook, the connect action will be missing: ${err instanceof Error ? err.message : String(err)}`
);
integrationRegistration = undefined;
}
}
/**
* `aisdk.language` is newer than the catalog domain, so a host may not
* expose it; the plugin must stay loadable there, minus the sanitising.
*/
const languageHook = (ctx.aisdk as Partial<PluginContext["aisdk"]> | undefined)?.language;
// 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) => {
if (input.model.providerID !== X) return;
input.language = sanitizeToolSchemasFor(input.language, input.model.id, log);
});
} catch (err) {
log.warn(
`[omniroute-v2] host refused the language-model hook, Gemini tool schemas will not be cleaned: ${err instanceof Error ? err.message : String(err)}`
);
}
}
await catalogRegistration;
if (integrationRegistration !== undefined) {
try {
await integrationRegistration;
} catch (err) {
log.warn(
`[omniroute-v2] host refused the integration hook, the connect action will be missing: ${err instanceof Error ? err.message : String(err)}`
);
}
}
if (languageRegistration !== undefined) {
try {
await languageRegistration;
} catch (err) {
log.warn(
`[omniroute-v2] language-model hook registration failed, Gemini tool schemas will not be cleaned: ${err instanceof Error ? err.message : String(err)}`
);
}
}
},
});

View File

@@ -0,0 +1,117 @@
import { z } from "zod";
const apiFormatSchema = z
.object({
allowAnthropic: z.boolean().optional(),
anthropicModels: z.array(z.string()).optional(),
// Deprecated v1 prefix list. Accepted (warn at resolve time) so copied
// v1 configs keep routing; prefer anthropicModels (full IDs).
anthropicPrefixes: z.array(z.string()).optional(),
})
.strict();
const timeoutsSchema = z
.object({
models: z.number().positive().optional(),
combos: z.number().positive().optional(),
autoCombos: z.number().positive().optional(),
enrichment: z.number().positive().optional(),
})
.strict();
const pluginOptionsSchema = z
.object({
// Reaches a filesystem path (the on-disk catalog snapshot) and the
// catalog keys, so it is bounded here rather than escaped at each use.
providerId: z
.string()
.regex(/^[A-Za-z0-9._-]+$/, "providerId may only contain letters, digits, '.', '_' and '-'")
.refine((v) => v !== "." && v !== "..", "providerId cannot be a path segment")
.default("omniroute"),
baseURL: z.string().url(),
apiKey: z.string().optional(),
displayName: z.string().optional(),
managementReadToken: z.string().optional(),
timeoutMs: z.number().positive().default(10000),
timeouts: timeoutsSchema.optional(),
logLevel: z.enum(["error", "warn", "info", "debug"]).optional(),
startupDebug: z.boolean().optional(),
modelCacheTtlMs: z.number().positive().optional(),
visibleModels: z.array(z.string()).optional(),
hiddenModels: z.array(z.string()).optional(),
usableOnly: z.boolean().default(false),
// v1 parity: enrichment overlay on by default (names + pricing).
enrichment: z.boolean().default(true),
// v1 parity: strip the JSON-Schema keywords Gemini rejects from tool
// declarations bound for a Gemini model. On by default — leaving them in
// fails the whole request with 400 INVALID_ARGUMENT.
geminiSanitization: z.boolean().default(true),
// v1 parity: prefix a model's display name with the upstream provider it
// routes to, so the same model sold through two connections is
// distinguishable in the picker.
providerTag: z.boolean().default(true),
apiFormat: apiFormatSchema.optional(),
})
.strict();
export type PluginOptions = z.infer<typeof pluginOptionsSchema>;
/** Per-endpoint timeout defaults (v1 parity). `timeoutMs` is the global fallback. */
export const DEFAULT_TIMEOUT_MS = 10_000 as const;
/** Auto-combos keep the v1 5s budget; the field is resolved now for the P3 port. */
export const DEFAULT_AUTO_COMBOS_TIMEOUT_MS = 5_000 as const;
export interface EndpointTimeouts {
models: number;
combos: number;
autoCombos: number;
enrichment: number;
}
export function resolveTimeouts(
opts: Pick<PluginOptions, "timeoutMs" | "timeouts">
): EndpointTimeouts {
const fallback =
typeof opts.timeoutMs === "number" && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;
return {
models: opts.timeouts?.models ?? fallback,
combos: opts.timeouts?.combos ?? fallback,
autoCombos: opts.timeouts?.autoCombos ?? DEFAULT_AUTO_COMBOS_TIMEOUT_MS,
enrichment: opts.timeouts?.enrichment ?? fallback,
};
}
/**
* Parse the plugin block of `opencode.json`.
*
* A rejected option aborts the whole plugin, and the host reports that as a
* bare load failure with the validator's raw dump attached — which is how a
* single mistyped key turns into a wall of JSON and an empty model picker. The
* schema is strict on purpose (a silently ignored option is worse), so the
* least we owe the user is a first line naming what to fix.
*/
export function parsePluginOptions(raw: unknown): PluginOptions {
const result = pluginOptionsSchema.safeParse(raw);
if (result.success) return result.data;
const problems = result.error.issues.map((issue) => {
const at = issue.path.length > 0 ? issue.path.join(".") : "(root)";
const unknown = issue.code === "unrecognized_keys" ? issue.keys.join(", ") : undefined;
return unknown !== undefined ? `unknown option "${unknown}"` : `${at}: ${issue.message}`;
});
throw new Error(`[omniroute-v2] invalid plugin options — ${problems.join("; ")}`);
}
/**
* The host reads the plugin id from the module, before any option is known, so
* it cannot carry the configured provider id. Publishing two gateways from one
* install is a `providerId` matter — that one does reach the catalog.
*/
export const PLUGIN_ID = "omniroute-v2";
export function providerIdFor(providerId: string): string {
return providerId;
}
export function integrationIdFor(providerId: string): string {
return providerId;
}

View File

@@ -0,0 +1,219 @@
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
import type { ApiFormatV2 } from "./models-map.js";
import { resolveApiBlockV2 } from "./models-map.js";
import { autoComboModelId, formatAutoComboName, type AutoVariant } from "./naming.js";
export type { AutoVariant };
/**
* Raw shape of an auto combo entry as returned by OmniRoute's
* `/api/combos/auto` endpoint. Auto combos are virtual -- they self-manage
* provider selection via scoring/bandit exploration at runtime.
*
* Ported from the v1 plugin (`index.ts:1672-1698`); the shape is unchanged
* so old and new gateways stay wire-compatible.
*/
export interface OmniRouteRawAutoCombo {
/** Stable id (e.g. "auto", "auto/coding"). */
id: string;
/** Human-readable name (e.g. "Auto", "Auto Coding"). */
name?: string;
/** Variant key or undefined for the default auto. */
variant?: AutoVariant;
/** Provider names eligible for this auto combo. */
candidatePool?: string[];
/** Number of candidates resolved at fetch time. */
candidateCount?: number;
/** MAX of candidates' context windows, served by newer gateway builds.
* Absent on older servers -- the mapper falls back to a safe default. */
context_length?: number;
/** MAX of candidates' max output tokens (same provenance as context_length). */
max_output_tokens?: number;
/** Whether this auto combo should be hidden from the picker. */
isHidden?: boolean;
/** Auto-combo configuration. */
config?: {
auto?: {
candidatePool?: string[];
explorationRate?: number;
routerStrategy?: string;
};
};
}
/** Minimal warn sink so the fetcher never depends on the plugin logger. */
export interface AutoCombosWarnSink {
warn: (message: string, ...args: unknown[]) => void;
}
/**
* Fetcher contract for `/api/combos/auto`. Returns the list of virtual
* auto combos the server can create. Same DI shape as the other fetchers
* so unit tests can inject a stub instead of monkey-patching `fetch`.
*
* HTTP refusals (non-2xx other than 404) and network errors THROW: the caller
* distinguishes "the gateway failed" (keep last-known) from "the gateway
* answered empty" (publish empty). Only 404 stays soft — the endpoint does
* not exist yet on older gateways, and that is an answer, not a failure.
*/
export type OmniRouteAutoCombosFetcher = (
baseURL: string,
apiKey: string,
timeoutMs?: number,
logger?: AutoCombosWarnSink,
onSourceError?: (endpoint: string, reason: string) => void
) => Promise<OmniRouteRawAutoCombo[]>;
function trimTrailingSlashes(value: string): string {
let i = value.length;
while (i > 0 && value.charCodeAt(i - 1) === 0x2f /* "/" */) i--;
return i === value.length ? value : value.slice(0, i);
}
function fallbackWarn(message: string, ...args: unknown[]): void {
console.warn(`[omniroute-plugin] [WARN] ${message}`, ...args);
}
/**
* Default auto combos fetcher: `GET <baseURL>/api/combos/auto`.
*
* 404 stays soft (endpoint not deployed yet on older gateways — an answer,
* not a failure). Any other non-2xx or network error THROWS so the caller
* keeps last-known instead of publishing an empty tier: a 403 behind a
* management-token gate must not wipe the auto combos the picker had.
* v1 parity keeps the 5s timeout budget.
*/
export const defaultOmniRouteAutoCombosFetcher: OmniRouteAutoCombosFetcher = async (
baseURL,
apiKey,
timeoutMs = 5_000,
logger?: AutoCombosWarnSink,
onSourceError?: (endpoint: string, reason: string) => void
) => {
if (!apiKey || !baseURL) return [];
const warn = logger?.warn ?? fallbackWarn;
const report = (reason: string): void => {
warn(reason);
onSourceError?.("/api/combos/auto", reason);
};
const trimmed = trimTrailingSlashes(baseURL);
const root = trimmed.replace(/\/v\d+$/, "");
const url = `${root}/api/combos/auto`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
signal: controller.signal,
});
// 404 = endpoint not deployed yet -- expected during rollout
if (res.status === 404) {
warn(`/api/combos/auto not available (404) -- auto combos disabled`);
return [];
}
if (!res.ok) {
const reason = `HTTP ${res.status} ${res.statusText}`;
report(`/api/combos/auto refused (${reason}) -- keeping last-known auto combos`);
throw new Error(reason);
}
const body = (await res.json()) as unknown;
const rawList: unknown[] = Array.isArray(body)
? body
: body && typeof body === "object" && Array.isArray((body as { combos?: unknown }).combos)
? ((body as { combos: unknown[] }).combos as unknown[])
: [];
const out: OmniRouteRawAutoCombo[] = [];
for (const r of rawList) {
if (r && typeof r === "object" && typeof (r as { id?: unknown }).id === "string") {
out.push(r as OmniRouteRawAutoCombo);
}
}
return out;
} catch (err) {
// Network error, timeout, abort -- keep last-known, never publish empty.
// (The 404-soft path above returns directly and never reaches this throw.)
const reason = `/api/combos/auto fetch failed: ${err instanceof Error ? err.message : String(err)} -- keeping last-known auto combos`;
report(reason);
throw err instanceof Error ? err : new Error(String(err));
} finally {
clearTimeout(timer);
}
};
/** Fallbacks when the server does not advertise auto-combo limits (older
* gateway builds). MUST be positive: OpenCode's overflow guard treats
* `limit.context === 0` as "never overflow" and silently DISABLES smart
* auto-compaction, letting the session grow until the gateway's destructive
* history purge kicks in. */
export const AUTO_COMBO_FALLBACK_CONTEXT = 128_000;
export const AUTO_COMBO_FALLBACK_OUTPUT = 8_192;
/**
* Convert a raw auto combo into a `ModelV2` entry for the picker.
* Auto combos route to capable models, so tool_call and reasoning default
* to true. Context/output limits come from the server (MAX of the
* candidate pool's windows); a safe positive fallback applies when the
* server omits them. Never 0.
*/
export function mapAutoComboToModelV2(
autoCombo: OmniRouteRawAutoCombo,
providerId: string,
baseURL: string,
apiFormat?: ApiFormatV2
): ModelV2 {
const name = formatAutoComboName(autoCombo.variant, autoCombo.candidateCount);
const context =
typeof autoCombo.context_length === "number" && autoCombo.context_length > 0
? autoCombo.context_length
: AUTO_COMBO_FALLBACK_CONTEXT;
const output =
typeof autoCombo.max_output_tokens === "number" && autoCombo.max_output_tokens > 0
? autoCombo.max_output_tokens
: AUTO_COMBO_FALLBACK_OUTPUT;
return {
id: autoComboModelId(autoCombo.variant),
providerID: providerId,
api: resolveApiBlockV2(autoComboModelId(autoCombo.variant), baseURL, apiFormat),
name,
capabilities: {
temperature: true,
reasoning: true,
attachment: false,
toolcall: true,
input: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
},
limit: {
context,
output,
},
status: "active",
options: {},
headers: {},
release_date: "",
};
}

View File

@@ -0,0 +1,254 @@
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
import { type ApiFormatV2, type OmniRouteRawModelEntry, resolveApiBlockV2 } from "./models-map.js";
export interface OmniRouteRawComboMemberRef {
/** Step kind: "model" references a raw model id; "combo-ref" nests another combo. */
kind?: "model" | "combo-ref";
/** Full model id referenced by this step (when kind === "model"). */
model?: string;
/** Nested combo name (when kind === "combo-ref"). */
comboName?: string;
/** Routing weight inside the combo (0100, advisory at LCD time). */
weight?: number;
/** Step-local label, distinct from the parent combo's display name. */
label?: string;
}
export interface OmniRouteRawCombo {
id: string;
name?: string;
/** Routing strategy. Surfaced for forward-compat but not consumed by LCD. */
strategy?: string;
/** Member step list. Only `kind: "model"` steps participate in LCD. */
models?: OmniRouteRawComboMemberRef[];
/** Hidden combos are excluded from the OC model picker. */
isHidden?: boolean;
/** When OmniRoute attaches a lifecycle hint we forward it; today it doesn't. */
release_date?: string;
/**
* Server-computed context window for this combo (aggregated from member
* models using the same logic as /v1/models). When present, the client
* uses this value directly instead of re-aggregating from member models.
*
* Added in 3.9.x — old servers do not send it.
*/
computed_context_length?: number;
}
/**
* Fetcher contract for `/api/combos`. Same DI shape as
* `OmniRouteModelsFetcher` so unit tests can inject a stub instead of
* monkey-patching global `fetch`.
*/
export type OmniRouteCombosFetcher = (
baseURL: string,
apiKey: string,
timeoutMs?: number
) => Promise<OmniRouteRawCombo[]>;
function trimTrailingSlashes(value: string): string {
let i = value.length;
while (i > 0 && value.charCodeAt(i - 1) === 0x2f /* "/" */) i--;
return i === value.length ? value : value.slice(0, i);
}
/**
* Default fetcher: `GET <baseURL>/api/combos` with bearer auth +
* AbortController timeout. Accepts both the `{combos: [...]}` envelope the
* gateway emits today and a bare-array envelope (defensive — keeps the
* plugin working if a future OmniRoute build trims the wrapper).
*
* Differences from `defaultOmniRouteModelsFetcher`:
* - URL is `/api/combos`, NOT `/v1/combos`. The `/v1/...` namespace is the
* OpenAI-compatible surface (chat completions, models); combo discovery
* lives on the management plane under `/api/...`. We tolerate both
* `https://host` and `https://host/v1` baseURL forms by stripping the
* trailing `/v1` segment before appending `/api/combos`.
* - Combos endpoint requires a management-scoped API key when
* `REQUIRE_API_KEY` is enabled. We don't enforce that here; the
* gateway returns 401/403 with an actionable error which we propagate.
*
* Anything that isn't an object with a string `id` is filtered out silently.
*/
export const defaultOmniRouteCombosFetcher: OmniRouteCombosFetcher = async (
baseURL,
apiKey,
timeoutMs = 10_000
) => {
if (!apiKey) throw new Error("[omniroute-v2] apiKey required to fetch /api/combos");
if (!baseURL) throw new Error("[omniroute-v2] baseURL required to fetch /api/combos");
// Strip trailing slashes, then strip a trailing `/v1` so we land on the
// management plane. Models live under `/v1/models`; combos live under
// `/api/combos` from the same gateway root.
const trimmed = trimTrailingSlashes(baseURL);
const root = trimmed.replace(/\/v\d+$/, "");
const url = `${root}/api/combos`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
signal: controller.signal,
});
if (!res.ok) {
throw new Error(`[omniroute-v2] GET ${url} failed: ${res.status} ${res.statusText}`);
}
const body = (await res.json()) as unknown;
const rawList: unknown[] = Array.isArray(body)
? body
: body && typeof body === "object" && Array.isArray((body as { combos?: unknown }).combos)
? ((body as { combos: unknown[] }).combos as unknown[])
: [];
const out: OmniRouteRawCombo[] = [];
for (const r of rawList) {
if (r && typeof r === "object" && typeof (r as { id?: unknown }).id === "string") {
out.push(r as OmniRouteRawCombo);
}
}
return out;
} finally {
clearTimeout(timer);
}
};
/**
* Map a raw combo entry → `ModelV2` by computing the lowest-common-denominator
* (LCD) of its underlying member models. The LCD policy is the only way to
* surface a single capability vector to OpenCode without lying: if any member
* lacks a capability, the combo as a whole cannot guarantee it.
*
* LCD rules:
* - `limit.context` = `min(...members.context_length)`.
* - `limit.output` = `min(...members.max_output_tokens)`.
* - `limit.input` = `min(...members.max_input_tokens)` ONLY when every
* member declares one (ModelV2.limit.input is optional — better to
* omit than to fabricate a min over partial data).
* - `capabilities.toolcall` / `reasoning` / `attachment` / `temperature`:
* `every(member ⇒ supports?)`. The `reasoning` axis ORs across
* `reasoning` and `thinking` per member before AND-ing across the
* combo (mirrors `mapRawModelToModelV2`). The `attachment` axis ORs
* across `attachment` and `vision` per member. The `temperature` axis
* uses default-true semantics: a member supports temperature unless
* it explicitly declares `temperature: false`.
* - `capabilities.input.*` / `output.*`: flattened AND across members'
* modality flags. Missing arrays default to `["text"]` (same default
* as `mapRawModelToModelV2`).
*
* Defensive: empty members array → ALL capabilities `false`, limits zero.
* That's an intentional safety posture (you can't route through an empty
* combo, so OC should grey it out in the picker).
*
* Spec mapping: `cost` zeroed; `status = "active"`;
* `release_date = combo.release_date ?? ""`;
* `api = LCD (all-anthropic else openai-compatible)`;
* `name = combo.name ?? combo.id`.
*
* @param combo Raw `/api/combos` entry.
* @param members Raw `/v1/models` entries for THIS combo's member ids.
* Caller resolves `combo.models[].model` ids; unknown ids
* are silently dropped before this call.
* @param providerId OpenCode provider id (multi-instance aware).
* @param baseURL Resolved gateway base URL for ModelV2.api.url.
*/
export function mapComboToModelV2(
combo: OmniRouteRawCombo,
members: OmniRouteRawModelEntry[],
providerId: string,
baseURL: string,
apiFormat?: ApiFormatV2
): ModelV2 {
// `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;
const memberInMods = members.map((m) => new Set(m.input_modalities ?? ["text"]));
const memberOutMods = members.map((m) => new Set(m.output_modalities ?? ["text"]));
const modalityAllHave = (sets: Array<Set<string>>, key: string): boolean =>
hasMembers && sets.every((s) => s.has(key));
const contextValues = members
.map((m) => m.context_length)
.filter((v): v is number => typeof v === "number" && v > 0);
const outputValues = members
.map((m) => m.max_output_tokens)
.filter((v): v is number => typeof v === "number" && v > 0);
const inputValues = members
.map((m) => m.max_input_tokens)
.filter((v): v is number => typeof v === "number" && v > 0);
const everyDeclaresInput = hasMembers && inputValues.length === members.length;
const capabilities: ModelV2["capabilities"] = {
temperature:
hasMembers && members.every((m) => (m.capabilities?.temperature ?? true) !== false),
reasoning:
hasMembers &&
members.every((m) => Boolean(m.capabilities?.reasoning || m.capabilities?.thinking)),
attachment:
hasMembers &&
members.every((m) => Boolean(m.capabilities?.attachment ?? m.capabilities?.vision ?? false)),
toolcall: hasMembers && members.every((m) => Boolean(m.capabilities?.tool_calling ?? false)),
input: {
text: modalityAllHave(memberInMods, "text"),
audio: modalityAllHave(memberInMods, "audio"),
image: modalityAllHave(memberInMods, "image"),
video: modalityAllHave(memberInMods, "video"),
pdf: modalityAllHave(memberInMods, "pdf"),
},
output: {
text: modalityAllHave(memberOutMods, "text"),
audio: modalityAllHave(memberOutMods, "audio"),
image: modalityAllHave(memberOutMods, "image"),
video: modalityAllHave(memberOutMods, "video"),
pdf: modalityAllHave(memberOutMods, "pdf"),
},
interleaved: hasMembers && members.every((m) => Boolean(m.capabilities?.thinking)),
};
// Combos span multiple providers. Use Anthropic format only when ALL
// members resolve to Anthropic — otherwise fall back to OpenAI-compat
// (lowest common denominator that every upstream understands).
const comboApiBlock = (() => {
if (!hasMembers) return resolveApiBlockV2(combo.id, baseURL, apiFormat);
const allAnthropic = members.every(
(m) => resolveApiBlockV2(m.id, baseURL, apiFormat).id === "anthropic"
);
return allAnthropic
? resolveApiBlockV2(members[0].id, baseURL, apiFormat)
: resolveApiBlockV2(combo.id, baseURL, apiFormat);
})();
return {
id: combo.id,
providerID: providerId,
api: comboApiBlock,
name: combo.name && combo.name.trim().length > 0 ? combo.name : combo.id,
capabilities,
cost: {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
},
limit: {
context:
typeof combo.computed_context_length === "number" && combo.computed_context_length > 0
? combo.computed_context_length
: contextValues.length > 0
? Math.min(...contextValues)
: 0,
...(everyDeclaresInput ? { input: Math.min(...inputValues) } : {}),
output: outputValues.length > 0 ? Math.min(...outputValues) : 0,
},
status: "active",
options: {},
headers: {},
release_date: combo.release_date ?? "",
};
}

View File

@@ -0,0 +1,606 @@
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
import { buildModelDisplayName } from "./naming.js";
import type { FreeModelFreeType } from "./naming.js";
export interface OmniRouteEnrichmentEntry {
/** Human-readable display name. Replaces ModelV2.name when present. */
name?: string;
/** Per-million-token cost overlay onto ModelV2.cost. */
pricing?: {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
};
/**
* Provider alias prefix seen in `/v1/models` ids (e.g. `cc`, `gemini`).
* Populated by `defaultOmniRouteEnrichmentFetcher` from
* `/api/pricing/models` keys. Drives the `usableOnly` alias↔canonical
* resolution.
*/
providerAlias?: string;
/**
* Canonical provider id used by `/api/providers` connections (e.g.
* `claude`, `gemini`, `kiro`). Populated from the per-provider
* `entry.id` field inside `/api/pricing/models`.
*/
providerCanonical?: string;
/**
* Human-readable upstream provider label (e.g. `Claude`, `Kiro`,
* `Windsurf`, `GitHub Models`). Populated from the per-provider
* `entry.name` field inside `/api/pricing/models`. Used by the
* `providerTag` feature to suffix `ModelV2.name` with the routing
* destination so the OC TUI picker can differentiate the same
* model id sold through different upstream connections.
*/
providerDisplayName?: string;
/** Free-model budget type (from freeModelCatalog). */
freeType?: FreeModelFreeType;
/** Monthly token budget for recurring free models. */
monthlyTokens?: number;
/** Credit token budget for credit-based free models. */
creditTokens?: number;
}
/** Map keyed by full model id (possibly namespaced, e.g. `cc/claude-sonnet-4-6`). */
export type OmniRouteEnrichmentMap = Map<string, OmniRouteEnrichmentEntry>;
/**
* Reverse-index the enrichment map from `providerCanonical → providerAlias`.
*
* OmniRoute's `/api/pricing/models` is keyed by short ALIAS (`cc`, `cx`,
* `pol`). But `/v1/models` exposes some models a SECOND time under their
* CANONICAL name (`claude/claude-opus-4-7`, `codex/gpt-5.5`,
* `pollinations/midjourney`). Without a reverse map, those canonical
* rows miss enrichment entirely and surface as raw ids in the picker.
*
* Built once per refresh from the enrichment entries themselves — no
* hardcoded registry. Only records `canonical → alias` mappings when
* both are present AND distinct (skips slots where alias === canonical
* like `kiro`).
*/
export function buildCanonicalToAliasMap(
enrichment: OmniRouteEnrichmentMap | undefined
): Map<string, string> {
const out = new Map<string, string>();
if (!enrichment) return out;
for (const entry of enrichment.values()) {
const alias = typeof entry.providerAlias === "string" ? entry.providerAlias.trim() : "";
const canonical =
typeof entry.providerCanonical === "string" ? entry.providerCanonical.trim() : "";
if (alias.length === 0 || canonical.length === 0) continue;
if (alias === canonical) continue;
if (!out.has(canonical)) out.set(canonical, alias);
}
return out;
}
/**
* Enrichment lookup with alias-fallback chain.
*
* Resolution order (first hit wins):
*
* 1. `enrichment.get(rawId)` — direct hit on `<prefix>/<modelId>` or
* bare id (the fetcher writes under both forms).
* 2. If `rawId` is `<canonical>/<modelId>` and `canonicalToAlias` has
* a mapping for `canonical`, try `<alias>/<modelId>`. This rescues
* duplicate rows like `claude/claude-opus-4-7` (canonical) when
* enrichment only indexed under `cc/claude-opus-4-7` (alias).
* 3. Bare `<modelId>` as a last resort. Already covered by step 1 in
* practice (fetcher writes bare keys), but kept defensive.
*
* Returns `undefined` when no lookup hits.
*/
export function lookupEnrichment(
rawId: string,
enrichment: OmniRouteEnrichmentMap | undefined,
canonicalToAlias: Map<string, string>
): OmniRouteEnrichmentEntry | undefined {
if (!enrichment) return undefined;
const direct = enrichment.get(rawId);
if (direct) return direct;
const slash = rawId.indexOf("/");
if (slash > 0) {
const prefix = rawId.slice(0, slash);
const modelId = rawId.slice(slash + 1);
const alias = canonicalToAlias.get(prefix);
if (alias && alias !== prefix) {
const viaAlias = enrichment.get(`${alias}/${modelId}`);
if (viaAlias) return viaAlias;
}
const bare = enrichment.get(modelId);
if (bare) return bare;
}
return undefined;
}
/**
* Pre-pass: detect raw rows that are the CANONICAL twin of an ALIAS row
* already in the catalog. Returns the set of canonical-keyed ids to skip
* during the raw-model loop so each model surfaces exactly once under
* its enriched alias key.
*
* Example: `/v1/models` returns BOTH `cc/claude-opus-4-7` and
* `claude/claude-opus-4-7`. The former is enriched (alias `cc` exists
* in `/api/pricing/models`); the latter is raw. We keep `cc/...` and
* drop `claude/...`.
*
* Built once per refresh. Cheap — O(M) where M = raw model count.
*/
export function canonicalDedupSet(
rawModels: ReadonlyArray<{ id: string }>,
canonicalToAlias: Map<string, string>
): Set<string> {
const drop = new Set<string>();
if (canonicalToAlias.size === 0) return drop;
// Index every alias key present in the raw catalog.
const aliasKeys = new Set<string>();
for (const m of rawModels) {
if (typeof m.id === "string" && m.id.length > 0) aliasKeys.add(m.id);
}
for (const m of rawModels) {
if (typeof m.id !== "string" || m.id.length === 0) continue;
const slash = m.id.indexOf("/");
if (slash <= 0) continue;
const prefix = m.id.slice(0, slash);
const modelId = m.id.slice(slash + 1);
const alias = canonicalToAlias.get(prefix);
if (!alias || alias === prefix) continue;
// Canonical row only gets suppressed if the alias row actually
// exists — otherwise we'd hide the model entirely.
if (aliasKeys.has(`${alias}/${modelId}`)) drop.add(m.id);
}
return drop;
}
/**
* Build a per-alias index of enrichment metadata so we can render the
* provider prefix even for raw models that don't have their own
* curated `/api/pricing/models` entry.
*
* Real example: OmniRoute's `pricing['cohere']` slot lists 10 curated
* models but `/v1/models` also returns `cohere/rerank-multilingual-v3.0`
* and `cohere/rerank-v4.0-fast` (not in the curated 10). Without this
* index, those rows surface in the picker as `cohere/...` with no
* `Cohere - ` prefix because the per-model enrichment lookup misses.
*
* This index records the first non-empty `providerDisplayName` seen
* for each alias, plus the alias itself. Callers use it to synthesize
* a minimal `OmniRouteEnrichmentEntry` whenever the direct lookup
* misses but the raw id's prefix matches a known alias.
*
* Built once per refresh; first-wins on duplicate alias (matches
* `buildCanonicalToAliasMap` semantics).
*/
export function buildAliasIndex(
enrichment: OmniRouteEnrichmentMap | undefined
): Map<string, OmniRouteEnrichmentEntry> {
const out = new Map<string, OmniRouteEnrichmentEntry>();
if (!enrichment) return out;
for (const entry of enrichment.values()) {
const alias = typeof entry.providerAlias === "string" ? entry.providerAlias.trim() : "";
if (alias.length === 0) continue;
if (out.has(alias)) {
// First-wins, but upgrade to the first entry that carries a
// non-empty providerDisplayName so the prefix renders nicely.
const existing = out.get(alias);
if (
existing &&
(!existing.providerDisplayName || existing.providerDisplayName.trim().length === 0) &&
typeof entry.providerDisplayName === "string" &&
entry.providerDisplayName.trim().length > 0
) {
out.set(alias, entry);
}
continue;
}
out.set(alias, entry);
}
return out;
}
/**
* Resolve a synthesised enrichment entry for `applyProviderTag` /
* `shortProviderLabel` consumption, combining two sources:
*
* 1. The direct per-model enrichment match (if present).
* 2. A per-alias fallback derived from `buildAliasIndex` — covers raw
* ids whose prefix matches a known alias but the specific model
* id wasn't curated in `/api/pricing/models`. Example:
* `cohere/rerank-multilingual-v3.0` falls back to the cohere slot's
* `providerDisplayName='Cohere'` even though that specific id
* isn't in the curated 10-model list.
*
* Returns `undefined` when neither source surfaces an alias.
*
* NOTE: this function is read-only over its inputs; it never mutates
* the underlying `direct` entry. When it falls back to the alias
* index, it constructs a fresh minimal entry exposing only the
* provider-prefix fields (`providerAlias`, `providerCanonical`,
* `providerDisplayName`). Other fields (name, pricing) are explicitly
* left undefined so `applyEnrichment` won't accidentally overwrite a
* model name with the alias-slot label.
*/
export function resolveProviderTagEntry(
rawId: string,
direct: OmniRouteEnrichmentEntry | undefined,
aliasIndex: Map<string, OmniRouteEnrichmentEntry>,
canonicalToAlias?: Map<string, string>
): OmniRouteEnrichmentEntry | undefined {
if (direct) {
const alias = typeof direct.providerAlias === "string" ? direct.providerAlias.trim() : "";
const display =
typeof direct.providerDisplayName === "string" ? direct.providerDisplayName.trim() : "";
if (alias.length > 0 || display.length > 0) return direct;
}
const slash = rawId.indexOf("/");
if (slash <= 0) return direct;
const prefix = rawId.slice(0, slash);
// 1. Direct alias lookup (`cohere/...` → cohere slot keyed by alias=cohere).
let fromAlias = aliasIndex.get(prefix);
// 2. Canonical fallback (`pollinations/...` → look up via alias `pol`).
if (!fromAlias && canonicalToAlias) {
const alias = canonicalToAlias.get(prefix);
if (alias) fromAlias = aliasIndex.get(alias);
}
if (!fromAlias) return direct;
// Synthesize: borrow only the provider-prefix metadata.
return {
providerAlias: fromAlias.providerAlias,
providerCanonical: fromAlias.providerCanonical,
providerDisplayName: fromAlias.providerDisplayName,
};
}
/**
* Fetcher contract: resolves the enrichment overlay (display names +
* pricing + free-tier budgets) from a running OmniRoute instance.
*/
/**
* Reports a source that could not be read. Enrichment stays best-effort, but
* a caller that swallows this loses display names, provider tags, canonical
* dedupe and pricing with no way to tell why.
*/
export type OmniRouteEnrichmentSourceError = (endpoint: string, reason: string) => void;
export type OmniRouteEnrichmentFetcher = (
baseURL: string,
apiKey: string,
timeoutMs?: number,
onSourceError?: OmniRouteEnrichmentSourceError
) => Promise<OmniRouteEnrichmentMap>;
function trimTrailingSlashes(value: string): string {
let i = value.length;
while (i > 0 && value.charCodeAt(i - 1) === 0x2f /* "/" */) i--;
return i === value.length ? value : value.slice(0, i);
}
/**
* Default enrichment fetcher — pulls nice display names from
* `GET /api/pricing/models` and merges per-million-token pricing from
* `GET /api/pricing` (the actual pricing source — `/api/pricing/models` is
* a catalog endpoint whose entries are `{id, name, custom}` only).
*
* `/api/pricing/models` shape (catalog):
* - `{ [providerAlias]: { id, alias, name, models: [{ id, name, custom }] } }`
*
* `/api/pricing` shape (pricing only):
* - `{ [providerAlias]: { [modelId]: { input, output, cached, reasoning, cache_creation } } }`
* where values are USD per million tokens.
*
* The two responses are joined on `(providerAlias, modelId)` and the merged
* entries are stored under both `${providerAlias}/${modelId}` and bare
* `${modelId}` keys so downstream lookups against either form succeed.
*
* Soft-fails (returns whatever was collected) on non-2xx or parse errors;
* the two fetches are independent so one missing source still surfaces the
* other. A third best-effort fetch attaches free-tier budgets from
* `/api/free-tier/summary`.
*
* Ported from the v1 plugin (`index.ts:1906-2106`); the shared logger is
* the only intentional difference (no plugin-contract dependency here).
*/
export const defaultOmniRouteEnrichmentFetcher: OmniRouteEnrichmentFetcher = async (
baseURL,
apiKey,
timeoutMs = 10_000,
onSourceError
) => {
const report = (endpoint: string, reason: unknown): void => {
onSourceError?.(endpoint, reason instanceof Error ? reason.message : String(reason));
};
const out: OmniRouteEnrichmentMap = new Map();
if (!baseURL || !apiKey) return out;
const root = trimTrailingSlashes(baseURL.replace(/\/v1\/?$/, ""));
const headers = {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
};
// 1. Catalog with nice display names.
const catalogAc = new AbortController();
const catalogTimer = setTimeout(() => catalogAc.abort(), timeoutMs);
let catalogStatus = 0;
try {
const res = await fetch(`${root}/api/pricing/models`, {
method: "GET",
headers,
signal: catalogAc.signal,
});
catalogStatus = res.status;
if (res.ok) {
const body = (await res.json()) as unknown;
const providers =
(body as { providers?: Record<string, { models?: unknown[] }> })?.providers ??
(body as Record<string, { models?: unknown[] }>);
if (providers && typeof providers === "object") {
for (const [providerAlias, slot] of Object.entries(providers)) {
if (!slot || typeof slot !== "object") continue;
const models = (slot as { models?: unknown[] }).models;
if (!Array.isArray(models)) continue;
const canonicalRaw = (slot as { id?: unknown }).id;
const providerCanonical =
typeof canonicalRaw === "string" && canonicalRaw.length > 0
? canonicalRaw
: providerAlias;
const slotNameRaw = (slot as { name?: unknown }).name;
const providerDisplayName =
typeof slotNameRaw === "string" && slotNameRaw.trim().length > 0
? slotNameRaw.trim()
: undefined;
for (const m of models) {
if (!m || typeof m !== "object") continue;
const id = (m as { id?: unknown }).id;
if (typeof id !== "string" || id.length === 0) continue;
const name = (m as { name?: unknown }).name;
const entry: OmniRouteEnrichmentEntry = {
providerAlias,
providerCanonical,
};
if (providerDisplayName) entry.providerDisplayName = providerDisplayName;
if (typeof name === "string" && name.trim().length > 0) entry.name = name;
const namespaced = `${providerAlias}/${id}`;
if (!out.has(namespaced)) out.set(namespaced, entry);
// The bare id is a fallback for ids that arrive unnamespaced. It
// gets its OWN copy: sharing the object would let a later write
// for one provider — a price, typically — land on another
// provider's entry that happens to sell the same model id.
if (!out.has(id)) out.set(id, { ...entry });
}
}
}
}
} catch (err) {
// Network error, timeout, abort: nothing collected from THIS source, but
// the pricing fetch below may still succeed — let it try, then decide at
// the end whether the whole overlay failed (see the throw below).
report("/api/pricing/models", err);
catalogStatus = -1;
} finally {
clearTimeout(catalogTimer);
}
if (
catalogStatus !== 0 &&
catalogStatus !== -1 &&
(catalogStatus < 200 || catalogStatus >= 300)
) {
report("/api/pricing/models", `HTTP ${catalogStatus}`);
}
// 2. Pricing values from /api/pricing.
const priceAc = new AbortController();
const priceTimer = setTimeout(() => priceAc.abort(), timeoutMs);
let priceStatus = 0;
try {
const res = await fetch(`${root}/api/pricing`, {
method: "GET",
headers,
signal: priceAc.signal,
});
priceStatus = res.status;
if (res.ok) {
const body = (await res.json()) as unknown;
if (body && typeof body === "object" && !Array.isArray(body)) {
for (const [providerAlias, slot] of Object.entries(body as Record<string, unknown>)) {
if (!slot || typeof slot !== "object" || Array.isArray(slot)) continue;
for (const [modelId, raw] of Object.entries(slot as Record<string, unknown>)) {
if (!raw || typeof raw !== "object") continue;
const p = raw as Record<string, unknown>;
const parsed: NonNullable<OmniRouteEnrichmentEntry["pricing"]> = {};
if (typeof p.input === "number") parsed.input = p.input;
if (typeof p.output === "number") parsed.output = p.output;
const cacheRead =
typeof p.cached === "number"
? p.cached
: typeof p.cacheRead === "number"
? p.cacheRead
: undefined;
if (typeof cacheRead === "number") parsed.cacheRead = cacheRead;
const cacheWrite =
typeof p.cache_creation === "number"
? p.cache_creation
: typeof p.cacheWrite === "number"
? p.cacheWrite
: undefined;
if (typeof cacheWrite === "number") parsed.cacheWrite = cacheWrite;
if (Object.keys(parsed).length === 0) continue;
const namespaced = `${providerAlias}/${modelId}`;
const existingNs = out.get(namespaced);
if (existingNs) {
existingNs.pricing = { ...(existingNs.pricing ?? {}), ...parsed };
} else {
out.set(namespaced, { pricing: parsed });
}
const existingBare = out.get(modelId);
// Only the provider that owns the bare entry may price it.
// Otherwise the second provider selling the same model id
// overwrites the first one's price, and the picker shows a cost
// that belongs to a different connection.
const bareBelongsHere =
existingBare === undefined || existingBare.providerAlias === undefined
? true
: existingBare.providerAlias === providerAlias;
if (bareBelongsHere) {
if (existingBare) {
existingBare.pricing = { ...(existingBare.pricing ?? {}), ...parsed };
} else {
out.set(modelId, { pricing: parsed });
}
}
}
}
}
}
} catch (err) {
// Same as above: report, mark this source failed, let the remaining
// sources try before deciding.
report("/api/pricing", err);
priceStatus = -1;
} finally {
clearTimeout(priceTimer);
}
if (priceStatus !== 0 && priceStatus !== -1 && (priceStatus < 200 || priceStatus >= 300)) {
report("/api/pricing", `HTTP ${priceStatus}`);
}
// 3. Free model budgets from /api/free-tier/summary (best-effort).
const freeAc = new AbortController();
const freeTimer = setTimeout(() => freeAc.abort(), timeoutMs);
let freeStatus = 0;
try {
const res = await fetch(`${root}/api/free-tier/summary`, {
method: "GET",
headers,
signal: freeAc.signal,
});
freeStatus = res.status;
if (res.ok) {
const body = (await res.json()) as unknown;
const perModel: unknown[] =
body && typeof body === "object" && Array.isArray((body as { perModel?: unknown }).perModel)
? ((body as { perModel: unknown[] }).perModel as unknown[])
: Array.isArray(body)
? (body as unknown[])
: [];
for (const fm of perModel) {
if (!fm || typeof fm !== "object") continue;
const fmObj = fm as Record<string, unknown>;
const provider = typeof fmObj.provider === "string" ? fmObj.provider : "";
const modelId = typeof fmObj.modelId === "string" ? fmObj.modelId : "";
const freeType = typeof fmObj.freeType === "string" ? fmObj.freeType : "";
if (!modelId || !freeType) continue;
const monthlyTokens =
typeof fmObj.monthlyTokens === "number" ? fmObj.monthlyTokens : undefined;
const creditTokens =
typeof fmObj.creditTokens === "number" ? fmObj.creditTokens : undefined;
const displayName = typeof fmObj.displayName === "string" ? fmObj.displayName : "";
const candidates = [
`${provider}/${modelId}`,
modelId,
...(displayName ? [displayName] : []),
];
for (const key of candidates) {
const entry = out.get(key);
if (entry) {
entry.freeType = freeType as FreeModelFreeType;
if (monthlyTokens !== undefined) entry.monthlyTokens = monthlyTokens;
if (creditTokens !== undefined) entry.creditTokens = creditTokens;
break;
}
}
}
}
} catch (err) {
report("/api/free-tier/summary", err);
// Soft-fail; free metadata is optional.
} finally {
clearTimeout(freeTimer);
}
if (freeStatus !== 0 && (freeStatus < 200 || freeStatus >= 300)) {
report("/api/free-tier/summary", `HTTP ${freeStatus}`);
}
// A source that failed contributes nothing — but the overlay keeps its own
// memory per source: names collected while the catalog endpoint answered
// survive a later pricing outage, and prices collected while pricing
// answered survive a later catalog outage. Without this a single flapping
// source wipes the other source's good data on every refresh. So a failed
// catalog source throws (the caller keeps last-known) UNLESS the pricing
// source brought something on THIS call — then whatever was collected,
// names or prices, is the gateway's answer and ships as-is. (Status alone
// cannot decide: a 2xx pricing answer with zero priced models is still an
// answer, but it carries nothing to save the overlay with.)
const sourceFailed = (status: number): boolean =>
status === -1 || (status !== 0 && (status < 200 || status >= 300));
const catalogFailed = sourceFailed(catalogStatus);
const pricingBroughtSomething = !sourceFailed(priceStatus) && out.size > 0;
if (catalogFailed && !pricingBroughtSomething) {
throw new Error(
`enrichment catalog source failed (pricing/models: ${catalogStatus}, pricing: ${priceStatus})`
);
}
return out;
};
/**
* Apply enrichment overlay onto a ModelV2 entry. Mutates and returns the
* passed entry for convenience.
*/
/** What the caller knows about the entry that the overlay itself cannot tell. */
export interface EnrichmentDisplayContext {
/** Combos never carry a provider tag: they route across providers. */
isCombo?: boolean;
isAutoCombo?: boolean;
/** Set false to publish the bare display name, without the provider tag. */
providerTag?: boolean;
}
/**
* Fold the overlay into a mapped model: display name, provider tag, free-tier
* marker and budget, and pricing.
*
* The name is built rather than copied, because the gateway ships the parts
* separately — the pricing catalog gives a display name and an upstream
* provider label, the free-tier summary gives the budget. A picker showing
* `Claude - [Free] Sonnet 4.6 · 1M/mo` tells the user which connection serves
* the model and what it costs them; `claude-sonnet-4-6` tells them nothing.
*/
export function applyEnrichment(
model: ModelV2,
enrichment: OmniRouteEnrichmentEntry | undefined,
context: EnrichmentDisplayContext = {}
): ModelV2 {
if (!enrichment) return model;
const built = buildModelDisplayName({
rawId: model.name && model.name.length > 0 ? model.name : model.id,
enrichmentName: enrichment.name,
providerAlias: context.providerTag === false ? undefined : enrichment.providerAlias,
providerDisplayName: context.providerTag === false ? undefined : enrichment.providerDisplayName,
isFree: enrichment.freeType !== undefined,
freeType: enrichment.freeType,
monthlyTokens: enrichment.monthlyTokens,
creditTokens: enrichment.creditTokens,
isCombo: context.isCombo,
isAutoCombo: context.isAutoCombo,
});
if (built.trim().length > 0) {
model.name = built;
}
if (enrichment.pricing) {
if (typeof enrichment.pricing.input === "number") {
model.cost.input = enrichment.pricing.input;
}
if (typeof enrichment.pricing.output === "number") {
model.cost.output = enrichment.pricing.output;
}
if (typeof enrichment.pricing.cacheRead === "number") {
model.cost.cache.read = enrichment.pricing.cacheRead;
}
if (typeof enrichment.pricing.cacheWrite === "number") {
model.cost.cache.write = enrichment.pricing.cacheWrite;
}
}
return model;
}

View File

@@ -0,0 +1,127 @@
import { createHash } from "node:crypto";
/**
* Fingerprint the CONTENT of a catalog snapshot (not endpoint/credential
* identity) so lazy refresh can reload-after-publish only when something
* actually changed.
*
* sha256 over sorted `id + "|" + (release_date ?? "")` lines for models
* plus sorted combo ids, joined with `\n`. Order-insensitive: two
* snapshots with the same entries in different order hash identically.
*/
export function catalogContentFingerprint(
models: { id: string; release_date?: string }[],
combos: { id: string }[],
autoCombos: { id: string }[] = []
): string {
const modelLines = models
.map((m) => `${m.id}|${m.release_date ?? ""}`)
.sort()
.join("\n");
const comboLines = combos
.map((c) => c.id)
.sort()
.join("\n");
const autoLines = autoCombos
.map((c) => c.id)
.sort()
.join("\n");
return createHash("sha256").update(`${modelLines}\n${comboLines}\n${autoLines}`).digest("hex");
}
/**
* Digest of the optional tier (auto-combos, provider connections, enrichment).
* The catalog fingerprint covers model and combo ids only, so an overlay that
* moves — a renamed model, a provider going unusable — leaves it unchanged.
* Reloading on every refresh instead would ask the host to rebuild its catalog
* once per TTL window for nothing.
*/
export function optionalTierFingerprint(
autoCombos: { id: string }[],
providers: {
id?: string;
name?: string;
testStatus?: string;
isActive?: boolean;
providerDisplayName?: string;
}[],
enrichment:
| Map<
string,
{
name?: string;
freeType?: string;
providerDisplayName?: string;
monthlyTokens?: number;
creditTokens?: number;
pricing?: Record<string, number | undefined>;
}
>
| undefined,
combos: { id: string; name?: string; models?: unknown[] }[] = []
): string {
const parts: string[] = [];
// Membership matters: a combo keeping its id while losing a member is a
// different combo to anyone picking it.
parts.push(
combos
.map((c) => c.id + "|" + (c.name ?? "") + "|" + String(c.models?.length ?? 0))
.sort()
.join(",")
);
parts.push(
autoCombos
.map((c) => c.id)
.sort()
.join(",")
);
// A provider going quiet or getting renamed is as visible to the user as a
// price move: its activity flag and display name belong in the digest.
parts.push(
providers
.map(
(p) =>
(p.id ?? p.name ?? "") +
":" +
(p.testStatus ?? "") +
":" +
String(p.isActive ?? "") +
":" +
(p.providerDisplayName ?? "")
)
.sort()
.join(",")
);
if (enrichment !== undefined) {
const rows: string[] = [];
for (const [key, entry] of enrichment) {
// Pricing is part of what the user sees, so a price move must reach
// the picker without waiting for an id to change.
const price = entry.pricing
? Object.entries(entry.pricing)
.map(([k, v]) => k + "=" + String(v ?? ""))
.sort()
.join(";")
: "";
rows.push(
key +
"|" +
(entry.name ?? "") +
"|" +
(entry.freeType ?? "") +
"|" +
(entry.providerDisplayName ?? "") +
"|" +
String(entry.monthlyTokens ?? "") +
";" +
String(entry.creditTokens ?? "") +
"|" +
price
);
}
rows.sort();
parts.push(String(enrichment.size));
parts.push(rows.join("\n"));
}
return createHash("sha256").update(parts.join(" ")).digest("hex");
}

View File

@@ -0,0 +1,166 @@
/**
* Gemini rejects several standard JSON-Schema keywords in tool declarations
* and answers `400 INVALID_ARGUMENT` for the whole request when it meets one.
* The keywords carry no meaning Gemini would honour anyway, so stripping them
* costs nothing and is what keeps a tool-calling chain alive.
*/
/**
* Keywords Gemini rejects outright. `$ref` is deliberately NOT here: it
* cannot be stripped without turning the schema into "accept anything", so
* tools carrying one are forwarded untouched (see below). `ref` is not a
* JSON Schema keyword at all, and stripping it by name destroys a legitimate
* tool parameter called `ref` — a walker that cannot tell a keyword from a
* property name mangles the schema it was meant to repair.
*/
const REJECTED_KEYWORDS = new Set(["$schema", "additionalProperties"]);
/** Keys whose value is itself a schema. */
const SCHEMA_VALUE_KEYS = [
"items",
"additionalItems",
"contains",
"not",
"if",
"then",
"else",
"propertyNames",
"contentSchema",
"unevaluatedItems",
"unevaluatedProperties",
];
/** Keys whose value maps arbitrary NAMES to schemas — never keyword space. */
const SCHEMA_MAP_KEYS = [
"properties",
"patternProperties",
"$defs",
"definitions",
"dependentSchemas",
"dependencies",
];
/** Keys whose value is a list of schemas. */
const SCHEMA_LIST_KEYS = ["allOf", "anyOf", "oneOf", "prefixItems"];
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** True when any schema in the tree carries a `$ref` we cannot resolve. */
function hasUnresolvableRef(node: unknown): boolean {
if (Array.isArray(node)) return node.some(hasUnresolvableRef);
if (!isRecord(node)) return false;
if ("$ref" in node) return true;
for (const key of SCHEMA_VALUE_KEYS) if (hasUnresolvableRef(node[key])) return true;
// (arrays are handled by the Array branch at the top of this function)
for (const key of SCHEMA_LIST_KEYS) if (hasUnresolvableRef(node[key])) return true;
for (const key of SCHEMA_MAP_KEYS) {
const map = node[key];
if (isRecord(map) && Object.values(map).some(hasUnresolvableRef)) return true;
}
return false;
}
/**
* Strip the rejected keywords in place, walking only the positions where a
* schema can appear. Property names are never treated as keywords, so a tool
* whose parameter happens to be called `additionalProperties` keeps it.
* Returns whether anything was removed.
*/
function stripAtSchemaPositions(node: Record<string, unknown>): boolean {
let changed = false;
for (const keyword of REJECTED_KEYWORDS) {
if (keyword in node) {
delete node[keyword];
changed = true;
}
}
for (const key of SCHEMA_VALUE_KEYS) {
const child = node[key];
if (isRecord(child)) {
changed = stripAtSchemaPositions(child) || changed;
continue;
}
// `items` also takes the tuple form: an array of schemas, one per position.
if (Array.isArray(child)) {
for (const item of child) {
if (isRecord(item)) changed = stripAtSchemaPositions(item) || changed;
}
}
}
for (const key of SCHEMA_LIST_KEYS) {
const list = node[key];
if (Array.isArray(list)) {
for (const child of list) {
if (isRecord(child)) changed = stripAtSchemaPositions(child) || changed;
}
}
}
for (const key of SCHEMA_MAP_KEYS) {
const map = node[key];
if (!isRecord(map)) continue;
for (const child of Object.values(map)) {
if (isRecord(child)) changed = stripAtSchemaPositions(child) || changed;
}
}
return changed;
}
/**
* Families Google actually ships, anchored on the last path segment. A plain
* substring test also claims `gemini-compatible-proxy` and `my-gemini-wrapper`
* — and since the sanitiser removes keywords, a false positive is not free.
*/
const GEMINI_MODEL_ID =
/^gemini(?:[-_.](?:\d|pro|flash|ultra|nano|exp|thinking|embedding|live|imagen)|$)/i;
/**
* True for the routing forms a Gemini model reaches a gateway under — bare
* (`gemini-2.5-flash`), canonical (`models/gemini-1.5-pro`) and prefixed
* (`google-vertex/gemini-2.0`).
*/
export function isGeminiModelId(modelId: unknown): boolean {
if (typeof modelId !== "string") return false;
const segment = modelId.split("/").pop() ?? "";
return GEMINI_MODEL_ID.test(segment);
}
/** The subset of an AI SDK tool declaration this module reads. */
export interface ToolWithInputSchema {
readonly type?: string;
readonly inputSchema?: unknown;
readonly [key: string]: unknown;
}
/**
* Return a copy of `tools` whose input schemas are free of the keywords Gemini
* rejects, or `undefined` when there was nothing to strip — which lets the
* caller forward the original array and skip the clone entirely.
*
* Tools this module cannot read (provider-defined tools, entries without an
* object schema) are carried through unchanged rather than dropped: a tool the
* sanitiser does not understand is still a tool the model needs.
*/
export function sanitizeToolInputSchemas<T extends ToolWithInputSchema>(
tools: readonly T[] | undefined
): T[] | undefined {
if (tools === undefined || tools.length === 0) return undefined;
let changed = false;
const out = tools.map((tool) => {
if (!isRecord(tool.inputSchema)) return tool;
// A schema carrying something uncloneable is not worth failing a request
// over: forward the tool untouched and let the model answer.
// A `$ref` cannot be stripped without turning the schema into "anything
// goes", and cannot be resolved here. Forward the tool untouched and let
// the gateway answer rather than silently widen what the model may send.
if (hasUnresolvableRef(tool.inputSchema)) return tool;
let schema: Record<string, unknown>;
try {
schema = structuredClone(tool.inputSchema) as Record<string, unknown>;
} catch {
return tool;
}
if (!stripAtSchemaPositions(schema)) return tool;
changed = true;
return { ...tool, inputSchema: schema };
});
return changed ? out : undefined;
}

View File

@@ -0,0 +1,9 @@
export * from "./models-map.js";
export * from "./combos-map.js";
export * from "./auto-combos.js";
export * from "./naming.js";
export * from "./enrich.js";
export * from "./fingerprint.js";
export * from "./logger.js";
export * from "./usable.js";
export * from "./gemini.js";

View File

@@ -0,0 +1,81 @@
/**
* Namespaced leveled logger shared by the OmniRoute OpenCode packages.
*
* Levels: error < warn < info < debug. Default: warn.
* Ported from the v1 plugin (`logger.ts`) so both new packages share one
* sink instead of raw `console.warn` / `console.log` calls.
*/
export type LogLevel = "error" | "warn" | "info" | "debug";
const LEVEL_ORDER: Record<LogLevel, number> = {
error: 0,
warn: 1,
info: 2,
debug: 3,
};
const TAG = "[omniroute-plugin]";
function shouldLog(current: LogLevel, target: LogLevel): boolean {
return LEVEL_ORDER[current] >= LEVEL_ORDER[target];
}
let _level: LogLevel = "warn";
export function setLogLevel(level: LogLevel): void {
_level = level;
}
export function getLogLevel(): LogLevel {
return _level;
}
function fmt(level: LogLevel, msg: string, tag?: string): string {
const prefix = tag ? `${TAG}${tag}` : TAG;
return `${prefix} [${level.toUpperCase()}] ${msg}`;
}
function buildLogger(getLevel: () => LogLevel) {
return {
error(msg: string, ...args: unknown[]): void {
if (shouldLog(getLevel(), "error")) console.error(fmt("error", msg), ...args);
},
warn(msg: string, ...args: unknown[]): void {
if (shouldLog(getLevel(), "warn")) console.warn(fmt("warn", msg), ...args);
},
info(msg: string, ...args: unknown[]): void {
if (shouldLog(getLevel(), "info")) console.warn(fmt("info", msg), ...args);
},
debug(msg: string, ...args: unknown[]): void {
if (shouldLog(getLevel(), "debug")) console.warn(fmt("debug", msg), ...args);
},
/** Always emit regardless of level (for critical init breadcrumbs). */
always(msg: string, ...args: unknown[]): void {
console.warn(TAG, msg, ...args);
},
child(tag: string) {
return {
error: (msg: string, ...args: unknown[]) =>
shouldLog(getLevel(), "error") && console.error(fmt("error", msg, tag), ...args),
warn: (msg: string, ...args: unknown[]) =>
shouldLog(getLevel(), "warn") && console.warn(fmt("warn", msg, tag), ...args),
info: (msg: string, ...args: unknown[]) =>
shouldLog(getLevel(), "info") && console.warn(fmt("info", msg, tag), ...args),
debug: (msg: string, ...args: unknown[]) =>
shouldLog(getLevel(), "debug") && console.warn(fmt("debug", msg, tag), ...args),
};
},
};
}
export type Logger = ReturnType<typeof buildLogger>;
/** Create an instance-scoped logger whose level cannot be changed by other instances. */
export function createLogger(level: LogLevel): Logger {
return buildLogger(() => level);
}
/** Backward-compatible module-global logger controlled by setLogLevel(). */
export const logger: Logger = buildLogger(() => _level);

View File

@@ -0,0 +1,323 @@
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
import { normaliseFreeLabel } from "./naming.js";
export interface OmniRouteRawModelEntry {
id: string;
object?: string;
owned_by?: string;
root?: string | null;
parent?: string | null;
context_length?: number;
max_input_tokens?: number;
max_output_tokens?: number;
input_modalities?: string[];
output_modalities?: string[];
capabilities?: {
tool_calling?: boolean;
reasoning?: boolean;
vision?: boolean;
thinking?: boolean;
attachment?: boolean;
structured_output?: boolean;
temperature?: boolean;
/** Runtime-learned or synced reasoning tiers (server-gated, blind-mapped). */
effort_tiers?: string[];
};
release_date?: string;
last_updated?: string;
api_format?: string;
}
/**
* Fetcher contract: returns the raw `/v1/models` entry list from a running
* OmniRoute instance. Surfaced as a dependency so unit tests can inject a
* stub without monkey-patching global `fetch`.
*
* Why we inline this instead of using `@omniroute/opencode-provider`'s
* `fetchLiveModels`: the sibling helper returns a stripped `{id, name,
* contextLength?}` shape that drops the `capabilities` / `*_modalities` /
* `max_*_tokens` blocks the mapping needs for ModelV2 pass-through.
*/
export type OmniRouteModelsFetcher = (
baseURL: string,
apiKey: string,
timeoutMs?: number
) => Promise<OmniRouteRawModelEntry[]>;
/**
* Default fetcher: `GET <baseURL>/v1/models` with bearer auth + AbortController
* timeout. Accepts both the `{object:"list", data:[…]}` envelope OmniRoute
* emits today and a bare-array envelope (defensive — keeps the plugin
* working if a future OmniRoute build trims the wrapper). Anything that
* isn't an object with a string `id` is filtered out silently.
*/
export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async (
baseURL,
apiKey,
timeoutMs = 10_000
) => {
if (!apiKey) throw new Error("[omniroute-v2] apiKey required to fetch /v1/models");
if (!baseURL) throw new Error("[omniroute-v2] baseURL required to fetch /v1/models");
const trimmed = trimTrailingSlashes(baseURL);
// Tolerate both `https://host` and `https://host/v1` forms — the gateway
// exposes /v1/models either way; we just don't want a double `/v1/v1`.
const url = /\/v\d+$/.test(trimmed) ? `${trimmed}/models` : `${trimmed}/v1/models`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
signal: controller.signal,
});
if (!res.ok) {
throw new Error(`[omniroute-v2] GET ${url} failed: ${res.status} ${res.statusText}`);
}
const body = (await res.json()) as unknown;
const rawList: unknown[] = Array.isArray(body)
? body
: body && typeof body === "object" && Array.isArray((body as { data?: unknown }).data)
? ((body as { data: unknown[] }).data as unknown[])
: [];
const out: OmniRouteRawModelEntry[] = [];
for (const r of rawList) {
if (r && typeof r === "object" && typeof (r as { id?: unknown }).id === "string") {
out.push(r as OmniRouteRawModelEntry);
}
}
return out;
} finally {
clearTimeout(timer);
}
};
// Manual trim helpers avoid polynomial-regex CodeQL warnings on
// user-supplied baseURL strings (string.replace(/\/+$/, "")). The same
// behaviour, no backtracking.
function trimTrailingSlashes(value: string): string {
let i = value.length;
while (i > 0 && value.charCodeAt(i - 1) === 0x2f /* "/" */) i--;
return i === value.length ? value : value.slice(0, i);
}
/**
* Ensure a baseURL ends with `/v1` so the OpenAI-compat SDK constructs
* `/v1/chat/completions` correctly. The Anthropic SDK does NOT want `/v1`
* (it appends `/v1/messages` automatically), so callers should branch on
* format first.
*/
export function ensureV1Suffix(url: string): string {
const trimmed = trimTrailingSlashes(url);
return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;
}
export interface ApiFormatV2 {
allowAnthropic?: boolean;
anthropicModels?: string[];
/**
* Deprecated v1 prefix list (default v1:
* `cc,claude,anthropic,kiro,kr`). Accepted for backward compatibility:
* prefix OR allowlist routes to anthropic, with a one-time deprecation
* warning pointing at `anthropicModels`. Prefer full IDs.
*/
anthropicPrefixes?: string[];
}
/** Default v1 prefix list, kept so copied v1 configs keep routing. */
export const DEFAULT_ANTHROPIC_PREFIXES_V1 = ["cc", "claude", "anthropic", "kiro", "kr"];
const warnedPrefixLists = new Set<string>();
function warnDeprecatedPrefixesOnce(prefixes: string[]): void {
const key = [...prefixes].sort().join(",");
if (warnedPrefixLists.has(key)) return;
warnedPrefixLists.add(key);
console.warn(
"[omniroute-plugin] [WARN] apiFormat.anthropicPrefixes is deprecated; convert to anthropicModels (full IDs)"
);
}
/**
* The Anthropic SDK block appends `/v1/messages` itself, so it needs the
* gateway root. A config carrying the `/v1` the OpenAI-compatible block wants
* would otherwise produce `/v1/v1/messages`.
*/
function stripV1Suffix(baseURL: string): string {
return baseURL.replace(/\/v1\/?$/, "");
}
/**
* Resolve the API block (id + url + npm package) for a given model id.
*
* v2 rule: a model routes to the Anthropic SDK block when
* `apiFormat.allowAnthropic === true` AND (its FULL id is allowlisted in
* `apiFormat.anthropicModels` OR its prefix is listed in the deprecated
* `apiFormat.anthropicPrefixes`, defaulting to the v1 list when prefixes
* are absent). The deprecated path warns once per prefix list. With
* neither allowlist nor prefix match, the model stays openai-compatible.
*/
export function resolveApiBlockV2(
modelId: string,
baseURL: string,
apiFormat?: ApiFormatV2
): { id: string; url: string; npm: string } {
if (apiFormat?.allowAnthropic === true) {
if ((apiFormat.anthropicModels ?? []).includes(modelId)) {
return {
id: "anthropic",
url: stripV1Suffix(trimTrailingSlashes(baseURL)),
npm: "@ai-sdk/anthropic",
};
}
const prefixes = apiFormat.anthropicPrefixes ?? DEFAULT_ANTHROPIC_PREFIXES_V1;
if (apiFormat.anthropicPrefixes !== undefined) warnDeprecatedPrefixesOnce(prefixes);
const slash = modelId.indexOf("/");
const prefix = slash === -1 ? modelId : modelId.slice(0, slash);
if (prefixes.includes(prefix)) {
return {
id: "anthropic",
url: stripV1Suffix(trimTrailingSlashes(baseURL)),
npm: "@ai-sdk/anthropic",
};
}
}
return {
id: "openai-compatible",
url: ensureV1Suffix(baseURL),
npm: "@ai-sdk/openai-compatible",
};
}
/**
* Map a raw `/v1/models` entry → `ModelV2` (the type @opencode-ai/sdk/v2
* exports as `Model`, re-exported by @opencode-ai/plugin as `ModelV2`).
*
* ModelV2 requires a much richer shape than a flat record. Concretely it
* expects:
* - flat `id`, `name`, `providerID`, `api: {id,url,npm}`
* - nested `capabilities: { temperature, reasoning, attachment, toolcall,
* input:{text,audio,image,video,pdf}, output:{…}, interleaved }`
* - `cost: { input, output, cache:{read,write} }` (NOT optional)
* - `limit: { context, input?, output }`
* - `status: "alpha"|"beta"|"deprecated"|"active"`, `options:{}`, `headers:{}`
* - `release_date: string`
*
* Field adaptations:
* 1. Flat `tool_call` / `reasoning` / `attachment` / `modalities`
* top-level fields don't exist in ModelV2 — folded into
* `capabilities.{toolcall, reasoning, attachment, input.*, output.*}`.
* 2. `cost: undefined` is illegal (cost is required). OmniRoute doesn't
* surface pricing on /v1/models, so we emit a zeroed cost block.
* Downstream opencode reads this for display only — the live pricing
* is OmniRoute's responsibility at routing time.
* 3. `tool_call` → `toolcall` (ModelV2 field name; one word).
* 4. `attachment` maps from `capabilities.vision` per OmniRoute
* convention: vision = ability to receive image attachments. If the
* raw entry happens to expose an explicit `capabilities.attachment`,
* that wins.
* 5. `thinking` from OmniRoute has no 1:1 ModelV2 slot. We OR it into
* `reasoning` so thinking-only models still surface a non-false
* reasoning flag.
* 6. `last_updated` from OmniRoute has no ModelV2 slot — dropped.
* `release_date` lands in ModelV2.release_date with `""` fallback
* (the field is required as `string`).
* 7. `temperature: true` per OmniRoute convention (OpenAI-compat mode
* always supports the temperature knob). If a raw entry sets
* `capabilities.temperature` explicitly, that wins.
* 8. Input/output modality arrays: each known modality flips its boolean.
* Unknown strings (future OmniRoute additions) are ignored — when the
* server adds new modalities we can map them here without breaking
* existing entries.
* 9. `status: "active"` — OmniRoute doesn't tier models alpha/beta on
* /v1/models, and opencode needs a non-deprecated status to expose
* the model in the picker. If a future entry surfaces an explicit
* lifecycle hint we can map it then.
* 10. `options: {}` and `headers: {}` left empty — they're escape hatches
* for opencode users to attach per-model overrides; the provider
* plugin must not preempt them.
* 11. `limit.input` is OPTIONAL on ModelV2 (the `?` modifier). We only
* emit it when OmniRoute supplies `max_input_tokens` — keeps the
* shape clean for combo entries that only carry context_length.
*/
export function mapRawModelToModelV2(
raw: OmniRouteRawModelEntry,
ctx: { providerId: string; baseURL: string; apiFormat?: ApiFormatV2 }
): ModelV2 {
const caps = raw.capabilities ?? {};
// effort_tiers loop: server-declared tiers become ModelV2 variants so the
// UI offers exactly the tiers OmniRoute vouches for (instead of opencode's
// invented [low, medium, high] fallback). Blind: filtering/exclusion rules
// live server-side. Absent/empty/malformed => key omitted ENTIRELY (an
// empty variants object would suppress opencode's fallback for this model).
const declaredTiers = Array.isArray(caps.effort_tiers)
? caps.effort_tiers.filter((t): t is string => typeof t === "string" && t.length > 0)
: [];
const variants =
declaredTiers.length > 0
? Object.fromEntries(declaredTiers.map((tier) => [tier, { reasoningEffort: tier }]))
: undefined;
const inMods = new Set(raw.input_modalities ?? ["text"]);
const outMods = new Set(raw.output_modalities ?? ["text"]);
return {
// OC's static-catalog reader parses the key on `/` to recover
// `(providerID, modelID)`. If the raw id is already provider-prefixed
// (e.g. `cc/claude-opus-4-7` from the `cc` Claude Code alias, or
// `nvidia/llama-3-70b` from a provider that ships prefixed ids), leave
// it as-is — double-prefixing breaks OC's lookup. Bare **combo** ids
// (`owned_by: "combo"`, e.g. `gpt-5.6-sol`) must also stay unprefixed:
// OpenCode looks up `-m <plugin>/<combo>` as model id `<combo>` under
// the plugin provider. Other bare ids still prefix with
// `providerId` so credentials resolve as `(omniroute, model)`.
id: raw.id.includes("/") || raw.owned_by === "combo" ? raw.id : `${ctx.providerId}/${raw.id}`,
/**
* Display name. Falls back to raw.id when no enrichment is available;
* the caller overlays `/api/pricing/models` data via enrichment when
* the enrichment feature is enabled.
*/
name: normaliseFreeLabel(raw.id),
capabilities: {
temperature: caps.temperature ?? true,
reasoning: Boolean(caps.reasoning || caps.thinking),
attachment: Boolean(caps.attachment ?? caps.vision ?? false),
toolcall: Boolean(caps.tool_calling ?? false),
input: {
text: inMods.has("text"),
audio: inMods.has("audio"),
image: inMods.has("image"),
video: inMods.has("video"),
pdf: inMods.has("pdf"),
},
output: {
text: outMods.has("text"),
audio: outMods.has("audio"),
image: outMods.has("image"),
video: outMods.has("video"),
pdf: outMods.has("pdf"),
},
interleaved: Boolean(caps.thinking),
},
cost: {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
},
limit: {
context: typeof raw.context_length === "number" ? raw.context_length : 0,
...(typeof raw.max_input_tokens === "number" ? { input: raw.max_input_tokens } : {}),
output: typeof raw.max_output_tokens === "number" ? raw.max_output_tokens : 0,
},
...(variants ? { variants } : {}),
status: "active",
options: {},
headers: {},
release_date: raw.release_date ?? "",
providerID: ctx.providerId,
api: resolveApiBlockV2(raw.id, ctx.baseURL, ctx.apiFormat),
};
}

View File

@@ -0,0 +1,295 @@
/**
* Universal model naming template for the OmniRoute plugin.
*
* Naming pipeline:
* [tag] <provider-label><separator><display-name><suffix>
*
* [Free] <provider> - <name> · <budget> ← free model
* Auto: <variant> (<N>p) ← auto combo
* Combo: <name> ← DB combo
* <provider> - <name> ← regular model
*/
// ── Constants ────────────────────────────────────────────────────────────
/** Separator between provider label and model display name. */
export const PROVIDER_TAG_SEPARATOR = " - ";
/** Threshold beyond which providerDisplayName is abbreviated. */
const PROVIDER_LABEL_MAX_CHARS = 12;
/** Aliases longer than this get title-case instead of UPPER. */
const ALIAS_UPPER_MAX_CHARS = 5;
// ── Auto Combo Types ─────────────────────────────────────────────────────
export type AutoVariant = "coding" | "fast" | "cheap" | "offline" | "smart" | "lkgp";
export const AUTO_VARIANTS: AutoVariant[] = ["coding", "fast", "cheap", "offline", "smart", "lkgp"];
export const AUTO_VARIANT_DESCRIPTIONS: Record<AutoVariant | "default", string> = {
default: "Best provider via scoring",
coding: "Quality-first for code tasks",
fast: "Latency-optimized routing",
cheap: "Cost-optimized routing",
offline: "Offline-friendly providers",
smart: "Quality-first with exploration",
lkgp: "Last-Known-Good-Provider routing",
};
// ── Free Model Types ─────────────────────────────────────────────────────
export type FreeModelFreeType =
| "recurring-daily"
| "recurring-monthly"
| "recurring-credit"
| "one-time-initial"
| "keyless"
| "discontinued";
// ── Provider Label ────────────────────────────────────────────────────────
/**
* Title-case a long, lowercase-looking alias.
* `antigravity` → `Antigravity`
*/
function titleCaseAlias(alias: string): string {
if (alias.length === 0) return alias;
return alias.charAt(0).toUpperCase() + alias.slice(1).toLowerCase();
}
/**
* Pick the short label for an upstream provider.
*
* Rules:
* 1. Trim `providerDisplayName`. If ≤12 chars → use verbatim.
* 2. Alias ≤5 chars → UPPER(alias). Alias >5 → titleCase.
* 3. Neither → undefined.
*/
export function shortProviderLabel(
enrichment: { providerDisplayName?: string; providerAlias?: string } | undefined
): string | undefined {
if (!enrichment) return undefined;
const raw =
typeof enrichment.providerDisplayName === "string" ? enrichment.providerDisplayName.trim() : "";
if (raw.length > 0 && raw.length <= PROVIDER_LABEL_MAX_CHARS) return raw;
const alias = typeof enrichment.providerAlias === "string" ? enrichment.providerAlias.trim() : "";
if (alias.length > 0) {
return alias.length <= ALIAS_UPPER_MAX_CHARS ? alias.toUpperCase() : titleCaseAlias(alias);
}
// Long displayName with no alias to fall back on: keep the long label
// rather than dropping the provider prefix entirely.
return raw.length > 0 ? raw : undefined;
}
// ── Free Label ────────────────────────────────────────────────────────────
/**
* Normalise display name so free-tier models get a consistent `[Free] ` prefix.
*
* "GPT-4.1 (Free)" → "[Free] GPT-4.1"
* "DeepSeek V4 Flash Free" → "[Free] DeepSeek V4 Flash"
* "Claude Opus 4.7" → "Claude Opus 4.7" (unchanged)
*/
export function normaliseFreeLabel(name: string): string {
// Bounded whitespace quantifiers ({0,8}/{1,8}) avoid the polynomial-ReDoS
// backtracking that unbounded \s* before an anchored \s*$ would allow on
// attacker-influenced display names. 8 covers any realistic label spacing.
const cleaned = name
.replace(/\s{0,8}\(free\)\s{0,8}$/i, "")
.replace(/[\s-]{1,8}free\s{0,8}$/i, "")
.trim();
const wasFree = cleaned.length < name.trim().length;
if (!wasFree) return name;
return `[Free] ${cleaned}`;
}
// ── Free Budget Formatting ────────────────────────────────────────────────
/** Scales, largest first, so the unit is chosen by descending magnitude. */
const TOKEN_UNITS = [
[1e9, "B"],
[1e6, "M"],
[1e3, "K"],
] as const;
/**
* Format a token count as a short magnitude string: `25M`, `1.5K`, `999`.
*
* The unit has to be picked from the value that will actually be *printed*,
* not from the raw input. `toFixed(1)` rounds to the nearest tenth, so at the
* K scale 999_950 and above render as `1000.0` — and by then the M branch has
* already been skipped, producing `1000K` for a number that is `1M`. The same
* carry turns just under a billion into `1000M`. When the rounded value reaches
* the next scale, re-render at that scale instead.
*/
function fmtTokens(n: number): string {
for (let i = 0; i < TOKEN_UNITS.length; i++) {
const [scale, suffix] = TOKEN_UNITS[i]!;
if (n < scale) continue;
const value = Number((n / scale).toFixed(1));
// `Number()` also drops a trailing `.0`, which the previous regex did.
if (value < 1000 || i === 0) return `${value}${suffix}`;
const [nextScale, nextSuffix] = TOKEN_UNITS[i - 1]!;
return `${Number((n / nextScale).toFixed(1))}${nextSuffix}`;
}
return String(n);
}
/**
* Format a free model budget into a short human-readable suffix.
*
* recurring-daily → "25M tokens/day"
* recurring-monthly → "25M tokens/month"
* recurring-credit → "10M credits"
* one-time-initial → "1M credits (one-time)"
* keyless → "(keyless)"
* discontinued → "(discontinued)"
*/
export function formatFreeBudget(params: {
freeType: FreeModelFreeType;
monthlyTokens?: number;
creditTokens?: number;
}): string {
const { freeType, monthlyTokens = 0, creditTokens = 0 } = params;
switch (freeType) {
case "recurring-daily":
return `${fmtTokens(monthlyTokens)} tokens/day`;
case "recurring-monthly":
return `${fmtTokens(monthlyTokens)} tokens/month`;
case "recurring-credit":
return `${fmtTokens(creditTokens)} credits`;
case "one-time-initial":
return `${fmtTokens(creditTokens)} credits (one-time)`;
case "keyless":
return "(keyless)";
case "discontinued":
return "(discontinued)";
default:
return "";
}
}
// ── Auto Combo Naming ─────────────────────────────────────────────────────
/**
* Format auto combo display name.
*
* "Auto: Coding (4p)"
* "Auto: Default (6p)"
* "Auto" (no candidate count when unknown)
*/
export function formatAutoComboName(
variant: AutoVariant | undefined,
candidateCount?: number
): string {
const label = variant ? variant.charAt(0).toUpperCase() + variant.slice(1) : "Default";
const count =
typeof candidateCount === "number" && candidateCount > 0 ? ` (${candidateCount}p)` : "";
return `Auto: ${label}${count}`;
}
/**
* Build the model ID for an auto combo entry.
* "auto/coding", "auto/fast", "auto" (default).
*/
export function autoComboModelId(variant: AutoVariant | undefined): string {
return variant ? `auto/${variant}` : "auto";
}
// ── Universal Display Name Builder ────────────────────────────────────────
export interface ModelDisplayNameParams {
/** Raw model ID (e.g. "cc/claude-sonnet-4-6"). */
rawId: string;
/** Enrichment display name (e.g. "Claude Sonnet 4.6"). */
enrichmentName?: string;
/** Provider tag enrichment. */
providerAlias?: string;
/** Human-readable upstream provider label. */
providerDisplayName?: string;
/** Whether model is free tier. */
isFree?: boolean;
/** Free model budget info. */
freeType?: FreeModelFreeType;
/** Monthly token budget (for recurring free models). */
monthlyTokens?: number;
/** Credit token budget (for credit-based free models). */
creditTokens?: number;
/** Whether this is a combo entry (skip provider tag). */
isCombo?: boolean;
/** Whether this is an auto combo entry. */
isAutoCombo?: boolean;
/** Auto combo variant. */
autoVariant?: AutoVariant;
/** Auto combo candidate count. */
autoCandidateCount?: number;
}
/**
* Build the final display name following the universal template.
*
* Priority:
* 1. Auto combo → "Auto: <variant> (<N>p)"
* 2. DB combo → "Combo: <name>"
* 3. Free + enrichment + provider tag → "[Free] <label> - <name> · <budget>"
* 4. Free + enrichment → "[Free] <name> · <budget>"
* 5. Free + raw → "[Free] <rawId> · <budget>"
* 6. Enrichment + provider tag → "<label> - <name>"
* 7. Enrichment only → "<name>"
* 8. Raw fallback → normaliseFreeLabel(rawId)
*/
export function buildModelDisplayName(params: ModelDisplayNameParams): string {
// Auto combos
if (params.isAutoCombo) {
return formatAutoComboName(params.autoVariant, params.autoCandidateCount);
}
// Determine base name — strip any existing free suffix first
const rawBase =
params.enrichmentName && params.enrichmentName.trim().length > 0
? params.enrichmentName
: params.rawId;
const cleanedBase = rawBase
.replace(/\s*\(free\)\s*$/i, "")
.replace(/[\s-]+free\s*$/i, "")
.trim();
const wasFree = cleanedBase.length < rawBase.trim().length;
const isFree = !!params.isFree || wasFree;
let baseName = cleanedBase;
// Provider tag (skip for combos)
if (!params.isCombo) {
const label = shortProviderLabel({
providerDisplayName: params.providerDisplayName,
providerAlias: params.providerAlias,
});
if (label) {
const prefix = `${label}${PROVIDER_TAG_SEPARATOR}`;
if (!baseName.startsWith(prefix)) {
baseName = `${prefix}${baseName}`;
}
}
}
// Prepend [Free] if applicable (AFTER provider tag for correct ordering)
if (isFree) {
baseName = `[Free] ${baseName}`;
}
// Free budget suffix
if (isFree && params.freeType) {
const budget = formatFreeBudget({
freeType: params.freeType,
monthlyTokens: params.monthlyTokens,
creditTokens: params.creditTokens,
});
if (budget) {
baseName = `${baseName} · ${budget}`;
}
}
return baseName;
}

View File

@@ -0,0 +1,171 @@
import type { OmniRouteEnrichmentMap } from "./enrich.js";
import type { OmniRouteRawCombo } from "./combos-map.js";
/** Subset of `/api/providers` connections read by the usableOnly filter. */
export interface OmniRouteProviderConnection {
/** Connection UUID. */
id: string;
/** Canonical provider id, e.g. `claude`, `gemini`, `kiro`. */
provider: string;
/** Operator toggle — when false, the connection is provisioned but disabled. */
isActive?: boolean;
/** Health-check verdict — `active` means routable. */
testStatus?: string;
/** Permissive bag — additional fields pass through untouched. */
[k: string]: unknown;
}
export type OmniRouteProvidersFetcher = (
baseURL: string,
apiKey: string,
timeoutMs?: number,
onSourceError?: (endpoint: string, reason: string) => void
) => Promise<OmniRouteProviderConnection[]>;
export interface UsableProviderSet {
aliases: Set<string>;
canonicals: Set<string>;
knownAliases: Set<string>;
}
/**
* Default providers fetcher: `GET <baseURL>/api/providers` with bearer auth
* + AbortController timeout. Accepts the `{ connections: [...] }` envelope
* the gateway emits today, a bare-array envelope, and a `{ data: [...] }`
* envelope (defensive). Refusals and network errors THROW so the caller keeps
* last-known instead of silently disabling the filter; a 2xx with an empty
* list is the only empty answer.
*/
export const defaultOmniRouteProvidersFetcher: OmniRouteProvidersFetcher = async (
baseURL,
apiKey,
timeoutMs = 10_000,
onSourceError
) => {
const empty: OmniRouteProviderConnection[] = [];
if (!baseURL || !apiKey) return empty;
const root = baseURL.replace(/\/v1\/?$/, "").replace(/\/$/, "");
const url = `${root}/api/providers`;
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
try {
const res = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
signal: ac.signal,
});
if (!res.ok) {
// A refusal (403 behind a management-token gate, 503 mid-outage) is a
// failure, not an empty allowlist: the caller keeps last-known instead
// of silently disabling the filter.
onSourceError?.("/api/providers", `HTTP ${res.status}`);
throw new Error(`HTTP ${res.status}`);
}
const body = (await res.json()) as unknown;
const list = Array.isArray(body)
? body
: Array.isArray((body as { connections?: unknown[] })?.connections)
? (body as { connections: unknown[] }).connections
: Array.isArray((body as { data?: unknown[] })?.data)
? (body as { data: unknown[] }).data
: [];
const out: OmniRouteProviderConnection[] = [];
for (const raw of list) {
if (!raw || typeof raw !== "object") continue;
const provider = (raw as { provider?: unknown }).provider;
if (typeof provider !== "string" || provider.length === 0) continue;
const id = (raw as { id?: unknown }).id;
const idStr = typeof id === "string" && id.length > 0 ? id : provider;
out.push({ ...(raw as Record<string, unknown>), id: idStr, provider });
}
return out;
} catch (err) {
// Network error, timeout, abort: keep last-known, never silently unfilter.
onSourceError?.("/api/providers", err instanceof Error ? err.message : String(err));
throw err instanceof Error ? err : new Error(String(err));
} finally {
clearTimeout(timer);
}
};
/**
* Compute the provider prefixes safe to keep. A canonical provider is usable
* when at least one connection is not explicitly disabled (`isActive: false`)
* and has no failing health verdict (`testStatus` present and not "active"). Aliases (e.g. `cc` -> `claude`) resolve through the enrichment
* map, which records every alias in `knownAliases` so the downstream filter
* decides "this prefix was in /api/pricing/models" in O(1).
*
* Subtract-filter semantics: callers keep prefixes unknown to BOTH tables.
*/
export function usableProviderAliasSet(
connections: OmniRouteProviderConnection[],
enrichment: OmniRouteEnrichmentMap | undefined
): UsableProviderSet {
const usableCanonicals = new Set<string>();
for (const c of connections) {
// A missing toggle means "no opinion", not "disabled": the field only
// exists on newer gateways, and treating its absence as a veto would hide
// every model behind a filter the operator never asked to tighten. Only an
// explicit `false` disables.
if (!c || c.isActive === false) continue;
if (typeof c.testStatus === "string" && c.testStatus !== "active") continue;
if (typeof c.provider === "string" && c.provider.length > 0) {
usableCanonicals.add(c.provider);
}
}
const aliases = new Set<string>();
const knownAliases = new Set<string>();
if (enrichment) {
for (const entry of enrichment.values()) {
const alias = entry.providerAlias;
const canonical = entry.providerCanonical;
if (typeof alias !== "string" || alias.length === 0) continue;
knownAliases.add(alias);
if (typeof canonical !== "string" || canonical.length === 0) continue;
if (usableCanonicals.has(canonical)) aliases.add(alias);
}
}
for (const canonical of usableCanonicals) aliases.add(canonical);
return { aliases, canonicals: usableCanonicals, knownAliases };
}
/**
* Decide whether a raw `/v1/models` id passes the usableOnly filter.
* Rules (subtract-filter, bias toward keep): no `/` -> keep; usable alias
* or canonical -> keep; unknown to both tables -> keep; known alias but not
* usable -> drop.
*/
export function isUsableRawModelId(id: string, usable: UsableProviderSet): boolean {
const slash = id.indexOf("/");
if (slash <= 0) return true;
const prefix = id.slice(0, slash);
if (usable.aliases.has(prefix) || usable.canonicals.has(prefix)) return true;
if (usable.knownAliases.has(prefix)) return false;
return true;
}
/**
* Decide whether a combo passes the usableOnly filter. A combo keeps when AT
* LEAST ONE member maps to a usable provider; unknown prefixes keep (cannot
* prove unroutable); combos with zero resolvable members keep.
*/
export function isUsableCombo(combo: OmniRouteRawCombo, usable: UsableProviderSet): boolean {
const steps = Array.isArray(combo.models) ? combo.models : [];
if (steps.length === 0) return true;
let sawResolvableMember = false;
for (const step of steps) {
if (step?.kind === "combo-ref") continue;
const modelId = typeof step?.model === "string" ? step.model : "";
const slash = modelId.indexOf("/");
if (slash <= 0) continue;
sawResolvableMember = true;
const prefix = modelId.slice(0, slash);
if (usable.aliases.has(prefix) || usable.canonicals.has(prefix)) return true;
if (!usable.knownAliases.has(prefix)) return true;
}
if (!sawResolvableMember) return true;
return false;
}

View File

@@ -0,0 +1,93 @@
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";
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>();
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;
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 { models, draft };
}
const baseOpts = {
providerId: "omniroute",
baseURL: "https://gw.example.com",
apiKey: "k",
timeoutMs: 1000,
modelCacheTtlMs: 300000,
usableOnly: false,
};
function apiPackageOf(m: ModelV2Info | 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");
return m.api.package;
}
describe("catalog api package (models + combos + auto-combos)", () => {
it("every published entry carries a non-empty supported api.package", async () => {
const { models, draft } = fakeDraft();
const res = await publishCatalog(draft, baseOpts, {
fetcher: async () => [{ id: "gpt-x", context_length: 128000, max_output_tokens: 4096 }],
combosFetcher: async () => [
{ id: "combo-a", name: "Combo A", models: [{ kind: "model", model: "gpt-x" }] },
],
autoCombosFetcher: async () => [{ id: "auto", candidateCount: 6 }],
});
assert.deepEqual(res, { models: 1, combos: 1, autoCombos: 1 });
for (const key of ["omniroute/gpt-x", "omniroute/combo-a", "omniroute/auto"]) {
const pkg = apiPackageOf(models.get(key));
assert.ok(pkg.length > 0, `${key} api.package must be non-empty`);
assert.ok(SUPPORTED_PACKAGES.has(pkg), `${key} api.package must be supported, got ${pkg}`);
}
});
it("auto-combos follow the same anthropic apiFormat rule as models", async () => {
const { models, draft } = fakeDraft();
await publishCatalog(
draft,
{
...baseOpts,
apiFormat: { allowAnthropic: true, anthropicModels: ["anthropic/claude-x", "auto/coding"] },
},
{
fetcher: async () => [{ id: "anthropic/claude-x" }],
combosFetcher: async () => [],
autoCombosFetcher: async () => [
{ id: "auto/coding", variant: "coding", candidateCount: 4 },
{ id: "auto/fast", variant: "fast", candidateCount: 2 },
],
}
);
assert.equal(apiPackageOf(models.get("omniroute/anthropic/claude-x")), "@ai-sdk/anthropic");
assert.equal(apiPackageOf(models.get("omniroute/auto/coding")), "@ai-sdk/anthropic");
assert.equal(apiPackageOf(models.get("omniroute/auto/fast")), "@ai-sdk/openai-compatible");
});
});

View File

@@ -0,0 +1,196 @@
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";
function fakeDraft(): {
models: Map<string, ModelV2Info>;
draft: CatalogDraft;
warns: string[];
restore: () => void;
} {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
warns.push(String(args[0]));
};
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;
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 {
models,
draft,
warns,
restore: () => {
console.warn = origWarn;
},
};
}
const baseOpts = {
providerId: "omniroute",
baseURL: "https://gw.example.com",
apiKey: "k",
timeoutMs: 1000,
timeouts: { autoCombos: 5000 },
modelCacheTtlMs: 300000,
usableOnly: false,
};
describe("catalog auto combos (v1 parity)", () => {
it("publishes auto/* ids from the auto-combos fetcher", async () => {
const { models, draft, warns, restore } = fakeDraft();
try {
const res = await publishCatalog(draft, baseOpts, {
fetcher: async () => [],
combosFetcher: async () => [],
autoCombosFetcher: async () => [
{ id: "auto", variant: undefined, candidateCount: 6 },
{ id: "auto/coding", variant: "coding", candidateCount: 4 },
],
});
assert.deepEqual(res, { models: 0, combos: 0, autoCombos: 2 });
assert.ok(models.has("omniroute/auto"), "auto entry must be published");
assert.ok(models.has("omniroute/auto/coding"), "auto/coding entry must be published");
const coding = models.get("omniroute/auto/coding");
assert.equal(coding?.limit.context, 128_000);
assert.equal(coding?.limit.output, 8_192);
} finally {
restore();
}
assert.ok(!warns.some((w) => w.includes("auto combo") && w.includes("failed")));
});
it("auto-combos fetch throw stays fail-open (warn, models kept)", async () => {
const { models, draft, warns, restore } = fakeDraft();
try {
const res = await publishCatalog(draft, baseOpts, {
fetcher: async () => [{ id: "m1" }],
combosFetcher: async () => [],
autoCombosFetcher: async () => {
throw new Error("GET https://gw.example.com/api/combos/auto failed: 500 boom");
},
});
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
assert.ok(models.has("omniroute/m1"));
} finally {
restore();
}
assert.ok(warns.some((w) => w.includes("auto combos") || w.includes("auto-combos")));
});
it("forwards the 5s auto-combos timeout to the fetcher", async () => {
const seen: number[] = [];
const { draft, restore } = fakeDraft();
try {
await publishCatalog(
draft,
{ ...baseOpts, timeoutMs: 9999, timeouts: { autoCombos: 5000 } },
{
fetcher: async () => [],
combosFetcher: async () => [],
autoCombosFetcher: async (_base, _key, timeoutMs) => {
seen.push(timeoutMs ?? -1);
return [];
},
}
);
} finally {
restore();
}
assert.deepEqual(seen, [5000]);
});
it("hidden auto combos are skipped", async () => {
const { models, draft, restore } = fakeDraft();
try {
const res = await publishCatalog(draft, baseOpts, {
fetcher: async () => [],
combosFetcher: async () => [],
autoCombosFetcher: async () => [
{ id: "auto/offline", variant: "offline", isHidden: true },
{ id: "auto/fast", variant: "fast" },
],
});
assert.deepEqual(res, { models: 0, combos: 0, autoCombos: 1 });
assert.ok(!models.has("omniroute/auto/offline"));
assert.ok(models.has("omniroute/auto/fast"));
} finally {
restore();
}
});
});
describe("auto combos obey the same allowlists as everything else", () => {
it("hides an auto combo the user hid, and keeps the rest", async () => {
const { publishCatalog } = await import("../src/catalog.js");
const published = new Map<string, Record<string, unknown>>();
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 publishCatalog(
draft as never,
{
providerId: "omni",
baseURL: "https://gw.example.com",
apiKey: "k",
timeoutMs: 1000,
modelCacheTtlMs: 1000,
usableOnly: false,
enrichment: new Map(),
hiddenModels: ["auto/best-chaos"],
} as never,
{
models: async () => [{ id: "m1" }],
combos: async () => [],
autoCombos: async () =>
[
{ id: "auto/best-coding", variant: "best-coding", candidateCount: 3 },
{ id: "auto/best-chaos", variant: "best-chaos", candidateCount: 2 },
] as never,
providers: async () => [],
enrichment: async () => new Map(),
}
);
const keys = [...published.keys()];
assert.ok(
keys.some((k) => k.includes("best-coding")),
`the other auto combo stays, got ${JSON.stringify(keys)}`
);
assert.equal(
keys.some((k) => k.includes("best-chaos")),
false,
`a hidden auto combo must not reach the picker, got ${JSON.stringify(keys)}`
);
});
});

View File

@@ -0,0 +1,378 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import plugin from "../src/index.js";
import { DEFAULT_MODEL_CACHE_TTL_MS, diskSnapshotPath } from "../src/cache.js";
interface CtxOpts {
providerId: string;
baseURL: string;
apiKey: string;
modelCacheTtlMs?: number;
}
function stubFetch(
counter: { models: number; combos: number; autoCombos?: number; enrichment?: number },
modelIds: string[]
) {
return (async (url: unknown) => {
const href = String(url);
if (href.includes("/api/pricing") || href.includes("/api/free-tier")) {
if (counter.enrichment !== undefined) counter.enrichment += 1;
return { ok: true, status: 200, statusText: "OK", json: async () => ({}) };
}
if (href.includes("/api/combos/auto")) {
if (counter.autoCombos !== undefined) counter.autoCombos += 1;
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
}
if (href.includes("/api/combos")) {
counter.combos += 1;
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
}
counter.models += 1;
return {
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ data: modelIds.map((id) => ({ id })) }),
};
}) as typeof fetch;
}
async function setupPlugin(opts: CtxOpts): Promise<{
callbacks: Array<(draft: unknown) => Promise<void>>;
reloads: { count: number };
}> {
const callbacks: Array<(draft: unknown) => Promise<void>> = [];
const reloads = { count: 0 };
const ctx = {
options: { ...opts },
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
callbacks.push(cb);
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {
reloads.count += 1;
},
},
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
};
const logs: string[] = [];
const warns: string[] = [];
const origLog = console.log;
const origWarn = console.warn;
console.log = (...args: unknown[]) => {
logs.push(String(args[0]));
};
console.warn = (...args: unknown[]) => {
warns.push(String(args[0]));
};
try {
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
} finally {
console.log = origLog;
console.warn = origWarn;
}
return { callbacks, reloads };
}
function stubDraft(): { draft: unknown; published: Map<string, Record<string, unknown>> } {
const published = new Map<string, Record<string, unknown>>();
return {
published,
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);
},
},
},
};
}
function isolateDisk(): { dir: string; restore: () => void } {
const dir = mkdtempSync(join(tmpdir(), "omniroute-p1-"));
const prev = process.env.OPENCODE_DATA_DIR;
process.env.OPENCODE_DATA_DIR = dir;
return {
dir,
restore: () => {
if (prev === undefined) delete process.env.OPENCODE_DATA_DIR;
else process.env.OPENCODE_DATA_DIR = prev;
},
};
}
describe("plugin-v2 P1 parity: TTL 300s + disk snapshot", () => {
it("TTL default is 300s (v1 parity)", () => {
assert.equal(DEFAULT_MODEL_CACHE_TTL_MS, 300_000);
});
it("2nd transform within TTL -> 0 network fetches (stub counter)", async () => {
const disk = isolateDisk();
const counter = { models: 0, combos: 0, autoCombos: 0 };
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch(counter, ["m1"]);
try {
const { callbacks } = await setupPlugin({
providerId: "ttl-hit",
baseURL: "https://gw.example.com",
apiKey: "k-ttl",
});
const { draft, published } = stubDraft();
await callbacks[0](draft);
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();
}
});
it("expired TTL -> network refetch", async () => {
const disk = isolateDisk();
const counter = { models: 0, combos: 0 };
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch(counter, ["m1"]);
const origNow = Date.now;
let now = 1_000_000;
Date.now = () => now;
try {
const { callbacks } = await setupPlugin({
providerId: "ttl-expire",
baseURL: "https://gw.example.com",
apiKey: "k-expire",
modelCacheTtlMs: 1000,
});
const { draft } = stubDraft();
await callbacks[0](draft);
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);
} finally {
Date.now = origNow;
globalThis.fetch = origFetch;
disk.restore();
}
});
it("concurrent calls share a single refresh promise", async () => {
const disk = isolateDisk();
const counter = { models: 0, combos: 0 };
const origFetch = globalThis.fetch;
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
globalThis.fetch = (async (url: unknown) => {
const href = String(url);
if (href.includes("/api/pricing") || href.includes("/api/free-tier")) {
return { ok: true, status: 200, statusText: "OK", json: async () => ({}) };
}
if (href.includes("/v1/models")) {
counter.models += 1;
await gate;
return {
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ data: [{ id: "m1" }] }),
};
}
if (href.includes("/api/combos/auto")) {
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
}
if (href.includes("/api/providers")) {
return { ok: true, status: 200, statusText: "OK", json: async () => ({}) };
}
counter.combos += 1;
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
}) as typeof fetch;
const origLog = console.log;
const origWarn = console.warn;
console.log = () => {};
console.warn = () => {};
try {
const { callbacks } = await 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]);
assert.equal(counter.models, 1);
} finally {
console.log = origLog;
console.warn = origWarn;
globalThis.fetch = origFetch;
disk.restore();
}
});
it("snapshot: fetch down at boot + snapshot present -> warm catalog published", async () => {
const disk = isolateDisk();
const warmCounter = { models: 0, combos: 0 };
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch(warmCounter, ["mw"]);
const origLog = console.log;
const origWarn = console.warn;
console.log = () => {};
console.warn = () => {};
try {
const { callbacks } = await setupPlugin({
providerId: "warm",
baseURL: "https://gw.example.com",
apiKey: "k-warm",
});
const { draft } = stubDraft();
await callbacks[0](draft);
assert.ok(statSync(diskSnapshotPath("warm")).isFile());
} finally {
console.log = origLog;
console.warn = origWarn;
globalThis.fetch = origFetch;
disk.restore();
}
const warmBody = (await import("node:fs/promises").then((fs) =>
fs.readFile(join(disk.dir, "plugins", "omniroute-warm.json"), "utf8")
)) as string;
void warmBody;
const disk2prev = process.env.OPENCODE_DATA_DIR;
process.env.OPENCODE_DATA_DIR = disk.dir;
const downFetch = (async (url: unknown) => {
const href = String(url);
if (href.includes("/api/pricing") || href.includes("/api/free-tier")) {
return { ok: true, status: 200, statusText: "OK", json: async () => ({}) };
}
if (href.includes("/api/combos/auto")) {
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
}
if (href.includes("/api/combos")) {
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
}
return { ok: false, status: 500, statusText: "Down", json: async () => ({}) };
}) as typeof fetch;
globalThis.fetch = downFetch;
const warns: string[] = [];
console.warn = (...args: unknown[]) => {
warns.push(String(args[0]));
};
console.log = () => {};
try {
const { callbacks } = await setupPlugin({
providerId: "warm",
baseURL: "https://gw.example.com",
apiKey: "k-warm",
});
const { draft, published } = stubDraft();
await callbacks[0](draft);
assert.ok(
published.has("warm/mw"),
`warm snapshot must publish mw, got: ${JSON.stringify([...published.keys()])}`
);
} finally {
console.log = origLog;
console.warn = origWarn;
globalThis.fetch = origFetch;
if (disk2prev === undefined) delete process.env.OPENCODE_DATA_DIR;
else process.env.OPENCODE_DATA_DIR = disk2prev;
}
void warns;
});
it("invalidation: fresh setup does not reuse the previous in-memory cache", async () => {
const disk = isolateDisk();
const counter = { models: 0, combos: 0 };
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch(counter, ["m1"]);
const origLog = console.log;
const origWarn = console.warn;
console.log = () => {};
console.warn = () => {};
try {
const first = await setupPlugin({
providerId: "inval",
baseURL: "https://gw.example.com",
apiKey: "k-inval",
modelCacheTtlMs: 1,
});
const { draft } = stubDraft();
await first.callbacks[0](draft);
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.
const { setTimeout: sleep } = await import("node:timers/promises");
await sleep(5);
const second = await setupPlugin({
providerId: "inval",
baseURL: "https://gw.example.com",
apiKey: "k-inval",
modelCacheTtlMs: 1,
});
await second.callbacks[0](draft);
assert.equal(counter.models, 2);
} finally {
console.log = origLog;
console.warn = origWarn;
globalThis.fetch = origFetch;
disk.restore();
}
});
});
describe("the snapshot carries the display overlay across a restart", () => {
it("round-trips enrichment so a cold start is not a wall of raw ids", async () => {
const { mkdtempSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { writeDiskSnapshot, readDiskSnapshot } = await import("../src/cache.js");
const dir = mkdtempSync(join(tmpdir(), "omniroute-enrich-snap-"));
const prev = process.env.OPENCODE_DATA_DIR;
process.env.OPENCODE_DATA_DIR = dir;
try {
const enrichment = new Map([
["cc/sonnet", { name: "Claude Sonnet 4.6", providerDisplayName: "Claude" }],
]);
await writeDiskSnapshot(
"omni",
{
models: [{ id: "cc/sonnet" }],
combos: [],
autoCombos: [],
providers: [],
enrichment,
fetchedAt: Date.now(),
} as never,
"fp-1"
);
const back = await readDiskSnapshot("omni", "fp-1");
assert.ok(back, "snapshot readable");
assert.equal(back.enrichment?.get("cc/sonnet")?.name, "Claude Sonnet 4.6");
assert.equal(back.enrichment?.get("cc/sonnet")?.providerDisplayName, "Claude");
} finally {
if (prev === undefined) delete process.env.OPENCODE_DATA_DIR;
else process.env.OPENCODE_DATA_DIR = prev;
}
});
});

View File

@@ -0,0 +1,330 @@
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";
interface FakeDraft {
providers: Map<string, ProviderV2Info>;
models: Map<string, ModelV2Info>;
warns: string[];
provider: CatalogDraft["provider"];
model: CatalogDraft["model"];
}
function fakeDraft(): FakeDraft {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
return {
providers,
models,
warns: [],
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;
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: () => {} },
},
};
}
const baseOpts = {
providerId: "omniroute",
baseURL: "https://gw.example.com",
apiKey: "k",
timeoutMs: 1000,
modelCacheTtlMs: 300000,
usableOnly: false,
};
describe("catalog provider template", () => {
it("writes provider api package/url plus integrationID", async () => {
const draft = fakeDraft();
await publishCatalog(draft, baseOpts, {
fetcher: async () => [],
combosFetcher: async () => [],
});
const p = draft.providers.get("omniroute");
assert.ok(p);
assert.equal(p?.name, "OmniRoute");
assert.deepEqual(p?.api, {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://gw.example.com/v1",
});
assert.equal(p?.integrationID, "omniroute");
});
it("publishes stub models with mapped fields, counts them", async () => {
const draft = fakeDraft();
const res = await publishCatalog(draft, baseOpts, {
fetcher: async () => [
{
id: "gpt-x",
context_length: 128000,
max_output_tokens: 4096,
input_modalities: ["text", "image"],
capabilities: { tool_calling: true, reasoning: true },
},
{ id: "plain" },
],
combosFetcher: async () => [],
});
assert.deepEqual(res, { models: 2, combos: 0, autoCombos: 0 });
const m = draft.models.get("omniroute/gpt-x");
assert.ok(m);
assert.equal(m?.providerID, "omniroute");
assert.equal(m?.limit.context, 128000);
assert.equal(m?.capabilities.tools, true);
assert.equal(m?.status, "active");
});
});
describe("catalog allowlist", () => {
const stubModels = async () => [{ id: "cc/keep-me" }, { id: "cc/drop-me" }, { id: "bare-keep" }];
it("visible exact allowlist keeps matches, drops the rest", async () => {
const draft = fakeDraft();
const res = await publishCatalog(
draft,
{ ...baseOpts, visibleModels: ["cc/keep-me"] },
{ fetcher: stubModels, combosFetcher: async () => [] }
);
assert.equal(res.models, 1);
assert.ok(draft.models.has("omniroute/cc/keep-me"));
assert.ok(!draft.models.has("omniroute/cc/drop-me"));
});
it("hidden deny-wins over visible allow", async () => {
const draft = fakeDraft();
const res = await publishCatalog(
draft,
{ ...baseOpts, visibleModels: ["cc/keep-me"], hiddenModels: ["cc/keep-me"] },
{ fetcher: stubModels, combosFetcher: async () => [] }
);
assert.equal(res.models, 0);
});
it("bare hidden id matches any prefix via suffix rule", async () => {
const draft = fakeDraft();
const res = await publishCatalog(
draft,
{ ...baseOpts, hiddenModels: ["drop-me"] },
{ fetcher: stubModels, combosFetcher: async () => [] }
);
assert.equal(res.models, 2);
assert.ok(!draft.models.has("omniroute/cc/drop-me"));
assert.ok(draft.models.has("omniroute/bare-keep"));
});
});
describe("catalog fail-open", () => {
it("models fetch throw warns and returns zeros without throwing", async () => {
const draft = fakeDraft();
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
warns.push(String(args[0]));
};
try {
const res = await publishCatalog(draft, baseOpts, {
fetcher: async () => {
throw new Error("boom 500");
},
combosFetcher: async () => [],
});
assert.deepEqual(res, { models: 0, combos: 0, autoCombos: 0 });
} finally {
console.warn = origWarn;
}
assert.equal(warns.length, 1);
assert.match(warns[0], /models fetch failed/);
assert.ok(draft.providers.has("omniroute"));
});
it("combos fetch throw keeps models, warns, returns models-only counts", async () => {
const draft = fakeDraft();
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
warns.push(String(args[0]));
};
try {
const res = await publishCatalog(draft, baseOpts, {
fetcher: async () => [{ id: "m1" }],
enrichmentFetcher: async () => new Map(),
combosFetcher: async () => {
throw Object.assign(new Error("Not Found"), { status: 404 });
},
});
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
} finally {
console.warn = origWarn;
}
assert.equal(warns.length, 1);
assert.match(warns[0], /combos fetch failed/);
assert.ok(draft.models.has("omniroute/m1"));
});
it("combos fetch 403 (PROD) keeps models, warns, never rejects", async () => {
const draft = fakeDraft();
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
warns.push(String(args[0]));
};
try {
const res = await publishCatalog(draft, baseOpts, {
fetcher: async () => [{ id: "m1" }],
enrichmentFetcher: async () => new Map(),
combosFetcher: async () => {
throw new Error(
"[omniroute-v2] GET https://gw.example.com/api/combos failed: 403 Forbidden"
);
},
});
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
} finally {
console.warn = origWarn;
}
assert.equal(warns.length, 1);
assert.match(warns[0], /combos fetch failed/);
assert.match(warns[0], /403/);
assert.ok(draft.models.has("omniroute/m1"));
});
it("combos fetch 500 keeps models, warns, never rejects", async () => {
const draft = fakeDraft();
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
warns.push(String(args[0]));
};
try {
const res = await publishCatalog(draft, baseOpts, {
fetcher: async () => [{ id: "m1" }],
enrichmentFetcher: async () => new Map(),
combosFetcher: async () => {
throw new Error(
"[omniroute-v2] GET https://gw.example.com/api/combos failed: 500 Internal Server Error"
);
},
});
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
} finally {
console.warn = origWarn;
}
assert.equal(warns.length, 1);
assert.match(warns[0], /combos fetch failed/);
assert.match(warns[0], /500/);
assert.ok(draft.models.has("omniroute/m1"));
});
it("combos fetch timeout (AbortError) keeps models, warns, never rejects", async () => {
const draft = fakeDraft();
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
warns.push(String(args[0]));
};
try {
const res = await publishCatalog(draft, baseOpts, {
fetcher: async () => [{ id: "m1" }],
enrichmentFetcher: async () => new Map(),
combosFetcher: async () => {
const err = new Error("This operation was aborted");
err.name = "AbortError";
throw err;
},
});
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
} finally {
console.warn = origWarn;
}
assert.equal(warns.length, 1);
assert.match(warns[0], /combos fetch failed/);
assert.ok(draft.models.has("omniroute/m1"));
});
});
describe("catalog combo vs combo", () => {
it("second combo with same id warns only once", async () => {
const draft = fakeDraft();
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
warns.push(String(args[0]));
};
try {
const res = await publishCatalog(draft, baseOpts, {
fetcher: async () => [{ id: "dupe" }],
enrichmentFetcher: async () => new Map(),
combosFetcher: async () => [
{
id: "dupe",
name: "Dupe Combo",
models: [{ kind: "model", model: "dupe" }],
},
{
id: "dupe",
name: "Dupe Combo Again",
models: [{ kind: "model", model: "dupe" }],
},
],
});
assert.deepEqual(res, { models: 1, combos: 2, autoCombos: 0 });
} finally {
console.warn = origWarn;
}
assert.equal(warns.length, 1);
assert.match(warns[0], /collides with a model id; combo wins/);
const m = draft.models.get("omniroute/dupe");
assert.ok(m);
assert.equal(m?.name, "Dupe Combo Again");
});
});
describe("catalog model bare vs combo", () => {
it("bare model id colliding with combo id warns exactly once", async () => {
const draft = fakeDraft();
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
warns.push(String(args[0]));
};
try {
const res = await publishCatalog(draft, baseOpts, {
fetcher: async () => [{ id: "dupe" }],
enrichmentFetcher: async () => new Map(),
combosFetcher: async () => [
{
id: "dupe",
name: "Dupe Combo",
models: [{ kind: "model", model: "dupe" }],
},
],
});
assert.deepEqual(res, { models: 1, combos: 1, autoCombos: 0 });
} finally {
console.warn = origWarn;
}
assert.equal(warns.length, 1);
assert.match(warns[0], /collides with a model id; combo wins/);
assert.match(warns[0], /"omniroute\/dupe"/);
});
});

View File

@@ -0,0 +1,34 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { assertContext } from "../src/compat.js";
function validContext() {
return {
options: { baseURL: "https://gw.example.com" },
catalog: { transform: async () => {} },
integration: { transform: async () => {} },
};
}
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: {} };
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: {} }));
});
it("throws when options is not an object", () => {
const ctx = { ...validContext(), options: undefined };
assert.throws(() => assertContext(ctx), /\[omniroute-v2\] contract breach/);
});
it("passes for a valid context", () => {
assert.doesNotThrow(() => assertContext(validContext()));
});
});

View File

@@ -0,0 +1,128 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { PluginContext } from "@opencode-ai/plugin/v2/promise";
import type { Logger } from "../src/shared/index.js";
import { resolveApiKey, warnIfMissing } from "../src/credentials.js";
function collectingLogger(): { log: Logger; warnings: string[] } {
const warnings: string[] = [];
const log = {
error: () => {},
warn: (m: string) => warnings.push(m),
info: () => {},
debug: () => {},
} as unknown as Logger;
return { log, warnings };
}
/** A host exposing `integration.connection`, optionally with a stored value. */
function ctxWith(stored: unknown, opts: { withConnectionApi?: boolean } = {}): PluginContext {
const integration =
opts.withConnectionApi === false
? {}
: {
connection: {
active: async () =>
stored === undefined ? undefined : { type: "credential", id: "c", label: "l" },
resolve: async () => stored,
},
};
return { integration } as unknown as PluginContext;
}
const ENV = "OMNIROUTE_API_KEY";
async function withEnv<T>(value: string | undefined, fn: () => Promise<T>): Promise<T> {
const previous = process.env[ENV];
if (value === undefined) delete process.env[ENV];
else process.env[ENV] = value;
try {
return await fn();
} finally {
if (previous === undefined) delete process.env[ENV];
else process.env[ENV] = previous;
}
}
describe("gateway credential resolution", () => {
it("prefers the credential the host stored over one written in config", async () => {
const { log } = collectingLogger();
const got = await withEnv("from-env", () =>
resolveApiKey(ctxWith({ type: "key", key: "from-host" }), "omniroute", "from-option", log)
);
assert.deepEqual(got, { key: "from-host", origin: "connection" });
});
it("falls back to the configured option, then to the environment", async () => {
const { log } = collectingLogger();
const option = await withEnv("from-env", () =>
resolveApiKey(ctxWith(undefined), "omniroute", "from-option", log)
);
assert.deepEqual(option, { key: "from-option", origin: "option" });
const env = await withEnv("from-env", () =>
resolveApiKey(ctxWith(undefined), "omniroute", undefined, log)
);
assert.deepEqual(env, { key: "from-env", origin: "env" });
});
it("reports no key rather than pretending an empty one works", async () => {
const { log, warnings } = collectingLogger();
const got = await withEnv(undefined, () =>
resolveApiKey(ctxWith(undefined), "omniroute", undefined, log)
);
assert.deepEqual(got, { key: "", origin: "missing" });
warnIfMissing(got, "omniroute", log);
assert.equal(warnings.length, 1);
// The message must name every way out, or it sends the user hunting.
assert.match(warnings[0] ?? "", /Connect the integration/);
assert.match(warnings[0] ?? "", /"apiKey"/);
assert.match(warnings[0] ?? "", new RegExp(ENV));
});
it("declines an oauth credential instead of reading a token as a key", async () => {
const { log, warnings } = collectingLogger();
const got = await withEnv(undefined, () =>
resolveApiKey(
ctxWith({ type: "oauth", methodID: "m", refresh: "r", access: "a", expires: 0 }),
"omniroute",
undefined,
log
)
);
assert.equal(got.origin, "missing");
assert.equal(warnings.length, 1);
assert.match(warnings[0] ?? "", /oauth/);
});
it("stays loadable on a host that has no connection api", async () => {
const { log, warnings } = collectingLogger();
const got = await withEnv(undefined, () =>
resolveApiKey(
ctxWith(undefined, { withConnectionApi: false }),
"omniroute",
"from-option",
log
)
);
assert.deepEqual(got, { key: "from-option", origin: "option" });
assert.deepEqual(warnings, []);
});
it("treats a lookup that throws as no credential, not as a failure to load", async () => {
const { log, warnings } = collectingLogger();
const ctx = {
integration: {
connection: {
active: async () => {
throw new Error("store unavailable");
},
resolve: async () => undefined,
},
},
} as unknown as PluginContext;
const got = await withEnv(undefined, () => resolveApiKey(ctx, "omniroute", "from-option", log));
assert.deepEqual(got, { key: "from-option", origin: "option" });
assert.equal(warnings.length, 1);
assert.match(warnings[0] ?? "", /store unavailable/);
});
});

View File

@@ -0,0 +1,59 @@
import { describe, it, afterEach } from "node:test";
import assert from "node:assert/strict";
import { defaultOmniRouteEnrichmentFetcher } from "../src/shared/enrich.js";
const realFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = realFetch;
});
/**
* A gateway routes the same model id through several upstream providers. The
* overlay must keep them apart: one connection's price shown on another's
* model is worse than no price at all, because it looks authoritative.
*/
describe("two providers selling the same model id keep their own overlay", () => {
it("does not let the second provider's price land on the first one's model", async () => {
globalThis.fetch = (async (url: unknown) => {
const href = String(url);
const ok = (body: unknown) => ({
ok: true,
status: 200,
statusText: "OK",
json: async () => body,
});
if (href.includes("/api/pricing/models"))
return ok({
providers: {
cc: {
id: "claude",
alias: "cc",
name: "Claude",
models: [{ id: "shared", name: "From Claude" }],
},
kir: {
id: "kiro",
alias: "kir",
name: "Kiro",
models: [{ id: "shared", name: "From Kiro" }],
},
},
});
if (href.includes("/api/pricing"))
return ok({
cc: { shared: { input: 3, output: 15 } },
kir: { shared: { input: 99, output: 99 } },
});
return ok({});
}) as unknown as typeof fetch;
const map = await defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000);
assert.deepEqual(map.get("cc/shared")?.pricing, { input: 3, output: 15 });
assert.deepEqual(map.get("kir/shared")?.pricing, { input: 99, output: 99 });
// The bare fallback belongs to whoever claimed it first, and keeps that
// provider's numbers rather than the last writer's.
assert.equal(map.get("shared")?.providerAlias, "cc");
assert.deepEqual(map.get("shared")?.pricing, { input: 3, output: 15 });
assert.notEqual(map.get("shared"), map.get("cc/shared"), "the bare entry is its own object");
});
});

View File

@@ -0,0 +1,64 @@
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";
function model(id: string, name = id): ModelV2 {
return {
id,
name,
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
} as unknown as ModelV2;
}
describe("the enrichment overlay reaches the picker", () => {
it("renders the upstream provider a model routes to", () => {
const m = applyEnrichment(model("cc/sonnet"), {
name: "Claude Sonnet 4.6",
providerDisplayName: "Claude",
providerAlias: "cc",
});
assert.equal(m.name, "Claude - Claude Sonnet 4.6");
});
it("marks a free model and states the budget the user actually gets", () => {
const m = applyEnrichment(model("pol/grok"), {
name: "Grok 4 Fast",
providerDisplayName: "Pollinations",
freeType: "recurring-monthly" as const,
monthlyTokens: 1_000_000,
});
assert.match(m.name, /\[Free\]/);
assert.match(m.name, /Grok 4 Fast/);
assert.match(
m.name,
/tokens\/month/,
"the budget the gateway reports is stated, not just the fact it is free"
);
assert.equal(m.name, "[Free] Pollinations - Grok 4 Fast · 1M tokens/month");
});
it("drops the tag when the caller turns it off", () => {
const m = applyEnrichment(
model("cc/sonnet"),
{ name: "Claude Sonnet 4.6", providerDisplayName: "Claude" },
{ providerTag: false }
);
assert.equal(m.name, "Claude Sonnet 4.6");
});
it("never tags a combo: it routes across providers, not to one", () => {
const m = applyEnrichment(
model("combo-fast", "Combo Fast"),
{ name: "Combo Fast", providerDisplayName: "Claude" },
{ isCombo: true }
);
assert.equal(m.name, "Combo Fast");
});
it("still overlays pricing", () => {
const m = applyEnrichment(model("x"), { pricing: { input: 3, output: 15 } });
assert.equal(m.cost.input, 3);
assert.equal(m.cost.output, 15);
});
});

View File

@@ -0,0 +1,106 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { Logger } from "../src/shared/index.js";
import { createSourceErrorReporter } from "../src/enrichment-report.js";
function collectingLogger(): { log: Logger; warnings: string[] } {
const warnings: string[] = [];
const log = {
error: () => {},
warn: (m: string) => warnings.push(m),
info: () => {},
debug: () => {},
} as unknown as Logger;
return { log, warnings };
}
describe("degraded enrichment is reported, not swallowed", () => {
it("names the endpoint and what the catalog loses", () => {
const { log, warnings } = collectingLogger();
createSourceErrorReporter(log, false)("/api/pricing", "HTTP 500");
assert.equal(warnings.length, 1);
assert.match(warnings[0] ?? "", /\/api\/pricing/);
assert.match(warnings[0] ?? "", /HTTP 500/);
assert.match(warnings[0] ?? "", /pricing are degraded/);
});
it("points a 403 at the management token when the inference key stands in", () => {
const { log, warnings } = collectingLogger();
createSourceErrorReporter(log, true)("/api/pricing/models", "HTTP 403");
assert.match(warnings[0] ?? "", /managementReadToken/);
assert.match(warnings[0] ?? "", /falls back to "apiKey"/);
});
it("does not blame the fallback when a management token was configured", () => {
const { log, warnings } = collectingLogger();
createSourceErrorReporter(log, false)("/api/pricing/models", "HTTP 403");
assert.match(warnings[0] ?? "", /was rejected/);
assert.doesNotMatch(warnings[0] ?? "", /falls back/);
});
it("keeps a transport failure free of auth advice", () => {
const { log, warnings } = collectingLogger();
createSourceErrorReporter(log, true)("/api/pricing", "connect ECONNREFUSED");
assert.doesNotMatch(warnings[0] ?? "", /managementReadToken/);
});
it("warns once per endpoint so a refresh loop cannot spam the log", () => {
const { log, warnings } = collectingLogger();
const report = createSourceErrorReporter(log, true);
report("/api/pricing", "HTTP 403");
report("/api/pricing", "HTTP 403");
report("/api/free-tier/summary", "HTTP 403");
assert.equal(warnings.length, 2);
});
});
describe("the library path reports too, not only the plugin", () => {
it("passes a refused /api/providers up to the caller through publishCatalog", async () => {
const { publishCatalog } = await import("../src/catalog.js");
const seen: Array<[string, string]> = [];
const origFetch = globalThis.fetch;
globalThis.fetch = (async (url: unknown) => {
const href = String(url);
const ok = (body: unknown) => ({
ok: true,
status: 200,
statusText: "OK",
json: async () => body,
});
if (href.includes("/api/providers"))
return { ok: false, status: 403, statusText: "Forbidden", json: async () => ({}) };
if (href.includes("/api/pricing") || href.includes("/api/free-tier"))
return { ok: false, status: 403, statusText: "Forbidden", json: async () => ({}) };
if (href.includes("/api/combos")) return ok({ combos: [] });
return ok({ data: [{ id: "m1" }] });
}) as unknown as typeof fetch;
const draft = {
provider: { update: (_id: string, fn: (p: Record<string, unknown>) => void) => fn({}) },
model: {
update: (_p: string, _m: string, fn: (x: Record<string, unknown>) => void) => fn({}),
},
};
try {
await publishCatalog(
draft as never,
{
providerId: "omni",
baseURL: "https://gw.example.com",
apiKey: "k",
timeoutMs: 1000,
modelCacheTtlMs: 1000,
usableOnly: true,
} as never,
{ onSourceError: (endpoint, reason) => seen.push([endpoint, reason]) }
);
} finally {
globalThis.fetch = origFetch;
}
const endpoints = seen.map(([e]) => e);
assert.ok(
endpoints.includes("/api/providers"),
`the usable filter must say it was refused, got ${JSON.stringify(endpoints)}`
);
assert.ok(endpoints.some((e) => e.startsWith("/api/pricing")));
});
});

View File

@@ -0,0 +1,124 @@
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";
function fakeDraft(): { models: Map<string, ModelV2Info>; draft: CatalogDraft } {
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) ?? { id }) as ProviderV2Info;
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 { models, draft };
}
const baseOpts = {
providerId: "omniroute",
baseURL: "https://gw.example.com",
apiKey: "k",
timeoutMs: 1000,
modelCacheTtlMs: 300000,
usableOnly: false,
};
const stubModels = async () => [{ id: "cc/m1", context_length: 1000 }];
describe("catalog enrichment source", () => {
it("applies names and pricing from an injected enrichmentFetcher", async () => {
const { models, draft } = fakeDraft();
const enrichment: OmniRouteEnrichmentMap = new Map([
["cc/m1", { name: "Model One", pricing: { input: 3, output: 15 } }],
["m1", { name: "Model One", pricing: { input: 3, output: 15 } }],
]);
const res = await publishCatalog(draft, baseOpts, {
fetcher: stubModels,
combosFetcher: async () => [],
enrichmentFetcher: async () => enrichment,
});
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
const m = models.get("omniroute/cc/m1");
assert.ok(m);
assert.equal(m?.name, "Model One");
assert.equal(m?.cost[0].input, 3);
assert.equal(m?.cost[0].output, 15);
});
it("stays fail-open when the enrichment fetch throws", async () => {
const { models, draft } = fakeDraft();
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
warns.push(String(args[0]));
};
try {
const res = await publishCatalog(draft, baseOpts, {
fetcher: stubModels,
combosFetcher: async () => [],
enrichmentFetcher: async () => {
throw new Error("pricing down");
},
});
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
assert.ok(models.get("omniroute/cc/m1"));
} finally {
console.warn = origWarn;
}
assert.ok(warns.some((w) => w.includes("enrichment fetch failed")));
});
it("forwards the enrichment timeout and skips the fetch when enrichment is false", async () => {
const { draft } = fakeDraft();
let seenTimeout: number | undefined;
await publishCatalog(
draft,
{ ...baseOpts, timeouts: { enrichment: 4321 } },
{
fetcher: stubModels,
combosFetcher: async () => [],
enrichmentFetcher: async (_base, _key, timeout) => {
seenTimeout = timeout;
return new Map();
},
}
);
assert.equal(seenTimeout, 4321);
const { draft: draft2 } = fakeDraft();
let called = 0;
await publishCatalog(
draft2,
{ ...baseOpts, enrichment: false },
{
fetcher: stubModels,
combosFetcher: async () => [],
enrichmentFetcher: async () => {
called += 1;
return new Map();
},
}
);
assert.equal(called, 0);
});
});

View File

@@ -0,0 +1,68 @@
{
"models": [
{
"id": "m-alpha",
"object": "model",
"owned_by": "openai",
"context_length": 128000,
"max_output_tokens": 4096,
"input_modalities": ["text", "image"],
"output_modalities": ["text"],
"capabilities": { "tool_calling": true, "reasoning": true }
},
{
"id": "m-beta",
"object": "model",
"owned_by": "openai",
"context_length": 64000,
"max_output_tokens": 2048,
"input_modalities": ["text"],
"output_modalities": ["text"],
"capabilities": { "tool_calling": false, "thinking": true, "temperature": false }
},
{
"id": "cc/m-gamma",
"object": "model",
"owned_by": "anthropic",
"context_length": 200000,
"max_output_tokens": 32000,
"input_modalities": ["text", "image", "pdf"],
"output_modalities": ["text"],
"capabilities": { "tool_calling": true, "reasoning": true, "vision": true }
},
{
"id": "local-delta",
"object": "model",
"context_length": 32768,
"capabilities": { "effort_tiers": ["low", "high"] }
},
{
"id": "good-combo",
"object": "model",
"owned_by": "omniroute",
"context_length": 100000,
"max_output_tokens": 8000,
"capabilities": { "tool_calling": true }
}
],
"combos": [
{
"id": "combo-fast",
"name": "Combo Fast",
"strategy": "priority",
"models": [
{ "kind": "model", "model": "m-alpha" },
{ "kind": "model", "model": "cc/m-gamma" }
]
},
{
"id": "good-combo",
"name": "Good Combo",
"strategy": "priority",
"models": [
{ "kind": "model", "model": "m-beta" },
{ "kind": "model", "model": "local-delta" }
]
}
]
}

View File

@@ -0,0 +1,301 @@
{
"_note": "Recorded output of @omniroute/opencode-plugin (v1) against tests/fixtures/catalog.json. This is the contract this package claims parity with; regenerate only when intentionally re-basing on a newer v1.",
"v1PluginVersion": "0.2.1",
"hookId": "opencode-omniroute",
"publishedKeys": ["combo-fast", "good-combo", "local-delta", "m-alpha", "m-beta"],
"comboSlugs": ["combo-fast", "good-combo"],
"mappedModels": {
"m-alpha": {
"id": "omniroute/m-alpha",
"name": "m-alpha",
"capabilities": {
"temperature": true,
"reasoning": true,
"attachment": false,
"toolcall": true,
"input": {
"text": true,
"audio": false,
"image": true,
"video": false,
"pdf": false
},
"output": {
"text": true,
"audio": false,
"image": false,
"video": false,
"pdf": false
},
"interleaved": false
},
"cost": {
"input": 0,
"output": 0,
"cache": {
"read": 0,
"write": 0
}
},
"limit": {
"context": 128000,
"output": 4096
},
"status": "active",
"options": {},
"headers": {},
"release_date": "",
"providerID": "omniroute",
"api": {
"id": "openai-compatible",
"url": "https://gw.example.com/v1",
"npm": "@ai-sdk/openai-compatible"
}
},
"m-beta": {
"id": "omniroute/m-beta",
"name": "m-beta",
"capabilities": {
"temperature": false,
"reasoning": true,
"attachment": false,
"toolcall": false,
"input": {
"text": true,
"audio": false,
"image": false,
"video": false,
"pdf": false
},
"output": {
"text": true,
"audio": false,
"image": false,
"video": false,
"pdf": false
},
"interleaved": true
},
"cost": {
"input": 0,
"output": 0,
"cache": {
"read": 0,
"write": 0
}
},
"limit": {
"context": 64000,
"output": 2048
},
"status": "active",
"options": {},
"headers": {},
"release_date": "",
"providerID": "omniroute",
"api": {
"id": "openai-compatible",
"url": "https://gw.example.com/v1",
"npm": "@ai-sdk/openai-compatible"
}
},
"local-delta": {
"id": "omniroute/local-delta",
"name": "local-delta",
"capabilities": {
"temperature": true,
"reasoning": false,
"attachment": false,
"toolcall": false,
"input": {
"text": true,
"audio": false,
"image": false,
"video": false,
"pdf": false
},
"output": {
"text": true,
"audio": false,
"image": false,
"video": false,
"pdf": false
},
"interleaved": false
},
"cost": {
"input": 0,
"output": 0,
"cache": {
"read": 0,
"write": 0
}
},
"limit": {
"context": 32768,
"output": 0
},
"variants": {
"low": {
"reasoningEffort": "low"
},
"high": {
"reasoningEffort": "high"
}
},
"status": "active",
"options": {},
"headers": {},
"release_date": "",
"providerID": "omniroute",
"api": {
"id": "openai-compatible",
"url": "https://gw.example.com/v1",
"npm": "@ai-sdk/openai-compatible"
}
},
"good-combo": {
"id": "omniroute/good-combo",
"name": "good-combo",
"capabilities": {
"temperature": true,
"reasoning": false,
"attachment": false,
"toolcall": true,
"input": {
"text": true,
"audio": false,
"image": false,
"video": false,
"pdf": false
},
"output": {
"text": true,
"audio": false,
"image": false,
"video": false,
"pdf": false
},
"interleaved": false
},
"cost": {
"input": 0,
"output": 0,
"cache": {
"read": 0,
"write": 0
}
},
"limit": {
"context": 100000,
"output": 8000
},
"status": "active",
"options": {},
"headers": {},
"release_date": "",
"providerID": "omniroute",
"api": {
"id": "openai-compatible",
"url": "https://gw.example.com/v1",
"npm": "@ai-sdk/openai-compatible"
}
}
},
"mappedCombos": {
"combo-fast": {
"id": "combo-fast",
"providerID": "omniroute",
"api": {
"id": "openai-compatible",
"url": "https://gw.example.com/v1",
"npm": "@ai-sdk/openai-compatible"
},
"name": "Combo Fast",
"capabilities": {
"temperature": true,
"reasoning": true,
"attachment": false,
"toolcall": true,
"input": {
"text": true,
"audio": false,
"image": true,
"video": false,
"pdf": false
},
"output": {
"text": true,
"audio": false,
"image": false,
"video": false,
"pdf": false
},
"interleaved": false
},
"cost": {
"input": 0,
"output": 0,
"cache": {
"read": 0,
"write": 0
}
},
"limit": {
"context": 128000,
"output": 4096
},
"status": "active",
"options": {},
"headers": {},
"release_date": ""
},
"good-combo": {
"id": "good-combo",
"providerID": "omniroute",
"api": {
"id": "openai-compatible",
"url": "https://gw.example.com/v1",
"npm": "@ai-sdk/openai-compatible"
},
"name": "Good Combo",
"capabilities": {
"temperature": false,
"reasoning": false,
"attachment": false,
"toolcall": false,
"input": {
"text": true,
"audio": false,
"image": false,
"video": false,
"pdf": false
},
"output": {
"text": true,
"audio": false,
"image": false,
"video": false,
"pdf": false
},
"interleaved": false
},
"cost": {
"input": 0,
"output": 0,
"cache": {
"read": 0,
"write": 0
}
},
"limit": {
"context": 32768,
"output": 2048
},
"status": "active",
"options": {},
"headers": {},
"release_date": ""
}
}
}

View File

@@ -0,0 +1,226 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { Logger } from "../src/shared/index.js";
import type { LanguageModelV3 } from "@ai-sdk/provider";
import plugin from "../src/index.js";
import { sanitizeToolSchemasFor } from "../src/gemini-language.js";
function collectingLogger(): { log: Logger; warnings: string[] } {
const warnings: string[] = [];
const log = {
error: () => {},
warn: (m: string) => warnings.push(m),
info: () => {},
debug: () => {},
} as unknown as Logger;
return { log, warnings };
}
/** Records what the underlying model was actually asked to do. */
function recordingModel(): { model: Record<string, unknown>; seen: unknown[] } {
const seen: unknown[] = [];
const model = {
specificationVersion: "v3",
modelId: "gemini-2.5-flash",
provider: "omniroute",
doGenerate: async (options: unknown) => {
seen.push(options);
return { content: [], finishReason: "stop", usage: {}, warnings: [] };
},
doStream: async (options: unknown) => {
seen.push(options);
return { stream: new ReadableStream() };
},
};
return { model, seen };
}
const dirtyTools = [
{
type: "function",
name: "edit",
inputSchema: {
type: "object",
additionalProperties: false,
properties: { p: { type: "string" } },
},
},
];
describe("Gemini sanitising on the language model", () => {
it("cleans the tool schemas a Gemini model would reject, on both call paths", async () => {
const { log } = collectingLogger();
const { model, seen } = recordingModel();
const wrapped = sanitizeToolSchemasFor(
model as unknown as LanguageModelV3,
"gemini-2.5-flash",
log
);
await wrapped.doGenerate({ prompt: [], tools: structuredClone(dirtyTools) } as never);
await wrapped.doStream({ prompt: [], tools: structuredClone(dirtyTools) } as never);
assert.equal(seen.length, 2);
for (const options of seen) {
const schema = (options as { tools: Array<{ inputSchema: Record<string, unknown> }> })
.tools[0]!.inputSchema;
assert.equal("additionalProperties" in schema, false);
assert.equal(
((schema["properties"] as Record<string, Record<string, unknown>>)["p"] ?? {})["type"],
"string"
);
}
});
it("leaves a non-Gemini model's request exactly as it was", async () => {
const { log } = collectingLogger();
const { model, seen } = recordingModel();
const wrapped = sanitizeToolSchemasFor(
model as unknown as LanguageModelV3,
"claude-opus-5",
log
);
assert.equal(wrapped, model, "a model that needs no cleaning must not even be wrapped");
await (wrapped as unknown as { doGenerate: (o: unknown) => Promise<unknown> }).doGenerate({
prompt: [],
tools: structuredClone(dirtyTools),
});
const schema = (seen[0] as { tools: Array<{ inputSchema: Record<string, unknown> }> }).tools[0]!
.inputSchema;
assert.equal(schema["additionalProperties"], false);
});
it("forwards a request with no tools untouched", async () => {
const { log } = collectingLogger();
const { model, seen } = recordingModel();
const wrapped = sanitizeToolSchemasFor(
model as unknown as LanguageModelV3,
"gemini-2.5-flash",
log
);
await wrapped.doGenerate({ prompt: [] } as never);
assert.deepEqual(seen[0], { prompt: [] });
});
it("keeps the properties the model still needs, and says once that it cleaned", async () => {
const { log, warnings } = collectingLogger();
const { model, seen } = recordingModel();
const wrapped = sanitizeToolSchemasFor(
model as unknown as LanguageModelV3,
"gemini-2.5-flash",
log
);
await wrapped.doGenerate({ prompt: [], tools: structuredClone(dirtyTools) } as never);
await wrapped.doGenerate({ prompt: [], tools: structuredClone(dirtyTools) } as never);
const schema = (seen[0] as { tools: Array<{ inputSchema: Record<string, any> }> }).tools[0]!
.inputSchema;
assert.equal(schema["properties"]["p"]["type"], "string");
assert.equal(warnings.length, 0, "a routine cleaning is not a warning");
});
it("passes the untouched model through when the host hands over nothing to wrap", () => {
const { log } = collectingLogger();
assert.equal(
sanitizeToolSchemasFor(undefined as LanguageModelV3 | undefined, "gemini-2.5-flash", log),
undefined
);
});
});
describe("Gemini sanitising is wired into the host, and only where it belongs", () => {
interface LanguageInput {
model: { id: string; providerID: string };
sdk: unknown;
options: Record<string, unknown>;
language?: LanguageModelV3;
}
function hostCtx(opts: { geminiSanitization?: boolean; withAisdk?: boolean }): {
ctx: Record<string, unknown>;
languageCallbacks: Array<(input: LanguageInput) => void | Promise<void>>;
} {
const languageCallbacks: Array<(input: LanguageInput) => 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.geminiSanitization !== undefined)
options["geminiSanitization"] = opts.geminiSanitization;
const ctx: Record<string, unknown> = {
options,
catalog: { transform: () => registration, reload: async () => {} },
integration: { transform: () => registration },
};
if (opts.withAisdk !== false) {
ctx["aisdk"] = {
language: (cb: (input: LanguageInput) => void | Promise<void>) => {
languageCallbacks.push(cb);
return registration;
},
sdk: () => registration,
};
}
return { ctx, languageCallbacks };
}
async function setupPlugin(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;
}
}
const bareModel = { specificationVersion: "v3", modelId: "x" } as unknown as LanguageModelV3;
it("wraps a Gemini model of this provider and leaves every other one alone", async () => {
const { ctx, languageCallbacks } = hostCtx({});
await setupPlugin(ctx);
assert.equal(languageCallbacks.length, 1);
const wrapOf = async (model: { id: string; providerID: string }) => {
const input: LanguageInput = { model, sdk: {}, options: {}, language: bareModel };
await languageCallbacks[0]!(input);
return input.language !== bareModel;
};
assert.equal(await wrapOf({ id: "gemini-2.5-flash", providerID: "omni" }), true);
assert.equal(await wrapOf({ id: "claude-opus-5", providerID: "omni" }), false);
assert.equal(
await wrapOf({ id: "gemini-2.5-flash", providerID: "some-other-provider" }),
false,
"another provider's models are none of this plugin's business"
);
});
it("registers nothing when the option is turned off", async () => {
const { ctx, languageCallbacks } = hostCtx({ geminiSanitization: false });
await setupPlugin(ctx);
assert.deepEqual(languageCallbacks, []);
});
it("still loads on a host that exposes no aisdk domain", async () => {
const { ctx } = hostCtx({ withAisdk: false });
await setupPlugin(ctx);
});
it("keeps the catalog when the host refuses the language hook", async () => {
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 () => {} },
integration: { transform: () => registration },
aisdk: {
language: () => {
throw new Error("host says no");
},
sdk: () => registration,
},
};
// Must not reject: tool-schema cleaning is an extra, the catalog is the job.
await setupPlugin(ctx);
});
});

View File

@@ -0,0 +1,164 @@
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 };
}
const baseOpts = {
providerId: "omniroute",
baseURL: "https://gw.example.com",
apiKey: "k",
timeoutMs: 1000,
modelCacheTtlMs: 300000,
usableOnly: false,
};
const rawModel = {
id: "af/chat-latest",
capabilities: { effort_tiers: ["low", "high"] },
};
async function publish(kind: SeedKind) {
const { draft, providers, models } = fakeDraft(kind);
await publishCatalog(draft, 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");
assert.ok(model, "model must be published");
return { provider: provider as BinaryCompatProvider, model: model as BinaryCompatModel };
}
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");
});
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[];
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

@@ -0,0 +1,244 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import plugin from "../src/index.js";
interface CapturedCall {
kind: "catalog" | "integration";
}
interface FakeCtx {
options: Record<string, unknown>;
catalog: {
transform: (cb: (draft: unknown) => unknown) => Promise<{ dispose: () => Promise<void> }>;
};
integration: {
transform: (cb: (draft: unknown) => unknown) => Promise<{ dispose: () => Promise<void> }>;
};
}
function fakeCtx(options: Record<string, unknown>, seen: CapturedCall[]): FakeCtx {
return {
options,
catalog: {
transform: (cb: (draft: unknown) => unknown) => {
seen.push({ kind: "catalog" });
assert.equal(typeof cb, "function");
return Promise.resolve({ dispose: async () => {} });
},
},
integration: {
transform: (cb: (draft: unknown) => unknown) => {
seen.push({ kind: "integration" });
assert.equal(typeof cb, "function");
return Promise.resolve({ dispose: async () => {} });
},
},
};
}
describe("plugin-v2 entrypoint", () => {
it("boot line is silent by default, visible with startupDebug", async () => {
const seen: CapturedCall[] = [];
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
warns.push(args.map(String).join(" "));
};
try {
await (plugin as unknown as { setup: (ctx: FakeCtx) => Promise<void> }).setup(
fakeCtx({ baseURL: "https://gw.example.com", providerId: "omniroute" }, seen)
);
} finally {
console.warn = origWarn;
}
assert.ok(
!warns.some((line) => line.includes("init providerId=")),
`boot line must stay silent by default, got: ${JSON.stringify(warns)}`
);
assert.deepEqual(
seen.map((s) => s.kind),
["catalog", "integration"]
);
const seen2: CapturedCall[] = [];
const warns2: string[] = [];
console.warn = (...args: unknown[]) => {
warns2.push(args.map(String).join(" "));
};
try {
await (plugin as unknown as { setup: (ctx: FakeCtx) => Promise<void> }).setup(
fakeCtx(
{ baseURL: "https://gw.example.com", providerId: "omniroute", startupDebug: true },
seen2
)
);
} finally {
console.warn = origWarn;
}
assert.ok(
warns2.some((line) => line.includes("init providerId=omniroute")),
`boot line must show with startupDebug, got: ${JSON.stringify(warns2)}`
);
});
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("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 ctx: FakeCtx = {
options: { baseURL: "https://gw.example.com", providerId: "omniroute" },
catalog: {
transform: (cb: (draft: unknown) => unknown) => {
seen.push({ kind: "catalog" });
catalogCallbacks.push(cb);
return Promise.resolve({ dispose: async () => {} });
},
},
integration: {
transform: (cb: (draft: unknown) => unknown) => {
seen.push({ kind: "integration" });
integrationCallbacks.push(cb);
return Promise.resolve({ dispose: async () => {} });
},
},
};
const logs: string[] = [];
const origLog = console.log;
console.log = (...args: unknown[]) => {
logs.push(String(args[0]));
};
try {
await (plugin as unknown as { setup: (ctx: FakeCtx) => Promise<void> }).setup(ctx);
} finally {
console.log = origLog;
}
assert.equal(integrationCallbacks.length, 1);
interface MethodUpdate {
integrationID: string;
method: { type: string; label?: string; names?: string[] };
}
interface FakeIntegrationDraft {
updates: Array<{ id: string; name: string }>;
methods: MethodUpdate[];
update: (id: string, fn: (i: { name: string }) => void) => void;
method: { update: (input: MethodUpdate) => void };
}
const draft: FakeIntegrationDraft = {
updates: [],
methods: [],
update(id: string, fn: (i: { name: string }) => void) {
const entry = { name: "" };
fn(entry);
this.updates.push({ id, name: entry.name });
},
method: {
update(input: MethodUpdate) {
draft.methods.push(input);
},
},
};
(integrationCallbacks[0] as (draft: FakeIntegrationDraft) => unknown)(draft);
assert.deepEqual(draft.updates, [{ id: "omniroute", name: "OmniRoute" }]);
const keyMethod = draft.methods.find((m) => m.method.type === "key");
const envMethod = draft.methods.find((m) => m.method.type === "env");
assert.ok(keyMethod);
assert.equal(keyMethod?.integrationID, "omniroute");
assert.ok(envMethod);
assert.deepEqual(envMethod?.method.names, ["OMNIROUTE_API_KEY"]);
assert.ok(!draft.methods.some((m) => m.method.type === "oauth"));
});
it("lazy refresh: [m1] then [m1,m2] reloads once; identical runs never reload", async () => {
const { mkdtempSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { setTimeout: sleep } = await import("node:timers/promises");
const dir = mkdtempSync(join(tmpdir(), "omniroute-lazy-"));
const prevDataDir = process.env.OPENCODE_DATA_DIR;
process.env.OPENCODE_DATA_DIR = dir;
let modelsCall = 0;
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 () => ({ combos: [] }) };
}
modelsCall += 1;
const ids = modelsCall <= 1 ? [{ id: "m1" }] : [{ id: "m1" }, { id: "m2" }];
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: {
baseURL: "https://gw.example.com",
providerId: "lazy-reload",
apiKey: "k-lazy",
modelCacheTtlMs: 1,
},
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
catalogCallbacks.push(cb);
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {
reloads += 1;
},
},
integration: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
};
const logs: string[] = [];
const origLog = console.log;
console.log = (...args: unknown[]) => {
logs.push(String(args[0]));
};
try {
await (plugin as unknown as { setup: (ctx: unknown) => Promise<void> }).setup(ctx);
} 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);
await sleep(5);
// The optional tier lands after that first publish and brings combos and
// the overlay with it — one reload, so the picker shows them without
// waiting for the next refresh.
const afterFirstUpgrade = 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 sleep(5);
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 {
globalThis.fetch = origFetch;
}
});
});

View File

@@ -0,0 +1,233 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import plugin from "../src/index.js";
import { publishCatalog } from "../src/catalog.js";
const MODELS_URL = "https://gw.example.com/v1/models";
const COMBOS_URL = "https://gw.example.com/api/combos";
function silence() {
const warns: string[] = [];
const origWarn = console.warn;
const origLog = console.log;
console.warn = (...args: unknown[]) => {
warns.push(String(args[0]));
};
console.log = () => {};
return {
warns,
restore() {
console.warn = origWarn;
console.log = origLog;
},
};
}
function setup(options: Record<string, unknown>, reload?: () => Promise<void>) {
const catalogCallbacks: Array<(draft: unknown) => Promise<void>> = [];
const ctx = {
options,
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
catalogCallbacks.push(cb);
return Promise.resolve({ dispose: async () => {} });
},
...(reload ? { reload } : {}),
},
integration: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
};
return { catalogCallbacks, ctx };
}
function stubDraft() {
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 };
}
describe("plugin-v2 managementReadToken wiring (F1)", () => {
it("combos fetch uses managementReadToken while models use apiKey", async () => {
const seen = new Map<string, string>();
const origFetch = globalThis.fetch;
globalThis.fetch = (async (url: unknown, init?: { headers?: Record<string, string> }) => {
const href = String(url);
seen.set(href, String(init?.headers?.Authorization ?? ""));
if (href.includes("/api/combos/auto")) {
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
}
if (href.includes("/api/combos")) {
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
}
return {
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ data: [{ id: "m1" }] }),
};
}) as typeof fetch;
const guard = silence();
try {
const { catalogCallbacks, 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"));
assert.equal(seen.get(COMBOS_URL), "Bearer mgmt-key");
assert.equal(seen.get(MODELS_URL), "Bearer chat-key");
} finally {
globalThis.fetch = origFetch;
guard.restore();
}
});
it("combos fetch falls back to apiKey when managementReadToken is absent", async () => {
const seen = new Map<string, string>();
const origFetch = globalThis.fetch;
globalThis.fetch = (async (url: unknown, init?: { headers?: Record<string, string> }) => {
const href = String(url);
seen.set(href, String(init?.headers?.Authorization ?? ""));
if (href.includes("/api/combos/auto")) {
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
}
if (href.includes("/api/combos")) {
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
}
return {
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ data: [{ id: "m1" }] }),
};
}) as typeof fetch;
const guard = silence();
try {
const { catalogCallbacks, 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;
guard.restore();
}
});
it("publishCatalog 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,
{
providerId: "omniroute",
baseURL: "https://gw.example.com",
apiKey: "chat-key",
managementReadToken: "mgmt-key",
timeoutMs: 1000,
modelCacheTtlMs: 300000,
usableOnly: false,
},
{
fetcher: async (_baseURL, token) => {
calls.push(["models", token]);
return [{ id: "m1" }];
},
combosFetcher: async (_baseURL, token) => {
calls.push(["combos", token]);
return [];
},
}
);
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
assert.deepEqual(calls, [
["models", "chat-key"],
["combos", "mgmt-key"],
]);
});
});
describe("plugin-v2 fail-closed models (F2)", () => {
it("empty models fetch on 2nd refresh keeps the last-known catalog", async () => {
const { mkdtempSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const dir = mkdtempSync(join(tmpdir(), "omniroute-f2-"));
const prevDataDir = process.env.OPENCODE_DATA_DIR;
process.env.OPENCODE_DATA_DIR = dir;
let modelsCall = 0;
const origFetch = globalThis.fetch;
globalThis.fetch = (async (url: unknown) => {
const href = String(url);
if (href.includes("/api/combos/auto")) {
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
}
if (href.includes("/api/combos")) {
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
}
modelsCall += 1;
if (modelsCall === 1) {
return {
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ data: [{ id: "m1" }] }),
};
}
return { ok: false, status: 500, statusText: "Error", json: async () => ({}) };
}) 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 { setTimeout: sleep } = await import("node:timers/promises");
await sleep(5);
const second = stubDraft();
await catalogCallbacks[0](second.draft);
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"
);
assert.ok(
guard.warns.some((w) => w.includes("keeping last-known catalog")),
`expected keep-last-known warn, got: ${JSON.stringify(guard.warns)}`
);
} finally {
globalThis.fetch = origFetch;
guard.restore();
}
});
});

View File

@@ -0,0 +1,236 @@
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";
interface Captured {
models: Map<string, ModelV2Info>;
draft: CatalogDraft;
warns: string[];
restore: () => void;
}
function fakeDraft(): Captured {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
warns.push(String(args[0]));
};
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;
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 {
models,
draft,
warns,
restore: () => {
console.warn = origWarn;
},
};
}
const baseOpts = {
providerId: "omniroute",
baseURL: "https://gw.example.com",
apiKey: "k",
timeoutMs: 1000,
modelCacheTtlMs: 300000,
usableOnly: false,
};
describe("catalog nested combo refs", () => {
it("resolves a forward combo-ref: parent LCD follows the nested combo, not zero", async () => {
const c = fakeDraft();
try {
const res = await publishCatalog(c.draft, baseOpts, {
fetcher: async () => [
{
id: "a",
context_length: 100000,
max_output_tokens: 4000,
capabilities: { tool_calling: true, reasoning: true },
},
{
id: "b",
context_length: 50000,
max_output_tokens: 2000,
capabilities: { tool_calling: true, reasoning: true },
},
],
combosFetcher: async () => [
{
id: "parent",
name: "Parent",
models: [
{ kind: "combo-ref", comboName: "Child" } as never,
{ kind: "model", model: "a" } as never,
],
},
{
id: "child",
name: "Child",
models: [{ kind: "model", model: "b" } as never],
},
],
});
assert.deepEqual(res, { models: 2, combos: 2, autoCombos: 0 });
const parent = c.models.get("omniroute/parent");
assert.ok(parent);
assert.equal(parent?.limit.context, 50000);
assert.equal(parent?.limit.output, 2000);
assert.equal(parent?.capabilities.tools, true);
} finally {
c.restore();
}
});
it("drops an unresolvable combo-ref after the fixpoint instead of claiming empty LCD", async () => {
const c = fakeDraft();
try {
const res = await publishCatalog(c.draft, baseOpts, {
fetcher: async () => [{ id: "a" }],
combosFetcher: async () => [
{
id: "orphan",
name: "Orphan",
models: [{ kind: "combo-ref", comboName: "Missing" } as never],
},
],
});
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
assert.ok(!c.models.has("omniroute/orphan"));
assert.ok(c.warns.some((w) => w.includes("could not resolve")));
} finally {
c.restore();
}
});
it("resolves a two-level chain within the pass budget", async () => {
const c = fakeDraft();
try {
await publishCatalog(c.draft, baseOpts, {
fetcher: async () => [
{
id: "a",
context_length: 80000,
max_output_tokens: 3000,
capabilities: { tool_calling: true },
},
],
combosFetcher: async () => [
{
id: "top",
name: "Top",
models: [{ kind: "combo-ref", comboName: "Mid" } as never],
},
{
id: "mid",
name: "Mid",
models: [{ kind: "combo-ref", comboName: "Leaf" } as never],
},
{
id: "leaf",
name: "Leaf",
models: [{ kind: "model", model: "a" } as never],
},
],
});
const top = c.models.get("omniroute/top");
assert.ok(top);
assert.equal(top?.limit.context, 80000);
} finally {
c.restore();
}
});
});
describe("catalog collision dedupe", () => {
it("warns once per key even across repeated publishes sharing collisionWarned", async () => {
const c = fakeDraft();
try {
const collisionWarned = new Set<string>();
const args = {
fetcher: async () => [{ id: "dupe" }],
combosFetcher: async () => [
{ id: "dupe", name: "Dupe Combo", models: [{ kind: "model", model: "dupe" } as never] },
],
};
await publishCatalog(c.draft, { ...baseOpts, collisionWarned }, args);
await publishCatalog(c.draft, { ...baseOpts, collisionWarned }, args);
const hits = c.warns.filter((w) => w.includes("collides with a model id"));
assert.equal(hits.length, 1);
} finally {
c.restore();
}
});
it("suppresses the warning for the intentional dedup pattern", async () => {
const c = fakeDraft();
try {
// v1 pattern: /v1/models pre-mirrors the combo under its friendly
// NAME and the combo is keyed by that name, so the overwritten model
// id equals the combo name exactly and the draft key collides.
const res = await publishCatalog(c.draft, baseOpts, {
fetcher: async () => [{ id: "omniroute/Mirror Combo" }],
combosFetcher: async () => [
{
id: "Mirror Combo",
name: "Mirror Combo",
models: [{ kind: "model", model: "omniroute/Mirror Combo" } as never],
},
],
});
assert.deepEqual(res, { models: 1, combos: 1, autoCombos: 0 });
assert.ok(!c.warns.some((w) => w.includes("collides")));
assert.equal(c.models.get("omniroute/Mirror Combo")?.name, "Mirror Combo");
} finally {
c.restore();
}
});
it("shares the dedupe set when the caller passes collisionWarned", async () => {
const c = fakeDraft();
try {
const collisionWarned = new Set<string>();
const args = {
fetcher: async () => [{ id: "other" }],
combosFetcher: async () => [
{
id: "other",
name: "Other Combo",
models: [{ kind: "model", model: "other" } as never],
},
],
};
await publishCatalog(c.draft, { ...baseOpts, collisionWarned }, args);
await publishCatalog(c.draft, { ...baseOpts, collisionWarned }, args);
const hits = c.warns.filter((w) => w.includes("collides with a model id"));
assert.equal(hits.length, 1);
} finally {
c.restore();
}
});
});

View File

@@ -0,0 +1,129 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
integrationIdFor,
parsePluginOptions,
PLUGIN_ID,
providerIdFor,
resolveTimeouts,
} from "../src/options.js";
describe("parsePluginOptions", () => {
it("applies defaults for providerId, timeoutMs, usableOnly, enrichment", () => {
const opts = parsePluginOptions({ baseURL: "https://gw.example.com" });
assert.equal(opts.providerId, "omniroute");
assert.equal(opts.timeoutMs, 10000);
assert.equal(opts.usableOnly, false);
assert.equal(opts.enrichment, true);
assert.equal(opts.modelCacheTtlMs, undefined);
});
it("accepts a positive modelCacheTtlMs (in-memory TTL cache, default 300s)", () => {
const opts = parsePluginOptions({ baseURL: "https://gw.example.com", modelCacheTtlMs: 60000 });
assert.equal(opts.modelCacheTtlMs, 60000);
});
it("rejects a non-positive modelCacheTtlMs", () => {
assert.throws(() =>
parsePluginOptions({ baseURL: "https://gw.example.com", modelCacheTtlMs: 0 })
);
});
it("requires baseURL", () => {
assert.throws(() => parsePluginOptions({}), /baseURL/);
});
it("rejects unknown top-level keys (strict)", () => {
assert.throws(() => parsePluginOptions({ baseURL: "https://gw.example.com", bogus: 1 }));
});
it("rejects unknown apiFormat keys (strict)", () => {
assert.throws(() =>
parsePluginOptions({
baseURL: "https://gw.example.com",
apiFormat: { bogus: ["claude"] },
})
);
});
it("accepts deprecated anthropicPrefixes (warn at resolve time, not parse time)", () => {
const opts = parsePluginOptions({
baseURL: "https://gw.example.com",
apiFormat: { allowAnthropic: true, anthropicPrefixes: ["cc", "claude"] },
});
assert.deepEqual(opts.apiFormat, {
allowAnthropic: true,
anthropicPrefixes: ["cc", "claude"],
});
});
it("passes apiFormat allowlist through (shared enforces semantics)", () => {
const opts = parsePluginOptions({
baseURL: "https://gw.example.com",
apiFormat: { allowAnthropic: true, anthropicModels: ["anthropic/claude-x"] },
});
assert.deepEqual(opts.apiFormat, {
allowAnthropic: true,
anthropicModels: ["anthropic/claude-x"],
});
});
});
describe("identity table", () => {
it("maps providerId X to provider X and integration X, under one fixed plugin id", () => {
assert.equal(providerIdFor("omniroute"), "omniroute");
assert.equal(integrationIdFor("omniroute"), "omniroute");
assert.equal(providerIdFor("second-gateway"), "second-gateway");
// The host reads the plugin id before any option exists, so it never
// varies with providerId.
assert.equal(PLUGIN_ID, "omniroute-v2");
});
});
describe("invalid options say what to fix", () => {
it("names an unknown key instead of dumping the validator output", () => {
assert.throws(
() => parsePluginOptions({ baseURL: "http://gw.example.com", modelCacheTtl: 300000 }),
(err: Error) => {
assert.match(err.message, /invalid plugin options/);
assert.match(err.message, /unknown option "modelCacheTtl"/);
return true;
}
);
});
it("names the offending field for a wrong type", () => {
assert.throws(
() => parsePluginOptions({ baseURL: 42 }),
(err: Error) => {
assert.match(err.message, /baseURL/);
return true;
}
);
});
it("accepts the documented option names", () => {
const parsed = parsePluginOptions({
baseURL: "http://gw.example.com",
modelCacheTtlMs: 300000,
timeouts: { models: 15000, combos: 8000 },
geminiSanitization: false,
});
assert.equal(parsed.modelCacheTtlMs, 300000);
assert.equal(resolveTimeouts(parsed).models, 15000);
});
});
describe("providerId is bounded because it reaches a filesystem path", () => {
it("rejects a traversal attempt instead of writing outside the snapshot directory", () => {
for (const bad of ["../../etc/cron.d/x", "a/b", "..", "."]) {
assert.throws(
() => parsePluginOptions({ baseURL: "https://gw.example.com", providerId: bad }),
/invalid plugin options/,
`providerId ${JSON.stringify(bad)} must be rejected`
);
}
});
it("keeps the ids a user would actually pick", () => {
for (const ok of ["omniroute", "omniroute-2", "gw.staging", "gw_prod"]) {
assert.equal(
parsePluginOptions({ baseURL: "https://gw.example.com", providerId: ok }).providerId,
ok
);
}
});
});

View File

@@ -0,0 +1,224 @@
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,
mapRawModelToModelV2 as sharedMapModel,
type OmniRouteCombosFetcher,
type OmniRouteModelsFetcher,
type OmniRouteRawCombo,
type OmniRouteRawModelEntry,
} from "../src/shared/index.js";
interface Fixture {
models: OmniRouteRawModelEntry[];
combos: OmniRouteRawCombo[];
}
/**
* What the v1 plugin produces for the same fixture, recorded in
* `fixtures/v1-parity.json`. Running v1 here instead would mean importing its
* build output from a sibling package: it only exists on a machine that has
* built v1, so the check silently passed locally and could not run in CI at
* all. Recording it makes the claim reviewable in the diff and reproducible
* anywhere.
*/
interface V1Parity {
v1PluginVersion: string;
hookId: string;
publishedKeys: string[];
comboSlugs: string[];
mappedModels: Record<string, unknown>;
mappedCombos: Record<string, unknown>;
}
async function loadFixture(): Promise<Fixture> {
const chunks: Buffer[] = [];
for await (const chunk of createReadStream(new URL("./fixtures/catalog.json", import.meta.url))) {
chunks.push(chunk as Buffer);
}
return JSON.parse(Buffer.concat(chunks).toString("utf8")) as Fixture;
}
async function loadV1Parity(): Promise<V1Parity> {
const chunks: Buffer[] = [];
for await (const chunk of createReadStream(
new URL("./fixtures/v1-parity.json", import.meta.url)
)) {
chunks.push(chunk as Buffer);
}
return JSON.parse(Buffer.concat(chunks).toString("utf8")) as V1Parity;
}
type ApiAuth = { type: "api"; key: string };
function fakeDraft() {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
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;
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: () => {} },
},
};
}
const TEST_OPTS = {
baseURL: "https://gw.example.com",
providerId: "omniroute",
apiKey: "parity-key",
timeoutMs: 1000,
modelCacheTtlMs: 300000,
usableOnly: false as const,
};
describe("v1-vs-v2 catalog parity", () => {
it("same fixture models publish the same key set modulo documented exclusions", async () => {
const fixture = await loadFixture();
assert.equal(fixture.models.length, 5);
assert.equal(fixture.combos.length, 2);
const fetcher: OmniRouteModelsFetcher = async () => fixture.models;
const combosFetcher: OmniRouteCombosFetcher = async () => fixture.combos;
const recorded = await loadV1Parity();
assert.equal(recorded.hookId, "opencode-omniroute", "v1 published under its own provider id");
const stripX = (k: string): string =>
k.startsWith("omniroute/") ? k.slice("omniroute/".length) : k;
const v1Keys = recorded.publishedKeys;
const draft = fakeDraft();
const counts = await publishCatalog(draft, TEST_OPTS, { fetcher, combosFetcher });
assert.equal(counts.models, 5);
assert.equal(counts.combos, 2);
assert.equal(counts.autoCombos, 0);
// Final converted ModelV2Info 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).
const mAlpha = draft.models.get("omniroute/m-alpha");
assert.ok(mAlpha, "m-alpha published in v2 draft");
assert.equal(mAlpha.api.type, "aisdk");
if (mAlpha.api.type !== "aisdk") throw new Error("m-alpha api must be aisdk");
assert.equal(mAlpha.api.package, "@ai-sdk/openai-compatible");
assert.equal(mAlpha.capabilities.tools, true);
assert.equal(mAlpha.cost[0].input, 0);
// Measured key shapes: v1 namespaces the friendly name
// (`Combo Fast` -> `omniroute/combo-fast`) and keys the colliding combo
// by its raw id (`good-combo` -> `omniroute/good-combo`, combo wins);
// v2 publishes combo ids verbatim (`omniroute/combo-fast`,
// `omniroute/good-combo` overwriting the raw model). v1 and v2 therefore
// publish the SAME model keys (modulo the slashed-id exclusion above)
// and the SAME non-colliding combo key; the colliding `good-combo` combo
// overwrites the same-named raw model on BOTH sides (warn asserted below).
const v1Combo = v1Keys.filter((k) => k.startsWith("combo-") || k === "good-combo").sort();
const v2Combo = [...draft.models.keys()]
.map(stripX)
.filter((k) => k.startsWith("combo-") || k === "good-combo")
.sort();
// slug("Combo Fast") = "combo-fast", slug("Good Combo") = "good-combo".
assert.deepEqual(v2Combo, ["combo-fast", "good-combo"]);
assert.deepEqual(v1Combo, ["combo-fast", "good-combo"]);
assert.deepEqual(v2Combo, v1Combo);
assert.deepEqual(recorded.comboSlugs, ["combo-fast", "good-combo"]);
const v1Models = v1Keys.filter((k) => !k.startsWith("combo-") && k !== "good-combo").sort();
// Anchor v1 model keys as literals (modulo the slashed-id exclusion
// documented above: the bare `cc/m-gamma` key fails the
// `startsWith("omniroute/")` filter and is dropped here).
assert.deepEqual(v1Models, ["local-delta", "m-alpha", "m-beta"]);
const v2Models = [...draft.models.keys()]
.map(stripX)
.filter((k) => !k.includes("/") && !k.startsWith("combo-") && k !== "good-combo")
.sort();
assert.deepEqual(v2Models, v1Models);
// Collision: the combo whose friendly name collides with a raw model id
// wins on both sides (v1 warns via injected logger, v2 via console.warn).
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
warns.push(String(args[0]));
};
try {
const draft2 = fakeDraft();
await publishCatalog(draft2, TEST_OPTS, { fetcher, combosFetcher });
assert.ok(draft2.models.has("omniroute/good-combo"), "colliding combo key wins in v2");
} finally {
console.warn = origWarn;
}
assert.ok(warns.some((w) => w.includes("collides with a model id; combo wins")));
// Mapper-level parity for the slashed-id exclusion: v1 and shared mappers
// must produce identical ModelV2 payloads for every fixture entry.
// (No apiFormat on either side — the parity scope is key publication,
// not the v1-prefix vs v2-allowlist routing rule covered by A2 tests.
// The `cc/m-gamma` fixture entry is therefore EXCLUDED from the mapper
// comparison: v1 routes it to anthropic via its default
// anthropicPrefixes while shared/v2 leave it openai-compatible without
// an explicit anthropicModels allowlist.)
// Mapper parity for the slashed-id exclusion: shared must still produce
// the payloads v1 produced. (`cc/m-gamma` is excluded: v1 routes it to
// anthropic through its default prefix list, while v2 leaves it
// openai-compatible without an explicit allowlist — covered by the
// apiFormat tests.)
for (const entry of fixture.models.filter((m) => m.id !== "cc/m-gamma")) {
const expected = recorded.mappedModels[entry.id];
assert.ok(expected, `v1 output recorded for ${entry.id}`);
const viaShared = sharedMapModel(entry, {
providerId: "omniroute",
baseURL: TEST_OPTS.baseURL,
});
assert.deepEqual(
JSON.parse(JSON.stringify({ ...viaShared, providerID: undefined })),
JSON.parse(JSON.stringify({ ...(expected as object), providerID: undefined })),
`model mapper parity for ${entry.id}`
);
}
const byId = new Map(fixture.models.map((m) => [m.id, m]));
for (const combo of fixture.combos) {
const members = (combo.models ?? [])
.filter((s) => s?.kind !== "combo-ref" && typeof s?.model === "string")
.map((s) => byId.get(s.model as string))
.filter((m): m is OmniRouteRawModelEntry => m !== undefined);
assert.deepEqual(
JSON.parse(JSON.stringify(sharedMapCombo(combo, members, "omniroute", TEST_OPTS.baseURL))),
recorded.mappedCombos[combo.id],
`combo mapper parity for ${combo.id}`
);
}
});
it("fixture is rejected when it drifts from the 5+2 shape (guard against silent shrink)", async () => {
const fixture = await loadFixture();
assert.ok(fixture.models.length >= 5, "fixture must keep at least 5 models");
assert.ok(fixture.combos.length >= 2, "fixture must keep at least 2 combos");
});
});
void (0 as unknown as ApiAuth);
void (0 as unknown as CatalogDraft);

View File

@@ -0,0 +1,137 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
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)", () => {
function isolateDisk(): () => void {
const dir = mkdtempSync(join(tmpdir(), "omniroute-guard-"));
const prev = process.env.OPENCODE_DATA_DIR;
process.env.OPENCODE_DATA_DIR = dir;
return () => {
if (prev === undefined) delete process.env.OPENCODE_DATA_DIR;
else process.env.OPENCODE_DATA_DIR = prev;
};
}
function setupCtx(): {
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: "omniroute", apiKey: "k" },
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
catalogCallbacks.push(cb);
return Promise.resolve({ dispose: async () => {} });
},
},
integration: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
};
return { catalogCallbacks, ctx };
}
function stubFetch(): typeof fetch {
return (async (url: unknown) => {
const href = String(url);
if (href.includes("/api/combos/auto")) {
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
}
if (href.includes("/api/combos")) {
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
}
return {
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ data: [{ id: "m1" }] }),
};
}) as typeof fetch;
}
async function silenceConsole<T>(fn: () => Promise<T>): Promise<{ result: T; warns: string[] }> {
const warns: string[] = [];
const origWarn = console.warn;
const origLog = console.log;
console.warn = (...args: unknown[]) => {
warns.push(String(args[0]));
};
console.log = () => {};
try {
const result = await fn();
return { result, warns };
} finally {
console.warn = origWarn;
console.log = origLog;
}
}
it("host throw in draft.model.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);
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);
});
assert.ok(
warns.some((w) => w.includes("catalog publish failed") && w.includes("host boom")),
`expected a publish-guard warn, got: ${JSON.stringify(warns)}`
);
} finally {
globalThis.fetch = origFetch;
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

@@ -0,0 +1,214 @@
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).
describe("plugin-v2 fail-open refresh (PROD 403 combos)", () => {
let diskSeq = 0;
async function isolateDisk(): Promise<() => void> {
const { mkdtempSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
diskSeq += 1;
const dir = mkdtempSync(join(tmpdir(), `omniroute-fo-${diskSeq}-`));
const prev = process.env.OPENCODE_DATA_DIR;
process.env.OPENCODE_DATA_DIR = dir;
return () => {
if (prev === undefined) delete process.env.OPENCODE_DATA_DIR;
else process.env.OPENCODE_DATA_DIR = prev;
};
}
function setupCtx(opts: {
combosStatus: number;
modelsStatus?: number;
reloads: { count: number };
}): {
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);
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {
opts.reloads.count += 1;
},
},
integration: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
};
return { catalogCallbacks, ctx };
}
function stubFetch(opts: { combosStatus: number; modelsStatus?: number }): typeof fetch {
const modelsStatus = opts.modelsStatus ?? 200;
return (async (url: unknown) => {
const href = String(url);
if (href.includes("/api/combos/auto")) {
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
}
if (href.includes("/api/combos")) {
return {
ok: opts.combosStatus === 200,
status: opts.combosStatus,
statusText: opts.combosStatus === 403 ? "Forbidden" : "Error",
json: async () => ({ combos: [] }),
};
}
return {
ok: modelsStatus === 200,
status: modelsStatus,
statusText: "OK",
json: async () => ({ data: [{ id: "m1" }] }),
};
}) 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;
const origLog = console.log;
console.warn = (...args: unknown[]) => {
warns.push(String(args[0]));
};
console.log = () => {};
try {
const result = await fn();
return { result, warns };
} finally {
console.warn = origWarn;
console.log = origLog;
}
}
it("combos 403: catalog callback resolves (models-only + warn), never rejects", async () => {
const restoreDisk = await isolateDisk();
const reloads = { count: 0 };
const { catalogCallbacks, ctx } = setupCtx({ combosStatus: 403, reloads });
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"));
assert.ok(
key,
`models-only fallback must publish m1, got: ${JSON.stringify([...published.keys()])}`
);
});
assert.ok(
warns.some((w) => w.includes("combos") && w.includes("403")),
`expected a combos 403 warn, got: ${JSON.stringify(warns)}`
);
} finally {
globalThis.fetch = origFetch;
restoreDisk();
}
});
it("combos 500: catalog callback resolves (models-only + warn), never rejects", async () => {
const restoreDisk = await isolateDisk();
const reloads = { count: 0 };
const { catalogCallbacks, ctx } = setupCtx({ combosStatus: 500, reloads });
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()])}`
);
});
assert.ok(
warns.some((w) => w.includes("combos")),
`expected a combos warn, got: ${JSON.stringify(warns)}`
);
} finally {
globalThis.fetch = origFetch;
restoreDisk();
}
});
it("combos timeout (abort): catalog callback resolves, never rejects", async () => {
const restoreDisk = await isolateDisk();
const reloads = { count: 0 };
const { catalogCallbacks, ctx } = setupCtx({ combosStatus: 200, reloads });
const origFetch = globalThis.fetch;
globalThis.fetch = (async (url: unknown) => {
const href = String(url);
if (href.includes("/api/combos/auto")) {
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
}
if (href.includes("/api/combos")) {
const err = new Error("This operation was aborted");
err.name = "AbortError";
throw err;
}
return {
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ data: [{ id: "m1" }] }),
};
}) as typeof fetch;
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()])}`
);
});
assert.ok(
warns.some((w) => w.includes("combos")),
`expected a combos warn, got: ${JSON.stringify(warns)}`
);
} finally {
globalThis.fetch = origFetch;
restoreDisk();
}
});
});

View File

@@ -0,0 +1,165 @@
import { describe, it } from "node:test";
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";
const GW = "https://gw.example.com";
const PREFIXES = ["cc", "claude", "anthropic", "kiro", "kr"];
describe("deprecated anthropicPrefixes", () => {
it("routes a default prefix id to anthropic with a deprecation warning", async () => {
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
warns.push(String(args[0]));
};
try {
const out = resolveApiBlockV2("cc/claude-x", GW, {
allowAnthropic: true,
anthropicPrefixes: [...PREFIXES],
});
assert.equal(out.id, "anthropic");
} finally {
console.warn = origWarn;
}
assert.ok(warns.some((w) => w.includes("deprecated") && w.includes("anthropicModels")));
});
it("warns only once per process for the same prefix list", async () => {
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
warns.push(String(args[0]));
};
try {
resolveApiBlockV2("cc/a", GW, { allowAnthropic: true, anthropicPrefixes: ["cc"] });
resolveApiBlockV2("cc/b", GW, { allowAnthropic: true, anthropicPrefixes: ["cc"] });
} finally {
console.warn = origWarn;
}
assert.equal(warns.filter((w) => w.includes("deprecated")).length, 1);
});
it("documents the known claude/openai-compatible-model edge: prefix wins", async () => {
// v1 parity keeps prefix matching, so a model literally named
// `claude/openai-compatible-model` routes to anthropic when its prefix
// is listed. Documented (not silently fixed): operators hitting this
// rare name should move the id to the allowlist instead.
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
warns.push(String(args[0]));
};
try {
const out = resolveApiBlockV2("claude/openai-compatible-model", GW, {
allowAnthropic: true,
anthropicPrefixes: [...PREFIXES],
anthropicModels: ["anthropic/claude-x"],
});
assert.equal(out.id, "anthropic");
const mapped = mapRawModelToModelV2(
{ id: "claude/openai-compatible-model" },
{
providerId: "omniroute",
baseURL: GW,
apiFormat: {
allowAnthropic: true,
anthropicPrefixes: [...PREFIXES],
anthropicModels: ["anthropic/claude-x"],
},
}
);
assert.equal(mapped.api.id, "anthropic");
} finally {
console.warn = origWarn;
}
});
it("prefix OR allowlist routes to anthropic; neither means openai-compatible", async () => {
const origWarn = console.warn;
console.warn = () => {};
try {
assert.equal(
resolveApiBlockV2("kiro/m", GW, { allowAnthropic: true, anthropicPrefixes: ["kiro"] }).id,
"anthropic"
);
assert.equal(
resolveApiBlockV2("anthropic/claude-x", GW, {
allowAnthropic: true,
anthropicModels: ["anthropic/claude-x"],
}).id,
"anthropic"
);
assert.equal(resolveApiBlockV2("gpt-x", GW).id, "openai-compatible");
} finally {
console.warn = origWarn;
}
});
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 warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
warns.push(String(args[0]));
};
try {
const parsed = parsePluginOptions({
baseURL: GW,
apiFormat: { allowAnthropic: true, anthropicPrefixes: [...PREFIXES, "v1copy"] },
});
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;
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;
const res = await publishCatalog(
draft,
{
providerId: "omniroute",
baseURL: GW,
apiKey: "k",
timeoutMs: 1000,
modelCacheTtlMs: 300000,
usableOnly: false,
apiFormat: parsed.apiFormat,
},
{
fetcher: async () => [{ id: "cc/claude-x" }],
combosFetcher: async () => [],
enrichmentFetcher: async () => new Map(),
}
);
assert.deepEqual(res, { models: 1, combos: 0, autoCombos: 0 });
const m = models.get("omniroute/cc/claude-x");
assert.ok(m);
if (m?.api.type !== "aisdk") throw new Error("model api must be aisdk");
assert.equal(m?.api.id, "anthropic");
} finally {
console.warn = origWarn;
}
assert.ok(warns.some((w) => w.includes("deprecated") && w.includes("anthropicModels")));
});
});

View File

@@ -0,0 +1,196 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
defaultOmniRouteAutoCombosFetcher,
mapAutoComboToModelV2,
} from "../src/shared/auto-combos.js";
// A fetch stub that hangs until the caller aborts: proves the AbortController
// wiring fires. Rejects with an AbortError like undici does on abort.
function hangingFetch(): typeof fetch {
return ((url: unknown, init?: { signal?: AbortSignal }) => {
return new Promise((_resolve, reject) => {
const signal = init?.signal;
if (signal?.aborted) {
reject(Object.assign(new Error("This operation was aborted"), { name: "AbortError" }));
return;
}
signal?.addEventListener("abort", () => {
reject(Object.assign(new Error("This operation was aborted"), { name: "AbortError" }));
});
}) as Promise<Response>;
}) as unknown as typeof fetch;
}
function stubFetch(res: { status: number; statusText?: string; body?: unknown }): typeof fetch {
return (async () => ({
ok: res.status >= 200 && res.status < 300,
status: res.status,
statusText: res.statusText ?? (res.status === 404 ? "Not Found" : "Error"),
json: async () => res.body,
})) as unknown as typeof fetch;
}
function silenceWarn(): { warns: string[]; restore: () => void } {
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
warns.push(String(args[0]));
};
return {
warns,
restore() {
console.warn = origWarn;
},
};
}
describe("defaultOmniRouteAutoCombosFetcher", () => {
it("404 returns [] with a warn (old gateway without the endpoint stays fail-open)", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch({ status: 404 });
const guard = silenceWarn();
try {
const res = await defaultOmniRouteAutoCombosFetcher("https://gw.example.com", "k", 5000);
assert.deepEqual(res, []);
} finally {
globalThis.fetch = origFetch;
guard.restore();
}
assert.ok(
guard.warns.some((w) => w.includes("/api/combos/auto") && w.includes("404")),
`expected a 404 warn, got: ${JSON.stringify(guard.warns)}`
);
});
it("500 throws (with warn + token-hint report) so the caller keeps last-known", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch({ status: 500, statusText: "Internal Server Error" });
const guard = silenceWarn();
const reported: string[] = [];
try {
await assert.rejects(
defaultOmniRouteAutoCombosFetcher("https://gw.example.com", "k", 5000, undefined, (e, r) =>
reported.push(`${e} ${r}`)
),
/HTTP 500/
);
} finally {
globalThis.fetch = origFetch;
guard.restore();
}
assert.ok(
guard.warns.some((w) => w.includes("/api/combos/auto") && w.includes("500")),
`expected a 500 warn, got: ${JSON.stringify(guard.warns)}`
);
assert.ok(
reported.some((r) => r.includes("/api/combos/auto")),
`expected a per-endpoint report, got: ${JSON.stringify(reported)}`
);
});
it("a network throw rejects instead of resolving empty", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = hangingFetch();
const guard = silenceWarn();
try {
await assert.rejects(
defaultOmniRouteAutoCombosFetcher("https://gw.example.com", "k", 20),
/abort|aborted/i
);
} finally {
globalThis.fetch = origFetch;
guard.restore();
}
assert.ok(
guard.warns.some((w) => w.includes("/api/combos/auto")),
`expected an abort warn, got: ${JSON.stringify(guard.warns)}`
);
});
it("accepts the {combos:[...]} envelope and filters entries without a string id", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch({
status: 200,
body: { combos: [{ id: "auto/coding", name: "Auto Coding" }, { name: "no-id" }] },
});
const guard = silenceWarn();
try {
const res = await defaultOmniRouteAutoCombosFetcher("https://gw.example.com", "k", 5000);
assert.deepEqual(res, [{ id: "auto/coding", name: "Auto Coding" }]);
} finally {
globalThis.fetch = origFetch;
guard.restore();
}
});
});
describe("mapAutoComboToModelV2", () => {
it("falls back to 128k context / 8k output when the server omits limits (never 0)", () => {
const m = mapAutoComboToModelV2(
{ id: "auto/coding", name: "Auto Coding", variant: "coding" },
"omniroute",
"https://gw.example.com"
);
assert.equal(m.id, "auto/coding");
assert.equal(m.limit.context, 128_000);
assert.equal(m.limit.output, 8_192);
assert.equal(m.capabilities.toolcall, true);
assert.equal(m.capabilities.reasoning, true);
});
it("uses server limits when positive, falls back when zero or negative", () => {
const served = mapAutoComboToModelV2(
{ id: "auto", name: "Auto", context_length: 200000, max_output_tokens: 16000 },
"omniroute",
"https://gw.example.com"
);
assert.equal(served.limit.context, 200000);
assert.equal(served.limit.output, 16000);
const zeroed = mapAutoComboToModelV2(
{ id: "auto", name: "Auto", context_length: 0, max_output_tokens: -1 },
"omniroute",
"https://gw.example.com"
);
assert.equal(zeroed.limit.context, 128_000);
assert.equal(zeroed.limit.output, 8_192);
});
it("defaults the model id to the auto variant key (auto, auto/coding)", () => {
const def = mapAutoComboToModelV2({ id: "whatever" }, "omniroute", "https://gw.example.com");
assert.equal(def.id, "auto");
const coding = mapAutoComboToModelV2(
{ id: "auto/coding", variant: "coding" },
"omniroute",
"https://gw.example.com"
);
assert.equal(coding.id, "auto/coding");
});
it("stamps the openai-compatible api block by default", () => {
const m = mapAutoComboToModelV2(
{ id: "auto", candidateCount: 3 },
"omniroute",
"https://gw.example.com"
);
assert.deepEqual(m.api, {
id: "openai-compatible",
url: "https://gw.example.com/v1",
npm: "@ai-sdk/openai-compatible",
});
});
it("routes an allowlisted auto id to anthropic (same rule as models)", () => {
const m = mapAutoComboToModelV2(
{ id: "auto/coding", variant: "coding" },
"omniroute",
"https://gw.example.com",
{ allowAnthropic: true, anthropicModels: ["auto/coding"] }
);
assert.deepEqual(m.api, {
id: "anthropic",
url: "https://gw.example.com",
npm: "@ai-sdk/anthropic",
});
});
});

View File

@@ -0,0 +1,77 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { mapComboToModelV2 } from "../src/shared/combos-map.js";
const member = (id: string, caps = {}) => ({
id,
context_length: 100000,
max_output_tokens: 2000,
capabilities: caps,
});
describe("mapComboToModelV2", () => {
it("rolls up LCD: min context/output, AND of toolcall", () => {
const m = mapComboToModelV2(
{
id: "combo-a",
name: "A",
models: [
{ kind: "model", model: "a" },
{ kind: "model", model: "b" },
],
},
[
{
...member("a"),
context_length: 100000,
max_output_tokens: 2000,
capabilities: { tool_calling: true },
},
{
...member("b"),
context_length: 50000,
max_output_tokens: 1000,
capabilities: { tool_calling: false },
},
],
"omniroute",
"https://gw.example.com"
);
assert.equal(m.limit.context, 50000);
assert.equal(m.limit.output, 1000);
assert.equal(m.capabilities.toolcall, false);
assert.equal(m.api.id, "openai-compatible");
});
it("empty members short-circuit to all-false capabilities", () => {
const m = mapComboToModelV2({ id: "combo-empty" }, [], "omniroute", "https://gw.example.com");
assert.equal(m.capabilities.toolcall, false);
assert.equal(m.capabilities.reasoning, false);
});
it("stamps the api block via resolveApiBlockV2 (openai-compatible by default)", () => {
const m = mapComboToModelV2(
{ id: "combo-a", models: [{ kind: "model", model: "a" }] },
[{ ...member("a") }],
"omniroute",
"https://gw.example.com"
);
assert.deepEqual(m.api, {
id: "openai-compatible",
url: "https://gw.example.com/v1",
npm: "@ai-sdk/openai-compatible",
});
});
it("routes an allowlisted combo id to anthropic even with mixed members", () => {
const m = mapComboToModelV2(
{ id: "combo-a", models: [{ kind: "model", model: "gpt-x" }] },
[{ ...member("gpt-x") }],
"omniroute",
"https://gw.example.com",
{ allowAnthropic: true, anthropicModels: ["combo-a"] }
);
assert.deepEqual(m.api, {
id: "anthropic",
url: "https://gw.example.com",
npm: "@ai-sdk/anthropic",
});
});
});

View File

@@ -0,0 +1,47 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
applyEnrichment,
buildCanonicalToAliasMap,
canonicalDedupSet,
lookupEnrichment,
type OmniRouteEnrichmentMap,
} from "../src/shared/enrich.js";
import { mapRawModelToModelV2 } from "../src/shared/models-map.js";
const enrichment: OmniRouteEnrichmentMap = new Map([
["cc/model-x", { name: "Model X", providerAlias: "cc", providerCanonical: "claude" }],
["model-x", { name: "Model X", providerAlias: "cc", providerCanonical: "claude" }],
]);
describe("lookupEnrichment", () => {
it("resolves a canonical id via the alias fallback chain", () => {
const canonicalToAlias = buildCanonicalToAliasMap(enrichment);
assert.equal(canonicalToAlias.get("claude"), "cc");
const found = lookupEnrichment("claude/model-x", enrichment, canonicalToAlias);
assert.equal(found?.name, "Model X");
});
});
describe("applyEnrichment", () => {
it("overlays the enrichment display name onto the model", () => {
const model = mapRawModelToModelV2(
{ id: "cc/model-x" },
{ providerId: "omniroute", baseURL: "https://gw.example.com" }
);
applyEnrichment(model, { name: "Model X" });
assert.equal(model.name, "Model X");
});
});
describe("canonicalDedupSet", () => {
it("drops the canonical twin when the alias row exists", () => {
const canonicalToAlias = buildCanonicalToAliasMap(enrichment);
const drop = canonicalDedupSet(
[{ id: "cc/model-x" }, { id: "claude/model-x" }],
canonicalToAlias
);
assert.ok(drop.has("claude/model-x"));
assert.ok(!drop.has("cc/model-x"));
});
});

View File

@@ -0,0 +1,125 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { defaultOmniRouteEnrichmentFetcher } from "../src/shared/enrich.js";
function stubFetch(handler: (url: string) => unknown): typeof fetch {
return (async (url: unknown) => {
const body = handler(String(url));
return {
ok: true,
status: 200,
statusText: "OK",
json: async () => body,
};
}) as unknown as typeof fetch;
}
describe("defaultOmniRouteEnrichmentFetcher", () => {
it("merges display names and pricing from the two catalog sources", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch((url) =>
url.endsWith("/api/pricing/models")
? {
cc: {
id: "claude",
alias: "cc",
name: "Claude",
models: [{ id: "m1", name: "Model One" }],
},
}
: url.endsWith("/api/pricing")
? { cc: { m1: { input: 3, output: 15, cached: 0.3, cache_creation: 3.75 } } }
: { perModel: [] }
);
try {
const map = await defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000);
const namespaced = map.get("cc/m1");
assert.ok(namespaced);
assert.equal(namespaced?.name, "Model One");
assert.equal(namespaced?.providerAlias, "cc");
assert.equal(namespaced?.providerCanonical, "claude");
assert.deepEqual(namespaced?.pricing, {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
});
assert.deepEqual(map.get("m1")?.pricing, namespaced?.pricing);
} finally {
globalThis.fetch = origFetch;
}
});
it("soft-fails per source: a dead pricing endpoint still returns catalog names", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = (async (url: unknown) => {
const href = String(url);
if (href.endsWith("/api/pricing")) throw new Error("boom");
return {
ok: true,
status: 200,
statusText: "OK",
json: async () => ({
cc: { id: "claude", models: [{ id: "m1", name: "Model One" }] },
}),
};
}) as unknown as typeof fetch;
try {
const map = await defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000);
assert.equal(map.get("cc/m1")?.name, "Model One");
assert.equal(map.get("cc/m1")?.pricing, undefined);
} finally {
globalThis.fetch = origFetch;
}
});
it("soft-fails per source: a dead catalog endpoint still returns pricing", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = (async (url: unknown) => {
const href = String(url);
if (href.endsWith("/api/pricing/models")) {
return { ok: false, status: 500, statusText: "err", json: async () => ({}) };
}
return {
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ cc: { m1: { input: 1, output: 2 } } }),
};
}) as unknown as typeof fetch;
try {
const map = await defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000);
assert.deepEqual(map.get("cc/m1")?.pricing, { input: 1, output: 2 });
assert.equal(map.get("cc/m1")?.name, undefined);
} finally {
globalThis.fetch = origFetch;
}
});
it("attaches free-tier budgets from the third source", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch((url) =>
url.endsWith("/api/pricing/models")
? { cc: { id: "claude", models: [{ id: "m1", name: "Model One" }] } }
: url.endsWith("/api/pricing")
? {}
: {
perModel: [
{ provider: "cc", modelId: "m1", freeType: "monthly", monthlyTokens: 100 },
],
}
);
try {
const map = await defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000);
assert.equal(map.get("cc/m1")?.freeType, "monthly");
assert.equal(map.get("cc/m1")?.monthlyTokens, 100);
} finally {
globalThis.fetch = origFetch;
}
});
it("returns an empty map without credentials instead of throwing", async () => {
const map = await defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "", 1000);
assert.equal(map.size, 0);
});
});

View File

@@ -0,0 +1,56 @@
import { describe, it, afterEach } from "node:test";
import assert from "node:assert/strict";
import { defaultOmniRouteEnrichmentFetcher } from "../src/shared/enrich.js";
const realFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = realFetch;
});
describe("enrichment source failures are reported", () => {
it("names each endpoint a gateway refuses, then throws so the caller keeps last-known", async () => {
globalThis.fetch = (async () =>
new Response("forbidden", { status: 403 })) as unknown as typeof globalThis.fetch;
const seen: Array<[string, string]> = [];
await assert.rejects(
defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000, (endpoint, reason) =>
seen.push([endpoint, reason])
),
/enrichment (catalog source|sources) failed/
);
assert.deepEqual(seen.map(([endpoint]) => endpoint).sort(), [
"/api/free-tier/summary",
"/api/pricing",
"/api/pricing/models",
]);
for (const [, reason] of seen) assert.match(reason, /403/);
});
it("reports a transport failure with its cause, then throws", async () => {
globalThis.fetch = (async () => {
throw new Error("connect ECONNREFUSED");
}) as unknown as typeof globalThis.fetch;
const seen: string[] = [];
await assert.rejects(
defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000, (_e, reason) =>
seen.push(reason)
),
/enrichment (catalog source|sources) failed/
);
assert.equal(seen.length, 3);
for (const reason of seen) assert.match(reason, /ECONNREFUSED/);
});
it("stays silent when every source answers", async () => {
globalThis.fetch = (async () =>
new Response("{}", {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof globalThis.fetch;
const seen: string[] = [];
await defaultOmniRouteEnrichmentFetcher("https://gw.example.com", "k", 1000, (e) =>
seen.push(e)
);
assert.deepEqual(seen, []);
});
});

View File

@@ -0,0 +1,49 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { defaultOmniRouteModelsFetcher } from "../src/shared/models-map.js";
import { defaultOmniRouteCombosFetcher } from "../src/shared/combos-map.js";
// A fetch stub that hangs until the caller aborts: proves the AbortController
// wiring fires. Rejects with an AbortError like undici does on abort.
function hangingFetch(): typeof fetch {
return ((url: unknown, init?: { signal?: AbortSignal }) => {
return new Promise((_resolve, reject) => {
const signal = init?.signal;
if (signal?.aborted) {
reject(Object.assign(new Error("This operation was aborted"), { name: "AbortError" }));
return;
}
signal?.addEventListener("abort", () => {
reject(Object.assign(new Error("This operation was aborted"), { name: "AbortError" }));
});
}) as Promise<Response>;
}) as unknown as typeof fetch;
}
describe("shared default fetchers abort on timeout", () => {
it("models fetcher aborts a hanging request after timeoutMs", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = hangingFetch();
try {
await assert.rejects(
defaultOmniRouteModelsFetcher("https://gw.example.com", "k", 20),
/aborted/i
);
} finally {
globalThis.fetch = origFetch;
}
});
it("combos fetcher aborts a hanging request after timeoutMs", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = hangingFetch();
try {
await assert.rejects(
defaultOmniRouteCombosFetcher("https://gw.example.com", "k", 20),
/aborted/i
);
} finally {
globalThis.fetch = origFetch;
}
});
});

View File

@@ -0,0 +1,62 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { catalogContentFingerprint, optionalTierFingerprint } from "../src/shared/fingerprint.js";
describe("catalogContentFingerprint", () => {
it("returns the same hash for two identical catalogs", () => {
const models = [
{ id: "b", release_date: "2026-01-02" },
{ id: "a", release_date: "2026-01-01" },
];
const combos = [{ id: "combo-b" }, { id: "combo-a" }];
assert.equal(
catalogContentFingerprint(models, combos),
catalogContentFingerprint([...models].reverse(), [...combos].reverse())
);
});
it("returns a different hash when one model id changes", () => {
const combos = [{ id: "combo-a" }];
const before = catalogContentFingerprint([{ id: "a" }], combos);
const after = catalogContentFingerprint([{ id: "a2" }], combos);
assert.notEqual(before, after);
});
it("returns a different hash when the auto-combos set changes", () => {
const models = [{ id: "a" }];
const combos = [{ id: "combo-a" }];
const before = catalogContentFingerprint(models, combos, [{ id: "auto" }]);
const after = catalogContentFingerprint(models, combos, [
{ id: "auto" },
{ id: "auto/coding" },
]);
assert.notEqual(before, after);
});
});
describe("optionalTierFingerprint", () => {
it("moves on a pricing-only change, so stale prices reach the picker", () => {
const priced = (input: number) =>
new Map([["cc/m1", { name: "M1", pricing: { input, output: 1 } }]]);
assert.notEqual(
optionalTierFingerprint([], [], priced(3)),
optionalTierFingerprint([], [], priced(99))
);
});
it("moves when a combo loses a member without changing id", () => {
const one = [{ id: "combo-a", name: "A", models: [{ model: "m1" }] }];
const two = [{ id: "combo-a", name: "A", models: [{ model: "m1" }, { model: "m2" }] }];
assert.notEqual(
optionalTierFingerprint([], [], undefined, one),
optionalTierFingerprint([], [], undefined, two)
);
});
it("moves when a provider goes quiet or gets renamed", () => {
const active = [{ id: "c1", testStatus: "active", isActive: true }];
const quiet = [{ id: "c1", testStatus: "active", isActive: false }];
assert.notEqual(
optionalTierFingerprint([], active, undefined),
optionalTierFingerprint([], quiet, undefined)
);
});
});

View File

@@ -0,0 +1,144 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { isGeminiModelId, sanitizeToolInputSchemas } from "../src/shared/gemini.js";
describe("Gemini tool-schema sanitising", () => {
it("recognises the routing forms a Gemini model arrives under", () => {
for (const id of [
"gemini-2.5-flash",
"models/gemini-1.5-pro",
"google-vertex/gemini-2.0",
"GEMINI-PRO",
]) {
assert.equal(isGeminiModelId(id), true, id);
}
for (const id of ["gpt-5", "claude-opus-5", "", "gemma-2-9b"]) {
assert.equal(isGeminiModelId(id), false, id);
}
});
it("strips the keywords Gemini rejects, however deep they sit", () => {
const tools = [
{
type: "function" as const,
name: "edit",
inputSchema: {
$schema: "https://json-schema.org/draft-07/schema",
type: "object",
additionalProperties: false,
properties: {
path: { type: "string" },
nested: {
type: "object",
additionalProperties: true,
properties: { deep: { type: "string" } },
},
list: { type: "array", items: [{ type: "object", additionalProperties: false }] },
},
},
},
];
const out = sanitizeToolInputSchemas(tools);
assert.notEqual(out, undefined, "a payload that needed cleaning must come back changed");
const schema = out![0]!.inputSchema as Record<string, any>;
assert.equal("$schema" in schema, false);
assert.equal("additionalProperties" in schema, false);
assert.equal("additionalProperties" in schema["properties"]["nested"], false);
assert.equal("additionalProperties" in schema["properties"]["list"]["items"][0], false);
// What the schema means must survive the cleaning.
assert.equal(schema["properties"]["path"]["type"], "string");
assert.equal(schema["properties"]["nested"]["properties"]["deep"]["type"], "string");
});
it("leaves the caller's tools untouched", () => {
const tools = [
{
type: "function" as const,
name: "t",
inputSchema: { type: "object", additionalProperties: false },
},
];
sanitizeToolInputSchemas(tools);
assert.equal((tools[0]!.inputSchema as Record<string, unknown>)["additionalProperties"], false);
});
it("reports nothing to do rather than cloning a clean payload", () => {
assert.equal(
sanitizeToolInputSchemas([
{ type: "function" as const, name: "t", inputSchema: { type: "object", properties: {} } },
]),
undefined
);
assert.equal(sanitizeToolInputSchemas(undefined), undefined);
assert.equal(sanitizeToolInputSchemas([]), undefined);
});
it("walks past a provider tool it cannot read without dropping it", () => {
const tools = [
{ type: "provider" as const, id: "p.search", name: "search", args: {} },
{
type: "function" as const,
name: "t",
inputSchema: { type: "object", additionalProperties: false },
},
];
const out = sanitizeToolInputSchemas(tools as never);
assert.equal(out?.length, 2);
assert.deepEqual(out![0], tools[0]);
});
});
describe("the sanitiser repairs schemas without mangling them", () => {
it("keeps a tool parameter that happens to be named like a keyword", () => {
const out = sanitizeToolInputSchemas([
{
type: "function" as const,
name: "checkout",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["ref"],
properties: {
ref: { type: "string", description: "a git ref" },
additionalProperties: { type: "boolean" },
},
},
},
]);
const schema = out![0]!.inputSchema as Record<string, any>;
// The keyword goes; the parameters keep their names, or `required` would
// point at a property the model can no longer send.
assert.equal("additionalProperties" in schema, false);
assert.deepEqual(Object.keys(schema["properties"]).sort(), ["additionalProperties", "ref"]);
assert.deepEqual(schema["required"], ["ref"]);
});
it("forwards a $ref schema untouched rather than widening it to anything", () => {
assert.equal(
sanitizeToolInputSchemas([
{ type: "function" as const, name: "t", inputSchema: { $ref: "#/$defs/x" } },
]),
undefined
);
});
it("recognises the Gemini families, and only those", () => {
for (const id of [
"gemini",
"gemini-2.5-flash",
"models/gemini-1.5-pro",
"google-vertex/gemini-2.0",
"GEMINI-3-PRO",
]) {
assert.equal(isGeminiModelId(id), true, id);
}
for (const id of [
"gemini-compatible-proxy",
"my-gemini-wrapper",
"openai/gpt-5-gemini-eval",
"gemma-2-9b",
]) {
assert.equal(isGeminiModelId(id), false, id);
}
});
});

View File

@@ -0,0 +1,130 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createLogger, getLogLevel } from "../src/shared/logger.js";
function capture(): {
warns: string[];
errors: string[];
restore: () => void;
} {
const warns: string[] = [];
const errors: string[] = [];
const origWarn = console.warn;
const origError = console.error;
console.warn = (...args: unknown[]) => {
warns.push(args.map(String).join(" "));
};
console.error = (...args: unknown[]) => {
errors.push(args.map(String).join(" "));
};
return {
warns,
errors,
restore() {
console.warn = origWarn;
console.error = origError;
},
};
}
describe("shared leveled logger (v1 parity)", () => {
it("default level is warn", () => {
assert.equal(getLogLevel(), "warn");
});
it("warn logger emits error+warn, suppresses info+debug", () => {
const log = createLogger("warn");
const cap = capture();
try {
log.error("e");
log.warn("w");
log.info("i");
log.debug("d");
} finally {
cap.restore();
}
assert.equal(cap.errors.length, 1);
assert.equal(cap.warns.length, 1);
});
it("error logger emits only error", () => {
const log = createLogger("error");
const cap = capture();
try {
log.error("e");
log.warn("w");
log.info("i");
log.debug("d");
} finally {
cap.restore();
}
assert.equal(cap.errors.length, 1);
assert.equal(cap.warns.length, 0);
});
it("info logger emits error+warn+info, suppresses debug", () => {
const log = createLogger("info");
const cap = capture();
try {
log.error("e");
log.warn("w");
log.info("i");
log.debug("d");
} finally {
cap.restore();
}
assert.equal(cap.errors.length, 1);
assert.equal(cap.warns.length, 2);
});
it("debug logger emits everything", () => {
const log = createLogger("debug");
const cap = capture();
try {
log.error("e");
log.warn("w");
log.info("i");
log.debug("d");
} finally {
cap.restore();
}
assert.equal(cap.errors.length, 1);
assert.equal(cap.warns.length, 3);
});
it("lines carry the namespace tag and the uppercased level", () => {
const log = createLogger("debug");
const cap = capture();
try {
log.warn("hello");
log.error("boom");
} finally {
cap.restore();
}
assert.match(cap.warns[0], /\[omniroute-plugin\] \[WARN\] hello/);
assert.match(cap.errors[0], /\[omniroute-plugin\] \[ERROR\] boom/);
});
it("child loggers append their tag after the namespace", () => {
const log = createLogger("debug").child("[v2]");
const cap = capture();
try {
log.info("hello");
} finally {
cap.restore();
}
assert.match(cap.warns[0], /\[omniroute-plugin\]\[v2\] \[INFO\] hello/);
});
it("always() emits regardless of level", () => {
const log = createLogger("error");
const cap = capture();
try {
log.always("breadcrumb");
} finally {
cap.restore();
}
assert.equal(cap.warns.length, 1);
assert.match(cap.warns[0], /breadcrumb/);
});
});

View File

@@ -0,0 +1,94 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { mapRawModelToModelV2, resolveApiBlockV2 } from "../src/shared/models-map.js";
describe("resolveApiBlockV2", () => {
it("routes unknown ids to openai-compatible with /v1 suffix", () => {
assert.deepEqual(resolveApiBlockV2("gpt-x", "https://gw.example.com"), {
id: "openai-compatible",
url: "https://gw.example.com/v1",
npm: "@ai-sdk/openai-compatible",
});
});
it("does not double the /v1 suffix", () => {
assert.equal(
resolveApiBlockV2("gpt-x", "https://gw.example.com/v1").url,
"https://gw.example.com/v1"
);
});
it("routes allowlisted full id to anthropic even with claude-like prefix elsewhere", () => {
const out = resolveApiBlockV2("anthropic/claude-x", "https://gw.example.com/", {
allowAnthropic: true,
anthropicModels: ["anthropic/claude-x"],
});
assert.deepEqual(out, {
id: "anthropic",
url: "https://gw.example.com",
npm: "@ai-sdk/anthropic",
});
});
it("keeps a non-allowlisted id on openai-compatible when prefixes are emptied", () => {
const out = resolveApiBlockV2("claude/openai-compatible-model", "https://gw.example.com", {
allowAnthropic: true,
anthropicModels: ["anthropic/claude-x"],
anthropicPrefixes: [],
});
assert.equal(out.id, "openai-compatible");
});
it("routes a deprecated-prefix id to anthropic with a deprecation warning", () => {
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
warns.push(String(args[0]));
};
try {
const out = resolveApiBlockV2("cc/claude-x", "https://gw.example.com", {
allowAnthropic: true,
anthropicPrefixes: ["cc"],
});
assert.equal(out.id, "anthropic");
} finally {
console.warn = origWarn;
}
assert.ok(warns.some((w) => w.includes("deprecated")));
});
});
describe("mapRawModelToModelV2", () => {
it("prefixes bare ids, keeps slashed ids, maps capabilities", () => {
const m = mapRawModelToModelV2(
{
id: "gpt-x",
context_length: 128000,
max_output_tokens: 4096,
input_modalities: ["text", "image"],
capabilities: { tool_calling: true, reasoning: true },
},
{ providerId: "omniroute", baseURL: "https://gw.example.com" }
);
assert.equal(m.id, "omniroute/gpt-x");
assert.equal(m.providerID, "omniroute");
assert.equal(m.capabilities.toolcall, true);
assert.equal(m.capabilities.reasoning, true);
assert.equal(m.capabilities.input.image, true);
assert.equal(m.limit.context, 128000);
assert.equal(m.limit.output, 4096);
assert.equal(m.api.id, "openai-compatible");
assert.equal(m.status, "active");
});
it("maps effort_tiers to variants, omits key when absent", () => {
const withTiers = mapRawModelToModelV2(
{ id: "r1", capabilities: { effort_tiers: ["low", "high"] } },
{ providerId: "omniroute", baseURL: "https://gw.example.com" }
);
assert.deepEqual(withTiers.variants, {
low: { reasoningEffort: "low" },
high: { reasoningEffort: "high" },
});
const without = mapRawModelToModelV2(
{ id: "r2" },
{ providerId: "omniroute", baseURL: "https://gw.example.com" }
);
assert.ok(!("variants" in without));
});
});

View File

@@ -0,0 +1,75 @@
/**
* Tests for `formatFreeBudget` (@omniroute/opencode-plugin/src/naming.ts):
* formats a free-tier model's budget info into a short human-readable
* suffix, branching on `freeType`.
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { formatFreeBudget, type FreeModelFreeType } from "../src/shared/naming.js";
test("formatFreeBudget: recurring-daily formats tokens/day", () => {
assert.equal(
formatFreeBudget({ freeType: "recurring-daily", monthlyTokens: 25_000_000 }),
"25M tokens/day"
);
});
test("formatFreeBudget: recurring-monthly formats tokens/month", () => {
assert.equal(
formatFreeBudget({ freeType: "recurring-monthly", monthlyTokens: 1_000_000 }),
"1M tokens/month"
);
});
test("formatFreeBudget: recurring-credit formats credits", () => {
assert.equal(
formatFreeBudget({ freeType: "recurring-credit", creditTokens: 10_000_000 }),
"10M credits"
);
});
test("formatFreeBudget: one-time-initial formats credits with (one-time) suffix", () => {
assert.equal(
formatFreeBudget({ freeType: "one-time-initial", creditTokens: 1_000_000 }),
"1M credits (one-time)"
);
});
test("formatFreeBudget: keyless has no token/credit args", () => {
assert.equal(formatFreeBudget({ freeType: "keyless" }), "(keyless)");
});
test("formatFreeBudget: discontinued has no token/credit args", () => {
assert.equal(formatFreeBudget({ freeType: "discontinued" }), "(discontinued)");
});
test("formatFreeBudget: missing token/credit counts default to 0", () => {
assert.equal(formatFreeBudget({ freeType: "recurring-daily" }), "0 tokens/day");
});
test("formatFreeBudget: unrecognised freeType falls through to the default branch", () => {
// `freeType` is populated from catalog data at runtime, so a value the
// build doesn't know about is reachable even though TypeScript treats the
// `default:` arm as dead code for a well-typed caller.
assert.equal(formatFreeBudget({ freeType: "some-future-type" as FreeModelFreeType }), "");
});
test("formatFreeBudget: sub-1K token count is not abbreviated", () => {
assert.equal(
formatFreeBudget({ freeType: "recurring-daily", monthlyTokens: 500 }),
"500 tokens/day"
);
});
test("formatFreeBudget: the 999_999 rounding wart is fixed — promotes to 1M", () => {
// `toFixed(1)` rounds 999999/1e3 up to "1000.0" before the `>= 1e6` threshold
// check has a chance to apply. fmtTokens now promotes a rounded-up "1000" in
// any unit to the next unit up, so this correctly reads "1M" instead of the
// old "1000K" wart.
assert.equal(
formatFreeBudget({ freeType: "recurring-daily", monthlyTokens: 999_999 }),
"1M tokens/day"
);
});

View File

@@ -0,0 +1,248 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
defaultOmniRouteProvidersFetcher,
isUsableCombo,
isUsableRawModelId,
usableProviderAliasSet,
} from "../src/shared/usable.js";
import type { OmniRouteEnrichmentMap } from "../src/shared/enrich.js";
function entry(
alias: string,
canonical: string
): [string, { providerAlias: string; providerCanonical: string }] {
return [`${alias}/model-x`, { providerAlias: alias, providerCanonical: canonical }];
}
function enrichmentOf(...pairs: Array<[string, string]>): OmniRouteEnrichmentMap {
return new Map(pairs.map(([alias, canonical]) => entry(alias, canonical)));
}
function stubFetch(handler: (url: unknown) => unknown): typeof fetch {
return (async (url: unknown) => handler(url)) as unknown as typeof fetch;
}
describe("defaultOmniRouteProvidersFetcher envelopes", () => {
it("reads the { connections: [...] } envelope", async () => {
const origFetch = globalThis.fetch;
const seen: string[] = [];
globalThis.fetch = stubFetch((url) => {
seen.push(String(url));
return {
ok: true,
status: 200,
statusText: "OK",
json: async () => ({
connections: [{ id: "c1", provider: "claude", isActive: true, testStatus: "active" }],
}),
};
});
try {
const out = await defaultOmniRouteProvidersFetcher("https://gw.example.com/v1/", "k");
assert.equal(seen[0], "https://gw.example.com/api/providers");
assert.deepEqual(
out.map((c) => c.provider),
["claude"]
);
} finally {
globalThis.fetch = origFetch;
}
});
it("reads a bare array envelope", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch(() => ({
ok: true,
status: 200,
statusText: "OK",
json: async () => [{ id: "c1", provider: "gemini", isActive: true, testStatus: "active" }],
}));
try {
const out = await defaultOmniRouteProvidersFetcher("https://gw.example.com", "k");
assert.deepEqual(
out.map((c) => c.provider),
["gemini"]
);
} finally {
globalThis.fetch = origFetch;
}
});
it("reads the { data: [...] } envelope", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch(() => ({
ok: true,
status: 200,
statusText: "OK",
json: async () => ({
data: [{ id: "c1", provider: "kiro", isActive: true, testStatus: "active" }],
}),
}));
try {
const out = await defaultOmniRouteProvidersFetcher("https://gw.example.com", "k");
assert.deepEqual(
out.map((c) => c.provider),
["kiro"]
);
} finally {
globalThis.fetch = origFetch;
}
});
it("throws on non-2xx so the caller keeps last-known instead of unfiltering", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch(() => ({
ok: false,
status: 500,
statusText: "Error",
json: async () => ({}),
}));
try {
await assert.rejects(
defaultOmniRouteProvidersFetcher("https://gw.example.com", "k"),
/HTTP 500/
);
} finally {
globalThis.fetch = origFetch;
}
});
it("throws on network failure so the caller keeps last-known", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch(() => {
throw new Error("down");
});
try {
await assert.rejects(defaultOmniRouteProvidersFetcher("https://gw.example.com", "k"), /down/);
} finally {
globalThis.fetch = origFetch;
}
});
it("soft-fails to [] without baseURL or apiKey", async () => {
assert.deepEqual(await defaultOmniRouteProvidersFetcher("", "k"), []);
assert.deepEqual(await defaultOmniRouteProvidersFetcher("https://gw.example.com", ""), []);
});
});
describe("usableProviderAliasSet", () => {
it("maps usable canonicals to aliases via enrichment", () => {
const usable = usableProviderAliasSet(
[{ id: "c1", provider: "claude", isActive: true, testStatus: "active" }],
enrichmentOf(["cc", "claude"])
);
assert.ok(usable.aliases.has("cc"));
assert.ok(usable.aliases.has("claude"));
assert.ok(usable.canonicals.has("claude"));
assert.ok(usable.knownAliases.has("cc"));
});
it("ignores inactive or unhealthy connections", () => {
const usable = usableProviderAliasSet(
[
{ id: "c1", provider: "claude", isActive: false, testStatus: "active" },
{ id: "c2", provider: "gemini", isActive: true, testStatus: "error" },
],
enrichmentOf(["cc", "claude"], ["gm", "gemini"])
);
assert.equal(usable.aliases.size, 0);
assert.equal(usable.canonicals.size, 0);
assert.ok(usable.knownAliases.has("cc"));
assert.ok(usable.knownAliases.has("gm"));
});
it("treats a missing toggle as no opinion, not disabled", () => {
// Older gateways predate the `isActive` field: reading its absence as a
// veto would hide every model behind a filter the operator never asked
// to tighten. Only an explicit `false` disables.
const usable = usableProviderAliasSet(
[{ id: "c1", provider: "claude", testStatus: "active" }],
enrichmentOf(["cc", "claude"])
);
assert.ok(usable.aliases.has("cc"));
assert.ok(usable.canonicals.has("claude"));
});
});
describe("isUsableRawModelId subtract-filter", () => {
const usable = usableProviderAliasSet(
[{ id: "c1", provider: "claude", isActive: true, testStatus: "active" }],
enrichmentOf(["cc", "claude"], ["dead", "legacy"])
);
it("keeps ids with a usable alias prefix", () => {
assert.equal(isUsableRawModelId("cc/claude-opus-4-7", usable), true);
});
it("keeps ids with a usable canonical prefix", () => {
assert.equal(isUsableRawModelId("claude/sonnet-4", usable), true);
});
it("keeps ids with an unknown prefix", () => {
assert.equal(isUsableRawModelId("agentrouter/mystery", usable), true);
});
it("drops ids with a known-but-not-provisioned prefix", () => {
assert.equal(isUsableRawModelId("dead/legacy-model", usable), false);
});
it("keeps ids without a prefix", () => {
assert.equal(isUsableRawModelId("bare-model", usable), true);
});
});
describe("isUsableCombo", () => {
const usable = usableProviderAliasSet(
[{ id: "c1", provider: "claude", isActive: true, testStatus: "active" }],
enrichmentOf(["cc", "claude"], ["dead", "legacy"])
);
it("keeps a combo with one usable member", () => {
assert.equal(
isUsableCombo(
{
id: "c",
models: [
{ kind: "model", model: "dead/legacy" },
{ kind: "model", model: "cc/x" },
],
},
usable
),
true
);
});
it("drops a combo whose members are all known-but-not-provisioned", () => {
assert.equal(
isUsableCombo(
{
id: "c",
models: [
{ kind: "model", model: "dead/a" },
{ kind: "model", model: "dead/b" },
],
},
usable
),
false
);
});
it("keeps a combo with an unknown-prefix member", () => {
assert.equal(
isUsableCombo({ id: "c", models: [{ kind: "model", model: "agentrouter/mystery" }] }, usable),
true
);
});
it("keeps combos with no resolvable members", () => {
assert.equal(isUsableCombo({ id: "c", models: [] }, usable), true);
assert.equal(isUsableCombo({ id: "c" }, usable), true);
assert.equal(
isUsableCombo({ id: "c", models: [{ kind: "combo-ref", comboName: "nested" }] }, usable),
true
);
});
});

View File

@@ -0,0 +1,93 @@
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", () => {
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 () => {
const seen: string[] = [];
const ctx = {
options: { baseURL: "https://gw.example.com", providerId: "omniroute" },
catalog: {
transform: async () => {
seen.push("catalog.transform");
return { dispose: async () => {} };
},
reload: async () => {},
},
integration: {
transform: async () => {
seen.push("integration.transform");
return { dispose: async () => {} };
},
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: () => {} },
},
};
const res = await publishCatalog(
draft,
{
providerId: "omniroute",
baseURL: "https://gw.example.com",
apiKey: "k",
timeoutMs: 1000,
modelCacheTtlMs: 300000,
usableOnly: false,
},
{
models: async () => [{ id: "m1", context_length: 1000 }],
combos: async () => [],
}
);
assert.equal(res.models, 1);
assert.equal(written.provider, "omniroute");
assert.deepEqual(written.models, ["omniroute/m1"]);
});
});

View File

@@ -0,0 +1,219 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createHash } from "node:crypto";
import plugin from "../src/index.js";
import { diskSnapshotPath, snapshotIdentityFingerprint } from "../src/cache.js";
import { legacyApiToInfoApi } from "../src/catalog.js";
function isolateDisk(): { dir: string; restore: () => void } {
const dir = mkdtempSync(join(tmpdir(), "omniroute-snapfix-"));
const prev = process.env.OPENCODE_DATA_DIR;
process.env.OPENCODE_DATA_DIR = dir;
return {
dir,
restore: () => {
if (prev === undefined) delete process.env.OPENCODE_DATA_DIR;
else process.env.OPENCODE_DATA_DIR = prev;
},
};
}
function setupCtx(providerId: string): {
callbacks: Array<(draft: unknown) => Promise<void>>;
ctx: Record<string, unknown>;
} {
const callbacks: Array<(draft: unknown) => Promise<void>> = [];
const ctx = {
options: {
providerId,
baseURL: "https://gw.example.com",
apiKey: "k-snapfix",
},
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
callbacks.push(cb);
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {},
},
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
};
return { callbacks, ctx };
}
function stubDraft(): { draft: unknown; published: Map<string, Record<string, unknown>> } {
const published = new Map<string, Record<string, unknown>>();
return {
published,
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);
},
},
},
};
}
async function silenceConsole<T>(fn: () => Promise<T>): Promise<{ result: T; warns: string[] }> {
const warns: string[] = [];
const origWarn = console.warn;
const origLog = console.log;
console.warn = (...args: unknown[]) => {
warns.push(String(args[0]));
};
console.log = () => {};
try {
const result = await fn();
return { result, warns };
} finally {
console.warn = origWarn;
console.log = origLog;
}
}
function downFetch(): typeof fetch {
return (async (url: unknown) => {
const href = String(url);
if (href.includes("/api/pricing") || href.includes("/api/free-tier")) {
return { ok: true, status: 200, statusText: "OK", json: async () => ({}) };
}
if (href.includes("/api/combos/auto")) {
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
}
if (href.includes("/api/combos")) {
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
}
return { ok: false, status: 500, statusText: "Down", json: async () => ({}) };
}) as typeof fetch;
}
const fingerprint = snapshotIdentityFingerprint("https://gw.example.com", "k-snapfix", "k-snapfix");
describe("plugin-v2 snapshot stale-entry filter", () => {
it("snapshot with 2 entries without api block + 1 valid: only the valid one is published + warn emitted", async () => {
const disk = isolateDisk();
const providerId = "snapfix-mixed";
mkdirSync(join(disk.dir, "plugins"), { recursive: true });
writeFileSync(
diskSnapshotPath(providerId),
JSON.stringify({
v: 2,
identityFingerprint: fingerprint,
// Two pre-mapped entries with a broken api block (missing npm) plus
// one plain raw entry (no api block: synthesized at publish time).
models: [
{ id: "stale-a", api: {} },
{ id: "stale-b", api: { npm: "" } },
{ id: "good-1", context_length: 128000 },
],
combos: [],
autoCombos: [],
providers: [],
writtenAt: Date.now(),
})
);
const origFetch = globalThis.fetch;
globalThis.fetch = downFetch();
try {
const { callbacks, 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);
assert.ok(
published.has(`${providerId}/good-1`),
`valid entry must be published, got: ${JSON.stringify([...published.keys()])}`
);
assert.ok(
![...published.keys()].some((k) => k.includes("stale")),
`stale entries must be dropped, got: ${JSON.stringify([...published.keys()])}`
);
});
assert.ok(
warns.some((w) => w.includes("dropping 2 stale snapshot entries without api block")),
`expected stale-drop warn, got: ${JSON.stringify(warns)}`
);
} finally {
globalThis.fetch = origFetch;
disk.restore();
}
});
it("snapshot without version (v1 format): ignored entirely, fail-open to fresh fetch", async () => {
const disk = isolateDisk();
const providerId = "snapfix-unversioned";
mkdirSync(join(disk.dir, "plugins"), { recursive: true });
writeFileSync(
diskSnapshotPath(providerId),
JSON.stringify({
identityFingerprint: "whatever",
rawModels: [{ id: "ancient" }],
rawCombos: [],
writtenAt: Date.now(),
})
);
const origFetch = globalThis.fetch;
globalThis.fetch = (async (url: unknown) => {
const href = String(url);
if (href.includes("/api/pricing") || href.includes("/api/free-tier")) {
return { ok: true, status: 200, statusText: "OK", json: async () => ({}) };
}
if (href.includes("/api/combos/auto")) {
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
}
if (href.includes("/api/combos")) {
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
}
return {
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ data: [{ id: "fresh-1" }] }),
};
}) as typeof fetch;
try {
const { callbacks, 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);
assert.ok(
published.has(`${providerId}/fresh-1`),
`fresh fetch must win over unversioned snapshot, got: ${JSON.stringify([...published.keys()])}`
);
assert.ok(
![...published.keys()].some((k) => k.includes("ancient")),
`unversioned snapshot must be ignored, got: ${JSON.stringify([...published.keys()])}`
);
});
} finally {
globalThis.fetch = origFetch;
disk.restore();
}
});
it("legacyApiToInfoApi throws on missing api.npm (fail-fast, publish guard turns it into a warn)", () => {
assert.throws(
() => legacyApiToInfoApi(undefined as unknown as { id: string; npm: string; url: string }),
/without an api block/
);
assert.throws(
() =>
legacyApiToInfoApi({ id: "openai-compatible", url: "https://x/v1" } as unknown as {
id: string;
npm: string;
url: string;
}),
/without an api block/
);
// Sanity: sha256 helper used above matches the plugin identity scheme.
assert.equal(createHash("sha256").update("x").digest("hex").length, 64);
});
});

View File

@@ -0,0 +1,452 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import plugin from "../src/index.js";
// A catalog that only appears once the slowest optional source has answered is
// a catalog that never appears at all on a host that exits first: an
// unreachable `/api/combos/auto` kept models, combos and everything else
// unpublished until its own timeout fired. Models and combos must reach the
// draft as soon as they are known; the optional sources upgrade the snapshot
// when they land.
describe("plugin-v2 staged refresh: optional sources never gate the publish", () => {
let seq = 0;
async function isolateDisk(): Promise<() => void> {
const { mkdtempSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
seq += 1;
const dir = mkdtempSync(join(tmpdir(), `omniroute-staged-${seq}-`));
const prev = process.env.OPENCODE_DATA_DIR;
process.env.OPENCODE_DATA_DIR = dir;
return () => {
if (prev === undefined) delete process.env.OPENCODE_DATA_DIR;
else process.env.OPENCODE_DATA_DIR = prev;
};
}
function setupCtx(
providerId: string,
reloads: { count: number }
): {
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, apiKey: "k-" + providerId },
catalog: {
transform: (cb: (draft: unknown) => Promise<void>) => {
catalogCallbacks.push(cb);
return Promise.resolve({ dispose: async () => {} });
},
reload: async () => {
reloads.count += 1;
},
},
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
};
return { catalogCallbacks, ctx };
}
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 };
}
/**
* `/api/combos/auto` never answers and never honours the abort signal — the
* shape of a gateway that accepts the connection and then goes quiet.
*/
function stubFetch(opts: {
autoCombosHangs: boolean;
combosHangs?: boolean;
enrichmentDelayMs?: number;
}): typeof fetch {
return (async (url: unknown) => {
const href = String(url);
const ok = (body: unknown) => ({
ok: true,
status: 200,
statusText: "OK",
json: async () => body,
});
if (href.includes("/api/combos/auto")) {
if (opts.autoCombosHangs) return await new Promise(() => {});
return ok({ combos: [] });
}
if (href.includes("/api/combos")) {
// A gateway that accepts the connection and then goes quiet on the
// combos endpoint: models must still publish without waiting for it.
if (opts.combosHangs) return await new Promise(() => {});
return ok({ combos: [] });
}
if (href.includes("/api/pricing/models")) {
if (opts.enrichmentDelayMs !== undefined) {
await new Promise((r) => setTimeout(r, opts.enrichmentDelayMs));
}
return ok({
omni: {
id: "omni",
alias: "omni",
name: "Omni",
models: [{ id: "m1", name: "Model One" }],
},
});
}
if (href.includes("/api/pricing")) return ok({});
if (href.includes("/api/free-tier/summary")) return ok({});
return ok({ data: [{ id: "m1" }] });
}) as typeof fetch;
}
async function withSilentConsole<T>(fn: () => Promise<T>): Promise<T> {
const warn = console.warn;
const log = console.log;
console.warn = () => {};
console.log = () => {};
try {
return await fn();
} finally {
console.warn = warn;
console.log = log;
}
}
it("publishes models while an optional source is still hanging", async () => {
const restoreDisk = await isolateDisk();
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch({ autoCombosHangs: true });
const reloads = { count: 0 };
const { catalogCallbacks, 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 raced = await Promise.race([
done.then(() => "published" as const),
new Promise<"timeout">((r) => setTimeout(() => r("timeout"), 1500)),
]);
assert.equal(
raced,
"published",
"the publish must not wait on a source that never answers"
);
assert.ok([...published.keys()].some((k) => k.endsWith("/m1")));
});
} finally {
globalThis.fetch = origFetch;
restoreDisk();
}
});
it("applies an optional source that lands after the publish, on the next transform", async () => {
const restoreDisk = await isolateDisk();
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch({ autoCombosHangs: false, enrichmentDelayMs: 120 });
const reloads = { count: 0 };
const { catalogCallbacks, 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");
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");
// The overlay is rendered, not just stored: the provider label the
// gateway ships alongside the display name reaches the picker.
assert.equal(
late?.["name"],
"Omni - Model One",
"the late enrichment must reach the catalog, provider tag included"
);
});
} finally {
globalThis.fetch = origFetch;
restoreDisk();
}
});
it("does not ask the host to reload when the overlay came back identical", async () => {
const restoreDisk = await isolateDisk();
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch({ autoCombosHangs: false });
const reloads = { count: 0 };
const { catalogCallbacks, 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 new Promise((r) => setTimeout(r, 60));
}
});
assert.ok(
reloads.count <= 1,
`an unchanged overlay must not trigger a reload per refresh, got ${reloads.count}`
);
} finally {
globalThis.fetch = origFetch;
restoreDisk();
}
});
it("a refresh after the TTL keeps the overlay instead of downgrading the picker", async () => {
const restoreDisk = await isolateDisk();
const origFetch = globalThis.fetch;
// Enrichment answers once, then goes away: the second refresh must not
// strip the names the first one obtained.
let enrichCalls = 0;
globalThis.fetch = (async (url: unknown) => {
const href = String(url);
const ok = (body: unknown) => ({
ok: true,
status: 200,
statusText: "OK",
json: async () => body,
});
if (href.includes("/api/combos/auto")) return ok({ combos: [] });
if (href.includes("/api/combos")) return ok({ combos: [] });
if (href.includes("/api/pricing/models")) {
enrichCalls += 1;
if (enrichCalls > 1) {
return { ok: false, status: 503, statusText: "Unavailable", json: async () => ({}) };
}
return ok({
omni: {
id: "omni",
alias: "omni",
name: "Omni",
models: [{ id: "m1", name: "Model One" }],
},
});
}
if (href.includes("/api/pricing")) return ok({});
if (href.includes("/api/free-tier/summary")) return ok({});
return ok({ data: [{ id: "m1" }] });
}) as unknown as typeof fetch;
const reloads = { count: 0 };
const { catalogCallbacks, 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");
assert.equal(
m1?.["name"],
"Omni - Model One",
"the second refresh must keep the name the first one resolved"
);
});
} finally {
globalThis.fetch = origFetch;
restoreDisk();
}
});
it("publishes models while a hanging /api/combos holds nothing back", async () => {
// Combos used to sit on the critical path (Promise.all with models), so a
// gateway slow on /api/combos held the whole picker back. Regression pin:
// models publish even when combos never answers.
const restoreDisk = await isolateDisk();
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch({ autoCombosHangs: false, combosHangs: true });
const reloads = { count: 0 };
const { catalogCallbacks, 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 raced = await Promise.race([
done.then(() => "published" as const),
new Promise<"timeout">((r) => setTimeout(() => r("timeout"), 1500)),
]);
assert.equal(
raced,
"published",
"models must publish without waiting for a hanging /api/combos"
);
assert.ok([...published.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,
0,
`no combos known yet — models-only on the first publish is correct, got ${JSON.stringify([...published.keys()])}`
);
});
} finally {
globalThis.fetch = origFetch;
restoreDisk();
}
});
it("keeps names/pricing on the third transform when enrichment stays down", async () => {
// The old all-empty guard is gone by design (it also blocked genuine
// removals); the per-source failure signal replaces it. A persistent 503
// on the overlay must keep last-known names on EVERY later transform,
// not just the second one — this is the regression pin for the throw
// instead of soft-fail change.
const restoreDisk = await isolateDisk();
const origFetch = globalThis.fetch;
let enrichCalls = 0;
globalThis.fetch = (async (url: unknown) => {
const href = String(url);
const ok = (body: unknown) => ({
ok: true,
status: 200,
statusText: "OK",
json: async () => body,
});
if (href.includes("/api/combos/auto")) return ok({ combos: [] });
if (href.includes("/api/combos")) return ok({ combos: [] });
if (href.includes("/api/pricing/models")) {
enrichCalls += 1;
if (enrichCalls > 1) {
return { ok: false, status: 503, statusText: "Unavailable", json: async () => ({}) };
}
return ok({
omni: {
id: "omni",
alias: "omni",
name: "Omni",
models: [{ id: "m1", name: "Model One" }],
},
});
}
if (href.includes("/api/pricing")) return ok({});
if (href.includes("/api/free-tier/summary")) return ok({});
return ok({ data: [{ id: "m1" }] });
}) as unknown as typeof fetch;
const reloads = { count: 0 };
const { catalogCallbacks, 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 new Promise((r) => setTimeout(r, 250));
const third = stubDraft();
await catalogCallbacks[0]!(third.draft);
const m1 = [...third.published.values()].find((m) => m["id"] === "m1");
assert.equal(
m1?.["name"],
"Omni - Model One",
`a persistently failing overlay must not wipe names, got ${JSON.stringify(m1?.["name"])}`
);
});
} finally {
globalThis.fetch = origFetch;
restoreDisk();
}
});
it("serves last-known without refetching inside the unreachable window", async () => {
// A gateway that answers nothing at all gets a short breather instead of
// a full fetch suite on every transform: the cooldown arms only once a
// total failure is confirmed (no models AND a prior entry exists to
// serve), and transforms inside the window must not issue new requests.
const restoreDisk = await isolateDisk();
const origFetch = globalThis.fetch;
let modelCalls = 0;
let down = false;
globalThis.fetch = (async (url: unknown) => {
const href = String(url);
const ok = (body: unknown) => ({
ok: true,
status: 200,
statusText: "OK",
json: async () => body,
});
if (href.includes("/api/combos/auto")) return ok({ combos: [] });
if (href.includes("/api/combos")) return ok({ combos: [] });
if (href.includes("/api/pricing") || href.includes("/api/free-tier")) return ok({});
modelCalls += 1;
if (down) {
return { ok: false, status: 500, statusText: "Down", json: async () => ({}) };
}
return ok({ data: [{ id: "m1" }] });
}) as unknown as typeof fetch;
const reloads = { count: 0 };
const { catalogCallbacks, 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);
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;
restoreDisk();
}
});
it("throws on a synchronously refusing integration hook, catalog intact", async () => {
// A host whose integration.transform throws while registering must cost
// the plugin the connect action only — setup resolves, the catalog
// callback is registered, and the throw is warned, not propagated.
const restoreDisk = await isolateDisk();
const origFetch = globalThis.fetch;
globalThis.fetch = stubFetch({ autoCombosHangs: false });
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);
return Promise.resolve({ dispose: async () => {} });
},
},
integration: {
transform: () => {
throw new Error("host says no");
},
},
};
try {
await withSilentConsole(async () => {
await (plugin as unknown as { setup: (c: unknown) => Promise<void> }).setup(ctx);
assert.equal(catalogCallbacks.length, 1, "the catalog still registers");
});
} finally {
globalThis.fetch = origFetch;
restoreDisk();
}
});
});

View File

@@ -0,0 +1,161 @@
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";
function fakeDraft(): CatalogDraft {
const providers = new Map<string, ProviderV2Info>();
const models = new Map<string, ModelV2Info>();
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;
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;
}
const BER = "https://gw.example.com";
describe("plugin-v2 P2 parity: per-endpoint timeouts", () => {
it("timeoutMs default is 10s (v1 parity over the old 30s)", () => {
const opts = parsePluginOptions({ baseURL: BER });
assert.equal(opts.timeoutMs, 10000);
});
it("endpoint timeouts default to the global timeout", () => {
const opts = parsePluginOptions({ baseURL: BER });
assert.deepEqual(resolveTimeouts(opts), {
models: 10000,
combos: 10000,
autoCombos: 5000,
enrichment: 10000,
});
});
it("endpoint overrides win over the global timeout", () => {
const opts = parsePluginOptions({
baseURL: BER,
timeoutMs: 3000,
timeouts: { models: 1111, combos: 2222, autoCombos: 3333, enrichment: 4444 },
});
assert.deepEqual(resolveTimeouts(opts), {
models: 1111,
combos: 2222,
autoCombos: 3333,
enrichment: 4444,
});
});
it("publishCatalog forwards per-endpoint timeouts (slow models stub aborts at 20ms)", async () => {
const seen: number[] = [];
const t0 = Date.now();
const slowModels = async (_base: string, _key: string, timeoutMs?: number) => {
seen.push(timeoutMs ?? -1);
await new Promise<void>((_resolve, reject) => {
setTimeout(() => {
reject(Object.assign(new Error("This operation was aborted"), { name: "AbortError" }));
}, timeoutMs ?? 10000);
});
return [{ id: "m1" }];
};
const draft = fakeDraft();
const origWarn = console.warn;
console.warn = () => {};
try {
const res = await publishCatalog(
draft,
{
providerId: "omniroute",
baseURL: BER,
apiKey: "k",
// A per-endpoint value must win over the global one: the 20ms
// models budget is what this test asserts, not the 10s fallback.
timeoutMs: 10000,
timeouts: { models: 20, combos: 10000 },
modelCacheTtlMs: 300000,
usableOnly: false,
},
{ fetcher: slowModels, combosFetcher: async () => [] }
);
assert.deepEqual(res, { models: 0, combos: 0, autoCombos: 0 });
} finally {
console.warn = origWarn;
}
assert.deepEqual(seen, [20]);
assert.ok(Date.now() - t0 < 2000, "slow fetcher must abort near 20ms, not hang");
});
it("logger option accepts logLevel plus startupDebug (default warn)", () => {
assert.equal(parsePluginOptions({ baseURL: BER }).logLevel, undefined);
assert.equal(parsePluginOptions({ baseURL: BER }).startupDebug, undefined);
assert.equal(parsePluginOptions({ baseURL: BER, logLevel: "debug" }).logLevel, "debug");
assert.equal(parsePluginOptions({ baseURL: BER, startupDebug: true }).startupDebug, true);
assert.throws(() => parsePluginOptions({ baseURL: BER, logLevel: "verbose" }));
});
it("startupDebug silences the boot line at default, shows it with startupDebug", async () => {
const plugin = (await import("../src/index.js")).default as unknown as {
setup: (ctx: unknown) => Promise<void>;
};
async function bootLogLines(options: Record<string, unknown>): Promise<string[]> {
const warns: string[] = [];
const origWarn = console.warn;
console.warn = (...args: unknown[]) => {
warns.push(String(args[0]));
};
const ctx = {
options,
catalog: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
integration: {
transform: () => Promise.resolve({ dispose: async () => {} }),
},
};
try {
await plugin.setup(ctx);
} finally {
console.warn = origWarn;
}
return warns;
}
const quiet = await bootLogLines({
baseURL: BER,
providerId: "p2-quiet",
apiKey: "k",
});
assert.ok(
!quiet.some((line) => line.includes("init providerId=")),
`boot line must stay silent by default, got: ${JSON.stringify(quiet)}`
);
const loud = await bootLogLines({
baseURL: BER,
providerId: "p2-loud",
apiKey: "k",
startupDebug: true,
});
assert.ok(
loud.some((line) => line.includes("init providerId=p2-loud")),
`boot line must show with startupDebug, got: ${JSON.stringify(loud)}`
);
});
});

View File

@@ -0,0 +1,222 @@
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";
function fakeDraft(): { models: Map<string, ModelV2Info>; draft: CatalogDraft } {
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) ?? { id }) as ProviderV2Info;
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 { models, draft };
}
function silence() {
const origWarn = console.warn;
const origLog = console.log;
console.warn = () => {};
console.log = () => {};
return {
restore() {
console.warn = origWarn;
console.log = origLog;
},
};
}
function enrichmentOf(...pairs: Array<[string, string]>) {
return new Map(
pairs.map(([alias, canonical]) => [
`${alias}/model-x`,
{ providerAlias: alias, providerCanonical: canonical },
])
);
}
const baseOpts = {
providerId: "omniroute",
baseURL: "https://gw.example.com",
apiKey: "k",
timeoutMs: 1000,
modelCacheTtlMs: 300000,
};
describe("usableOnly option default", () => {
it("defaults to false", () => {
const opts = parsePluginOptions({ baseURL: "https://gw.example.com" });
assert.equal(opts.usableOnly, false);
});
});
describe("catalog usableOnly gating", () => {
const stubModels = async () => [
{ id: "cc/keep-me" },
{ id: "dead/drop-me" },
{ id: "agentrouter/mystery" },
];
it("usableOnly=false performs zero providers fetches", async () => {
const { draft, models } = fakeDraft();
let providersCalls = 0;
const res = await publishCatalog(
draft,
{ ...baseOpts, usableOnly: false },
{
fetcher: stubModels,
combosFetcher: async () => [],
providersFetcher: async () => {
providersCalls += 1;
return [];
},
}
);
assert.equal(providersCalls, 0);
assert.equal(res.models, 3);
assert.ok(models.has("omniroute/cc/keep-me"));
assert.ok(models.has("omniroute/dead/drop-me"));
});
it("usableOnly=true drops known-but-not-provisioned models, keeps unknown prefixes", async () => {
const { draft, models } = fakeDraft();
const res = await publishCatalog(
draft,
{
...baseOpts,
usableOnly: true,
enrichment: enrichmentOf(["cc", "claude"], ["dead", "legacy"]),
},
{
fetcher: stubModels,
combosFetcher: async () => [],
providersFetcher: async () => [
{ id: "c1", provider: "claude", isActive: true, testStatus: "active" },
],
}
);
assert.equal(res.models, 2);
assert.ok(models.has("omniroute/cc/keep-me"));
assert.ok(!models.has("omniroute/dead/drop-me"));
assert.ok(models.has("omniroute/agentrouter/mystery"));
});
it("usableOnly=true with providers soft-fail keeps the full catalog", async () => {
const { draft, models } = fakeDraft();
const res = await publishCatalog(
draft,
{ ...baseOpts, usableOnly: true },
{
fetcher: stubModels,
combosFetcher: async () => [],
providersFetcher: async () => {
throw new Error("down");
},
}
);
assert.equal(res.models, 3);
assert.ok(models.has("omniroute/dead/drop-me"));
});
it("usableOnly=true filters combos by member usability", async () => {
const { draft, models } = fakeDraft();
const res = await publishCatalog(
draft,
{
...baseOpts,
usableOnly: true,
enrichment: enrichmentOf(["cc", "claude"], ["dead", "legacy"]),
},
{
fetcher: async () => [{ id: "cc/x" }, { id: "dead/y" }],
combosFetcher: async () => [
{ id: "good", name: "Good", models: [{ kind: "model", model: "cc/x" }] },
{ id: "bad", name: "Bad", models: [{ kind: "model", model: "dead/y" }] },
],
providersFetcher: async () => [
{ id: "c1", provider: "claude", isActive: true, testStatus: "active" },
],
}
);
assert.equal(res.combos, 1);
assert.ok(models.has("omniroute/good"));
assert.ok(!models.has("omniroute/bad"));
});
it("usableOnly=false issues no providers request through setup (gating)", async () => {
const seen: string[] = [];
const origFetch = globalThis.fetch;
globalThis.fetch = (async (url: unknown) => {
const href = String(url);
seen.push(href);
if (href.includes("/api/combos/auto")) {
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
}
if (href.includes("/api/combos")) {
return { ok: true, status: 200, statusText: "OK", json: async () => ({ combos: [] }) };
}
return {
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ data: [{ id: "m1" }] }),
};
}) as typeof fetch;
const guard = silence();
try {
const plugin = (await import("../src/index.js")).default as unknown as {
setup: (ctx: unknown) => Promise<void>;
};
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);
return Promise.resolve({ dispose: async () => {} });
},
},
integration: { transform: () => Promise.resolve({ dispose: async () => {} }) },
});
const { mkdtempSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const prevDataDir = process.env.OPENCODE_DATA_DIR;
process.env.OPENCODE_DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-usable-"));
try {
const { draft } = fakeDraft();
await catalogCallbacks[0](draft as never);
} finally {
if (prevDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
else process.env.OPENCODE_DATA_DIR = prevDataDir;
}
assert.ok(
!seen.some((href) => href.includes("/api/providers")),
`no providers fetch expected, got: ${JSON.stringify(seen)}`
);
} finally {
globalThis.fetch = origFetch;
guard.restore();
}
});
});

View File

@@ -0,0 +1,88 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import plugin from "../src/index.js";
import { snapshotIdentityFingerprint, writeDiskSnapshot } from "../src/cache.js";
/**
* The recommended setup stores the gateway key in the host's credential store,
* so the key the plugin ends up using is not the one its options carry. The
* disk snapshot is keyed by that credential: reading it before the credential
* is resolved looks up the wrong identity and throws away a usable catalog —
* exactly when it is needed, on a cold start against an unreachable gateway.
*/
describe("warm snapshot is read under the credential actually in use", () => {
it("serves the snapshot written for the host credential, gateway down", async () => {
const { mkdtempSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const dir = mkdtempSync(join(tmpdir(), "omniroute-warm-id-"));
const prevDir = process.env.OPENCODE_DATA_DIR;
process.env.OPENCODE_DATA_DIR = dir;
const origFetch = globalThis.fetch;
globalThis.fetch = (async () => {
throw new Error("gateway unreachable");
}) as unknown as typeof fetch;
const warn = console.warn;
const log = console.log;
console.warn = () => {};
console.log = () => {};
try {
const baseURL = "https://gw.example.com";
const hostKey = "key-from-the-host-store";
await writeDiskSnapshot(
"warmid",
{
models: [{ id: "m-snap" }],
combos: [],
autoCombos: [],
providers: [],
fetchedAt: Date.now(),
} as never,
snapshotIdentityFingerprint(baseURL, hostKey, hostKey)
);
const published = new Map<string, Record<string, unknown>>();
const callbacks: Array<(draft: unknown) => Promise<void>> = [];
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);
return registration;
},
reload: async () => {},
},
integration: {
transform: () => registration,
connection: {
active: async () => ({ type: "credential", id: "c", label: "l" }),
resolve: async () => ({ type: "key", key: hostKey }),
},
},
};
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);
assert.ok(
[...published.keys()].some((k) => k.endsWith("/m-snap")),
`the snapshot must survive the credential switch, published: ${JSON.stringify([...published.keys()])}`
);
} finally {
globalThis.fetch = origFetch;
console.warn = warn;
console.log = log;
if (prevDir === undefined) delete process.env.OPENCODE_DATA_DIR;
else process.env.OPENCODE_DATA_DIR = prevDir;
}
});
});

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"declaration": true,
"outDir": "dist",
"skipLibCheck": true,
"isolatedModules": true,
"forceConsistentCasingInFileNames": true,
"types": [
"node"
],
"lib": [
"ES2022"
],
"esModuleInterop": true
},
"include": [
"src/**/*.ts",
"tests/**/*.ts"
]
}

View File

@@ -0,0 +1,16 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
clean: true,
sourcemap: false,
splitting: false,
treeshake: false,
target: "node22",
outDir: "dist",
minify: false,
cjsInterop: false,
noExternal: ["zod"],
});

View File

@@ -23,7 +23,7 @@
"scripts": {
"build": "tsup",
"clean": "rm -rf dist",
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts tests/log-level.test.ts tests/effort-tier-variants.test.ts tests/naming.test.ts tests/free-budget-magnitude.test.ts",
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/telemetry.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts tests/log-level.test.ts tests/effort-tier-variants.test.ts tests/naming.test.ts tests/free-budget-magnitude.test.ts tests/models-fetcher.test.ts",
"prepublishOnly": "npm run clean && npm run build && npm test"
},
"keywords": [

View File

@@ -75,6 +75,7 @@ import {
AUTO_VARIANT_DESCRIPTIONS,
type FreeModelFreeType,
} from "./naming.js";
import { applyOmniRouteInferenceTelemetry } from "./telemetry.js";
/**
* Minimal leveled logger sink accepted by the default fetchers and the static
@@ -1199,7 +1200,7 @@ export type OmniRouteModelsFetcher = (
export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async (
baseURL,
apiKey,
timeoutMs = 10_000
timeoutMs = 30_000
) => {
if (!apiKey) throw new Error("@omniroute/opencode-plugin: apiKey required to fetch /v1/models");
if (!baseURL) throw new Error("@omniroute/opencode-plugin: baseURL required to fetch /v1/models");
@@ -1221,9 +1222,12 @@ export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async (
signal: controller.signal,
});
if (!res.ok) {
throw new Error(
const err = new Error(
`@omniroute/opencode-plugin: GET ${url} failed: ${res.status} ${res.statusText}`
);
) as Error & { statusCode: number; status: number };
err.statusCode = res.status;
err.status = res.status;
throw err;
}
const body = (await res.json()) as unknown;
const rawList: unknown[] = Array.isArray(body)
@@ -3766,6 +3770,8 @@ export function createOmniRouteFetchInterceptor(config: {
baseOrigin = baseUrl.origin;
const basePath = ensureV1Suffix(baseUrl.pathname);
inferencePaths.add(`${basePath}/chat/completions`);
inferencePaths.add(`${basePath}/responses`);
inferencePaths.add(`${basePath}/messages`);
inferencePaths.add(`${basePath}/models`);
} catch {
// Credential-attached base URLs are not schema-validated. A malformed
@@ -3809,7 +3815,7 @@ export function createOmniRouteFetchInterceptor(config: {
headers.set("Content-Type", "application/json");
}
return fetch(input, { ...init, headers });
return applyOmniRouteInferenceTelemetry(await fetch(input, { ...init, headers }));
};
}
@@ -5398,7 +5404,7 @@ export function createOmniRouteConfigHook(
// exact warn message so per-endpoint fallbacks are preserved.
const doModels = async (): Promise<void> => {
try {
localRawModels = await fetcher(baseURL, apiKey, 10_000);
localRawModels = await fetcher(baseURL, apiKey, 30_000);
} catch (err) {
logAt(
"error",

View File

@@ -0,0 +1,249 @@
/**
* Map gateway-reported OmniRoute inference telemetry onto the JSON/SSE
* payload OpenCode already consumes. Prefer headers / usage fields from the
* gateway. Never invent tok/s from tokens / latency (that includes TTFT).
*/
export type OmniRouteInferenceTelemetry = {
costUsd?: number;
tokensIn?: number;
tokensOut?: number;
tokensPerSecond?: number;
ttftMs?: number;
latencyMs?: number;
model?: string;
provider?: string;
};
const HEADER = {
cost: "x-omniroute-response-cost",
tokensIn: "x-omniroute-tokens-in",
tokensOut: "x-omniroute-tokens-out",
tokensPerSecond: "x-omniroute-tokens-per-second",
ttftMs: "x-omniroute-ttft-ms",
latencyMs: "x-omniroute-latency-ms",
model: "x-omniroute-model",
provider: "x-omniroute-provider",
} as const;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readFiniteNumber(raw: string | null): number | undefined {
if (raw == null) return undefined;
const trimmed = raw.trim();
if (trimmed === "") return undefined;
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : undefined;
}
function readPositiveNumber(raw: string | null): number | undefined {
const parsed = readFiniteNumber(raw);
if (parsed === undefined || parsed <= 0) return undefined;
return parsed;
}
function readNonNegativeInt(raw: string | null): number | undefined {
const parsed = readFiniteNumber(raw);
if (parsed === undefined || parsed < 0) return undefined;
return Math.round(parsed);
}
function readToken(raw: string | null): string | undefined {
if (raw == null) return undefined;
const trimmed = raw.trim();
return trimmed === "" ? undefined : trimmed;
}
export function parseOmniRouteInferenceTelemetry(headers: Headers): OmniRouteInferenceTelemetry {
const out: OmniRouteInferenceTelemetry = {};
const cost = readFiniteNumber(headers.get(HEADER.cost));
if (cost !== undefined && cost >= 0) out.costUsd = cost;
const tokensIn = readNonNegativeInt(headers.get(HEADER.tokensIn));
if (tokensIn !== undefined) out.tokensIn = tokensIn;
const tokensOut = readNonNegativeInt(headers.get(HEADER.tokensOut));
if (tokensOut !== undefined) out.tokensOut = tokensOut;
const tps = readPositiveNumber(headers.get(HEADER.tokensPerSecond));
if (tps !== undefined) out.tokensPerSecond = tps;
const ttft = readPositiveNumber(headers.get(HEADER.ttftMs));
if (ttft !== undefined) out.ttftMs = ttft;
const latency = readPositiveNumber(headers.get(HEADER.latencyMs));
if (latency !== undefined) out.latencyMs = latency;
const model = readToken(headers.get(HEADER.model));
if (model) out.model = model;
const provider = readToken(headers.get(HEADER.provider));
if (provider) out.provider = provider;
return out;
}
function telemetryFromUsage(usage: Record<string, unknown>): OmniRouteInferenceTelemetry {
const out: OmniRouteInferenceTelemetry = {};
const tps = usage.tokens_per_second;
if (typeof tps === "number" && Number.isFinite(tps) && tps > 0) {
out.tokensPerSecond = tps;
}
const ttft = usage.ttft_ms;
if (typeof ttft === "number" && Number.isFinite(ttft) && ttft > 0) {
out.ttftMs = ttft;
}
return out;
}
function mergeTelemetry(
base: OmniRouteInferenceTelemetry,
extra: OmniRouteInferenceTelemetry,
): OmniRouteInferenceTelemetry {
return {
...base,
...Object.fromEntries(Object.entries(extra).filter(([, value]) => value !== undefined)),
};
}
function isInferencePayload(payload: Record<string, unknown>): boolean {
return (
isRecord(payload.usage) ||
Array.isArray(payload.choices) ||
payload.object === "chat.completion" ||
payload.object === "response" ||
payload.type === "message" ||
Array.isArray(payload.output)
);
}
function attachToUsage(
usage: Record<string, unknown>,
telemetry: OmniRouteInferenceTelemetry,
): Record<string, unknown> {
const next = { ...usage };
if (
telemetry.tokensPerSecond !== undefined &&
(typeof next.tokens_per_second !== "number" || next.tokens_per_second <= 0)
) {
next.tokens_per_second = telemetry.tokensPerSecond;
}
if (telemetry.ttftMs !== undefined && (typeof next.ttft_ms !== "number" || next.ttft_ms <= 0)) {
next.ttft_ms = telemetry.ttftMs;
}
if (telemetry.costUsd !== undefined && typeof next.cost !== "number") {
next.cost = telemetry.costUsd;
}
return next;
}
export function attachOmniRouteTelemetryToPayload(
payload: unknown,
telemetry: OmniRouteInferenceTelemetry,
): unknown {
if (!isRecord(payload) || !isInferencePayload(payload)) {
return payload;
}
const next: Record<string, unknown> = { ...payload };
if (telemetry.model) {
next.model = telemetry.model;
}
if (isRecord(next.usage)) {
next.usage = attachToUsage(next.usage, mergeTelemetry(telemetry, telemetryFromUsage(next.usage)));
}
if (isRecord(next.response) && isRecord(next.response.usage)) {
next.response = {
...next.response,
usage: attachToUsage(
next.response.usage,
mergeTelemetry(telemetry, telemetryFromUsage(next.response.usage)),
),
};
}
return next;
}
export function attachOmniRouteTelemetryToSseLine(
line: string,
telemetry: OmniRouteInferenceTelemetry,
): string {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) {
return line;
}
const jsonText = trimmed.slice("data:".length).trim();
if (!jsonText.startsWith("{")) {
return line;
}
try {
const parsed = JSON.parse(jsonText) as unknown;
const updated = attachOmniRouteTelemetryToPayload(parsed, telemetry);
if (updated === parsed) {
return line;
}
const prefix = line.slice(0, line.indexOf(jsonText));
const suffix = line.endsWith("\r") ? "\r" : "";
return `${prefix}${JSON.stringify(updated)}${suffix}`;
} catch {
return line;
}
}
export async function applyOmniRouteInferenceTelemetry(response: Response): Promise<Response> {
const telemetry = parseOmniRouteInferenceTelemetry(response.headers);
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("text/event-stream") && response.body) {
return new Response(mapSseBody(response.body, telemetry), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
if (!contentType.includes("json")) {
return response;
}
const text = await response.text();
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return new Response(text, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
const next = attachOmniRouteTelemetryToPayload(parsed, telemetry);
if (next === parsed) {
return new Response(text, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
return new Response(JSON.stringify(next), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
function mapSseBody(
body: ReadableStream<Uint8Array>,
telemetry: OmniRouteInferenceTelemetry,
): ReadableStream<Uint8Array> {
const decoder = new TextDecoder();
const encoder = new TextEncoder();
let pending = "";
let live = { ...telemetry };
return body.pipeThrough(
new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
pending += decoder.decode(chunk, { stream: true });
const lines = pending.split("\n");
pending = lines.pop() ?? "";
for (const line of lines) {
controller.enqueue(encoder.encode(`${attachOmniRouteTelemetryToSseLine(line, live)}\n`));
}
},
flush(controller) {
if (pending.length > 0) {
controller.enqueue(encoder.encode(attachOmniRouteTelemetryToSseLine(pending, live)));
}
},
}),
);
}

View File

@@ -0,0 +1,45 @@
import test from "node:test";
import assert from "node:assert/strict";
import { defaultOmniRouteModelsFetcher } from "../src/index.js";
test("defaultOmniRouteModelsFetcher attaches statusCode on HTTP 401", async () => {
const original = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(JSON.stringify({ error: "authentication expired" }), {
status: 401,
statusText: "Unauthorized",
})) as typeof fetch;
try {
await assert.rejects(
() => defaultOmniRouteModelsFetcher("https://gateway.example/v1", "test-key"),
(err: unknown) => {
assert.ok(err instanceof Error);
const rec = err as Error & { statusCode?: number; status?: number };
assert.equal(rec.statusCode, 401);
assert.equal(rec.status, 401);
assert.match(rec.message, /401/);
return true;
},
);
} finally {
globalThis.fetch = original;
}
});
test("defaultOmniRouteModelsFetcher default timeout is 30s", async () => {
const original = globalThis.fetch;
let signal: AbortSignal | undefined;
globalThis.fetch = (async (_input, init) => {
signal = init?.signal ?? undefined;
return new Response(JSON.stringify({ object: "list", data: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
try {
await defaultOmniRouteModelsFetcher("https://gateway.example/v1", "test-key");
assert.equal(signal instanceof AbortSignal, true);
} finally {
globalThis.fetch = original;
}
});

View File

@@ -0,0 +1,103 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
applyOmniRouteInferenceTelemetry,
attachOmniRouteTelemetryToPayload,
attachOmniRouteTelemetryToSseLine,
parseOmniRouteInferenceTelemetry,
} from "../src/telemetry.js";
test("parseOmniRouteInferenceTelemetry: copies cost, tokens, tok/s, winning model", () => {
const headers = new Headers({
"X-OmniRoute-Response-Cost": "0.0123",
"X-OmniRoute-Tokens-In": "10",
"X-OmniRoute-Tokens-Out": "200",
"X-OmniRoute-Tokens-Per-Second": "100.5",
"X-OmniRoute-Ttft-Ms": "300",
"X-OmniRoute-Latency-Ms": "2300",
"X-OmniRoute-Model": "winner-model",
"X-OmniRoute-Provider": "openai",
});
const got = parseOmniRouteInferenceTelemetry(headers);
assert.equal(got.costUsd, 0.0123);
assert.equal(got.tokensIn, 10);
assert.equal(got.tokensOut, 200);
assert.equal(got.tokensPerSecond, 100.5);
assert.equal(got.ttftMs, 300);
assert.equal(got.model, "winner-model");
assert.equal(got.provider, "openai");
});
test("parseOmniRouteInferenceTelemetry: omits tok/s when header missing (do not invent from latency)", () => {
const headers = new Headers({
"X-OmniRoute-Tokens-Out": "200",
"X-OmniRoute-Latency-Ms": "2000",
});
const got = parseOmniRouteInferenceTelemetry(headers);
assert.equal(got.tokensPerSecond, undefined);
assert.equal(got.tokensOut, 200);
const payload = attachOmniRouteTelemetryToPayload(
{ object: "chat.completion", usage: { prompt_tokens: 10, completion_tokens: 200 } },
got,
) as { usage: { tokens_per_second?: number } };
assert.equal(payload.usage.tokens_per_second, undefined);
});
test("attachOmniRouteTelemetryToPayload: writes usage.tokens_per_second and winning model", () => {
const got = attachOmniRouteTelemetryToPayload(
{
object: "chat.completion",
model: "combo/auto",
usage: { prompt_tokens: 10, completion_tokens: 200 },
},
{ tokensPerSecond: 80, ttftMs: 250, costUsd: 0, model: "gpt-winner" },
) as {
model: string;
usage: { tokens_per_second: number; ttft_ms: number; cost: number };
};
assert.equal(got.model, "gpt-winner");
assert.equal(got.usage.tokens_per_second, 80);
assert.equal(got.usage.ttft_ms, 250);
assert.equal(got.usage.cost, 0);
});
test("attachOmniRouteTelemetryToPayload: does not mutate /v1/models catalog JSON", () => {
const catalog = { object: "list", data: [{ id: "m1" }] };
const got = attachOmniRouteTelemetryToPayload(catalog, {
tokensPerSecond: 99,
model: "should-not-apply",
});
assert.deepEqual(got, catalog);
});
test("attachOmniRouteTelemetryToSseLine: patches terminal usage data line", () => {
const line =
'data: {"object":"chat.completion.chunk","usage":{"completion_tokens":200}}';
const got = attachOmniRouteTelemetryToSseLine(line, { tokensPerSecond: 50 });
assert.match(got, /"tokens_per_second":50/);
assert.match(got, /^data: /);
});
test("applyOmniRouteInferenceTelemetry: JSON response gets header tok/s", async () => {
const response = new Response(
JSON.stringify({
object: "chat.completion",
model: "combo/auto",
usage: { prompt_tokens: 1, completion_tokens: 20 },
}),
{
headers: {
"Content-Type": "application/json",
"X-OmniRoute-Tokens-Per-Second": "40",
"X-OmniRoute-Model": "winner",
},
},
);
const next = await applyOmniRouteInferenceTelemetry(response);
const body = JSON.parse(await next.text()) as {
model: string;
usage: { tokens_per_second: number };
};
assert.equal(body.model, "winner");
assert.equal(body.usage.tokens_per_second, 40);
});

View File

@@ -46,7 +46,7 @@ Repository map and Reference Documentation sections below.
## Project at a Glance
**OmniRoute** — unified AI proxy/router. One endpoint, 355 LLM providers, auto-fallback.
**OmniRoute** — unified AI proxy/router. One endpoint, 356 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 (168 migrations) |
| Database | `src/lib/db/` | SQLite domain modules (171 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 |

View File

@@ -4,6 +4,7 @@
### ✨ New Features
- **feat(dashboard):** adaptive context-budget dial on the compression settings panel — mode (`off` / `floor` / `replace-autotrigger`) and policy (`reserve-output` / `percentage` / `absolute`) persist via `PUT /api/settings/compression` `contextBudget`. Completes the dashboard half of #7005 (API + DB already shipped in #7183).
- **feat(sse): STRICT_ZERO_COST** — opt-in, off-by-default `freeAccessPolicy: "strict"` setting
that hard-verifies every auto-combo candidate against live quota state and per-connection
economic safety before it can be dispatched, going beyond `hidePaidModels`'s static catalog
@@ -91,16 +92,884 @@
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
_Living section — reconciled 2026-09-07 from all cycle commits (`release/v3.8.50` tip `091589089c``release/v3.8.51` tip `d6f315018a`, 696 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 | **120** |
| 📝 Commits in the cycle | **696** |
| 🔀 Pull requests referenced | **672** |
| 📋 Changelog entries | **685** |
| 🙌 Contributors credited in entries | **118** |
| 🤖 Automated dependency commits | 18 |
**Entries by type**
| Type | Count |
| --- | ---: |
| 🐛 Fixes | 403 |
| ✨ Features | 118 |
| 🧹 Chore | 72 |
| 📚 Docs | 36 |
| 🧪 Tests | 29 |
| ⚡ Performance | 7 |
| ♻️ Refactor | 7 |
| 🏗️ Build | 6 |
| ⚙️ CI | 2 |
| ⏪ Reverts | 2 |
| 📦 Dependencies | 2 |
| 🔒 Security | 1 |
### 🏆 Top 25 contributors this cycle
_By commits in `release/v3.8.50..release/v3.8.51` (`091589089c..d6f315018a`), author identities consolidated via `.mailmap` and the merged PR's GitHub login. Bots excluded._
| # | Contributor | Commits |
| ---: | --- | ---: |
| 🥇 | diegosouzapw | 240 |
| 🥈 | Paco Cartones (@pacocartones) | 58 |
| 🥉 | Bob.Hou (@HouMinXi) | 57 |
| 4 | Dizzle (@maxmad64bis) | 27 |
| 5 | Webman (@jonlwheat2-gif) | 22 |
| 6 | Ravi Tharuma (@RaviTharuma) | 21 |
| 7 | backryun | 17 |
| 8 | Markus Hartung (@hartmark) | 17 |
| 9 | Nguyen Thanh Dat (@ntdatt812) | 16 |
| 10 | Rafa Martins (@rafacpti23) | 10 |
| 11 | Syed Raheemuddin (@raheemuddin786) | 10 |
| 12 | Koosha Paridehpour (@KooshaPari) | 9 |
| 13 | Nguyễn Viết Tuấn (@TheDemonTuan) | 8 |
| 14 | MumuTW | 7 |
| 15 | Paijo (@oyi77) | 7 |
| 16 | Tobias Andersen (@turbolego) | 6 |
| 17 | Bl0ck (@Bl0ck154) | 5 |
| 18 | SHANMUGAPRIYAN (@geek007git) | 5 |
| 19 | KaspaPulse | 5 |
| 20 | Abhishek Sharma (@abhisheksharma2411) | 4 |
| 21 | Abhishek Divekar (@adivekar-utexas) | 4 |
| 22 | Andrew B. (@AndrianBalanescu) | 4 |
| 23 | NoxzRCW | 4 |
| 24 | opensource-elearning | 4 |
| 25 | Alvin T. Veroy (@alvinveroy) | 3 |
### ✨ New Features
- **feat(audio):** proxy native ElevenLabs voices, text-to-speech, and speech-to-text HTTP routes through stored OmniRoute credentials, preserving query strings, multipart uploads, binary responses, and upstream errors (#10556). ([#11434](https://github.com/diegosouzapw/OmniRoute/pull/11434)) — thanks @hartmark
- Added Google AI Studio Gemini batch text-to-speech support through `POST /v1/audio/speech`. ([#11434](https://github.com/diegosouzapw/OmniRoute/pull/11434)) — thanks @hartmark
- Run synchronous RTK and Caveman request compression in a bounded worker-thread pool, keeping large `/v1/responses` compression heaps outside the HTTP isolate while preserving strict fail-open behavior and per-engine telemetry. ([#11434](https://github.com/diegosouzapw/OmniRoute/pull/11434)) — thanks @hartmark
- **feat(routing):** subscription-first auto groupings — `auto/subscription` routes only through plan-included connections with a documented hard-stop overage and fails closed on exhaustion, while `auto/thrifty` orders the pool `subscription → keyless → free → cheap → premium` and steps up one rung at a time as each is exhausted. Billing class comes from a curated per-connection catalog (uncurated is treated as metered, never plan-included), both reuse STRICT_ZERO_COST's per-connection verification, and a quota reading whose `resetAt` has passed is now refreshed regardless of TTL so routing returns to plan capacity as soon as it resets ([#11146](https://github.com/diegosouzapw/OmniRoute/pull/11146)) — thanks @yourspraveen
- **feat(providers):** publish a management-authenticated versioned web-session credential contract from OmniRoute's canonical browser credential metadata ([#11340](https://github.com/diegosouzapw/OmniRoute/pull/11340)) — thanks @Zartharas
- **feat(video bridge):** harden the optional drill-down cache substrate with exact-path broker policy, canonical principal/session/media isolation, independent retained-byte quotas, cancellation-safe commits, rejection of excess or non-canonical Base64 padding and non-JPEG/truncated media, warning-sensitive full JPEG canonicalization that strips trailing polyglot bytes, server-derived dimensions, and auditable derivation metadata; production tenant binding and multi-resolution selection remain follow-up work ([#11369](https://github.com/diegosouzapw/OmniRoute/pull/11369)) ([#11434](https://github.com/diegosouzapw/OmniRoute/pull/11434)) — thanks @hartmark
- **feat(search):** Add Xquik X search with typed results, credential validation, REST routing, and MCP selection ([#11370](https://github.com/diegosouzapw/OmniRoute/pull/11370)) — thanks @kriptoburak
- **feat(video):** add an opt-in focused analysis mode that safely uses a normalized, 500-code-point latest-user hint for task-aware frame captions while preserving full-mode prompts, temporal-window isolation, and cache identity without storing raw task text ([#11383](https://github.com/diegosouzapw/OmniRoute/pull/11383)). ([#11434](https://github.com/diegosouzapw/OmniRoute/pull/11434)) — thanks @hartmark
- **feat(dashboard):** surface durable exclusive managed leases in the existing Sessions view, keeping leased clients visible across idle gaps while marking connections with in-flight work as active ([#11389](https://github.com/diegosouzapw/OmniRoute/pull/11389)) — thanks @KaspaPulse
- **build(bun):** allow Turbopack bundler flag on Bun 1.4+ with configurable Webpack fallback ([#11471](https://github.com/diegosouzapw/OmniRoute/pull/11471)) — thanks @TheDemonTuan
- feat(api): add an opt-in `modelVisibilityAllowlist`/`modelVisibilityDenylist` settings pair to curate exactly which models `/v1/models` advertises, mirrored into every `auto/*` combo candidate pool so a denied model cannot be routed to via combo selection either (#11481) ([#11997](https://github.com/diegosouzapw/OmniRoute/pull/11997))
- **feat(rankings):** the Free Provider Rankings page shows what each provider actually served over the last 24 h. It ranked by ELO alone, which left a provider that answers every call with an error in first place; the usage data was already served by the API but never requested. A provider with too small a sample shows a dash, not a number ([#11546](https://github.com/diegosouzapw/OmniRoute/pull/11546)) ([#11553](https://github.com/diegosouzapw/OmniRoute/pull/11553)) — thanks @maxmad64bis
- **feat(guardrails):** enforce a bounded, deterministic contract for Video Bridge transcripts — 256 cues, 4096 input code units and 4 KiB UTF-8 per cue, 64 KiB total text, malformed-Unicode rejection, focus-window scoping, cross-source reconciliation with contributing-source metadata, and a structural provenance trust boundary so caller JSON can never self-assert `embedded`/`audio-bridge` provenance ([#11652](https://github.com/diegosouzapw/OmniRoute/issues/11652)) ([#12009](https://github.com/diegosouzapw/OmniRoute/pull/12009))
- **feat(video):** orchestrate optional Video Bridge audio extraction and Audio Bridge STT behind a dual opt-in (operator setting AND per-request signal) — a new loopback-only broker `mode=audio` operation shares the frame path's exact process queue, deadline, AbortSignal, and byte budgets to extract a bounded mono 16 kHz PCM WAV from the same already-downloaded video, then reuses the existing Audio Bridge transcription boundary; provider segment timing is preserved when available and marked coarse otherwise, and every failure degrades to a visual-only-safe partial instead of throwing (#11654). ([#12012](https://github.com/diegosouzapw/OmniRoute/pull/12012))
- Add a tenant-bound Video Bridge drill-down lifecycle on top of the existing secure cache substrate: opaque hashed handles (never raw session/video identifiers), preview/standard/detail multiresolution variants resampled on read, response pagination capped at 8 frames and 32 MiB, and a new authenticated `/api/v1/video-bridge/drilldown` consumer route that stays disabled for remote access by default and denies cross-key access with the same response as a nonexistent handle (no existence oracle). ([#12006](https://github.com/diegosouzapw/OmniRoute/pull/12006))
- **test(video):** Add the Video Bridge FU-07/FU-09 promotion-evidence harness (#11656) — a frozen Zod manifest schema covering the 8 required scenario kinds (static scenes, rapid cuts, late facts, fades, blur, small text, close events, visual prompt injection) with a minimum of 3 repetitions per case, deterministic declarative fixture recipes (`videoBridgePromotionFixtures.ts`), a pure medians/p95 metrics aggregator, a pure FU-07/FU-09 promotion-verdict evaluator applying the ticket's exact thresholds (missing token usage always holds), a digest-only persistence layer that never retains raw media or raw model responses, and a versioned per-model promotion allowlist shipped empty with every model defaulting to `hold`. The FU-07/FU-09 promotion verdicts themselves remain HOLD — they require a real evidence run against real models on VPS 192.168.0.15. ([#12008](https://github.com/diegosouzapw/OmniRoute/pull/12008))
- **feat(video bridge):** "embedded" transcript provenance can now be legitimately earned instead of merely asserted — a bounded, allowlisted (`mov_text`/`subrip`/`webvtt`) subtitle probe runs through the loopback-only Video Bridge broker (at most 2 streams, 10s subdeadline bounded by the request deadline, 256 KiB output, 4096-code-unit lines), normalized through a bounded, ReDoS-safe WebVTT parser and Zod-validated end to end. The adapter always resolves to an explicit `success`/`absent`/`transient_failure` outcome — a subtitle failure never breaks the visual description path, and only a fingerprint-verified broker response (never a caller-declared label) can produce embedded cues (#11659). ([#12011](https://github.com/diegosouzapw/OmniRoute/pull/12011))
- Default new Antigravity-family connections (agy CLI imports and Antigravity OAuth connects) to model auto-sync, so live model discovery lands in the synced catalog and `/v1/models` picks up freshly released upstream models (e.g. Gemini 3.7 Flash tiers) without code changes. Existing connections keep their current setting; the per-connection dashboard toggle remains the opt-out. (#11685 — thanks @MumuTW) ([#11887](https://github.com/diegosouzapw/OmniRoute/pull/11887))
- **feat(ui):** enable React Compiler (`reactCompiler: true` + `babel-plugin-react-compiler`) for automatic memoization at build time ([#11783](https://github.com/diegosouzapw/OmniRoute/pull/11783)) — thanks @jonlwheat2-gif
- **feat(providers):** add SeekAi (`seekai.cc`) as an OpenAI-compatible New-API gateway — catalog id `seekai` (alias `ska`), `https://seekai.cc/v1`, live `/v1/models` via `passthroughModels`, aggregator-list membership so New-API balance detection can opt in. No referral/aff codes. ([#11786](https://github.com/diegosouzapw/OmniRoute/issues/11786)) ([#12557](https://github.com/diegosouzapw/OmniRoute/pull/12557)) — thanks @HouMinXi
- **feat(zai):** add GLM-5.3-Flash Coding Plan support (1M context, 128K output, vision, `low|high|max` reasoning) and route `zai` GLM-5.3-family API-key traffic through the OpenAI-compatible Coding Plan endpoint with native thinking defaults ([#11801](https://github.com/diegosouzapw/OmniRoute/pull/11801)) — thanks @Neuron-Mr-White
- **feat(combo):** choose how combo models are ordered — manual, provider, score, or name — via a sort control in the dashboard builder, persisted in `config.modelSort` and re-applied on load and after add ([#11812](https://github.com/diegosouzapw/OmniRoute/pull/11812)) — thanks @maxmad64bis
- **feat(free):** custom models can be marked free-tier via `customModels[].isFree`; `isFreeModel()` is the first door and `hidePaidModels` respects it even for providers outside the free budget ([#11843](https://github.com/diegosouzapw/OmniRoute/pull/11843)) — thanks @maxmad64bis
- **feat(nodejs):** add `5dive` as a `configure` target — `omniroute configure 5dive` / `omniroute setup-5dive` write a 5dive auth profile that points an agent fleet's `claude` seats at OmniRoute, with the root-only write, the loopback-vs-`https` endpoint rule and the per-seat model pin handled explicitly ([#11852](https://github.com/diegosouzapw/OmniRoute/pull/11852)) — thanks @5dive-bot
- **feat(sse):** treat `max` as a first-class reasoning-effort tier and clamp per model family (GLM 5.1+/DeepSeek V4+/Kimi K3+ keep native `max`; o1/MiniMax/Grok/Muse Spark clamp to their upstream ceiling) ([#11875](https://github.com/diegosouzapw/OmniRoute/pull/11875)) — thanks @Chewji9875
- **feat(providers):** the provider plugin manifest now advertises a `usage-fetch` capability for the 40 providers that have a wired usage/quota fetcher, so external dashboards can read it from `GET /api/v1/provider-plugin-manifest` instead of parsing `open-sse/services/usage.ts` after every release. Discovery only — no new fetcher, no quota change, and the Dashboard quota widget stays gated by `USAGE_SUPPORTED_PROVIDERS`. `USAGE_FETCHER_PROVIDERS` moved to a zero-dependency leaf (`open-sse/services/usage/fetcherProviders.ts`) and is re-exported from `services/usage.ts`, keeping the manifest module a light leaf instead of pulling the ~490-module usage dispatcher into the manifest route. ([#11903](https://github.com/diegosouzapw/OmniRoute/pull/11903)) — thanks @maxmad64bis / @pacocartones
- **feat(plugins):** `OMNIROUTE_PLUGINS_DIR` sets the directory the runtime plugin scanner reads — and the root the plugin manager installs into — overriding the `HOME`-derived default, so a Docker/K8s deployment can point straight at its bind-mounted plugin tree instead of moving `HOME` just to relocate the scan path. An image that exports no home no longer scans `/tmp/.omniroute/plugins` in silence: the resolved directory is logged once at startup as `scanner.dir_resolved`, naming the input that won. Unset, behaviour is unchanged. Distinct from the CLI-only `OMNIROUTE_PLUGIN_PATH`, which finds `omniroute-cmd-*` command packages and never reached this scanner ([#11906](https://github.com/diegosouzapw/OmniRoute/pull/11906)) — thanks @amaleta / @pacocartones
- **feat(leases):** add an explicit owner-authenticated status action that returns only the active lease's privacy-safe configured connection and provider labels, with generation fencing and no credential or internal-id disclosure ([#11910](https://github.com/diegosouzapw/OmniRoute/pull/11910)) — thanks @KaspaPulse
- **feat(providers):** import-from-file modal shows per-row API errors and ships a downloadable CSV template ([#12071](https://github.com/diegosouzapw/OmniRoute/issues/12071)) ([#12504](https://github.com/diegosouzapw/OmniRoute/pull/12504)) — thanks @HouMinXi
- **feat(providers):** dashboard search matches connection name and `baseUrl` so imported OpenAI-compat nodes surface on the provider card ([#12108](https://github.com/diegosouzapw/OmniRoute/issues/12108)) ([#12495](https://github.com/diegosouzapw/OmniRoute/pull/12495)) — thanks @HouMinXi
- **feat(routing):** add a `score` Auto router strategy that selects the highest configured weighted score and reuses `explorationRate`. ([#12155](https://github.com/diegosouzapw/OmniRoute/pull/12155)) — thanks @jacobsparts
- Add a runtime feature flag to disable universal context handoffs globally without changing the default behavior. ([#12167](https://github.com/diegosouzapw/OmniRoute/pull/12167)) — thanks @jacobsparts
- **feat(providers):** the provider plugin manifest now also advertises a `usage-supported` capability for the 46 providers whose usage API is accepted by the server and Dashboard routes, so integrators can distinguish "the server will serve quota for this provider" from "a fetcher is wired" without reading TypeScript. Discovery only — no fetcher or quota change. `usage-fetch` resolves on id or alias (the usage dispatcher accepts both); `usage-supported` resolves on id alone, matching the runtime guard `USAGE_SUPPORTED_PROVIDERS.includes(providerId)`. `USAGE_SUPPORTED_PROVIDERS` moved to a zero-dependency leaf (`open-sse/services/usage/supportedProviders.ts`) and is re-exported from `providers.ts`, mirroring the `fetcherProviders` leaf from #11903 and keeping the manifest a light module. ([#12214](https://github.com/diegosouzapw/OmniRoute/pull/12214)) — thanks @maxmad64bis
- **feat(rankings):** order Free Provider Rankings by what each provider actually served — `GET /api/free-provider-rankings?sortBy=reliability` and a "Most reliable first" toggle on the page. Providers with too few calls to state a success rate keep their score order below the measured ones; the default order is unchanged ([#12218](https://github.com/diegosouzapw/OmniRoute/pull/12218)). — thanks @maxmad64bis
- **perf(sse):** defer `cloneLogPayload()` in the structured SSE collector until after the `maxEvents`/`maxBytes` cap check, eliminating ~9,800 wasted `structuredClone` calls per streaming response (6571% faster `push()`). Reducer snapshot isolation restored for OpenAI and Responses summaries ([#12241](https://github.com/diegosouzapw/OmniRoute/pull/12241)) — thanks @PauloHSOliveira ([#12243](https://github.com/diegosouzapw/OmniRoute/pull/12243))
- **feat(usage):** Devin CLI agentic quota (Codeium seat-management GetUserStatus) and OpenRouter key limits plus account credits now surface in Provider Limits ([#12256](https://github.com/diegosouzapw/OmniRoute/pull/12256) — thanks @Neuron-Mr-White)
- **feat(settings):** persist `headroomUrl` through Settings so status/start use the operator URL instead of only `HEADROOM_URL` ([#12306](https://github.com/diegosouzapw/OmniRoute/issues/12306)) ([#12487](https://github.com/diegosouzapw/OmniRoute/pull/12487)) — thanks @HouMinXi
- **feat(auto-combo):** Auto-Combo scoring can now weigh how often a provider/model has actually succeeded. The engine already carried that number on every candidate — 24 hours of usage history behind a ten-sample floor, real-time metrics otherwise — and the scoring function never read it, while the weight table described `stability` as if it did. `reliability` (`1 - failureRate`, with the same field precedence and the same rate-bounding the speed ranking already uses, so a corrupt reading means "nothing observed" rather than "fails every call") is now a declared factor shipping at weight `0`, so routing is unchanged until an operator gives it one, and the `stability` description now matches what that factor computes ([#12317](https://github.com/diegosouzapw/OmniRoute/pull/12317)) — thanks @maxmad64bis
- **feat(routing):** With `freeAccessPolicy: "strict"`, the read-only candidate listing (`GET /v1/auto-combo/{channel}/candidates`) no longer hides the candidates the zero-cost guard excludes — the same read-only transparency the resilience filter already honours (#9133). Each candidate now carries `freeAccessExclusion` saying why it would be kept out, and it tells an exhausted allowance apart from a quota reading that never arrived or went stale, which used to look identical from the outside. Routing is unchanged: the listing reports, it never enforces. The separate `excludeTosAvoid` guard still drops its candidates without a reason; that gap is now documented rather than closed ([#12319](https://github.com/diegosouzapw/OmniRoute/pull/12319)) — thanks @maxmad64bis
- **feat(radar):** The Radar catalog table now shows two facts it was already receiving from the feed and dropping on the floor: the per-model rate limits (requests and tokens, per minute and per day) in a new column, and a badge when a provider's terms state it may train on the prompts you send. A limit of zero renders as zero rather than "rate-only" — for a ceiling those are opposite facts — and a model with no training statement gets no badge, because an absent statement is not a guarantee ([#12320](https://github.com/diegosouzapw/OmniRoute/pull/12320)) — thanks @maxmad64bis
- **feat(radar):** explain Community, single-use, contributor, supporter, recovery, abuse, offers, and privacy rules before either Radar activation action, and remove the superseded fixed-PR grant promise from every UI locale ([#12342](https://github.com/diegosouzapw/OmniRoute/pull/12342))
- **feat(gamification):** the dashboard Profile page now shows the real daily streak — `/api/gamification/level` returns `streak: { current, longest }` (per key with `apiKeyId`, operator-wide maximum otherwise) and the streak card reads it instead of a hard-coded 0 (#2403) ([#12377](https://github.com/diegosouzapw/OmniRoute/pull/12377)) — thanks @pacocartones
- **feat(gamification):** the dashboard leaderboard now shows each API key's display name under the Name column instead of a truncated key id; `GET /api/gamification/leaderboard` attaches `name` per entry (name only — no key material), while the shared ranking helper and the federation leaderboard stay id-only — thanks @pacocartones ([#12385](https://github.com/diegosouzapw/OmniRoute/pull/12385))
- **feat(gamification):** enforce the documented 1000 XP/min per-API-key anti-cheat rate limit on the XP award path; over-limit awards are logged and skipped instead of persisted, and the sliding window now matches the timestamp format stored in `xp_audit_log` ([#2403](https://github.com/diegosouzapw/OmniRoute/issues/2403)) ([#12390](https://github.com/diegosouzapw/OmniRoute/pull/12390)) — thanks @pacocartones
- **feat(admin):** localize the gamification anomalies page — the loading state, the Status column and the Suspicious badge now come from the `common` catalog (new `common.suspicious` key propagated to every locale) — add it to the Gamification sidebar group as `gamification-admin` (`/dashboard/gamification/admin`), and expose the loading and empty states as polite `role="status"` live regions (#12401 — thanks @pacocartones)
- **feat(providers):** skip GitHub combo members missing from the live synced catalog, and drop Copilot models that are policy-disabled or hidden from the model picker ([#12473](https://github.com/diegosouzapw/OmniRoute/pull/12473)) — thanks @RaviTharuma
- **feat(i18n):** locale-expansion tooling — `npm run i18n:add-locale` adds a language to config, dashboard, docs mirrors, CLI, README/indexes and site in one command; browser, CLI and cookie detection resolve `uk`, `fil`/`tl`, `zh-Hant` (and the retired legacy `in``id`) via config aliases; `config/i18n.json` now ships in the npm package so the published CLI can read it; new real-translation ratio gate (`npm run i18n:check-ratio`, advisory) with a per-locale ratchet baseline; `run-translation --adopt` restores `.i18n-state.json`; `sync-language-bars` generates 🌐 bars from config; `validate_translation.py` loads its allowlist again. Retires the duplicate `in` locale (Indonesian mislabelled as Hindi) — 42 honest locales; saved `NEXT_LOCALE=in` / `OMNIROUTE_LANG=in` keep working. (#12496)
- **feat(api):** Emit gateway-measured `tokens_per_second` (TTFT excluded) on streaming usage and `X-OmniRoute-Tokens-Per-Second` when first-token latency is known ([#12616](https://github.com/diegosouzapw/OmniRoute/issues/12616)) ([#12631](https://github.com/diegosouzapw/OmniRoute/pull/12631)) — thanks @RaviTharuma
- **feat(opencode-plugin): map gateway cost/usage/tok/s onto OpenCode inference payloads** — the official plugin copies `X-OmniRoute-Response-Cost`, token counts, `X-OmniRoute-Tokens-Per-Second` / `usage.tokens_per_second`, TTFT, and the winning `X-OmniRoute-Model` onto the JSON/SSE body OpenCode already consumes. Missing tok/s is left unset (never `tokens / latency`). (#12636) — thanks @RaviTharuma
- **feat(combo):** add combo strategy `quota-weighted`: skip exhausted accounts, then weighted-draw by leftover / in-flight load; existing conversations stay pinned until the account is empty ([#12789](https://github.com/diegosouzapw/OmniRoute/pull/12789)) — thanks @HouMinXi
- **feat(models):** Account live listings become the chat catalog source for Claude, Codex, Copilot, and AGY; public metadata only fills prices on IDs those accounts already list. ([#12866](https://github.com/diegosouzapw/OmniRoute/pull/12866)) — thanks @HouMinXi
- **fix(models):** Union `agy` and `antigravity` live catalogs so `agy/gemini-3.8-flash-high` is not rejected after the prefix folds to `antigravity`. ([#12866](https://github.com/diegosouzapw/OmniRoute/pull/12866)) — thanks @HouMinXi
- **feat(opencode):** Add `@omniroute/opencode-plugin-v2`, an opencode v2 plugin that gives a v2 install a route to an OmniRoute gateway: it publishes models, combos and auto-combos into the host catalog, refreshes them lazily behind a 300s TTL, and keeps serving the last known catalog from an on-disk snapshot when the gateway is unreachable. The v1 plugin (`@omniroute/opencode-plugin`) is untouched and keeps working as before. The plugin adapts to the host instead of assuming one shape: it reads the shape the host seeds into the catalog draft and publishes the `api` block, the pre-`api` top-level fields, or both. The gateway key comes from the host credential store when one is connected, so no secret needs to sit in a config file; a source the gateway refuses is named in a warning instead of silently degrading the catalog to raw model ids; and tool schemas bound for a Gemini model are stripped of the JSON-Schema keywords Gemini rejects, so tool calling survives that route ([#12870](https://github.com/diegosouzapw/OmniRoute/pull/12870)) — thanks @maxmad64bis
- feat(services): show sanitized CLIProxyAPI account health from its authenticated management API without exposing credentials, file paths, or raw account metadata (#6342) ([#11434](https://github.com/diegosouzapw/OmniRoute/pull/11434)) — thanks @hartmark
- **feat(search):** Add AnySearch free web search + URL extract (webFetch) with typed results, credential validation, REST routing, and MCP selection - fallback-only ([#11690](https://github.com/diegosouzapw/OmniRoute/pull/11690)) — thanks @Xxx91n
- **feat(dashboard):** display clamped `[0, 100]%` cached input token ratio in request logs table ([#11970](https://github.com/diegosouzapw/OmniRoute/pull/11970)) ([#11970](https://github.com/diegosouzapw/OmniRoute/pull/11970)) — thanks @b3nw
- **feat(catalog):** add `OMNIROUTE_DISABLE_THINKING_LEVEL_VARIANTS` feature flag to optionally filter out thinking level variants from model catalog ([#11971](https://github.com/diegosouzapw/OmniRoute/pull/11971)) ([#11971](https://github.com/diegosouzapw/OmniRoute/pull/11971)) — thanks @b3nw
- **feat(dashboard):** continuously export call logs to external analytics stores. A pluggable destination registry ships the full Logs-tab record set on an hourly `JobRegistry` cron, with a persisted per-destination cursor, batched inserts, a config UI rendered from each destination's own field descriptors, and a REST layer (`/api/log-export/*`) for CRUD, a connection test, and an on-demand run. A destination can opt into `includeBodies` to also ship the request and response payloads shown in the Logs detail pane, including the client and provider views of each call; this is off by default, and payloads inherit the dashboard's PII sanitisation, secret redaction and `noLog` handling. Google BigQuery is the first destination, using a service-account key stored encrypted at rest and streaming inserts keyed by call-log id, into a table that is day-partitioned on `timestamp` and clustered on `api_key_name`, `provider`, `model` and `status`. ([#11945](https://github.com/diegosouzapw/OmniRoute/pull/11945)) — thanks @dpozimski
- **feat(providers):** add MaxAI as a signed, OpenAI-compatible provider serving its 13 paid chat models (GPT-5.6 / Luna / Thinking, Claude 5 Sonnet, Claude Haiku 4.5, Gemini 3.1 Pro / Flash-Lite, Grok 4.1-fast / 4.5, DeepSeek V3.2 / R1, Llama 3.3 70B) through OmniRoute's `/v1` endpoint, with per-request HMAC-SHA1→SM3→AES request signing, live model + context-window discovery from `/models/get_config`, and prompted tool-calling translated to OpenAI `tool_calls` ([#11461](https://github.com/diegosouzapw/OmniRoute/pull/11461)) — thanks @arminanton
- **feat(providers):** MaxAI vision input — image_url content parts are forwarded inline in `message_content` to the 6 vision-capable models (GPT-5.6 / Luna / Thinking, Claude Haiku 4.5, Gemini 3.1 Pro / Flash-Lite) ([#11461](https://github.com/diegosouzapw/OmniRoute/pull/11461)) — thanks @arminanton
- **feat(providers):** MaxAI image generation — 6 image models (gpt-image-1, dall-e-3, flux-1-schnell/dev/pro, sd3-medium) exposed through `POST /v1/images/generations` ([#11461](https://github.com/diegosouzapw/OmniRoute/pull/11461)) — thanks @arminanton
- **feat(providers):** MaxAI document RAG — inline base64 file/document attachments are uploaded to MaxAI (content-addressed `doc_id`) and attached to the chat via `doc_list` ([#11461](https://github.com/diegosouzapw/OmniRoute/pull/11461)) — thanks @arminanton
- **feat(providers):** browserless MaxAI onboarding — email device-pair login (`/api/providers/[id]/login`) and signed access-token refresh, so a connection can be created and kept fresh without a real browser or Google OAuth ([#11461](https://github.com/diegosouzapw/OmniRoute/pull/11461)) — thanks @arminanton
- **feat(providers):** per-provider TLS impersonation profile (MaxAI presents a Windows Firefox-150 client fingerprint) so its bot-sensitive endpoints accept OmniRoute traffic ([#11461](https://github.com/diegosouzapw/OmniRoute/pull/11461)) — thanks @arminanton
- **feat(providers):** add **Nimble** as a web-search and web-fetch provider (`nimble-search`) — `/v1/search` routes to Nimble's search API at `lite` depth (locale, freshness and include/exclude domain filters mapped onto the shared request contract), and `/v1/web/fetch` routes to Nimble Extract, which covers all four fetch formats (`markdown`, `html`, `links`, `screenshot`) from a single call. One API key serves both surfaces. ([#11620](https://github.com/diegosouzapw/OmniRoute/pull/11620)) — thanks @wildcard
- The npm publish is automatic again, through npm Trusted Publishing (OIDC): the hosted `stage-npm` job publishes with a short-lived credential minted from GitHub's id-token — no `NPM_TOKEN`, no 2FA prompt, provenance attached. `publish_mode=staged` (owner approves with 2FA) and `direct` (token) remain available on `workflow_dispatch`. ([#11931](https://github.com/diegosouzapw/OmniRoute/pull/11931))
- **feat(providers):** Add **Opper** as an API-key gateway provider — EU-hosted AI gateway with 700+ models from 30+ providers behind one OpenAI-compatible API and one key (`OPPER_API_KEY`); model ids use `provider/model` format (e.g. `anthropic/claude-sonnet-4-6`, `openai/gpt-5`); live model catalog at `https://api.opper.ai/v3/compat/models`; entry mirrors `requesty` (same shape, `passthroughModels: true`, no static seed) ([#11629](https://github.com/diegosouzapw/OmniRoute/pull/11629))
- **feat(dashboard):** the `/dashboard/orchestration` snapshot hook now subscribes to the `agents` WebSocket channel (`agent.task.updated`) instead of `requests` as its refetch trigger, and relaxes its background poll from 5s to 30s while that WS connection is up — falling back to the tighter 5s cadence, reprogrammed live on any connect/disconnect transition, whenever the socket is down. ([#12409](https://github.com/diegosouzapw/OmniRoute/pull/12409))
- **feat(dashboard):** Orchestration canvas quick wins — search box plus state/source/provider filter chips with a one-click clear, and per-source collapse/expand, all reflected in the URL so a filtered/collapsed view is shareable and survives a refresh; the detail drawer gained a "copy trace JSON" action and hardened error/empty-state and accessibility handling; the Agents-tab edges now animate traveling particles along active (running) connections; and the canvas node/edge status colors moved off fixed hex values onto theme-aware `--orch-status-*` CSS custom properties, so they adapt correctly to light/dark mode. ([#12393](https://github.com/diegosouzapw/OmniRoute/pull/12393))
- **chore(dashboard):** Orchestration UI hardening pass and the missing component/model test coverage it called for — `OrchestratorNode`/`ActivityNode`/`OverflowNode` rendering, the `?node=`/overflow-click page routing, the Agents-tab orchestrator-click no-op and `showCompleted` toggle, and the overview kanban's done-column sort order (#12270, #12271). ([#12393](https://github.com/diegosouzapw/OmniRoute/pull/12393))
- New `/dashboard/orchestration` page: live unified view of everything running — Cloud Agent, A2A and Conductor as a real-time graph (Agents tab), the combo cascade (Routing tab, reusing the Combo Live Studio) and a state kanban (Overview tab), with a detail drawer (trace, cost, approve/cancel). Read-only over existing APIs — no new backend. Canvas concept credit: PR #11815 design ([#12261](https://github.com/diegosouzapw/OmniRoute/pull/12261))
- **feat(dashboard):** new "History" tab on `/dashboard/orchestration` — an Airflow-style grid of finished runs over a 24h/7d/30d preset window, one row per (source, identity), clicking a cell opens the existing detail drawer. It is backed by real persistence: A2A task lifecycle transitions are now written to the `a2a_tasks` table (purged after 30 days, configurable via `OMNIROUTE_A2A_HISTORY_RETENTION_DAYS`) and served by the new `GET /api/a2a/tasks/history` listing endpoint, with the task-detail route falling back to persisted history once a run leaves the in-memory snapshot. Conductor runs stay remote and are not persisted locally — the tab says so instead of silently omitting them. ([#12479](https://github.com/diegosouzapw/OmniRoute/pull/12479))
- **feat(dashboard):** the orchestration detail drawer gained a "Repeat" action for Cloud Agent, A2A and Conductor tasks — a two-click confirm (click once to arm, click again within the confirm window to fire) re-submits the original prompt/input as a new run. The button is disabled with an explanatory tooltip whenever the original input can't be recovered from the loaded task detail (e.g. it never carried a prompt, or the detail failed to load). Two limitations of the A2A variant, by design: it targets the `/a2a` JSON-RPC endpoint, which authenticates with an API key only (`REQUIRE_API_KEY=true` or a configured `OMNIROUTE_API_KEY` makes a dashboard-session repeat answer `HTTP 400` — surfaced verbatim in the drawer's error line, never as a success), and the `message/send` call is SYNCHRONOUS: the POST blocks for the whole skill run, so the success confirmation only appears once the repeated task finishes. A dashboard-authenticated A2A creation path is deliberately left to a follow-up — widening the endpoint's auth posture is an operator decision, not a side effect of this feature. ([#12508](https://github.com/diegosouzapw/OmniRoute/pull/12508))
- **feat(a2a):** A2A task execution now records which memories were consulted for the task's last user message as `metadata.memoryHits` (id/key/type/content-snippet) plus a `memory_hits` history event, purely for observability — the retrieved memory is never injected into a skill's prompt or behavior. Gated by the `OMNIROUTE_A2A_MEMORY_HITS` kill-switch (default enabled; set to `0` to skip the recall lookup entirely). The drawer's new "Memory used" section lists these hits for a2a tasks and is omitted whenever there are none. Known limitation: recall only resolves under the keyless posture — a keyed caller's task owner is a SHA-256 prefix of the API key, while memory rows are keyed by the database api-key id, and no hash→id lookup exists today, so the hit list stays empty for keyed callers. The recorded hits are also kept out of the task's own `input` (and therefore out of the persisted input and of the "Repeat" request body), so repeating a task never re-sends the previous run's memory snippets. ([#12508](https://github.com/diegosouzapw/OmniRoute/pull/12508))
- **feat(providers):** add a Perplexity Agent API provider (`perplexity-agent` / `pplx-agent`) for Perplexity `/v1/responses`, including the documented Anthropic, OpenAI, Google, xAI, DeepSeek, Z.AI, Moonshot/Kimi, NVIDIA, and Perplexity model IDs plus Anthropic-model `max_output_tokens` compatibility. ([#12103](https://github.com/diegosouzapw/OmniRoute/pull/12103)) — thanks @AIB1TAL0S
- **feat(providers):** add RPD (Requests Per Day) limit to provider rate limit overrides across UI, schemas, DB, and i18n ([#12147](https://github.com/diegosouzapw/OmniRoute/pull/12147)) ([#12147](https://github.com/diegosouzapw/OmniRoute/pull/12147)) — thanks @b3nw
- **feat(providers): add UC Direct (uncensored.com Developer API), the metered OpenAI-compatible surface.** A standard OpenAI-compatible passthrough (default executor) for uncensored.com's official REST API at `https://api.uncensored.com/api/v1`: `X-api-key` auth (never-expiring `uai_sk_live_` key), `POST /chat/completions` with streaming SSE and native tool-calling, and the full live metered catalog (82 models across 15 providers, discovered from the public `GET /v1/models`). Registered as provider `uc-direct` (alias `ucd`). Complements the un-metered `uc` persona provider — same models, metered credits and a plain API key instead of a subscription session. ([#11513](https://github.com/diegosouzapw/OmniRoute/pull/11513)) — thanks @arminanton
- **feat(providers): add UC (uncensored.com), the un-metered subscription "persona" chat as an OpenAI-compatible provider.** A WebSocket web-app port: a durable Clerk credential mints a short-lived session token per connect (browserless — no API key), driving UC's persona socket. Ships the browserless email-code login (request → verify → harvest), the 19 verified persona models (Claude Opus, Gemini, Grok, GLM, Kimi, DeepSeek, MiniMax, incl. the uncensored variants), prompted `<tool>` tool-calling with a per-model code-style dialect + auto-cure retry for guardrailed models, live `<think>`/reasoning split, streaming + non-streaming OpenAI responses, and full quota/auth error surfacing (paywall / message-limit / rate-limit → 429, invalid session → 401 re-login). Full multimodal parity via the persona blob-upload layer: **vision** (image input, 15 vision-capable models), **document RAG** (PDF/doc upload, server-side extraction), **image generation** (22 models), **video generation** (14 models, async signed-url → poll), and **TTS** (streaming MP3). Registered as provider `uc` (alias `ucn`). The metered OpenAI-compatible Developer API is a separate `uc-direct` provider. ([#11513](https://github.com/diegosouzapw/OmniRoute/pull/11513)) — thanks @arminanton
- **feat(logs):** add copy-all button to request detail modal ([#11083](https://github.com/diegosouzapw/OmniRoute/pull/11083)) — thanks @AndrianBalanescu
- **feat(check):** make serviceKinds required and add the reverse-walk provider consistency gate ([#11392](https://github.com/diegosouzapw/OmniRoute/pull/11392)) — thanks @Tushar49
- **feat(providers):** only advertise verified-working connections by default ([#11448](https://github.com/diegosouzapw/OmniRoute/pull/11448)) — thanks @alltomatos
- **feat(resilience):** add hierarchical concurrency admission ([#11493](https://github.com/diegosouzapw/OmniRoute/pull/11493))
- **feat(providers):** derive + surface expiry for JWT-bearing web cookies (#11497) ([#11505](https://github.com/diegosouzapw/OmniRoute/pull/11505)) — thanks @oyi77
- **feat(tunnels):** support named cloudflared tunnels via CLOUDFLARED_CONFIG ([#11561](https://github.com/diegosouzapw/OmniRoute/pull/11561)) — thanks @ggiak
- **feat(compression):** make proactive context-compression threshold a live setting ([#11564](https://github.com/diegosouzapw/OmniRoute/pull/11564)) — thanks @ragnar-claude
- **feat(resilience):** add provider peak-hour protection ([#11622](https://github.com/diegosouzapw/OmniRoute/pull/11622)) — thanks @Neuron-Mr-White
- **feat(providers):** add 1min.ai provider ([#11631](https://github.com/diegosouzapw/OmniRoute/pull/11631)) — thanks @zero-executioner
- **feat(dashboard):** collapsible JSON tree viewer for request/response payloads ([#11703](https://github.com/diegosouzapw/OmniRoute/pull/11703)) — thanks @hartmark
- **feat(routing):** add LiquidAI LFM2.5-2.6B free tier via OpenRouter ([#11752](https://github.com/diegosouzapw/OmniRoute/pull/11752)) — thanks @brick30llc-ctrl
- **feat(pricing):** add GLM-5.3-Flash pricing, model specs, and catalog registration ([#11830](https://github.com/diegosouzapw/OmniRoute/pull/11830)) — thanks @AndrianBalanescu
- **feat(routing):** add relayMode for schema-locked context handoffs ([#11839](https://github.com/diegosouzapw/OmniRoute/pull/11839)) — thanks @raheemuddin786
- **feat(config):** add support for runtime system prompt configuration and hot-reloading ([#11841](https://github.com/diegosouzapw/OmniRoute/pull/11841)) — thanks @raheemuddin786
- **feat(routing):** expand connection-aware quota prefilter across combo strategies (#11682) ([#11850](https://github.com/diegosouzapw/OmniRoute/pull/11850)) — thanks @HouMinXi
- **feat(models):** reactive discovery sync after Antigravity model-not-found 404 ([#11888](https://github.com/diegosouzapw/OmniRoute/pull/11888)) — thanks @MumuTW
- **feat(models):** auto-aliases and token limits from synced Antigravity discovery ([#11889](https://github.com/diegosouzapw/OmniRoute/pull/11889)) — thanks @MumuTW
- **feat(settings):** raise sticky round-robin limit caps to 1000 ([#12015](https://github.com/diegosouzapw/OmniRoute/pull/12015)) — thanks @adivekar-utexas
- **feat(resilience):** operator-configurable global credential health check interval ([#12043](https://github.com/diegosouzapw/OmniRoute/pull/12043)) — thanks @adivekar-utexas
- **feat(quota):** use official OpenCode Go usage API ([#12124](https://github.com/diegosouzapw/OmniRoute/pull/12124)) — thanks @ddarkr
- **feat(resilience):** default the credential health check sweep to 60 minutes ([#12138](https://github.com/diegosouzapw/OmniRoute/pull/12138)) — thanks @adivekar-utexas
- **feat(quality):** new-code mode for the complexity and dead-code ratchets (Clean as You Code) ([#12142](https://github.com/diegosouzapw/OmniRoute/pull/12142))
- **feat(dashboard):** orchestration canvas — unified model + snapshot hook (part 1/2) ([#12156](https://github.com/diegosouzapw/OmniRoute/pull/12156))
- **feat(usage):** add Kilo Code balance and Kilo Pass quotas ([#12178](https://github.com/diegosouzapw/OmniRoute/pull/12178)) — thanks @killer30001000
- **feat(providers):** refresh vendored ChatGPT Web connector to v4.0.7 ([#12181](https://github.com/diegosouzapw/OmniRoute/pull/12181)) — thanks @backryun
- **feat(providers):** manual "Clear cooldown" action in the cooling panel ([#12224](https://github.com/diegosouzapw/OmniRoute/pull/12224)) — thanks @patrykkopycinski
- **feat(providers):** restore ChatGPT Web via clean-room browser transport ([#12239](https://github.com/diegosouzapw/OmniRoute/pull/12239)) — thanks @backryun
- **feat(sse):** wire the PROVIDER_PROFILES window gate into the global provider cooldown ([#12247](https://github.com/diegosouzapw/OmniRoute/pull/12247))
- **feat(providers):** modernize CLOVA Studio chat and embeddings ([#12277](https://github.com/diegosouzapw/OmniRoute/pull/12277)) — thanks @backryun
- **feat(browser):** adopt Obscura as primary headless browser engine with Chromium fallback ([#12286](https://github.com/diegosouzapw/OmniRoute/pull/12286)) — thanks @opensource-elearning
- **feat(sse):** full language parity for output styles (es/de/fr/it/ru/zh + autoDetect) ([#12289](https://github.com/diegosouzapw/OmniRoute/pull/12289))
- **feat(quality):** complete test:scoped — --full map rebuild, stdin selection, CI loader parity (#8084 D1) ([#12353](https://github.com/diegosouzapw/OmniRoute/pull/12353))
- **feat(providers):** refresh Fable, Cursor, and Devin catalogs ([#12367](https://github.com/diegosouzapw/OmniRoute/pull/12367)) — thanks @backryun
- **feat(video):** redact transcript text from logs and durable memory (#12150 P1) ([#12427](https://github.com/diegosouzapw/OmniRoute/pull/12427))
- **feat(dashboard):** parent-link, genuine-continuation badge, and modal perf fixes ([#12448](https://github.com/diegosouzapw/OmniRoute/pull/12448)) — thanks @hartmark
- **feat(dashboard):** adaptive context-budget dial on compression panel ([#12488](https://github.com/diegosouzapw/OmniRoute/pull/12488)) — thanks @HouMinXi
- **feat(providers):** refresh Z.ai Web models and browser transport ([#12524](https://github.com/diegosouzapw/OmniRoute/pull/12524)) — thanks @backryun
- **feat(video):** redact raw client-snapshot transcript fields in the detailed log (#12150 P2) ([#12528](https://github.com/diegosouzapw/OmniRoute/pull/12528))
- **feat(providers):** refresh NVIDIA hosted models ([#12538](https://github.com/diegosouzapw/OmniRoute/pull/12538)) — thanks @backryun
- **feat(quota):** Moonshot Open Platform balance and TPD lock for custom nodes ([#12590](https://github.com/diegosouzapw/OmniRoute/pull/12590)) — thanks @HouMinXi
- **feat(video):** redact transcript in the in-memory pending-request snapshot (#12430 item 6) ([#12596](https://github.com/diegosouzapw/OmniRoute/pull/12596))
- **feat(dashboard):** link a log entry's Conversation Context to its owning conversation ([#12646](https://github.com/diegosouzapw/OmniRoute/pull/12646)) — thanks @hartmark
- **feat(catalog):** eligibility-gated free-tier bucket ([#12669](https://github.com/diegosouzapw/OmniRoute/pull/12669))
- **feat(db):** fail-closed previous_response_id continuation for redacted video turns (#12150 P2b) ([#12707](https://github.com/diegosouzapw/OmniRoute/pull/12707))
### 🐛 Bug Fixes
- **security(streaming):** sanitize generic mid-stream error messages before emitting OpenAI,
Responses, or Claude SSE failure frames and before diagnostic logging, while preserving raw
failures for internal classification and keeping client disconnects out of provider failure state. ([#12457](https://github.com/diegosouzapw/OmniRoute/pull/12457))
- **fix(claude):** `blockExtraUsage: false` no longer lets 5h quota preflight skip the account; extra usage is billed after the session bar is gone, so the request must reach Anthropic ([#12803](https://github.com/diegosouzapw/OmniRoute/pull/12803)) — thanks @HouMinXi
- Sanitize HuggingChat conversation-creation and message-send transport failures before they reach client error bodies or provider logs. ([#12467](https://github.com/diegosouzapw/OmniRoute/pull/12467))
- **fix(dashboard):** Keep local and theme-aware provider SVG icons at a definite layout size so Chromium does not collapse them to 0×0 after the v3.8.50 image-rendering change ([#12054](https://github.com/diegosouzapw/OmniRoute/pull/12054)) — thanks @ponkcore
- **fix(security):** Sanitize provider and runtime failures before public API, SSE and MCP responses and before persistent request, proxy and usage logs, preventing credentials, stack traces and host filesystem paths from crossing those boundaries while preserving stable error codes and useful diagnostics. ([#12506](https://github.com/diegosouzapw/OmniRoute/pull/12506))
- **fix(cli):** `omniroute tunnel create` no longer crashes with `Cannot read properties of undefined (reading optsWithGlobals)` — removed the duplicate positional argument that caused Commander.js to misalign the action callback parameters ([#12295](https://github.com/diegosouzapw/OmniRoute/issues/12295)) ([#12368](https://github.com/diegosouzapw/OmniRoute/pull/12368)) — thanks @gonisulaimann
- Stop advertising Gemini Live-only models as supported audio endpoints until OmniRoute proxies the bidirectional Live protocol. ([#11710](https://github.com/diegosouzapw/OmniRoute/pull/11710)) — thanks @RaviTharuma
- **fix(kie):** reroute `flux/kontext` off the KIE Market `createTask` flow — it is catalogued with `isMarket: true` but has no Market catalog page, so KIE rejected it with "model name not supported"; it now hits the dedicated `POST /api/v1/flux/kontext/generate` / `GET /api/v1/flux/kontext/record-info` endpoints instead (#11296). ([#11985](https://github.com/diegosouzapw/OmniRoute/pull/11985)) — thanks @hartmark
- **fix(kie):** correct 12 more KIE Market catalog ids that were sent to `createTask` unchanged but diverge from KIE's documented upstream `model` values — GPT Image 2 T2I/I2I (drops the `gpt/` prefix), GPT Image 1.5 T2I/I2I (`gpt-image/` namespace), Seedream 5.0 Lite T2I/I2I (drops the `.0`), all 4 Flux 2 variants (`flux-2/` namespace, generic variant renamed `flex`), and Wan 2.7 Image / Image Pro (dash instead of dot) — each verified individually against the literal example request published on docs.kie.ai. `#11326`'s "everything else already matches" claim was wrong a second time (#11296); `z-image/4.0-*`/`z-image/4.5-*` and `flux/kontext` remain open, documented as unresolved in `KIE_MARKET_UPSTREAM_MODEL_IDS`'s comment pending further verification. ([#11667](https://github.com/diegosouzapw/OmniRoute/pull/11667))
- **fix(dashboard):** `useApiKeySave.handleSaveApiKey` no longer forces a full upstream `/models` catalog sync on every non-curated provider connection save — callers can now pass `skipModelSync: true` to opt out, so a workflow that only wants to add one manual model no longer floods the provider's available-models list with hundreds/thousands of synced entries. The flag is a client-side intent signal only and is stripped before the connection payload is POSTed to `/api/providers`; default behavior (full sync on save) is unchanged when the flag is omitted (#11324) ([#11664](https://github.com/diegosouzapw/OmniRoute/pull/11664))
- **Provider connections:** keep `tokenExpiresAt` when a connection is created. The create-path allowlist omitted it, so every insert stored NULL and the dashboard token badge could read a fresh connection as expired until its first background refresh ([#11368](https://github.com/diegosouzapw/OmniRoute/pull/11368)). — thanks @ntdatt812
- **fix(combos):** the combo builder's precision-select, global-model-search, and manual-entry flows now serialize a model step's `model` string using the provider's already-computed routing-alias prefix (e.g. `oc/`) instead of rebuilding it from the raw canonical `providerId`, fixing the no-auth "OpenCode Free" provider (`opencode`) being routed to the unrelated paid "OpenCode Zen" provider (`opencode-zen`) because `opencode` doubles as a manual routing-prefix override ([#11433](https://github.com/diegosouzapw/OmniRoute/issues/11433)). ([#11670](https://github.com/diegosouzapw/OmniRoute/pull/11670))
- **fix(radar):** the catalog feed cache now keeps `generatedAt`, the date the feed's data was built, next to `fetchedAt`, the date this install downloaded it (#11435). The feed schema requires that date and the sync path validates it, but the cache dropped it — so a feed fetched minutes ago and one carrying weeks-old figures looked identical to everything downstream, including the dashboard's "Last fetched" line. `getRadarCatalog().meta` and `GET /api/radar/status` now report both dates, the latter as its own field rather than folded into `version` — and omitted entirely for the offers and intel caches, which keep no build date, where a `null` would read as "unknown" rather than "never stored". The dashboard still shows only the fetch time; surfacing the build date there needs a new translated label and is left to a follow-up. Rows cached before migration 163 read back as `null`: unknown stays unknown instead of borrowing the fetch time. The referrals cache has persisted the same date since migration 142. — thanks @maxmad64bis
- **fix(cli):** postinstall no longer fills `JWT_SECRET` and `API_KEY_SECRET` in the installed package's `.env` (#11436). `.env.example` ships both blank on purpose: the server restores them from its durable store, or generates and persists them there, in `ensureSecrets()`. Pre-filling them defeated that — the file lives inside the package directory, so `npm i -g` replaced it and postinstall wrote _different_ values, while `ensureSecrets()` (which only acts on an empty variable) never got to restore the real ones. Both secrets rotated silently on every update, invalidating dashboard sessions and API-key CRCs. `STORAGE_ENCRYPTION_KEY` left the same list for the same reason in #1622; its comment pointed at a function that no longer exists and now names the real provisioning path. — thanks @maxmad64bis
- **fix(cli):** the CLI now says when a loaded `.env` lives inside the installed package directory (#11437). It already announces every env file it reads, without distinguishing the ones that survive an update from the one that does not: `npm i -g` replaces the package directory wholesale, so values set there are gone at the next update, silently. The warning names the durable path to move them to, and fires only when that file actually supplied a value — a file entirely shadowed by a durable one supplied nothing. A development checkout stays silent: there the same path is stable and documented in `SETUP_GUIDE.md`. — thanks @maxmad64bis
- **fix(free-models):** the shared `isFreeModel()` predicate no longer reports catalog entries marked `freeType: "discontinued"` as free, so `hidePaidModels` can't route to Pollinations' seven premium models that now require a paid key ([#11441](https://github.com/diegosouzapw/OmniRoute/pull/11441)) — thanks @maxmad64bis
- fix(ui): let AnySearch use the normal provider-icon fallback when LobeHub has no matching icon (#11449)
- Fixed the v3.8.50 Costs and Analytics dashboards so flat-rate Claude Code usage can be shown as an explicitly requested token-price estimate without changing default billed-cost semantics. ([#11460](https://github.com/diegosouzapw/OmniRoute/pull/11460)) — thanks @xiaoyaner0201
- Fixed archived usage retention so each request is priced individually instead of pricing a day's summed tokens once, which understated archived cost whenever a day mixed cache-heavy and ordinary requests. ([#11460](https://github.com/diegosouzapw/OmniRoute/pull/11460)) — thanks @xiaoyaner0201
- Fixed the Costs dashboard so it discloses when displayed figures include flat-rate token-price estimates instead of labelling them as billed spend, using the flag the analytics API already returns; the month-end projection and the CSV/JSON exports carry the same marker, and billed-cost mode is unchanged. ([#11460](https://github.com/diegosouzapw/OmniRoute/pull/11460)) — thanks @xiaoyaner0201
- **fix(combo):** attach the same combo-diagnostics trace (`poolSize`/`attemptOrder`/`excluded`/`terminalReason`, plus `x-omniroute-combo-*` headers) to the round-robin strategy's and the nested pipeline/fusion runtime-unit loop's "Maximum combo retry limit reached" 503 that the priority-strategy path already attaches for the identical terminal condition — previously those two paths returned a bare, contextless 503 ([#11462](https://github.com/diegosouzapw/OmniRoute/issues/11462)). ([#11665](https://github.com/diegosouzapw/OmniRoute/pull/11665))
- **fix(bun):** use native `bun:sqlite` in `bootstrap-env` and `sync-env` to avoid loading `better-sqlite3` N-API addon during Bun startup ([#11468](https://github.com/diegosouzapw/OmniRoute/pull/11468)) — thanks @TheDemonTuan
- **fix(docker-bun):** make `Dockerfile.bun` install reliable with pre-install script helpers and native `bun:sqlite` smoke check ([#11470](https://github.com/diegosouzapw/OmniRoute/pull/11470)) — thanks @TheDemonTuan
- **fix(docker-bun):** remove every vendored `better-sqlite3` native addon from the Bun runtime image so startup cannot abort before the `bun:sqlite` fallback ([#11482](https://github.com/diegosouzapw/OmniRoute/pull/11482)) — thanks @TheDemonTuan
- **fix(autoCombo):** effort/alias model variants (`gpt-5.6-sol-xhigh`, `gpt-5.6`, cursor's `claude-4.6-opus-high`) inherit their base model's task fitness instead of falling to the wildcard 0.5 ([#11492](https://github.com/diegosouzapw/OmniRoute/pull/11492)) — thanks @MumuTW
- fix(db): rate-limit repeated Arena ELO leaderboard fetch-failure warnings instead of logging one per sync attempt (#11500) ([#11989](https://github.com/diegosouzapw/OmniRoute/pull/11989)) — thanks @hartmark
- **fix(db):** dedupe the raw `[Encryption] Decryption failed...` log line emitted by the lazy-decrypt views (`createLazyRowProxy`/`createLazyConnectionView`), which power `getProviderConnections()` and were re-triggering that line on every CredentialHealth/model-sync cycle for the same corrupt or stale-key credential — a fresh Proxy over a fresh row on every cycle meant the per-proxy memoization never suppressed it, unlike the dedup `decryptConnectionFields()` already had since [#9927](https://github.com/diegosouzapw/OmniRoute/issues/9927). Now shares that dedupe tracking so the line logs at most once per credential ([#11500](https://github.com/diegosouzapw/OmniRoute/issues/11500)). ([#11678](https://github.com/diegosouzapw/OmniRoute/pull/11678))
- **fix(intelligence):** Arena ELO sync stores a synthesized base-model row for every effort/harness variant (`claude-opus-5-max``claude-opus-5`, `gpt-5.6-sol-xhigh (codex-harness)``gpt-5.6-sol`), so bare model requests reach the synced intelligence layer instead of the static fallback table; the hand-maintained `MODEL_ALIAS_MAP` — which collapsed generations (`gpt-5.5``gpt-5`) — is gone ([#11506](https://github.com/diegosouzapw/OmniRoute/pull/11506)) — thanks @MumuTW
- **fix(autoCombo,sse):** catalog hygiene — the static task-fitness table no longer ranks retired models above live ones (dead rows dropped, patterns matched on segment boundaries), the 7 deprecation aliases that forwarded to retired or non-existent models now point at the vendors' published replacements, an alias is no longer rewritten when the serving provider offers the id as-is, and a new `check:model-lifecycle` gate diffs both tables against a vendor lifecycle snapshot ([#11507](https://github.com/diegosouzapw/OmniRoute/pull/11507)) — thanks @MumuTW
- **fix(sse):** stop dropping resolved `thoughtSignature` values on parallel (multi tool-call) turns sent to Gemini 3.x — the claude→gemini and openai→gemini translators previously kept the signature only on the *first* function call of a message, causing Gemini to reject subsequent calls in the same turn with HTTP 400 "Function call is missing a thought_signature"; each function call now keeps its own resolved signature ([#11510](https://github.com/diegosouzapw/OmniRoute/issues/11510)). ([#11669](https://github.com/diegosouzapw/OmniRoute/pull/11669))
- Treat Docker publishing from a non-default release branch as an expected skip instead of a failed workflow, while preserving the guard that prevents it from overwriting the `next` channel. ([#11527](https://github.com/diegosouzapw/OmniRoute/pull/11527)) — thanks @pacocartones
- fix(search): prefer credentialed providers over duckduckgo-free fallback (#11524) — the fallback-only loop ran before the credentialed-providers loop in `executeWebSearch`, making configured providers unreachable when `duckduckgo-free` was available. ([#11565](https://github.com/diegosouzapw/OmniRoute/pull/11565)) — thanks @vsd2807
- **fix(sse):** cap the upstream headers-wait phase for STREAMING requests to a client-realistic ceiling (110s, under Codex's own ~120s hard client-abort window) instead of the flat 10-minute `FETCH_TIMEOUT_MS` default — that default was 5x longer than the body-phase readiness watchdog's own adaptive bound, so a request whose upstream never returned any response at all (not even headers, e.g. a stalled NVIDIA target behind a tool-heavy Responses→Chat translation) kept the client connection alive on keepalives only, guaranteeing the client's own patience ran out first with an opaque 499 instead of OmniRoute detecting and failing the stall fast. Non-streaming requests are unaffected — they keep the existing flat default (`open-sse/utils/fetchStartTimeoutPolicy.ts`) (#11526) ([#11668](https://github.com/diegosouzapw/OmniRoute/pull/11668))
- **fix(ci):** protocol-clients E2E harness boots the peer-stamped custom server (`run-next.mjs dev`) instead of the bare `next` CLI, so `LOCAL_ONLY` locality resolves from the real TCP peer stamp and `/api/mcp/audit` answers 200 (open bootstrap preserved via an `OMNIROUTE_E2E_BOOTSTRAP_MODE=open` credential clear in `run-next.mjs`, pinned to loopback `HOST`) instead of a deterministic 403 (#11535) ([#11549](https://github.com/diegosouzapw/OmniRoute/pull/11549)) — thanks @jonlwheat2-gif
- **fix(docs):** the Auto-Combo engine is described with the number of factors it actually declares. Four different counts were in circulation — 6, 9, 13 and 14 across `AGENTS.md`, four guides and six code comments — while `DEFAULT_WEIGHTS` declares 15. `check:docs-counts` now reads that number from the source, so adding a factor without updating the prose fails CI ([#11545](https://github.com/diegosouzapw/OmniRoute/pull/11545)) ([#11553](https://github.com/diegosouzapw/OmniRoute/pull/11553)) — thanks @maxmad64bis
- **fix(quota-share):** in-flight leases are per request, so an aborted request's slot ages out instead of being kept alive by later traffic on the same connection ([#11547](https://github.com/diegosouzapw/OmniRoute/pull/11547)) — thanks @abhisheksharma2411
- The free-tier summary route now serves the refreshed Radar catalog when the feed is active — the same numbers the dashboard shows — and states which catalog answered plus its real build date, instead of always reporting release-frozen figures with a stale curation date. The supporter-key live feed stays reserved to authenticated callers of the instance. ([#11550](https://github.com/diegosouzapw/OmniRoute/pull/11550)) — thanks @maxmad64bis
- **sse:** fix LiveWS/embed-WS servers crashing at startup under the Node/tsx runtime — `liveServer.ts`, `embedWsProxy.ts` and `apiBridgeServer.ts` imported `@/shared/utils/httpClientAbortGuard` without the `.mjs` extension, so the client-abort crash guard added by [#11556](https://github.com/diegosouzapw/OmniRoute/pull/11556) was unreachable and every dependent test failed with `ERR_MODULE_NOT_FOUND` ([#11556](https://github.com/diegosouzapw/OmniRoute/pull/11556)). ([#11671](https://github.com/diegosouzapw/OmniRoute/pull/11671)) — thanks @jonlwheat2-gif / @yxyxy
- fix(chat): shed any of the matched provider-node's routing identifiers (public prefix or internal id) before model lookup, so composites like `<connId>/<connId>/<model>` no longer reach upstream verbatim (#11557) — thanks @maxmad64bis
- **fix(cache):** a semantic-cache entry now expires on its TTL instead of surviving until the next UTC midnight, and `dbEntries` stops counting expired rows ([#11573](https://github.com/diegosouzapw/OmniRoute/pull/11573)) — thanks @ntdatt812
- **fix(api):** `/v1/models` schedules its stale-while-revalidate rebuild through Next's `after()` again, so a stale catalog reaches the client before the rebuild blocks the event loop ([#11574](https://github.com/diegosouzapw/OmniRoute/pull/11574)) — thanks @ntdatt812
- **fix(check):** the `known-symbols` executor conformance gate awaits the now-async `getExecutor()`, so it stops reporting all 142 registered aliases as dead symbols and can detect a lazy import that fails ([#11582](https://github.com/diegosouzapw/OmniRoute/pull/11582)) — thanks @ntdatt812
- **fix(pack):** register `bin/cli/utils/volatileEnvPath.mjs` in `PACK_ARTIFACT_REQUIRED_PATHS`, so the prune can no longer silently drop a module the published CLI imports on every boot ([#11588](https://github.com/diegosouzapw/OmniRoute/pull/11588)) — thanks @ntdatt812
- **i18n(vi):** translate the five reliability-column strings on the free-provider rankings page, which rendered as `__MISSING__:` markers in the Vietnamese UI ([#11589](https://github.com/diegosouzapw/OmniRoute/pull/11589)) — thanks @ntdatt812
- **resilience:** restore the expired-connection retry-budget probe in the token-health sweep — the `!isGitHubAccessTokenOnlyConnection` carve-out reintroduced by #11608 contradicted the boundary pinned by #11592, so a GitHub connection parked at `expired` with retry budget remaining was never probed and could never self-heal ([#11592](https://github.com/diegosouzapw/OmniRoute/pull/11592)). ([#11672](https://github.com/diegosouzapw/OmniRoute/pull/11672)) — thanks @jonlwheat2-gif / @ntdatt812
- **fix(dashboard):** Provider Stats now displays combo success rates on their native 0100 percentage scale instead of multiplying them twice ([#11595](https://github.com/diegosouzapw/OmniRoute/pull/11595)) — thanks @pacocartones
- **fix(providers):** route DuckDuckGo Web Chat through the verified `duck.ai` endpoint, refresh its free model catalog, and avoid forwarding unsolved anti-abuse challenges ([#11597](https://github.com/diegosouzapw/OmniRoute/pull/11597)) — thanks @solstxce
- **fix(ui):** Console log Refresh and Copy controls now expose localized accessible names, keep copy actions visible on keyboard focus, and announce copy completion safely ([#11599](https://github.com/diegosouzapw/OmniRoute/pull/11599)) — thanks @pacocartones
- Dropped the stale-`.eslintcache` `restore-keys` fallback from both "Restore ESLint file cache" steps in `ci.yml`, so the blocking `Lint` job can no longer be served per-file verdicts computed under a different lint config, suppressions file or lockfile. `quality.yml` had already dropped it in #11963; `ci.yml` — the workflow that actually gates PRs — had not (#11600). ([#11996](https://github.com/diegosouzapw/OmniRoute/pull/11996))
- fix(ci): pass `--pass-on-unpruned-suppressions` in `run-eslint-json.mjs` so the CI Lint job no longer fails when a suppression is merely orphaned by a genuine fix, mirroring the identical fix already in `validate-release-green.mjs` (#11600) ([#11983](https://github.com/diegosouzapw/OmniRoute/pull/11983)) — thanks @hartmark
- fix(handoff): enforce provider allowlist for universal handoff (#11602) — universal handoff now skips summarization when the selected summary model's provider is not included in the configured provider allowlist. ([#11619](https://github.com/diegosouzapw/OmniRoute/pull/11619)) — thanks @vsd2807
- **fix(dashboard):** Show a stable error state when Search Analytics returns an HTTP error or malformed data ([#11603](https://github.com/diegosouzapw/OmniRoute/pull/11603)) — thanks @pacocartones
- **test(gamification):** pin the aggregate profile level to the XP-derived semantics of #11604`getAggregateXp()` now derives `currentLevel` from the summed XP (`calculateLevel(sum)`), not `MAX(stored current_level)`, and the #3484 fixture levels are aligned with the XP curve ([#11604](https://github.com/diegosouzapw/OmniRoute/pull/11604)). ([#11676](https://github.com/diegosouzapw/OmniRoute/pull/11676)) — thanks @jonlwheat2-gif / @pacocartones
- **fix(dashboard):** Keep the Profile level and progress aligned with aggregate XP, including bounded handling for invalid totals ([#11604](https://github.com/diegosouzapw/OmniRoute/pull/11604)) — thanks @pacocartones
- **fix(dashboard):** Prevent locked hidden badges from revealing their icon or opening private badge details before they are earned ([#11605](https://github.com/diegosouzapw/OmniRoute/pull/11605)) — thanks @pacocartones
- **fix(dashboard):** Restore keyboard focus after shared modals close and cancel delayed autofocus during cleanup ([#11607](https://github.com/diegosouzapw/OmniRoute/pull/11607)) — thanks @pacocartones
- **fix(dashboard):** Enable Enter and Space activation for clickable data-table rows without hijacking nested controls ([#11610](https://github.com/diegosouzapw/OmniRoute/pull/11610)) — thanks @pacocartones
- **fix(autoCombo,sse):** vendor-retired catalog ids are dropped from the auto-combo candidate pool and no longer win on leftover `arena_elo` / `user_override` rows; `getModelLifecycleDecision` consults `model-lifecycle.json` (prefix-stripped) so aggregator traffic is not `untracked` for ids the snapshot already knows ([#11625](https://github.com/diegosouzapw/OmniRoute/issues/11625)) ([#11626](https://github.com/diegosouzapw/OmniRoute/pull/11626)) — thanks @MumuTW
- **test(chatcore):** move Codex/Claude combo fixtures off models the lifecycle guard now rejects — `gpt-5.1-codex`/`gpt-5-codex` are vendor-retired (snapshot, #11626) and `claude-3-5-sonnet-20241022` is shut down, so native-passthrough and combo-fallback tests switched to `gpt-5.6-sol` and `claude-sonnet-4.6` ([#11626](https://github.com/diegosouzapw/OmniRoute/pull/11626)). ([#11675](https://github.com/diegosouzapw/OmniRoute/pull/11675)) — thanks @jonlwheat2-gif / @MumuTW
- fix(opencode-plugin): stop a free-tier budget that rounds up across a magnitude from rendering as `1000K`/`1000M` in the model picker — `fmtTokens` chose its unit from the raw token count and then rounded with `toFixed(1)`, so 999,950999,999 printed as `1000K` rather than `1M` and just under a billion printed as `1000M` rather than `1B` ([#11684](https://github.com/diegosouzapw/OmniRoute/pull/11684)) — thanks @abhisheksharma2411
- Cap prose-derived Antigravity quota resets at the configured model cooldown maximum while preserving authoritative Retry-After headers and Google RetryInfo hints. (#11695) Thanks @pacocartones. ([#11823](https://github.com/diegosouzapw/OmniRoute/pull/11823))
- Compare `_rebaseline_*` notes structurally in the ratchet-bank verifier so an object-valued note no longer reports itself as rewritten on an unmodified tree — the nightly `bank-ratchet-shrinks` lane had been aborting on every run since 2026-08-11, leaving 41,672 lines of earned file-size shrinks unbanked (#11702). ([#11892](https://github.com/diegosouzapw/OmniRoute/pull/11892)) — thanks @MumuTW
- **fix(build):** `prepublish.ts` bundles the ChatGPT Web (Codex) MCP bridge through `runBuildTool()` instead of spawning `npx.cmd` raw, fixing the build crash on Node ≥ 20/Windows where `.cmd` shims cannot be spawned without a shell (EINVAL) ([#11704](https://github.com/diegosouzapw/OmniRoute/issues/11704)) ([#11706](https://github.com/diegosouzapw/OmniRoute/pull/11706)) — thanks @jonlwheat2-gif
- fix(codex): keep `parallel_tool_calls:false` on the translated Codex Responses Lite path (#11707) ([#11984](https://github.com/diegosouzapw/OmniRoute/pull/11984)) — thanks @hartmark
- **fix(tests):** the new Gemini Web Images retirement test (#11708) called the now-async `getExecutor()` synchronously, throwing `TypeError: getExecutor(...).getProvider is not a function` instead of asserting Gemini Web chat stays available. Awaited to match the R0.3 executor-registry refactor (#11220). ([#11868](https://github.com/diegosouzapw/OmniRoute/pull/11868))
- **fix(search):** Allow REST and MCP web-fetch requests to select the advertised `anysearch-search` provider explicitly ([#11716](https://github.com/diegosouzapw/OmniRoute/pull/11716)) — thanks @pacocartones
- **fix(providers):** Keep overnight peak-hour protection active after midnight according to the window's configured start day ([#11718](https://github.com/diegosouzapw/OmniRoute/pull/11718)) — thanks @pacocartones
- **fix(providers):** correct all five g4f.space gateway descriptions to document proof-of-work credits or member API keys, variable limits, and the remote prompt boundary instead of promising free keyless access at five requests per minute ([#11738](https://github.com/diegosouzapw/OmniRoute/pull/11738)).
- **fix(db):** `model_capabilities` is created by a migration instead of lazily on the first models.dev sync, so a clean install and an upgraded install converge on the same schema regardless of which features have run ([#11845](https://github.com/diegosouzapw/OmniRoute/pull/11845))
- **fix(ci):** `check:install-upgrade` now fails on an `npm` install truncated by ENOSPC (npm reports it as a warning and still exits 0), authenticates its health probe so the version assertion works against the hardened health payload, frees the clean-install tree before the upgrade phase, and no longer reports a schema divergence computed from a boot that never served ([#11845](https://github.com/diegosouzapw/OmniRoute/pull/11845))
- **fix(routing):** harden session-affinity key derivation against oversized explicit IDs and empty structural-payload collisions, while recognizing Gemini and common text request shapes ([#11746](https://github.com/diegosouzapw/OmniRoute/pull/11746)) — thanks @MumuTW
- **fix(packages/browser-pool):** regenerate `package-lock.json` so the `packages/browser-pool` workspace's locked `playwright`/`@types/node` (and transitives) match its `package.json` specs, fixing cache-only/offline installs (`npm ci --offline`, Nix `buildNpmPackage`) that previously failed with `ENOTCACHED` ([#11747](https://github.com/diegosouzapw/OmniRoute/issues/11747)) — thanks @benjaminkitt ([#11784](https://github.com/diegosouzapw/OmniRoute/pull/11784)) — thanks @geekyNads
- **fix(assets):** Stop distributing 79 provider and CLI logos without verified source/license provenance, keep every provider/tool available through the internal generic icon, and prevent those IDs from falling through to an external icon CDN. ([#11750](https://github.com/diegosouzapw/OmniRoute/pull/11750))
- **fix(providers):** retire the common `chatgpt-web` / `cgpt-web` provider because its pre-key and proof-of-work provenance could not be cleared ([#11754](https://github.com/diegosouzapw/OmniRoute/pull/11754)) — explicit chat and image requests, slash-prefixed models, persisted aliases, proxy overrides, and combo targets now fail closed with a sanitized HTTP `410` / `PROVIDER_RETIRED` before upstream dispatch. Migration 163 tombstones matching connections and invalidates their active leases without deleting history or widening API-key allowlists. The independent `chatgpt-web-codex` / `cgpt-codex` provider and its MIT-noticed vendored implementation remain available.
- **fix(usage):** quota and usage refresh no longer 409 when an exclusive lease reserves the connection ([#11758](https://github.com/diegosouzapw/OmniRoute/pull/11758)) — thanks @TheDemonTuan
- Keep the embedding registry's vector width and `embedding` type on models when a synced model exists for the same id, so `/v1/models` no longer reports registry-described embedding models widthless or untyped (#11761) — thanks @rahilmavani
- Correct `google/gemini-embedding-001` on the OpenRouter route to 3072 dimensions, the width it returns when `dimensions` is not sent (#11761) — thanks @rahilmavani
- **fix(vision-bridge):** Restrict automatic vision routing and fallbacks to models available in authoritative live provider catalogs, including aliases and effort variants ([#11767](https://github.com/diegosouzapw/OmniRoute/issues/11767)) — thanks @pacocartones ([#11813](https://github.com/diegosouzapw/OmniRoute/pull/11813))
- **fix(providers):** reclassify Cerebras as a one-time $5 signup credit (payment method required, 30-day validity), not a recurring no-card 1M tokens/day trial ([#11773](https://github.com/diegosouzapw/OmniRoute/issues/11773)) ([#12591](https://github.com/diegosouzapw/OmniRoute/pull/12591)) — thanks @HouMinXi
- fix(cli): stop prepublish from re-rebuilding the already-built ESM-only opencode-plugin dist (#11787) ([#11990](https://github.com/diegosouzapw/OmniRoute/pull/11990)) — thanks @hartmark
- **fix(db):** Fresh installs no longer log a non-fatal `no such table: compression_run_telemetry` warning when retention cleanup runs before the lazily-created telemetry table exists ([#11802](https://github.com/diegosouzapw/OmniRoute/pull/11802)) — thanks @RaviTharuma
- fix(sse): set X-OmniRoute-Selected-Connection-Id on successful combo dispatches so downstream consumers stop falling back to an empty connection id (#11810) ([#11986](https://github.com/diegosouzapw/OmniRoute/pull/11986)) — thanks @hartmark
- **fix(services):** embedded CLIProxyAPI lifecycle checks now use public `/healthz`, while model discovery uses the configured dedicated data-plane API key instead of the management password ([#11811](https://github.com/diegosouzapw/OmniRoute/pull/11811)) — thanks @RaviTharuma
- Fix persisted model steps returning an error in the playground route simulator, and warn when structural steps cannot be simulated. ([#11862](https://github.com/diegosouzapw/OmniRoute/pull/11862)) — thanks @pacocartones
- **fix(providers):** Antigravity's dynamic mitmAlias table no longer routes `gemini-3.7-flash-{high,medium,low}` to a literal tier-suffixed upstream id just because one connected account's own discovery listed it directly — those display ids always resolve through the safe `gemini-3.7-flash-tiered` static alias, so one account's Google-provisioned access no longer 404s every sibling account of the provider ([#11824](https://github.com/diegosouzapw/OmniRoute/issues/11824), [#11651](https://github.com/diegosouzapw/OmniRoute/issues/11651)) ([#11988](https://github.com/diegosouzapw/OmniRoute/pull/11988)) — thanks @hartmark
- **fix(dashboard):** Expose Profile loading, errors, page structure, and XP progress to assistive technologies ([#11838](https://github.com/diegosouzapw/OmniRoute/pull/11838)) — thanks @pacocartones
- **fix(socks):** `Agent.connectTimeout` now bounds both SOCKS handshake and TLS connect, and `family === null` no longer falls back to `fetch-socks` ([#11842](https://github.com/diegosouzapw/OmniRoute/pull/11842)) — thanks @maxmad64bis
- **perf(compression):** OOM mitigations for large payload hashing, memoization, and token estimation ([#11844](https://github.com/diegosouzapw/OmniRoute/pull/11844) — thanks @AndrianBalanescu)
- Excluded Next.js Node File Trace manifests (`*.nft.json`) from the published npm tarball. They are build-time metadata and are never read while serving, but had grown to 668.7 MB — 61% of the package — which pushed the upload past the registry limit and made `npm publish` fail with `413 Payload Too Large`. ([#11864](https://github.com/diegosouzapw/OmniRoute/pull/11864))
- **fix(cli):** keep the global OmniRoute server `--base-url` separate from provider-node `--endpoint` values in add, update, and validate commands ([#11860](https://github.com/diegosouzapw/OmniRoute/pull/11860)) — thanks @pacocartones
- **fix(config):** Nous Research's `Hermes-4-405B` model now displays as "Hermes 4 405B (Nous Research)" in both the provider registry and the free-model catalog, instead of the mislabelled "Hermes 4 7B" ([#11861](https://github.com/diegosouzapw/OmniRoute/issues/11861)) — thanks @Karan825 ([#11993](https://github.com/diegosouzapw/OmniRoute/pull/11993))
- **fix(provider/nous):** inject required user= tag into Nous Research inference requests to resolve upstream 400 "missing tags" error ([#11861](https://github.com/diegosouzapw/OmniRoute/issues/11861)) — thanks @Karan825 ([#12044](https://github.com/diegosouzapw/OmniRoute/pull/12044))
- **fix(build):** `npm run build` now fails in one second with a named package and a copy-pasteable fix when npm silently drops an externalised optional native dependency, instead of dying four minutes in with `Module not found: Can't resolve 'better-sqlite3'` ([#11863](https://github.com/diegosouzapw/OmniRoute/pull/11863)) — thanks @ujjawalkaushik1110
- **fix(install):** `postinstall` no longer throws `ReferenceError: isAndroid is not defined` — failing the whole `npm install` — when the `better-sqlite3` rebuild fallback times out; the manual-fix guidance is reachable again ([#11863](https://github.com/diegosouzapw/OmniRoute/pull/11863)) — thanks @ujjawalkaushik1110
- Fixed the Alibaba free-tier allowlist test that went red on its own once the shipped catalog's `validUntil` (2026-08-27) passed, leaving every PR and `main` with a failing `Unit Tests (1/8)`. The test now builds its own packs with dates it controls, and covers the expired-pack fallback that production has actually been serving. ([#11867](https://github.com/diegosouzapw/OmniRoute/pull/11867))
- Split the npm registry upload into its own GitHub-hosted job. npm refuses `--provenance` from a self-hosted runner (`422 ... Only "github-hosted" runners are supported`), which blocked the v3.8.50 publish; the heavy verification cannot move to a hosted runner, so it now hands the proven tarball over instead. ([#11868](https://github.com/diegosouzapw/OmniRoute/pull/11868))
- **fix(ui):** the shared `DataTable` loading state no longer reads its decorative ⏳ glyph out to assistive technology, and now carries the same `role="status"` / `aria-live="polite"` / `aria-busy="true"` semantics as `PageLoading` ([#11869](https://github.com/diegosouzapw/OmniRoute/pull/11869)) — thanks @pacocartones
- **fix(api):** Bound the `timeout` accepted by `POST /api/providers/[id]/login` to the same 15s600s window the Adobe Firefly and Conol browser-login paths already enforce, so a request can no longer pin a headful browser and the single active-login slot with an arbitrarily large poll budget. ([#11871](https://github.com/diegosouzapw/OmniRoute/pull/11871)) — thanks @pacocartones
- **fix(api):** Stop the tunnel and MITM routes from returning raw child-process error text, which disclosed host paths, binary install locations and Tailscale `tskey-*` credentials in a body some of these routes serve to non-loopback callers, and return a real 400 instead of a framework 500 when a tunnel request body fails validation. ([#11872](https://github.com/diegosouzapw/OmniRoute/pull/11872)) — thanks @pacocartones
- **fix(open-sse):** stream TTFT/ITL are sampled from a monotonic clock (`performance.now`) instead of `Date.now`, so an NTP correction or wall-clock jump can no longer inflate a genuine TTFT or produce a negative one that the `ttft >= 0` guard silently drops — protecting the router's OTel/EWMA/`usage_history`/speed-ranking signals ([#11873](https://github.com/diegosouzapw/OmniRoute/pull/11873)) — thanks @pacocartones
- **fix(providers):** xAI `reasoning_effort: "xhigh"` now reaches grok-4.6+ instead of being silently clamped to `"high"` ([#11879](https://github.com/diegosouzapw/OmniRoute/pull/11879)) — thanks @NoxzRCW
- **fix(dashboard):** the providers page no longer crashes into the error boundary when a provider id collides with an `Object.prototype` member (`constructor`, `__proto__`); icon lookups are own-property guarded ([#11880](https://github.com/diegosouzapw/OmniRoute/pull/11880)) — thanks @NoxzRCW
- **fix(skills):** injected skill tools declared in shorthand (`{"content": "string"}`) now forward valid JSON Schema, unblocking providers that validate tool schemas strictly such as Zhipu GLM on the Console Go tier ([#11881](https://github.com/diegosouzapw/OmniRoute/pull/11881)) — thanks @NoxzRCW
- **fix(api):** `POST /api/playground/simulate-route` now surfaces `combo-ref` and `provider-wildcard` persisted combo steps with a specific warning (naming the referenced combo, or the unresolved `provider/model` wildcard) instead of folding them into a generic "unsupported step" count; a `provider-wildcard` step is also now included as an unresolved target so the operator can see it is in the route (ported from [#11882](https://github.com/diegosouzapw/OmniRoute/pull/11882) — thanks @NoxzRCW). ([#11930](https://github.com/diegosouzapw/OmniRoute/pull/11930))
- **fix(translator):** the streaming OpenAI→Claude translator keeps upstream usage, including prompt-cache tokens, when it arrives on a trailing `choices: []` chunk (Fireworks and any upstream using `stream_options.include_usage`) ([#11883](https://github.com/diegosouzapw/OmniRoute/pull/11883)) — thanks @NoxzRCW
- **fix(cli):** `omniroute update --apply` now tells you explicitly whether a running server was detected and, if so, that you must run `omniroute restart` to apply the update — it never restarted anything and previously implied the update was already live once files were installed. The dashboard's npm-mode Update flow (`/api/system/version`) now tries OmniRoute's own PID-file-managed supervisor before falling back to pm2, and reports an honest "restart required" step instead of a silent pm2-only "skipped" that read like a completed update. The server-side latest-version lookup backing the dashboard's update banner also gained `--prefer-online`, closing the same stale-npm-cache class already fixed in the CLI's own copy for #4376 ([#11885](https://github.com/diegosouzapw/OmniRoute/issues/11885)). ([#12005](https://github.com/diegosouzapw/OmniRoute/pull/12005))
- **fix(resilience):** clear persisted LKGP pins when a target suffers connection/provider exhaustion or is skipped before dispatch due to cooldown/exhaustion/unavailability, preventing subsequent requests from repeatedly prioritizing known-dead providers ([#11911](https://github.com/diegosouzapw/OmniRoute/issues/11911)). ([#12013](https://github.com/diegosouzapw/OmniRoute/pull/12013)) — thanks @HouMinXi
- **fix(sse):** the OpenAI→Claude stream translator now defers the terminal `message_delta`/`message_stop` emission until the real usage block has arrived (or a genuine end-of-stream flush forces it) instead of emitting it immediately on `finish_reason` — previously, when the trailing usage-only chunk (`{"choices":[],"usage":{...}}`) arrived *after* the `finish_reason` chunk (the normal order for Fireworks/vLLM/Together and other `stream_options.include_usage` upstreams), the client-visible `message_delta` still carried stale/zero usage even though `state.usage` was internally corrected too late to matter (ported from [#11915](https://github.com/diegosouzapw/OmniRoute/pull/11915) — thanks @HouMinXi). ([#11933](https://github.com/diegosouzapw/OmniRoute/pull/11933))
- **fix(sse):** `stripStore()` now forces `store=false` for stateless OpenAI-compatible Responses-API targets (unless the connection has explicitly opted in via `providerSpecificData.openaiStoreEnabled`), instead of only handling the `openai`/`agentrouter` cases — a client-supplied `store` value previously passed through untouched to backends that don't actually persist responses server-side ([#11916](https://github.com/diegosouzapw/OmniRoute/pull/11916) — thanks @HouMinXi).
- **fix(models):** custom provider-node models (synced, custom, and alias-backed) now appear under their configured prefix in the unified catalog when the operator's model-id prefix mode is canonical, instead of being dropped whenever alias-inclusion was otherwise disabled ([#11918](https://github.com/diegosouzapw/OmniRoute/pull/11918) — thanks @HouMinXi).
- **fix(models):** the unified model catalog now suppresses stale static registry models (including effort-tier variants) for any provider whose active connection has an authoritative live synced catalog, not only providers already using exclusive-synced-listing — a connection with `providerUsesAuthoritativeLiveCatalog` previously kept serving both the live-synced models and the stale static rows side by side ([#11919](https://github.com/diegosouzapw/OmniRoute/pull/11919) — thanks @HouMinXi).
- **fix(ui):** `ProviderIcon`'s three remaining unguarded lookups (`PROVIDER_ICON_ALIASES`, `LOCAL_SVG_ALIASES`, `THEMED_SVGS`) now use `Object.hasOwn()` own-property checks — a provider id such as `constructor` or `__proto__` previously resolved through the prototype chain instead of falling through to the unknown-provider CDN fallback (`getLobeProviderIcon()` itself was already guarded by [#11880](https://github.com/diegosouzapw/OmniRoute/pull/11880)); `ProviderPageHeader`'s `color` field is also now optional, matching the rest of the component's defensive typing (ported from [#11920](https://github.com/diegosouzapw/OmniRoute/pull/11920) — thanks @HouMinXi). ([#11935](https://github.com/diegosouzapw/OmniRoute/pull/11935)) — thanks @NoxzRCW
- fix(ollama): preserve multi-byte UTF-8 content split across stream chunks in the Ollama NDJSON transform, which previously corrupted CJK/emoji into U+FFFD (#11921) — thanks @pacocartones
- **fix(providers):** OrcaRouter chat requests now target `/v1/chat/completions` instead of the bare `/v1` API root, fixing the upstream `404 Invalid URL (POST /v1)` ([#11923](https://github.com/diegosouzapw/OmniRoute/pull/11923)). — thanks @echel0nn
- **fix(plugins):** deliver the `onStreamComplete` event to disk-installed plugins. The event shipped in v3.8.50 (#9669) was emitted internally but had no plugin-facing wiring, so no plugin could ever subscribe: the manifest schema silently dropped `hooks.onStreamComplete`, and the loader/manager only knew the seven legacy hooks. `onStreamComplete` is now a declarable manifest hook, wired through the loader and registered by the manager like the other hooks, and its payload carries a `requestId` so consumers can correlate the stream-completion event with the originating request ([#11934](https://github.com/diegosouzapw/OmniRoute/pull/11934)) — thanks @amaleta / @pacocartones
- **fix(db):** the Qdrant embedding-model dropdown now lists local/self-hosted providers (Ollama, LM Studio, vLLM, etc.) — an active connection is treated as "configured" when the provider allows an optional API key, not only when it has a real key or OAuth, so a running local embedding provider is no longer hidden from the picker ([#11949](https://github.com/diegosouzapw/OmniRoute/issues/11949)) ([#11995](https://github.com/diegosouzapw/OmniRoute/pull/11995))
- **fix(providers):** Vertex AI Anthropic partner-model discovery now calls the Model Garden `v1beta1` publisher list (`/v1beta1/publishers/anthropic/models`, global) and parses its `publisherModels` envelope, so Claude models auto-synced from Vertex populate the active live catalog and route at request time instead of returning `Model '<id>' is not available in the active live catalog` ([#11991](https://github.com/diegosouzapw/OmniRoute/issues/11991)) — thanks @fabioluissilva ([#11998](https://github.com/diegosouzapw/OmniRoute/pull/11998))
- **fix(cli):** support `--base-url` alongside `--endpoint` in `omniroute nodes add`, `update`, and `validate` subcommands to prevent global `--base-url` shadowing issues ([#11999](https://github.com/diegosouzapw/OmniRoute/issues/11999)). ([#12033](https://github.com/diegosouzapw/OmniRoute/pull/12033)) — thanks @HouMinXi
- **fix(providers):** `cloudflare-ai` no longer refuses image content parts for every Workers AI model ([#12002](https://github.com/diegosouzapw/OmniRoute/pull/12002)) — the plain-string `content` requirement behind #2539 is carried by the _model_ schema, not by the `/ai/v1/chat/completions` endpoint (measured: an all-text part array returns 200 on `@cf/mistralai/mistral-small-3.1-24b-instruct`, `@cf/meta/llama-4-scout-17b-16e-instruct` and `@cf/meta/llama-3.3-70b-instruct-fp8-fast`, and 400 on the text-only `@cf/qwen/qwen2.5-coder-32b-instruct`). `transformRequest()` flattened every array and threw on the first non-text part (#6390), so image input was refused for vision-capable Cloudflare models that accept it. All-text arrays are still flattened — the one shape every model accepts — while an array carrying a non-text part is passed through untouched, so the attachment is still never silently dropped. Regression guards: `tests/unit/cloudflare-ai-image-parts-6390.test.ts`. — thanks @davidlinfr
- **fix(resilience):** decouple the limiter-managed execution backstop from the queue-wait budget — new `requestQueue.executionMaxWaitMs` (env `RATE_LIMIT_EXECUTION_MAX_WAIT_MS`, default 600000 = 10 min) now feeds Bottleneck's post-dispatch `expiration`, while `requestQueue.maxWaitMs` keeps its documented queue-wait semantics. Previously the queue-wait budget doubled as the execution expiration, so legitimate long-running calls on non-incremental gateways (whole generation buffered before the first upstream byte, e.g. Console Go / Command Code tiers serving GLM models) were killed mid-flight at the queue budget with a false 504 `RATE_LIMIT_EXECUTION_TIMEOUT` — the local limiter undercut the provider-aware upstream fetch-start timeouts. The surfaced 504 message now names `requestQueue.executionMaxWaitMs`; the error keeps the #4165 guarantees (disclaims an upstream timeout, preserves the Bottleneck error as `cause`, branded code + trusted provenance, classified request-scoped so combo falls back). A real queue-wait bound (the `Promise.race` around `limiter.schedule()` sketched in #9533) remains future work. (#12025) ([#12027](https://github.com/diegosouzapw/OmniRoute/pull/12027)) — thanks @alvinveroy
- **Call logs:** keep the `error` field when an artifact exceeds the storage cap, instead of replacing it with the omission marker. The error is the only field that says *why* a request failed and is typically ~90 bytes next to the multi-hundred-KB bodies that trip the cap, so dropping it left a size-limited row undiagnosable — a provider outage, a local timeout and an upstream 400 all rendered identically. It is now preserved at every fallback stage, truncated to 4KB if it is itself large ([#12026](https://github.com/diegosouzapw/OmniRoute/issues/12026)). ([#12095](https://github.com/diegosouzapw/OmniRoute/pull/12095)) — thanks @ntdat812
- **fix(diagnostics):** preserve the error field (truncated to 4KB with a `[truncated: …]` suffix) in every call-log artifact size-limit fallback stage. Previously the minimal fallback replaced the error with `[omitted: call log artifact size limit exceeded]`, so an oversized artifact row showed nothing about WHY the request failed — e.g. 91 of 847 opencode-go 504 rows on one production instance were undiagnosable from the dashboard. Oversized request/response bodies are still omitted exactly as before; the error cap is independent of the payload sizes that tripped the fallback. (#12026) ([#12027](https://github.com/diegosouzapw/OmniRoute/pull/12027)) — thanks @alvinveroy
- **fix(sse):** OpenAI Responses clients that declare the native `web_search` tool now receive a spec-shaped `web_search_call` output item with `action.sources` alongside the preserved function-call round-trip, so search results executed through OmniRoute's own search backend are consumable by standard Responses clients (Codex, pi-web-access, …). ([#12031](https://github.com/diegosouzapw/OmniRoute/pull/12031)) — thanks @watchingdogs
- **fix(cli):** use in-thread alias resolver hooks on modern runtimes to avoid deprecation noise and improve Node.js forward compatibility ([#12073](https://github.com/diegosouzapw/OmniRoute/issues/12073)). ([#12083](https://github.com/diegosouzapw/OmniRoute/pull/12083)) — thanks @xiaoyaner0201
- **fix(combo):** an operator-set **Agent Features → Context length** on a combo is now honored at request time. The value was persisted and advertised through `/v1/models`, but `resolveComboContextLimit()` never consulted it — so a multi-target combo whose members carry no per-model window fell through to the provider's generic `defaultContextLength` (openrouter 128000, command-code 200000) and rejected large requests with `Input exceeds context window … limit 128000` despite the combo being explicitly sized much larger. An identical single-target combo worked, because it collapses to its concrete target before the guard runs. Invalid values (0/negative/NaN/Infinity) are ignored, so the existing target → combo-min → fallback order is unchanged. ([#12090](https://github.com/diegosouzapw/OmniRoute/pull/12090)) — thanks @adivekar-utexas
- **fix(sse):** passthrough streams now estimate usage on finish when upstream closes without usage even with `stream_options.include_usage` — avoids `0 tokens / 0%` for providers that stay silent (and correctly handles trailing empty-choices usage) ([#12151](https://github.com/diegosouzapw/OmniRoute/pull/12151)) — thanks @maxmad64bis
- **fix(combos):** clearing an agent feature in the combos editor now persists — unchecking context cache protection, or emptying the system message or tool filter, sends an explicit `null` instead of dropping the field from the `PUT` body, which the update merge read as "leave unchanged" ([#12177](https://github.com/diegosouzapw/OmniRoute/pull/12177)) — thanks @foreveryh / @ntdatt812
- **fix(memory):** self-hosted embedding endpoints now vectorize — the vector width is measured from the first embedding that comes back instead of being read from a registry that cannot describe them, so `vec_memories` is created and memories stop piling up unvectorized behind a green health check ([#12180](https://github.com/diegosouzapw/OmniRoute/pull/12180)) — thanks @kanade-hoshino / @ntdatt812
- **fix(translator):** the leading `system` message now reaches Responses-API upstreams when its `content` is a content-part array — it was read as `typeof content === "string" ? content : ""`, so a prompt-caching client (Anthropic `cache_control`, the shape LiteLLM and the Anthropic SDK emit) had its entire system prompt replaced by an empty `instructions`. The request was still accepted with a normal `prompt_tokens` count, so the model answered with no instructions and nothing in the response said they were missing. Mid-conversation system turns already handled the array shape ([#7056](https://github.com/diegosouzapw/OmniRoute/pull/7056)); only the first one did not ([#12206](https://github.com/diegosouzapw/OmniRoute/issues/12206)). Regression guard: `tests/unit/translator-openai-responses-system-content-parts.test.ts`. ([#12207](https://github.com/diegosouzapw/OmniRoute/pull/12207)) — thanks @Gorillaz322
- **fix(free-tier):** `/api/free-tier/summary` no longer computes its totals from a Radar feed built before the catalog the running release ships. When the cached feed is older — or carries no build date at all — the route answers from the shipped catalog, resolved through the operator's local model state so disabled and tombstoned models stay out of the numbers ([#12215](https://github.com/diegosouzapw/OmniRoute/pull/12215)). — thanks @maxmad64bis
- **fix(oauth):** Keep a Claude personal workspace and a Team organization as separate connections — they share the same email and `accountUUID`, so the email-only OAuth dedup let the second login overwrite the first account's tokens; `organizationUUID` now disambiguates them, the way `workspaceId` does for Codex ([#12222](https://github.com/diegosouzapw/OmniRoute/pull/12222)) — thanks @davidebaraldo
- **fix(resilience):** a 402 on a single paid model of a passthrough/gateway provider (e.g. `kilo-gateway`, `ollama-cloud`) no longer terminalizes the whole connection with a never-auto-recovered `credits_exhausted` status — only the paid model is locked out, so free models on the same key keep serving. 402 variant of [#3027](https://github.com/diegosouzapw/OmniRoute/issues/3027). Single-credential providers are unaffected — a 402 there is still treated as the key being genuinely out of credit ([#5239](https://github.com/diegosouzapw/OmniRoute/issues/5239) / [#10616](https://github.com/diegosouzapw/OmniRoute/issues/10616)) ([#12242](https://github.com/diegosouzapw/OmniRoute/issues/12242)) — thanks @brick30llc-ctrl ([#12266](https://github.com/diegosouzapw/OmniRoute/pull/12266)) — thanks @geekyNads
- **fix(sse):** trust `finish_reason: "length"`/`"max_tokens"` over the reasoning-consumed-token ratio in response quality validation, so a reasoning model truncated below the old 90% threshold correctly fails and retries instead of returning empty content as a silent "success" ([#12262](https://github.com/diegosouzapw/OmniRoute/pull/12262)) — thanks @brick30llc-ctrl
- **fix(settings):** `PUT /api/settings/cache-config` now persists `alwaysPreserveClientCache` to the flat general settings the runtime cache-control policy actually reads; previously the value landed in the databaseSettings "cache" section and was silently ignored, so the endpoint had no effect on `cache_control` passthrough ([#12304](https://github.com/diegosouzapw/OmniRoute/pull/12304)) — thanks @davidebaraldo
- **fix(grok-cli):** treat omitted SuperGrokPro `creditUsagePercent` as 0% used so Provider Limits still renders a weekly bar (proto3 zero-elision) ([#12312](https://github.com/diegosouzapw/OmniRoute/pull/12312)) — thanks @HouMinXi
- **fix(combo):** Expose the two Auto-Combo scoring factors nobody could set — the combo validation schema and the dashboard weight sliders both declared 13 of the scorer's 15 factors, so `connectionDensity` (spreads load across a provider's connections) and `quality` were dropped on save and offered nowhere. The sliders also shipped their own default table that differed from the engine's on every non-zero factor and summed to 1.05, so the percentages shown next to them added up to 105%. Both lists now match `DEFAULT_WEIGHTS`, and a test keeps them there. Note that a combo whose stored `weights` omitted the two keys was effectively running with them at zero and the other thirteen renormalized upward; it now runs with the engine's intended distribution, so its routing does shift ([#12314](https://github.com/diegosouzapw/OmniRoute/pull/12314)) — thanks @maxmad64bis
- **fix(docs):** The free-tier reference no longer says its numbers come "confidence tagged per row" — no catalog entry carries a confidence tag and the API serves none, so every figure on that page is an estimate of the same, unstated quality. The page now states what an entry does vouch for: an independently documented hard stop (set by hand with the source in a comment, never defaulted to `true`) and a prompt-training disclosure, both with live counts the `check:docs-counts` gate keeps honest ([#12318](https://github.com/diegosouzapw/OmniRoute/pull/12318)) — thanks @maxmad64bis
- **fix(usage):** `adobe-firefly` and `firefly` have had a working usage fetcher since Adobe Firefly landed, but neither was ever added to the registration list, so the provider-plugin manifest, `genericQuotaFetcher` and the free-access quota cache all reported them as having no usage support — while `USAGE_SUPPORTED_PROVIDERS` said the opposite. Both are now declared, which also means their credit balance is fetched like any other declared provider's: `registerGenericQuotaFetchers` now registers a generic quota fetcher for them, and `resolveFreeAccessState` no longer returns early. A test holds the registration list to the dispatcher's switch in both directions, which is what the module's own docstring already asked for in prose ([#12321](https://github.com/diegosouzapw/OmniRoute/pull/12321)) — thanks @maxmad64bis
- **fix(quota):** drop the generic quota cache (agy / Antigravity / Claude OAuth) on an upstream 429 so reset-aware scoring does not keep a 60s stale snapshot, and force-refresh the next usage fetch so inner provider caches cannot recache the same window ([#12325](https://github.com/diegosouzapw/OmniRoute/pull/12325)) — thanks @HouMinXi
- **fix(combos):** deleting a combo now clears its persisted LKGP pins instead of leaving unreachable `key_value` rows behind ([#12326](https://github.com/diegosouzapw/OmniRoute/issues/12326)) ([#12425](https://github.com/diegosouzapw/OmniRoute/pull/12425)) — thanks @KrzysiekSko
- **fix(sse):** Keep ZWNJ (U+200C) and ZWJ (U+200D) in assistant text, reasoning and tool-call arguments — Persian/Kurdish half-space (`ارائه‌دهنده`), Arabic/Indic shaping and emoji sequences no longer lose them; the response de-obfuscation now removes joiners only between ASCII word characters, where the request side inserts them ([#12186](https://github.com/diegosouzapw/OmniRoute/issues/12186)) — thanks @rezjalibd ([#12359](https://github.com/diegosouzapw/OmniRoute/pull/12359)) — thanks @pacocartones
- **fix(resilience):** count resolved upstream 5xx results against the provider circuit breaker on the chat path — `CircuitBreaker.execute()` no longer reads a resolved `{ success: false, status: 5xx }` as a success that cancels the call-site failure, so a provider answering 503s now trips its breaker instead of staying `CLOSED` at `failureCount: 1`; single-model and combo dispatches are each accounted exactly once ([#12254](https://github.com/diegosouzapw/OmniRoute/issues/12254)) ([#12360](https://github.com/diegosouzapw/OmniRoute/pull/12360)) — thanks @pacocartones
- **fix(providers):** resolve the Codex quota auto-ping model from the live provider catalog and lifecycle registry instead of the retired `gpt-5.1-codex-mini`, and pause the ping with one actionable warning when no selectable Codex model exists rather than retrying a shut-down id every cooldown window ([#11905](https://github.com/diegosouzapw/OmniRoute/issues/11905)) ([#12361](https://github.com/diegosouzapw/OmniRoute/pull/12361)) — thanks @pacocartones
- **fix(api):** keep the `{created, data}` wrapper on combo-routed `/v1/images/generations` responses and default Codex image results to `b64_json` on both `/v1/images/generations` and `/v1/images/edits` so Codex CLI's built-in `image_gen` can decode them ([#12268](https://github.com/diegosouzapw/OmniRoute/issues/12268)) ([#12362](https://github.com/diegosouzapw/OmniRoute/pull/12362)) — thanks @pacocartones
- **fix(sse):** Name the shadowed custom provider node when a built-in provider id/alias (e.g. `openference``of`) reserves the prefix of an existing OpenAI/Anthropic-compatible node, so the runtime `No active credentials for provider: <built-in>` error explains that the prefix routed to the built-in and never reached the node's healthy connections, instead of contradicting the dashboard ([#11943](https://github.com/diegosouzapw/OmniRoute/issues/11943)) — thanks @morpheus9393 ([#12365](https://github.com/diegosouzapw/OmniRoute/pull/12365)) — thanks @pacocartones
- **fix(i18n):** wrap `ccOnboardingKeyPlaceholder` in ICU single quotes across all 43 locales so angle brackets render literally instead of being parsed as rich-text tags, which crashed the Claude Code onboarding block with `INVALID_MESSAGE: INVALID_TAG` ([#12302](https://github.com/diegosouzapw/OmniRoute/issues/12302)) ([#12369](https://github.com/diegosouzapw/OmniRoute/pull/12369)) — thanks @gonisulaimann
- **fix(models):** publish `effort_tiers` on Kimi K3's synced base-model entries (`kmca/k3`, `kmca/k3-256k`) so catalog-only clients (OpenCode, plain SDK pickers) can see and select the reasoning tiers (`low`/`high`/`max`) the synced metadata already carried — the `isSkippedEffortProvider` gate no longer suppresses tier visibility on those base entries, while synthetic `<id>-<tier>` variant generation stays prevented and Codex/GLM base models remain excluded unchanged ([#12299](https://github.com/diegosouzapw/OmniRoute/issues/12299)) ([#12371](https://github.com/diegosouzapw/OmniRoute/pull/12371)) — thanks @gonisulaimann
- **fix(guardrails):** keep `auto`/`auto/*` virtual combos exempt from the Vision Bridge `fixedModel` credential guard so a combo target is passed through instead of silently falling back to global auto-selection ([#12237](https://github.com/diegosouzapw/OmniRoute/issues/12237)) ([#12373](https://github.com/diegosouzapw/OmniRoute/pull/12373)) — thanks @pacocartones
- **fix(combo):** capability-filter exhaustion caused by `max_tokens` above every target's known output limit now reports that reason (requested `max_tokens` vs the pool's highest known ceiling) instead of the unrelated "supports structured output" message ([#12229](https://github.com/diegosouzapw/OmniRoute/issues/12229)) — thanks @DW-MediaLab ([#12374](https://github.com/diegosouzapw/OmniRoute/pull/12374)) — thanks @pacocartones
- **fix(auth):** the `least-used` account strategy now prefers accounts without backoff before falling back to oldest `lastUsedAt`, the same tie-break `round-robin` already applies, so a failover no longer lands on a just-rate-limited account for a single request ([#12279](https://github.com/diegosouzapw/OmniRoute/issues/12279)) — thanks @tenshiak ([#12375](https://github.com/diegosouzapw/OmniRoute/pull/12375)) — thanks @pacocartones
- **fix(docker):** the `chatgpt-web-codex-browser` image now finds the Chrome binary under `chrome-linux64/` (Chrome for Testing layout in `playwright:v1.62.0-noble`) as well as the legacy `chrome-linux/`, so the container no longer crash-loops with `exec: --headless=new: not found` ([#12024](https://github.com/diegosouzapw/OmniRoute/issues/12024)) ([#12376](https://github.com/diegosouzapw/OmniRoute/pull/12376)) — thanks @pacocartones
- **fix(providers):** declare `groq/compound` and `allam-2-7b` as non-reasoning models in the curated Groq registry so `reasoning_effort` / `output_config.effort` / `thinking` from Claude Code are stripped instead of forwarded, which Groq rejected with HTTP 400 ([#12134](https://github.com/diegosouzapw/OmniRoute/issues/12134)) ([#12379](https://github.com/diegosouzapw/OmniRoute/pull/12379)) — thanks @pacocartones
- **fix(executors):** `OpencodeExecutor` no longer forces a direct connection when the connection has a proxy assigned in Proxy Management but no per-account proxies: the single-account fast path used to wrap the upstream dispatch in the direct-egress sentinel, discarding the ambient proxy context the chat handler had pinned from `proxy_assignments`, so API-key `opencode`/`opencode-go` connections egressed from the host IP (and hit geoblocks) despite the assignment. The direct pin is now applied only when no ambient proxy context exists ([#11894](https://github.com/diegosouzapw/OmniRoute/issues/11894) — thanks @hizzt) ([#12380](https://github.com/diegosouzapw/OmniRoute/pull/12380)) — thanks @pacocartones
- **fix(api):** `GET /v1/models` with `MODELS_CATALOG_PREFIX_MODE=canonical` (or `?prefix=canonical`) now lists providers whose registry alias is undefined or equal to their own id (Antigravity, Antigravity CLI and other self-aliased built-ins) — their single `provider/model` id was dropped by the alias/canonical duplicate guard in the static, synced, custom and alias-backed catalog loops ([#12058](https://github.com/diegosouzapw/OmniRoute/issues/12058)) — thanks @cheynetom ([#12381](https://github.com/diegosouzapw/OmniRoute/pull/12381)) — thanks @pacocartones
- **fix(translator):** Drop replayed `thinking` blocks that carry no signature (the shape produced from cross-provider `reasoning_content`) instead of stamping the default Claude signature on them, which Anthropic rejected with `400 Invalid signature in thinking block` on the next turn served by an Anthropic rung ([#12105](https://github.com/diegosouzapw/OmniRoute/issues/12105)) — thanks @atescivitci-cmd ([#12386](https://github.com/diegosouzapw/OmniRoute/pull/12386)) — thanks @pacocartones
- **fix(cli):** Resolve Bun's `--preload` polyfill path against the package root instead of `dist/`, so `omniroute` installed with `bun install -g` no longer crashes at startup with `error: preload not found …/dist/open-sse/utils/setupPolyfill.ts` ([#11980](https://github.com/diegosouzapw/OmniRoute/issues/11980)) — thanks @joglomedia ([#12387](https://github.com/diegosouzapw/OmniRoute/pull/12387)) — thanks @pacocartones
- **fix(providers):** `gemini-business` now publishes its model catalog — `/v1/models` and `/v1/providers/gemini-business/models` list the 12 enterprise Gemini ids the executor understands instead of returning an empty list (#12107) ([#12389](https://github.com/diegosouzapw/OmniRoute/pull/12389)) — thanks @pacocartones
- **fix(db):** install `busy_timeout` before the SQLite connection's first statement so a process opening the database while another one closes its WAL connection waits out the transient EXCLUSIVE lock instead of dying with `database is locked`, and recognise the drivers' real BUSY/PROTOCOL/IOERR errors as transient in the corruption probe so the same lock no longer renames the database away as corrupt; deflakes `cross-process contenders never both acquire the same connection` (#12394 — thanks @pacocartones)
- **fix(chat-admission):** derive the `chat_admission_busy` 503 `Retry-After` from observed heavyweight-lease occupancy — the larger of the exhausted `OMNIROUTE_CHAT_ADMISSION_QUEUE_MS` window and the time since capacity last turned over, capped at 60 s — instead of a fixed 1 s (structural) / 2 s (byte-stage) hint that invited Codex/agent fan-out clients to re-send ~1 MiB `/v1/responses` bodies every second into a gate held for the whole SSE lifetime; an idle gate keeps the historical floors ([#12135](https://github.com/diegosouzapw/OmniRoute/issues/12135)) (#12395 — thanks @pacocartones)
- **fix(api-manager):** the API key permissions modal no longer silently drops `allowedCombos` entries its Combo picker cannot render — routing-rule names such as `rt-*`, which the backend already honours — when "All" is clicked and the key is switched back to "Restrict"; those entries now survive the toggle, are listed read-only under the combo list so the count and the list agree, and are saved back verbatim instead of persisting `[]` (deny-all) (#12397 — thanks @pacocartones)
- **fix(catalog):** write the NUL separator of the catalog connection memo key, the provider serviceKind memo key, the Video Bridge promotion group key and a JSON-exactness test fixture as the `\u0000` escape instead of a raw byte — same runtime value, but the raw byte made git, GitHub and ripgrep treat those files as binary (hidden PR diffs, silently skipped searches); a guard test now keeps raw NUL bytes out of `src/`, `open-sse/` and `tests/` (#12403 — thanks @pacocartones)
- **fix(providers):** add `CLAUDE_CODE_CLIENT_VERSION` and `GITHUB_COPILOT_CLI_VERSION` env overrides so Anthropic/Copilot client-version gates can be unblocked without a rebuild ([#12417](https://github.com/diegosouzapw/OmniRoute/issues/12417)) ([#12632](https://github.com/diegosouzapw/OmniRoute/pull/12632)) — thanks @HouMinXi
- **fix(db):** back-fill `last_ping_at` and `last_pinged_reset_key` on `provider_connections` during schema reconciliation so divergent lineages that skipped `123_quota_auto_ping` still accept quota auto-ping writes ([#12470](https://github.com/diegosouzapw/OmniRoute/pull/12470) — thanks @KooshaPari)
- **fix(ci):** document MIT exceptions for `@eloqnt/{config,format-json,format-po}` (next-intl transitive; locked tarballs omit `license`) and keep the A2A lifecycle vitest off the real SQLite persistence seam ([#12581](https://github.com/diegosouzapw/OmniRoute/issues/12581)) ([#12595](https://github.com/diegosouzapw/OmniRoute/pull/12595))
- OpenCode plugin `/v1/models` catalog fetch now waits 30s by default and attaches HTTP `statusCode` on 401/5xx so host fallback plugins can hop instead of seeing an untyped AbortError/UnknownError. ([#12607](https://github.com/diegosouzapw/OmniRoute/pull/12607)) — thanks @RaviTharuma
- **CI:** the OpenAPI security-tier gate now mirrors `isAlwaysProtectedPath()` in full — it also reads `ALWAYS_PROTECTED_API_PATTERNS`, so the pattern-gated credential routes (`/api/providers/{id}/{claude,codex}-auth/{export,apply-local}`, GHSA-5926-2w35-7h4q) no longer report as unannotated. (#12605)
- **fix(api):** GET /v1/models no longer waits forever on a hung coalesced catalog rebuild; cold-path waits are bounded (`CATALOG_BUILD_TIMEOUT_MS`, default 8s) and a last-good 200 is served when the rebuild times out ([#12627](https://github.com/diegosouzapw/OmniRoute/issues/12627)). ([#12628](https://github.com/diegosouzapw/OmniRoute/pull/12628)) — thanks @RaviTharuma
- **fix(cursor):** a non-array `tool_calls` on an assistant message no longer crashes the cursor request translator with a `TypeError`; both loops now require an array ([#12689](https://github.com/diegosouzapw/OmniRoute/issues/12689)) ([#12691](https://github.com/diegosouzapw/OmniRoute/pull/12691)) — thanks @soroush5
- **fix(chat):** Chat Completions no longer return empty `content` after a server-owned memory or skills tool; the first provider send and account/model recovery now share one pipeline so a follow-up round-trip can fill the reply ([#12696](https://github.com/diegosouzapw/OmniRoute/issues/12696)) — thanks @HouMinXi ([#12867](https://github.com/diegosouzapw/OmniRoute/pull/12867))
- **fix(dashboard):** batch-deleting provider keys no longer toasts failure after a successful delete when the confirm button's click event is forwarded as `onAfter` ([#12711](https://github.com/diegosouzapw/OmniRoute/pull/12711)) — thanks @HouMinXi
- **fix(glm):** drop the extra 16th argument to `createSSETransformStreamWithLogger` that TypeScript rejected (TS2554) and that never reached the TransformStream ([#12711](https://github.com/diegosouzapw/OmniRoute/pull/12711)) ([#12770](https://github.com/diegosouzapw/OmniRoute/pull/12770)) — thanks @HouMinXi
- **fix(dashboard):** pass `nodeMap` into Runtime `QuotaGroup` so a quota monitor in error/exhausted/alerting no longer throws `ReferenceError: nodeMap is not defined`. ([#12868](https://github.com/diegosouzapw/OmniRoute/pull/12868)) — thanks @HouMinXi
- Restricted API keys whose `allowedModels` lists a combo name no longer skip every combo member at pre-dispatch (`ALL_TARGETS_SKIPPED`). Inner-target filtering still applies when the allow-list is a provider prefix or `disableNonPublicModels` is on ([#12899](https://github.com/diegosouzapw/OmniRoute/pull/12899)). — thanks @HouMinXi
- **fix(combos):** Effort-suffixed combo members inherit the base model's `model_context_overrides` row so priority order is not inverted on large requests ([#12926](https://github.com/diegosouzapw/OmniRoute/pull/12926)) — thanks @HouMinXi
- **fix(catalog):** Picker-added `customModels` enter the dispatch-time live catalog so combo members and bare inference no longer 400 ([#12934](https://github.com/diegosouzapw/OmniRoute/pull/12934)) — thanks @HouMinXi
- **fix(docker):** re-derive the Docker build's worker-pool memory budget from the MEASURED ~4.5 GB per-process RSS (the issue owner's own VPS dmesg OOM-killer reproduction) instead of the stale 2560 MB/worker inference #11419 shipped, and lower `OMNIROUTE_BUILD_WORKERS` 3 → 2 so 1 parent + 1 page-data worker (2 processes × 4.5 GB = 9 GB) fits the 12.288 GB (75%) budget on a 16 GB GitHub Actions runner — the previous default (1 parent + 2 workers = 13.5 GB) still overcommitted the runner and kept "Publish to Docker Hub" failing with `cannot allocate memory` after #11419 merged (#7518). ([#11663](https://github.com/diegosouzapw/OmniRoute/pull/11663))
- **fix(electron):** the packaged Windows desktop build now passes the #7592 cold-restart smoke end to end. Five defects were found and fixed on the way: (1) optional-pack staging failed on any machine whose `tar` is GNU tar — it reads the drive letter in an absolute `-f C:\...` archive path as a remote rsh host (`Cannot connect to C:`), so staging now passes a bare filename with `cwd` at the tarball directory and surfaces tar stderr on failure; (2) the Electron `files` allowlist was missing `lib/loginHeaderCapture.js`, so the packaged main process crashed at startup with `Cannot find module './lib/loginHeaderCapture'`; (3) electron-builder ≥26 injects an `!**/node_modules/**` ignore into every extraResources pattern list that no positive filter can override, which silently dropped the staged runtime `node_modules` (including the better-sqlite3 N-API prebuild) from `resources/app` — a fresh v3.8.50 desktop build would have shipped with no native SQLite driver and reproduced the #7592 sql.js fallback on every machine; an `afterPack` hook now restores the staged `node_modules` after packing; (4) the packaged-app smoke harness redirected `USERPROFILE` into its temp DATA_DIR, but Electron resolves the Roaming profile from `%USERPROFILE%\AppData\Roaming\<name>` (USERPROFILE takes precedence over the APPDATA env var) and the path service throws instead of creating it, so `requestSingleInstanceLock()` returned false and the app exited(0) silently before `app.whenReady()` — the harness now pre-creates the derived tree, and `ensureSmokeEnvDirs` is exported and unit-tested; (5) the #7592 driver assertion parsed a `[DB] Driver: ...` line that the server's primary DB path never printed (only the unused `openDatabaseAsync()` did) — `getDbInstance()` now emits the same line on open so the guard can actually assert the native driver. Also: the smoke env-allowlist unit test hardcoded POSIX paths and could only pass on Linux/macOS; expectations are now host-agnostic, with new regression tests for the USERPROFILE-derived tree and `tarPack` under absolute Windows-style paths ([#11443](https://github.com/diegosouzapw/OmniRoute/pull/11443)) — thanks @jonlwheat2-gif
- fix(sse): stop the auto-combo candidates inspector from silently dropping model-locked/cooled-down rows (#9133) ([#11994](https://github.com/diegosouzapw/OmniRoute/pull/11994)) — thanks @hartmark
- **fix(providers):** Perplexity Web no longer turns upstream stream failures into successful assistant text; pre-content failures remain eligible for fallback, partial output ends with a structured sanitized error, and failed sessions are not persisted ([#12465](https://github.com/diegosouzapw/OmniRoute/pull/12465))
- **fix(video-bridge):** preserve chronological video and transcript fusion, source-frame timestamps, transcript provenance, full contact-sheet intervals, and pre-abort callback isolation ([#11681](https://github.com/diegosouzapw/OmniRoute/pull/11681))
- **Z.ai Web:** HTTP 200 streams carrying an upstream error now terminate with a structured failure instead of assistant text plus a normal stop, preserving partial output while allowing pre-content combo fallback. ([#12454](https://github.com/diegosouzapw/OmniRoute/pull/12454))
- Absorb `Error [AbortError]: request_signal_aborted` and DOMException AbortError shapes in the process-level client-abort crash guard so routine client disconnects no longer kill the server (exit code 7). ([#12165](https://github.com/diegosouzapw/OmniRoute/pull/12165)) — thanks @alvinveroy
- **fix(sse):** Treat Adapta Web `type:error` SSE events as sanitized non-stream failures instead of empty HTTP 200 completions. ([#12459](https://github.com/diegosouzapw/OmniRoute/pull/12459))
- **fix(autoCombo):** layer 3 (`models_dev_tier`) no longer scores retired models as premium, merge cross-provider capability rows deterministically, and inherit tier scores through variant ids ([#11508](https://github.com/diegosouzapw/OmniRoute/issues/11508)). `getModelsDevTierFitness` now vetoes ids present in `config/quality/model-lifecycle.json` with status `retired` before any other signal; `loadModelCapabilities()` aggregates per `model_id` (any-non-null-true for booleans, max for `limit_context`) instead of last-write-wins over SQLite's undefined row order; a capabilities miss retries through `resolveScoresAs()`'s base id and reports `models_dev_tier:inherited`, matching layers 12 (#11492). Adds `getModelsDevTierFitnessWithSource()` and an `invalidateCapabilitiesCache()` test/ops hook. ([#11598](https://github.com/diegosouzapw/OmniRoute/pull/11598)) — thanks @Meet6338-X
- fix(sse): accept Claude `content:[]` + `stop_reason: max_tokens`/`tool_use` in `detectMalformedNonStream` (match `isEmptyContentResponse`) so Claude Code `/model` probes with `max_tokens: 1` no longer become a false 502 ([#11568](https://github.com/diegosouzapw/OmniRoute/pull/11568)) — thanks @Seramicx
- **fix(security):** sanitize `request.failed` diagnostics before publishing them to live dashboard listeners and replay history, while keeping status, model, provider, latency, and internal call-log diagnostics intact. ([#12469](https://github.com/diegosouzapw/OmniRoute/pull/12469))
- **fix(pwa):** do not intercept dashboard navigations so Chrome can fall back from a stale HTTP/3 Alt-Svc advertisement (UDP :20128 is unpublished; F5 on a long-lived tab hung until a new tab opened a fresh TCP connection). ([#12767](https://github.com/diegosouzapw/OmniRoute/pull/12767)) — thanks @HouMinXi
- **fix(executors):** handle DuckDuckGo ERR_BN_LIMIT (418) without retrying — when the upstream returns `418 ERR_BN_LIMIT` (rate-limit/ban), the executor now returns the error immediately instead of burning another VQD acquisition that would only count against the IP limit. The retry logic for `418 ERR_CHALLENGE` (unsolved challenge) remains unchanged. ([#11598](https://github.com/diegosouzapw/OmniRoute/pull/11598)) ([#12110](https://github.com/diegosouzapw/OmniRoute/pull/12110)) — thanks @oyi77 / @Meet6338-X
- Fixed DuckDuckGo streaming responses losing JSON lines and UTF-8 characters split across network chunks. ([#11528](https://github.com/diegosouzapw/OmniRoute/pull/11528)) — thanks @pacocartones
- Harden generated Cloudflare, Vercel, and Deno relays by stripping hop-by-hop, framing, and proxy authentication headers before forwarding requests upstream. ([#11533](https://github.com/diegosouzapw/OmniRoute/pull/11533)) — thanks @pacocartones
- **feat(grok-cli):** Provider Limits shows grok-cli banked reset credits from `GetRemainingResets` (including a real zero; a failed RPC omits the row) and the existing View credits button now calls `ConsumerUiSvc/RedeemReset` for grok-cli. Live tokens use nested fields 10/20/30 (id + Timestamp), not compact 1/2/3. ([#12805](https://github.com/diegosouzapw/OmniRoute/pull/12805)) — thanks @HouMinXi
- **fix(memory):** Embedding Model Quick select, Embedding Source remote dropdown, and Rerank selector now list every configured provider with embedding/rerank support instead of only chat-catalog text matches plus OpenRouter live discovery; a generic OpenAI-compatible `/embeddings` + Cohere-compatible `/rerank` runtime fallback resolves any configured chat provider's embedding/rerank endpoint, so unlisted providers no longer fail with "Unknown embedding provider"; both memory selectors gained a free-text model override ([#11390](https://github.com/diegosouzapw/OmniRoute/pull/11390)) — thanks @rqzbeh
- Harden SQLite upgrades around the historical migration-074 version collision: missing discovery and inspector tables are replayed atomically, pre-existing databases (including setup-created skeletons) receive reusable content-addressed safety snapshots, and Node test/eval probes without `DATA_DIR` are isolated from the operator database. ([#12435](https://github.com/diegosouzapw/OmniRoute/pull/12435))
- **fix(dashboard):** Moonshot/Kimi Open Platform voucher and cash leftover percentages follow the bucket balance, so an empty wallet no longer paints those rows as 100% while Available is 0% ([#12733](https://github.com/diegosouzapw/OmniRoute/pull/12733)) — thanks @HouMinXi
- **fix(combo):** return non-retryable HTTP 400 when all candidates for a pinned native Codex turn are unavailable due to model-scoped lockout, terminating the turn cleanly while preserving turn continuity and enabling standard Combo routing on subsequent turns ([#12240](https://github.com/diegosouzapw/OmniRoute/pull/12240)) — thanks @mdigitalbh81
- Fixed auto-router strategies preserving the selected connection through ranking and dispatch when multiple connections share the same provider and model. ([#11530](https://github.com/diegosouzapw/OmniRoute/pull/11530)) — thanks @pacocartones
- Preserve Chat Completions JSON-object response formats when translating requests to the Responses API. ([#11529](https://github.com/diegosouzapw/OmniRoute/pull/11529)) — thanks @pacocartones
- Fix idempotency fingerprints so Responses API requests include `input` and other semantic generation fields using stable serialization, while excluding credentials and request metadata. ([#11532](https://github.com/diegosouzapw/OmniRoute/pull/11532)) — thanks @pacocartones
- **fix(api):** Generated API CLI commands now enforce required OpenAPI request bodies; Combo test commands forward the required `comboName` body, while API keys created by older writers after migration 149 preserve legacy allow-all Combo access without widening explicit empty allowlists — thanks @marcelokarval ([#12070](https://github.com/diegosouzapw/OmniRoute/pull/12070))
- **fix(grok-web):** treat upstream streaming failures as failures instead of successful assistant text: error-only streams now fail readiness with HTTP 502, while failures after legitimate content preserve that partial output and terminate through the sanitized stream failure path without a normal `stop` completion. ([#12458](https://github.com/diegosouzapw/OmniRoute/pull/12458))
- HuggingChat now turns HTTP 200 JSONL generation failures into a sanitized 502 before content, or a fixed public stream failure after partial output, so fallback and request persistence no longer record a false successful stop. ([#12456](https://github.com/diegosouzapw/OmniRoute/pull/12456))
- **fix(providers):** keep 1min.ai HTTP 200 stream errors out of assistant content, preserve partial output, and expose sanitized terminal errors so pre-content failures can fall back. ([#12466](https://github.com/diegosouzapw/OmniRoute/pull/12466))
- **fix(sessions):** preserve legacy Sessions data when the additive exclusive-session projection is unavailable, returning an empty projection and warning only once per contiguous outage instead of failing the endpoint; the Sessions badge intentionally reflects the merged legacy and exclusive row count introduced by [#11389](https://github.com/diegosouzapw/OmniRoute/pull/11389) ([#11469](https://github.com/diegosouzapw/OmniRoute/pull/11469)) — thanks @KaspaPulse
- Render Nimble Search with the generic provider icon and serve Opper's proven logo locally. ([#12415](https://github.com/diegosouzapw/OmniRoute/pull/12415))
- **fix(quality):** `config/quality/provider-assets-provenance.jsonl` was stale against the physical `public/providers/` tree (still listing 225 assets and 5 duplicate-content groups after several provider-retirement PRs removed 85 icon files), which would have made `check:provider-asset-provenance` fail on the next full run. Reconciled the manifest to the real 142-file snapshot (142 asset records, 1 duplicate-content group, 2 newly-registered files marked `unresolved` pending provenance research), repinned `auditedCommit`, and updated the regression test's hardcoded counts to match. ([#11876](https://github.com/diegosouzapw/OmniRoute/pull/11876))
- fix(ui): wrap quota card footer action buttons to prevent clipping Refresh button on narrow cards — when extra action buttons such as `View credits` are present on Codex cards, allow the footer row to flex-wrap with `shrink-0` buttons so `Refresh now` remains visible and clickable across all responsive card widths ([#11464](https://github.com/diegosouzapw/OmniRoute/pull/11464)) — thanks @adevwithpurpose
- Keep Antigravity Gemini usable when the same connection's Claude weekly quota is empty; generic quota cache stays per-connection for every other provider. ([#12637](https://github.com/diegosouzapw/OmniRoute/pull/12637)) — thanks @HouMinXi
- Fixed Tailscale tunnel setup to reject ports outside the valid 1-65535 range and safely install Windows MSI packages from paths containing apostrophes. ([#11534](https://github.com/diegosouzapw/OmniRoute/pull/11534)) — thanks @pacocartones
- Classify Cloudflared and Tailscale process-management routes as local-only and spawn-capable while preserving remote access to read-only tunnel status endpoints. ([#11531](https://github.com/diegosouzapw/OmniRoute/pull/11531)) — thanks @pacocartones
- 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(dashboard):** The Combos page usage guide now reads its dismissal through `useSyncExternalStore` instead of correcting SSR state inside an effect, removing an extra commit of the page tree on every load (and the `react-hooks/set-state-in-effect` error it raised). ([#12671](https://github.com/diegosouzapw/OmniRoute/pull/12671))
- 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, `electron-release.yml` gained a `build_ref` dispatch input and stops regenerating release notes on a re-attach dispatch, and the npm publish workflow attaches the SBOM to the GitHub Release on dispatch publishes too — so the v3.8.51 tag ships every desktop asset and the SBOM like v3.8.49 did ([#12022](https://github.com/diegosouzapw/OmniRoute/pull/12022))
- Drained the reds every PR against `release/v3.8.51` was born with: documented the three service-worker build-id variables, registered the six retirement/tunnel tests with the mutation gate, approved `eslint-plugin-react-hooks` in the dependency allowlist, added the six `combo.sort.*` strings to `vi` and `pt-BR`, pointed the ChatGPT Web doc at the real migration-168 test, worded the g4f hint around the member key, and realigned four tests to the retired-provider catalog and the legacy-schema fixtures the retirement migrations touch. ([#11940](https://github.com/diegosouzapw/OmniRoute/pull/11940))
- **fix(providers):** Claude, Grok, LMArena, Notion, and Perplexity web-cookie transports now use pooled `wreq-js` 3.2 instead of the native sidecar, with all nine supported bindings pinned and audited, and the applicable platform binding plus native-license evidence included in each release artifact ([#12429](https://github.com/diegosouzapw/OmniRoute/pull/12429), supersedes [#11753](https://github.com/diegosouzapw/OmniRoute/pull/11753)).
- **fix(providers):** Zed Hosted streaming failures now trigger fallback before content and end partial streams with a sanitized structured error instead of fake assistant text and a normal-success stop. ([#12455](https://github.com/diegosouzapw/OmniRoute/pull/12455))
- **fix(test):** capture hook result after render (direct commit `b85d0abb0b`)
- **fix(sync):** repair the two regressions the v3.8.50 sync-back left on release/v3.8.51 (direct commit `5ade9e0851`)
- **revert(models):** drop the memo reset hook ported from #10055 (direct commit `282de841ae`)
- **fix(typecheck):** guard the optional connectionId before the reactive model sync (direct commit `f2825bad75`)
- **fix(quota-share):** release the winner's reserved in-flight slot (#11371) ([#11408](https://github.com/diegosouzapw/OmniRoute/pull/11408)) — thanks @oyi77
- **fix(providers):** stop silently dropping reasoning effort on opencode families (#10788) ([#11409](https://github.com/diegosouzapw/OmniRoute/pull/11409)) — thanks @oyi77
- **fix(combo):** enable genuine quota-aware routing for generic providers (antigravity, claude, etc.) ([#11411](https://github.com/diegosouzapw/OmniRoute/pull/11411)) — thanks @HouMinXi
- **fix(oauth):** allow expired token retry before deactivation and activate expired retry loop (#P0) ([#11414](https://github.com/diegosouzapw/OmniRoute/pull/11414)) — thanks @HouMinXi
- **perf(executors):** lazy-load the executor registry — defer class imports + construction to first use (#11220) ([#11421](https://github.com/diegosouzapw/OmniRoute/pull/11421)) — thanks @oyi77
- **fix:** make release/v3.8.50 CI gates green (run 32786966560 root causes) ([#11450](https://github.com/diegosouzapw/OmniRoute/pull/11450)) — thanks @jonlwheat2-gif
- **fix(sse):** exact-domain cookie match and origin-equality URL assertions ([#11452](https://github.com/diegosouzapw/OmniRoute/pull/11452)) — thanks @hartmark
- **fix(providers):** send Responses API payload during OpenAI-compatible validation ([#11454](https://github.com/diegosouzapw/OmniRoute/pull/11454)) — thanks @tuandinh0801
- **fix(command-code):** fallback to /alpha/generate for Go plan without Provider API access ([#11455](https://github.com/diegosouzapw/OmniRoute/pull/11455)) — thanks @Chewji9875
- **fix(responses-continuation):** fail closed on a log-truncated stored input/output array ([#11473](https://github.com/diegosouzapw/OmniRoute/pull/11473)) — thanks @hartmark
- **fix(cli):** write jcode settings as [providers.omniroute] in config.toml ([#11484](https://github.com/diegosouzapw/OmniRoute/pull/11484)) — thanks @hongnoul
- **fix(logging):** raise chat-log array truncation cap to 1000 and unify duplicate implementations ([#11499](https://github.com/diegosouzapw/OmniRoute/pull/11499)) — thanks @hartmark
- **fix:** restore live websocket URL helpers ([#11509](https://github.com/diegosouzapw/OmniRoute/pull/11509)) — thanks @PauloFH
- **perf(core):** eliminate async waterfalls across home, cache, and db operations (#11396) ([#11512](https://github.com/diegosouzapw/OmniRoute/pull/11512)) — thanks @HouMinXi
- **fix(kiro):** add runtime.us-east-1.kiro.dev as first-attempt endpoint ([#11517](https://github.com/diegosouzapw/OmniRoute/pull/11517)) — thanks @rifqiawl
- **fix(providers):** remove duplicate 128-tool truncation in OpencodeExecutor (#11444) ([#11518](https://github.com/diegosouzapw/OmniRoute/pull/11518)) — thanks @HouMinXi
- **fix(providers):** poll onboardUser LRO before classifying BYOP ([#11519](https://github.com/diegosouzapw/OmniRoute/pull/11519)) — thanks @rifqiawl
- **fix(translator):** preserve exact tool name casing for Claude tool_use payloads (#11487) ([#11520](https://github.com/diegosouzapw/OmniRoute/pull/11520)) — thanks @HouMinXi
- **fix(kimi-web):** align health probe with executor domain ([#11521](https://github.com/diegosouzapw/OmniRoute/pull/11521)) — thanks @pacocartones
- **fix(cli):** preserve existing admin password during setup ([#11522](https://github.com/diegosouzapw/OmniRoute/pull/11522)) — thanks @pacocartones
- **fix(mitm):** keep Windows DNS elevation off the POSIX sudo path (#11430) ([#11542](https://github.com/diegosouzapw/OmniRoute/pull/11542)) — thanks @official-burak
- **fix(sse):** scale chat admission by ingest byte budget instead of a fixed request count ([#11548](https://github.com/diegosouzapw/OmniRoute/pull/11548)) — thanks @TheDemonTuan
- **fix:** accept Gemini Web cookie exports ([#11554](https://github.com/diegosouzapw/OmniRoute/pull/11554)) — thanks @rafacpti23
- **fix(i18n):** complete Brazilian Portuguese translations ([#11558](https://github.com/diegosouzapw/OmniRoute/pull/11558)) — thanks @rafacpti23
- **fix(sse):** explicit types for openai-responses pureHelpers — clears last failing core typecheck gate ([#11567](https://github.com/diegosouzapw/OmniRoute/pull/11567)) — thanks @jonlwheat2-gif
- **fix(opencode):** hydrate Proxy Pool references for no-auth connections ([#11584](https://github.com/diegosouzapw/OmniRoute/pull/11584)) — thanks @AStupidBear
- **fix(ci):** resolve inherited release/v3.8.51 base-red CI failures ([#11608](https://github.com/diegosouzapw/OmniRoute/pull/11608)) — thanks @jonlwheat2-gif
- **fix(db):** fallback load STORAGE_ENCRYPTION_KEY from env files ([#11614](https://github.com/diegosouzapw/OmniRoute/pull/11614)) — thanks @benzntech
- **fix(providers):** mark Brave Search as no longer having a free tier ([#11621](https://github.com/diegosouzapw/OmniRoute/pull/11621)) — thanks @turbolego
- **fix:** pass tool result content through as string in OpenAI/Claude -> Gemini translation ([#11624](https://github.com/diegosouzapw/OmniRoute/pull/11624)) — thanks @Hsia97
- **fix(sse):** scope credential health to exact key ([#11628](https://github.com/diegosouzapw/OmniRoute/pull/11628)) — thanks @KaspaPulse
- **fix(install):** guard prepare script against missing husky ([#11634](https://github.com/diegosouzapw/OmniRoute/pull/11634)) — thanks @Prajeeth-12
- **fix(security):** redact NVIDIA_API_KEY at the log sink in the ad-hoc diag script ([#11639](https://github.com/diegosouzapw/OmniRoute/pull/11639))
- **fix(types):** clear the two type regressions red on release/v3.8.51 ([#11640](https://github.com/diegosouzapw/OmniRoute/pull/11640)) — thanks @jonlwheat2-gif
- **fix(build):** resolve workspace: protocol dependencies to concrete semver versions during prepublish (#11540) ([#11641](https://github.com/diegosouzapw/OmniRoute/pull/11641)) — thanks @HouMinXi
- **fix(search):** properly resolve configured search connection in /v1/responses pipeline (#11524) ([#11642](https://github.com/diegosouzapw/OmniRoute/pull/11642)) — thanks @HouMinXi
- **fix(authz):** trust cf-connecting-ip for via-proxy detection when peer is Cloudflare edge (#11514) ([#11643](https://github.com/diegosouzapw/OmniRoute/pull/11643)) — thanks @HouMinXi
- **fix(security):** compare the OIDC state cookie in constant time ([#11646](https://github.com/diegosouzapw/OmniRoute/pull/11646))
- **fix(security):** namespace the dedup hash by the calling API key ([#11649](https://github.com/diegosouzapw/OmniRoute/pull/11649))
- **fix(models):** gate the Codex-native catalog loop on prefix mode (#11632) ([#11662](https://github.com/diegosouzapw/OmniRoute/pull/11662)) — thanks @xiaoyaner0201
- **fix(dashboard):** project Codex routing quota in account pools ([#11666](https://github.com/diegosouzapw/OmniRoute/pull/11666)) — thanks @KaspaPulse
- **fix(ddgw):** normaliza system no upstream da release v3.8.51 ([#11692](https://github.com/diegosouzapw/OmniRoute/pull/11692)) — thanks @RhianB14
- **fix(memory):** honor category filter in GET /api/memory ([#11699](https://github.com/diegosouzapw/OmniRoute/pull/11699)) — thanks @Sabeekhann
- **fix(ci):** detect stale workspace lockfile entries ([#11714](https://github.com/diegosouzapw/OmniRoute/pull/11714)) — thanks @pacocartones
- **fix(ci):** stamp dist/BUILD_SHA before the npm publish provenance gate ([#11721](https://github.com/diegosouzapw/OmniRoute/pull/11721))
- **fix(docker):** decouple the best-effort Bun image from the release manifest ([#11724](https://github.com/diegosouzapw/OmniRoute/pull/11724))
- **perf(compression):** avoid cloning memo misses twice ([#11727](https://github.com/diegosouzapw/OmniRoute/pull/11727)) — thanks @RaviTharuma
- **fix(docker):** let the bun digest artifact be absent, not fatal ([#11740](https://github.com/diegosouzapw/OmniRoute/pull/11740))
- **fix(shared):** block cloud-metadata hosts under default remote-image guard ([#11755](https://github.com/diegosouzapw/OmniRoute/pull/11755)) — thanks @santosraju99-hub
- **fix(api):** preserve caller-provided X-Correlation-Id on chat completions ([#11760](https://github.com/diegosouzapw/OmniRoute/pull/11760)) — thanks @aniruddhaadak80
- **fix(resilience):** lock permanently retired models instead of short backoff (Gemini ban prevention) ([#11762](https://github.com/diegosouzapw/OmniRoute/pull/11762)) — thanks @turbolego
- **fix:** mark Vercel AI Gateway as passthroughModels ([#11771](https://github.com/diegosouzapw/OmniRoute/pull/11771)) — thanks @Rahulsharma0810 / @Rahulsharma0810
- **fix(resilience):** stop hammering permanently-moved endpoints and billing-suspended accounts ([#11774](https://github.com/diegosouzapw/OmniRoute/pull/11774)) — thanks @turbolego
- **fix(lease):** remove global static reservation and gate routing on live active lease occupancy ([#11775](https://github.com/diegosouzapw/OmniRoute/pull/11775)) — thanks @TheDemonTuan
- **fix(ci):** size the install-upgrade gate to a measured run, and log the pack cost ([#11776](https://github.com/diegosouzapw/OmniRoute/pull/11776))
- **fix(pwa):** stop serving the stale shell after deploys ([#11779](https://github.com/diegosouzapw/OmniRoute/pull/11779)) — thanks @HouMinXi
- **fix(combo):** lock GitHub models rejected as "not supported" for future requests ([#11781](https://github.com/diegosouzapw/OmniRoute/pull/11781)) — thanks @turbolego
- **fix(cli):** probe both IPv4 and IPv6 loopback for server readiness (#11766) ([#11794](https://github.com/diegosouzapw/OmniRoute/pull/11794)) — thanks @vermasomesh835
- **fix(sse):** preserve CLIProxyAPI wrappers per connection ([#11797](https://github.com/diegosouzapw/OmniRoute/pull/11797)) — thanks @RaviTharuma
- **fix(providers):** honor model auto-fetch opt-in on create ([#11798](https://github.com/diegosouzapw/OmniRoute/pull/11798)) — thanks @RaviTharuma
- **fix(dashboard):** make model auto-fetch order independent ([#11805](https://github.com/diegosouzapw/OmniRoute/pull/11805)) — thanks @RaviTharuma
- **fix(docker):** make GHA cache exports best effort ([#11806](https://github.com/diegosouzapw/OmniRoute/pull/11806)) — thanks @RaviTharuma
- **fix(kiro):** do not permanently ban on 'User is not authorized to make this call' ([#11809](https://github.com/diegosouzapw/OmniRoute/pull/11809)) — thanks @Deftera186
- **fix(sse):** keep cache-write tokens in OpenAI-shaped usage ([#11814](https://github.com/diegosouzapw/OmniRoute/pull/11814)) — thanks @TheDemonTuan
- **fix(docs):** resolve relative markdown and wiki links across Fumadocs and GitHub wiki ([#11834](https://github.com/diegosouzapw/OmniRoute/pull/11834)) — thanks @AndrianBalanescu
- **fix(chat):** preserve unstripped model string for passthrough provider routing ([#11840](https://github.com/diegosouzapw/OmniRoute/pull/11840)) — thanks @raheemuddin786
- **fix(mcp):** avoid async initialization inside sync esm wrappers (#11569) ([#11849](https://github.com/diegosouzapw/OmniRoute/pull/11849)) — thanks @HouMinXi
- **fix(sse):** preserve coherent markdown token boundaries across stream translation chunks (#11606) ([#11851](https://github.com/diegosouzapw/OmniRoute/pull/11851)) — thanks @HouMinXi
- **fix(ci):** run the install-upgrade gate on disk, not on the /tmp tmpfs ([#11855](https://github.com/diegosouzapw/OmniRoute/pull/11855))
- **fix(skills):** expand shorthand property types in injected tool schemas ([#11857](https://github.com/diegosouzapw/OmniRoute/pull/11857)) — thanks @alvinveroy
- **fix(ci):** publish npm from a hosted runner so provenance is accepted ([#11877](https://github.com/diegosouzapw/OmniRoute/pull/11877))
- **fix(lint):** pin react hooks plugin to 7.0.1 ([#11886](https://github.com/diegosouzapw/OmniRoute/pull/11886))
- **fix(ci):** surface and clear release-green ESLint failure ([#11890](https://github.com/diegosouzapw/OmniRoute/pull/11890)) — thanks @MumuTW
- **fix(guardrails):** prevent duplicate prompt-injection-guard log output ([#11936](https://github.com/diegosouzapw/OmniRoute/pull/11936)) — thanks @patrykkopycinski
- **fix(auth):** downgrade expected transient states from warn to debug ([#11937](https://github.com/diegosouzapw/OmniRoute/pull/11937)) — thanks @patrykkopycinski
- **fix(codex):** fail over image generation for imported free plans ([#11948](https://github.com/diegosouzapw/OmniRoute/pull/11948)) — thanks @Bl0ck154
- **fix(codex):** normalize non-stream responses ([#11951](https://github.com/diegosouzapw/OmniRoute/pull/11951)) — thanks @Bl0ck154
- **fix(images):** forward Antigravity image size ([#11952](https://github.com/diegosouzapw/OmniRoute/pull/11952)) — thanks @Bl0ck154
- **fix(db):** invalidate connection cache after upsert ([#11953](https://github.com/diegosouzapw/OmniRoute/pull/11953)) — thanks @Bl0ck154
- **fix(codex):** restore imported account state ([#11954](https://github.com/diegosouzapw/OmniRoute/pull/11954)) — thanks @Bl0ck154
- **fix(sse):** give extended-thinking targets the reasoning readiness budget ([#11959](https://github.com/diegosouzapw/OmniRoute/pull/11959)) — thanks @abhisheksharma2411
- **fix(dashboard):** use opaque background and readable text color on cost chart tooltips ([#11960](https://github.com/diegosouzapw/OmniRoute/pull/11960)) — thanks @ZaimMarzuki
- **fix(antigravity):** send complete loadCodeAssist metadata (ideType/platform/pluginType as numeric enums) ([#11969](https://github.com/diegosouzapw/OmniRoute/pull/11969)) — thanks @rifqiawl
- **fix(core):** resolve DB init race condition and reasoning translation ([#12003](https://github.com/diegosouzapw/OmniRoute/pull/12003)) — thanks @raheemuddin786
- **fix(admission):** exclude reclaimable page cache from the cgroup pressure ratio ([#12017](https://github.com/diegosouzapw/OmniRoute/pull/12017)) — thanks @HouMinXi
- **fix(build):** prune non-production trees in NFT trace excludes and tsconfig ([#12028](https://github.com/diegosouzapw/OmniRoute/pull/12028)) — thanks @Chewji9875
- **fix(migrations):** add renamed migration compatibility for 056/073/077/101 ([#12036](https://github.com/diegosouzapw/OmniRoute/pull/12036)) — thanks @oyi77
- **fix(sse):** default crash-guard logger to console.warn, not console ([#12042](https://github.com/diegosouzapw/OmniRoute/pull/12042)) — thanks @HouMinXi
- **fix(catalog):** derive vision/modalities for built-in auto combos from effective target pool ([#12046](https://github.com/diegosouzapw/OmniRoute/pull/12046)) — thanks @Prajeeth-12
- **fix(release):** the packaged-app smoke verifies the database opened, not a driver line the primary path never prints (twin of #12032) ([#12047](https://github.com/diegosouzapw/OmniRoute/pull/12047))
- **fix(barrel):** migrate src/app/ off the @/lib/localDb barrel import (#11795 Phase 2) ([#12051](https://github.com/diegosouzapw/OmniRoute/pull/12051)) — thanks @jonlwheat2-gif
- **fix(barrel):** migrate src/lib/ off the localDb barrel to direct db imports (#59) ([#12052](https://github.com/diegosouzapw/OmniRoute/pull/12052)) — thanks @jonlwheat2-gif
- **fix(barrel):** migrate open-sse, src/shared, src/sse, src/models, src/domain off the @/lib/localDb barrel import (#11795 Phase 4) ([#12053](https://github.com/diegosouzapw/OmniRoute/pull/12053)) — thanks @jonlwheat2-gif
- **fix(barrel):** delete the @/lib/localDb barrel — every consumer migrated (#11795 Phase 5) ([#12055](https://github.com/diegosouzapw/OmniRoute/pull/12055)) — thanks @jonlwheat2-gif
- **fix(radar):** restore D12 public boundary ([#12057](https://github.com/diegosouzapw/OmniRoute/pull/12057))
- **fix(dev):** bound webpack and Tailwind scans ([#12075](https://github.com/diegosouzapw/OmniRoute/pull/12075)) — thanks @backryun
- **fix(dev):** isolate root layout settings reads ([#12076](https://github.com/diegosouzapw/OmniRoute/pull/12076)) — thanks @backryun
- **fix(dev):** reduce instrumentation executor fan-out (phase 3) ([#12078](https://github.com/diegosouzapw/OmniRoute/pull/12078)) — thanks @backryun
- **fix(dev):** make logging resources HMR-singleton ([#12079](https://github.com/diegosouzapw/OmniRoute/pull/12079)) — thanks @backryun
- **fix(sse):** keep unavailable forced connections scoped ([#12080](https://github.com/diegosouzapw/OmniRoute/pull/12080)) — thanks @keeltrace
- **fix(dev):** isolate batch dispatch from instrumentation ([#12081](https://github.com/diegosouzapw/OmniRoute/pull/12081)) — thanks @backryun
- **fix(ci):** reconcile release test contract drift ([#12082](https://github.com/diegosouzapw/OmniRoute/pull/12082)) — thanks @backryun
- **fix(ci):** accept CVE-2025-68121 in the prebuilt tls-client .so, auto-close base-red issues, guard Scorecard on the default branch ([#12085](https://github.com/diegosouzapw/OmniRoute/pull/12085))
- **fix(db):** drop three consumer-less 1proxy exports — dead-code base-red on release/v3.8.51 after the barrel deletion (#12055) ([#12087](https://github.com/diegosouzapw/OmniRoute/pull/12087))
- **fix(api):** clear the six API-route TypeScript regressions the new gate landed red on ([#12094](https://github.com/diegosouzapw/OmniRoute/pull/12094))
- **fix(release):** never let the tag-push Create Release append auto notes to the curated body ([#12096](https://github.com/diegosouzapw/OmniRoute/pull/12096))
- **fix(sse):** honor CLIProxyAPI environment API key ([#12099](https://github.com/diegosouzapw/OmniRoute/pull/12099)) — thanks @RaviTharuma
- **fix(oauth):** bind Google refresh to the client that issued the token ([#12106](https://github.com/diegosouzapw/OmniRoute/pull/12106)) — thanks @HouMinXi
- **fix(dashboard):** make RequestLoggerDetail loadable outside Next — CSS via globals.css + CJS/ESM interop (#11703 base-reds) ([#12114](https://github.com/diegosouzapw/OmniRoute/pull/12114))
- **fix(leases):** project status lease row to lease columns so joined connection PII never escapes ([#12115](https://github.com/diegosouzapw/OmniRoute/pull/12115)) — thanks @geek007git
- **fix(plugins):** do not kill the plugin process when a fire-and-forget hook times out ([#12116](https://github.com/diegosouzapw/OmniRoute/pull/12116)) — thanks @geek007git
- **fix(guardrails):** restore injection-guard logging on middleware-only routes ([#12117](https://github.com/diegosouzapw/OmniRoute/pull/12117)) — thanks @geek007git
- **fix(plugins):** refresh stored manifest from disk on activate so new hook fields reach existing installs ([#12120](https://github.com/diegosouzapw/OmniRoute/pull/12120)) — thanks @geek007git
- **fix(codex):** preserve existing provider state when bulk-import upserts a matching connection ([#12122](https://github.com/diegosouzapw/OmniRoute/pull/12122)) — thanks @geek007git
- **fix(ci):** clear the two base-reds the 2026-08-30 merge batch left on release/v3.8.51 (round 3) ([#12123](https://github.com/diegosouzapw/OmniRoute/pull/12123))
- **fix(sse):** strip type:'custom' from Claude tools on agentrouter dispatch ([#12126](https://github.com/diegosouzapw/OmniRoute/pull/12126)) — thanks @wahidsadik371-coder
- **fix(sse):** stop advertising video providers the dispatcher cannot run ([#12131](https://github.com/diegosouzapw/OmniRoute/pull/12131)) — thanks @quiterunner-commits
- **fix(search):** treat HTTP 432 and plan limit errors as transient cooldown ([#12139](https://github.com/diegosouzapw/OmniRoute/pull/12139)) — thanks @HouMinXi
- **fix(sse):** refuse an AI Horde queue that cannot fit the request budget ([#12143](https://github.com/diegosouzapw/OmniRoute/pull/12143)) — thanks @quiterunner-commits
- **fix(ci):** clear the base-reds the afternoon merge batch left on release/v3.8.51 (round 5: provider count 352, TS2554/TS2677) ([#12144](https://github.com/diegosouzapw/OmniRoute/pull/12144))
- **fix(translator):** strip neutral tool_choice when tools absent in Responses-to-Chat (#12141) ([#12166](https://github.com/diegosouzapw/OmniRoute/pull/12166)) — thanks @HouMinXi
- **fix(guardrails):** pass providerId to getResolvedModelCapabilities in checkComboVision (#12112) ([#12169](https://github.com/diegosouzapw/OmniRoute/pull/12169)) — thanks @HouMinXi
- **fix(translator):** strip plaintext reasoning content for opaque responses backends (#12128) ([#12171](https://github.com/diegosouzapw/OmniRoute/pull/12171)) — thanks @HouMinXi
- **perf(stream):** compile hot-path regexes once, bound token caches, fix quadratic buffering ([#12179](https://github.com/diegosouzapw/OmniRoute/pull/12179)) — thanks @opensource-elearning
- **fix:** resolve compression worker file using runtime anchors instead of… ([#12183](https://github.com/diegosouzapw/OmniRoute/pull/12183)) — thanks @NightStalker-87
- **fix:** prevent Claude Code session kills via liveness-aware readiness + auto model echo ([#12189](https://github.com/diegosouzapw/OmniRoute/pull/12189)) — thanks @opensource-elearning
- **fix(translator):** preserve falsy primitive values in Gemini and Antigravity function response results ([#12191](https://github.com/diegosouzapw/OmniRoute/pull/12191)) — thanks @Siva010
- **fix(oauth):** mark empty Antigravity projectId as degraded and clear stale errors (#11284) ([#12205](https://github.com/diegosouzapw/OmniRoute/pull/12205)) — thanks @HouMinXi
- **fix(providers):** gate the Codex auto-ping usage read on the shared quota throttle ([#12209](https://github.com/diegosouzapw/OmniRoute/pull/12209)) — thanks @abhisheksharma2411
- **fix(combos):** prioritize SQLite row id over inner JSON id and notify delete errors ([#12213](https://github.com/diegosouzapw/OmniRoute/pull/12213)) — thanks @HouMinXi
- **fix(providers):** mark featherless.ai as no longer having a free tier (`hasFree: false`) ([#12216](https://github.com/diegosouzapw/OmniRoute/pull/12216)) — thanks @turbolego
- **fix(deepseek-web):** stop Turbopack dev panic in the PoW worker path resolver ([#12221](https://github.com/diegosouzapw/OmniRoute/pull/12221)) — thanks @hartmark
- **fix(chat-admission):** clarify local 503 source ([#12223](https://github.com/diegosouzapw/OmniRoute/pull/12223)) — thanks @keeltrace
- **fix(dev):** silence webpack runtime module warnings ([#12228](https://github.com/diegosouzapw/OmniRoute/pull/12228)) — thanks @backryun
- **fix(db):** use module.require for CommonJS runtime driver loading ([#12230](https://github.com/diegosouzapw/OmniRoute/pull/12230)) — thanks @raheemuddin786
- **fix(memory):** honest probe-driven FTS5 keyword status + memory_id rowid sync ([#12231](https://github.com/diegosouzapw/OmniRoute/pull/12231)) — thanks @raheemuddin786
- **fix(system):** propagate abort signal to stream reader in HTTP version checks ([#12232](https://github.com/diegosouzapw/OmniRoute/pull/12232)) — thanks @raheemuddin786
- **fix(resilience):** isolate local host execution errors from provider circuit breakers ([#12233](https://github.com/diegosouzapw/OmniRoute/pull/12233)) — thanks @raheemuddin786
- **fix(sse):** sort injected tools deterministically for prompt caching ([#12234](https://github.com/diegosouzapw/OmniRoute/pull/12234)) — thanks @raheemuddin786
- **fix(combo):** always clear the loop-safety timer, not just on the happy path (#11804) ([#12245](https://github.com/diegosouzapw/OmniRoute/pull/12245))
- **fix(quality):** base-red pair — stryker tap registration + turn-pin suites aligned to the window gate ([#12255](https://github.com/diegosouzapw/OmniRoute/pull/12255)) — thanks @backryun
- **fix(dev):** qualify Turbopack runtime boundaries ([#12258](https://github.com/diegosouzapw/OmniRoute/pull/12258)) — thanks @backryun
- **fix(combo):** fall back to full pool when collapsed sole survivor is context-too-small ([#12278](https://github.com/diegosouzapw/OmniRoute/pull/12278)) — thanks @opensource-elearning
- **fix(combo):** bound the pre-dispatch unavailable skip so a stale label cannot dark a pool (#12168) ([#12285](https://github.com/diegosouzapw/OmniRoute/pull/12285))
- **fix(security):** strip Qwen/Alibaba console-session cookies from provider API responses ([#12287](https://github.com/diegosouzapw/OmniRoute/pull/12287))
- **fix(usage):** console-aware Token Plan guidance + subscription hint on bailian 401 ([#12288](https://github.com/diegosouzapw/OmniRoute/pull/12288))
- **fix(resourcePressure):** log numeric detail on every rejection, recover faster ([#12293](https://github.com/diegosouzapw/OmniRoute/pull/12293)) — thanks @hartmark
- **fix(release):** drain the 2026-09-01 base-red window — passthrough usage regression + radarPage i18n keys ([#12327](https://github.com/diegosouzapw/OmniRoute/pull/12327))
- **fix(ci):** openapi-security-tiers checker must honor routeGuard patterns + imported prefixes ([#12350](https://github.com/diegosouzapw/OmniRoute/pull/12350)) — thanks @ggiak
- **perf(ci):** cache node_modules in the npm-ci-retry composite (#8084 D3) ([#12408](https://github.com/diegosouzapw/OmniRoute/pull/12408))
- **revert(ui):** point CTAs back at their real destinations ([#12410](https://github.com/diegosouzapw/OmniRoute/pull/12410))
- **fix(release):** drain the 2026-09-02 base-red — rerank-providers import + api-typecheck baseline ratchet ([#12414](https://github.com/diegosouzapw/OmniRoute/pull/12414))
- **fix(types):** make system prompt injection noImplicitAny-safe ([#12416](https://github.com/diegosouzapw/OmniRoute/pull/12416))
- **fix(quality):** validate typecheck baseline schema ([#12419](https://github.com/diegosouzapw/OmniRoute/pull/12419))
- **fix(memory):** resolve rerank provider node cache import ([#12421](https://github.com/diegosouzapw/OmniRoute/pull/12421))
- **fix(ci):** repair release-root regressions — pack dedup, web-session syntax, uc-image ids ([#12423](https://github.com/diegosouzapw/OmniRoute/pull/12423)) — thanks @backryun
- **fix(providers):** separate MaxAI and UC credential contracts ([#12431](https://github.com/diegosouzapw/OmniRoute/pull/12431))
- **fix(cli):** preserve Claude settings on config set ([#12432](https://github.com/diegosouzapw/OmniRoute/pull/12432)) — thanks @KrzysiekSko
- **fix(providers):** repair the maxai credential block truncated by merge auto-resolve ([#12433](https://github.com/diegosouzapw/OmniRoute/pull/12433))
- **fix(quality):** record the 2026-09-02 merged growth in the file-size baseline ([#12434](https://github.com/diegosouzapw/OmniRoute/pull/12434))
- **fix(uc):** route UC error strings through sanitizeErrorMessage; allowlist the retired codex id ([#12437](https://github.com/diegosouzapw/OmniRoute/pull/12437))
- **fix(adapta):** redact streamed upstream errors ([#12438](https://github.com/diegosouzapw/OmniRoute/pull/12438))
- **fix(sse):** remap non-contiguous upstream tool_calls index to a gap-free output_index ([#12445](https://github.com/diegosouzapw/OmniRoute/pull/12445)) — thanks @hartmark
- **fix(quality):** detect duplicate tool_calls entries in one response ([#12446](https://github.com/diegosouzapw/OmniRoute/pull/12446)) — thanks @hartmark
- **fix(conversations):** resolve turn content OmniRoute never sends back to the client ([#12447](https://github.com/diegosouzapw/OmniRoute/pull/12447)) — thanks @hartmark
- **fix(combo):** do not treat credits-exhausted 401 as auth skip ([#12449](https://github.com/diegosouzapw/OmniRoute/pull/12449)) — thanks @RaviTharuma
- **fix(auth):** do not park healthy quota accounts as expired ([#12452](https://github.com/diegosouzapw/OmniRoute/pull/12452)) — thanks @RaviTharuma
- **fix(responses-continuation):** fail closed on a collector-truncated, empty output array ([#12460](https://github.com/diegosouzapw/OmniRoute/pull/12460)) — thanks @hartmark
- **fix(logging):** raise the SSE payload collector's default cap ([#12461](https://github.com/diegosouzapw/OmniRoute/pull/12461)) — thanks @hartmark
- **fix(resilience):** surface Responses failed.error.message in 502s ([#12472](https://github.com/diegosouzapw/OmniRoute/pull/12472)) — thanks @RaviTharuma
- **fix(security):** unbiased maxai X-Random nonce + stricter URL/regex assertions ([#12502](https://github.com/diegosouzapw/OmniRoute/pull/12502))
- **fix(video):** re-anchor transcript log-redaction so PII/credential maskers can't reopen the leak (#12150 P1 follow-up) ([#12503](https://github.com/diegosouzapw/OmniRoute/pull/12503))
- **fix(monitoring):** serve cached credentialHealth off the request path ([#12533](https://github.com/diegosouzapw/OmniRoute/pull/12533)) — thanks @RaviTharuma
- **fix(quota):** keep Antigravity Gemini usable when Claude weekly is empty ([#12566](https://github.com/diegosouzapw/OmniRoute/pull/12566)) — thanks @HouMinXi
- **fix(authz):** hard-gate every credential export and CLI-config write (GHSA-5926-2w35-7h4q) ([#12600](https://github.com/diegosouzapw/OmniRoute/pull/12600))
- **fix(docs):** restore the Next build — REMOVED_PROVIDERS.md had no frontmatter (base-red #12581) ([#12610](https://github.com/diegosouzapw/OmniRoute/pull/12610))
- **fix(quality):** re-point the zcodeProtocol public-creds allowlist to line 313 ([#12615](https://github.com/diegosouzapw/OmniRoute/pull/12615))
- **fix(security):** close 3 advisories — search baseUrl exfil, sk- in the error sanitizer, bifrost relay header leak ([#12620](https://github.com/diegosouzapw/OmniRoute/pull/12620))
- **fix(providers):** sanitize boolean required and nested bare maps for Gemini (#12269) ([#12624](https://github.com/diegosouzapw/OmniRoute/pull/12624)) — thanks @HouMinXi
- **fix(resilience):** keep Overloaded STREAM_EARLY_EOF off the provider breaker ([#12626](https://github.com/diegosouzapw/OmniRoute/pull/12626)) — thanks @HouMinXi
- **fix(catalog):** re-audit free-tier quotas against official pages ([#12649](https://github.com/diegosouzapw/OmniRoute/pull/12649))
- **fix(dashboard):** keep a request's pending-tracking id stable across combo target retries ([#12650](https://github.com/diegosouzapw/OmniRoute/pull/12650)) — thanks @hartmark
- **fix(tests):** retire dead model ids from the chat-pipeline integration suite (base-red #12581) ([#12670](https://github.com/diegosouzapw/OmniRoute/pull/12670))
- **fix:** resolve SqliteError no such table compression_run_telemetry during cleanup ([#12682](https://github.com/diegosouzapw/OmniRoute/pull/12682)) — thanks @groovecityJO
- **fix(combo):** treat a pin-only step as implicit connection allowlist ([#12697](https://github.com/diegosouzapw/OmniRoute/pull/12697)) — thanks @HouMinXi
- **fix(ci):** ship .npmrc in published package so legacy-peer-deps applies to consumers (#11544) ([#12699](https://github.com/diegosouzapw/OmniRoute/pull/12699)) — thanks @KooshaPari
- **fix(docker):** pin 4 CLI tools to exact versions (#12576) ([#12703](https://github.com/diegosouzapw/OmniRoute/pull/12703)) — thanks @KooshaPari
- **fix(docker):** document and harden cli profile trust boundary (#12570) ([#12706](https://github.com/diegosouzapw/OmniRoute/pull/12706)) — thanks @KooshaPari
- **fix(sse):** redact video transcript in pre-guardrail rejected-request logs (#12150 P2 item 7) ([#12710](https://github.com/diegosouzapw/OmniRoute/pull/12710))
- **fix(i18n):** quote <name> placeholder in OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES description (#12505) ([#12769](https://github.com/diegosouzapw/OmniRoute/pull/12769)) — thanks @KooshaPari
- **fix(api/pricing):** surface validation error message as string, not raw object (#12494) ([#12771](https://github.com/diegosouzapw/OmniRoute/pull/12771)) — thanks @KooshaPari
- **fix(sse):** re-enable prompt compression for native Codex passthrough ([#12834](https://github.com/diegosouzapw/OmniRoute/pull/12834)) — thanks @thomasmaerz
### 📝 Maintenance
---
- **docs(readme):** reconcile live v3.8.50 provider, free-tier, CLI, routing, test, community, sponsor, acknowledgment, and SVG metrics with their audited source denominators, including a deduplicated OmniRoute-in-Action snapshot and distinct contributor rankings for merged pull requests, GitHub-attributed commits, and Git history ([#11356](https://github.com/diegosouzapw/OmniRoute/pull/11356)). ([#11434](https://github.com/diegosouzapw/OmniRoute/pull/11434)) — thanks @hartmark
- **docs:** sync the canonical provider count 354 → 356 across `README.md`, `AGENTS.md`, `llm.txt` (+ 42 i18n mirrors), the four README SVG diagrams, `docs/reference/PROVIDER_REFERENCE.md` (regenerated) and the `package.json` description after Opper (#11629) and 1min.ai (#11631) boarded the catalog — closes the `check:docs-counts-sync` strict drifts that kept `release/v3.8.51` red ([#11449](https://github.com/diegosouzapw/OmniRoute/issues/11449)). ([#11673](https://github.com/diegosouzapw/OmniRoute/pull/11673)) — thanks @jonlwheat2-gif
- **test(providers):** update count-derived assertions after the v3.8.51 provider additions — `APIKEY_PROVIDERS` 233 → 235 (Opper #11629 + 1min.ai #11631), reserved-prefix REGISTRY walk 395 → 398, `WEB_FETCH_PROVIDERS` now includes `nimble-search` (#11620), and the provider translate-path golden snapshot regenerated ([#11449](https://github.com/diegosouzapw/OmniRoute/issues/11449)). ([#11674](https://github.com/diegosouzapw/OmniRoute/pull/11674)) — thanks @jonlwheat2-gif
- **test(sse):** bump the hard-lease connection-query inventory for `src/lib/tokenHealthCheck.ts` to 2 — the verify-only web-cookie sweep added by #11495 split the single `getProviderConnections` call into oauth + cookie variants, which the frozen inventory had not tracked ([#11495](https://github.com/diegosouzapw/OmniRoute/pull/11495)). ([#11677](https://github.com/diegosouzapw/OmniRoute/pull/11677)) — thanks @jonlwheat2-gif / @oyi77
- **chore(release):** clear the `release/v3.8.51` base-red gates — docs migration count (+38 i18n mirrors), stryker `tap.testFiles`, lockfile registry host, stale ESLint suppressions, the 7 lint regressions they were hiding, the stale `APIKEY_PROVIDERS` partition count (231 → 233), the `glm-5.3-max` tier missing from the GLM test table, a dead `modelType === "chat"` comparison in the models catalog, and the `resolveLiveWsUrl` / `sanitizeLiveWsPort` helpers from #11388 that a later merge dropped from `wsPath.ts` (dashboard live WebSocket URL resolution) ([#11502](https://github.com/diegosouzapw/OmniRoute/pull/11502)) — thanks @MumuTW
- **chore(free-models):** the free-tier regime table now declares which totals figure each regime feeds and whether it may take the no-auth shortcut, so the sets that used to repeat those answers by hand are derived from it — a new `freeType` no longer compiles until it has answered every question, instead of silently contributing to no total ([#11537](https://github.com/diegosouzapw/OmniRoute/pull/11537)) — thanks @maxmad64bis
- **docs(routing):** LKGP strategy copy now states provider-level stickiness and the explicit rules fallback instead of promising per-target pinning ([#11555](https://github.com/diegosouzapw/OmniRoute/pull/11555)) — thanks @maxmad64bis
- **test(chatcore):** pin the response tool-name alias resolution against the ordering hazard that broke every Gemini/Antigravity MCP tool call in v3.8.49. `extractRequestToolIdentityMap` deletes `translatedBody._toolNameMap`, so the later read is always undefined and the ledger survives only through the `requestToolIdentityMap` fallback — removing that fallback previously left the entire tool-name suite green. The resolution moves into `resolveResponseToolNameMap()` next to the map it depends on, with a regression guard that fails without the recovery. No behaviour change. ([#11575](https://github.com/diegosouzapw/OmniRoute/pull/11575)) — thanks @amartinawi
- **docs(env):** document `OMNIROUTE_CORPUS_CACHE_SIZE` in `.env.example` and `ENVIRONMENT.md`, so the env/docs contract check stops failing Docs Gates on every branch ([#11576](https://github.com/diegosouzapw/OmniRoute/pull/11576)) — thanks @ntdatt812
- **chore(stryker):** register `route-guard-tunnel-processes-local-only` and `universal-quota-aware-routing` in `tap.testFiles`, so their mutant kills count for the three modules they cover ([#11577](https://github.com/diegosouzapw/OmniRoute/pull/11577)) — thanks @ntdatt812
- **test(authz):** pin `LOCAL_ONLY_API_GET_EXEMPTIONS` by exact membership instead of by entry count, so the guard names the offending path and also catches a substitution ([#11580](https://github.com/diegosouzapw/OmniRoute/pull/11580)) — thanks @ntdatt812
- **test(build):** pin `resolveNextBuildBundlerFlag` to the `OMNIROUTE_USE_TURBOPACK` contract it implements, across runtimes, instead of asserting an unimplemented Bun override ([#11583](https://github.com/diegosouzapw/OmniRoute/pull/11583)) — thanks @ntdatt812
- **test(claude-web):** the Claude Web transport test's barrel anchors no longer pin the exact construction spelling. #11421 made the executor map lazy, so `/"claude-web": new ClaudeWebExecutor\(\)/` stopped matching and the test went red on `release/v3.8.51` itself — a base-red gate every open PR inherited. The anchors now match the alias key and the class name and tolerate either spelling in between, and were checked to still fail when an alias is genuinely repointed at another executor ([#11585](https://github.com/diegosouzapw/OmniRoute/pull/11585)) — thanks @ntdat812
- **test(models):** pin `qwen3.8-max` resolution per provider — two of the four catalogs now serve the bare id and no longer carry `-preview` ([#11590](https://github.com/diegosouzapw/OmniRoute/pull/11590)) — thanks @ntdatt812
- **test(executors):** drop the stale 128-tool truncation assertion that contradicted the #11444 pass-through contract ([#11591](https://github.com/diegosouzapw/OmniRoute/pull/11591)) — thanks @ntdatt812
- **test(token-health):** pin the current terminal-skip boundary — expired with retry budget is probed, `account_deactivated` stays skipped ([#11592](https://github.com/diegosouzapw/OmniRoute/pull/11592)) — thanks @ntdatt812
- **test(models):** follow #11503's retarget of the `gemini-3-pro-high` alias to the hyphenated catalog id ([#11593](https://github.com/diegosouzapw/OmniRoute/pull/11593)) — thanks @ntdatt812
- **refactor(video bridge):** extract per-video acquisition, whole-result caching, description, and metrics/abort/cleanup out of `VideoBridgeGuardrail.preCall` into a `processVideoPart` seam in a new `videoBridgePipeline.ts`, behind explicit `VideoMediaBrokerPort`, `VideoAudioTranscriptionPort`, and `VideoDrilldownPort` boundaries; `preCall` now only handles request traversal, policy, and response aggregation. The Video tab's FFmpeg/ffprobe runtime status is now an explicit `unknown` / `restricted` / `unavailable` / `available` state instead of a nullable boolean pair, fixing a case where an in-flight or failed probe was mislabeled as "install FFmpeg" ([#11657](https://github.com/diegosouzapw/OmniRoute/issues/11657)). ([#12016](https://github.com/diegosouzapw/OmniRoute/pull/12016))
- **test(opencode-plugin):** add unit test coverage for `formatFreeBudget()` naming helper ([#11660](https://github.com/diegosouzapw/OmniRoute/pull/11660)) — thanks @f9td56dbgh-hub
- **docs(video):** clarify that the Video Bridge transcript `source` field (`client`, `embedded`, `audio-bridge`) is presently caller-declared and not yet server-verified — OmniRoute enforces the enum shape but does not cryptographically confirm that an `embedded`/`audio-bridge` label came from a server-owned extraction ([#11661](https://github.com/diegosouzapw/OmniRoute/issues/11661)). ([#12001](https://github.com/diegosouzapw/OmniRoute/pull/12001))
- **chore(providers):** retire the distributed Raycast Relay and Hailuo Web integrations after a provenance audit confirmed substantial derivation from GPL-3.0 sources; the official GitHub and MiniMax API providers remain available. Migration 163 disables legacy connections and invalidates their active leases while preserving connection IDs, audit/history rows, quota snapshots, usage records, and fail-closed API-key connection policies. ([#11691](https://github.com/diegosouzapw/OmniRoute/pull/11691))
- **chore(providers):** retire the Felo Web provider while its GPL-derived provenance remains on licensing HOLD ([#11698](https://github.com/diegosouzapw/OmniRoute/pull/11698)) — stale runtime and database references now fail closed while existing connection, lease, and usage history remains preserved for auditability.
- **fix(sse):** retire the Gemini Web image-generation bridge while provenance and licensing remain on HOLD, preserving Gemini Web chat/auth/tools and legitimate Gemini/Nano Banana image providers ([#11708](https://github.com/diegosouzapw/OmniRoute/pull/11708))
- **chore(audio):** retire the built-in EdgeTTS provider and its 11-voice catalog while provenance/licensing review remains on HOLD; the generic `/v1/audio/speech` endpoint and the other speech providers remain available ([#11711](https://github.com/diegosouzapw/OmniRoute/pull/11711))
- **fix(providers):** retire the unofficial Qwen Web integration and its `qwen-web`/`qw` identities while third-party provenance remains on HOLD ([#11713](https://github.com/diegosouzapw/OmniRoute/pull/11713)) — direct requests now fail closed with a sanitized HTTP 410 and combos skip the retired target, while migration guards preserve connection IDs, allowlists, usage/audit history and invalidate active leases. Supported Qwen API, Qwen Code, cloud, token-plan, audio, local and model-name surfaces remain available; restoring Qwen Web requires a qualified provenance/license decision.
- **fix(providers):** retire `microsoft-designer-web` and its `msdesigner` alias from runtime and catalogs while preserving stored credentials, provider history and allowlists as inactive records — source dependency on gpt4free's [`MicrosoftDesigner.py`](https://github.com/xtekky/gpt4free/blob/0943d16eef757a44deb60651c07bbe5fa958cafe/g4f/Provider/needs_auth/MicrosoftDesigner.py) is proven at snapshot `0943d16eef757a44deb60651c07bbe5fa958cafe`, whose [`LICENSE`](https://github.com/xtekky/gpt4free/blob/0943d16eef757a44deb60651c07bbe5fa958cafe/LICENSE) is GPL-3.0; expressive adaptation is probable, which records provenance/licensing risk without asserting a legal conclusion ([#11720](https://github.com/diegosouzapw/OmniRoute/pull/11720)).
- **docs(legal):** centralize pinned MIT notices for the vendored GCF codec and local flag assets ([#11726](https://github.com/diegosouzapw/OmniRoute/pull/11726))
- Split the 2,952-line `adobeFireflyClient.ts` into nine focused modules (catalog, credentials, payload, ARP session, upload, responses, IMS, account, poll) behind an unchanged barrel — no consumer edits and no public API change (#11728). — thanks @MumuTW
- **fix(dashboard):** replaced the Model Lockout card's unprovenanced notification MP3 with a short, locally synthesized Web Audio chime ([#11731](https://github.com/diegosouzapw/OmniRoute/pull/11731)) — both toggles retain optional audible feedback without shipping replacement media or third-party code, and unsupported, suspended, or rejected audio contexts fail open without blocking the setting change.
- **chore(deepseek-web):** remove the provenance-unresolved DeepSeek PoW WASM binary and its runtime loader/tracing while retaining the existing JavaScript solver (slower at high difficulty) ([#11732](https://github.com/diegosouzapw/OmniRoute/pull/11732)).
- **chore(assets):** remove four unused provider assets whose redistribution provenance could not be verified while retaining the active Freebuff SVG ([#11735](https://github.com/diegosouzapw/OmniRoute/pull/11735))
- **docs(assets):** retired two raster images whose redistribution provenance could not be verified ([#11736](https://github.com/diegosouzapw/OmniRoute/pull/11736)) — PicoClaw documentation now reuses the existing SVG, while Zoo Code remains a text link without an image.
- **docs(legal):** add pinned notices for six LobeHub-derived provider SVGs and 65 byte-exact theSVG assets, retaining registry claims as unverified metadata with explicit trademark and HOLD boundaries ([#11737](https://github.com/diegosouzapw/OmniRoute/pull/11737)).
- **chore(quality):** added a versioned 225/225 provider-asset provenance manifest and blocking drift gate ([#11743](https://github.com/diegosouzapw/OmniRoute/pull/11743)) — the inventory pins magic MIME and SHA-256 for every physical file, records immutable source matches for 71 assets, keeps 69 probable and 85 unresolved entries explicit but non-blocking, and treats provenance as source evidence rather than copyright or trademark clearance.
- **refactor(mitm):** replace four previously derived Traffic Inspector utilities — conversation normalization, SSE merging, secret masking, and header sanitization — with independent clean-room implementations based on public provider schemas, WHATWG SSE, and RFC 6750, while keeping the two independently classified passthrough surfaces unchanged ([#11748](https://github.com/diegosouzapw/OmniRoute/pull/11748)).
- Added a unit test that fails seven days before any dated pack under `config/` (`validUntil` and sibling keys) lapses, naming the file and key. The Alibaba free-tier pack expired on 2026-08-27 and turned every PR red the next morning with no commit involved; renewal now happens on someone's terms, not the clock's. ([#11891](https://github.com/diegosouzapw/OmniRoute/pull/11891))
- `check:workflows` now fails (under `--strict`/`--ratchet`) when any job routed to a self-hosted runner publishes with `--provenance` — npm rejects that with `422` at the registry, which in v3.8.50 only surfaced after the tag and Docker images were public. ([#11895](https://github.com/diegosouzapw/OmniRoute/pull/11895))
- `scripts/ops/runner-janitor.sh` now proves a path is idle with one `lsof` snapshot and removes stale leftovers itself (tmpfs after 3 h — it is RAM — disk after 24 h), kills orphan `next-build` processes, prunes checkouts of stopped runners, and alerts on memory pressure; `--dry-run` shows exactly what it would do. `docs/ops/RUNNER_BOX.md` reconciled to the measured box (31 GB, 10 listeners). ([#11893](https://github.com/diegosouzapw/OmniRoute/pull/11893))
- The `next-build` artefact (1.3 GB) is now written and read under `$RUNNER_TEMP` (per-runner, on disk) instead of `/tmp`, which on the self-hosted pool is a 12 GB tmpfs in RAM. Landing it there took 2732 of the publish job's 76 minutes, and the fixed `/tmp/e2e-build.tar.gz` name let E2E jobs on different runners overwrite each other's download. ([#11896](https://github.com/diegosouzapw/OmniRoute/pull/11896))
- The CI `build` job now runs in two concurrency lanes — `main` and pull requests — so a release build is never queued behind (or OOM-killed beside) PR builds on the self-hosted pool, which holds one `next-build` comfortably and two at the edge. ([#11901](https://github.com/diegosouzapw/OmniRoute/pull/11901))
- Re-freeze the ESLint suppressions on `release/v3.8.51` from a clean-room measurement (2 stale file entries pruned, 55 pre-existing `no-explicit-any` in six new files frozen under #11924) and drop the dead `GPT_SIZE_MAP` constant orphaned by the Adobe Firefly client split, so `No new ESLint warnings` stops failing every PR with exit 2 (Refs #11924) ([#11955](https://github.com/diegosouzapw/OmniRoute/pull/11955))
- Type the 55 `no-explicit-any` sites that had been frozen under #11924 — four redundant casts in `socksConnectorWithFamily.ts` (undici/socks types already accept them) and the mocks/fixtures of the socks-timeout and isFree suites — and drop their suppression entries; the ESLint ratchet shrinks from 5487 to 5432 (Closes #11924) ([#11975](https://github.com/diegosouzapw/OmniRoute/pull/11975))
- Take the two hosted-runner builds off the PR rail: `Build App` (`build.yml`) is `workflow_dispatch`-only and `DAST smoke (PR)` runs only for PRs into `main` — the 7 GB hosted VM cannot build `release/v3.8.51` in any profile (19/30 red, VM shutdown ~8 min into `next build`) and both checks had turned into permanent noise on every release PR; the bundle stays validated by `ci.yml` on `main` and by `nightly-release-green` on `release/**` (Closes #11946) ([#11962](https://github.com/diegosouzapw/OmniRoute/pull/11962))
- Move the four nightly jobs that build the backend (`nightly-schemathesis`, `nightly-llm-security` promptfoo + garak, `nightly-resilience` axe-a11y) off the hosted 7 GB runner — where they died on `release/v3.8.51` unseen — onto the box's new `omni-light` pool (two listeners), and document the reshaped fleet (4 active OmniRoute listeners: 2 `omni-build` + 2 `omni-light`, janitor ceiling 4) (Closes #11965) ([#11967](https://github.com/diegosouzapw/OmniRoute/pull/11967))
- Stop painting every fork PR into `release/**` red: `quality.yml` `Build (advisory)` is skipped (GitHub still reports `continue-on-error` failures as check FAILURE). Hosted `ubuntu-latest` cannot finish `npm run build` on this tree — same class as #11962 taking `build.yml` off the PR rail. `docker-publish.yml` amd64 now runs on the `.113` `omni-build` pool (31 GB / 32 cores, two listeners) with Turbopack, shares the `heavy-build-main` lane with `ci.yml` `Build` so it queues instead of becoming a third heavy, and keeps per-ref concurrency (a merge storm was starting 8 concurrent OOM builds). arm64 stays on `ubuntu-24.04-arm` with webpack — there is no ARM box. Fallback when `USE_VPS_RUNNER` is off: hosted amd64 + webpack (#11976). ([#12021](https://github.com/diegosouzapw/OmniRoute/pull/12021))
- `docker-publish.yml` builds with webpack (`OMNIROUTE_USE_TURBOPACK=0`) on **both** arches, including amd64 on the `.113` `omni-build` pool. Turbopack had the RAM (31 GB, `omniroute-113-6`) and still panicked (`TurbopackInternalError: there must be a path to a root`, run 33253576569, 26 min in). The same tree's arm64 webpack build on hosted `ubuntu-24.04-arm` succeeded (run 33264823398). Dockerfile already documents webpack as the Docker escape hatch (#11976). ([#12050](https://github.com/diegosouzapw/OmniRoute/pull/12050))
- **docs(free-tier):** declare the counting vs deciding regimes for "is it free?" and guard the deciding path from DB-backed catalog resolution ([#12226](https://github.com/diegosouzapw/OmniRoute/pull/12226)) — thanks @maxmad64bis
- **docs(auto-combo):** The mode pack table in `docs/routing/AUTO-COMBO.md` now lists all six shipped packs with every weight each one sets, replacing a four-pack table whose numbers had also drifted from the source. It states plainly that no pack sets `quality`, so selecting any pack silences the observed-quality signal. Six more documents that quote the scoring factor count joined the `check:docs-counts` gate, which caught five stale claims — including one naming nine factors that do not exist — and two stale mode pack counts. The dashboard routing panel, which offered four of the six packs and labelled the default strategy "6-Factor Scoring", is now covered by a test; the two packs it was missing are `reliability-first` and `chaos-mode`, the latter labelled as the fault-injection profile it is rather than as one more routing preference ([#12316](https://github.com/diegosouzapw/OmniRoute/pull/12316)) — thanks @maxmad64bis
- **chore(quality):** rebaseline `src/lib/db/apiKeys.ts` for the ACL the key-creation path now preserves ([#12352](https://github.com/diegosouzapw/OmniRoute/pull/12352)) ([#12673](https://github.com/diegosouzapw/OmniRoute/pull/12673)) — thanks @KrzysiekSko
- **chore(providers):** bump the Claude Code wire identity and the Devin bridge image pin from `2.1.220` to `2.1.258` ([#12402](https://github.com/diegosouzapw/OmniRoute/pull/12402)) — thanks @ggiak ([#12604](https://github.com/diegosouzapw/OmniRoute/pull/12604))
- **docs(env):** align `.env.example`, the README Bun section, and the troubleshooting guide with the code: `OMNIROUTE_USE_TURBOPACK` also governs `npm run build` (not dev-only), `bun run build` follows that flag instead of auto-selecting Webpack, `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` is unset by default (no request-count cap), and the structural `503 chat_admission_busy` message matches `chatAdmissionResponses.ts` (#12404 — thanks @pacocartones)
- **chore(electron):** upgrade the desktop app to Electron 44 (Chromium 152, Node 24.18.1) ([#12217](https://github.com/diegosouzapw/OmniRoute/pull/12217)). **Requires macOS 13 (Ventura) or later** — Chromium dropped macOS 12 (Monterey), so Monterey users must stay on an earlier OmniRoute desktop build. Windows and Linux are unaffected; the app already shipped only x64/arm64, so Electron 44 dropping 32-bit builds changes nothing. Removes the `openAsHidden`/`wasOpenedAsHidden` login-item fields deleted in Electron 44 — hidden autostart continues to work through the `--hidden` argument registered with the login item ([#12554](https://github.com/diegosouzapw/OmniRoute/pull/12554)) — thanks @app/dependabot
- **chore(quality):** rebaseline `src/sse/handlers/chat.ts` for the effective-input persistence the continuation fix needs ([#12641](https://github.com/diegosouzapw/OmniRoute/pull/12641)) ([#12680](https://github.com/diegosouzapw/OmniRoute/pull/12680)) — thanks @hartmark
- `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))
- CI hardening on the PR rail: the four `Unit Tests fast-path` shards get `timeout-minutes: 30` (a hung shard held a PR for 64 min instead of GitHub's 6 h default) and both ESLint file caches lose their `restore-keys` fallback, so a cache built under another suppressions file or lint config can no longer report stale verdicts (Refs #11600, #11924) ([#11963](https://github.com/diegosouzapw/OmniRoute/pull/11963))
- Every CI job that runs a `next build` (`build`, the npm `publish`, both release-green validations) now targets the `omni-build` runner label, which only two of the eight self-hosted runners carry. The box holds one build comfortably and two at the edge; a third now queues on GitHub instead of being OOM-killed by the kernel. ([#11932](https://github.com/diegosouzapw/OmniRoute/pull/11932))
- **refactor(combo):** move `handleRoundRobinCombo` (and `resolveTargetTokenLimit`) into `open-sse/services/combo/roundRobinCombo.ts`. `combo.ts` drops from 2164 to 1014 lines (`split("\n").length`); the leaf is 1199 (under the 1200 new-file cap). The round-robin call site uses a dynamic `import()` so `releaseStickyPinOnFailure` / `clearStaleLKGP` can stay exported from `combo.ts` without a static cycle. Skip / sticky / semaphore / safety-timer behavior is unchanged. ([#12811](https://github.com/diegosouzapw/OmniRoute/pull/12811)) — thanks @HouMinXi
- **chore(quality):** rebaseline the file-size caps the error-boundary campaign grew past (`open-sse/executors/codex.ts`, `open-sse/vendor/codex-chatgpt-web/bridge.ts`, both via [#12444](https://github.com/diegosouzapw/OmniRoute/pull/12444)) ([#12654](https://github.com/diegosouzapw/OmniRoute/pull/12654))
- **chore(quality):** rebaseline the file-size caps the hartmark batch grew past (`combos/page.tsx` via [#12355](https://github.com/diegosouzapw/OmniRoute/pull/12355), `open-sse/services/combo.ts` via [#12338](https://github.com/diegosouzapw/OmniRoute/pull/12338)) ([#12623](https://github.com/diegosouzapw/OmniRoute/pull/12623)) — thanks @hartmark
- **chore(quality):** rebaseline the file-size caps the HouMinXi batch grew past when its PRs stacked (`providers/page.tsx`, `chatCore.ts`, `accountFallback.ts`) — each PR measured correctly in isolation, none saw the stacking ([#12619](https://github.com/diegosouzapw/OmniRoute/pull/12619))
- **chore(quality):** rebaseline `open-sse/services/combo.ts` for the reset-aware scoring the HouMinXi batch stacked ([#12637](https://github.com/diegosouzapw/OmniRoute/pull/12637)) ([#12678](https://github.com/diegosouzapw/OmniRoute/pull/12678)) — thanks @HouMinXi
- **docs:** sync provider catalog count (direct commit `4f11b2ae3d`)
- **docs(skills):** sync omni-inference search fallback (direct commit `b4e76a7ed9`)
- **chore(lint):** prune two stale base suppressions (direct commit `1325047d56`)
- **test(auto-combo):** tolerate full-suite rotation load (direct commit `ad3e293f9f`)
- **test:** drain inherited quality snapshot reds (direct commit `21ed68d8ac`)
- **chore(quality):** rebaseline measured CLI bundle growth (direct commit `d35b3f9779`)
- **test(ui):** reproduce invalid AnySearch icon alias (direct commit `12cc6ca834`)
- **test(translator):** fix the relative imports of the relocated deferred-finish test (direct commit `777d9d1629`)
- **docs(counts):** bring the documented provider and migration counts to the measured values (direct commit `e70bea30e9`)
- **test(security):** drop the guard-target set left unused by the sync (direct commit `a32ba0e362`)
- **chore(db):** renumber model_capabilities migration 163 → 169 (cross-branch collision with 163_radar_feed_cache_generated_at) (direct commit `70f5d4cbf9`)
- **docs(cli):** document CLI_PRIME_AGENT_BIN and correct the CLI Agents count (direct commit `9058e39b61`)
- **chore(deps):** refresh runtimes and adopt ESLint 10 ([#11259](https://github.com/diegosouzapw/OmniRoute/pull/11259)) — thanks @backryun
- **refactor(local-corpus):** implement dynamic root resolution and LRU cache ([#11491](https://github.com/diegosouzapw/OmniRoute/pull/11491)) — thanks @raheemuddin786
- **chore(quality):** prune stale eslint-suppressions entries ([#11596](https://github.com/diegosouzapw/OmniRoute/pull/11596))
- **chore(ci):** unblock quality gates - env-doc sync + stryker tap.testFiles registration (#9282) ([#11609](https://github.com/diegosouzapw/OmniRoute/pull/11609)) — thanks @Meet6338-X
- **chore(ci):** point the circular-deps gate at dpdm's real JS entrypoint ([#11615](https://github.com/diegosouzapw/OmniRoute/pull/11615)) — thanks @benzntech
- **chore(quality):** rebaseline file-size for #11448/#11495/#11561 growth ([#11630](https://github.com/diegosouzapw/OmniRoute/pull/11630))
- **deps:** realign onnxruntime-node pin to @huggingface/transformers' 1.24.3 lockstep ([#11633](https://github.com/diegosouzapw/OmniRoute/pull/11633)) — thanks @jonlwheat2-gif
- **test(autoCombo):** port models_dev_tier #11508 guard from node:test to vitest ([#11635](https://github.com/diegosouzapw/OmniRoute/pull/11635)) — thanks @jonlwheat2-gif
- **chore(quality):** rebaseline file-size for #11083/#11631/#11628 growth ([#11636](https://github.com/diegosouzapw/OmniRoute/pull/11636))
- **chore(lint):** prune stale ESLint suppressions (base-red #11449) ([#11644](https://github.com/diegosouzapw/OmniRoute/pull/11644)) — thanks @jonlwheat2-gif
- **docs(changelog):** aggregate the ten v3.8.50 fragments into CHANGELOG.md ([#11683](https://github.com/diegosouzapw/OmniRoute/pull/11683))
- **chore(search):** drop unreachable credential sweep after #11642 ([#11687](https://github.com/diegosouzapw/OmniRoute/pull/11687)) — thanks @HouMinXi
- **ci:** add API route TypeScript regression gate ([#11705](https://github.com/diegosouzapw/OmniRoute/pull/11705)) — thanks @Sabeekhann
- **docs(dedup):** correct the CodeQL claim on the tenant-namespace comment ([#11712](https://github.com/diegosouzapw/OmniRoute/pull/11712))
- **docs(changelog):** add consolidated v3.8.50 stats, top-25 ranking and a .mailmap ([#11715](https://github.com/diegosouzapw/OmniRoute/pull/11715))
- **docs:** recommend gstack for AI-assisted workflows ([#11770](https://github.com/diegosouzapw/OmniRoute/pull/11770)) — thanks @santosraju99-hub
- **chore(quality):** tighten complexity/cognitive-complexity ratchets to the current tip; land the missed gateways.ts rebaseline ([#11771](https://github.com/diegosouzapw/OmniRoute/pull/11771)) — thanks @Rahulsharma0810 / @Rahulsharma0810
- **docs(i18n):** finish Freepik → Magnific rebrand in locale strings and README ([#11772](https://github.com/diegosouzapw/OmniRoute/pull/11772)) — thanks @turbolego
- **refactor(auto-combo):** fix divergent scoring in combo health reporting ([#11854](https://github.com/diegosouzapw/OmniRoute/pull/11854)) — thanks @jacobsparts
- **chore(changelog):** add missing fragments for #11919/#11918/#11916 ([#11938](https://github.com/diegosouzapw/OmniRoute/pull/11938))
- **test(check):** escape the runs-on fixture with JSON.stringify, not a quote-only replace ([#11942](https://github.com/diegosouzapw/OmniRoute/pull/11942)) (direct commit `fb7445eaa3`)
- **test(release):** align five suites with the contracts #11933, #11919 and #11876 shipped on release/v3.8.51 ([#11944](https://github.com/diegosouzapw/OmniRoute/pull/11944))
- **chore(quality):** remove unreachable code and restore test discovery ([#11950](https://github.com/diegosouzapw/OmniRoute/pull/11950)) — thanks @backryun
- **docs(agents):** sync-back landings are fast-forward, never squash ([#11964](https://github.com/diegosouzapw/OmniRoute/pull/11964))
- **test(infra):** retry recursive temp-dir removal instead of failing a shard on ENOTEMPTY (#11966) ([#11968](https://github.com/diegosouzapw/OmniRoute/pull/11968))
- **docs(ops):** the .113 heavy-build ceiling is one runner, not two ([#12048](https://github.com/diegosouzapw/OmniRoute/pull/12048))
- **test(ui):** correct inactive auto-fetch expectation ([#12098](https://github.com/diegosouzapw/OmniRoute/pull/12098)) — thanks @RaviTharuma
- **docs:** document native dependency check escape hatch ([#12101](https://github.com/diegosouzapw/OmniRoute/pull/12101)) — thanks @RaviTharuma
- **test(providers):** regenerate the translate-path golden for OrcaRouter (#11923) ([#12118](https://github.com/diegosouzapw/OmniRoute/pull/12118))
- **chore(quality):** velocity phase — loosen every numeric baseline by 20% until v4.0, monitor headroom nightly ([#12125](https://github.com/diegosouzapw/OmniRoute/pull/12125))
- **chore(lint):** batch 0 of #12146 — type the call-log-cap sqlite rows instead of 45 as-any casts ([#12157](https://github.com/diegosouzapw/OmniRoute/pull/12157))
- **chore(lint):** batch 4 of #12146 — shared/components react-hooks violations resolved ([#12159](https://github.com/diegosouzapw/OmniRoute/pull/12159))
- **chore(lint):** batch 1 of #12146 — dashboard/cli-code react-hooks violations resolved ([#12160](https://github.com/diegosouzapw/OmniRoute/pull/12160))
- **chore(lint):** batch 3 of #12146 — dashboard/settings react-hooks violations resolved ([#12162](https://github.com/diegosouzapw/OmniRoute/pull/12162))
- **chore(lint):** batch 2 of #12146 — resolve the react-hooks compiler violations in dashboard/providers ([#12163](https://github.com/diegosouzapw/OmniRoute/pull/12163))
- **chore(quality):** register search-432 cooldown test in stryker tap.testFiles ([#12170](https://github.com/diegosouzapw/OmniRoute/pull/12170))
- **chore(lint):** batch 5 of #12146 — combos, endpoint, provider-stats, api-manager and costs react-hooks violations resolved ([#12174](https://github.com/diegosouzapw/OmniRoute/pull/12174))
- **build:** add contributor fast profile ([#12192](https://github.com/diegosouzapw/OmniRoute/pull/12192)) — thanks @rafacpti23
- **docs(readme):** README touch-ups ([#12193](https://github.com/diegosouzapw/OmniRoute/pull/12193)) ([#12194](https://github.com/diegosouzapw/OmniRoute/pull/12194)) ([#12202](https://github.com/diegosouzapw/OmniRoute/pull/12202)) — thanks @rafacpti23
- **build:** force the webpack bundler in the contributor build profile ([#12197](https://github.com/diegosouzapw/OmniRoute/pull/12197)) — thanks @rafacpti23
- **build:** skip standalone packaging in the contributor profile ([#12198](https://github.com/diegosouzapw/OmniRoute/pull/12198)) — thanks @rafacpti23
- **docs(audit):** align every published number with the code and harden check:docs-counts ([#12200](https://github.com/diegosouzapw/OmniRoute/pull/12200))
- **build:** stub the instrumentation entrypoints in the contributor profile ([#12203](https://github.com/diegosouzapw/OmniRoute/pull/12203)) — thanks @rafacpti23
- **build:** omit the standalone output target for contributor builds ([#12204](https://github.com/diegosouzapw/OmniRoute/pull/12204)) — thanks @rafacpti23
- **chore(lint):** batch 6 of #12146 — memory, radar, audit, analytics, cache, usage, activity, home and RequestLoggerV2 react-hooks violations resolved ([#12208](https://github.com/diegosouzapw/OmniRoute/pull/12208))
- **docs(diagrams):** rename number-carrying diagram files to stable names ([#12210](https://github.com/diegosouzapw/OmniRoute/pull/12210))
- **docs(dashboard):** redraw onboarding tier-flow SVGs for the real 4-tier model ([#12211](https://github.com/diegosouzapw/OmniRoute/pull/12211))
- **docs(api):** document every implemented route in openapi.yaml (276 → 692 paths) ([#12212](https://github.com/diegosouzapw/OmniRoute/pull/12212))
- **chore(lint):** batch 7 of #12146 — final src tail: 37 react-hooks violations across 33 files resolved ([#12244](https://github.com/diegosouzapw/OmniRoute/pull/12244))
- Revert "docs: recommend gstack for AI-assisted workflows (#11770)" ([#12248](https://github.com/diegosouzapw/OmniRoute/pull/12248)) ([#12249](https://github.com/diegosouzapw/OmniRoute/pull/12249))
- **docs:** Chaos Mode setup guide + weighted strategy semantics ([#12250](https://github.com/diegosouzapw/OmniRoute/pull/12250))
- **docs(agents):** protected-surface merge rule — operator approval for agent-instruction files ([#12253](https://github.com/diegosouzapw/OmniRoute/pull/12253))
- **chore(quality):** register native-codex turn-pin tests in stryker tap.testFiles ([#12263](https://github.com/diegosouzapw/OmniRoute/pull/12263))
- **chore(quality):** dedupe tap.testFiles entries added by racing base-red fixes ([#12265](https://github.com/diegosouzapw/OmniRoute/pull/12265))
- **docs(readme):** introduce OmniRouteTray — the macOS menu-bar companion ([#12276](https://github.com/diegosouzapw/OmniRoute/pull/12276)) — thanks @ggiak
- **chore(proxy):** purge the legacy 1proxy residue — sync/rotator modules, dead DB exports, dead settings tab and flag, docs (#12091) ([#12290](https://github.com/diegosouzapw/OmniRoute/pull/12290))
- **chore(quality):** baseline-headroom skips generated and vendored files in the fileSize worst-file signal ([#12291](https://github.com/diegosouzapw/OmniRoute/pull/12291))
- **refactor(video):** unify the JPEG frame data-URI contract ([#12322](https://github.com/diegosouzapw/OmniRoute/pull/12322))
- **chore(deps):** freeze onnxruntime-node and eslint-plugin-react-hooks out of dependabot groups ([#12329](https://github.com/diegosouzapw/OmniRoute/pull/12329))
- **test(executors):** restore the #10986 reasoning-only fallback guards ([#12364](https://github.com/diegosouzapw/OmniRoute/pull/12364)) — thanks @pacocartones
- **chore(quality):** re-tighten the file-size ratchet to the real LOC (plan 3.8.52 task 0) ([#12411](https://github.com/diegosouzapw/OmniRoute/pull/12411))
- **test(grok):** format web executor suite ([#12412](https://github.com/diegosouzapw/OmniRoute/pull/12412))
- **chore(quality):** ratchet open-sse typecheck baseline to zero ([#12418](https://github.com/diegosouzapw/OmniRoute/pull/12418))
- **test(build):** guard the artifact path policy arrays against duplicates ([#12422](https://github.com/diegosouzapw/OmniRoute/pull/12422))
- **chore(lint):** adopt eslint-plugin-react-hooks 7.1.1 ([#12428](https://github.com/diegosouzapw/OmniRoute/pull/12428))
- **chore(providers):** remove a keyless provider integration at its operator's request ([#12440](https://github.com/diegosouzapw/OmniRoute/pull/12440))
- **docs(providers):** register providers removed at their operator's request and guard against reintroduction ([#12478](https://github.com/diegosouzapw/OmniRoute/pull/12478))
- **chore(quality):** base fixes — stryker tap.testFiles + node_modules cache key ([#12482](https://github.com/diegosouzapw/OmniRoute/pull/12482))
- **docs(arch):** one-process recipe for tens of long /v1/responses ([#12493](https://github.com/diegosouzapw/OmniRoute/pull/12493)) — thanks @RaviTharuma
- **chore(quality):** tighten the CodeQL ratchet baseline from 11 to 6 ([#12530](https://github.com/diegosouzapw/OmniRoute/pull/12530))
- **chore(deps):** pin browserslist override to ^4.28.8 ([#12592](https://github.com/diegosouzapw/OmniRoute/pull/12592)) — thanks @KooshaPari
- **chore(deps):** bump fast-uri to 3.1.7 in the electron lockfile ([#12601](https://github.com/diegosouzapw/OmniRoute/pull/12601))
- **docs(quality):** document how the CodeQL ratchet refreshes and how to tighten it ([#12611](https://github.com/diegosouzapw/OmniRoute/pull/12611))
- **test(ci):** pin the openapi-security-tiers two-arm contract with an executing gate test (#12581) ([#12652](https://github.com/diegosouzapw/OmniRoute/pull/12652))
- **docs(gamification):** align XP Rewards table with code (#12501) ([#12667](https://github.com/diegosouzapw/OmniRoute/pull/12667)) — thanks @KooshaPari
- **refactor(combo):** split executeTarget into gates, attempt, and loop ([#12746](https://github.com/diegosouzapw/OmniRoute/pull/12746)) — thanks @HouMinXi
- **docs(security):** document socket.yml scanner config + CI workflow link (#12575) ([#12764](https://github.com/diegosouzapw/OmniRoute/pull/12764)) — thanks @KooshaPari
- **chore(ci):** guard commit identity in pre-commit to stop author misattribution ([#12772](https://github.com/diegosouzapw/OmniRoute/pull/12772))
- **deps:** 17 Dependabot bumps — bump electron from 43.4.0 to 43.4.1 in /electron ([#11426](https://github.com/diegosouzapw/OmniRoute/pull/11426)); bump the development group across 1 directory with 8 updates ([#11428](https://github.com/diegosouzapw/OmniRoute/pull/11428)); bump the production group across 1 directory with 11 updates ([#11440](https://github.com/diegosouzapw/OmniRoute/pull/11440)); chore(deps): bump github/codeql-action/init from 4.37.7 to 4.37.8 ([#11925](https://github.com/diegosouzapw/OmniRoute/pull/11925)); chore(deps): bump github/codeql-action from 4.37.7 to 4.37.8 ([#11926](https://github.com/diegosouzapw/OmniRoute/pull/11926)); chore(deps): bump github/codeql-action/analyze from 4.37.7 to 4.37.8 ([#11927](https://github.com/diegosouzapw/OmniRoute/pull/11927)); build(deps): bump github/codeql-action/init from 4.37.8 to 4.37.9 ([#12345](https://github.com/diegosouzapw/OmniRoute/pull/12345)); build(deps): bump github/codeql-action/analyze from 4.37.8 to 4.37.9 ([#12346](https://github.com/diegosouzapw/OmniRoute/pull/12346)); bump the development group across 1 directory with 2 updates ([#12347](https://github.com/diegosouzapw/OmniRoute/pull/12347)); build(deps): bump github/codeql-action from 4.37.8 to 4.37.9 ([#12349](https://github.com/diegosouzapw/OmniRoute/pull/12349)); bump browserslist from 4.28.2 to 4.28.8 ([#12396](https://github.com/diegosouzapw/OmniRoute/pull/12396)); bump the production group across 1 directory with 4 updates ([#12399](https://github.com/diegosouzapw/OmniRoute/pull/12399)); bump @xmldom/xmldom ([#12500](https://github.com/diegosouzapw/OmniRoute/pull/12500)); bump qs from 6.15.2 to 6.16.0 ([#12512](https://github.com/diegosouzapw/OmniRoute/pull/12512)); bump @xmldom/xmldom from 0.9.10 to 0.9.12 ([#12513](https://github.com/diegosouzapw/OmniRoute/pull/12513)); bump fast-uri from 3.1.5 to 3.1.7 ([#12514](https://github.com/diegosouzapw/OmniRoute/pull/12514)); bump @humanfs/node from 0.16.7 to 0.16.8 ([#12515](https://github.com/diegosouzapw/OmniRoute/pull/12515))
### 🙌 Contributors
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 |
| [@adevwithpurpose](https://github.com/adevwithpurpose) | #11464 |
| [@adivekar-utexas](https://github.com/adivekar-utexas) | #12015, #12043, #12090, #12138 |
| [@AIB1TAL0S](https://github.com/AIB1TAL0S) | #12103 |
| [@alltomatos](https://github.com/alltomatos) | #11448 |
| [@alvinveroy](https://github.com/alvinveroy) | #11857, #12027, #12165 |
| [@amaleta](https://github.com/amaleta) | #11906, #11934 |
| [@amartinawi](https://github.com/amartinawi) | #11575 |
| [@AndrianBalanescu](https://github.com/AndrianBalanescu) | #11083, #11830, #11834, #11844 |
| [@aniruddhaadak80](https://github.com/aniruddhaadak80) | #11760 |
| [@app](https://github.com/app) | #12554 |
| [@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 |
| [@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 |
| [@Chewji9875](https://github.com/Chewji9875) | #11455, #11875, #12028 |
| [@cheynetom](https://github.com/cheynetom) | #12058 |
| [@davidebaraldo](https://github.com/davidebaraldo) | #12222, #12304 |
| [@davidlinfr](https://github.com/davidlinfr) | #6390 |
| [@ddarkr](https://github.com/ddarkr) | #12124 |
| [@Deftera186](https://github.com/Deftera186) | #11809 |
| [@dpozimski](https://github.com/dpozimski) | #11945 |
| [@DW-MediaLab](https://github.com/DW-MediaLab) | #12229 |
| [@echel0nn](https://github.com/echel0nn) | #11923 |
| [@f9td56dbgh-hub](https://github.com/f9td56dbgh-hub) | #11660 |
| [@fabioluissilva](https://github.com/fabioluissilva) | #11991 |
| [@foreveryh](https://github.com/foreveryh) | #12177 |
| [@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 |
| [@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 |
| [@hizzt](https://github.com/hizzt) | #11894 |
| [@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 |
| [@Hsia97](https://github.com/Hsia97) | #11624 |
| [@jacobsparts](https://github.com/jacobsparts) | #11854, #12155, #12167 |
| [@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 |
| [@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 |
| [@kriptoburak](https://github.com/kriptoburak) | #11370 |
| [@KrzysiekSko](https://github.com/KrzysiekSko) | #12425, #12432, #12673 |
| [@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, #12870 |
| [@mdigitalbh81](https://github.com/mdigitalbh81) | #12240 |
| [@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 |
| [@NightStalker-87](https://github.com/NightStalker-87) | #12183 |
| [@NoxzRCW](https://github.com/NoxzRCW) | #11879, #11880, #11881, #11882, #11883, #11935 |
| [@ntdat812](https://github.com/ntdat812) | #11585, #12095 |
| [@ntdatt812](https://github.com/ntdatt812) | #11368, #11573, #11574, #11576, #11577, #11580, #11582, #11583, #11588, #11589, #11590, #11591, #11592, #11593, #11672, #12177, #12180 |
| [@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, #12359, #12360, #12361, #12362, #12364, #12365, #12373, #12374, #12375, #12376, #12377, #12379, #12380, #12381, #12386, #12387, #12389, #12390, #12394, #12395, #12397, #12401, #12403, #12404 |
| [@patrykkopycinski](https://github.com/patrykkopycinski) | #11936, #11937, #12224 |
| [@PauloFH](https://github.com/PauloFH) | #11509 |
| [@PauloHSOliveira](https://github.com/PauloHSOliveira) | #12241 |
| [@ponkcore](https://github.com/ponkcore) | #12054 |
| [@Prajeeth-12](https://github.com/Prajeeth-12) | #11634, #12046 |
| [@quiterunner-commits](https://github.com/quiterunner-commits) | #12131, #12143 |
| [@rafacpti23](https://github.com/rafacpti23) | #11554, #11558, #12192, #12197, #12198, #12202, #12203, #12204 |
| [@ragnar-claude](https://github.com/ragnar-claude) | #11564 |
| [@raheemuddin786](https://github.com/raheemuddin786) | #11491, #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 |
| [@rezjalibd](https://github.com/rezjalibd) | #12186 |
| [@RhianB14](https://github.com/RhianB14) | #11692 |
| [@rifqiawl](https://github.com/rifqiawl) | #11517, #11519, #11969 |
| [@rqzbeh](https://github.com/rqzbeh) | #11390 |
| [@Sabeekhann](https://github.com/Sabeekhann) | #11699, #11705 |
| [@santosraju99-hub](https://github.com/santosraju99-hub) | #11755, #11770 |
| [@Seramicx](https://github.com/Seramicx) | #11568 |
| [@Siva010](https://github.com/Siva010) | #12191 |
| [@solstxce](https://github.com/solstxce) | #11597 |
| [@soroush5](https://github.com/soroush5) | #12691 |
| [@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 |
| [@Tushar49](https://github.com/Tushar49) | #11392 |
| [@ujjawalkaushik1110](https://github.com/ujjawalkaushik1110) | #11863 |
| [@vermasomesh835](https://github.com/vermasomesh835) | #11794 |
| [@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 |
| [@Xxx91n](https://github.com/Xxx91n) | #11690 |
| [@yourspraveen](https://github.com/yourspraveen) | #11146 |
| [@yxyxy](https://github.com/yxyxy) | #11671 |
| [@ZaimMarzuki](https://github.com/ZaimMarzuki) | #11960 |
| [@Zartharas](https://github.com/Zartharas) | #11340 |
| [@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

@@ -331,7 +331,18 @@ RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,targe
&& git config --system url."https://github.com/".insteadOf "ssh://git@github.com/"
# Install CLI tools globally. Separate layer from apt for better cache reuse.
# Pinned to exact versions per Diego's diagnosis in #12576 — floating
# `@latest` causes two CI failures:
# 1. `openclaw` ships a breaking major ~weekly; overnight builds silently
# advance to a version that no longer matches the tested combo stack.
# 2. `codex` / `claude-code` dev pre-releases (`@next`, dist-tags) mutate
# API surface without notice; reproducible builds need a SHA-pinned dev
# 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 @anthropic-ai/claude-code droid openclaw@latest
npm install -g --no-audit --no-fund \
@openai/codex@0.153.2 \
@anthropic-ai/claude-code@2.1.260 \
droid@0.212.0 \
openclaw@2026.9.1
USER node

View File

@@ -7,19 +7,19 @@
# 🚀 OmniRoute — The Free AI Gateway
<img src="./docs/diagrams/readme-hero.svg" width="100%" alt="OmniRoute — Never stop coding. Every AI tool → 355 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. 355 AI providers · 150+ free tiers · ~1.51B 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 → 356 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. 356 AI providers · 150+ free tiers · ~1.47B free tokens/mo · 19 routing strategies · $0 to start."/>
</div>
<div align="center">
## 💰 ~1.51B Free Tokens / Month
## 💰 ~1.47B Free Tokens / Month
</div>
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **446 free-tier entries across 38 recurring pool keys** and computes the token headline from the **20 pools with a published positive monthly budget**, deduplicated by shared pool. The result stays visible on the dashboard (`/dashboard/free-tiers`).
> Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **444 free-tier entries across 34 recurring pool keys** and computes the token headline from the **16 pools with a published positive monthly budget plus five per-model Groq caps**, deduplicated by shared pool. Quotas that only open after a regional identity check (today: ModelScope) are shown apart, +~6M behind regional identity verification, and never summed into the headline. The result stays visible on the dashboard (`/dashboard/free-tiers`).
<img src="./docs/diagrams/free-tier-budget.svg" width="100%" alt="OmniRoute free-tier budget card: ~1.51B free tokens per month steady, up to ~2.13B in the first month with signup credits, from 38 documented recurring pool keys covering 446 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 20 recurring pools with a published positive monthly token budget; 13 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, LLM7 150M, Nara 150M, Gemini 60M and smaller pools, plus first-month signup credits and permanently-free no-token-cap providers surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers."/>
<img src="./docs/diagrams/free-tier-budget.svg" width="100%" alt="OmniRoute free-tier budget card: ~1.47B free tokens per month steady, up to ~2.10B in the first month with signup credits, from 34 documented recurring pool keys covering 444 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 16 recurring pools with a published positive monthly token budget plus five per-model Groq caps; 13 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, Nara 210M, LLM7 150M, Groq 30M (five per-model caps) and smaller pools, plus first-month signup credits and permanently-free no-token-cap providers surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers."/>
> Animated summary of the live `/dashboard/free-tiers` page. Full methodology (pool dedupe, credit tiers, provider terms): **[docs/reference/FREE_TIERS.md](docs/reference/FREE_TIERS.md)**.
>
@@ -133,7 +133,7 @@
</div>
<div align="center">
<b>🌐 In 43 languages</b>
<b>🌐 In 51 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>
@@ -164,7 +164,6 @@
<a href="docs/i18n/id/README.md"><img src="docs/assets/flags/id.svg" width="30" alt="Bahasa Indonesia (id)" title="Bahasa Indonesia (id)"></a>
<a href="docs/i18n/ms/README.md"><img src="docs/assets/flags/my.svg" width="30" alt="Bahasa Melayu (ms)" title="Bahasa Melayu (ms)"></a>
<a href="docs/i18n/phi/README.md"><img src="docs/assets/flags/ph.svg" width="30" alt="Filipino (phi)" title="Filipino (phi)"></a>
<a href="docs/i18n/in/README.md"><img src="docs/assets/flags/in.svg" width="30" alt="हिन्दी (in)" title="हिन्दी (in)"></a>
<a href="docs/i18n/hi/README.md"><img src="docs/assets/flags/in.svg" width="30" alt="हिन्दी (hi)" title="हिन्दी (hi)"></a>
<a href="docs/i18n/gu/README.md"><img src="docs/assets/flags/in.svg" width="30" alt="ગુજરાતી (gu)" title="ગુજરાતી (gu)"></a>
<a href="docs/i18n/mr/README.md"><img src="docs/assets/flags/in.svg" width="30" alt="मराठी (mr)" title="मराठी (mr)"></a>
@@ -178,6 +177,15 @@
<a href="docs/i18n/tr/README.md"><img src="docs/assets/flags/tr.svg" width="30" alt="Türkçe (tr)" title="Türkçe (tr)"></a>
<a href="docs/i18n/az/README.md"><img src="docs/assets/flags/az.svg" width="30" alt="Azərbaycan (az)" title="Azərbaycan (az)"></a>
<a href="docs/i18n/sw/README.md"><img src="docs/assets/flags/tz.svg" width="30" alt="Kiswahili (sw)" title="Kiswahili (sw)"></a>
<a href="docs/i18n/el/README.md"><img src="docs/assets/flags/gr.svg" width="30" alt="Ελληνικά (el)" title="Ελληνικά (el)"></a>
<a href="docs/i18n/hr/README.md"><img src="docs/assets/flags/hr.svg" width="30" alt="Hrvatski (hr)" title="Hrvatski (hr)"></a>
<a href="docs/i18n/sr/README.md"><img src="docs/assets/flags/rs.svg" width="30" alt="Српски (sr)" title="Српски (sr)"></a>
<a href="docs/i18n/lt/README.md"><img src="docs/assets/flags/lt.svg" width="30" alt="Lietuvių (lt)" title="Lietuvių (lt)"></a>
<a href="docs/i18n/et/README.md"><img src="docs/assets/flags/ee.svg" width="30" alt="Eesti (et)" title="Eesti (et)"></a>
<a href="docs/i18n/lv/README.md"><img src="docs/assets/flags/lv.svg" width="30" alt="Latviešu (lv)" title="Latviešu (lv)"></a>
<a href="docs/i18n/sl/README.md"><img src="docs/assets/flags/si.svg" width="30" alt="Slovenščina (sl)" title="Slovenščina (sl)"></a>
<a href="docs/i18n/mt/README.md"><img src="docs/assets/flags/mt.svg" width="30" alt="Malti (mt)" title="Malti (mt)"></a>
<a href="docs/i18n/ga/README.md"><img src="docs/assets/flags/ie.svg" width="30" alt="Gaeilge (ga)" title="Gaeilge (ga)"></a>
</div>
<br/>
@@ -210,7 +218,7 @@ curl http://localhost:20128/v1/chat/completions \
</div>
<img src="./docs/diagrams/promise-pillars.svg" width="100%" alt="The Promise — One endpoint and 355 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 355 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 53 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 356 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 356 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 52 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/>
@@ -463,7 +471,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: 355 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 43 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: 356 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>
@@ -519,9 +527,9 @@ Pix copia-e-cola:
## 📡 OmniRoute Radar
The main free-tier headline remains **~1.51B tokens/month** from the documented,
The main free-tier headline remains **~1.47B tokens/month** from the documented,
pool-deduplicated catalog above. Temporary provider signup credits can separately lift the first
month to **~2.13B**. Radar is an optional, signed catalog overlay for people who want fresher
month to **~2.10B**. Radar is an optional, signed catalog overlay for people who want fresher
free-model availability between OmniRoute releases; the community catalog and every existing free
feature remain free.
@@ -649,7 +657,7 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
</div>
> **352 registered providers** across the canonical chat, media, search, local, cloud-agent and system collections, including **152 carrying `hasFree: true` discovery metadata**. The chat model registry covers **229 providers / 2,554 distinct provider-model pairs / 1,283 raw model IDs**; the separate free-budget catalog has **446 per-model rows**, **38 recurring pools** and **53 recurring/keyless free-forever providers**. These are different denominators by design; definitions and pool-deduped calculations live in the [Provider Reference](docs/reference/PROVIDER_REFERENCE.md) and [Free Tiers](docs/reference/FREE_TIERS.md).
> **352 registered providers** across the canonical chat, media, search, local, cloud-agent and system collections, including **152 carrying `hasFree: true` discovery metadata**. The chat model registry covers **229 providers / 2,554 distinct provider-model pairs / 1,283 raw model IDs**; the separate free-budget catalog has **444 per-model rows**, **34 recurring pools** and **52 recurring/keyless free-forever providers**. These are different denominators by design; definitions and pool-deduped calculations live in the [Provider Reference](docs/reference/PROVIDER_REFERENCE.md) and [Free Tiers](docs/reference/FREE_TIERS.md).
<div align="center">
@@ -725,6 +733,7 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
<tr><td align="left" nowrap>📦 <b>npm (global)</b></td><td align="left" nowrap><code>npm install -g omniroute</code></td><td align="left">One command, any OS</td></tr>
<tr><td align="left" nowrap>🐳 <b>Docker</b></td><td align="left" nowrap><code>docker run … diegosouzapw/omniroute</code></td><td align="left">Multi-arch <b>AMD64 + ARM64</b></td></tr>
<tr><td align="left" nowrap>🖥️ <b>Desktop (Electron)</b></td><td align="left" nowrap><code>npm run electron:build</code></td><td align="left">Native window + system tray — <b>Windows / macOS / Linux</b></td></tr>
<tr><td align="left" nowrap>🎩 <b>Menu-bar (OmniRouteTray)</b></td><td align="left" nowrap><code>brew install --cask zoispag/tap/omniroute-tray</code></td><td align="left">Supervises &amp; auto-updates the server — <b>macOS</b></td></tr>
<tr><td align="left" nowrap>💪 <b>ARM</b></td><td align="left" nowrap>native <code>arm64</code></td><td align="left">Raspberry Pi, ARM servers, Apple Silicon</td></tr>
<tr><td align="left" nowrap>📱 <b>Android (Termux)</b></td><td align="left" nowrap><code>pkg install nodejs && npx -y omniroute</code></td><td align="left">Runs <b>on your phone</b>, 24/7, no root</td></tr>
<tr><td align="left" nowrap>📲 <b>PWA</b></td><td align="left" nowrap>"Add to Home Screen"</td><td align="left">Fullscreen, offline, installable from browser</td></tr>
@@ -733,7 +742,7 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md)
<tr><td align="left" nowrap>🛠️ <b>From source</b></td><td align="left" nowrap><code>npm install && npm run dev</code></td><td align="left">Hack on it, contribute</td></tr>
</table>
<sub>📖 [Docker Guide](docs/guides/DOCKER_GUIDE.md) · [Desktop](electron/README.md) · [Termux](docs/guides/TERMUX_GUIDE.md) · [PWA](docs/guides/PWA_GUIDE.md) · [OpenCode](docs/frameworks/OPENCODE.md)</sub>
<sub>📖 [Docker Guide](docs/guides/DOCKER_GUIDE.md) · [Desktop](electron/README.md) · [Menu-bar tray](https://github.com/zoispag/omniroute-tray) · [Termux](docs/guides/TERMUX_GUIDE.md) · [PWA](docs/guides/PWA_GUIDE.md) · [OpenCode](docs/frameworks/OPENCODE.md)</sub>
<br/>
@@ -768,6 +777,42 @@ From inside the editor: open the **Extensions** view, search **"OmniRoute"**, cl
<div align="center">
### 🎩 New: OmniRouteTray — your gateway, living in the menu bar
</div>
> `omniroute serve` is happiest when it's always on. **[OmniRouteTray](https://github.com/zoispag/omniroute-tray)**
> turns that into a set-and-forget menu-bar app for macOS: it starts the server, keeps it alive
> across reboots, updates it in place, and puts your live token budget one click away — **no
> terminal window left open, no `npm install -g omniroute` to babysit.**
Built with [Tauri v2](https://v2.tauri.app/) (a Rust core the size of a rounding error), it ships
its own signed Node 24 runtime and manages an app-owned OmniRoute install, so it never fights your
global `node`/`bun`. It **shares your existing `~/.omniroute/` config and database** — so it's the
same OmniRoute you already run, just with a hat on. 🎩
<table>
<tr><th align="left">What it does</th><th align="left">How</th></tr>
<tr><td align="left" nowrap>🟢 <b>Supervises the server</b></td><td align="left">Spawns <code>omniroute serve</code>, adopts an already-running instance instead of duplicating it</td></tr>
<tr><td align="left" nowrap>📊 <b>Live usage at a glance</b></td><td align="left">Provider quota bars, Claude session/weekly limits with reset countdowns, 30-day cost breakdown</td></tr>
<tr><td align="left" nowrap>🔄 <b>Auto-updates in place</b></td><td align="left">Staged install, atomic swap, rollback on failure — always on the newest release</td></tr>
<tr><td align="left" nowrap>🚀 <b>Start on login</b></td><td align="left">Optional launch at login; tray-only, no dock icon</td></tr>
<tr><td align="left" nowrap>🩺 <b>Doctor &amp; logs</b></td><td align="left">One-click diagnostics and server log access</td></tr>
</table>
```sh
brew install --cask zoispag/tap/omniroute-tray
```
<sub>Prefer a download? Grab the latest <code>.dmg</code> from
<a href="https://github.com/zoispag/omniroute-tray/releases">Releases</a>. Source, issues and build
docs live at <a href="https://github.com/zoispag/omniroute-tray">zoispag/omniroute-tray</a>.
<br/>💛 A community project by <a href="https://github.com/zoispag">@zoispag</a> — not an official OmniRoute release.</sub>
<br/>
<div align="center">
## 🔒 Private & Local-First
</div>
@@ -1208,7 +1253,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, 168 migrations</td></tr>
<tr><td nowrap><b>Database</b></td><td>better-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 171 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>
@@ -1271,7 +1316,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
<tr><td nowrap><b><a href="docs/architecture/RESILIENCE_GUIDE.md">Resilience Guide</a></b></td><td>Circuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing</td></tr>
<tr><td nowrap><b><a href="docs/routing/AUTO-COMBO.md">Auto-Combo Engine</a></b></td><td>16-factor scoring, mode packs, self-healing</td></tr>
<tr><td nowrap><b><a href="docs/ops/PROXY_GUIDE.md">Proxy Guide</a></b></td><td>3-level proxy system, 1proxy marketplace, registry CRUD</td></tr>
<tr><td nowrap><b><a href="docs/reference/FREE_TIERS.md">Free Tiers</a></b></td><td>Consolidated directory: 38 documented recurring pools / 446 cataloged free-tier entries</td></tr>
<tr><td nowrap><b><a href="docs/reference/FREE_TIERS.md">Free Tiers</a></b></td><td>Consolidated directory: 34 documented recurring pools / 444 cataloged free-tier entries</td></tr>
<tr><td nowrap><b><a href="docs/guides/FEATURES.md">Features Gallery</a></b></td><td>Visual dashboard tour with screenshots</td></tr>
<tr><td nowrap><b><a href="docs/architecture/CODEBASE_DOCUMENTATION.md">Codebase Documentation</a></b></td><td>Beginner-friendly codebase walkthrough</td></tr>
</table>
@@ -1296,7 +1341,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi
<tr><td nowrap><b><a href="docs/ops/BRANCHING_MODEL.md">Branching & Release Model</a></b></td><td>Where PRs target (<code>release/*</code>), what <code>main</code> and tags mean</td></tr>
<tr><td nowrap><b><a href="CHANGELOG.md">Changelog</a></b></td><td>Full per-version release history</td></tr>
<tr><td nowrap><b><a href="SECURITY.md">Security Policy</a></b></td><td>Vulnerability reporting and security practices</td></tr>
<tr><td nowrap><b><a href="docs/guides/I18N.md">i18n Guide</a></b></td><td>43-language support, translation workflow, RTL</td></tr>
<tr><td nowrap><b><a href="docs/guides/I18N.md">i18n Guide</a></b></td><td>42-language support, translation workflow, RTL</td></tr>
<tr><td nowrap><b><a href="docs/ops/RELEASE_CHECKLIST.md">Release Checklist</a></b></td><td>Pre-release validation steps</td></tr>
<tr><td nowrap><b><a href="docs/ops/COVERAGE_PLAN.md">Coverage Plan</a></b></td><td>Test coverage strategy for 39,000+ static test declarations across 5,100+ tracked test files</td></tr>
</table>

View File

@@ -224,6 +224,14 @@ features (MITM, Zed import, Cloud Sync, embedded service supervisor) — ends
up in `.next/server/*.js` minified chunks. Heuristic supply-chain scanners
frequently pattern-match those chunks against malware signatures.
The scanner configuration we use lives at [`socket.yml`](socket.yml) in the
repo root (Socket.dev GitHub App format v2 — see
<https://docs.socket.dev/docs/socket-yml>). It explicitly excludes
non-shipped directories (`tests/`, `_tasks/`, `_references/`, `_ideia/`,
`_mono_repo/`, `docs/`, etc.) so the scanner only reports on code paths that
actually reach published users — the scan itself is driven by the Socket
GitHub App reading that file, not by a workflow in this repository.
For each finding category we maintain a per-finding maintainer attestation:
- **[`docs/security/SOCKET_DEV_FINDINGS.md`](docs/security/SOCKET_DEV_FINDINGS.md)** —

View File

@@ -22,9 +22,9 @@ bin/cli/
├── provider-test.mjs ← testProviderApiKey()
├── settings-store.mjs ← DB CRUD for key_value settings
├── locales/
│ ├── en.json ← English strings (source of truth, 43 locales)
│ ├── en.json ← English strings (source of truth, 42 locales)
│ ├── pt-BR.json ← Portuguese (Brazil) — fully translated
│ └── {locale}.json ← 42 additional locales (ar, az, de, es, fr, ja, zh-CN, …)
│ └── {locale}.json ← 41 additional locales (ar, az, de, es, fr, ja, zh-CN, …)
├── scripts/
│ └── generate-locales.mjs ← scaffold new locale files from config/i18n.json
└── commands/

View File

@@ -16,6 +16,29 @@ function ensureBackup(configPath) {
return backupPath;
}
function mergeClaudeSettings(existingContent, generatedContent) {
const generated = JSON.parse(generatedContent);
let current = {};
if (existingContent && existingContent.trim()) {
current = JSON.parse(existingContent);
if (!current || typeof current !== "object" || Array.isArray(current)) current = {};
}
return JSON.stringify(
{
...current,
...generated,
env: {
...(current.env && typeof current.env === "object" && !Array.isArray(current.env)
? current.env
: {}),
...(generated.env || {}),
},
},
null,
2
);
}
async function runConfigListCommand(opts = {}) {
const { detectAllTools } = await import("../../../src/lib/cli-helper/tool-detector.ts");
const tools = await detectAllTools();
@@ -120,7 +143,12 @@ async function runConfigSetCommand(toolId, opts = {}) {
const backupPath = ensureBackup(result.configPath);
if (backupPath) printInfo(`Backup saved to: ${backupPath}`);
fs.writeFileSync(result.configPath, result.content, "utf-8");
let content = result.content;
if (toolId === "claude" && fs.existsSync(result.configPath)) {
content = mergeClaudeSettings(fs.readFileSync(result.configPath, "utf-8"), result.content);
}
fs.writeFileSync(result.configPath, content, "utf-8");
printSuccess(`Config written to ${result.configPath}`);
return 0;
}

View File

@@ -18,7 +18,7 @@ export function registerTunnel(program) {
});
tunnel
.command("create [type]")
.command("create")
.description(t("tunnel.createDescription"))
.addArgument(
new Argument("[type]", "Tunnel type").choices(VALID_TUNNEL_TYPES).default("cloudflare")

View File

@@ -5,6 +5,25 @@ import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const LOCALES_DIR = join(__dirname, "locales");
const FALLBACK_LOCALE = "en";
const I18N_CONFIG_PATH = join(__dirname, "..", "..", "config", "i18n.json");
let aliasMap = null; // lower-case tag → canonical locale code
function loadAliasMap() {
if (aliasMap) return aliasMap;
aliasMap = new Map();
try {
const { locales } = JSON.parse(readFileSync(I18N_CONFIG_PATH, "utf8"));
for (const entry of locales) {
aliasMap.set(entry.code.toLowerCase(), entry.code);
for (const alias of entry.aliases || []) {
aliasMap.set(String(alias).toLowerCase(), entry.code);
}
}
} catch {
// config absent (trimmed package): keep file-based detection only
}
return aliasMap;
}
const cache = new Map();
let activeLocale = null;
@@ -24,8 +43,16 @@ function normalize(raw) {
const stripped = String(raw).split(".")[0].replaceAll("_", "-");
if (!stripped || !/^[a-zA-Z0-9-]+$/.test(stripped)) return FALLBACK_LOCALE;
if (hasCatalog(stripped)) return stripped;
const base = stripped.split("-")[0];
const lower = stripped.toLowerCase();
const base = lower.split("-")[0];
const aliases = loadAliasMap();
const viaAlias = aliases.get(lower) ?? aliases.get(base);
if (viaAlias && hasCatalog(viaAlias)) return viaAlias;
if (hasCatalog(base)) return base;
const regional = [...new Set(aliases.values())].find(
(code) => code.includes("-") && code.toLowerCase().split("-")[0] === base
);
if (regional && hasCatalog(regional)) return regional;
return FALLBACK_LOCALE;
}
@@ -103,4 +130,5 @@ export function resetForTests() {
cache.clear();
activeLocale = null;
fallbackCatalog = null;
aliasMap = null;
}

1329
bin/cli/locales/el.json Normal file

File diff suppressed because it is too large Load Diff

1329
bin/cli/locales/et.json Normal file

File diff suppressed because it is too large Load Diff

1329
bin/cli/locales/ga.json Normal file

File diff suppressed because it is too large Load Diff

1329
bin/cli/locales/hr.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +0,0 @@
{
"redis": {
"description": "Luncurkan kontainer Redis lokal dengan satu klik (Podman atau Docker) untuk caching dan pelacakan kuota OmniRoute"
}
}

1329
bin/cli/locales/lt.json Normal file

File diff suppressed because it is too large Load Diff

1329
bin/cli/locales/lv.json Normal file

File diff suppressed because it is too large Load Diff

1329
bin/cli/locales/mt.json Normal file

File diff suppressed because it is too large Load Diff

1329
bin/cli/locales/sl.json Normal file

File diff suppressed because it is too large Load Diff

1329
bin/cli/locales/sr.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,9 @@
* For top-tier languages, a translated `common` + `program` section is included.
* All other keys fall back to `en` via i18n.mjs's existing fallback mechanism.
*
* Run: node bin/cli/scripts/generate-locales.mjs [--force]
* Run: node bin/cli/scripts/generate-locales.mjs [--force] [--code=<locale>]
* --code=<locale> generate only that locale (scripts/i18n/add-locale.mjs uses it
* to scaffold the catalog of the locale it is adding)
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
@@ -17,8 +19,13 @@ const ROOT = join(__dirname, "..", "..", "..");
const LOCALES_DIR = join(__dirname, "..", "locales");
const I18N_CFG = join(ROOT, "config", "i18n.json");
const FORCE = process.argv.includes("--force");
const ONLY = process.argv.find((arg) => arg.startsWith("--code="))?.slice("--code=".length) || null;
const { locales } = JSON.parse(readFileSync(I18N_CFG, "utf8"));
if (ONLY && !locales.some((locale) => locale.code === ONLY)) {
console.error(`--code=${ONLY} is not listed in config/i18n.json`);
process.exit(1);
}
// common + program translations for each language code.
// Keys that are absent fall back to en automatically.
@@ -883,12 +890,12 @@ const TRANSLATIONS = {
// Languages with no translation in this script — will be created as empty objects
// All keys fall back to `en` via i18n.mjs's fallback mechanism.
const SCAFFOLD_ONLY = ["bn", "gu", "he", "in", "mr", "ms", "phi", "sw", "ta", "te", "ur"];
const SCAFFOLD_ONLY = ["bn", "gu", "he", "mr", "ms", "phi", "sw", "ta", "te", "ur"];
let created = 0;
let skipped = 0;
for (const locale of locales) {
for (const locale of locales.filter((candidate) => !ONLY || candidate.code === ONLY)) {
const { code } = locale;
if (code === "en" || code === "pt-BR") {
skipped++;

View File

@@ -1 +0,0 @@
- **feat(audio):** proxy native ElevenLabs voices, text-to-speech, and speech-to-text HTTP routes through stored OmniRoute credentials, preserving query strings, multipart uploads, binary responses, and upstream errors (#10556).

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