Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@ permissions:
contents: read

jobs:
verify:
verify-node:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: [22, 24]
steps:
# Keep title validation in the required verify check. Read event data rather
# than interpolating untrusted PR text into shell commands.
Expand All @@ -33,7 +37,7 @@ jobs:

- uses: actions/setup-node@v7
with:
node-version: 24
node-version: ${{ matrix.node }}

- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@11.0.0 --activate
Expand All @@ -45,4 +49,42 @@ jobs:
run: pnpm run verify

- name: Offline release checks
if: matrix.node == 24
run: pnpm run check:release

opencode:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
opencode: [2.0.16, 2.0.24]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@11.0.0 --activate
- name: Install
run: pnpm install --frozen-lockfile
- name: Install pinned OpenCode host
env:
OPENCODE_VERSION: ${{ matrix.opencode }}
run: npm install --global "@opencode/cli@$OPENCODE_VERSION"
- name: Packed artifact and concrete wire model
run: pnpm run smoke:opencode
- name: Inspection RPC without model calls
run: pnpm run smoke:inspect

# Preserve the required check name used by the branch rulesets.
verify:
runs-on: ubuntu-latest
needs: [verify-node, opencode]
if: always()
steps:
- name: Require all compatibility checks
env:
NODE_RESULT: ${{ needs.verify-node.result }}
HOST_RESULT: ${{ needs.opencode.result }}
run: test "$NODE_RESULT" = success && test "$HOST_RESULT" = success
40 changes: 36 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@
[![CI](https://github.com/vmvarela/opencode-model-aliases/actions/workflows/ci.yml/badge.svg)](https://github.com/vmvarela/opencode-model-aliases/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

**Stop hardcoding model versions in your OpenCode config.**

An [OpenCode](https://opencode.ai) v2 plugin that materializes **floating model aliases**
into the model catalog. Configure an alias once — say `anthropic/smart` — and the plugin
picks the newest matching source model at every catalog refresh, exposing it under a stable
ID. Your configuration and prompts keep working as the provider ships new models; you never
hard-code a model ID that goes stale.

A concrete reference pins a model. A floating alias names a selection policy: the stable
ID stays the same while its eligible target can change. Use a concrete reference when
you want to keep a specific model; use this plugin when you want to follow a model family.

## Why

Model IDs age badly: Sonnet gets point releases, `*-preview` snapshots get promoted, and
Expand Down Expand Up @@ -62,6 +68,8 @@ Then define aliases in `.opencode/opencode-model-aliases.jsonc` next to your pro

Restart OpenCode. Type `/model-aliases` to see what each alias resolved to.

See [the OMO-slim example](https://github.com/vmvarela/opencode-model-aliases/blob/master/docs/oh-my-opencode-slim.md) for agent presets using stable IDs.

> OpenCode caches installed plugin packages. Apply a newer release with
> `opencode plugin update opencode-model-aliases@latest`; restarting alone won't update it.

Expand Down Expand Up @@ -198,6 +206,28 @@ The report reflects the last **successful** catalog replay; if the last replay f
get a clear "unavailable" message instead of a stale mapping. With no aliases configured
the report is `No aliases configured.`

### Target changes

The first confirmed resolution establishes a silent baseline. Later changes show a
grouped TUI toast and remain visible in `/model-aliases`, including the previous target,
current target and detection time. Inspection RPC rows expose the same optional
`transition` object. A change in the execution `modelID` also counts, even when the catalog
ID stays the same. Changes may move to an older model; notifications do not claim upgrades.

History uses OpenCode's native plugin storage, scoped by directory/workspace and each
alias's effective selection policy. Changing a policy resets that alias's baseline;
renaming it or changing debug/strict settings does not. Only the last confirmed target
and the most recent transition are stored. `changedAt` is the observation time, not the
model's release date. Failed reads, unresolved aliases and inactive mappings do not
overwrite the baseline. Recovery to the same target produces no new transition.

The server observes native `model.updated` events and confirmed inspection reads, even
without a TUI. Each open TUI keeps local notification acknowledgments. Native TUI storage
also remembers acknowledgments across restarts of the same profile; newly opened clients
sharing that profile inherit them. Already open clients can each show the change once.
There is no cross-client delivery coordination. Storage or notification failures leave
normal alias resolution working; persistence and notification delivery are best effort.

## Limitations

- Only the `latest` strategy exists; other strategies are rejected at startup.
Expand All @@ -216,9 +246,11 @@ pnpm run verify # lint + typecheck + tests + build (self-contained)
pnpm run check:release # offline release checks; run after verify before opening a PR
```

Opt-in real-host smoke tests against a locally installed OpenCode v2 CLI (not part of
`pnpm verify` or CI, [#29](https://github.com/vmvarela/opencode-model-aliases/issues/29)):
`pnpm smoke:opencode` (end-to-end session) and `pnpm smoke:inspect` (inspect RPC against
the real host surface).
CI verifies Node.js 22 and 24, plus the minimum supported OpenCode **2.0.16** and the
current pinned host **2.0.24**. Both host jobs load the packed npm artifact, verify the
concrete wire model against a local fake provider, and inspect through the real RPC
without model calls. Inspection also verifies native history across host restarts.
Run locally with an installed OpenCode v2 CLI: `pnpm smoke:opencode` and
`pnpm smoke:inspect`. These are separate from the self-contained `pnpm verify` checks.

MIT License — see [LICENSE](LICENSE).
58 changes: 58 additions & 0 deletions docs/oh-my-opencode-slim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Stable model references in OMO-slim presets

An OMO-slim preset can refer to a floating alias instead of a particular model generation.
Model selection stays provider-local and uses release metadata from OpenCode's catalog.

Load the alias plugin before OMO-slim in `opencode.json`:

```json
{
"plugins": [
{ "package": "opencode-model-aliases@latest" },
{ "package": "oh-my-opencode-slim@latest" }
]
}
```

Define the policy in `.opencode/opencode-model-aliases.jsonc`:

```jsonc
{
"aliases": {
"opencode-go/glm-latest": {
"match": "opencode-go/glm-*",
"filter": { "capabilities": { "tools": true } }
},
"opencode-go/kimi-code-latest": {
"match": "opencode-go/kimi-*-code",
"filter": { "capabilities": { "tools": true } }
}
}
}
```

Use the full `provider/alias` references in your OMO-slim configuration:

```json
{
"preset": "floating-go",
"presets": {
"floating-go": {
"orchestrator": { "model": "opencode-go/glm-latest" },
"fixer": { "model": "opencode-go/kimi-code-latest" }
}
}
}
```

Merge these entries into your existing preset and keep the other agent settings.
Check `opencode models --refresh` for the provider and model IDs available to your account,
then restart and inspect `/model-aliases`. Patterns must match real catalog entries:
the plugin cannot discover missing providers or models. Variant availability comes from
the selected model; a floating alias does not guarantee that a particular variant exists.

When a later catalog refresh changes an eligible target, the alias ID stays the same and
the plugin records the transition. This complements OMO-slim's orchestration and preset
switching; it does not implement retries, failover or model routing.

Upstream configuration: [OMO-slim presets](https://github.com/alvinunreal/oh-my-opencode-slim/blob/master/docs/configuration.md).
69 changes: 63 additions & 6 deletions scripts/smoke-opencode.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
#!/usr/bin/env node
/**
* Opt-in real-host smoke test against a locally installed OpenCode v2 CLI
* (verified against v2.0.22; POSIX-only, fails fast on win32). Opt-in: NOT
* part of `pnpm verify` or CI — runners don't install OpenCode.
* (POSIX-only, fails fast on win32). Runs in CI against pinned minimum and
* current hosts; also available locally when OpenCode is on PATH.
*
* What it does, concisely:
*
Expand Down Expand Up @@ -52,7 +52,7 @@
*/
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, readdir, rm, symlink, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
import http from "node:http";
import os from "node:os";
import path from "node:path";
Expand Down Expand Up @@ -151,7 +151,7 @@ function modelEntry(id, name, releaseDate) {
temperature: true,
modalities: { input: ["text"], output: ["text"] },
release_date: releaseDate,
limit: { context: 8192, output: 4096 },
limit: { context: 128000, output: 4096 },
cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 },
};
}
Expand Down Expand Up @@ -257,10 +257,10 @@ async function main() {
version.code === 0 && !version.timedOut ? (match ? Number(match[1]) : null) : null;
if (major !== 2) {
throw new Error(
`OpenCode v2 CLI is required on PATH (probe: code=${version.code}, timedOut=${version.timedOut}, spawnError=${version.spawnError}, output=${JSON.stringify(probe.slice(0, 200))}). This smoke is opt-in and is NOT part of \`pnpm verify\`/CI.`,
`OpenCode v2 CLI is required on PATH (probe: code=${version.code}, timedOut=${version.timedOut}, spawnError=${version.spawnError}, output=${JSON.stringify(probe.slice(0, 200))}). Install @opencode/cli and rerun this smoke.`,
);
}
console.log("smoke:opencode: OpenCode CLI v2 detected.");
console.log(`smoke:opencode: OpenCode ${probe} detected.`);

// Build first: stale dist must not make the smoke pass falsely.
const build = await runSpawn("pnpm", ["run", "build"], {
Expand Down Expand Up @@ -547,6 +547,63 @@ async function main() {
}
}

if (problems.length === 0) {
const baseline = reportRows.find((row) => row.key === ALIAS_KEY);
if (baseline.transition !== undefined)
problems.push("first resolution produced a false transition");
// Restart two standalone hosts under the same isolated HOME/location.
// Changing release metadata makes fake-small win without changing policy.
const catalog = JSON.parse(await readFile(path.join(dir, "models.json"), "utf8"));
catalog[PROVIDER].models["fake-small"].release_date = "2026-01-01";
await writeFile(path.join(dir, "models.json"), JSON.stringify(catalog));
const consumerFile = path.join(consumerDir, "index.js");
await writeFile(
consumerFile,
(await readFile(consumerFile, "utf8")).replaceAll("fake-large", "fake-small"),
);
let transitionID;
for (let restart = 0; restart < 2; restart++) {
const result = await runSpawn(
"opencode",
[
"api",
"--standalone",
"post",
"/api/rpc/opencode-model-aliases/inspect",
"--data",
JSON.stringify({ input: {} }),
],
{ cwd: project, env, timeoutMs: CLI_TIMEOUT_MS },
);
if (result.code !== 0 || result.timedOut || result.spawnError) {
problems.push(`history restart ${restart} failed: ${result.stderr.slice(-1500)}`);
continue;
}
let row;
try {
row = JSON.parse(result.stdout).output.rows.find((value) => value.key === ALIAS_KEY);
} catch {}
const change = row?.transition;
if (
row?.status !== "active" ||
row?.target !== "fake-small" ||
change?.from !== `${PROVIDER}/fake-large` ||
change?.to !== `${PROVIDER}/fake-small` ||
!Number.isFinite(Date.parse(change?.changedAt ?? ""))
) {
problems.push(
`history restart ${restart} did not preserve confirmed A → B: ${JSON.stringify(row)}`,
);
}
if (restart === 0) transitionID = change?.id;
else if (!transitionID || change?.id !== transitionID)
problems.push("unchanged restart repeated the transition");
}
console.log(
"smoke:opencode: native storage and transition identity verified across restarts.",
);
}

// The sink counts EVERY provider request: zero after this bounded
// observation (the subprocess finished); inspecting runs no sessions.
if (requests.length !== 0) {
Expand Down
Loading
Loading