opencode-peak-scheduler 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,291 @@
1
+ # opencode-peak-scheduler
2
+
3
+ OpenCode plugin that automatically switches the DeepSeek model OpenCode uses
4
+ based on Beijing **peak-pricing windows**: during peak hours a cheaper/faster
5
+ model is selected, off-peak the full model is restored.
6
+
7
+ Built and verified against the **anomalyco/opencode fork (~1.18.x)** — the
8
+ plugin API and the session-model lever were confirmed against that source (see
9
+ `spike/README.md`).
10
+
11
+ ## Schedule (Asia/Shanghai, UTC+8)
12
+
13
+ | Window | Model | When |
14
+ | -------- | ----------------------------- | ------------------------------------------ |
15
+ | Peak | `deepseek/deepseek-v4-flash` | Mon–Fri 09:00–12:00 and 14:00–18:00 |
16
+ | Off-peak | `deepseek/deepseek-v4-pro` | Everything else + weekends (Sat & Sun) |
17
+
18
+ Half-open intervals `[start, end)`. Weekday means Monday–Friday in the
19
+ **Asia/Shanghai** calendar, regardless of the host machine's timezone.
20
+
21
+ ## How it works
22
+
23
+ Two parts cooperate:
24
+
25
+ ### Provider-level routing — the real lever
26
+
27
+ The `deepseek` provider in `opencode.json` is repointed from
28
+ `@ai-sdk/openai-compatible` to this package. `createSchedulerProvider`
29
+ (exported from the package entry) **wraps** the openai-compatible provider and
30
+ adds one pseudo-model, `auto`, which becomes the default model:
31
+
32
+ ```jsonc
33
+ {
34
+ "$schema": "https://opencode.ai/config.json",
35
+ "model": "deepseek/auto",
36
+ "provider": {
37
+ "deepseek": {
38
+ "npm": "file:///home/mukminov/src/opencode-peak-scheduler",
39
+ "name": "DeepSeek",
40
+ "options": {
41
+ "baseURL": "https://api.deepseek.com",
42
+ "apiKey": "…",
43
+ "scheduler": {
44
+ "peakModel": "deepseek-v4-flash",
45
+ "offModel": "deepseek-v4-pro",
46
+ "timezone": "Asia/Shanghai",
47
+ "autoID": "auto"
48
+ }
49
+ },
50
+ "models": {
51
+ "deepseek-v4-pro": { "name": "DeepSeek-V4-Pro" },
52
+ "deepseek-v4-flash": { "name": "deepseek-v4-flash" },
53
+ "auto": {
54
+ "name": "Auto (peak-aware)",
55
+ "limit": { "context": 1048576, "output": 393216 }
56
+ }
57
+ }
58
+ }
59
+ }
60
+ }
61
+ ```
62
+
63
+ When opencode requests `deepseek/auto`, the factory returns a
64
+ `TimeRoutedLanguageModel`: on every `doGenerate`/`doStream` it runs the pure
65
+ `resolveModel(date)` scheduler against the Beijing window and delegates to the
66
+ real inner model for that moment. Any other model id (e.g.
67
+ `deepseek/deepseek-v4-pro`) passes straight through untouched.
68
+
69
+ The `npm` value must be a `file:///` URL (three slashes: `file://` +
70
+ absolute path). The fork's `resolveSDK` only takes its direct-import branch
71
+ for specs starting with `file://`; anything else (e.g. `file:/…` with one
72
+ slash) is treated as an npm package and goes through `Npm.add` → cross-process
73
+ flock + arborist reify into a shared cache directory. That path is slow and
74
+ fragile (shared mutable state across every opencode instance using the same
75
+ config) and fails with `Failed to initialize provider: deepseek` when the
76
+ reify/entrypoint resolution hiccups. The `file:///` form imports the package
77
+ directory directly and skips all of that.
78
+
79
+ ### TUI plugin — informational only
80
+
81
+ The TUI plugin (`tui.ts`) keeps watching the clock and switching the session
82
+ **row** model (`POST /api/session/:id/model`) at peak↔off-peak transitions.
83
+ That switch is **informational**: the TUI always sends its own model in the
84
+ message body and `input.model` is authoritative for the whole turn, so actual
85
+ call routing happens in the provider (`auto`), not in the row. The row switch
86
+ only steers model-less turns, and the plugin surfaces window transitions as a
87
+ toast plus the switch's own chat line, adds palette entries, and shows a live
88
+ "Model" block at the top of the right sidebar (window + resolved model).
89
+ A palette entry **Peak Scheduler: Toggle Mode** switches between the two global
90
+ modes. **Auto** keeps the session on `auto` (window-routed, approximate cost).
91
+ **Manual** stops all switching so you can pick the real model yourself (accurate
92
+ cost); picking any non-`auto` model in the TUI auto-pauses switching, and
93
+ picking `auto` (or the toggle) resumes it. The sidebar `Model` block shows the
94
+ mode badge plus the window and resolved model (or a hint in manual mode).
95
+
96
+ In **auto** mode the sidebar `Model` block also shows a `Cost:` line — an
97
+ estimate the plugin computes itself (opencode prices the `auto` model at $0).
98
+ It prices the session's token deltas with the resolved model's `cost`, applying
99
+ a 2× peak multiplier during peak hours (DeepSeek bills off-peak at half the peak
100
+ rate). Configure the **off-peak** per-1M-token prices on the model entries in
101
+ `opencode.json` (see the live table at
102
+ `https://api-docs.deepseek.com/quick_start/pricing/`):
103
+
104
+ "deepseek-v4-pro": { "cost": { "input": 0.66, "output": 1.98, "cache": { "read": 0.022, "write": 0 } } },
105
+ "deepseek-v4-flash": { "cost": { "input": 0.22, "output": 0.66, "cache": { "read": 0.007, "write": 0 } } }
106
+
107
+ `input` is the cache-miss price, `cache.read` the cache-hit price, and
108
+ `cache.write` is 0 (DeepSeek doesn't charge for cache writes). DeepSeek's peak
109
+ hours (`01:00–04:00` and `06:00–10:00` UTC) are exactly the plugin's peak
110
+ windows, so during peak the plugin routes to `deepseek-v4-flash` at 2× and
111
+ off-peak to `deepseek-v4-pro` at 1×. The estimate is accurate to the 30s check
112
+ interval; in manual mode opencode prices the real model itself, so the plugin
113
+ hides the line.
114
+
115
+ ### Cost — disclaimer
116
+
117
+ **The `Cost:` line is an estimate, not your bill.** opencode prices a turn from
118
+ the model id in the request; under `auto` that id has no `cost`, so opencode's
119
+ own `Context` block always shows `$0.00` in auto mode. The plugin works around
120
+ this by computing its own estimate from the session's token deltas and the
121
+ resolved model's `cost` (with the 2× peak multiplier). This is a workaround
122
+ with known gaps:
123
+
124
+ - It is **per-session** and sampled every 30s — tokens at peak↔off-peak and
125
+ auto↔manual boundaries may be attributed to the neighbouring window.
126
+ - On the first observation of a session (e.g. right after a restart) its
127
+ existing tokens are priced at the **current** window's model, so the estimate
128
+ is approximate for sessions that span multiple windows.
129
+ - It assumes a **flat 2×** peak multiplier and `cache.write = 0`; if DeepSeek
130
+ changes its pricing or cache-billing model, the estimate drifts.
131
+ - It is **not authoritative** — your actual spend is what DeepSeek bills
132
+ (`platform.deepseek.com`). For exact per-turn cost, switch to **manual** mode:
133
+ there opencode prices the real model with its `cost` directly.
134
+
135
+ ### Balance
136
+
137
+ The sidebar also shows a `Balance:` line — the account's current DeepSeek
138
+ balance, fetched via `GET /user/balance` (see
139
+ `https://api-docs.deepseek.com/api/get-user-balance`) using the provider's
140
+ `apiKey`. The balance is **account-wide** (one balance for both
141
+ `deepseek-v4-pro` and `deepseek-v4-flash`), refreshed on startup and then every
142
+ 5 minutes. The line is hidden when no API key is configured or the request
143
+ fails.
144
+
145
+ ### Cache
146
+
147
+ With the `file:///` npm spec the provider package is imported directly from
148
+ the source directory — no npm snapshot is taken, and rebuilds are picked up by
149
+ restarting OpenCode. If you ever use a package-style spec instead, OpenCode
150
+ snapshots the provider into its npm cache (`Npm.add`) at startup and does
151
+ **not** refresh it on rebuild; in that case clear the snapshot before
152
+ restarting:
153
+
154
+ ```bash
155
+ rm -rf "$HOME/.cache/opencode/packages/file:/home/mukminov/src/opencode-peak-scheduler"
156
+ ```
157
+
158
+ ## Install
159
+
160
+ 1. `bun run build`
161
+ 2. Point the `deepseek` provider at the package and add the `auto` model +
162
+ top-level `model` key (snippet above).
163
+ 3. Register the TUI plugin in the **TUI config** (`tui.json`), not in
164
+ `opencode.json`. The fork's TUI plugin host loads plugins from
165
+ `TuiConfig.pluginOrigins()`, which only reads the `plugin` array out of
166
+ `tui.json` files — the `plugin` array in `opencode.json` feeds the server
167
+ process only, so a TUI plugin declared there is never loaded (no toasts, no
168
+ palette commands):
169
+
170
+ ```jsonc
171
+ // ~/.config/opencode/tui.json
172
+ {
173
+ "$schema": "https://opencode.ai/tui.json",
174
+ "plugin": [
175
+ ["/home/mukminov/src/opencode-peak-scheduler", { "peakModel": "deepseek-v4-flash" }],
176
+ "/home/mukminov/src/opencode-git-footer"
177
+ ]
178
+ }
179
+ ```
180
+
181
+ The custom sidebar footer (path + git branch + dirty indicator) lives in a
182
+ separate package, `opencode-git-footer`; this package only ships the
183
+ peak/off-peak routing and its TUI status UI.
184
+
185
+ 4. Restart OpenCode (the `file:///` import is resolved once at startup).
186
+
187
+ ## Options
188
+
189
+ Two config surfaces, both validated with zod (unknown keys rejected).
190
+
191
+ Plugin options (`plugin` tuple):
192
+
193
+ | Key | Type | Default | Meaning |
194
+ | ----------------- | -------- | ------------------------ | ---------------------------------------- |
195
+ | `providerID` | `string` | `"deepseek"` | Provider part of the target model |
196
+ | `peakModel` | `string` | `"deepseek-v4-flash"` | Model used during peak hours |
197
+ | `offModel` | `string` | `"deepseek-v4-pro"` | Model used off-peak |
198
+ | `timezone` | `string` | `"Asia/Shanghai"` | IANA timezone the schedule runs in |
199
+ | `autoID` | `string` | `"auto"` | Pseudo-model id routed by the window |
200
+ | `checkIntervalMs` | `number` | `30000` | How often the period is re-checked |
201
+
202
+ Provider `scheduler` options (inside `provider.deepseek.options.scheduler`):
203
+
204
+ | Key | Type | Default | Meaning |
205
+ | ----------- | -------- | --------------------- | ------------------------------------ |
206
+ | `peakModel` | `string` | `"deepseek-v4-flash"` | Model used during peak hours |
207
+ | `offModel` | `string` | `"deepseek-v4-pro"` | Model used off-peak |
208
+ | `timezone` | `string` | `"Asia/Shanghai"` | IANA timezone the schedule runs in |
209
+ | `autoID` | `string` | `"auto"` | Pseudo-model id routed by the window |
210
+
211
+ An invalid `timezone` falls back to `Asia/Shanghai` with a warning.
212
+
213
+ ## Commands
214
+
215
+ | Palette entry | What it shows |
216
+ | -------------------------- | ---------------------------------------- |
217
+ | **Peak Scheduler: Status** | Current window, resolved model, session |
218
+ | **Peak Scheduler: Window Now** | Model that applies right now |
219
+
220
+ Notes:
221
+
222
+ - These are **command-palette** entries (`api.command.register`), not `/peak`
223
+ slash commands. The fork has no server hook that answers a slash command
224
+ without an LLM turn (`command.execute.before` only mutates the prompt parts
225
+ and still calls the model), so a cheap status UI lives in the palette.
226
+ - Peak/off transitions surface as a toast, and the session-row switch itself
227
+ also shows a "Model switched" line in the chat.
228
+
229
+ ## Limitations (honest)
230
+
231
+ - **Explicit model choices are always respected.** Picking a concrete model
232
+ (e.g. `deepseek-v4-flash` during off-peak) bypasses the scheduler entirely —
233
+ only `auto` is routed by the window, and `input.model` wins per turn.
234
+ - **The provider package must be reachable at that exact `file:///` path.** The
235
+ import is resolved at startup; moving or deleting the source directory while
236
+ OpenCode is running does not affect the loaded copy, and a fresh start will
237
+ fail with `Failed to initialize provider: deepseek` until the path exists
238
+ again.
239
+ - **Interactive turns are not rewritten.** When you type a message, the TUI
240
+ sends your selected model in the message body and it is authoritative for that
241
+ whole turn. The plugin changes the **session row model**, which steers
242
+ *subsequent model-less turns* (autonomous/agentic subflows, compactions,
243
+ summaries) — never an already-in-flight or already-typed message.
244
+ - **Sessions with an explicit model are respected.** A session that was created
245
+ with a provider other than `providerID`, or that already carries an explicit
246
+ model, is left untouched (the plugin cannot tell intent; it only acts on
247
+ model-less sessions and on transitions).
248
+ - **Requires a running TUI** for the informational row switches, toasts, and
249
+ status UI. A headless `opencode run`/`serve` without the TUI still gets
250
+ provider-level routing for `auto`, but no session-row switching or status UI.
251
+ - **Per-model `options` are not applied to `auto`-routed calls.** OpenCode
252
+ computes provider options from the `auto` model entry (which has none), so
253
+ `options` on the `deepseek-v4-pro`/`deepseek-v4-flash` entries (e.g.
254
+ `reasoningEffort`) are bypassed for calls routed via `auto`. Put shared
255
+ options at provider level (`provider.deepseek.options`) or on the `auto`
256
+ entry instead.
257
+ - **Cost under `auto` is a plugin-computed estimate.** opencode prices a turn
258
+ from the model id in the request, so under `auto` its own `$0.00` is expected.
259
+ The plugin's `Cost:` line estimates it from the resolved model's `cost` and
260
+ the 2× peak multiplier (accurate to the 30s tick). Manual mode uses the real
261
+ model, so opencode's own pricing is exact there.
262
+
263
+ ## Development
264
+
265
+ ```bash
266
+ bun install
267
+ bun run verify # bun test && tsc --noEmit
268
+ bun run build # bun build index.ts tui.ts + .d.ts into dist/
269
+ ```
270
+
271
+ Layout: `src/scheduler.ts` (pure period resolution),
272
+ `src/provider/` (`createSchedulerProvider` wrapper + `TimeRoutedLanguageModel`),
273
+ `src/engine.ts` (transition logic — fully unit-tested), `src/switch-model.ts`
274
+ (V2 switch call), `src/tui-plugin.ts` (TUI glue), `tui.ts` (TUI module entry),
275
+ `index.ts` (exports `createSchedulerProvider`).
276
+
277
+ After a rebuild, clear the provider package cache (see above) before
278
+ re-verifying live routing.
279
+
280
+ ## Donate
281
+
282
+ If this plugin saves you money, you can say thanks:
283
+
284
+ | QR | Address |
285
+ | --- | --- |
286
+ | ![BNB Smart Chain](assets/bsc.png) | **BNB Smart Chain (BEP-20):** `0x1Df93A331CF8D5bE9d382B6d55fe227D6489B2a2` |
287
+ | ![Tron](assets/tron.png) | **Tron (TRC-20):** `TDfatR9JWqcfx5VuTMHDLQP3BN6f8Ynk5r` |
288
+
289
+ ## License
290
+
291
+ MIT — see `LICENSE`.
@@ -0,0 +1,5 @@
1
+ import type { PluginModule } from "@opencode-ai/plugin";
2
+ import { createSchedulerProvider } from "./src/provider/scheduler-provider";
3
+ export { createSchedulerProvider };
4
+ declare const plugin: PluginModule;
5
+ export default plugin;