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 +21 -0
- package/README.md +291 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +19051 -0
- package/dist/src/balance.d.ts +6 -0
- package/dist/src/config.d.ts +35 -0
- package/dist/src/cost.d.ts +54 -0
- package/dist/src/engine.d.ts +39 -0
- package/dist/src/mode.d.ts +13 -0
- package/dist/src/provider/override.d.ts +7 -0
- package/dist/src/provider/scheduler-provider.d.ts +27 -0
- package/dist/src/scheduler.d.ts +15 -0
- package/dist/src/switch-model.d.ts +18 -0
- package/dist/src/tui-plugin.d.ts +5 -0
- package/dist/src/tui-sidebar.d.ts +6 -0
- package/dist/tui.d.ts +6 -0
- package/dist/tui.js +4562 -0
- package/package.json +44 -0
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 (BEP-20):** `0x1Df93A331CF8D5bE9d382B6d55fe227D6489B2a2` |
|
|
287
|
+
|  | **Tron (TRC-20):** `TDfatR9JWqcfx5VuTMHDLQP3BN6f8Ynk5r` |
|
|
288
|
+
|
|
289
|
+
## License
|
|
290
|
+
|
|
291
|
+
MIT — see `LICENSE`.
|