From 20f670c1580b27f50f13169493736c7c4a202454 Mon Sep 17 00:00:00 2001 From: Elijah Shaw-Rutschman Date: Wed, 16 Sep 2026 10:48:24 -0500 Subject: [PATCH] feat: add /output-limit for a per-model max output budget Generalize the rule engine so a budget can target either `limit.context` or `limit.output`. `/output-limit` shares `/context-limit`'s parsing and pattern matching (tokens, `16K`, `1M`, `50%`, `0` clears, and `*` / `provider/*` / exact patterns) and clamps to the field's catalog value so it only lowers, never raises. Rules for each field are stored separately under `context-limit` and `output-limit`, and one catalog transform applies both. --- AGENTS.md | 16 +-- README.md | 31 +++--- context-limit.test.ts | 54 +++++++++- context-limit.ts | 227 +++++++++++++++++++++++++----------------- package.json | 4 +- 5 files changed, 218 insertions(+), 114 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f33bdfb..2cda37a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,8 +5,8 @@ Guidance for agents working in this repository. ## What this is An OpenCode V2 plugin (`context-limit.ts`) that sets a per-model working context -budget by lowering the model's `limit.context` through a catalog transform. No -build step, no dependencies, MIT. +budget and a max output-token budget by lowering the model's `limit.context` / +`limit.output` through a catalog transform. No build step, no dependencies, MIT. ## Local development @@ -40,18 +40,20 @@ config edit is needed. ## API notes -- `ctx.catalog.transform((catalog) => catalog.model.update(providerID, modelID, (model) => { model.limit = { ...model.limit, context: n } }))` +- `ctx.catalog.transform((catalog) => catalog.model.update(providerID, modelID, (model) => { model.limit = { ...model.limit, [field]: n } }))` lowers a window. Call `ctx.catalog.reload()` after changing the rules. - Model entries from `ctx.catalog.model.list()` carry `providerID`, `id`, and - `limit.context`. -- Rules live in `ctx.storage` under `context-limit`. + `limit.context` / `limit.output`. +- Rules live in `ctx.storage` under `context-limit` and `output-limit`. ## Layout - `parseBudget` - parses tokens, `128K`, `1M`, and `50%`, with clamping. - `matchPattern`, `longestMatch`, `resolveBudget` - rule matching. -- `applyBudget` - the catalog transform body, exported for tests. -- `setup` - registers the command and the transform. +- `applyBudget` - the catalog transform body (`kind` selects `context` vs + `output`), exported for tests. +- `makeCommand` - builds `/context-limit` and `/output-limit` from one template. +- `setup` - registers both commands and the transform. - `context-limit.test.ts` - tests with a fake catalog and ctx. ## Releasing diff --git a/README.md b/README.md index 5881c0f..4822421 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ # opencode-context-limit -An OpenCode V2 plugin that sets a working context budget per model. Lower it to -make compaction fire earlier than the catalog window, for cost tiers or when a -provider serves less than the catalog claims. The budget never raises the -window. +An OpenCode V2 plugin that sets a working context budget and a max output-token +budget per model. Lower the context budget to make compaction fire earlier than +the catalog window, for cost tiers or when a provider serves less than the +catalog claims. Lower the output budget to cap a single reply. A budget never +raises the value. ## OpenCode @@ -23,20 +24,25 @@ curl -fsSL \ For one project, put it in `.opencode/plugins/`. Tested against OpenCode v2.0.3. -To pin a release, replace `main` in the URL with a tag such as `v0.1.0`. +To pin a release, replace `main` in the URL with a tag such as `v0.2.0`. ## Use | Command | Effect | | ---------------------------------- | ---------------------------------------- | -| `/context-limit` | Show the budget for the current model | +| `/context-limit` | Show the context budget for the current model | | `/context-limit 128K` | Set it for the current model | | `/context-limit 50%` | Set half the catalog window | -| `/context-limit opencode-go/* 128K`| Set a pattern | -| `/context-limit 0` | Clear a target | +| `/context-limit opencode-go/* 128K`| Set a context pattern | +| `/context-limit 0` | Clear a context target | +| `/output-limit` | Show the output budget for the current model | +| `/output-limit 16K` | Set it for the current model | +| `/output-limit * 16K` | Cap output for every model | +| `/output-limit 0` | Clear an output target | Values accept plain tokens (`128000`), `128K`, `1M`, and `50%`. Every value is -clamped to the catalog window, so it can only lower the budget, never raise it. +clamped to the catalog value for that field, so it can only lower the budget, +never raise it. Patterns match `provider/model`. `opencode-go/*` matches one provider, `*` matches everything. The longest matching pattern wins. @@ -44,9 +50,10 @@ matches everything. The longest matching pattern wins. ## How it works The plugin registers a catalog transform that lowers the matched models' -`limit.context`. Compaction's default threshold follows the model's usable input -budget, so compaction fires earlier. A change calls `ctx.catalog.reload()` and -applies at once. Nothing is written to `opencode.json`. +`limit.context` and/or `limit.output`. Compaction's default threshold follows +the model's usable input budget, so a lower window makes compaction fire +earlier. A change calls `ctx.catalog.reload()` and applies at once. Nothing is +written to `opencode.json`. ## Tests diff --git a/context-limit.test.ts b/context-limit.test.ts index 16b6868..cae7cf0 100644 --- a/context-limit.test.ts +++ b/context-limit.test.ts @@ -145,6 +145,27 @@ describe("applyBudget", () => { }) }) +describe("applyBudget output", () => { + test("lowers only the output limit for matched models", () => { + const catalog = makeCatalog([ + { providerID: "opencode-go", id: "x", context: 1_000_000 }, + { providerID: "deepseek", id: "y", context: 1_000_000 }, + ]) + applyBudget(catalog, [{ pattern: "opencode-go/*", value: 512, unit: "tokens" }], "output") + const byKey = Object.fromEntries( + catalog.entries.map((entry: any) => [`${entry.providerID}/${entry.id}`, entry.limit.output]), + ) + expect(byKey["opencode-go/x"]).toBe(512) + expect(byKey["deepseek/y"]).toBe(1000) + }) + + test("clamps an output rule to the catalog output ceiling", () => { + const catalog = makeCatalog([{ providerID: "a", id: "b", context: 1_000_000 }]) + applyBudget(catalog, [{ pattern: "*", value: 999_999, unit: "tokens" }], "output") + expect(catalog.entries[0].limit.output).toBe(1000) + }) +}) + describe("command", () => { const run = (commands: any[], text: string) => commands[0].execute({ sessionID: "ses_1", prompt: { text } }) @@ -200,12 +221,43 @@ describe("command", () => { }) }) +describe("output-limit command", () => { + const run = (commands: any[], text: string) => { + const command = commands.find((entry: any) => entry.name === "output-limit") + return command.execute({ sessionID: "ses_1", prompt: { text } }) + } + + test("sets an output budget for the current model", async () => { + const { ctx, store, commands, reloads } = makeCtx() + await (plugin as any).setup(ctx) + await run(commands, "16K") + expect(store.get("output-limit")).toEqual([ + { pattern: "opencode-go/deepseek-v4.1-flash", value: 16_000, unit: "tokens" }, + ]) + expect(reloads()).toBe(1) + }) + + test("keeps output rules separate from context rules", async () => { + const { ctx, store, commands } = makeCtx() + await (plugin as any).setup(ctx) + await run(commands, "16K") + expect(store.get("context-limit")).toBeUndefined() + }) + + test("shows the current model and effective output", async () => { + const { ctx, commands } = makeCtx() + await (plugin as any).setup(ctx) + await run(commands, "512") + await expect(run(commands, "")).rejects.toThrow(/Effective output: 512/) + }) +}) + describe("setup", () => { test("registers the command and a transform that uses stored rules", async () => { const { ctx, store, commands, transforms } = makeCtx() await store.set("context-limit", [{ pattern: "opencode-go/*", value: 128_000, unit: "tokens" }]) await (plugin as any).setup(ctx) - expect(commands.map((entry) => entry.name)).toEqual(["context-limit"]) + expect(commands.map((entry) => entry.name)).toEqual(["context-limit", "output-limit"]) expect(transforms).toHaveLength(1) const catalog = makeCatalog([{ providerID: "opencode-go", id: "x", context: 1_000_000 }]) diff --git a/context-limit.ts b/context-limit.ts index 982dce5..5fb6b63 100644 --- a/context-limit.ts +++ b/context-limit.ts @@ -1,16 +1,18 @@ -// OpenCode V2 context-limit plugin. +// OpenCode V2 context-limit / output-limit plugin. // -// Sets a working context budget per model by lowering the model's -// `limit.context` through a catalog transform. Compaction's default threshold -// follows the model's usable input budget, so a lower window makes compaction -// fire earlier. A budget can only lower a window, never raise it. +// Sets a working context budget AND a max output-token budget per model by +// lowering the model's `limit.context` / `limit.output` through a catalog +// transform. Compaction's default threshold follows the model's usable input +// budget, so a lower context window makes compaction fire earlier. A budget +// only ever lowers a value, never raises it. // // The runtime does not resolve @opencode/plugin, so this file exports a plain // { id, setup } object. -const VERSION = "0.1.2" +const VERSION = "0.2.0" type Unit = "tokens" | "percent" +type LimitKind = "context" | "output" interface Rule { pattern: string @@ -50,46 +52,49 @@ function longestMatch(rules: Rule[], key: string): Rule | undefined { return best } -function budgetFor(rule: Rule, catalogContext: number): number { - const raw = rule.unit === "percent" ? Math.floor((catalogContext * rule.value) / 100) : rule.value - return Math.max(1, Math.min(raw, catalogContext)) +function budgetFor(rule: Rule, catalogCeiling: number): number { + const raw = rule.unit === "percent" ? Math.floor((catalogCeiling * rule.value) / 100) : rule.value + return Math.max(1, Math.min(raw, catalogCeiling)) } -function resolveBudget(rules: Rule[], key: string, catalogContext: number): number | undefined { +function resolveBudget(rules: Rule[], key: string, catalogCeiling: number): number | undefined { const rule = longestMatch(rules, key) - return rule ? budgetFor(rule, catalogContext) : undefined + return rule ? budgetFor(rule, catalogCeiling) : undefined } // The catalog transform body. `catalog` is a catalog editor: it exposes -// provider.list() and model.update(providerID, modelID, change). -function applyBudget(catalog: any, rules: Rule[]): void { +// provider.list() and model.update(providerID, modelID, change). `kind` picks +// which `limit` field is lowered (context vs output). +function applyBudget(catalog: any, rules: Rule[], kind: LimitKind = "context"): void { if (rules.length === 0) return for (const record of catalog.provider.list()) { for (const model of record.models.values()) { + const ceiling = model.limit?.[kind] + if (typeof ceiling !== "number") continue const key = `${model.providerID}/${model.id}` - const budget = resolveBudget(rules, key, model.limit.context) + const budget = resolveBudget(rules, key, ceiling) if (budget === undefined) continue catalog.model.update(model.providerID, model.id, (entry: any) => { - entry.limit = { ...entry.limit, context: budget } + entry.limit = { ...entry.limit, [kind]: budget } }) } } } -async function loadRules(ctx: any): Promise { - const stored = await ctx.storage.get("context-limit") +async function loadRules(ctx: any, storageKey: string): Promise { + const stored = await ctx.storage.get(storageKey) return Array.isArray(stored) ? (stored as Rule[]) : [] } -async function saveRules(ctx: any, rules: Rule[]): Promise { - await ctx.storage.set("context-limit", rules) +async function saveRules(ctx: any, storageKey: string, rules: Rule[]): Promise { + await ctx.storage.set(storageKey, rules) } -async function modelContext(ctx: any, key: string): Promise { +async function modelLimit(ctx: any, key: string, kind: LimitKind): Promise { const result = await ctx.catalog.model.list() const data: any[] = Array.isArray(result) ? result : (result?.data ?? []) const found = data.find((model) => `${model.providerID}/${model.id}` === key) - return found?.limit?.context + return found?.limit?.[kind] } function describeRules(rules: Rule[]): string { @@ -99,85 +104,123 @@ function describeRules(rules: Rule[]): string { .join("\n") } +interface CommandOptions { + kind: LimitKind + name: string + storageKey: string + fieldLabel: "window" | "output" + description: string +} + +// Builds a `/context-limit` or `/output-limit` command. `state` is shared with +// the catalog transform so rule edits apply on the next `catalog.reload()`. +function makeCommand(ctx: any, state: { context: Rule[]; output: Rule[] }, opts: CommandOptions) { + const { kind, name, storageKey, fieldLabel, description } = opts + return { + name, + description, + execute: async ({ sessionID, prompt }: any) => { + const text = typeof prompt?.text === "string" ? prompt.text.trim() : "" + + if (!text) { + const info: any = await ctx.session.get({ sessionID }).catch(() => undefined) + const model = info?.model ?? info?.data?.model + const key = model ? `${model.providerID}/${model.id}` : undefined + const current = key ? await modelLimit(ctx, key, kind) : undefined + const rule = key ? longestMatch(state[kind], key) : undefined + const budget = rule + ? rule.unit === "percent" + ? `${rule.value}%` + : String(Math.min(rule.value, current ?? rule.value)) + : "none" + throw new Error( + [ + key ? `Model: ${key}` : "Model: unknown", + `${fieldLabel === "window" ? "Effective window" : "Effective output"}: ${current ?? "unknown"}`, + `Budget: ${budget}`, + "", + describeRules(state[kind]), + `${name} ${VERSION}`, + ].join("\n"), + ) + } + + const parts = text.split(/\s+/) + let pattern: string + let valueText: string + if (parts.length === 1) { + const info: any = await ctx.session.get({ sessionID }).catch(() => undefined) + const model = info?.model ?? info?.data?.model + if (!model) throw new Error("could not resolve the current model") + pattern = `${model.providerID}/${model.id}` + valueText = parts[0] + } else { + const target = parts[0] + valueText = parts.slice(1).join(" ") + if (target === "current") { + const info: any = await ctx.session.get({ sessionID }).catch(() => undefined) + const model = info?.model ?? info?.data?.model + if (!model) throw new Error("could not resolve the current model") + pattern = `${model.providerID}/${model.id}` + } else { + pattern = target + } + } + + const parsed = parseBudget(valueText) + const clears = valueText === "0" || (parsed?.unit === "tokens" && parsed.value === 0) + if (clears) { + state[kind] = state[kind].filter((rule) => rule.pattern !== pattern) + await saveRules(ctx, storageKey, state[kind]) + await ctx.catalog.reload() + return + } + if (!parsed || (parsed.unit === "tokens" && parsed.value <= 0)) { + throw new Error(`bad value "${valueText}"; use 128000, 128K, 1M, or 50% (0 clears)`) + } + state[kind] = state[kind].filter((rule) => rule.pattern !== pattern) + state[kind].push({ pattern, value: parsed.value, unit: parsed.unit }) + await saveRules(ctx, storageKey, state[kind]) + await ctx.catalog.reload() + }, + } +} + const plugin = { id: "context-limit", async setup(ctx: any) { - let rules = await loadRules(ctx) + const state = { + context: await loadRules(ctx, "context-limit"), + output: await loadRules(ctx, "output-limit"), + } - await ctx.catalog.transform((catalog: any) => applyBudget(catalog, rules)) + await ctx.catalog.transform((catalog: any) => { + applyBudget(catalog, state.context, "context") + applyBudget(catalog, state.output, "output") + }) await ctx.command.transform((editor: any) => { - editor.add({ - name: "context-limit", - description: "Show or set a working context budget per model", - execute: async ({ sessionID, prompt }: any) => { - const text = typeof prompt?.text === "string" ? prompt.text.trim() : "" - - if (!text) { - const info: any = await ctx.session.get({ sessionID }).catch(() => undefined) - const model = info?.model ?? info?.data?.model - const key = model ? `${model.providerID}/${model.id}` : undefined - const context = key ? await modelContext(ctx, key) : undefined - const rule = key ? longestMatch(rules, key) : undefined - const budget = rule - ? rule.unit === "percent" - ? `${rule.value}%` - : String(Math.min(rule.value, context ?? rule.value)) - : "none" - throw new Error( - [ - key ? `Model: ${key}` : "Model: unknown", - `Effective window: ${context ?? "unknown"}`, - `Budget: ${budget}`, - "", - describeRules(rules), - `context-limit ${VERSION}`, - ].join("\n"), - ) - } - - const parts = text.split(/\s+/) - let pattern: string - let valueText: string - if (parts.length === 1) { - const info: any = await ctx.session.get({ sessionID }).catch(() => undefined) - const model = info?.model ?? info?.data?.model - if (!model) throw new Error("could not resolve the current model") - pattern = `${model.providerID}/${model.id}` - valueText = parts[0] - } else { - const target = parts[0] - valueText = parts.slice(1).join(" ") - if (target === "current") { - const info: any = await ctx.session.get({ sessionID }).catch(() => undefined) - const model = info?.model ?? info?.data?.model - if (!model) throw new Error("could not resolve the current model") - pattern = `${model.providerID}/${model.id}` - } else { - pattern = target - } - } - - const parsed = parseBudget(valueText) - const clears = valueText === "0" || (parsed?.unit === "tokens" && parsed.value === 0) - if (clears) { - rules = rules.filter((rule) => rule.pattern !== pattern) - await saveRules(ctx, rules) - await ctx.catalog.reload() - return - } - if (!parsed || (parsed.unit === "tokens" && parsed.value <= 0)) { - throw new Error(`bad value "${valueText}"; use 128000, 128K, 1M, or 50% (0 clears)`) - } - rules = rules.filter((rule) => rule.pattern !== pattern) - rules.push({ pattern, value: parsed.value, unit: parsed.unit }) - await saveRules(ctx, rules) - await ctx.catalog.reload() - }, - }) + editor.add( + makeCommand(ctx, state, { + kind: "context", + name: "context-limit", + storageKey: "context-limit", + fieldLabel: "window", + description: "Show or set a working context budget per model", + }), + ) + editor.add( + makeCommand(ctx, state, { + kind: "output", + name: "output-limit", + storageKey: "output-limit", + fieldLabel: "output", + description: "Show or set a max output token budget per model", + }), + ) }) }, } export { applyBudget, budgetFor, longestMatch, matchPattern, parseBudget, resolveBudget, VERSION } -export default plugin +export default plugin \ No newline at end of file diff --git a/package.json b/package.json index 1ce1689..af50455 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "opencode-context-limit", - "version": "0.1.2", - "description": "OpenCode V2 plugin that sets a per-model working context budget", + "version": "0.2.0", + "description": "OpenCode V2 plugin that sets a per-model working context and max output token budget", "type": "module", "license": "MIT", "scripts": {