Skip to content

feat(opencode): opencode v2 plugin publishing the OmniRoute catalog - #12870

Merged
diegosouzapw merged 1 commit into
diegosouzapw:release/v3.8.51from
maxmad64bis:feat/opencode-plugin-v2
Sep 6, 2026
Merged

diegosouzapw merged 1 commit into
diegosouzapw:release/v3.8.51from
maxmad64bis:feat/opencode-plugin-v2

Conversation

@maxmad64bis

@maxmad64bis maxmad64bis commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

⚠️ base-red inherited: #12732

Summary

OpenCode v2 has no route to an OmniRoute gateway. The existing @omniroute/opencode-plugin targets v1, whose loader expects plugin factories; v2 expects a default define({id, setup}) with catalog and integration domains. This adds a second, self-contained package. The v1 one is untouched — no move, no migration, no breaking version — and they share no code and no release.

It publishes models, combos and auto-combos into the host catalog, refreshes them behind a 300s TTL, and falls back to a disk snapshot when the gateway is down. Models and combos go out as soon as they're known; the optional sources fold in when they land, so one slow endpoint can't hold the picker hostage.

Four things it does that the v1 plugin can't. The key comes from OpenCode's own credential store when the integration is connected, so no secret has to sit in opencode.json. Failures are named instead of swallowed: three empty catch blocks used to turn a refused management token into a picker full of raw ids with no explanation. Display names now carry what the gateway knows — the upstream provider, a free marker, the budget — so two connections selling the same model stay distinguishable. And tool schemas bound for Gemini are stripped of the keywords it rejects with 400 INVALID_ARGUMENT, at the model level rather than by rewriting HTTP bodies.

Four v1 behaviours are deliberately dropped, because v2 owns them or no longer needs them: the plugin-side debug log, the compression suffix on combo names, the MCP auto-emit, and the omni-sync command with its timer (v2 commands are prompt templates, not callbacks — the TTL and a fingerprint drive catalog.reload()).

Related Issues

  • Closes #
  • Related to #

Validation

  • Change type: other (new npm package + docs; no change to src/, open-sse/, electron/ or bin/)
  • Focused tests and category gates from the golden path — 189 tests in the new package (npm test inside @omniroute/opencode-plugin-v2), plus tsc --noEmit, Prettier and the production build
  • npm run lint
  • Reconciled with the current active release base; focused checks rerun afterward
  • Production-code changes include a new or updated automated test in this PR

npm run lint doesn't start on a clean checkout of the base either: string.prototype.repeat@1.0.0 needs es-abstract/2019/RequireObjectCoercible, which es-abstract@1.24.1 (pinned by the lockfile) no longer ships. Installing es-abstract@1.23.9 out of the lockfile makes it run, and it then exits 0 with only the pre-existing violations frozen in config/quality/eslint-suppressions.json.

Three red checks on this PR come from the base, not from this branch — listing them so nobody chases them here:

Check Failure Why it isn't this branch
Docs Gates (fast-path) 169 migrations in README.md, AGENTS.md, llm.txt vs 170 migration files This branch adds no migration and edits none of those files
Merge integrity (changelog) changelog.d/fixes/reset-aware-model-family.md doesn't start with - Added by 2a6eff0ae (#12637); not in this diff
API Route Typecheck open-sse/executors/glm.ts TS2554 This branch touches no file under open-sse/

Tests Added Or Updated

All under @omniroute/opencode-plugin-v2/tests/ (202 tests, new package, nothing pre-existing changed):

  • credentials.test.ts, enrichment-report.test.ts, shared-enrichment-source-errors.test.ts — credential resolution order, OAuth refusal, old hosts, and every degraded-source path, on the plugin and library entry points alike
  • enrichment-render.test.ts — provider tag, free marker and budget suffix reaching the published name
  • parity.test.ts + fixtures/v1-parity.json — the v1 plugin's recorded output for the shared fixture, so the parity claim is reviewable and runs without building the v1 package
  • staged-refresh.test.ts — a source that never answers must not hold the publish; a late one must reach the catalog
  • gemini-language.test.ts, shared-gemini.test.ts — schema stripping, and that it applies to this provider's Gemini models only
  • catalog.test.ts, host-contract.test.ts, api-package.test.ts, parity.test.ts, nested-combos.test.ts, auto-combos.test.ts, usable-only.test.ts, cache-ttl-snapshot.test.ts, refresh-failopen.test.ts, publish-guard.test.ts, snapshot-stale-entries.test.ts, management-token.test.ts, timeouts-logger.test.ts, options.test.ts, compat.test.ts, enrichment.test.ts, smoke-types.test.ts, and the shared-*.test.ts files covering the mapping layer

Coverage Notes

No file under src/, open-sse/, electron/ or bin/ changes, so the repo coverage gate is unaffected. The new package ships its own suite and runs it in CI on Node 22 and 24 before publishing.

Reviewer Notes

The catalog contract is still moving, so the plugin reads the shape the host seeds into the draft rather than keying off a version list, and publishes the api block, the pre-api fields, or both. host-contract.test.ts pins it; the SDK stays pinned, so a contract move surfaces as a failing test on the bump. Three capability probes (integration.connection, aisdk, catalog.reload) keep the plugin loadable on older hosts.

Migration note: v1 published provider id opencode-<id>, this one publishes <id> bare, so a pinned session has to re-select its model. The plugin id itself is fixed (omniroute-v2) — the host reads it before any option exists.

Three things I left alone on purpose. The publish job copies the v1 one, bump included (npm version patch never lands back in the repo) — worth fixing, but that's a repo-wide convention, not mine to change here. Two v1 mapper behaviours stay as they are because fixtures/v1-parity.json freezes exactly that contract: a combo whose members are combos routes openai-compatible even when every leaf is Anthropic, and two aliases sharing a bare model id can overwrite each other's pricing. Both predate this package.

The Gemini sanitiser is unit-tested only; the 400 itself is inherited from v1 rather than re-measured against a live route.

@maxmad64bis
maxmad64bis force-pushed the feat/opencode-plugin-v2 branch 9 times, most recently from 968844b to 77b6844 Compare September 6, 2026 20:12
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.
@maxmad64bis
maxmad64bis force-pushed the feat/opencode-plugin-v2 branch from 77b6844 to d38ac9c Compare September 6, 2026 20:30
@diegosouzapw
diegosouzapw merged commit b345c7f into diegosouzapw:release/v3.8.51 Sep 6, 2026
12 of 21 checks passed
diegosouzapw added a commit that referenced this pull request Sep 7, 2026
check:pack-policy failed on the release tip with 27 'unexpected files',
all of them under @omniroute/opencode-plugin-v2/.

#12870 added that package next to @omniroute/opencode-plugin and
@omniroute/opencode-provider - both already on
PACK_ARTIFACT_ROOT_ALLOWED_PATH_PREFIXES - and package.json's files field
ships the whole @omniroute/ tree, but the allowlist was never widened. The
v2 package has the same shape as the v1 sibling it was modeled on
(LICENSE, README, package.json, src/, tests/, tsconfig.json,
tsup.config.ts), so it gets the same prefix entry.

Reproduced on the clean tip d6f3150 (fails) and with this change (passes) -
check:pack-policy runs --policy-only, so no build is involved.

Refs #12732
guanbear added a commit to guanbear/OmniRoute that referenced this pull request Sep 9, 2026
…fact policy (base-red)

diegosouzapw#12870 added @omniroute/opencode-plugin-v2 to the npm pack artifact but
did not register its prefix in PACK_ARTIFACT_ROOT_ALLOWED_PATH_PREFIXES,
so the pack-policy gate fails on the release tip for every open PR.
guanbear added a commit to guanbear/OmniRoute that referenced this pull request Sep 9, 2026
…fact policy (base-red)

diegosouzapw#12870 added @omniroute/opencode-plugin-v2 to the npm pack artifact but
did not register its prefix in PACK_ARTIFACT_ROOT_ALLOWED_PATH_PREFIXES,
so the pack-policy gate fails on the release tip for every open PR.
guanbear added a commit to guanbear/OmniRoute that referenced this pull request Sep 9, 2026
…fact policy (base-red)

diegosouzapw#12870 added @omniroute/opencode-plugin-v2 to the npm pack artifact but
did not register its prefix in PACK_ARTIFACT_ROOT_ALLOWED_PATH_PREFIXES,
so the pack-policy gate fails on the release tip for every open PR.
guanbear added a commit to guanbear/OmniRoute that referenced this pull request Sep 9, 2026
…fact policy (base-red)

diegosouzapw#12870 added @omniroute/opencode-plugin-v2 to the npm pack artifact but
did not register its prefix in PACK_ARTIFACT_ROOT_ALLOWED_PATH_PREFIXES,
so the pack-policy gate fails on the release tip for every open PR.
diegosouzapw added a commit that referenced this pull request Sep 14, 2026
… test flake, pack provenance + pack policy (#12959)

Merged after salvaging what still applies. The doc-count half of this PR is superseded — it moved migrations 169 → 171 while the tip is already at 174, and the provider count landed via #13216 — so every docs, diagram and llm.txt mirror change was reset to the tip's version and its changelog fragment dropped. Merging it as it stood would have regressed the counts.

Kept, none of it on the tip:

- `scripts/build/pack-artifact-policy.ts`: `@omniroute/opencode-plugin-v2/` added to the allowlist — #12870 shipped the v2 plugin without widening it, so every packed file under it read as unexpected
- `scripts/quality/validate-release-green.mjs`: points the pack provenance guard at `HEAD` instead of `origin/main`, which is structurally unreachable from a release branch mid-cycle
- `12058-models-catalog-canonical-self-aliased.test.ts`: pins `CATALOG_BUILD_TIMEOUT_MS` out of the way of a cold catalog build on a loaded runner; assertions unchanged

54/54 across those three suites, ESLint exit 0, changelog integrity OK.

⚠️ base-red inherited: #12732
@maxmad64bis
maxmad64bis deleted the feat/opencode-plugin-v2 branch September 24, 2026 21:11
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…iegosouzapw#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>
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
… test flake, pack provenance + pack policy (diegosouzapw#12959)

Merged after salvaging what still applies. The doc-count half of this PR is superseded — it moved migrations 169 → 171 while the tip is already at 174, and the provider count landed via diegosouzapw#13216 — so every docs, diagram and llm.txt mirror change was reset to the tip's version and its changelog fragment dropped. Merging it as it stood would have regressed the counts.

Kept, none of it on the tip:

- `scripts/build/pack-artifact-policy.ts`: `@omniroute/opencode-plugin-v2/` added to the allowlist — diegosouzapw#12870 shipped the v2 plugin without widening it, so every packed file under it read as unexpected
- `scripts/quality/validate-release-green.mjs`: points the pack provenance guard at `HEAD` instead of `origin/main`, which is structurally unreachable from a release branch mid-cycle
- `12058-models-catalog-canonical-self-aliased.test.ts`: pins `CATALOG_BUILD_TIMEOUT_MS` out of the way of a cold catalog build on a loaded runner; assertions unchanged

54/54 across those three suites, ESLint exit 0, changelog integrity OK.

⚠️ base-red inherited: diegosouzapw#12732
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants