Compare commits

..

27 Commits

Author SHA1 Message Date
diegosouzapw
e8c52b06b6 test(plugin): wait for the v2 lazy-refresh tier instead of sleeping past it
`lazy refresh: [m1] then [m1,m2] reloads once; identical runs never reload`
waited for the plugin's asynchronous optional tier with a fixed `await sleep(5)`.
That is a race, not a wait: when the tier landed after the sleep, its reload was
still pending, so the next assertion counted it and read

    AssertionError: an identical run never reloads
    2 !== 1

The test file fails on every isolated run on the current release tip; it only
passed when the whole suite ran together and unrelated work happened to give the
tier enough slack.

Replaces both sleeps with a `settle()` helper that polls until the observed value
holds steady for three consecutive turns (5s ceiling), so the wait tracks the
work rather than the clock. The assertions are unchanged — this fixes how the
test waits, not what it checks.

The failure was invisible to the base-green sweep because
`.github/workflows/opencode-plugin-ci.yml` filters on
`paths: @omniroute/opencode-plugin*/**`, so the job only runs when the plugin
directories change and the nightly sweep never triggers it.

Isolated file: 6/6 runs green (was 0/6). Full v2 suite: 218/218. Build passes.
2026-09-07 16:19:50 -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
310 changed files with 36613 additions and 4107 deletions

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

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,269 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import plugin from "../src/index.js";
interface CapturedCall {
kind: "catalog" | "integration";
}
/**
* Wait until `read()` stops changing, then return the settled value.
*
* The plugin's optional tier lands asynchronously after a publish. Waiting for
* it with a fixed `sleep(5)` raced the work: under load the tier arrived after
* the sleep, so the *next* assertion counted its reload and read 2 where it
* expected 1. Polling until the value holds steady for a few consecutive turns
* ties the wait to the work instead of to the clock.
*/
async function settle<T>(read: () => T, quietTurns = 3, timeoutMs = 5000): Promise<T> {
const { setTimeout: sleep } = await import("node:timers/promises");
const deadline = Date.now() + timeoutMs;
let last = read();
let stable = 0;
while (stable < quietTurns && Date.now() < deadline) {
await sleep(5);
const current = read();
if (current === last) {
stable += 1;
} else {
last = current;
stable = 0;
}
}
return last;
}
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 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);
// 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 = await settle(() => reloads);
assert.ok(afterFirstUpgrade <= 1, `at most one reload for the first upgrade, got ${reloads}`);
await cb(draft);
assert.equal(reloads, afterFirstUpgrade + 1, "a new model id reloads once");
assert.equal(modelsCall, 2);
await settle(() => reloads);
await cb(draft);
assert.equal(reloads, afterFirstUpgrade + 1, "an identical run never reloads");
assert.equal(modelsCall, 3);
if (prevDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
else process.env.OPENCODE_DATA_DIR = prevDataDir;
} finally {
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

@@ -0,0 +1 @@
- **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))

View File

@@ -0,0 +1,2 @@
- **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))
- **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))

View File

@@ -0,0 +1 @@
- **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

View File

@@ -0,0 +1 @@
- **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

View File

@@ -0,0 +1 @@
- **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))

View File

@@ -0,0 +1 @@
- **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

View File

@@ -0,0 +1,2 @@
- **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))
- **fix(glm):** drop the extra 16th argument to `createSSETransformStreamWithLogger` that TypeScript rejected (TS2554) and that never reached the TransformStream

View File

@@ -0,0 +1 @@
- **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))

View File

@@ -0,0 +1 @@
- 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)).

View File

@@ -0,0 +1 @@
- **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

View File

@@ -0,0 +1 @@
- **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

View File

@@ -0,0 +1 @@
- **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).

View File

@@ -0,0 +1 @@
- **fix(glm):** drop the extra 16th argument to `createSSETransformStreamWithLogger` that TypeScript rejected (TS2554) and that never reached TransformStream

View File

@@ -0,0 +1 @@
- **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.

View File

@@ -0,0 +1 @@
- **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%

View File

@@ -1 +1 @@
Keep Antigravity Gemini usable when the same connection's Claude weekly quota is empty; generic quota cache stays per-connection for every other provider.
- Keep Antigravity Gemini usable when the same connection's Claude weekly quota is empty; generic quota cache stays per-connection for every other provider.

View File

@@ -0,0 +1 @@
- **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.

View File

@@ -1,4 +1,10 @@
{
"_rebaseline_2026_09_06_runtime_quotagroup_nodemap": "Own growth: src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx 1201->1222 (+21, check-file-size split-newline). QuotaGroup is a module-level sibling and was reading nodeMap from RuntimePageClient's closure; that identifier is not in scope, so a quota monitor with status error/exhausted/alerting throws ReferenceError. Fix threads nodeMap as a prop (3 call sites + parameter + ProviderNodeEntry import). Prettier wraps the long import and the three QuotaGroup JSX tags. Covered by tests/unit/ui/runtime-page-client.test.tsx (empty monitors stay green; error+exhausted fixtures mount QuotaGroup).",
"_rebaseline_2026_09_05_claude_extra_usage_preflight": "Own growth: open-sse/services/combo.ts 4080->4084 (+4). buildAutoCandidates now forwards connection.providerSpecificData into evaluateQuotaCutoff so a Claude account with blockExtraUsage=false is not dropped at the 5h bar. Irreducible at the existing cutoff call site; the helper lives in claudeExtraUsage.ts (under cap). Covered by tests/unit/quota-preflight.test.ts.",
"_rebaseline_2026_09_04_12697_combo_pin_allowlist": "PR #12697 own growth: src/sse/handlers/chat.ts 2454->2458 (+4). checkModelAvailable preflight and handleSingleModelChat now call comboPinAllowlist so a pin-only combo step cannot scan the provider pool after 502/429. Helper lives in src/lib/combos/steps.ts under cap. Covered by tests/unit/combo-pin-implicit-allowlist.test.ts (11/11).",
"_rebaseline_2026_09_05_quota_weighted": "feat/quota-weighted-routing own growth: src/app/(dashboard)/dashboard/combos/page.tsx 5066->5080 (+14 = STRATEGY_GUIDANCE_FALLBACK + STRATEGY_RECOMMENDATIONS_FALLBACK entries for quota-weighted; copy is the spec-mandated when/avoid/example and tips, irreducible at the existing fallback maps). Rebased onto 9d1a896c6 where #12671 already grew the same file 5018->5066. Covered by tests/unit/combo/quota-weighted-strategy.test.ts + autocombo-unification.test.ts.",
"_rebaseline_2026_09_03_combo_execute_target_attempt": "Task 3 of handleComboChat split: new leaf open-sse/services/combo/executeTargetAttempt.ts lands at 1205 (check-file-size split-newline; wc -l 1204) above new-file cap 1200. Lift-as-is from combo.ts:1533-2616 retry loop. Pure classify predicates already extracted to executeTargetClassify.ts (54 LOC). Remaining growth is I/O + side effects (handleSingleModel, quality, pin/LKGP, cooldown, lockout) that cannot leave this file without splitting the retry loop mid-request. Frozen at exact LOC so it can only shrink. Covered by tests/unit/combo/execute-target-attempt.test.ts (7/7).",
"_rebaseline_2026_09_06_12696_chat_pipeline_retry_off": "PR #12696 own test growth: tests/integration/chat-pipeline.test.ts 1644->1648 (+4). The upstream-500 structured-error case now sets requestRetry/maxRetryIntervalSec to 0 so the new provider-execution pipeline cannot retry the mock 500 and double-count fetch. Irreducible at the existing seed+fetch mock; covered by the same test.",
"_rebaseline_2026_09_03_reset_aware_model_family": "Own growth: open-sse/services/combo.ts 4036->4041 (+5). buildAutoCandidates now keys the reset-aware quota cache by getQuotaFetchScope and spreads requestedModel onto the connection so Gemini windows stay off a Claude-empty Antigravity account. Irreducible wiring at the existing fetchResetAwareQuotaWithCache call site; the family helper itself lives in antigravityQuotaFamily.ts. Covered by tests/unit/reset-aware-request-scope-12600.test.ts.",
"_rebaseline_2026_09_03_overloaded_not_provider_breaker": "fix/overloaded-not-provider-breaker own growth: open-sse/services/combo.ts 4036->4075 (check-file-size split-newline, +39). Circuit-open pre-skip now records the breaker retryAfter and, when every target was skipped that way, waits the short reset via resolveCircuitOpenWaitDecision (new leaf in comboCooldownRetry.ts) instead of crystallizing ALL_TARGETS_SKIPPED in ~43ms. skippedForCircuitOpen / earliestCircuitOpenRetryMs reset each setTry so a later iteration cannot inherit a stale retryAfter. Irreducible at the existing ALL_TARGETS_SKIPPED chokepoint (same pattern as #7301/#8213 cooldown-wait). Predicate itself lives in circuitBreaker.ts / comboPredicates.ts / chatPredicates.ts, all under cap. Covered by tests/unit/overloaded-not-provider-breaker.test.ts + combo-cooldown-retry.test.ts.",
"_rebaseline_2026_09_03_12649_free_tier_reaudit_gateways": "PR #12649 (fix/free-tier-quota-reaudit) own growth: src/shared/constants/providers/apikey/gateways.ts 1459->1462 (+3 = the nara authHint rewritten for the re-audited 7M/day plan now wraps to two lines, plus the Prettier reflow of two pre-existing >100-col authHint lines (oneminai, freebuff) that lint-staged enforces on any touch of the file; additive text at the existing registry chokepoint, same god-file no-split rationale as prior gateways.ts rebaselines: #11786 seekai, #10987 logfare, #10531 freebuff). Covered by tests/unit/free-tier-reaudit-2026-09.test.ts and tests/unit/free-providers-batch-2026-07.test.ts.",
@@ -209,7 +215,7 @@
"_rebaseline_2026_08_24_video_bridge_fu01_fu03_fu04_result_cache_tests": "PRs #11362 (FU-01 cache hardening) + #11382 (FU-03 visual dedup policy identity) + #11383 (FU-04 focused analysis mode) own test growth: videoBridgeResultCache.test.ts <1000->1040, +40 (sum of three stacked PRs boarded together in the same merge-batch, each adding its own cache-identity assertions on the shared result-cache seam). Owner pre-authorized rebaseline for legitimate PR growth (2026-08-19 directive).",
"_rebaseline_basered_codebuddy_cn": "Base-red fix (#4664 CodeBuddy CN): oauth-providers-config.test.ts 867->870 (+3) to align the EXPECTED provider list/config with the codebuddy-cn provider that #4664 added to the registry without updating this test (it asserts 'exactly once').",
"_rebaseline_pr4613_compatible_provider_groups": "Reconcile #4613 already-merged growth: providers-page-utils.test.ts 1004->1052 (+48, buildCompatibleProviderGroups partition unit test). Fast-gate PR->release does not run check:file-size, so this surfaced post-merge.",
"tests/integration/chat-pipeline.test.ts": 1644,
"tests/integration/chat-pipeline.test.ts": 1648,
"tests/unit/account-fallback-service.test.ts": 2056,
"tests/unit/batch_api.test.ts": 1345,
"tests/unit/cc-compatible-provider.test.ts": 1225,
@@ -425,7 +431,9 @@
"open-sse/mcp-server/server.ts": 1572,
"open-sse/services/accountFallback.ts": 2467,
"open-sse/services/adobeFireflyBrowserLogin.ts": 1401,
"open-sse/services/combo.ts": 4084,
"open-sse/services/combo.ts": 4080,
"open-sse/services/combo/executeTargetAttempt.ts": 1205,
"open-sse/translator/response/openai-responses.ts": 1466,
"open-sse/utils/cursorAgentProtobuf.ts": 1547,
"open-sse/utils/proxyFetch.ts": 1271,
@@ -434,12 +442,12 @@
"open-sse/vendor/codex-chatgpt-web/bridge.ts": 1335,
"src/app/(dashboard)/dashboard/HomePageClient.tsx": 1344,
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3186,
"src/app/(dashboard)/dashboard/combos/page.tsx": 5066,
"src/app/(dashboard)/dashboard/combos/page.tsx": 5080,
"src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1319,
"src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2491,
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1631,
"src/app/(dashboard)/dashboard/providers/page.tsx": 2025,
"src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1201,
"src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1222,
"src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1475,
"src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": 1271,
"src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 1607,
@@ -457,7 +465,7 @@
"src/shared/components/RequestLoggerV2.tsx": 1718,
"src/shared/constants/providers/apikey/gateways.ts": 1462,
"src/shared/services/cliRuntime.ts": 1296,
"src/sse/handlers/chat.ts": 2454,
"src/sse/handlers/chat.ts": 2458,
"src/sse/services/auth.ts": 3450,
"tests/unit/account-fallback-service.test.ts": 2453,
"tests/unit/provider-validation-specialty.test.ts": 4656
@@ -575,7 +583,7 @@
"src/app/(dashboard)/dashboard/health/page.tsx": "1165",
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": "1324",
"src/app/(dashboard)/dashboard/providers/page.tsx": "1944",
"src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": "1201",
"src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": "1222",
"src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx": "1019",
"src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": "1470",
"src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": "1123",

View File

@@ -41,6 +41,7 @@ Simple guides for using OmniRoute — no technical background needed.
- [PWA_GUIDE.md](guides/PWA_GUIDE.md) — installing the dashboard as a PWA.
- [REMOTE-MODE.md](guides/REMOTE-MODE.md) — exposing OmniRoute remotely + scoped tokens.
- [CLI-INTEGRATIONS.md](guides/CLI-INTEGRATIONS.md) — master table of `setup-*` CLI integrations.
- [OPENCODE-V2-PLUGIN.md](guides/OPENCODE-V2-PLUGIN.md) — installing and configuring the OpenCode v2 plugin.
- [CLAUDE-CODE-CONFIGURATION.md](guides/CLAUDE-CODE-CONFIGURATION.md) — Claude Code CLI with OmniRoute.
- [CODEX-CLI-CONFIGURATION.md](guides/CODEX-CLI-CONFIGURATION.md) — Codex CLI with OmniRoute.
- [KIRO_SETUP.md](guides/KIRO_SETUP.md) — Kiro setup.

View File

@@ -132,6 +132,16 @@ are not presented as launchable targets.
> There is also a richer plugin integration — `omniroute setup opencode` — which
> installs `@omniroute/opencode-plugin`. They are different commands; the table
> above documents `setup-opencode`.
>
> The plugin comes in two packages, one per OpenCode major, because the two
> loaders expect different entrypoints:
> `@omniroute/opencode-plugin` for OpenCode v1 and
> `@omniroute/opencode-plugin-v2` for OpenCode v2. The v2 package is new
> (`0.1.0`) and follows a host contract that is still moving, so it reads the
> shape OpenCode seeds into the catalog draft rather than assuming one. Install
> it by adding a `plugins` entry to `opencode.json`; `omniroute setup opencode`
> still installs the v1 package. Options and the credential lookup order are in
> the package README.
---

View File

@@ -0,0 +1,133 @@
---
title: "OpenCode v2 plugin — install and configure"
version: 3.8.51
lastUpdated: 2026-09-06
---
# OpenCode v2 plugin — install and configure
`@omniroute/opencode-plugin-v2` puts your whole OmniRoute catalog — models, combos and
auto-combos — into OpenCode v2's model picker, with display names, pricing and free-tier
budgets.
It is a separate package from `@omniroute/opencode-plugin` because OpenCode v1 and v2 load
plugins through different contracts. Pick the one matching your OpenCode major; nothing is
shared between them, so upgrading one never forces the other.
## Requirements
- OpenCode v2.
- A reachable OmniRoute gateway (`http://localhost:20128` by default).
- Node.js 22 or 24.
## Install
Add the plugin to `opencode.json`:
```json
{
"plugins": [
{
"package": "@omniroute/opencode-plugin-v2",
"options": {
"providerId": "omniroute",
"baseURL": "http://localhost:20128"
}
}
]
}
```
Models then appear as `omniroute/<provider>/<model>`; `providerId` decides that prefix and
the id of the integration OpenCode stores the credential under.
## Credentials
The plugin looks for a gateway key in three places, in this order:
1. **The credential you connected in OpenCode.** The plugin registers an integration, so
OpenCode's own auth flow can store the key. Nothing lands in `opencode.json` — prefer this.
2. `apiKey` in the plugin options, for a per-project override. It puts the key in a file you
may be committing.
3. `OMNIROUTE_API_KEY` in the environment.
With none of the three, the catalog is empty and the plugin says so once at startup instead of
leaving you with a silent empty picker.
### 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:
```json
"options": {
"baseURL": "http://localhost:20128",
"managementReadToken": "<management read token>"
}
```
Left unset, `managementReadToken` falls back to `apiKey`. 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 that happens,
naming the endpoint and what was lost — so a degraded picker is never a mystery.
## Options
| Key | Default | Notes |
| -------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `providerId` | `"omniroute"` | Provider id, integration id, and the prefix models appear under |
| `baseURL` | required | Gateway root; the `/v1` suffix is added where needed |
| `apiKey` | connected credential, then `OMNIROUTE_API_KEY` | Chat key for `/v1/*` |
| `managementReadToken` | falls back to `apiKey` | Key for `/api/*` — usually **not** the same one |
| `displayName` | `"OmniRoute"` | Provider name in the picker |
| `timeoutMs` | `10000` | Per-endpoint fetch timeout (auto-combos use 5s) |
| `modelCacheTtlMs` | `300000` | Catalog cache TTL; a disk snapshot warms cold starts |
| `timeouts` | falls back to `timeoutMs` | Per-endpoint budgets in ms: `models`, `combos`, `autoCombos`, `enrichment` |
| `enrichment` | `true` | Fetch names, pricing and free-tier budgets |
| `providerTag` | `true` | Prefix a display name with the upstream provider it routes to |
| `usableOnly` | `false` | Keep only providers the gateway reports as provisioned |
| `visibleModels` / `hiddenModels` | `[]` | Exact-or-suffix allowlists; deny wins |
| `geminiSanitization` | `true` | Strip the JSON-Schema keywords Gemini rejects from tool schemas (`$ref` tools are forwarded untouched) |
| `apiFormat.allowAnthropic` | `false` | Route allowlisted ids through the Anthropic API block |
| `apiFormat.anthropicModels` | `[]` | Full model ids routed to Anthropic |
| `logLevel` / `startupDebug` | `warn` / `false` | Logger verbosity |
## How the catalog stays fresh
The catalog is fetched lazily and cached for `modelCacheTtlMs`, and a disk snapshot keeps the
last known catalog available when the gateway is unreachable — an outage costs you nothing but
freshness. Models and combos are published as soon as they arrive; auto-combos, the provider
list and the enrichment overlay fold in when they land, so one slow endpoint cannot hold the
whole picker hostage.
Names carry what the gateway knows: the upstream provider a model routes to, a `[Free]` marker
and the budget that comes with it — so two connections selling the same model stay
distinguishable. Turn the prefix off with `"providerTag": false`.
There is no manual refresh command: OpenCode v2 commands are prompt templates, not callbacks,
so a plugin cannot expose one. Refreshes follow the TTL; the host is asked to reload only when
the catalog or the overlay actually changed, never once per refresh.
## Tool calling on Gemini
Gemini rejects an entire request whose tool declarations carry `$schema` or
`additionalProperties`, answering `400 INVALID_ARGUMENT`. 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 rather than stripped, since removing the
reference would widen the schema to "accept anything".
Set `"geminiSanitization": false` to turn it off.
## Troubleshooting
| Symptom | Cause |
| ------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Picker shows no OmniRoute model | No key resolved (check the startup warning), or the gateway is unreachable |
| Raw model ids, no combos, no pricing | The management endpoints refused the token — set `managementReadToken` |
| A session pinned to `opencode-<id>/…` | The v1 plugin published `opencode-<id>`; v2 publishes `<id>` bare, so re-select the model under `<id>/…` |
## See also
- [CLI-INTEGRATIONS.md](CLI-INTEGRATIONS.md) — every `setup-*` CLI integration, including the
lightweight openai-compatible OpenCode setup.
- [REMOTE-MODE.md](REMOTE-MODE.md) — pointing a CLI at a remote gateway.

View File

@@ -58,8 +58,8 @@ OmniRoute includes a service worker (`sw.js`) that provides intelligent caching:
| **App Shell** | Cache-first | `/`, `/offline`, manifest, and icons are pre-cached on install |
| **Static assets** (CSS, JS, images, fonts) | Network-first with cache fallback | Fetches fresh from the network; falls back to cache if offline |
| **Next.js bundles** (`/_next/`) | Network-first with cache update | Fetches from network and updates cache; serves cached version if offline |
| **Navigation requests** | Network-only with offline fallback | Always fetches from network; shows `/offline` page if network is unavailable |
| **API routes** (`/api/`, `/a2a`, `/dashboard/endpoint`) | Bypass (never cached) | Always goes directly to the server — never intercepted by the service worker |
| **Navigation requests** | Bypass (never intercepted) | Browser owns HTTP/3→HTTP/2 fallback; a dead QUIC socket must not become `Response.error()` |
| **API / dashboard routes** (`/api/`, `/a2a`, `/dashboard`) | Bypass (never cached) | Always goes directly to the server — never intercepted by the service worker |
### Offline Page
@@ -118,7 +118,7 @@ A vanilla service worker (no framework dependencies) with:
- **Install phase**: Pre-caches the app shell (root, offline page, manifest, icons)
- **Activate phase**: Cleans up old cache versions and claims all clients
- **Fetch phase**: Intelligent routing based on request type (navigation, static asset, API)
- **Cache versioning**: `omniroute-pwa-v2` — bump this to force a fresh cache on update
- **Cache versioning**: `omniroute-pwa-v3` — bump this to force a fresh cache on update
### Layout Metadata (`src/app/layout.tsx`)

View File

@@ -337,6 +337,11 @@ opencode -m omniroute/glm/glm-5.2 "..." # export OMNIROUTE_API_KEY firs
> (now remote-aware via `--remote`) installs `@omniroute/opencode-plugin`.
> `setup-opencode` is the lightweight openai-compatible alternative. The API key
> is referenced via `{env:OMNIROUTE_API_KEY}` — never written to disk.
>
> On OpenCode v2, use `@omniroute/opencode-plugin-v2` instead: same catalog,
> different loader contract. It reads the key from OpenCode's own credential
> store when the integration is connected, so a remote gateway needs no key in
> `opencode.json` at all.
---

View File

@@ -25,5 +25,5 @@ export const agyProvider: RegistryEntry = {
},
models: [...AGY_PUBLIC_MODELS],
passthroughModels: true,
liveCatalogAuthoritative: false,
liveCatalogAuthoritative: true,
};

View File

@@ -25,5 +25,5 @@ export const antigravityProvider: RegistryEntry = {
},
models: [...ANTIGRAVITY_PUBLIC_MODELS],
passthroughModels: true,
liveCatalogAuthoritative: false,
liveCatalogAuthoritative: true,
};

View File

@@ -223,8 +223,8 @@ export function translateSseResponse(
suppressThinkClose: boolean = false
): Response {
if (!response.body) return response;
// GLM is a high-throughput provider — use a larger stream buffer (64KB) to
// keep provider → client pacing ahead of the model's token emission rate.
// Helper has 15 parameters; a 16th positional (65536) was a TS2554 and
// never reached TransformStream. highWaterMark stays at the helper default.
const transform = createSSETransformStreamWithLogger(
FORMATS.CLAUDE,
FORMATS.OPENAI,
@@ -238,10 +238,7 @@ export function translateSseResponse(
null,
null,
false,
suppressThinkClose,
undefined,
undefined,
65536
suppressThinkClose
);
const headers = cloneHeaders(response.headers);
headers.set("content-type", "text/event-stream");

File diff suppressed because it is too large Load Diff

View File

@@ -250,6 +250,15 @@ export type PersistAttemptLogsContext = {
* path) is never touched. Omitted/empty for every non-video request.
*/
videoBridgeLogRedaction?: VideoBridgeLogRedactionEntry[];
/**
* #12150 P2 surface 2: true when the video-bridge guardrail observed and
* rewrote video parts on this request, so the persisted client snapshot had
* its transcript cues structurally redacted (videoBridgeObserved in
* chatCore.ts). Written to the `call_logs.video_content_removed` marker so
* `resolvePreviousResponseState` refuses to rehydrate this row as continuation
* history. Omitted/false for every non-video request.
*/
videoContentRemoved?: boolean;
};
function toConnectionId(value: unknown): string | null {
@@ -368,6 +377,7 @@ export function persistAttemptLogs(args: PersistAttemptLogsArgs, ctx: PersistAtt
modelPinned,
sessionTag,
videoBridgeLogRedaction,
videoContentRemoved,
} = ctx;
const initialConnectionId = toConnectionId(connectionId);
const finalConnectionId = toConnectionId(credentials?.connectionId) || initialConnectionId;
@@ -499,6 +509,7 @@ export function persistAttemptLogs(args: PersistAttemptLogsArgs, ctx: PersistAtt
modelPinned: modelPinned || false,
sessionTag: sessionTag || null,
responseId: extractResponsesId(sourceFormat, clientResponse),
videoContentRemoved: videoContentRemoved || false,
}).catch(() => {});
// Emit the terminal request-lifecycle event to the live dashboard bus. `request.started`

View File

@@ -23,6 +23,36 @@ export function projectFailureUsageErrorCode(opts: {
return errorBody.error.code || String(opts.statusCode);
}
export interface FailureUsageAggregate {
prompt_tokens?: number;
completion_tokens?: number;
cache_read_input_tokens?: number;
cache_creation_input_tokens?: number;
reasoning_tokens?: number;
}
export function toFailureUsageAggregate(
usage:
| {
prompt_tokens?: number;
completion_tokens?: number;
cache_read_input_tokens?: number;
cache_creation_input_tokens?: number;
reasoning_tokens?: number;
}
| null
| undefined
): FailureUsageAggregate | undefined {
if (!usage) return undefined;
return {
prompt_tokens: usage.prompt_tokens,
completion_tokens: usage.completion_tokens,
cache_read_input_tokens: usage.cache_read_input_tokens,
cache_creation_input_tokens: usage.cache_creation_input_tokens,
reasoning_tokens: usage.reasoning_tokens,
};
}
export function buildFailureUsageRecord(opts: {
provider: string | null | undefined;
model: string | null | undefined;
@@ -35,11 +65,18 @@ export function buildFailureUsageRecord(opts: {
errorCode: string | null | undefined;
latencyMs: number;
endpoint?: string | null | undefined;
aggregate?: FailureUsageAggregate | null;
}) {
return {
provider: opts.provider || "unknown",
model: opts.model || "unknown",
tokens: { input: 0, output: 0, cacheRead: 0, cacheCreation: 0, reasoning: 0 },
tokens: {
input: opts.aggregate?.prompt_tokens ?? 0,
output: opts.aggregate?.completion_tokens ?? 0,
cacheRead: opts.aggregate?.cache_read_input_tokens ?? 0,
cacheCreation: opts.aggregate?.cache_creation_input_tokens ?? 0,
reasoning: opts.aggregate?.reasoning_tokens ?? 0,
},
status: String(opts.statusCode),
success: false,
latencyMs: opts.latencyMs,

View File

@@ -5,7 +5,7 @@ import {
toMemoryRetrievalConfig,
} from "@/lib/memory/settings";
import { injectMemory, shouldInjectMemory } from "@/lib/memory/injection";
import { injectSkills } from "@/lib/skills/injection";
import { injectSkillsWithMetadata } from "@/lib/skills/injection";
import { buildMemoryToolsForProvider } from "@/lib/skills/memoryBuiltins";
import { skillRegistry } from "@/lib/skills/registry";
import { FORMATS } from "../../translator/formats.ts";
@@ -13,6 +13,13 @@ import { detectCachingContext } from "../../services/compression/cachingAware.ts
type MemorySkillsLogger = { debug?: (...args: unknown[]) => void } | null | undefined;
export interface MemorySkillsInjectionResult {
body: Record<string, unknown>;
memorySettings: { enabled: boolean; skillsEnabled: boolean; maxTokens: number } | null;
builtinToolNames: string[];
injectedCustomSkillNames: string[];
}
function getToolName(tool: unknown): string {
if (!tool || typeof tool !== "object") return "";
const r = tool as Record<string, unknown>;
@@ -60,11 +67,14 @@ export async function injectMemoryAndSkills({
targetFormat: string;
backgroundReason: string | null;
log: MemorySkillsLogger;
}) {
}): Promise<MemorySkillsInjectionResult> {
const memorySettings = memoryOwnerId
? await getMemorySettings().catch(() => DEFAULT_MEMORY_SETTINGS)
: null;
const builtinOwnerSet: string[] = [];
const injectedCustomSkillNames: string[] = [];
if (
memoryOwnerId &&
memorySettings &&
@@ -178,34 +188,48 @@ export async function injectMemoryAndSkills({
return [];
})
);
const memoryTools = buildMemoryToolsForProvider(
const newMemoryTools = buildMemoryToolsForProvider(
getSkillsProviderForFormat(sourceFormat)
).filter((tool) => {
const record = tool as Record<string, unknown>;
const name = (record.function as Record<string, unknown> | undefined)?.name ?? record.name;
return typeof name === "string" && !existingToolNames.has(name);
});
if (memoryTools.length > 0) {
if (newMemoryTools.length > 0) {
body = {
...body,
tools: [...existingTools, ...memoryTools],
tools: [...existingTools, ...newMemoryTools],
};
// Track the names of newly injected memory tools for the owner set.
builtinOwnerSet.push(
...newMemoryTools
.map((tool) => {
const record = tool as Record<string, unknown>;
const name =
(record.function as Record<string, unknown> | undefined)?.name ?? record.name;
return typeof name === "string" ? name : "";
})
.filter(Boolean)
);
log?.debug?.(
"MEMORY",
`Injected ${memoryTools.length} memory tool(s) for key=${memoryOwnerId}`
`Injected ${newMemoryTools.length} memory tool(s) for key=${memoryOwnerId}`
);
}
}
if (memoryOwnerId && memorySettings?.skillsEnabled) {
if (memoryOwnerId && memorySettings?.skillsEnabled && body.stream !== true) {
// Ensure the registry cache is warm before listing: on a cold/fresh
// process skills that exist only in the DB would be missed (false
// negative -> silent skip). loadFromDatabase() is a no-op when the cache
// is already warm (TTL = 60 s), so repeated calls are cheap. Mirrors the
// pattern in src/lib/skills/interception.ts (#2815).
// Memory builtins and registered Skills are only executed by the
// non-streaming server-owned tool loop; stream clients execute tools
// client-side, so we skip injection for stream requests.
await skillRegistry.loadFromDatabase(memoryOwnerId);
const existingTools = Array.isArray(body.tools) ? body.tools : [];
const mergedTools = injectSkills({
const { tools: mergedTools, injectedNames } = injectSkillsWithMetadata({
provider: getSkillsProviderForFormat(sourceFormat),
existingTools,
apiKeyId: memoryOwnerId,
@@ -225,6 +249,7 @@ export async function injectMemoryAndSkills({
...body,
tools: mergedTools,
};
injectedCustomSkillNames.push(...injectedNames);
log?.debug?.("SKILLS", `Injected ${mergedTools.length - existingTools.length} skills`);
}
}
@@ -236,5 +261,45 @@ export async function injectMemoryAndSkills({
};
}
return { body, memorySettings };
return { body, memorySettings, builtinToolNames: builtinOwnerSet, injectedCustomSkillNames };
}
interface FallbackPlan {
enabled: boolean;
toolName: string | null;
convertedToolCount: number;
}
/**
* Pure helper: merge web-search/web-fetch fallback tool names into the
* builtin owner set. Adds a name only when plan.enabled===true,
* plan.convertedToolCount>0, plan.toolName is non-null, and that name
* did not already exist in the pre-conversion client tools (builtinToolNames)
* OR in the original client tool names captured before fallback injection.
* Does not mutate its input; returns a new result.
*/
export function mergeInjectedFallbackOwnerNames(
injectionResult: { builtinToolNames: string[] },
plans: FallbackPlan[],
preConversionClientToolNames?: string[]
): { builtinToolNames: string[] } {
const existing = new Set(injectionResult.builtinToolNames);
if (preConversionClientToolNames) {
for (const name of preConversionClientToolNames) {
existing.add(name);
}
}
const extraNames: string[] = [];
for (const plan of plans) {
if (
plan.enabled &&
plan.convertedToolCount > 0 &&
plan.toolName &&
!existing.has(plan.toolName)
) {
extraNames.push(plan.toolName);
existing.add(plan.toolName);
}
}
return { builtinToolNames: [...injectionResult.builtinToolNames, ...extraNames] };
}

View File

@@ -0,0 +1,159 @@
/**
* Client translation for non-streaming responses.
* Extracted from chatCore.ts (lines ~5098-5195) by symbol boundaries.
*
* Handles: translate, tool-name restore, finish-reason normalization, sanitize,
* reasoning replay capture, and client usage buffer application.
*
* Phase distinction:
* - "final": applies applyClientUsageBuffer (normalizes visible usage fields)
* - "intermediate": skips usage buffer (raw usage preserved for aggregation)
*/
import type {
NonStreamingClientTranslateInput,
NonStreamingClientTranslateResult,
} from "@/lib/skills/toolLoopTypes.ts";
import { needsTranslation } from "../../translator/index.ts";
import { FORMATS } from "../../translator/formats.ts";
import { translateNonStreamingResponse } from "../responseTranslator.ts";
import { extractToolSchemaMap } from "../../translator/response/openai-responses/toolSchemas.ts";
import { stripMarkdownCodeFence } from "../../utils/aiSdkCompat.ts";
import { normalizeOpenAIToolFinishReasons } from "./passthroughToolNames.ts";
import {
cacheReasoningFromAssistantMessage,
requiresReasoningReplay,
} from "../../services/reasoningCache.ts";
import {
sanitizeOpenAIResponse,
sanitizeResponsesApiResponse,
shouldParseTextualReasoningTags,
} from "../responseSanitizer.ts";
import { isStripReasoningRequested } from "./headers.ts";
import { applyClientUsageBuffer } from "./clientUsageBuffer.ts";
export type { NonStreamingClientTranslateInput, NonStreamingClientTranslateResult };
/**
* Translate a non-streaming provider response to the client's expected format.
*
* All of: translate, tool-name identity restore, finish-reason normalization,
* and sanitize are applied every round (both intermediate and final).
* `applyClientUsageBuffer` is applied only for `phase === "final"`.
* Reasoning replay capture runs every round.
*/
export function translateNonStreamingClientResponse(
input: NonStreamingClientTranslateInput
): NonStreamingClientTranslateResult {
const {
responseBody,
responsePayloadFormat,
clientResponseFormat,
sourceFormat,
provider,
model,
requestBody,
responseToolNameMap,
requestToolIdentityMap,
reasoningCacheScope,
clientHeaders,
isClaudeCodeCompatible,
phase,
} = input;
// ── Extract tool schemas for schema-aware translation ──────────────────────
const finalBody = requestBody as Record<string, unknown> | null;
const responseToolSchemas = extractToolSchemaMap(finalBody || responseBody);
// ── Translate response to client's expected format ─────────────────────────
let translatedResponse = needsTranslation(responsePayloadFormat, clientResponseFormat)
? translateNonStreamingResponse(
responseBody,
responsePayloadFormat,
clientResponseFormat,
responseToolNameMap,
responseToolSchemas
)
: responseBody;
const responseForMemoryExtraction = translatedResponse;
// ── T26: Strip markdown code blocks if provider format is Claude ───────────
if (sourceFormat === "claude") {
if (typeof translatedResponse?.choices?.[0]?.message?.content === "string") {
translatedResponse.choices[0].message.content = stripMarkdownCodeFence(
translatedResponse.choices[0].message.content
) as string;
}
}
// ── T18: Normalize finish_reason to 'tool_calls' if tool calls present ─────
normalizeOpenAIToolFinishReasons(translatedResponse);
// ── Reasoning Replay Cache (#1628) ────────────────────────────────────────
// Capture reasoning_content from non-streaming responses with tool_calls
// so it can be replayed on subsequent turns.
try {
const cacheResponse = translatedResponse?.choices?.[0]
? translatedResponse
: needsTranslation(responsePayloadFormat, FORMATS.OPENAI)
? translateNonStreamingResponse(
responseBody,
responsePayloadFormat,
FORMATS.OPENAI,
responseToolNameMap,
responseToolSchemas
)
: responseBody;
const firstChoice = cacheResponse?.choices?.[0];
const msg = firstChoice?.message;
// Prefer explicit historyMessages (parent: translatedBody.messages). Do not
// overload requestBody — Responses-shaped finalBody has `input`, not `messages`.
const historyMessages = Array.isArray(input.historyMessages)
? input.historyMessages
: (finalBody as { messages?: unknown[] } | null | undefined)?.messages;
if (requiresReasoningReplay({ provider, model })) {
cacheReasoningFromAssistantMessage(msg, provider, model, {
scope: reasoningCacheScope,
historyMessages: Array.isArray(historyMessages) ? historyMessages : [],
});
}
} catch {
// Cache capture is non-critical — never block the response
}
// ── Sanitize response for SDK compatibility ────────────────────────────────
if (clientResponseFormat === FORMATS.OPENAI_RESPONSES) {
translatedResponse = sanitizeResponsesApiResponse(translatedResponse);
// Restore {namespace, name} on function_call items for round-trip closure (#7936)
const responseOutput = translatedResponse?.output;
if (requestToolIdentityMap && Array.isArray(responseOutput)) {
for (const item of responseOutput) {
if (item?.type !== "function_call") continue;
const identity = requestToolIdentityMap.get(item.name);
if (identity) {
item.namespace = identity.namespace;
item.name = identity.name;
}
}
}
} else if (clientResponseFormat === FORMATS.OPENAI) {
const stripReasoning = isStripReasoningRequested(clientHeaders ?? null);
translatedResponse = sanitizeOpenAIResponse(translatedResponse, {
stripReasoning,
parseTextualReasoningTags: shouldParseTextualReasoningTags(provider, model),
});
}
// ── Client usage buffer (#8331) ───────────────────────────────────────────
// Only apply for final phase; intermediate preserves raw usage for aggregation.
if (phase === "final") {
applyClientUsageBuffer(translatedResponse, finalBody || responseBody, clientResponseFormat, {
preserveContextBudgetInVisibleUsage: isClaudeCodeCompatible,
});
}
return {
response: translatedResponse,
responseForMemoryExtraction,
};
}

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