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
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,22 @@ It works with Claude through the Anthropic API, through proxies that speak the A
<img src="docs/images/sidebar.svg" alt="The Cache section of the OpenCode sidebar: status warm, cached 92% · 141k / 153k, expires 14:52 (52m), ttl 1h (detected), next ping 14:42 (42m), pings 2/5 ✓ hit ($0.05), saved $0.42 · 2 rewrites, return 9× ping cost" width="420">
</p>

## Contents

- [Why](#why)
- [The three modes](#the-three-modes)
- [Modes per provider](#modes-per-provider)
- [Supported providers and endpoints](#supported-providers-and-endpoints)
- [Install](#install)
- [Usage](#usage)
- [Per-model override](#per-model-override)
- [Proxies and other provider ids](#proxies-and-other-provider-ids)
- [Keepalive](#keepalive)
- [Settings](#settings)
- [Sidebar](#sidebar)
- [Savings](#savings)
- [Contributing](#contributing)

## Why

OpenCode marks Claude's cache breakpoints on its own (tools, system prompt, the last message) but always sends `ttl: "5m"`, and there is no config option for it. Step away for ten minutes and your next prompt writes the whole context into the cache again at 1.25x the base input price (2x for the 1h cache) instead of reading it at about 0.1x. GPT-5.6+ has the same problem: OpenAI caches for 30 minutes on its own, and the 12-hour cache has to be requested per breakpoint, which OpenCode never does.
Expand Down Expand Up @@ -203,7 +219,7 @@ The effective value of each setting is the first one set, in this order:

1. the session's override (`/cache-manager` typed argument, or the picker's Session section)
2. the global value stored from the picker's Global section
3. the plugin option (`defaultMode` or `keepalive.*`)
3. the plugin option (`defaultMode`, `keepalive.*` or `savings`)
4. the built-in default

A global value stored from the picker overrides the plugin option for the same setting, so changing `defaultMode` in `opencode.jsonc` has no effect once the picker has stored a global value for that setting.
Expand Down Expand Up @@ -234,7 +250,7 @@ Cache
- `cached` describes the latest primary turn: the share of its prompt that was read from cache, then the tokens read / the prompt total, such as `92% · 14.8k / 16.1k`. Pings, titles and other one-off requests don't change it. It shows for every session with cache usage, Claude or not, once a response has arrived.
- `next ping` is when the next ping goes out. It reads `none` once the limit is reached.
- `pings 2/5` counts pings used in this idle stretch, followed by the last result: `✓ hit`, `✗ miss` or `✗ failed`. No result is shown before the first ping.
- `pings` also shows what the pings of the current idle stretch cost, such as `($0.05)`, when the model has a price.
- `pings` also shows what the pings of the current idle stretch cost, such as `($0.05)`, when every ping in the session was priced.
- `saved` and `return` are the session's savings totals; see [Savings](#savings).
- When keepalive isn't running, a `keepalive` row says why: `off · 31k < 50k` (context below the minimum), `off · disabled`, `waiting · session busy` (waiting on a tool call), or `stopped · last ping missed`.

Expand Down Expand Up @@ -269,7 +285,7 @@ Anything else is ping cost only: a session you never came back to, a return befo
- `saved` is the session total. In dollars it is net: avoided cost minus the cost of every ping, wasted ones included. Without a price it is `2 rewrites · 282k`, the rewrites and the tokens they avoided. `saved nothing yet` shows once you've paid for a ping and nothing has been saved.
- `return` is avoided cost divided by ping cost, such as `9× ping cost`. It appears after the first save. The label may change.

Dollars need a price for every model the session used. OpenCode's catalog prices the models of known providers. For a model under a custom provider, add `cost` to the model in `opencode.jsonc` (`input`, `output`, `cache.read`, `cache.write`, in dollars per million tokens); [the OpenAI-compatible example](docs/examples/opencode.openai-compatible.jsonc) shows where. Or set `modelsDevPrices` to `true` to look up models the catalog has no price for in models.dev. That is the only network call the plugin makes, once a day at most (after a failed fetch, the next try waits at least 15 minutes), and a price from there is the list price, which may differ from what a subscription-backed route charges you. A failed lookup, or a model with no clear match, leaves the model unpriced. If OpenCode's model catalog can't be loaded, events are unpriced and models.dev is not consulted.
Dollars need a price for every model the session used. OpenCode's catalog prices the models of known providers. For a model under a custom provider, add `cost` to the model in `opencode.jsonc` (`input`, `output`, `cache.read`, `cache.write`, in dollars per million tokens; `cache.write` prices 5m and 30m writes, while 1h and 12h writes are priced from `input` at 2x and 2.5x); [the OpenAI-compatible example](docs/examples/opencode.openai-compatible.jsonc) shows where. Or set `modelsDevPrices` to `true` to look up models the catalog has no price for in models.dev. That is the only network call the plugin makes, once a day at most (within one run of OpenCode, a failed fetch is retried after 15 minutes at the earliest; that wait isn't kept across restarts), and a price from there is the list price at the model's base tier, with no long-context tier, which may differ from what a subscription-backed route charges you. A failed lookup, or a model with no clear match, leaves the model unpriced. If OpenCode's model catalog can't be loaded, events are unpriced and models.dev is not consulted.

If any event in a session has no price, the whole session shows the unit-free form, since a partial dollar figure would mislead. Each event is priced with the model that served it, so a session that switches models still adds up. Dollar amounts round to the cent, with `<$0.01` for a non-zero amount below a cent and a minus sign (`-$0.02`) for a negative net.

Expand Down
2 changes: 1 addition & 1 deletion docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Where things go:
brew install charmbracelet/tap/freeze # other package managers: see the freeze README
```

After changing the sidebar component or a screen's samples, regenerate both images and commit them:
After changing the sidebar component or a screen's samples, regenerate all three images (`sidebar.svg`, `sidebar-states.svg`, `picker.svg`) and commit them. A change to `src/ui/picker.ts` or `scripts/capture/picker-dialog.tsx` needs it too:

```sh
bun scripts/capture-sidebar.ts --all
Expand Down
2 changes: 1 addition & 1 deletion docs/UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Update the version range in your `plugins` entry to `^0.7.0`: on 0.x, `^0.6.0` does not pick up 0.7.

0.7.0 adds a savings ledger. The sidebar gains `saved` and `return` rows by default, and `pings` shows the cost of the current stretch when the model has a price. Turn them off with the `savings` option, the picker's Savings row, or `/cache-manager savings off`; the ledger keeps counting while hidden. See [Savings](../README.md#savings).
0.7.0 adds a savings ledger. The sidebar gains `saved` and `return` rows by default, and `pings` shows the cost of the current stretch when every ping in the session was priced. Turn them off with the `savings` option, the picker's Savings row, or `/cache-manager savings off`; the ledger keeps counting while hidden. See [Savings](../README.md#savings).

`modelsDevPrices` is opt-in (default `false`). Leave it off and the plugin makes no network calls of its own; sessions on models the OpenCode catalog doesn't price show the unit-free form.

Expand Down
2 changes: 2 additions & 0 deletions docs/examples/opencode.openai-compatible.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@
},
// Optional: a price lets the sidebar show savings in dollars. Per million
// tokens; a custom provider has no price unless you add one here.
// cache.write prices 5m and 30m writes; 1h and 12h writes are priced from
// input (2x and 2.5x).
// "cost": { "input": 3, "output": 15, "cache": { "read": 0.3, "write": 3.75 } },
"variants": [
{
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@noppu-labs/opencode-cache-manager",
"version": "0.7.0",
"version": "0.7.1",
"description": "Prompt-cache manager for OpenCode V2: per-session 5m / 1h / hybrid TTL and keepalive pings for Claude and OpenAI GPT-5.6+ on the Anthropic Messages, Chat Completions and Responses APIs (OpenRouter, proxies), plus a sidebar cache status",
"keywords": [
"anthropic",
Expand Down
2 changes: 1 addition & 1 deletion scripts/capture/ansi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ function boldRuns(rows: readonly string[]): string[] {
function restoreBold(svg: string, rows: readonly string[]): string {
let out = svg;
for (const text of new Set(boldRuns(rows))) {
out = out.replaceAll(`>${text}</tspan>`, ` font-weight="bold">${text}</tspan>`);
out = out.replaceAll(`>${text}</tspan>`, () => ` font-weight="bold">${text}</tspan>`);
}
return out;
}
Expand Down
13 changes: 12 additions & 1 deletion scripts/capture/screens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,18 @@ const STATES: readonly Block[] = [
},
{
caption: 'Savings turned off',
snapshot: snapshot({ showSavings: false }),
snapshot: snapshot({
savings: {
avoidedTokens: 120_000,
netUsd: 0.27,
pings: 2,
priced: true,
rescues: 1,
returnRatio: 4.6,
stretchUsd: 0.05,
},
showSavings: false,
}),
},
];

Expand Down
9 changes: 9 additions & 0 deletions src/constants/savings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ const MODELS_DEV_STORAGE_KEY = 'models-dev';
const MODELS_DEV_FETCH_TIMEOUT_MS = 10_000;
/** A `ctx.model.list()` call that has not settled by then counts as a failed catalog load. */
const CATALOG_LOAD_TIMEOUT_MS = 10_000;
/** After a failed catalog load, wait this long before another reload for a missing model. */
const CATALOG_RETRY_MS = 60_000;
/** A trailing `-YYYYMMDD` release date on a model name. */
const DATE_SUFFIX = /-\d{8}$/u;
/** A trailing `-latest` alias on a model name. */
const LATEST_SUFFIX = /-latest$/u;
/** Vendors whose own listing wins over resellers. */
const MODELS_DEV_VENDORS: readonly string[] = ['anthropic', 'openai'];

Expand All @@ -57,4 +63,7 @@ export {
MODELS_DEV_FETCH_TIMEOUT_MS,
MODELS_DEV_VENDORS,
CATALOG_LOAD_TIMEOUT_MS,
CATALOG_RETRY_MS,
DATE_SUFFIX,
LATEST_SUFFIX,
};
1 change: 0 additions & 1 deletion src/models/keepalive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ type KeepaliveSnapshot = {
contextTokens: number | null;
/** Cache reads and total prompt tokens of the latest non-ping response; null before one arrives. */
lastCache: CacheReadout | null;
/** The `savings` setting. */
showSavings: boolean;
/** The session's ledger readout; null with savings off, for unmanaged or child sessions and before the ledger loads. */
savings: SavingsReadout | null;
Expand Down
2 changes: 1 addition & 1 deletion src/models/savings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ interface SavingsReadout {
avoidedTokens: number;
/** `avoidedUsd - pingUsd` when priced. */
netUsd: number | null;
/** Null until rescues > 0; dollars when priced, units otherwise. */
/** Avoided / spent, in dollars when priced and units otherwise; null while rescues is 0 or the spend is 0. */
returnRatio: number | null;
/** Ping cost in the current stretch; null when unpriced or none. */
stretchUsd: number | null;
Expand Down
27 changes: 17 additions & 10 deletions src/services/keepalive-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,6 @@ interface SessionState {
pingTtl: Ttl | null;
/** Bumped by every real request; a ping charged under another value was answered after its stretch ended. */
stretch: number;
/** The `stretch` the latest ping was sent in. */
pingStretch: number;
/** The latest primary or compaction request ran in `hybrid` mode. */
split: boolean;
Expand Down Expand Up @@ -155,12 +154,10 @@ class KeepaliveEngine {
state.requestSentAt = state.lastRequestAt;
const kind = info.kind ?? 'primary';
const sessionTurn = kind === 'primary' || kind === 'compaction';
// Any earlier pending return is overtaken by this request.
state.pendingReturn = sessionTurn
? this.returnCheck(state, state.lastRequestAt, info.split === true)
: null;
this.startStretch(sessionID, state);
if (sessionTurn) {
// Only a session turn overtakes an earlier pending return and ends the stretch; title and generate calls do neither.
state.pendingReturn = this.returnCheck(state, state.lastRequestAt, info.split === true);
this.startStretch(sessionID, state);
state.split = info.split === true;
if (info.model) state.model = info.model;
state.requestedTtl = info.requestedTtl;
Expand Down Expand Up @@ -309,19 +306,26 @@ class KeepaliveEngine {
}

private learnPrefix(state: SessionState, usage: CacheUsage, kind: RequestKind): void {
if (state.requestedTtl !== null && OPENAI_TTLS.has(state.requestedTtl)) {
if (this.estimatesPrefix(state, usage)) {
const context = contextTokens(usage);
// A zero context says nothing about the prefix; subtracting it would count the whole read.
if (kind === 'primary' && state.firstPrimaryContext === null && context > 0) {
state.firstPrimaryContext = context;
}
} else {
} else if (state.split) {
// A later turn may rewrite only the tail of the prefix; the largest write errs toward fewer avoided tokens.
// A 1h-mode full write is no prefix, so only hybrid turns count.
const write1h = usage.write1hTokens ?? 0;
if (write1h > 0) state.prefix1hTokens = Math.max(state.prefix1hTokens ?? 0, write1h);
}
}

/** OpenAI TTLs and wires that report no 1h/5m split give no measured prefix, so the first primary context stands in. */
private estimatesPrefix(state: SessionState, usage: CacheUsage): boolean {
const openai = state.requestedTtl !== null && OPENAI_TTLS.has(state.requestedTtl);
return openai || usage.write1hTokens === undefined;
}

/**
* A real primary or compaction request may rescue its stretch when the stretch had a hit
* ping and the request goes out past the margin after the cache would have expired without pings.
Expand All @@ -338,7 +342,10 @@ class KeepaliveEngine {
const openai = state.requestedTtl !== null && OPENAI_TTLS.has(state.requestedTtl);
return {
contextBefore: state.contextTokens,
prefix: openai ? state.firstPrimaryContext : state.prefix1hTokens,
// Claude prefers the measured 1h write and falls back to the estimate when its wire reports no split.
prefix: openai
? state.firstPrimaryContext
: (state.prefix1hTokens ?? state.firstPrimaryContext),
// Either side being split means the long-lived prefix may have stayed warm on its own.
split: state.split || split,
ttl: state.ttl,
Expand Down Expand Up @@ -399,7 +406,7 @@ class KeepaliveEngine {
try {
void Promise.resolve(call(ledger)).catch(() => {});
} catch {
// A failing ledger must not break scheduling.
// Ledger failures never reach scheduling.
}
}

Expand Down
Loading
Loading