Skip to content
Open
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
16 changes: 9 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
31 changes: 19 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -23,30 +24,36 @@ 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 <target> 0` | Clear a target |
| `/context-limit opencode-go/* 128K`| Set a context pattern |
| `/context-limit <target> 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 <target> 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.

## 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

Expand Down
54 changes: 53 additions & 1 deletion context-limit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } })

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