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
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>}}`.

## 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
Expand Down
50 changes: 17 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<alias>-<header>` 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 |
Expand Down Expand Up @@ -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`

Expand Down Expand Up @@ -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.
Expand Down
14 changes: 7 additions & 7 deletions presets/plugin-litellm-pricing.conf
Original file line number Diff line number Diff line change
@@ -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 <jan@trick77.com>
// @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"]
14 changes: 5 additions & 9 deletions presets/provider-litellm.conf
Original file line number Diff line number Diff line change
@@ -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 <jan@trick77.com>
// @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}}"
}
}
3 changes: 3 additions & 0 deletions src/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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');
Expand Down
55 changes: 51 additions & 4 deletions src/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -17,6 +20,7 @@ export interface ApplyStats {
added: number;
preserved: number;
overwritten: number;
superseded: number;
replaced: boolean;
}

Expand Down Expand Up @@ -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)) {
Expand All @@ -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 };
Expand Down
20 changes: 5 additions & 15 deletions test/builtin-presets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 },
],
);

Expand All @@ -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}}',
},
});
});
Expand Down Expand Up @@ -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' }],
};
Expand Down
Loading