diff --git a/AGENTS.md b/AGENTS.md index 7dd681d..6d5e030 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,16 @@ Body is JSONC, must parse to JSON matching the `@path` leaf: any shape for `replace`, object for `merge`/`merge-overwrite`, array for `append`. Substitutions in body and `@path`: `{{cache}}`, `{{prompt:}}`. +## Append supersedes a version bump + +`append` dedupes by deep equality, so `pkg@0.8.1` and `pkg@0.9.0` are two +entries and opencode loads the plugin twice. An incoming `name@spec` entry +therefore replaces every existing entry with the same package name, in place +(`specName` in `src/merge.ts`), and stacked configs collapse on next install. +Non-`name@spec` entries (`{{cache}}` fetch dests, prompted dirs, git URLs with +credentials) stay plain append — a heuristic there would delete unrelated +entries. `remove` is untouched: it still deletes only exact matches. + ## Merge stays additive `merge` MUST NOT overwrite existing keys — existing values win, only missing diff --git a/README.md b/README.md index 6606611..38e9425 100644 --- a/README.md +++ b/README.md @@ -57,8 +57,8 @@ something outside your opencode config say so in their description. | `mcp-litellm-passthrough` | MCP | replace | Install `mcp-litellm` first — re-running it replaces `mcp.litellm` and drops these headers. Adds one `x-mcp--
` passthrough header to the `mcp.litellm` server so an upstream MCP server authenticates as you (run once per header) | | `mcp-playwright` | MCP | replace | Add the Playwright MCP server (local stdio via npx; pins `@playwright/mcp` 0.0.79) | | `mcp-vscode` | MCP | replace | Requires the `JuehangQin.vscode-mcp-server` extension installed, enabled and toggled active in VS Code first — this preset does not install it. Adds the VS Code MCP server via that extension (loopback HTTP, default port 3000) | -| `plugin-litellm-pricing` | Plugin | append | Install `provider-litellm` too — the catalog URL has no default and lives there, and without it the models are still discovered, just unpriced. Adds `opencode-plugin-litellm-pricing`: discovers a LiteLLM proxy's models at runtime and adds them to the picker with real per-model pricing from a LiteLLM-format model catalog instead of `$0` (pins `opencode-plugin-litellm-pricing` 0.8.1) | -| `provider-litellm` | Provider | replace | Point the `litellm` provider at your proxy URL for `plugin-litellm-pricing`, and name the model catalog it prices against — neither the plugin nor this preset has a default one (prompts for base URL, API key and catalog URL; no models list) | +| `plugin-litellm-pricing` | Plugin | append | Install `provider-litellm` too — without a `litellm` provider pointing at your proxy the plugin does nothing. Adds `opencode-plugin-litellm-pricing`: discovers a LiteLLM proxy's models at runtime and adds them to the picker with the proxy's own per-model pricing instead of `$0` (pins `opencode-plugin-litellm-pricing` 0.9.0) | +| `provider-litellm` | Provider | replace | Point the `litellm` provider at your proxy URL and key for `plugin-litellm-pricing`, which prices the models against that same proxy (prompts for base URL and API key; no models list) | | `plugin-superpowers` | Plugin | append | Add the Superpowers OpenCode plugin from `obra/superpowers` (brainstorming, plans, TDD, review workflows; pins tag `v6.3.0`) | | `plugin-dcg` | Plugin | append | Install the external `dcg` binary yourself first — `brew install dicklesworthstone/tap/dcg`; or `cargo install destructive_command_guard` wherever a Rust toolchain is available (dcg is not in nixpkgs, so on nix that means `nix-env -iA nixpkgs.cargo` then the cargo line, and `~/.cargo/bin` on `PATH`); or a signed release binary from [the releases page](https://github.com/Dicklesworthstone/destructive_command_guard/releases), which ships a `.sha256` and sigstore attestations per asset — the x86_64 Linux build is static musl, so it needs nothing from the distro. Upstream also has a curl-pipe `install.sh` if you like those. The install refuses while `dcg` is not on `PATH`, because without it the plugin warns once and every command runs unchecked. **Experimental** — the plugin is at 0.2.x and its behaviour can still change. Adds `opencode-plugin-dcg`: runs every bash command past `dcg` and blocks the destructive ones (pins `opencode-plugin-dcg` 0.2.0) | | `privacy-share-disabled` | Privacy | replace | In the bundle. Sets `share` to "disabled" so opencode never publishes a session, automatically or on command | @@ -182,42 +182,22 @@ opencode-presets reset mcp.openrag-tom ### Pricing a LiteLLM proxy -`plugin-litellm-pricing` has no built-in catalog URL: it fetches the model -catalog you name in `options.catalogURL` and nothing else. Name none and the -models are still discovered and injected — they just carry no cost, and the -startup log says so, naming the provider: - -``` -[litellm-pricing] provider "litellm" has no options.catalogURL — set it to a - model catalog in LiteLLM `model_prices_and_context_window.json` format; - every model will be injected without pricing. -``` - -`provider-litellm` prompts for that URL and writes it. It offers no default -either — a default here would put back the third-party host the plugin -deliberately does not reach for, one layer down — so a blank answer ends the -install rather than writing someone else's URL into your config. Two answers are -usual: - -``` -https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json -``` - -LiteLLM's published catalog, which covers the public model line. Or, if your -gateway serves an enriched copy — upstream's entries plus your own model names, -with their real context and pricing — name that instead: those models then price -by exact name rather than by substring against the public line. The URL is -repeated in the preset's description, so it is on screen when the prompt asks. - -Non-interactively, pass it in: +Pricing takes no configuration of its own. `plugin-litellm-pricing` reads each +model's cost, limits and capabilities from the proxy `provider-litellm` already +points at, so the base URL and key are the whole setup. The numbers are +LiteLLM's own resolved ones, your config-level `model_info` overrides included, +so what opencode displays is what the gateway bills. A model the proxy reports +no cost for is injected without a `cost` block rather than with a wrong one, and +the startup log names it: ```sh -opencode-presets install provider-litellm --set catalogURL=https://…/model_prices_and_context_window.json +grep litellm-pricing ~/.local/share/opencode/log/opencode.log ``` `provider-litellm` is a `replace` preset, so re-running it rewrites the whole `provider.litellm` block and re-prompts for the base URL and key as well — have -the key to hand. +the key to hand. Coming from an earlier install, re-run it once: that is what +clears the now-unused `options.catalogURL` out of your config. ### Checking commands with `dcg` @@ -368,7 +348,11 @@ on a readline for the next. rules so user edits stick around. - `append` — the preset's array entries are appended if missing; existing array entries are preserved. Use this for shared arrays - like `plugin`. + like `plugin`. An entry of the form `name@version` supersedes every + existing entry naming the same package, in place: bumping a pinned + plugin replaces the old pin instead of leaving both in the array for + opencode to load twice. A config that already stacked several bumps + collapses to the newest one on the next install. Re-installing is always safe: a no-op produces no backup and no write. diff --git a/presets/plugin-litellm-pricing.conf b/presets/plugin-litellm-pricing.conf index a835542..52c0029 100644 --- a/presets/plugin-litellm-pricing.conf +++ b/presets/plugin-litellm-pricing.conf @@ -1,13 +1,13 @@ // @name: plugin-litellm-pricing -// @description: Needs provider-litellm installed too — the catalog URL has no -// default and lives there, and without it the models arrive unpriced. Adds the +// @description: Needs provider-litellm installed too — without a litellm provider +// pointing at your proxy the plugin does nothing. Adds the // opencode-plugin-litellm-pricing plugin: discovers a LiteLLM proxy's models at -// runtime and adds them to the picker with real per-model pricing from a -// LiteLLM-format model catalog instead of $0. +// runtime and adds them to the picker with the proxy's own per-model pricing +// instead of $0. // @author: Jan -// @version: 0.9.2 +// @version: 0.10.0 // @path: plugin // @mode: append -// @pins: opencode-plugin-litellm-pricing 0.8.1 +// @pins: opencode-plugin-litellm-pricing 0.9.0 -["opencode-plugin-litellm-pricing@0.8.1"] +["opencode-plugin-litellm-pricing@0.9.0"] diff --git a/presets/provider-litellm.conf b/presets/provider-litellm.conf index 9a565a6..cab01d9 100644 --- a/presets/provider-litellm.conf +++ b/presets/provider-litellm.conf @@ -1,21 +1,17 @@ // @name: provider-litellm -// @description: Points opencode's litellm provider at your proxy URL and key, -// plus the model catalog to price against, for use with plugin-litellm-pricing. -// No default catalog: name your own, or LiteLLM's published one at -// https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json -// Declares no models — the plugin fills them in at runtime. +// @description: Points opencode's litellm provider at your proxy URL and key, for +// use with plugin-litellm-pricing, which prices the models against the same +// proxy. Declares no models — the plugin fills them in at runtime. // @author: Jan -// @version: 0.7.0 +// @version: 0.8.0 // @path: provider.litellm // @prompt: baseURL | text | LiteLLM proxy base URL (OpenAI-compatible /v1 endpoint) | http://localhost:4000/v1 // @prompt: apiKey | secret | LiteLLM proxy API key (virtual key or master key) -// @prompt: catalogURL | text | Model catalog URL in LiteLLM model_prices_and_context_window.json format — no default, see the description for the published one { "npm": "@ai-sdk/openai-compatible", "name": "LiteLLM (proxy)", "options": { "baseURL": "{{prompt:baseURL}}", - "apiKey": "{{prompt:apiKey}}", - "catalogURL": "{{prompt:catalogURL}}" + "apiKey": "{{prompt:apiKey}}" } } diff --git a/src/batch.ts b/src/batch.ts index f795fdc..e7e99f9 100644 --- a/src/batch.ts +++ b/src/batch.ts @@ -473,6 +473,7 @@ function renderFooter( summary += c.dim(` (${m.stats.preservedBatch} dedup'd in batch)`); } if (m.stats.overwritten) summary += `, overwritten ${m.stats.overwritten}`; + if (m.stats.superseded) summary += `, superseded ${m.stats.superseded}`; } lines.push(' • ' + c.bold(m.meta.name) + c.meta(' — ') + summary); for (const line of shadowedDenyWarnings(m)) lines.push(line); @@ -483,12 +484,14 @@ function renderFooter( const leavesReplaced = modules.filter(m => m.stats && m.stats.mode === 'replace' && m.stats.replaced).length; const keysAdded = modules.reduce((n, m) => n + (m.stats?.added || 0), 0); const dedups = modules.reduce((n, m) => n + (m.stats?.preservedBatch || 0), 0); + const superseded = modules.reduce((n, m) => n + (m.stats?.superseded || 0), 0); lines.push(''); if (resetStats.length > 0) lines.push(' ' + c.dim('Resets applied: ') + resetsApplied); lines.push(' ' + c.dim('Leaves replaced: ') + leavesReplaced); lines.push(' ' + c.dim('Keys added: ') + keysAdded); if (dedups > 0) lines.push(' ' + c.dim('Duplicates skipped:') + ' ' + dedups); + if (superseded > 0) lines.push(' ' + c.dim('Versions replaced: ') + superseded); if (backupPath) lines.push(' ' + c.dim('Backup: ') + backupPath); return lines.join('\n'); diff --git a/src/merge.ts b/src/merge.ts index ddb65bd..859b3fc 100644 --- a/src/merge.ts +++ b/src/merge.ts @@ -9,6 +9,9 @@ // keys. // 'append' — additive array merge: existing entries are preserved // and missing incoming entries are appended. Idempotent. +// An incoming `name@spec` entry supersedes every existing +// entry naming the same package, rather than stacking a +// second version of it alongside the first. export type MergeMode = 'replace' | 'merge' | 'merge-overwrite' | 'append'; @@ -17,6 +20,7 @@ export interface ApplyStats { added: number; preserved: number; overwritten: number; + superseded: number; replaced: boolean; } @@ -58,8 +62,26 @@ export function applyAtPath( return { next, stats }; } +// The package named by a `name@spec` append entry — `pkg@1.2.3`, +// `@scope/pkg@1.2.3`, `superpowers@git+https://…#v6.3.0`. opencode loads every +// entry in the array, so two specs naming the same package load that plugin +// twice at two versions; append mode uses this to supersede instead of stack. +// Anything that is not a `name@spec` string returns null and keeps the plain +// append behaviour: a `{{cache}}` fetch destination, a prompted directory, or a +// git URL carrying credentials (`git+https://user@host/…`), whose trailing `@` +// would otherwise split in the wrong place. +function specName(value: Json): string | null { + if (typeof value !== 'string') return null; + const at = value.lastIndexOf('@'); + // at === 0 is a bare scope (`@scope/pkg`), which names no version. + if (at <= 0 || at === value.length - 1) return null; + const name = value.slice(0, at); + if (name.includes('/') && !name.startsWith('@')) return null; + return name; +} + function combine(existing: Json, incoming: Json, mode: MergeMode): { value: Json; stats: ApplyStats } { - const stats: ApplyStats = { mode, added: 0, preserved: 0, overwritten: 0, replaced: false }; + const stats: ApplyStats = { mode, added: 0, preserved: 0, overwritten: 0, superseded: 0, replaced: false }; if (mode === 'append') { if (!Array.isArray(incoming)) { @@ -70,11 +92,36 @@ function combine(existing: Json, incoming: Json, mode: MergeMode): { value: Json } const target: Json[] = Array.isArray(existing) ? [...existing] : []; for (const v of incoming) { - if (target.some(existingValue => deepEqual(existingValue, v))) { - stats.preserved++; - } else { + const name = specName(v); + // The first entry naming the same package anchors the position. Look it + // up before the deep-equality check: a config holding both `pkg@0.8.1` + // and `pkg@0.9.0` must still collapse when `pkg@0.9.0` is installed, and + // an equality-first check would call that a preserved no-op and leave the + // stale entry behind. + const at = name === null ? -1 : target.findIndex(existingValue => specName(existingValue) === name); + if (at === -1) { + if (target.some(existingValue => deepEqual(existingValue, v))) { + stats.preserved++; + continue; + } target.push(v); stats.added++; + continue; + } + // Same package. Overwrite in place so the entry keeps its position, and + // drop any further copies of the same package: a config that already + // stacked several bumps collapses to one on the next install. + if (deepEqual(target[at], v)) { + stats.preserved++; + } else { + target[at] = v; + stats.superseded++; + } + for (let i = target.length - 1; i > at; i--) { + if (specName(target[i]) === name) { + target.splice(i, 1); + stats.superseded++; + } } } return { value: target, stats }; diff --git a/test/builtin-presets.test.ts b/test/builtin-presets.test.ts index 4d4fba3..9e59631 100644 --- a/test/builtin-presets.test.ts +++ b/test/builtin-presets.test.ts @@ -50,7 +50,7 @@ test('ships a litellm plugin preset that appends the runtime-discovery plugin', assert.equal(meta.name, 'plugin-litellm-pricing'); assert.equal(meta.path, 'plugin'); assert.equal(meta.mode, 'append'); - assert.deepEqual(body, ['opencode-plugin-litellm-pricing@0.8.1']); + assert.deepEqual(body, ['opencode-plugin-litellm-pricing@0.9.0']); }); test('ships a dcg plugin preset that appends the destructive-command guard plugin', async () => { @@ -152,23 +152,14 @@ test('ships a litellm provider preset that points at a proxy URL, no models', as assert.equal(meta.mode, 'replace'); // The key is prompted for as a secret (hidden input) and written into the - // config, matching how mcp-http handles header credentials. `catalogURL` is - // the model catalog the plugin prices against — context windows, modalities - // and costs — so anyone serving an enriched copy can point at that instead - // of LiteLLM's published file. - // `catalogURL` has no default, and that is asserted rather than assumed: - // plugin 0.6.0 removed DEFAULT_PRICE_TABLE_URL so it would never fetch a - // host its operator had not named, and a default here would have reinstated - // exactly that, one layer down. The cost is that a blank answer aborts the - // install (src/batch.ts) instead of quietly writing someone else's URL into - // your config — which is the intended trade. The published table is in the - // preset's @description, where it can be read and pasted. + // config, matching how mcp-http handles header credentials. Base URL and key + // are the whole configuration: plugin 0.9.0 prices against the same proxy, so + // there is nothing else to name. assert.deepEqual( meta.prompts.map((p) => ({ name: p.name, type: p.type, default: p.default })), [ { name: 'baseURL', type: 'text', default: 'http://localhost:4000/v1' }, { name: 'apiKey', type: 'secret', default: undefined }, - { name: 'catalogURL', type: 'text', default: undefined }, ], ); @@ -180,7 +171,6 @@ test('ships a litellm provider preset that points at a proxy URL, no models', as options: { baseURL: '{{prompt:baseURL}}', apiKey: '{{prompt:apiKey}}', - catalogURL: '{{prompt:catalogURL}}', }, }); }); @@ -244,7 +234,7 @@ test('records the pinned third-party version of every preset that installs one', 'jdtls-lombok': [{ name: 'lombok', version: '1.18.46' }], 'mcp-playwright': [{ name: '@playwright/mcp', version: '0.0.79' }], 'plugin-dcg': [{ name: 'opencode-plugin-dcg', version: '0.2.0' }], - 'plugin-litellm-pricing': [{ name: 'opencode-plugin-litellm-pricing', version: '0.8.1' }], + 'plugin-litellm-pricing': [{ name: 'opencode-plugin-litellm-pricing', version: '0.9.0' }], 'plugin-opencode-planify-german': [{ name: 'opencode-planify-german', version: '0.3.2' }], 'plugin-superpowers': [{ name: 'superpowers', version: '6.3.0' }], }; diff --git a/test/merge.test.ts b/test/merge.test.ts index f763094..40d68c4 100644 --- a/test/merge.test.ts +++ b/test/merge.test.ts @@ -100,6 +100,76 @@ describe('applyAtPath — append mode', () => { assert.equal(stats.preserved, 1); }); + // opencode loads every entry in `plugin`, so leaving an old `pkg@0.8.1` + // behind next to a new `pkg@0.9.0` loads the plugin twice at two versions. + test('supersedes an older version of the same package instead of stacking it', () => { + const root = { plugin: ['opencode-plugin-dcg@0.2.0', 'pricing@0.8.1'] }; + const { next, stats } = applyAtPath(root, 'plugin', ['pricing@0.9.0'], 'append'); + assert.deepEqual((next as any).plugin, ['opencode-plugin-dcg@0.2.0', 'pricing@0.9.0']); + assert.equal(stats.added, 0); + assert.equal(stats.superseded, 1); + }); + + test('collapses a config that already stacked several versions, keeping position', () => { + const root = { plugin: ['pricing@0.7.0', 'dcg@0.2.0', 'pricing@0.8.0', 'pricing@0.8.1'] }; + const { next, stats } = applyAtPath(root, 'plugin', ['pricing@0.9.0'], 'append'); + assert.deepEqual((next as any).plugin, ['pricing@0.9.0', 'dcg@0.2.0']); + assert.equal(stats.superseded, 3); + }); + + test('reinstalling the same version stays a preserved no-op', () => { + const root = { plugin: ['pricing@0.9.0'] }; + const { next, stats } = applyAtPath(root, 'plugin', ['pricing@0.9.0'], 'append'); + assert.deepEqual((next as any).plugin, ['pricing@0.9.0']); + assert.equal(stats.preserved, 1); + assert.equal(stats.superseded, 0); + }); + + // The upgrade path from a config the old code stacked: the newest version is + // already present next to a stale one, so an equality-first check would call + // this a preserved no-op and leave the plugin loading twice. + test('collapses a stale sibling even when the incoming version is already present', () => { + const root = { plugin: ['pricing@0.8.1', 'pricing@0.9.0'] }; + const { next, stats } = applyAtPath(root, 'plugin', ['pricing@0.9.0'], 'append'); + assert.deepEqual((next as any).plugin, ['pricing@0.9.0']); + assert.equal(stats.added, 0); + assert.equal(stats.superseded, 2); // stale entry replaced, exact duplicate dropped + }); + + test('matches scoped packages and git specs on the package name', () => { + const scoped = applyAtPath({ plugin: ['@scope/pkg@1.0.0'] }, 'plugin', ['@scope/pkg@2.0.0'], 'append'); + assert.deepEqual((scoped.next as any).plugin, ['@scope/pkg@2.0.0']); + + const git = applyAtPath( + { plugin: ['superpowers@git+https://github.com/obra/superpowers.git#v6.3.0'] }, + 'plugin', + ['superpowers@git+https://github.com/obra/superpowers.git#v6.4.0'], + 'append' + ); + assert.deepEqual((git.next as any).plugin, ['superpowers@git+https://github.com/obra/superpowers.git#v6.4.0']); + }); + + // Only `name@spec` entries carry a package identity. A fetched skill path or + // a prompted directory must keep the plain additive behaviour, or unrelated + // entries sharing a prefix would silently delete each other. + test('leaves non-package entries to plain append', () => { + const root = { skill: ['{{cache}}/planify-skills-0.3.2'] }; + const { next, stats } = applyAtPath(root, 'skill', ['{{cache}}/planify-skills-0.4.0'], 'append'); + assert.deepEqual((next as any).skill, ['{{cache}}/planify-skills-0.3.2', '{{cache}}/planify-skills-0.4.0']); + assert.equal(stats.added, 1); + assert.equal(stats.superseded, 0); + }); + + // `git+https://user@host/...` has a trailing `@` that does not split a + // package name; splitting there would key on a URL fragment. + test('ignores a git spec whose URL carries credentials', () => { + const root = { plugin: ['pkg@git+https://user@host/a.git#v1'] }; + const { next, stats } = applyAtPath(root, 'plugin', ['pkg@git+https://user@host/a.git#v2'], 'append'); + assert.equal((next as any).plugin.length, 2); + assert.equal(stats.added, 1); + assert.equal(stats.superseded, 0); + }); + test('rejects non-array body', () => { assert.throws(() => applyAtPath({}, 'plugin', { a: 1 }, 'append'), /JSON array/); });