From 887a352a3176ca587c4555f832147951e5a209a8 Mon Sep 17 00:00:00 2001 From: Lutz Kind <132444134+lutzkind@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:15:29 +0000 Subject: [PATCH] feat(providers): direct DeepSeek API policy, verified Zen semantics, held-request feedback - policy: add the direct DeepSeek API as a second built-in policy (deepseek-api) for provider deepseek with the verified canonical model IDs (deepseek-flash, deepseek-v4-pro) and the legacy aliases the provider still bills at the Flash price; explicit IDs only, no deepseek-* wildcard, unknown direct models fail closed with an actionable message; shared peak-window constant and shared Chinese holiday calendar data, no duplicated evaluator logic - policy: verified flat-provider facts (OpenCode Zen) explain the deliberate absence of a time-based policy; flat models now evaluate as a distinct FLAT state (allowed, no transition, never held) - config: a stored policy set made only of unmodified built-in copies adopts newly shipped built-in policies at read time, so existing installs gain the direct DeepSeek policy without editing offpeak.json; customized sets stay authoritative - engine/server/tui: a held primary interactive request announces itself once as rpc.opencode-offpeak.hold with the display-zone deadline and its release (or a delivered recovery continuation) as rpc.opencode-offpeak.resume; the TUI renders toasts from the supported event stream; auxiliary kinds, queue-owned tasks, user cancels, reloads and restarts never produce noisy or duplicate notifications, and headless output stays clean - status: providerNote for verified flat providers, FLAT/UNTRACKED/PEAK wording in /offpeak status and /offpeak pricing - tests: 99 pass, including provider isolation, direct DeepSeek windows/holidays/legacy aliases, Zen pass-through, notification semantics (one per hold, silence for auxiliary and queue holds, no resumption after cancel, continuation notifications, no duplicates), read-time built-in adoption, and the display-zone formatter - docs: README support matrix, pricing policy guide, architecture, decisions, changelog, and v0.1.6 release notes --- ARCHITECTURE.md | 8 +- CHANGELOG.md | 9 + DECISIONS.md | 10 + README.md | 28 ++- RELEASING.md | 6 +- docs/pricing-policies.md | 23 ++- docs/releases/v0.1.6.md | 61 ++++++ docs/troubleshooting.md | 2 +- package.json | 2 +- src/config.ts | 24 ++- src/engine.ts | 89 ++++++++- src/index.ts | 13 ++ src/policy.ts | 133 ++++++++++-- src/rpc.ts | 58 +++++- src/server.ts | 27 ++- src/tui.ts | 66 +++++- test/engine.test.mjs | 377 +++++++++++++++++++++++++++++++++++ test/plugin-entries.test.mjs | 54 ++++- test/policy.test.mjs | 184 ++++++++++++++++- 19 files changed, 1126 insertions(+), 48 deletions(-) create mode 100644 docs/releases/v0.1.6.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b5ecb0b..b8645bb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -72,6 +72,10 @@ A held `title` request therefore persists a semantic `pending-title` recovery ma `UNKNOWN` stays fail-closed for every kind, including titles: a model with no policy entry produces no provider traffic at any hour until the policy is extended. The built-in Go policy avoids that for the models OpenCode actually requests by declaring the provider's documented flat-priced models explicitly (see `src/policy.ts` and `docs/pricing-policies.md`). +### Held-request notifications + +A primary interactive request that enters a hold announces itself exactly once as a plugin RPC event (`rpc.opencode-offpeak.hold`, carrying the session, the next transition and a display-zone formatted deadline); the release of that same hold, or a persisted recovery that actually continues a session, emits at most one `rpc.opencode-offpeak.resume`. Events travel on OpenCode's own stream to whichever clients subscribe; the TUI entrypoint renders them as toasts, while a headless run has no subscriber and its stdout stays untouched. The engine announces a hold only for `kind === "primary"` requests and remembers the announcement per instance, so auxiliary `title`/`compaction`/`generate` holds are silent, queue-owned sessions keep their queue semantics, a user cancel never produces a resumption, and a reload or restart cannot duplicate a notification for one hold. Notifications never enter the session, the transcript, or the model context. + ### Peak-aware retry recovery and the inactivity safety net OpenCode retries retryable transport failures on its own (for the agent loop: jittered exponential backoff; header-timeout failures are bounded to three retries). Each retry attempt is a new paid request, and every attempt re-enters the whole hook chain, so a retry during peak would either be held or exhaust the retry budget and fail the turn. @@ -85,9 +89,9 @@ OpenCode's location-activity sweep interrupts sessions whose location has had no Non-retryable failures (`provider.invalid-request`, `provider.auth`, `provider.quota`, `provider.content-filter`, …) are left untouched and still end as `FAILED`/error. Failures while pricing is allowed keep OpenCode's normal retry behavior. OFF, `queue`, and `guard` modes are unchanged. The continuation is a new state-aware turn; the failed provider request is never replayed byte-for-byte. A recovery whose session never settles is dropped without sending anything. -Policy matching uses provider IDs and exact/glob model IDs. Each policy declares its peak-priced `models` (weekly `blocked` windows) and its `flatModels` (vendor-documented all-hours pricing, always OFF_PEAK and never held); `models` wins if a pattern matches both. A configured provider with an unmatched model is `UNKNOWN` and blocked. Invalid timezone or schedule data is blocked. Providers without an installed policy are `UNTRACKED`; they are not covered by this plugin until a policy is added. Queue tasks must match a valid policy. +Policy matching uses provider IDs and exact/glob model IDs. Each policy declares its peak-priced `models` (weekly `blocked` windows) and its `flatModels` (vendor-documented all-hours pricing, which evaluate `FLAT`: allowed with no transition and never held); `models` wins if a pattern matches both. A configured provider with an unmatched model is `UNKNOWN` and blocked with an actionable message. Invalid timezone or schedule data is blocked. Providers without an installed policy are `UNTRACKED`; they are not covered by this plugin until a policy is added, and a verified provider fact records the ones whose pricing is known to be flat (OpenCode Zen) so status output can explain that no policy is needed. Queue tasks must match a valid policy. -The built-in `deepseek-opencode-go` policy is defined once in `src/policy.ts`, in UTC, with the Chinese statutory holiday calendar in `src/holidays.ts`. Time evaluation uses `Intl.DateTimeFormat` and the policy's IANA timezone, including weekday changes and DST; holiday dates are evaluated on the calendar's own timezone (`Asia/Shanghai`) so the host-local timezone never matters. The user's display timezone does not change pricing boundaries. Full-day off-peak exception dates override every weekday peak window, and `nextTransition` skips holiday peak windows. +The built-in `deepseek-opencode-go` (provider `opencode-go`) and `deepseek-api` (provider `deepseek`, the direct API) policies are defined once in `src/policy.ts`, in UTC, sharing the same peak windows and the Chinese statutory holiday calendar in `src/holidays.ts`. When a stored configuration contains only unmodified copies of the shipped policies, a newly shipped built-in policy is appended at read time so existing installations gain it without editing the file; any customization makes the stored set authoritative. Time evaluation uses `Intl.DateTimeFormat` and the policy's IANA timezone, including weekday changes and DST; holiday dates are evaluated on the calendar's own timezone (`Asia/Shanghai`) so the host-local timezone never matters. The user's display timezone does not change pricing boundaries. Full-day off-peak exception dates override every weekday peak window, and `nextTransition` skips holiday peak windows. ## Queue execution and sessions diff --git a/CHANGELOG.md b/CHANGELOG.md index 834a18d..9be47b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.1.6 - 2026-10-15 + +- **Direct DeepSeek API support.** OpenCode exposes the direct DeepSeek API as provider `deepseek` with models `deepseek-flash` and `deepseek-v4-pro` (verified against the live 2.0.27 registry). A second built-in policy, `deepseek-api`, covers exactly those two model IDs plus the legacy aliases the provider's pricing page still accepts and bills at the Flash price (`deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`). It uses the same verified weekly windows (01:00–04:00 and 06:00–10:00 UTC, Monday–Friday), the same shared Chinese public-holiday calendar data, and the same schedule evaluator as the Go policy. There is deliberately **no `deepseek-*` wildcard**: a future model that is not in the verified list stays `UNKNOWN` and fails closed with an actionable message ("not in the verified off-peak pricing policy for this release — update opencode-offpeak or add a verified custom policy"). +- **Newly shipped built-in policies reach existing installs.** When a stored `offpeak.json` contains only unmodified copies of the shipped built-in policies (the state a first `/offpeak on|off` persists), missing built-in policies are appended at read time, so an existing installation gains the direct-DeepSeek policy without editing the file. As soon as anything is customized, the stored set is authoritative and nothing is added. +- **OpenCode Zen is documented, not simulated.** Zen (provider `opencode`) publishes one all-hours price per model with no peak/off-peak tiers, so the plugin deliberately installs no policy for it: requests are never delayed or blocked, and a new Zen model can never be blocked because a DeepSeek policy exists elsewhere. `pricingState` now reports flat-priced models as a distinct `FLAT` state (never waits, no transition), and the status output adds a verified provider note for Zen ("no policy needed: …"). The Zen fact (source and verification date) is recorded in `src/policy.ts` and documented in the README support matrix. +- **Immediate, non-chat feedback while a primary request is held.** When an interactive primary request enters a PEAK hold, the server emits one `rpc.opencode-offpeak.hold` event with the next allowed transition, formatted in the configured display time zone ("Peak pricing active — request held until 08:00 Asia/Bangkok. It will resume automatically in this session."). On release, or when a persisted recovery continues the session, one `rpc.opencode-offpeak.resume` event follows ("Off-peak pricing started…"). Notifications are rendered by the TUI entrypoint as toasts via OpenCode's own event stream — never as chat or model context, never in headless output. Auxiliary title/compaction/generate holds are silent, queue-owned sessions keep their existing queue/status semantics, a user cancel produces no release notification, and a hold announces only from the instance that created it, so a reload or restart cannot produce duplicates. +- **`/offpeak status` vocabulary.** The status output distinguishes `PEAK` (blocked/waiting), `OFF_PEAK` (allowed now), `FLAT` (flat all-hours price; never waits, no transition), `UNTRACKED` (no policy, explained for verified flat providers) and `UNKNOWN` (no verified policy for this model; blocked). +- Tests: 99 pass (`npm run lint`, `npm run typecheck`, `npm test`), including new provider coverage (direct DeepSeek windows, weekends and holidays, legacy aliases, unknown models failing closed, provider isolation), Zen pass-through for current and hypothetical new models, the `FLAT` state, hold/resume notification semantics (exactly one per hold, silence for auxiliary kinds, no notification after cancel, continuation notifications, no duplicates across reloads, queue-owned silence), read-time adoption of newly shipped built-in policies, and the compact display-zone formatter. Real OpenCode 2.0.27 validation with a local fixture provider covers the Go TUI toast lifecycle and status output, the direct DeepSeek hold/release with exact SSE notification counts, Zen never waiting, title generation, and reload/restart without duplicate notifications. + ## 0.1.5 - 2026-10-15 - Automatic chat titles work again. OpenCode prepares automatic naming as a separate `title` model request that uses the provider's *small* model — for `opencode-go` that is `gpt-6-luna` — while the built-in policy only listed the DeepSeek models, so every title request was an `UNKNOWN` configured-provider model and failed closed at all hours. Titles stopped being generated when the plugin was installed, independent of peak pricing. The policy schema now supports `flatModels`: explicit, vendor-documented patterns for models with a single all-hours price (the Go pricing table lists peak/off-peak tiers only for the DeepSeek models). Flat models are `OFF_PEAK` at every hour, are never held or blocked, and cannot create peak-priced traffic; `models` takes precedence if a pattern matches both, and any unmatched model still fails closed. The built-in `deepseek-opencode-go` policy declares the provider's documented flat models, and an unmodified stored copy of the built-in policy adopts that list at read time. diff --git a/DECISIONS.md b/DECISIONS.md index 737e44a..014590d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -70,6 +70,16 @@ The DeepSeek pricing documentation defines the peak windows and the exemption: " A stored `policies` array that is an unmodified copy of the built-in policy (same id, providers, models, time zone, multiplier, and weekly windows) adopts the built-in holiday calendar *and the built-in flat model list* when the config is read. Existing installations commonly persist such a copy after their first `/offpeak on|off`, so they gain yearly holiday updates and the flat model list without editing the file, while any customized policy — including an explicit `holidays` or `flatModels` list — is preserved exactly. +## 2026-10-15: Direct DeepSeek API is a second built-in policy; Zen stays policy-free + +OpenCode's live 2.0.27 registry exposes the direct DeepSeek API as provider `deepseek` (`@opencode/ai/providers/deepseek`, `https://api.deepseek.com`) with exactly two models, `deepseek-flash` and `deepseek-v4-pro`. DeepSeek's own pricing page documents the same weekly peak windows and holiday exclusion as the Go gateway, plus the legacy aliases `deepseek-v4-flash` and `deepseek-v4-flash-vision-exp`, which are still accepted and billed at the Flash price. The plugin therefore ships a second built-in policy, `deepseek-api`, with an explicit four-ID list (no `deepseek-*` wildcard), reusing the shared weekly-window constant and the shared Chinese holiday calendar data rather than duplicating schedule or holiday logic. A future direct model fails closed with an actionable message until it is verified. A stored configuration that consists only of unmodified copies of the shipped policies adopts newly shipped built-in policies at read time, which is how an existing installation gains the direct policy without editing `offpeak.json`; any customization makes the stored set authoritative. + +OpenCode Zen (provider `opencode`) is deliberately different: its pricing page lists one all-hours price per model with no peak/off-peak tiers and no holiday rule, so the smallest correct representation is "this provider currently has no relevant time-based pricing rule" — no policy at all. Zen requests are never delayed or blocked, a new Zen model can never be blocked because a DeepSeek policy exists elsewhere, and the verified fact (source, date, note) lives in `VERIFIED_FLAT_PROVIDERS` so `/offpeak status` can explain `UNTRACKED` as "no policy needed". Maintaining a catalog of Zen models as `flatModels` was rejected as unnecessary complexity with the same enforcement outcome. + +## 2026-10-15: Held primary requests announce themselves on OpenCode's event stream + +A held primary interactive request previously gave no visible feedback, so OpenCode looked hung. The server entrypoint now announces the hold once and the release (or a persisted recovery continuation) at most once, as plugin RPC events (`rpc.opencode-offpeak.hold` / `rpc.opencode-offpeak.resume`) which OpenCode publishes on its own event stream; the TUI entrypoint subscribes with the supported `data.on(...)` API and renders `ui.toast.show(...)`, and a headless client simply has no subscriber. This is the smallest supported server-to-TUI push: no polling, no second process, no new transport, and nothing enters the session, the transcript, or the model context. Notifications are announced only for `kind === "primary"` requests, only by the instance that created the hold (so reloads and restarts cannot duplicate them), never after a user cancel, and never for queue-owned tasks, which keep their existing queue/status semantics. + ## 2026-10-15: Auxiliary title requests persist a pending-title marker; recovery never mutates the session Automatic chat naming is a separate `title` model request. Like every model request it is priced and peak-protected, but OpenCode generates it only once, when the first input of an untitled root session is promoted, and it is not retried by OpenCode itself. A held title therefore persists a small `pending-title` recovery entry (same minimal metadata as a deferral, with `origin: "pending-title"`), and a session can hold a primary continuation and a title marker at the same time. Recovery at the allowed window is a semantic check, never a replay or mutation: a session that already has a title (generated, concurrent, or manually renamed) clears the marker without any request, and a session that still needs one is left to OpenCode's own generation, which every interruption path re-triggers through the same input promotion that continues the session. The marker waits for the title and clears when it appears; a stale marker is dropped with a diagnostic after the settle cap. Evidence: in real OpenCode 2.0.27 fixture runs, a PEAK hold plus a full server restart produced exactly one native title request through the recovery continuation's promotion, and the plugin's own attempted `context.session.update({ title: "" })` was proven unsafe — the plugin host renames the session to an empty title, unlike the HTTP handler's regenerate semantic — so the delivery deliberately mutates nothing and the driver exposes no such call. diff --git a/README.md b/README.md index 01891bf..ebdcf33 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,10 @@ Queue OpenCode agent tasks, block paid model requests during expensive pricing w ## Install ```sh -opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.5 +opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.6 ``` -This installs the released `v0.1.5` 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.1.6` 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. @@ -93,7 +93,21 @@ All queue automation respects the persistent `enabled` setting. The guard itself ## Pricing policy -The built-in policy ID is `deepseek-opencode-go`. Its canonical rules are: +### Provider support matrix + +| Provider | Time-based protection | Covered current model IDs | +|---|---|---| +| OpenCode Go (`opencode-go`) | Yes — peak-deferred | Peak-priced: `deepseek-v4.1-flash`, `deepseek-v4-pro`, `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`. Every other documented Go model is `FLAT` (one all-hours price; never waits). | +| Direct DeepSeek API (`deepseek`) | Yes — peak-deferred | `deepseek-flash`, `deepseek-v4-pro`, plus the legacy aliases `deepseek-v4-flash` and `deepseek-v4-flash-vision-exp`, which the provider still accepts and bills at the Flash price. | +| OpenCode Zen (`opencode`) | No waiting required | No policy by design: Zen publishes one all-hours price per model with no peak/off-peak tiers, so requests are never delayed or blocked. | + +Sources and verification dates are recorded on each policy (`source`, `verifiedAt`) and in `docs/pricing-policies.md`. The direct-DeepSeek policy ships as the second built-in policy ID, `deepseek-api`; both peak-priced providers share the same rule (peak Monday–Friday 01:00–04:00 and 06:00–10:00 UTC; off-peak everything else, including weekends and Chinese public holidays in full) and the same shared holiday calendar data. + +Unknown or newly released model IDs under a peak-priced provider are **not guessed**: they evaluate `UNKNOWN` and fail closed with an actionable message until a verified policy update ships. There is deliberately no `deepseek-*` wildcard. OpenCode Zen has no policy at all, so a new Zen model is never blocked because a DeepSeek policy exists for another provider. + +### The built-in policy IDs + +`deepseek-opencode-go` (provider `opencode-go`) and `deepseek-api` (provider `deepseek`). Their canonical rules are: - Peak: Monday–Friday 01:00–04:00 and 06:00–10:00 UTC. - Off-peak: all other hours, weekends, and Chinese statutory public holidays in full. @@ -103,9 +117,13 @@ Holiday dates come from the official State Council calendar kept in `src/holiday The direct DeepSeek pricing documentation defines the exemption ("…excluding Chinese public holidays. All other hours are off-peak, including weekends and Chinese public holidays in full."), and the OpenCode Go pricing page documents the same weekly windows for this provider. The Go price table lists peak DeepSeek rates at twice their off-peak per-token rates, so equal token use consumes the allowance twice as fast during peak hours. Re-verify both pages annually; upstream [anomalyco/opencode#53071](https://github.com/anomalyco/opencode/issues/53071) tracks an open user report about National Day holiday billing and is the cue for a fresh check. -The built-in model IDs are `deepseek-v4.1-flash`, `deepseek-v4-pro`, `deepseek-v4-flash`, and `deepseek-v4-flash-vision-exp` under provider ID `opencode-go`. Every other Go model documented in the pricing table has a single all-hours price and is declared as a flat model: it is never peak-priced, so it is never held or blocked. That list mirrors the provider's own table, because OpenCode names automatic session titles with the provider's small model — for `opencode-go` that is `gpt-6-luna` — and a title must not be blocked as an unknown model. A model that matches neither list under a configured provider fails closed; a provider with no policy is shown as `UNTRACKED`; queued tasks require a matching policy. +The Go peak model IDs are `deepseek-v4.1-flash`, `deepseek-v4-pro`, `deepseek-v4-flash`, and `deepseek-v4-flash-vision-exp` under provider ID `opencode-go`; the direct API peak model IDs are `deepseek-flash`, `deepseek-v4-pro`, and those same two legacy aliases under provider ID `deepseek`. Every other Go model documented in the pricing table has a single all-hours price and is declared as a flat model: it is never peak-priced, so it is never held or blocked. That list mirrors the provider's own table, because OpenCode names automatic session titles with the provider's small model — for `opencode-go` that is `gpt-6-luna` — and a title must not be blocked as an unknown model. A model that matches neither list under a configured provider fails closed; a provider with no policy is shown as `UNTRACKED` (with a verified explanation when the provider's pricing is known to be flat, as for OpenCode Zen); queued tasks require a matching policy. When a provider ships a new model: verify the official model ID, its current pricing, its peak schedule and holiday semantics, add or update the policy in `src/policy.ts`, add tests, and cut a patch release. + +### Held-request feedback + +When an interactive primary request is held for peak pricing, the TUI shows a non-chat toast — "Peak pricing active — request held until 08:00 Asia/Bangkok. It will resume automatically in this session." — and another when off-peak returns. The deadline is rendered in the configured `displayTimeZone`. These notifications travel on OpenCode's own event stream (`rpc.opencode-offpeak.hold` / `rpc.opencode-offpeak.resume`); they never enter the session or the model context, they are not repeated, a user cancel produces no false resumption, and auxiliary `title`/`compaction`/`generate` requests stay silent. Headless clients such as `opencode run` have no subscriber and remain silent. -Pricing details can change. This release verified the built-in schedule, the flat model list, and the 2026 holiday calendar against the [OpenCode Go pricing page](https://opencode.ai/docs/go/), the [DeepSeek pricing documentation](https://api-docs.deepseek.com/quick_start/pricing), and the [State Council notice](https://www.gov.cn/gongbao/2025/issue_12406/202511/content_7048922.html) on 2026-10-15. Review those sources before relying on a later policy update. +Pricing details can change. This release verified the built-in schedules, the model lists, the Zen flat-pricing fact, and the 2026 holiday calendar against the [OpenCode Go pricing page](https://opencode.ai/docs/go/), the [OpenCode Zen pricing page](https://opencode.ai/docs/zen/), the [DeepSeek pricing documentation](https://api-docs.deepseek.com/quick_start/pricing), and the [State Council notice](https://www.gov.cn/gongbao/2025/issue_12406/202511/content_7048922.html) on 2026-10-15. Review those sources before relying on a later policy update. ## Configuration diff --git a/RELEASING.md b/RELEASING.md index acafad0..8fc4567 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -2,13 +2,13 @@ ## Current public distribution -`v0.1.5` is complete and publicly installable from its exact GitHub tag with OpenCode CLI v2: +`v0.1.6` 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.5 +opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.6 ``` -`v0.1.0` through `v0.1.4` remain available at their own tags, but `v0.1.5` is the canonical release: it restores automatic chat titles (OpenCode names sessions with the provider's small model, `opencode-go/gpt-6-luna`, which the built-in policy now declares as a vendor-documented flat-priced model instead of failing it closed as an unknown configured-provider model) and persists a semantic `pending-title` recovery for a title request that is held for peak pricing, so a shared session can carry both a primary continuation and a title marker and the recovery never overwrites or regenerates a title. This sits on top of 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.5` remain available at their own tags, but `v0.1.6` is the canonical release: it adds first-class direct DeepSeek API support (a second built-in policy, `deepseek-api`, covering the verified `deepseek` provider model IDs with the shared peak windows and shared Chinese holiday calendar, with no `deepseek-*` wildcard), documents OpenCode Zen as a verified flat-priced provider that is never delayed (no policy by design), and gives held primary interactive requests immediate non-chat TUI feedback through plugin RPC events on OpenCode's own event stream (hold and resume toasts with the configured display-zone deadline; auxiliary requests stay silent; headless runs stay silent; reloads and restarts cannot duplicate a notification). This sits on top of 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. ## Optional npm distribution diff --git a/docs/pricing-policies.md b/docs/pricing-policies.md index a33d477..6eab888 100644 --- a/docs/pricing-policies.md +++ b/docs/pricing-policies.md @@ -20,13 +20,20 @@ Weekdays are `0` Sunday through `6` Saturday. Windows use local policy time and are start-inclusive/end-exclusive. Windows may cross midnight; their weekday applies to the day on which the window starts. Model patterns support `*` as a wildcard. Do not create ambiguous patterns that match the same provider/model. -`models` are the peak-priced models: they are `PEAK` inside a blocked window and `OFF_PEAK` outside it. `flatModels` are the provider's models with a single all-hours price documented by the provider: they are `OFF_PEAK` at every hour and are never held or blocked. A pattern that matches both lists is treated as peak-priced (`models` wins). A model that matches neither list is `UNKNOWN` and fails closed. Both lists come from the provider's own pricing page; cite it in `source` and record the date in `verifiedAt`. +`models` are the peak-priced models: they are `PEAK` inside a blocked window and `OFF_PEAK` outside it. `flatModels` are the provider's models with a single all-hours price documented by the provider: they evaluate `FLAT` at every hour (allowed, no transition) and are never held or blocked. A pattern that matches both lists is treated as peak-priced (`models` wins). A model that matches neither list is `UNKNOWN` and fails closed. Both lists come from the provider's own pricing page; cite it in `source` and record the date in `verifiedAt`. Prefer explicit model IDs and family globs over a blanket `*`: a future model with new time-based pricing must fail closed, not inherit an old schedule. The policy includes a source URL and verification date to make schedule provenance reviewable. `multiplier` is status metadata; the guard does not estimate or aggregate bills. -## Built-in policy +## Built-in policies -`deepseek-opencode-go` matches provider ID `opencode-go` and the DeepSeek model IDs listed in the [README](../README.md). Its canonical schedule is defined only in `src/policy.ts`, with the holiday calendar in `src/holidays.ts`. +Two policies ship built in, both with the same verified weekly windows (`DEEPSEEK_PEAK_WINDOWS` in `src/policy.ts`) and the same shared holiday calendar data (`CHINA_PUBLIC_HOLIDAYS` in `src/holidays.ts`): + +- `deepseek-opencode-go` — provider ID `opencode-go` (OpenCode Go). +- `deepseek-api` — provider ID `deepseek` (the direct DeepSeek API). + +Their canonical schedules are defined only in `src/policy.ts`. When a stored `offpeak.json` contains only unmodified copies of the shipped policies, a newly shipped built-in policy is appended at read time, so existing installations gain it without editing the file; a customized policy set is authoritative and is never modified. + +### OpenCode Go (`opencode-go`) The rules are: peak Monday–Friday 01:00–04:00 and 06:00–10:00 UTC for the DeepSeek models; off-peak all other hours, weekends, and Chinese statutory public holidays in full; weekend make-up working days stay off-peak because weekends are always off-peak. @@ -49,6 +56,16 @@ A listed date overrides every weekday peak window for the whole calendar day in An unknown model under provider `opencode-go` returns `UNKNOWN` and fails closed. Add a verified model/policy entry before relying on newly added provider models. +### Direct DeepSeek API (`deepseek`) + +The [DeepSeek pricing documentation](https://api-docs.deepseek.com/quick_start/pricing) lists the two canonical API models `deepseek-flash` (DeepSeek-V4.1-Flash) and `deepseek-v4-pro` (DeepSeek-V4-Pro-0813), each with Off-Peak and Peak token rates ("Off-peak rates are half of the peak rates. Peak hours are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through Friday, excluding Chinese public holidays. All other hours are off-peak, including weekends and Chinese public holidays in full."). The same page notes that the legacy names `deepseek-v4-flash` and `deepseek-v4-flash-vision-exp` are still accepted, served by DeepSeek-V4.1-Flash and billed at the Flash price, so they share the schedule and are listed explicitly. The policy matches exactly these four IDs — there is no `deepseek-*` wildcard, and a future direct model fails closed until it is verified. + +OpenCode exposes provider ID `deepseek` (package `@opencode/ai/providers/deepseek`, base URL `https://api.deepseek.com`) with exactly the two canonical models in its live registry; the legacy aliases are kept for sessions pinned to an older catalog and for API-level use. + +### OpenCode Zen (`opencode`) — no policy by design + +The [OpenCode Zen pricing page](https://opencode.ai/docs/zen/) lists one all-hours price (input, output, cached read/write) per model. There are no peak/off-peak tiers and no holiday rule, so there is nothing to wait for: the plugin deliberately installs **no** policy for provider `opencode` and never delays or blocks a Zen request. This is the smallest correct representation — "this provider currently has no relevant time-based pricing rule" — and it means a new Zen model is never blocked merely because a DeepSeek policy exists for another provider. The verified fact (name, note, source URL, verification date) lives in `VERIFIED_FLAT_PROVIDERS` in `src/policy.ts` and is surfaced by `/offpeak status` as `Pricing: UNTRACKED` with `Requests: ALLOWED (no policy needed: …)`. If Zen ever introduces time-based tiers, add a policy with the verified IDs then. + ## Evaluation and timezones Pricing is evaluated in the policy's IANA timezone with `Intl.DateTimeFormat`; DST changes and local weekday transitions are evaluated from absolute timestamps. The display timezone is independent. Invalid zones and malformed schedules fail closed. diff --git a/docs/releases/v0.1.6.md b/docs/releases/v0.1.6.md new file mode 100644 index 0000000..f971e40 --- /dev/null +++ b/docs/releases/v0.1.6.md @@ -0,0 +1,61 @@ +# opencode-offpeak v0.1.6 + +Direct DeepSeek API support, verified OpenCode Zen semantics, and immediate non-chat feedback when a request is held for off-peak pricing. + +## Provider support matrix + +| Provider | Time-based protection | Covered model IDs | Source (verified 2026-10-15) | +|---|---|---|---| +| OpenCode Go (`opencode-go`) | Yes — peak-deferred | `deepseek-v4.1-flash`, `deepseek-v4-pro`, `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp` (peak-priced); all other documented Go models are `FLAT` (never wait) | https://opencode.ai/docs/go/ | +| Direct DeepSeek API (`deepseek`) | Yes — peak-deferred | `deepseek-flash`, `deepseek-v4-pro` (canonical API models) and the legacy aliases `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp` (still accepted, billed at the Flash price) | https://api-docs.deepseek.com/quick_start/pricing | +| OpenCode Zen (`opencode`) | No waiting required — flat pricing | No policy by design: every model has one all-hours price with no peak/off-peak tiers, so requests are never delayed or blocked | https://opencode.ai/docs/zen/ | + +Both peak-priced policies share the same verified rule: **peak** Monday–Friday 01:00–04:00 and 06:00–10:00 UTC; **off-peak** all other hours, weekends, and Chinese statutory public holidays in full (shared 2026 State Council calendar). + +Unknown or newly released model IDs under a peak-priced provider are **not guessed**: they evaluate `UNKNOWN` and fail closed with an actionable message until a verified policy update ships. There is no `deepseek-*` wildcard. `opencode` (Zen) has no policy at all, so a new Zen model is never blocked merely because a DeepSeek policy exists for another provider. + +## Held-request feedback + +A primary interactive request that enters a PEAK hold now announces itself exactly once: + +``` +OpenCode Offpeak +Peak pricing active — request held until 08:00 Asia/Bangkok. It will resume automatically in this session. +``` + +and at the boundary (or when a persisted recovery continues the session): + +``` +OpenCode Offpeak +Off-peak pricing started. Resuming the held request in this session. +``` + +- Delivered as plugin RPC events on OpenCode's own event stream and rendered by the TUI entrypoint as toasts — never a chat message, never model context, and never in headless output. +- The deadline uses the configured `displayTimeZone`, so it is never hard-coded. +- Exactly one `hold` and at most one `resume` per held request; a reload or restart cannot duplicate them, a user cancel produces no false resumption, auxiliary `title`/`compaction`/`generate` holds are silent, and queue-owned sessions keep their existing queue/status semantics. +- Headless `opencode run` stays silent: no subscriber exists, stdout is untouched. + +## Curated details + +- **`FLAT` pricing state.** A model with a vendor-documented all-hours price now reports `FLAT`: allowed, never waits, no next transition. `/offpeak status` shows `Pricing: FLAT`, and for Zen `Pricing: UNTRACKED` with `Requests: ALLOWED (no policy needed: …)`. +- **Adoption for existing installs.** A stored `offpeak.json` that contains only unmodified copies of the shipped policies (what a first toggle persists) gains newly shipped built-in policies at read time — the live install picks up `deepseek-api` without editing the file. A customized policy set is left exactly as written. +- **Actionable failure text.** `deepseek/deepseek-future-model` → "Provider deepseek is configured, but model … is not in the verified off-peak pricing policy for this release. Update opencode-offpeak or add a verified custom policy before using this model." +- **Annual maintenance.** The Chinese holiday calendar in `src/holidays.ts` and the verified model lists in `src/policy.ts` are reviewed each November (and whenever a provider adds a model); `docs/pricing-policies.md` documents the procedure. + +## Verification + +- `npm run lint`, `npm run typecheck`, `npm test` — **99 tests pass**; `npm pack` validated. +- Real OpenCode 2.0.27 + local fixture provider (no paid traffic, no peak-window provider calls): + - OpenCode Go shape in a real TUI: hold toast at +0.9s, **zero primary provider traffic during the simulated peak window**, resume toast at the boundary, reply rendered, one session, one user message, automatic title, exactly one primary + one flat title request; `/offpeak status` during the hold showed `Pricing: PEAK`, `Requests: BLOCKED`, `Interactive: 1 request waiting for off-peak`. + - Direct DeepSeek headless with the server event stream captured: exactly one `rpc.opencode-offpeak.hold` and one `rpc.opencode-offpeak.resume` (`kind: "release"`), both provider requests (title + primary) only at the boundary, clean recovery state, clean stdout, exit 0. + - Zen: no hold toast, reply inside the simulated peak window, status `UNTRACKED` + verified note. + - Reload: exactly one hold and one resume, no duplicates. Restart: exactly one hold and one `kind: "continuation"` resume, one continuation, no duplicate original prompt, title generated. +- Known platform race (pre-existing, reproduced identically on v0.1.5): if an `opencode reload` tears down the plugin generation while a released automatic-title request is still in flight, OpenCode's own title commit can be dropped and the session stays untitled until the next prompt. The pending-title marker watches for the title and is dropped with a diagnostic afterwards; the plugin deliberately never mutates a session title. + +## Installation + +```sh +opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.6 +``` + +Requires OpenCode CLI v2 `>=2.0.26 <2.1.0` (validated on 2.0.27). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 592330e..0174798 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.5`, 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.1.0`, install with `opencode plugin add git+https://github.com/lutzkind/opencode-offpeak.git#v0.1.6`, 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 diff --git a/package.json b/package.json index d68d938..8526693 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-offpeak", - "version": "0.1.5", + "version": "0.1.6", "description": "Queue OpenCode agent tasks and guard model requests against expensive pricing windows.", "author": "Lutz Kind", "license": "MIT", diff --git a/src/config.ts b/src/config.ts index 6f16420..0bb0756 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,7 +2,7 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import path from "node:path"; import { - DEEPSEEK_OPENCODE_GO, + BUILTIN_POLICIES, type PricingPolicy, validatePolicy, validateTimeZone, @@ -50,7 +50,7 @@ export function defaultConfig(options: Record = {}): OffpeakCon enabled, mode, displayTimeZone, - policies: [DEEPSEEK_OPENCODE_GO, ...(extra as PricingPolicy[])], + policies: [...structuredClone(BUILTIN_POLICIES), ...(extra as PricingPolicy[])], }; } @@ -96,9 +96,16 @@ export function validateConfig(value: unknown, fallback: OffpeakConfig): Offpeak * any customized schedule. A policy that differs in provider, model, time * zone, multiplier, or weekly windows is preserved exactly as stored, and an * explicit holiday list or flat model list always wins. + * + * When every stored policy is an unmodified copy of a built-in policy — that + * is, the stored file is only a cached copy of the shipped defaults, which is + * what a first `/offpeak on|off` persists — newly shipped built-in policies + * are appended too, so an existing installation gains a policy for a newly + * supported provider without editing the file. As soon as anything is + * customized, the stored set is authoritative and nothing is added or removed. */ function adoptBuiltinDefaults(stored: PricingPolicy[], builtin: PricingPolicy[]): PricingPolicy[] { - return stored.map((policy) => { + const adopted = stored.map((policy) => { const reference = builtin.find((candidate) => candidate.id === policy.id); if (!reference) return policy; if (!sameWeeklySchedule(policy, reference)) return policy; @@ -112,6 +119,17 @@ function adoptBuiltinDefaults(stored: PricingPolicy[], builtin: PricingPolicy[]) : {}), }; }); + const allBuiltinCopies = stored.every((policy) => { + const reference = builtin.find((candidate) => candidate.id === policy.id); + return reference !== undefined && sameWeeklySchedule(policy, reference); + }); + if (!allBuiltinCopies) return adopted; + for (const reference of builtin) { + if (!adopted.some((policy) => policy.id === reference.id)) { + adopted.push(structuredClone(reference)); + } + } + return adopted; } function sameWeeklySchedule(left: PricingPolicy, right: PricingPolicy): boolean { diff --git a/src/engine.ts b/src/engine.ts index 6238dfa..7c132e8 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -1,7 +1,12 @@ import { type FSWatcher, mkdirSync, watch } from "node:fs"; import path from "node:path"; import type { ConfigRepository, OffpeakConfig } from "./config.js"; -import { evaluatePricing, type PolicyDecision } from "./policy.js"; +import { + evaluatePricing, + flatProviderNote, + formatTransitionInTimeZone, + type PolicyDecision, +} from "./policy.js"; import { AsyncSignal, Mutex, @@ -56,6 +61,8 @@ export interface EngineStatus { pricingState: PolicyDecision["state"]; requestsAllowed: boolean; nextTransition?: number; + /** Verified explanation for providers with no time-based pricing (for example OpenCode Zen). */ + providerNote?: string; displayTimeZone: string; queued: number; running?: { id: string; sessionID?: string; state: TaskState }; @@ -137,6 +144,30 @@ export interface RetryFailureInput { willRetry: boolean; } +/** + * User-facing notifications for an intentionally held primary interactive + * request. They are emitted as plugin RPC events and rendered by the TUI as + * non-chat toasts: they never enter the session, the model context, or + * headless stdout. + * + * - `hold` fires once when a request starts waiting for off-peak. + * - `resume` fires once when the same request is released, or when a persisted + * recovery continues the session after a reload or server restart. A user + * cancel delivers neither. + */ +export type EngineNotification = + | { + type: "hold"; + sessionID: string; + /** Next allowed pricing transition in epoch milliseconds, when known. */ + until?: number; + /** Configured display time zone for the deadline. */ + timeZone: string; + /** Preformatted deadline ("17:00 Asia/Bangkok") for the UI. */ + display?: string; + } + | { type: "resume"; sessionID: string; kind: "release" | "continuation" }; + export class PricingBlockedError extends Error { readonly code = "OFFPEAK_PRICING_BLOCKED"; @@ -157,6 +188,8 @@ export interface EngineOptions { clock?: EngineClock; id?: () => string; onError?: (error: unknown) => void; + /** Delivers user-facing hold/resume notifications. */ + notify?: (event: EngineNotification) => void; } export class OffpeakEngine { @@ -166,10 +199,17 @@ export class OffpeakEngine { readonly #clock: EngineClock; readonly #id: () => string; readonly #onError: (error: unknown) => void; + readonly #notify: (event: EngineNotification) => void; readonly #lock = new Mutex(); readonly #signal: AsyncSignal; readonly #runners = new Set(); readonly #interactiveWaiters = new Set(); + /** + * Interactive sessions whose current hold was announced to the UI. A resume + * notification is emitted only for a hold this instance announced, so a + * reload or restart cannot produce a duplicate or a misleading resumption. + */ + readonly #announcedHolds = new Set(); /** * Per-session monotonic abort epochs. When OpenCode interrupts a session's * execution (user cancel, inactivity sweep, supersede), a hold loop whose @@ -204,6 +244,7 @@ export class OffpeakEngine { this.#signal = new AsyncSignal(this.#clock); this.#id = options.id ?? (() => crypto.randomUUID()); this.#onError = options.onError ?? (() => undefined); + this.#notify = options.notify ?? (() => undefined); } async start(): Promise { @@ -392,6 +433,7 @@ export class OffpeakEngine { (task) => task.overrideUntil && Date.parse(task.overrideUntil) > this.#clock.now(), ); const enabled = settings.config?.enabled ?? false; + const providerNote = selected?.providerID ? flatProviderNote(selected.providerID) : undefined; return { enabled, mode: settings.config?.mode ?? "auto", @@ -409,6 +451,7 @@ export class OffpeakEngine { ? false : decision.allowed, ...(decision.nextTransition !== undefined ? { nextTransition: decision.nextTransition } : {}), + ...(providerNote && decision.state === "UNTRACKED" ? { providerNote } : {}), displayTimeZone: settings.config?.displayTimeZone ?? "UTC", queued: tasks.filter((task) => task.state === "QUEUED").length, ...(running @@ -503,6 +546,11 @@ export class OffpeakEngine { // A normal interactive request in auto mode is held at the boundary instead of // being discarded; the same request and session continue when pricing allows. if (!task && first.config.enabled && first.config.mode === "auto") { + // Only the user's own primary request gets visible feedback; auxiliary + // title/compaction/generate holds stay silent. + if (input.kind === "primary") { + this.#announceHold(input.sessionID, decision, first.config.displayTimeZone); + } await this.#holdUntilAllowed(input, undefined, deferralOrigin); return; } @@ -584,6 +632,7 @@ export class OffpeakEngine { if (settings.config.enabled && decision.allowed) { if (task) await this.#transition(task.id, "RUNNING"); if (deferred) await this.#removeRecovery(input.sessionID, [deferralOrigin]); + this.#announceResume(input.sessionID); return; } if (settings.config.enabled && decision.nextTransition === undefined) { @@ -600,9 +649,44 @@ export class OffpeakEngine { throw new PricingBlockedError("Offpeak plugin is shutting down; request blocked."); } finally { if (waiter !== undefined) this.#interactiveWaiters.delete(waiter); + this.#announcedHolds.delete(input.sessionID); } } + /** + * Announces a new interactive hold exactly once per session. The TUI renders + * this as a toast; nothing is written to the session or the model context. + */ + #announceHold(sessionID: string, decision: PolicyDecision, timeZone: string): void { + if (this.#announcedHolds.has(sessionID)) return; + this.#announcedHolds.add(sessionID); + this.#notify({ + type: "hold", + sessionID, + timeZone, + ...(decision.nextTransition !== undefined + ? { + until: decision.nextTransition, + display: formatTransitionInTimeZone( + decision.nextTransition, + timeZone, + this.#clock.now(), + ), + } + : {}), + }); + } + + /** + * Announces the release of a hold that this instance announced. A hold that + * was interrupted, cancelled, or transferred to a recovery continuation + * produces no release notification here. + */ + #announceResume(sessionID: string): void { + if (!this.#announcedHolds.delete(sessionID)) return; + this.#notify({ type: "resume", sessionID, kind: "release" }); + } + #registerInteractiveWaiter(): number { const waiter = ++this.#nextInteractiveWaiter; this.#interactiveWaiters.add(waiter); @@ -837,6 +921,9 @@ export class OffpeakEngine { await this.#removeRecovery(entry.sessionID, [entry.origin]); try { await this.#sessions.prompt(entry.sessionID, RECOVERY_CONTINUATION); + // The session continues after a reload, restart, or retry veto; tell the + // UI once, from the instance that actually delivered the continuation. + this.#notify({ type: "resume", sessionID: entry.sessionID, kind: "continuation" }); } catch (error) { this.#onError(error); } diff --git a/src/index.ts b/src/index.ts index def45b0..33599a9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ export type { } from "./config.js"; export { ConfigRepository, defaultConfig, validateConfig } from "./config.js"; export type { + EngineNotification, EngineStatus, GuardInput, RetryFailureInput, @@ -17,18 +18,30 @@ export type { } from "./engine.js"; export { needsAutoTitle, OffpeakEngine, PricingBlockedError } from "./engine.js"; export type { + FlatProviderFact, PolicyDecision, PricingPolicy, Weekday, WeeklyWindow, } from "./policy.js"; export { + BUILTIN_POLICIES, + DEEPSEEK_API, DEEPSEEK_OPENCODE_GO, evaluatePricing, + flatProviderNote, formatInTimeZone, + formatTransitionInTimeZone, resolvePolicy, + VERIFIED_FLAT_PROVIDERS, validatePolicy, } from "./policy.js"; +export type { + OffpeakEventName, + OffpeakHoldEvent, + OffpeakResumeEvent, +} from "./rpc.js"; +export { OFFPEAK_RPC_ID, offpeakEventType } from "./rpc.js"; export type { OffpeakTask, QueueDocument, diff --git a/src/policy.ts b/src/policy.ts index cdcd77a..dec3e5c 100644 --- a/src/policy.ts +++ b/src/policy.ts @@ -21,8 +21,8 @@ export interface PricingPolicy { blocked: WeeklyWindow[]; /** * Patterns for models of the same provider with no peak pricing at all: a - * single all-hours rate documented by the provider. They are OFF_PEAK at - * every hour and are allowed without waiting. A model that matches both + * single all-hours rate documented by the provider. They evaluate as `FLAT` + * at every hour and are allowed without waiting. A model that matches both * `models` and `flatModels` is treated as peak-priced (`models` wins). * * These are vendor facts, not an assumption: they mirror the provider's own @@ -42,7 +42,16 @@ export interface PricingPolicy { } export interface PolicyDecision { - state: "OFF_PEAK" | "PEAK" | "UNTRACKED" | "UNKNOWN"; + /** + * - `PEAK`: inside a verified peak window; requests wait (auto) or block. + * - `OFF_PEAK`: outside every peak window; requests are allowed now. + * - `FLAT`: the model has a single vendor-documented all-hours price and no + * peak window at all; requests are allowed and never wait. + * - `UNTRACKED`: no policy covers this provider; the plugin does not guard it. + * - `UNKNOWN`: the provider is configured but this model has no verified + * pricing policy; requests fail closed. + */ + state: "OFF_PEAK" | "PEAK" | "FLAT" | "UNTRACKED" | "UNKNOWN"; allowed: boolean; policy?: PricingPolicy; nextTransition?: number; @@ -54,10 +63,15 @@ export interface PolicyDecision { // through Friday, excluding Chinese public holidays. All other hours are // off-peak, including weekends and Chinese public holidays in full." // (https://api-docs.deepseek.com/quick_start/pricing). The OpenCode Go pricing -// page documents the same windows for this provider -// (https://opencode.ai/docs/go/). Holiday dates come from the State Council -// calendar in src/holidays.ts. -// +// page documents the same windows for the Go gateway +// (https://opencode.ai/docs/go/), and DeepSeek's direct API uses the same +// schedule. Holiday dates come from the State Council calendar in +// src/holidays.ts and are shared by both policies. +const DEEPSEEK_PEAK_WINDOWS: WeeklyWindow[] = [ + { days: [1, 2, 3, 4, 5], start: "01:00", end: "04:00" }, + { days: [1, 2, 3, 4, 5], start: "06:00", end: "10:00" }, +]; + // The Go pricing table lists Off-Peak/Peak tiers only for the DeepSeek models. // Every other model in the Go catalog has one all-hours price per token, so // `flatModels` mirrors that table exactly (model IDs from the documented Go @@ -93,16 +107,76 @@ export const DEEPSEEK_OPENCODE_GO: PricingPolicy = { "cat-jam-free", ], timeZone: "UTC", - blocked: [ - { days: [1, 2, 3, 4, 5], start: "01:00", end: "04:00" }, - { days: [1, 2, 3, 4, 5], start: "06:00", end: "10:00" }, - ], + blocked: DEEPSEEK_PEAK_WINDOWS, holidays: CHINA_PUBLIC_HOLIDAYS, multiplier: 2, source: "https://opencode.ai/docs/go/", verifiedAt: "2026-10-15", }; +// The direct DeepSeek API (OpenCode provider id `deepseek`, +// https://api.deepseek.com) publishes the same weekly windows and holiday rule +// as the Go gateway: "Off-peak rates are half of the peak rates. Peak hours are +// 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through Friday, excluding +// Chinese public holidays. All other hours are off-peak, including weekends and +// Chinese public holidays in full." +// +// The canonical API model names are `deepseek-flash` and `deepseek-v4-pro`. +// DeepSeek's pricing page also documents the legacy aliases +// `deepseek-v4-flash` and `deepseek-v4-flash-vision-exp` as still accepted and +// forwarded to DeepSeek-V4.1-Flash, billed at the Flash price, so they share +// the same schedule. Every other name fails closed until it is verified: no +// `deepseek-*` wildcard is used, because future direct models may not share +// this schedule. +export const DEEPSEEK_API: PricingPolicy = { + id: "deepseek-api", + providers: ["deepseek"], + models: [ + "deepseek-flash", + "deepseek-v4-pro", + "deepseek-v4-flash", + "deepseek-v4-flash-vision-exp", + ], + timeZone: "UTC", + blocked: DEEPSEEK_PEAK_WINDOWS, + holidays: CHINA_PUBLIC_HOLIDAYS, + multiplier: 2, + source: "https://api-docs.deepseek.com/quick_start/pricing", + verifiedAt: "2026-10-15", +}; + +/** Every built-in policy shipped by this release, in documentation order. */ +export const BUILTIN_POLICIES: PricingPolicy[] = [DEEPSEEK_OPENCODE_GO, DEEPSEEK_API]; + +/** + * Providers whose official pricing pages have been verified to contain no + * peak/off-peak tiers at all: every model has one all-hours price. The plugin + * deliberately installs no policy for them — there is no window to wait for — + * so their requests are never delayed or blocked (`UNTRACKED`), and a new + * model of such a provider can never be blocked just because a DeepSeek policy + * exists elsewhere. This data is documentation for status output; it never + * changes enforcement. + */ +export interface FlatProviderFact { + name: string; + note: string; + source: string; + verifiedAt: string; +} + +export const VERIFIED_FLAT_PROVIDERS: Record = { + opencode: { + name: "OpenCode Zen", + note: "Zen publishes one all-hours price per model with no peak/off-peak tiers, so there is no off-peak window to wait for and requests are never delayed.", + source: "https://opencode.ai/docs/zen/", + verifiedAt: "2026-10-15", + }, +}; + +export function flatProviderNote(providerID: string): string | undefined { + return VERIFIED_FLAT_PROVIDERS[providerID.toLowerCase()]?.note; +} + const dayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; const MINUTE_MS = 60_000; // A transition is normally less than a week away; the longest Chinese holiday @@ -327,7 +401,7 @@ export function resolvePolicy( } if (flatPolicies.length) return flatPolicies[0]; throw new Error( - `Provider ${providerID} is configured, but model ${modelID} has no pricing policy.`, + `Provider ${providerID} is configured, but model ${modelID} is not in the verified off-peak pricing policy for this release. Update opencode-offpeak or add a verified custom policy before using this model.`, ); } return modelPolicies[0]; @@ -347,7 +421,7 @@ export function evaluatePricing( validatePolicy(policy); if (!policy.models.some((model) => matches(model, modelID))) { return { - state: "OFF_PEAK", + state: "FLAT", allowed: true, policy, reason: `${providerID}/${modelID} has flat all-hours pricing and no peak window.`, @@ -382,3 +456,36 @@ export function formatInTimeZone(epochMs: number, timeZone: string): string { timeZoneName: "short", }).format(epochMs); } + +/** + * Compact hold deadline such as `17:00 Asia/Bangkok`, with a local date prefix + * (`Oct 19, 01:00 Asia/Bangkok`) when the deadline falls on a different + * calendar day in that time zone than `nowMs` does. + */ +export function formatTransitionInTimeZone( + epochMs: number, + timeZone: string, + nowMs = Date.now(), +): string { + const time = new Intl.DateTimeFormat("en-GB", { + timeZone, + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).format(epochMs); + const day = (value: number) => + new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(value); + const label = `${time} ${timeZone}`; + if (day(epochMs) === day(nowMs)) return label; + const date = new Intl.DateTimeFormat("en-US", { + timeZone, + month: "short", + day: "numeric", + }).format(epochMs); + return `${date}, ${label}`; +} diff --git a/src/rpc.ts b/src/rpc.ts index 4fa4de0..8a0d46e 100644 --- a/src/rpc.ts +++ b/src/rpc.ts @@ -5,8 +5,10 @@ const object = { additionalProperties: true, } as const; +export const OFFPEAK_RPC_ID = "opencode-offpeak" as const; + export const OFFPEAK_RPC = { - id: "opencode-offpeak", + id: OFFPEAK_RPC_ID, methods: { control: { input: { @@ -25,9 +27,61 @@ export const OFFPEAK_RPC = { output: object, }, }, - events: {}, + /** + * Server-to-TUI notifications. They are published on OpenCode's event stream + * as `rpc.opencode-offpeak.` and rendered by the TUI entrypoint as + * non-chat toasts; they never touch the session, the model context, or + * headless output. + */ + events: { + hold: { + schema: { + type: "object", + properties: { + sessionID: { type: "string" }, + until: { type: "number" }, + timeZone: { type: "string" }, + display: { type: "string" }, + }, + required: ["sessionID"], + additionalProperties: false, + }, + }, + resume: { + schema: { + type: "object", + properties: { + sessionID: { type: "string" }, + kind: { type: "string" }, + }, + required: ["sessionID"], + additionalProperties: false, + }, + }, + }, } as const satisfies Rpc.PortableDefinition; +export type OffpeakEventName = keyof typeof OFFPEAK_RPC.events; + +/** Full event-stream type string, for example `rpc.opencode-offpeak.hold`. */ +export function offpeakEventType( + name: Name, +): `rpc.${typeof OFFPEAK_RPC_ID}.${Name}` { + return `rpc.${OFFPEAK_RPC_ID}.${name}`; +} + +export interface OffpeakHoldEvent { + sessionID: string; + until?: number; + timeZone?: string; + display?: string; +} + +export interface OffpeakResumeEvent { + sessionID: string; + kind?: "release" | "continuation"; +} + export interface OffpeakRpcInput { action: string; prompt?: string; diff --git a/src/server.ts b/src/server.ts index aaff3f8..41c6f7f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,7 +1,7 @@ import { Plugin } from "@opencode/plugin"; import { assertSupportedOpenCode } from "./compat.js"; import { ConfigRepository } from "./config.js"; -import { OffpeakEngine, type SessionDriver } from "./engine.js"; +import { type EngineNotification, OffpeakEngine, type SessionDriver } from "./engine.js"; import { OFFPEAK_RPC, type OffpeakRpcInput, type OffpeakRpcOutput } from "./rpc.js"; import { QueueRepository, RecoveryRepository } from "./store.js"; @@ -52,12 +52,21 @@ export default Plugin.define({ await context.session.interrupt({ sessionID }); }, }; + // Hold/resume notifications travel as plugin RPC events on OpenCode's own + // event stream; a TUI renders them as toasts, and headless runs simply have + // no subscriber. + let publishNotification: ((event: EngineNotification) => Promise) | undefined; const engine = new OffpeakEngine({ repository, recoveries, location: context.location?.directory ?? "", config, sessions, + notify: (event) => { + const publish = publishNotification; + if (!publish) return; + void publish(event).catch((error) => console.error("[opencode-offpeak]", error)); + }, onError: (error) => console.error("[opencode-offpeak]", error), }); const hooks = await Promise.all([ @@ -208,7 +217,23 @@ export default Plugin.define({ }); await engine.start(); + publishNotification = async (event) => { + if (event.type === "hold") { + await rpc.events?.emit?.("hold", { + sessionID: event.sessionID, + timeZone: event.timeZone, + ...(event.until !== undefined ? { until: event.until } : {}), + ...(event.display !== undefined ? { display: event.display } : {}), + }); + return; + } + await rpc.events?.emit?.("resume", { + sessionID: event.sessionID, + kind: event.kind, + }); + }; return async () => { + publishNotification = undefined; eventAbort.abort(); await events; await engine.dispose(); diff --git a/src/tui.ts b/src/tui.ts index afdd017..5aec2d5 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -1,7 +1,13 @@ import { Plugin } from "@opencode/plugin/tui"; import { assertSupportedOpenCode } from "./compat.js"; import { formatInTimeZone } from "./policy.js"; -import { OFFPEAK_RPC, type OffpeakRpcOutput } from "./rpc.js"; +import { + OFFPEAK_RPC, + type OffpeakHoldEvent, + type OffpeakResumeEvent, + type OffpeakRpcOutput, + offpeakEventType, +} from "./rpc.js"; export default Plugin.define({ id: "opencode-offpeak", @@ -36,6 +42,42 @@ export default Plugin.define({ }, ], })); + // Non-chat hold/resume feedback. These toasts come from the server plugin's + // event stream; they never enter the session or the model context, and a + // headless run simply has no subscriber. + const unsubscribe = [ + context.data.on(offpeakEventType("hold"), (event) => { + const data = event.data as OffpeakHoldEvent; + if (typeof data?.sessionID !== "string") return; + context.ui.toast.show({ + title: "OpenCode Offpeak", + message: + typeof data.display === "string" + ? `Peak pricing active — request held until ${data.display}. It will resume automatically in this session.` + : "Peak pricing active — the request is held until off-peak returns. It will resume automatically in this session.", + variant: "info", + duration: 15000, + sessionID: data.sessionID, + }); + }), + context.data.on(offpeakEventType("resume"), (event) => { + const data = event.data as OffpeakResumeEvent; + if (typeof data?.sessionID !== "string") return; + context.ui.toast.show({ + title: "OpenCode Offpeak", + message: + data.kind === "continuation" + ? "Off-peak pricing started. Continuing this session from where it stopped." + : "Off-peak pricing started. Resuming the held request in this session.", + variant: "success", + duration: 8000, + sessionID: data.sessionID, + }); + }), + ]; + return () => { + for (const off of unsubscribe) off(); + }; }, }); @@ -94,6 +136,7 @@ function summarize(output: OffpeakRpcOutput): string { pricingState?: string; requestsAllowed?: boolean; nextTransition?: number; + providerNote?: string; displayTimeZone?: string; queued?: number; waiting?: number; @@ -103,10 +146,23 @@ function summarize(output: OffpeakRpcOutput): string { failed?: number; configError?: string; }; + const pricing = status.pricingState ?? "UNKNOWN"; const next = status.nextTransition && status.displayTimeZone ? formatInTimeZone(status.nextTransition, status.displayTimeZone) - : "unknown"; + : pricing === "FLAT" || pricing === "UNTRACKED" + ? "none (no peak window)" + : "unknown"; + const requestDetail = + pricing === "FLAT" + ? " (flat all-hours pricing; never waits)" + : pricing === "UNTRACKED" + ? status.providerNote + ? ` (no policy needed: ${status.providerNote})` + : " (untracked provider; no policy installed)" + : pricing === "UNKNOWN" + ? " (no verified pricing policy for this model)" + : ""; return [ `Plugin: ${status.enabled ? "ENABLED" : "DISABLED"}`, `Mode: ${status.mode ?? "unknown"}`, @@ -114,9 +170,9 @@ function summarize(output: OffpeakRpcOutput): string { `Automatic resume: ${status.automaticResume ?? "PAUSED"}`, `Override: ${status.override?.active ? `ON for ${status.override.taskID} until ${status.override.expiresAt}` : "OFF"}`, `Provider/model: ${status.providerID ?? "unknown"}/${status.modelID ?? "unknown"}`, - `Policy: ${status.policyID ?? "none"}`, - `Pricing: ${status.pricingState ?? "UNKNOWN"}`, - `Requests: ${status.requestsAllowed ? "ALLOWED" : "BLOCKED"}${status.pricingState === "UNTRACKED" ? " (untracked policy)" : ""}`, + `Policy: ${status.policyID ?? (pricing === "UNKNOWN" ? "none (model not in any verified policy)" : "none")}`, + `Pricing: ${pricing}`, + `Requests: ${status.requestsAllowed ? "ALLOWED" : "BLOCKED"}${requestDetail}`, `Next transition: ${next}`, `Queue: ${status.queued ?? 0} queued, ${status.waiting ?? 0} waiting, ${status.failed ?? 0} failed`, status.interactiveWaiting diff --git a/test/engine.test.mjs b/test/engine.test.mjs index 2d84f0e..355d53f 100644 --- a/test/engine.test.mjs +++ b/test/engine.test.mjs @@ -6,6 +6,7 @@ import test from "node:test"; import { ConfigRepository, defaultConfig, + evaluatePricing, OffpeakEngine, PricingBlockedError, QueueRepository, @@ -138,6 +139,7 @@ async function harness(now = "2026-10-12T00:30:00Z", options = {}) { configPath: path.join(root, "offpeak.json"), ...(options.options ?? {}), }); + const notifications = options.notifications ?? []; const engine = new OffpeakEngine({ repository: new QueueRepository(storage), recoveries: new RecoveryRepository(storage), @@ -146,6 +148,7 @@ async function harness(now = "2026-10-12T00:30:00Z", options = {}) { sessions, clock, id: options.id ?? (() => `task-${Math.random().toString(16).slice(2, 8)}`), + notify: options.notify ?? ((event) => notifications.push(event)), }); await engine.start(); return { @@ -155,6 +158,7 @@ async function harness(now = "2026-10-12T00:30:00Z", options = {}) { sessions, config, engine, + notifications, async close() { await engine.dispose(); await rm(root, { recursive: true, force: true }); @@ -2040,3 +2044,376 @@ test("explicit holiday and flat lists on the built-in policy id are preserved on await rm(root, { recursive: true, force: true }); } }); + +test("a held primary interactive request announces exactly one hold and one release", async () => { + const clock = new ManualClock("2026-10-12T01:30:00Z"); + const h = await harness("2026-10-12T01:30:00Z", { + clock, + options: { displayTimeZone: "UTC" }, + }); + const sessionID = "notify-release"; + const hold = h.engine + .guardModelRequest({ sessionID, ...selectedModel, kind: "primary", transport: "model" }) + .then( + () => "resolved", + (error) => error, + ); + try { + await until(() => h.notifications.length === 1); + assert.deepEqual(h.notifications[0], { + type: "hold", + sessionID, + until: Date.parse("2026-10-12T04:00:00Z"), + timeZone: "UTC", + display: "04:00 UTC", + }); + // status still reports the held request while the notification is out + assert.equal((await h.engine.status(selectedModel)).interactiveWaiting, 1); + await clock.advanceTo("2026-10-12T04:00:00Z"); + assert.equal(await hold, "resolved"); + await until(() => h.notifications.length === 2); + assert.deepEqual(h.notifications[1], { type: "resume", sessionID, kind: "release" }); + // No duplicate release notification at the next window. + await clock.advanceTo("2026-10-12T07:00:00Z"); + await new Promise((resolve) => setTimeout(resolve, 25)); + assert.equal(h.notifications.length, 2); + } finally { + await h.close(); + } +}); + +test("auxiliary title, compaction, and generate holds never announce a notification", async () => { + const clock = new ManualClock("2026-10-12T01:30:00Z"); + const h = await harness("2026-10-12T01:30:00Z", { clock }); + const holds = ["notify-title", "notify-compaction", "notify-generate"].map((sessionID, index) => + h.engine + .guardModelRequest({ + sessionID, + ...selectedModel, + kind: ["title", "compaction", "generate"][index], + transport: "model", + }) + .then( + () => "resolved", + (error) => error, + ), + ); + try { + await until(async () => (await h.engine.status(selectedModel)).recoveryWaiting === 1); + assert.deepEqual(h.notifications, []); + // A title hold is released immediately (it has no peak window for this model + // and never waits); the others stay held until the boundary. + await clock.advanceTo("2026-10-12T04:00:00Z"); + await Promise.all(holds); + assert.deepEqual(h.notifications, []); + } finally { + await h.close(); + } +}); + +test("a user cancel produces no release notification; an inactivity recovery announces a continuation", async () => { + const clock = new ManualClock("2026-10-12T01:30:00Z"); + const h = await harness("2026-10-12T01:30:00Z", { + clock, + options: { displayTimeZone: "UTC" }, + }); + const cancelled = "notify-cancelled"; + const interrupted = "notify-interrupted"; + const holds = [cancelled, interrupted].map((sessionID) => + h.engine + .guardModelRequest({ sessionID, ...selectedModel, kind: "primary", transport: "model" }) + .then( + () => "resolved", + () => "blocked", + ), + ); + try { + await until(() => h.notifications.length === 2); + assert.deepEqual( + h.notifications.map((event) => event.type), + ["hold", "hold"], + ); + await h.engine.noteExecutionInterrupted({ sessionID: cancelled, reason: "user" }); + await h.engine.noteExecutionInterrupted({ sessionID: interrupted, reason: "inactivity" }); + assert.deepEqual(await Promise.all(holds), ["blocked", "blocked"]); + // The cancel never produces a resumption. + assert.equal(h.notifications.length, 2); + // The inactivity interrupt left a deferral: the boundary continuation + // announces exactly one continuation for that session. + h.sessions.outcomes.set(interrupted, "interrupted"); + h.sessions.behavior = async () => ({ outcome: "succeeded" }); + await clock.advanceTo("2026-10-12T04:00:00Z"); + await until(() => h.sessions.prompts.length === 1); + await until(() => h.notifications.length === 3); + assert.deepEqual(h.notifications[2], { + type: "resume", + sessionID: interrupted, + kind: "continuation", + }); + assert.equal((await h.engine.status(selectedModel)).recoveryWaiting, 0); + } finally { + await h.close(); + } +}); + +test("an off-peak interactive request and a queue-owned hold announce no notification", async () => { + // 2026-10-12 is a normal Monday: 01:30 UTC is inside the 01:00-04:00 peak + // window, 05:00 UTC is between the two windows. + const clock = new ManualClock("2026-10-12T01:30:00Z"); + const h = await harness("2026-10-12T01:30:00Z", { clock, options: { mode: "auto" } }); + try { + // A queue-owned session keeps its existing queue/status semantics only. + await h.storage.set("opencode-offpeak/queue-v1", { + schemaVersion: 1, + tasks: [ + { + id: "queue-notify-task", + prompt: "queued task", + cwd: "/project", + sessionID: "queue-notify-session", + model: selectedModel, + policyID: "deepseek-opencode-go", + state: "WAITING_OFFPEAK", + createdAt: "2026-10-12T00:00:00.000Z", + lastTransition: "2026-10-12T00:00:00.000Z", + retryCount: 0, + }, + ], + }); + const hold = h.engine + .guardModelRequest({ + sessionID: "queue-notify-session", + ...selectedModel, + kind: "primary", + transport: "model", + }) + .then( + () => "resolved", + () => "blocked", + ); + // Let the guard read its pricing decision while the clock is still inside peak. + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal((await h.engine.status(selectedModel)).waiting, 1); + assert.deepEqual(h.notifications, []); + await clock.advanceTo("2026-10-12T04:00:00Z"); + assert.equal(await hold, "resolved"); + assert.deepEqual(h.notifications, []); + + // Off-peak: an interactive request passes immediately with no hold and no notification. + clock.set("2026-10-12T05:00:00Z"); + await h.engine.guardModelRequest({ + sessionID: "notify-offpeak", + ...selectedModel, + kind: "primary", + transport: "model", + }); + assert.deepEqual(h.notifications, []); + } finally { + await h.close(); + } +}); + +test("an existing install storing only built-in copies gains newly shipped built-in policies", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "opencode-offpeak-policy-direct-")); + const configPath = path.join(root, "offpeak.json"); + const builtin = defaultConfig().policies[0]; + const storedGoCopy = structuredClone(builtin); + delete storedGoCopy.holidays; + delete storedGoCopy.flatModels; + storedGoCopy.verifiedAt = "2026-10-09"; + const custom = { + ...structuredClone(storedGoCopy), + id: "my-own-policy", + providers: ["my-provider"], + models: ["my-model"], + }; + try { + // Live shape: the file holds one unmodified copy of the shipped Go policy. + await writeFile( + configPath, + JSON.stringify({ + schemaVersion: 1, + enabled: true, + mode: "auto", + displayTimeZone: "Asia/Bangkok", + policies: [storedGoCopy], + }), + ); + const read = await new ConfigRepository({ configPath }).read(); + assert.equal(read.error, undefined); + assert.deepEqual( + read.config.policies.map((policy) => policy.id), + ["deepseek-opencode-go", "deepseek-api"], + ); + const [adoptedGo, adoptedDirect] = read.config.policies; + assert.deepEqual(adoptedGo.holidays, builtin.holidays); + assert.deepEqual(adoptedDirect.holidays, builtin.holidays); + assert.equal(adoptedDirect.source, "https://api-docs.deepseek.com/quick_start/pricing"); + assert.deepEqual(adoptedDirect.models, [ + "deepseek-flash", + "deepseek-v4-pro", + "deepseek-v4-flash", + "deepseek-v4-flash-vision-exp", + ]); + // Direct DeepSeek requests are now peak-guarded without editing the file. + const direct = evaluatePricing( + read.config.policies, + "deepseek", + "deepseek-flash", + Date.parse("2026-10-12T02:00:00Z"), + ); + assert.equal(direct.state, "PEAK"); + assert.equal(direct.policy?.id, "deepseek-api"); + + // A customized policy set stays exactly as written: nothing is added. + await writeFile( + configPath, + JSON.stringify({ + schemaVersion: 1, + enabled: true, + mode: "auto", + displayTimeZone: "Asia/Bangkok", + policies: [storedGoCopy, custom], + }), + ); + const customized = await new ConfigRepository({ configPath }).read(); + assert.equal(customized.error, undefined); + assert.deepEqual( + customized.config.policies.map((policy) => policy.id), + ["deepseek-opencode-go", "my-own-policy"], + ); + + // A modified copy of a built-in policy is not a cached default either. + const modified = { + ...structuredClone(builtin), + blocked: [{ days: [1], start: "22:00", end: "23:00" }], + }; + await writeFile( + configPath, + JSON.stringify({ + schemaVersion: 1, + enabled: true, + mode: "auto", + displayTimeZone: "Asia/Bangkok", + policies: [modified], + }), + ); + const kept = await new ConfigRepository({ configPath }).read(); + assert.equal(kept.error, undefined); + assert.deepEqual( + kept.config.policies.map((policy) => policy.id), + ["deepseek-opencode-go"], + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("status distinguishes flat models, untracked flat providers, and unknown models", async () => { + const h = await harness("2026-10-12T02:00:00Z", { options: { displayTimeZone: "Asia/Bangkok" } }); + try { + // A documented flat Go model: allowed, no wait, no transition to report. + const flat = await h.engine.status({ providerID: "opencode-go", modelID: "gpt-6-luna" }); + assert.equal(flat.pricingState, "FLAT"); + assert.equal(flat.requestsAllowed, true); + assert.equal(flat.nextTransition, undefined); + assert.equal(flat.policyID, "deepseek-opencode-go"); + + // The direct DeepSeek policy is active with its own id and boundaries. + const direct = await h.engine.status({ providerID: "deepseek", modelID: "deepseek-flash" }); + assert.equal(direct.pricingState, "PEAK"); + assert.equal(direct.requestsAllowed, false); + assert.equal(direct.policyID, "deepseek-api"); + assert.equal(direct.nextTransition, Date.parse("2026-10-12T04:00:00Z")); + const directOff = await h.engine.status({ providerID: "deepseek", modelID: "deepseek-v4-pro" }); + assert.equal(directOff.pricingState, "PEAK"); + const directAlias = await h.engine.status({ + providerID: "deepseek", + modelID: "deepseek-v4-flash-vision-exp", + }); + assert.equal(directAlias.pricingState, "PEAK"); + + // OpenCode Zen: no policy by design, but the status explains why. + const zen = await h.engine.status({ + providerID: "opencode", + modelID: "deepseek-v4.1-flash-free", + }); + assert.equal(zen.pricingState, "UNTRACKED"); + assert.equal(zen.requestsAllowed, true); + assert.match(zen.providerNote, /no peak\/off-peak tiers/); + const zenNew = await h.engine.status({ providerID: "opencode", modelID: "future-zen-model" }); + assert.equal(zenNew.pricingState, "UNTRACKED"); + assert.equal(zenNew.requestsAllowed, true); + assert.match(zenNew.providerNote, /never delayed/); + + // An unknown model under a configured provider fails closed with guidance. + const unknown = await h.engine.status({ providerID: "deepseek", modelID: "deepseek-future" }); + assert.equal(unknown.pricingState, "UNKNOWN"); + assert.equal(unknown.requestsAllowed, false); + await assert.rejects( + h.engine.guardModelRequest({ + sessionID: "direct-unknown", + providerID: "deepseek", + modelID: "deepseek-future", + kind: "primary", + transport: "model", + }), + (error) => { + assert.ok(error instanceof PricingBlockedError); + assert.match(error.message, /verified off-peak pricing policy/); + assert.match(error.message, /Update opencode-offpeak or add a verified custom policy/); + return true; + }, + ); + // No hold, no recovery, and no notification for an unknown model. + assert.equal((await h.engine.status(selectedModel)).recoveryWaiting, 0); + assert.deepEqual(h.notifications, []); + } finally { + await h.close(); + } +}); + +test("direct DeepSeek peak requests are held with one notification and released in the same session", async () => { + const clock = new ManualClock("2026-10-12T01:30:00Z"); + const h = await harness("2026-10-12T01:30:00Z", { + clock, + options: { displayTimeZone: "UTC" }, + }); + const sessionID = "direct-peak-session"; + const hold = h.engine + .guardModelRequest({ + sessionID, + providerID: "deepseek", + modelID: "deepseek-flash", + kind: "primary", + transport: "model", + }) + .then( + () => "resolved", + (error) => error, + ); + try { + await until(() => h.notifications.length === 1); + assert.deepEqual(h.notifications[0], { + type: "hold", + sessionID, + until: Date.parse("2026-10-12T04:00:00Z"), + timeZone: "UTC", + display: "04:00 UTC", + }); + const status = await h.engine.status({ providerID: "deepseek", modelID: "deepseek-flash" }); + assert.equal(status.interactiveWaiting, 1); + assert.equal(status.recoveryWaiting, 1); + await clock.advanceTo("2026-10-12T04:00:00Z"); + assert.equal(await hold, "resolved"); + await until(() => h.notifications.length === 2); + assert.deepEqual(h.notifications[1], { type: "resume", sessionID, kind: "release" }); + assert.equal( + (await h.engine.status({ providerID: "deepseek", modelID: "deepseek-flash" })) + .recoveryWaiting, + 0, + ); + } finally { + await h.close(); + } +}); diff --git a/test/plugin-entries.test.mjs b/test/plugin-entries.test.mjs index 1bec2de..bb5695d 100644 --- a/test/plugin-entries.test.mjs +++ b/test/plugin-entries.test.mjs @@ -30,6 +30,7 @@ test("OpenCode v2 server and TUI entrypoints register the request gate and /offp const hooks = new Map(); const sessionStore = new Map(); const toasts = []; + const emittedEvents = []; let rpcDefinition; let rpcHandlers; const eventQueue = []; @@ -109,7 +110,14 @@ test("OpenCode v2 server and TUI entrypoints register the request gate and /offp async register(definition, handlers) { rpcDefinition = definition; rpcHandlers = handlers; - return { dispose: async () => undefined }; + return { + dispose: async () => undefined, + events: { + async emit(name, data) { + emittedEvents.push({ name, data }); + }, + }, + }; }, }, }; @@ -118,6 +126,7 @@ test("OpenCode v2 server and TUI entrypoints register the request gate and /offp const heldOutcome = hooks .get("http.request")({ sessionID: "interactive-session", + kind: "primary", model: { providerID: "opencode-go", id: "deepseek-v4.1-flash" }, request: new Request("http://fixture.invalid"), }) @@ -127,6 +136,7 @@ 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"]); assert.ok(hooks.has("model.request")); assert.ok(hooks.has("http.request")); assert.ok(hooks.has("experimental.ws.handshake")); @@ -156,8 +166,14 @@ test("OpenCode v2 server and TUI entrypoints register the request gate and /offp assert.deepEqual(nonRetryable.decision, { retry: false }); // auto + ON + peak: the server entrypoint holds the interactive request at the - // boundary instead of discarding it. + // boundary instead of discarding it, and announces it once to the UI. await assertStillPending(heldOutcome, 60); + await new Promise((resolve) => setTimeout(resolve, 60)); + const holdEvents = emittedEvents.filter((event) => event.name === "hold"); + assert.equal(holdEvents.length, 1); + assert.equal(holdEvents[0].data.sessionID, "interactive-session"); + assert.equal(typeof holdEvents[0].data.display, "string"); + assert.equal(typeof holdEvents[0].data.until, "number"); // A primary pre-model request is deferred before any provider attempt exists and // persists a deferral entry so a reload can continue the same session. @@ -255,8 +271,15 @@ test("OpenCode v2 server and TUI entrypoints register the request gate and /offp assert.equal(status.status.recoveryWaiting, 2); let layer; + const eventHandlers = new Map(); const tuiContext = { app: { name: "opencode", version: "2.0.26", channel: "latest" }, + data: { + on(type, handler) { + eventHandlers.set(type, handler); + return () => eventHandlers.delete(type); + }, + }, keymap: { layer: (factory) => (layer = factory()) }, client: { rpc: (definition) => { @@ -270,7 +293,32 @@ test("OpenCode v2 server and TUI entrypoints register the request gate and /offp toast: { show: (options) => toasts.push(options) }, }, }; - tuiPlugin.setup(tuiContext); + const disposeTui = tuiPlugin.setup(tuiContext); + assert.deepEqual([...eventHandlers.keys()].sort(), [ + "rpc.opencode-offpeak.hold", + "rpc.opencode-offpeak.resume", + ]); + // The TUI renders the server's notifications as non-chat toasts. + eventHandlers.get("rpc.opencode-offpeak.hold")({ + type: "rpc.opencode-offpeak.hold", + data: { sessionID: "interactive-session", display: "04:00 UTC", until: 1, timeZone: "UTC" }, + }); + assert.match(toasts.at(-1).message, /request held until 04:00 UTC/); + assert.equal(toasts.at(-1).sessionID, "interactive-session"); + eventHandlers.get("rpc.opencode-offpeak.hold")({ type: "rpc.opencode-offpeak.hold", data: {} }); + assert.equal(toasts.length, 1); + eventHandlers.get("rpc.opencode-offpeak.resume")({ + type: "rpc.opencode-offpeak.resume", + data: { sessionID: "interactive-session", kind: "continuation" }, + }); + assert.match(toasts.at(-1).message, /Continuing this session/); + eventHandlers.get("rpc.opencode-offpeak.resume")({ + type: "rpc.opencode-offpeak.resume", + data: { sessionID: "interactive-session", kind: "release" }, + }); + assert.match(toasts.at(-1).message, /Resuming the held request/); + await disposeTui(); + assert.equal(eventHandlers.size, 0); assert.equal(layer.mode, "global"); const command = layer.commands.find((entry) => entry.slash?.name === "offpeak"); assert.ok(command); diff --git a/test/policy.test.mjs b/test/policy.test.mjs index 39f4b35..e3112e1 100644 --- a/test/policy.test.mjs +++ b/test/policy.test.mjs @@ -1,6 +1,16 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { DEEPSEEK_OPENCODE_GO, evaluatePricing, formatInTimeZone } from "../dist/policy.js"; +import { CHINA_PUBLIC_HOLIDAYS } from "../dist/holidays.js"; +import { + BUILTIN_POLICIES, + DEEPSEEK_API, + DEEPSEEK_OPENCODE_GO, + evaluatePricing, + flatProviderNote, + formatInTimeZone, + formatTransitionInTimeZone, + VERIFIED_FLAT_PROVIDERS, +} from "../dist/policy.js"; const model = "deepseek-v4.1-flash"; const provider = "opencode-go"; @@ -89,7 +99,7 @@ test("unknown models for a configured provider fail closed; unrelated providers assert.equal(unrelated.allowed, true); }); -test("documented flat models have no peak window and stay allowed during DeepSeek peak hours", () => { +test("documented flat models are FLAT with no peak window and stay allowed during DeepSeek peak hours", () => { // The Go pricing table lists peak/off-peak tiers only for the DeepSeek models. for (const modelID of [ "gpt-6-luna", @@ -119,14 +129,14 @@ test("documented flat models have no peak window and stay allowed during DeepSee modelID, at("2026-10-12T02:00:00Z"), ); - assert.equal(decision.state, "OFF_PEAK", modelID); + assert.equal(decision.state, "FLAT", modelID); assert.equal(decision.allowed, true, modelID); assert.equal(decision.policy?.id, "deepseek-opencode-go", modelID); assert.equal(decision.nextTransition, undefined, modelID); // The same flat model is never blocked at any hour, including peak edges. assert.equal( evaluatePricing([DEEPSEEK_OPENCODE_GO], provider, modelID, at("2026-10-12T06:00:00Z")).state, - "OFF_PEAK", + "FLAT", modelID, ); } @@ -160,7 +170,7 @@ test("a model matching both the peak and flat patterns stays peak-priced", () => ); assert.equal( evaluatePricing([policy], provider, "deepseek-other", at("2026-10-12T02:00:00Z")).state, - "OFF_PEAK", + "FLAT", ); }); @@ -366,3 +376,167 @@ test("an empty holiday list keeps the weekly schedule untouched", () => { "OFF_PEAK", ); }); + +// --------------------------------------------------------------------------- +// Direct DeepSeek API provider (`deepseek`) — v0.1.6 +// --------------------------------------------------------------------------- + +const directAt = (iso, modelID) => evaluatePricing([DEEPSEEK_API], "deepseek", modelID, at(iso)); + +test("direct DeepSeek API models use the same verified weekly windows and holiday rule", () => { + for (const modelID of [ + "deepseek-flash", + "deepseek-v4-pro", + // Legacy aliases the provider still accepts and bills at the Flash price. + "deepseek-v4-flash", + "deepseek-v4-flash-vision-exp", + ]) { + assert.equal(directAt("2026-10-12T00:59:59Z", modelID).state, "OFF_PEAK", modelID); + assert.equal(directAt("2026-10-12T01:00:00Z", modelID).state, "PEAK", modelID); + assert.equal(directAt("2026-10-12T03:59:59Z", modelID).state, "PEAK", modelID); + assert.equal(directAt("2026-10-12T04:00:00Z", modelID).state, "OFF_PEAK", modelID); + assert.equal(directAt("2026-10-12T05:59:59Z", modelID).state, "OFF_PEAK", modelID); + assert.equal(directAt("2026-10-12T06:00:00Z", modelID).state, "PEAK", modelID); + assert.equal(directAt("2026-10-12T09:59:59Z", modelID).state, "PEAK", modelID); + assert.equal(directAt("2026-10-12T10:00:00Z", modelID).state, "OFF_PEAK", modelID); + // Weekends are off-peak even inside the nominal window. + assert.equal(directAt("2026-10-10T02:00:00Z", modelID).state, "OFF_PEAK", modelID); + // Chinese public holidays are full-day off-peak (2026-10-01, National Day). + assert.equal(directAt("2026-10-01T02:00:00Z", modelID).state, "OFF_PEAK", modelID); + assert.equal(directAt("2026-10-01T08:00:00Z", modelID).state, "OFF_PEAK", modelID); + // The next transition is reported for peak windows. + assert.equal( + directAt("2026-10-12T02:00:00Z", modelID).nextTransition, + at("2026-10-12T04:00:00Z"), + ); + } +}); + +test("the direct DeepSeek policy reuses the shared Chinese holiday calendar data", () => { + assert.equal(DEEPSEEK_API.holidays, CHINA_PUBLIC_HOLIDAYS); + assert.equal(DEEPSEEK_API.timeZone, "UTC"); + assert.equal(DEEPSEEK_API.source, "https://api-docs.deepseek.com/quick_start/pricing"); + assert.equal(DEEPSEEK_API.verifiedAt, "2026-10-15"); + // The shared weekly windows are identical to the Go policy: both providers + // document 01:00-04:00 and 06:00-10:00 UTC, Monday through Friday. + assert.deepEqual(DEEPSEEK_API.blocked, DEEPSEEK_OPENCODE_GO.blocked); + assert.deepEqual(DEEPSEEK_API.holidays, DEEPSEEK_OPENCODE_GO.holidays); +}); + +test("an unknown direct DeepSeek model fails closed with an actionable reason", () => { + const decision = directAt("2026-10-12T05:00:00Z", "deepseek-future-ultra"); + assert.equal(decision.state, "UNKNOWN"); + assert.equal(decision.allowed, false); + assert.match(decision.reason, /not in the verified off-peak pricing policy/); + assert.match(decision.reason, /Update opencode-offpeak or add a verified custom policy/); + // No wildcard: a name that merely starts with deepseek- never inherits the schedule. + assert.equal(directAt("2026-10-12T02:00:00Z", "deepseek-anything").allowed, false); + // The Go policy never covers the direct provider and vice versa. + assert.equal( + evaluatePricing( + [DEEPSEEK_OPENCODE_GO], + "deepseek", + "deepseek-flash", + at("2026-10-12T05:00:00Z"), + ).state, + "UNTRACKED", + ); + assert.equal( + evaluatePricing( + [DEEPSEEK_API], + "opencode-go", + "deepseek-v4.1-flash", + at("2026-10-12T05:00:00Z"), + ).state, + "UNTRACKED", + ); +}); + +test("both built-in policies ship together and cover their own providers", () => { + assert.deepEqual( + BUILTIN_POLICIES.map((policy) => policy.id), + ["deepseek-opencode-go", "deepseek-api"], + ); + const go = evaluatePricing( + BUILTIN_POLICIES, + "opencode-go", + "deepseek-v4.1-flash", + at("2026-10-12T02:00:00Z"), + ); + assert.equal(go.state, "PEAK"); + assert.equal(go.policy?.id, "deepseek-opencode-go"); + const direct = evaluatePricing( + BUILTIN_POLICIES, + "deepseek", + "deepseek-flash", + at("2026-10-12T02:00:00Z"), + ); + assert.equal(direct.state, "PEAK"); + assert.equal(direct.policy?.id, "deepseek-api"); +}); + +// --------------------------------------------------------------------------- +// OpenCode Zen (`opencode`) — verified flat pricing, no policy by design +// --------------------------------------------------------------------------- + +test("OpenCode Zen has no time-based policy: every model passes through untouched", () => { + for (const modelID of [ + "deepseek-v4.1-flash-free", + "big-pickle", + "cat-jam-free", + "nemotron-3-ultra-free", + "a-brand-new-zen-model", + ]) { + const decision = evaluatePricing( + BUILTIN_POLICIES, + "opencode", + modelID, + at("2026-10-12T02:00:00Z"), + ); + assert.equal(decision.state, "UNTRACKED", modelID); + assert.equal(decision.allowed, true, modelID); + assert.equal(decision.nextTransition, undefined, modelID); + assert.equal(decision.policy, undefined, modelID); + } + assert.match(flatProviderNote("opencode"), /no peak\/off-peak tiers/); + assert.match(flatProviderNote("OPENCODE"), /never delayed/); + assert.equal(flatProviderNote("openrouter"), undefined); + assert.equal(VERIFIED_FLAT_PROVIDERS.opencode.source, "https://opencode.ai/docs/zen/"); + assert.equal(VERIFIED_FLAT_PROVIDERS.opencode.verifiedAt, "2026-10-15"); + // The evidence behind that fact: a DeepSeek id used on Zen is still flat, and + // the DeepSeek policies above never apply to the `opencode` provider. + assert.equal( + evaluatePricing(BUILTIN_POLICIES, "opencode", "deepseek-v4-pro", at("2026-10-12T02:00:00Z")) + .state, + "UNTRACKED", + ); +}); + +test("formatTransitionInTimeZone renders the configured display zone compactly", () => { + // Same calendar day in the display zone: clock + zone name only. + assert.equal( + formatTransitionInTimeZone(at("2026-10-15T16:00:00Z"), "UTC", at("2026-10-15T12:00:00Z")), + "16:00 UTC", + ); + assert.equal( + formatTransitionInTimeZone( + at("2026-10-15T16:00:00Z"), + "Asia/Bangkok", + at("2026-10-15T12:00:00Z"), + ), + "23:00 Asia/Bangkok", + ); + // A different calendar day in the display zone adds a local date prefix. + assert.equal( + formatTransitionInTimeZone(at("2026-10-16T01:00:00Z"), "UTC", at("2026-10-15T12:00:00Z")), + "Oct 16, 01:00 UTC", + ); + assert.equal( + formatTransitionInTimeZone( + at("2026-10-16T01:00:00Z"), + "Asia/Bangkok", + at("2026-10-15T12:00:00Z"), + ), + "Oct 16, 08:00 Asia/Bangkok", + ); +});