Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
10 changes: 10 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading