diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index be4bd7e..0ed6d3c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -9,7 +9,7 @@ The plugin does not run a second agent runtime. OpenCode owns the sessions, mode ## Compatibility enforcement -Both entrypoints are defined with `Plugin.define(...)` and start by checking `context.app.version` against the tested range `>=2.0.26 <2.1.0` (validated against OpenCode 2.0.26, 2.0.27, and 2.0.30; the same range declared in `package.json` `engines.opencode`). OpenCode releases before 2.0.26 activate TUI plugins outside the Solid keymap provider owner, so `context.keymap.layer()` fails with `Keymap.Provider is missing` there even though the plugin follows the documented API. On an unsupported host the plugin refuses to initialize with an explicit, actionable version error instead of reaching that confusing runtime failure. +Both entrypoints are defined with `Plugin.define(...)` and start by checking `context.app.version` against the tested range `>=2.0.26 <2.2.0` (validated against OpenCode 2.0.26, 2.0.27, 2.0.30, and 2.1.3; the same range declared in `package.json` `engines.opencode`). OpenCode 2.1's session/compaction schema change (`providerState` → `native`) does not affect the hooks used here; its internal retry of buffered model calls only changes how quickly a failed probe settles. OpenCode releases before 2.0.26 activate TUI plugins outside the Solid keymap provider owner, so `context.keymap.layer()` fails with `Keymap.Provider is missing` there even though the plugin follows the documented API. On an unsupported host the plugin refuses to initialize with an explicit, actionable version error instead of reaching that confusing runtime failure. ```mermaid flowchart LR @@ -62,6 +62,16 @@ The server plugin registers two lifecycle points: A queued `auto` task transitions to `WAITING_OFFPEAK` and waits at whichever point it reaches first (normally `model.request`); its existing override still applies. In `auto` mode, a normal interactive request for a supported model is deferred the same way. It creates no task and no session, is tracked only in server memory (shown by `/offpeak status`), and is released exactly once when the plugin is enabled and the policy allows. Wake-ups come from the shared config-change signal plus one timer scheduled for the policy's `nextTransition`; there is no polling loop. If the plugin shuts down while a request is held, the request fails closed as blocked rather than being sent under an uncertain pricing state. Requests in `queue`/`guard` mode, and requests that arrive while the plugin is OFF, are blocked immediately by the transport guard; OFF does not release a request that is already held. +### Read-only pricing query + +`pricing` is the second RPC method and the single canonical classification +surface for other plugins. It takes `{ providerID, modelID }`, calls the same +`evaluatePricing` used by every guard with the same stored policies and clock, +and returns `{ state, allowed, policyID?, nextTransition?, reason? }`. It has no +queue, hold, timer, or persistence side effect. `opencode-free-first` consumes it +for `peak-only`; this plugin remains the only owner of the weekly windows and the +Chinese holiday calendar. + ### Cancelling a held request A caller that owns a routing decision can cancel the request this plugin is holding for a session through the `cancelHold` RPC method. `state` on the held request is the single authoritative transition, and it is mutated synchronously: diff --git a/CHANGELOG.md b/CHANGELOG.md index 10581e7..9251f93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## 0.2.0 - 2026-10-23 + +### Added + +- **Read-only `pricing` RPC.** `{ providerID, modelID }` returns + `{ state, allowed, policyID?, nextTransition?, reason? }` from the same + `evaluatePricing` used by the guards, with no queue, hold, timer, provider + request, or persisted-state side effect. It is the canonical PEAK/OFF_PEAK + source for `opencode-free-first` `freeUsageMode: "peak-only"`, which needs no + copy of the weekly windows or Chinese holiday calendar. + +### Compatibility + +- **OpenCode `>=2.0.26 <2.2.0`**, validated against the current production + **2.1.3** binary. Root cause of the live load failure was the package's + deliberately narrow `<2.1.0` version guard, not a removed hook: OpenCode 2.1's + breaking session-schema change (`providerState` → `native`) is not used by + this plugin, and `model.request`, the HTTP/WebSocket guards, `retry`, session + events, RPC, and both entrypoints behave as before. The real TUI smoke test now + loads the packed plugin and runs `/offpeak status` on 2.1.3; the full + deterministic suite passes. + +### Tests + +- 134 deterministic tests, including a real OpenCode 2.1.3 TUI load and + `/offpeak status`, the packed-install manifest contract, and the new pricing + RPC (canonical PEAK answer, policy id, next transition, input validation). + ## 0.1.10 - 2026-10-16 - **Session-scoped off-peak protection (`/offpeak session on|off`).** One interactive session can now deliberately run through a peak window while every other session stays protected. `/offpeak session off` disables off-peak protection for the current session only: its verified peak-priced requests (primary, compaction, title, and generate alike) run immediately, and a request it already has waiting for off-peak is released on the spot through the existing engine signal — no `continue` prompt, no re-sent message, no duplicate provider call, no polling loop. `/offpeak session on` restores normal waiting. The default is ON for every session, and only explicit overrides are stored. diff --git a/DECISIONS.md b/DECISIONS.md index 8211456..3089094 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -107,3 +107,28 @@ Sometimes one interactive session must deliberately run through a peak window wh The override is deliberately narrow. It authorizes only requests that evaluate `PEAK` under a verified policy, and only for that one interactive session: `UNKNOWN` and invalid configuration still fail closed, `OFF_PEAK`/`FLAT`/`UNTRACKED` behavior is unchanged, queue-owned sessions never inherit it (a task running in the same OpenCode session still obeys its own task semantics and `/offpeak run-now`), and no other session changes. It covers the session's native lifecycle work (primary, compaction, title, generate) because the user authorized the session, not a request kind. It is checked after every fail-closed guard, so it cannot turn an unverified model into an allowed request. Two collision points were defined explicitly rather than left implicit. First, a hold that is already waiting must wake immediately: the hold loop re-reads the override after every signal wake-up, and `setSessionProtection` notifies the existing signal and pump, so the same parked request commits with no re-send, no retry, no duplicate provider call, and no new polling, watchdog, or timer; persisted recovery entries for that session are released on the same pass in native order. A refused hold (v0.1.7/v0.1.8 `cancelHold`) is handled before the override check and is never resurrected, and a committed dispatch is never recalled. Second, global precedence: `/offpeak off` remains passive automation (it pauses queue starts and auto-resume, releases nothing, and blocks every session and task), but the explicit per-session grant is newer and more specific, so it applies in `auto`/`queue`/`guard` modes and even while the plugin is globally OFF — for that one session only. This is a deliberate, documented choice in the same category as an explicit `run-now` authorization, not a silent redefinition of `/offpeak off`. Evidence: 19 new deterministic tests (134 total) for the full matrix, and real OpenCode 2.0.30 TUI fixture runs where a held request releases on the spot with exactly one provider call before the boundary, a second session keeps waiting through the same peak window, the override survives a reload and a full server restart, and compaction runs immediately under the override with no pending marker while compact-first behavior with protection on is unchanged. + +## OpenCode 2.1 compatibility and the canonical pricing RPC (v0.2.0) + +The live host was auto-upgraded from OpenCode 2.0.30 to 2.1.3 and this package +refused to load because the declared range was `>=2.0.26 <2.1.0`. The failure was +the guard itself, not a missing runtime contract: the 2.1.0 breaking change +renamed the session/step payload's `providerState` to `native`, and this plugin +does not read that field. It relies on `model.request`, `http.request` / +`http.response`, the experimental WebSocket hooks, `retry`, `session.execution.*` +and `session.model.selected` events, model transforms, and RPC — all unchanged in +shape and order. The range is therefore extended to `>=2.0.26 <2.2.0` only after +the real TUI and the full free-first coexistence suites pass on 2.1.3. A new +minor line is not claimed on version-number compatibility alone: the guard moves +with tested evidence. + +The same release adds a read-only `pricing` RPC that delegates directly to +`evaluatePricing`. `opencode-free-first` needs to know whether DeepSeek Go is in +PEAK before deciding whether Free may be spent. The alternatives were to import +this plugin (couples lifecycles and packaging), copy the weekly windows and +holiday calendar (two authorities that drift), or scrape status text (unstable). +A small RPC is the same pattern as the existing `cancelHold` capability: a +generic, side-effect-free interface on the existing OpenCode plugin RPC channel, +with no new service, queue, timer, or state. The method returns exactly the +canonical answer, including `nextTransition`, so the consumer can cache until the +boundary rather than poll. diff --git a/README.md b/README.md index 8298fe5..01983bc 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,15 @@ Queue OpenCode agent tasks, block paid model requests during expensive pricing w `opencode-offpeak` is an OpenCode CLI v2 plugin. Its default policy covers DeepSeek models through OpenCode Go. Pricing is enforced at two lifecycle points: a pre-model deferral and the outbound transport guard. In `auto` mode, a supported request that reaches peak pricing waits at the pre-model hook — before OpenCode creates the provider attempt and its response-header timer — and the same session continues the same turn automatically when off-peak pricing returns, with zero provider traffic while pricing is blocked. The transport guard remains the final hard barrier immediately before OpenCode sends an HTTP request or an experimental WebSocket frame. If a request is already in flight and fails with a retryable provider/network error after peak pricing begins, the retry is stopped before it can reach the provider and the same session continues automatically at the next off-peak window. When one session must deliberately run through a peak window, `/offpeak session off` authorizes verified peak-priced inference for that session alone; every other session stays protected. -> **Compatibility:** OpenCode CLI `>=2.0.26 <2.1.0`. This release targets the v2 plugin API shipped with OpenCode 2.0.26 and is validated against OpenCode 2.0.26, 2.0.27, and 2.0.30. The package declares the range in `engines.opencode` and both entrypoints refuse to initialize on an unsupported host with an actionable error instead of failing later at runtime. Later API versions are not claimed until CI and runtime checks cover them. +> **Compatibility:** OpenCode CLI `>=2.0.26 <2.2.0`. This release targets the v2 plugin API shipped with OpenCode 2.0.26 and is validated against OpenCode 2.0.26, 2.0.27, 2.0.30, and the current production **2.1.3**. OpenCode 2.1 changed the session/compaction payload schema (`providerState` → `native`) but not the plugin hooks this package uses; the real TUI and headless coexistence suites run on 2.1.3. The package declares the range in `engines.opencode` and both entrypoints refuse to initialize on an unsupported host with an actionable error instead of failing later at runtime. ## Install ```sh -opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.10 +opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.2.0 ``` -This installs the released `v0.1.10` directly from its GitHub tag using [OpenCode v2's supported npm-compatible Git package syntax](https://opencode.ai/v2/docs/plugins). The release includes both server and TUI entrypoints. npm publication is optional future distribution; it is not required to install or use this release. +This installs the released `v0.2.0` directly from its GitHub tag using [OpenCode v2's supported npm-compatible Git package syntax](https://opencode.ai/v2/docs/plugins). The release includes both server and TUI entrypoints. npm publication is optional future distribution; it is not required to install or use this release. Restart OpenCode after installation. The plugin defaults to enabled, uses the current system timezone for display, and enforces the built-in DeepSeek/OpenCode Go policy without extra setup. @@ -84,6 +84,24 @@ The refusal is deliberately boring: the outgoing request is replaced with a plac A refusal produced this way is not treated as a provider failure, so the peak-aware retry recovery does not register a continuation for it — the caller that asked for the cancellation owns what happens next, and no work is ever resumed twice. +### Canonical pricing for other plugins (`pricing` RPC) + +Another plugin may need to know whether a model is in a verified PEAK window +without copying this plugin's schedule or holiday calendar. The read-only +`pricing` RPC is the canonical answer: + +``` +rpc.opencode-offpeak.pricing { providerID, modelID } + -> { state: "PEAK" | "OFF_PEAK" | "FLAT" | "UNTRACKED" | "UNKNOWN", + allowed: boolean, policyID?, nextTransition?, reason? } +``` + +It evaluates the same configured policies and clock as the guard and has no side +effects: it never queues, holds, blocks, or sends a request. `opencode-free-first` +`v0.2.0` uses it for `freeUsageMode: "peak-only"`; an unavailable RPC or an +unrecognized state is treated as not-PEAK by the consumer. The method does not +create a second pricing engine or expose any mutable state. + ### Session-scoped off-peak protection (`/offpeak session on|off`) Sometimes one session should deliberately run through a peak window while every other session stays protected: diff --git a/RELEASING.md b/RELEASING.md index 9eff0ec..327b11d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -2,13 +2,22 @@ ## Current public distribution -`v0.1.10` is complete and publicly installable from its exact GitHub tag with OpenCode CLI v2: +`v0.2.0` is complete and publicly installable from its exact GitHub tag with OpenCode CLI v2: ```sh -opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.10 +opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.2.0 ``` -`v0.1.0` through `v0.1.9` remain available at their own tags, but `v0.1.10` is the canonical release: it adds session-scoped off-peak protection (`/offpeak session on|off` — an explicit, persistent, timer-free authorization for one interactive session to use verified peak-priced inference while every other session stays protected; the override wakes already-held work immediately, survives reloads and restarts, never touches queue tasks, and still fails closed for `UNKNOWN` and invalid configuration). This sits on top of the v0.1.9 pending-compaction recovery, the v0.1.7/v0.1.8 hold-cancellation work, the v0.1.6 direct DeepSeek API/verified Zen/held-request feedback, the v0.1.5 title-recovery work, the v0.1.4 pre-model peak deferral and Chinese holiday policy, the v0.1.3 retry recovery, the v0.1.2 interactive boundary hold, the v0.1.1 compatibility enforcement, the documented `Plugin.define(...)` TUI shape, and the real-TUI regression smoke test. OpenCode officially accepts npm-compatible Git package specifications, including GitHub URLs and exact tags ([plugin installation docs](https://opencode.ai/v2/docs/plugins)). This install path loads both the server and TUI entrypoints. npm publication is optional future distribution; npm login, 2FA, or registry publication is not required before this release can be installed or used. +`v0.1.0` through `v0.1.10` remain available at their own tags. `v0.2.0` is the +canonical release: it extends the tested OpenCode range through 2.1.3 and adds +the read-only canonical `pricing` RPC consumed by `opencode-free-first` +`peak-only`. The 2.1.3 validation covers the real TUI load and `/offpeak status`, +the full deterministic suite, and the two-plugin coexistence suite. OpenCode +officially accepts npm-compatible Git package specifications, including GitHub +URLs and exact tags ([plugin installation docs](https://opencode.ai/v2/docs/plugins)). +This install path loads both the server and TUI entrypoints. npm publication is +optional future distribution; npm login, 2FA, or registry publication is not +required before this release can be installed or used. ## Optional npm distribution diff --git a/ROADMAP.md b/ROADMAP.md index 19220b8..84aac50 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ ## Current: 0.1.x -- OpenCode CLI v2.0.26 API adapter. +- OpenCode CLI v2.0.26 API adapter; compatibility extended through 2.1.3 (v0.2.0). - DeepSeek via OpenCode Go policy. - Persistent sequential queue, ON/OFF, status/list/cancel/retry/resume controls. - Request-boundary HTTP and experimental WebSocket guard. diff --git a/docs/development.md b/docs/development.md index ce78f38..1913216 100644 --- a/docs/development.md +++ b/docs/development.md @@ -3,7 +3,7 @@ ## Requirements - Node.js 22 or newer for the TypeScript/build/test toolchain. -- OpenCode CLI `>=2.0.26 <2.1.0` for runtime integration. +- OpenCode CLI `>=2.0.26 <2.2.0` for runtime integration. - npm for install/build/pack checks. The plugin API dependency is pinned to `@opencode/plugin@2.0.26`, matching the minimum supported CLI version. OpenCode's generic plugin documentation still describes its older hook API, so changes to the v2 package/runtime are treated as compatibility changes and must be verified against the installed v2 CLI. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 050f9b5..ae639ee 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2,7 +2,7 @@ ## `/offpeak` is not recognized -Confirm the installed CLI is within `>=2.0.26 <2.1.0`, install with `opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.10`, then restart OpenCode. Check `opencode plugin list` and the OpenCode server logs. The server entrypoint provides RPC while the TUI entrypoint registers the slash command. +Confirm the installed CLI is within `>=2.0.26 <2.2.0`, install with `opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.2.0`, then restart OpenCode. Check `opencode plugin list` and the OpenCode server logs. The server entrypoint provides RPC while the TUI entrypoint registers the slash command. ## `Keymap.Provider is missing` in the TUI @@ -26,7 +26,7 @@ Use `/offpeak session off` in that session. It disables off-peak protection for In `auto` mode, a supported interactive request sent during a peak window is deferred at OpenCode's pre-model hook — before any provider attempt (and its response-header timeout) starts — instead of being discarded or timing out, and the same session proceeds automatically when off-peak returns; no second message is needed. `/offpeak status` shows it under `Interactive` while it waits. In `queue` or `guard` mode, and while the plugin is OFF, interactive peak requests are blocked immediately by the transport guard. If a deferred request fails closed because the plugin or server shut down, resend it after the server is back. -While the request is held, the TUI keeps its normal animated "working" indicator even though nothing has been sent to the provider. OpenCode 2.0.26–2.0.30 reports a session as either `idle` or `running` and exposes no "waiting" state to plugins (the hold happens inside the request hook, before any provider attempt), so the indicator cannot be switched off without ending the turn; the hold toast (transient, about 15 seconds) and `/offpeak status` (persistent) are the authoritative waiting signals. The indicator stops when the execution settles: at the off-peak release it flows straight into the real request, and after a user cancel, a service shutdown/reload, or OpenCode's 60-minute inactivity sweep the session goes idle — except after a user cancel, the persisted deferral continues the same session at the next allowed window. +While the request is held, the TUI keeps its normal animated "working" indicator even though nothing has been sent to the provider. OpenCode 2.0.26–2.1.3 reports a session as either `idle` or `running` and exposes no "waiting" state to plugins (the hold happens inside the request hook, before any provider attempt), so the indicator cannot be switched off without ending the turn; the hold toast (transient, about 15 seconds) and `/offpeak status` (persistent) are the authoritative waiting signals. The indicator stops when the execution settles: at the off-peak release it flows straight into the real request, and after a user cancel, a service shutdown/reload, or OpenCode's 60-minute inactivity sweep the session goes idle — except after a user cancel, the persisted deferral continues the same session at the next allowed window. ## Manual compaction during peak diff --git a/package-lock.json b/package-lock.json index bc17026..824effe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-offpeak", - "version": "0.1.1", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-offpeak", - "version": "0.1.1", + "version": "0.2.0", "license": "MIT", "dependencies": { "@opencode/plugin": "2.0.26" @@ -19,7 +19,7 @@ }, "engines": { "node": ">=22", - "opencode": ">=2.0.26 <2.1.0" + "opencode": ">=2.0.26 <2.2.0" } }, "node_modules/@ai-sdk/provider": { diff --git a/package.json b/package.json index 1a7bec1..64b5b68 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-offpeak", - "version": "0.1.10", + "version": "0.2.0", "description": "Queue OpenCode agent tasks and guard model requests against expensive pricing windows.", "author": "Lutz Kind", "license": "MIT", @@ -8,7 +8,7 @@ "main": "./src/server.ts", "engines": { "node": ">=22", - "opencode": ">=2.0.26 <2.1.0" + "opencode": ">=2.0.26 <2.2.0" }, "exports": { ".": { diff --git a/src/compat.ts b/src/compat.ts index 02f10b2..fdeaacf 100644 --- a/src/compat.ts +++ b/src/compat.ts @@ -2,13 +2,17 @@ * OpenCode host compatibility. * * The plugin is tested against the OpenCode v2 plugin API shipped with - * OpenCode 2.0.26. Hosts before 2.0.26 activate TUI plugins outside the - * Solid keymap provider owner, so `context.keymap.layer()` fails with - * "Keymap.Provider is missing" even though the plugin follows the - * documented API. Unsupported hosts must fail with this explicit - * compatibility error instead of the confusing runtime failure. + * OpenCode 2.0.26 through 2.1.3. Hosts before 2.0.26 activate TUI plugins + * outside the Solid keymap provider owner, so `context.keymap.layer()` fails + * with "Keymap.Provider is missing" even though the plugin follows the + * documented API. OpenCode 2.1 changes the session/compaction payload schema + * (`providerState` → `native`) but not the plugin hooks this plugin uses + * (`model.request`, `http.request`, `retry`, session events, RPC); it is + * covered by the compatibility and coexistence suites. Unsupported hosts must + * fail with this explicit compatibility error instead of the confusing runtime + * failure. */ -export const SUPPORTED_OPENCODE_RANGE = ">=2.0.26 <2.1.0"; +export const SUPPORTED_OPENCODE_RANGE = ">=2.0.26 <2.2.0"; const VERSION_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.-]+)?$/; @@ -19,7 +23,9 @@ export function isSupportedOpenCodeVersion(version: string): boolean { const major = Number(match[1]); const minor = Number(match[2]); const patch = Number(match[3]); - return major === 2 && minor === 0 && patch >= 26; + if (major !== 2) return false; + if (minor === 0) return patch >= 26; + return minor === 1; } /** diff --git a/src/engine.ts b/src/engine.ts index dfc00b8..78f325c 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -54,6 +54,23 @@ const systemClock: EngineClock = { clearTimeout: (timer) => clearTimeout(timer), }; +export interface PricingQuery { + providerID: string; + modelID: string; +} + +/** + * Read-only answer for the canonical pricing classification. Other plugins use + * this instead of copying the schedule or holiday calendar. + */ +export interface PricingAnswer { + state: PolicyDecision["state"]; + allowed: boolean; + policyID?: string; + nextTransition?: number; + reason?: string; +} + export interface EngineStatus { enabled: boolean; mode: OffpeakConfig["mode"]; @@ -625,6 +642,35 @@ export class OffpeakEngine { this.#requestPump(); } + /** + * Evaluate the configured pricing policy for one model. This is the single + * source of truth for PEAK/OFF_PEAK and is deliberately side-effect free: + * no queue, hold, timer, or provider request is involved. + */ + async pricing(input: PricingQuery): Promise { + const settings = await this.#config.read(); + if (!settings.config) { + return { + state: "UNKNOWN", + allowed: false, + reason: settings.error ?? "Offpeak configuration is unavailable.", + }; + } + const decision = evaluatePricing( + settings.config.policies, + input.providerID, + input.modelID, + this.#clock.now(), + ); + return { + state: decision.state, + allowed: decision.allowed, + ...(decision.policy?.id === undefined ? {} : { policyID: decision.policy.id }), + ...(decision.nextTransition === undefined ? {} : { nextTransition: decision.nextTransition }), + ...(decision.reason === undefined ? {} : { reason: decision.reason }), + }; + } + async status(current?: { providerID?: string; modelID?: string; diff --git a/src/rpc.ts b/src/rpc.ts index 11f899e..ad353c1 100644 --- a/src/rpc.ts +++ b/src/rpc.ts @@ -27,6 +27,24 @@ export const OFFPEAK_RPC = { }, output: object, }, + /** + * Read-only pricing classification for one model. This is the canonical + * PEAK/OFF_PEAK source for other plugins (for example + * `opencode-free-first`), evaluated with this plugin's configured policies, + * holiday calendar, and clock. It never queues, holds, or sends a request. + */ + pricing: { + input: { + type: "object", + properties: { + providerID: { type: "string" }, + modelID: { type: "string" }, + }, + required: ["providerID", "modelID"], + additionalProperties: false, + }, + output: object, + }, /** * Cancel a request this plugin is holding for the session, without * interrupting the session. Generic infrastructure for any caller that @@ -127,3 +145,14 @@ export interface OffpeakRpcOutput { state?: string; model?: string; } + +/** Answer to the read-only `pricing` RPC. */ +export interface OffpeakPricingOutput { + /** `PEAK`, `OFF_PEAK`, `FLAT`, `UNTRACKED`, or `UNKNOWN`. */ + state: string; + allowed: boolean; + policyID?: string; + /** Epoch milliseconds of the next classification boundary, when known. */ + nextTransition?: number; + reason?: string; +} diff --git a/src/server.ts b/src/server.ts index 16ec5ae..40c2de6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,7 +9,12 @@ import { REFUSED_HEADER, refusedRequest, } from "./refused.js"; -import { OFFPEAK_RPC, type OffpeakRpcInput, type OffpeakRpcOutput } from "./rpc.js"; +import { + OFFPEAK_RPC, + type OffpeakPricingOutput, + type OffpeakRpcInput, + type OffpeakRpcOutput, +} from "./rpc.js"; import { QueueRepository, RecoveryRepository, SessionOverridesRepository } from "./store.js"; export default Plugin.define({ @@ -315,6 +320,15 @@ export default Plugin.define({ ); } }, + pricing: async (rawInput: unknown): Promise => { + const input = rawInput as { providerID?: unknown; modelID?: unknown }; + const providerID = typeof input.providerID === "string" ? input.providerID.trim() : ""; + const modelID = typeof input.modelID === "string" ? input.modelID.trim() : ""; + if (!providerID || !modelID) { + throw new Error("pricing requires providerID and modelID."); + } + return engine.pricing({ providerID, modelID }); + }, cancelHold: async (rawInput: unknown): Promise => { const input = rawInput as { sessionID?: string; reason?: string }; const sessionID = input.sessionID?.trim(); diff --git a/test/compat.test.mjs b/test/compat.test.mjs index 077d645..0dff5c8 100644 --- a/test/compat.test.mjs +++ b/test/compat.test.mjs @@ -10,21 +10,21 @@ import { const root = path.resolve(import.meta.dirname, ".."); -test("supported OpenCode versions match the tested 2.0.26 range", () => { - assert.equal(SUPPORTED_OPENCODE_RANGE, ">=2.0.26 <2.1.0"); - for (const version of ["2.0.26", "2.0.27", "v2.0.26", "2.0.26-beta.1", "2.0.30+build.5"]) { - assert.equal(isSupportedOpenCodeVersion(version), true, version); - } +test("supported OpenCode versions match the tested 2.0.26–2.1.x range", () => { + assert.equal(SUPPORTED_OPENCODE_RANGE, ">=2.0.26 <2.2.0"); for (const version of [ - "2.0.25", - "2.0.18", - "1.18.35", + "2.0.26", + "2.0.27", + "v2.0.26", + "2.0.26-beta.1", + "2.0.30+build.5", "2.1.0", - "2.1.0-beta.1", - "3.0.0", - "not-a-version", - "", + "2.1.3", + "v2.1.3", ]) { + assert.equal(isSupportedOpenCodeVersion(version), true, version); + } + for (const version of ["2.0.25", "2.0.18", "1.18.35", "2.2.0", "3.0.0", "not-a-version", ""]) { assert.equal(isSupportedOpenCodeVersion(version), false, version); } }); @@ -39,7 +39,7 @@ test("compatibility guard passes inside the range and fails clearly outside it", assert.doesNotThrow(() => assertSupportedOpenCode({ version: "2.0.26" }, "TUI")); assert.throws( () => assertSupportedOpenCode({ version: "2.0.25" }, "TUI"), - /opencode-offpeak requires OpenCode >=2\.0\.26 <2\.1\.0; this TUI host runs 2\.0\.25\. Run `opencode upgrade` and restart OpenCode before using this plugin\./, + /opencode-offpeak requires OpenCode >=2\.0\.26 <2\.2\.0; this TUI host runs 2\.0\.25\. Run `opencode upgrade` and restart OpenCode before using this plugin\./, ); assert.throws( () => assertSupportedOpenCode(undefined, "server"), diff --git a/test/installed-package.test.mjs b/test/installed-package.test.mjs index a240254..e4b940f 100644 --- a/test/installed-package.test.mjs +++ b/test/installed-package.test.mjs @@ -30,7 +30,7 @@ test("a clean packed install loads both entrypoints and keeps the manifest contr const packageRoot = path.join(extract, "package"); const installed = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8")); assert.equal(installed.version, manifest.version); - assert.equal(installed.engines.opencode, ">=2.0.26 <2.1.0"); + assert.equal(installed.engines.opencode, ">=2.0.26 <2.2.0"); assert.equal(installed.engines.node, ">=22"); assert.equal(installed.main, "./src/server.ts"); assert.equal(installed.exports["./tui"].bun, "./tui.ts"); diff --git a/test/plugin-entries.test.mjs b/test/plugin-entries.test.mjs index 5569d2f..e3d3db9 100644 --- a/test/plugin-entries.test.mjs +++ b/test/plugin-entries.test.mjs @@ -137,6 +137,20 @@ test("OpenCode v2 server and TUI entrypoints register the request gate and /offp try { assert.equal(rpcDefinition.id, "opencode-offpeak"); assert.deepEqual(Object.keys(rpcDefinition.events).sort(), ["hold", "resume"]); + // The canonical, side-effect-free pricing classification exposed to other + // plugins. It uses the same configured policy and never queues or holds. + const pricing = await rpcHandlers.pricing({ + providerID: "opencode-go", + modelID: "deepseek-v4.1-flash", + }); + assert.equal(pricing.state, "PEAK"); + assert.equal(pricing.allowed, false); + assert.equal(pricing.policyID, "deepseek-opencode-go"); + assert.equal(typeof pricing.nextTransition, "number"); + await assert.rejects( + rpcHandlers.pricing({ providerID: "opencode-go" }), + /pricing requires providerID and modelID/, + ); assert.ok(hooks.has("model.request")); assert.ok(hooks.has("http.request")); assert.ok(hooks.has("experimental.ws.handshake")); @@ -462,7 +476,7 @@ test("both entrypoints reject unsupported OpenCode hosts with an actionable erro }, rpc: { register: async () => ({ dispose: async () => undefined }) }, }); - }, /requires OpenCode >=2\.0\.26 <2\.1\.0; this server host runs 2\.0\.18/); + }, /requires OpenCode >=2\.0\.26 <2\.2\.0; this server host runs 2\.0\.18/); await assert.rejects(async () => { await tuiPlugin.setup({ app: oldHost, @@ -474,7 +488,7 @@ test("both entrypoints reject unsupported OpenCode hosts with an actionable erro toast: { show: () => undefined }, }, }); - }, /requires OpenCode >=2\.0\.26 <2\.1\.0; this TUI host runs 2\.0\.18/); + }, /requires OpenCode >=2\.0\.26 <2\.2\.0; this TUI host runs 2\.0\.18/); await assert.rejects(async () => { await tuiPlugin.setup({ app: undefined,