@s-berdia/opencode-router 1.1.0 → 1.2.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/README.md +36 -11
- package/USAGE.md +73 -19
- package/dist/bin.js +116 -44
- package/dist/bin.js.map +1 -1
- package/dist/index.js +111 -35
- package/dist/index.js.map +1 -1
- package/package.json +9 -4
package/README.md
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
# opencode-router
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@s-berdia/opencode-router)
|
|
4
|
+
[](https://www.npmjs.com/package/@s-berdia/opencode-router)
|
|
5
|
+
[](https://github.com/s-berdia/Opencode_router/actions/workflows/ci.yml)
|
|
6
|
+
[](./LICENSE)
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
**Switch models before you hit your [OpenCode](https://opencode.ai) limit.** Automatic
|
|
9
|
+
model routing and quota failover for the **Go**, **Go Plus**, and **Free** plans.
|
|
10
|
+
|
|
11
|
+

|
|
6
12
|
|
|
7
13
|
[Watch the demo in full quality (MP4, 25 seconds)](docs/router-demo.mp4?raw=true)
|
|
8
14
|
|
|
9
15
|
opencode-router gives each OpenCode agent the best model for its job and for your plan.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
16
|
+
All Go models share one usage allowance, and the router estimates how much of it you
|
|
17
|
+
used. When the allowance is near its limit, the router moves the agents to free models,
|
|
18
|
+
so your work does not stop. When the use falls again, the router moves them back.
|
|
13
19
|
|
|
14
20
|
For full step-by-step instructions, read the [usage guide](./USAGE.md). Before you use
|
|
15
21
|
the router, read its [known limits](./USAGE.md#11-known-limits).
|
|
@@ -23,8 +29,10 @@ the router, read its [known limits](./USAGE.md#11-known-limits).
|
|
|
23
29
|
monthly allowance of each model to calculate how many tokens the model can supply in
|
|
24
30
|
5 hours.
|
|
25
31
|
- **Automatic failover.** The `plan`, `build`, `general`, and `explore` agents each have a
|
|
26
|
-
ranked list of up to 4 models.
|
|
27
|
-
|
|
32
|
+
ranked list of up to 4 models. On Go and Go Plus, the last one is a free model. At 85%
|
|
33
|
+
of the shared Go allowance, or after a usage-limit error, the agents move to their free
|
|
34
|
+
model. After a rate-limit or overload error of one model, the agent moves to the next
|
|
35
|
+
model in its list.
|
|
28
36
|
- **Live apply.** The router changes the models in the running OpenCode session. You do
|
|
29
37
|
not have to restart OpenCode after a refresh.
|
|
30
38
|
- **Clear results.** A scorecard shows each model and its scores. With `--verbose`, it
|
|
@@ -41,6 +49,15 @@ the router, read its [known limits](./USAGE.md#11-known-limits).
|
|
|
41
49
|
|
|
42
50
|
## Quick start
|
|
43
51
|
|
|
52
|
+
If you have the OpenCode command-line tool, run this command. Then close OpenCode, start
|
|
53
|
+
it again, and go to step 4.
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
opencode plugin @s-berdia/opencode-router --global
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
If you do not have the command-line tool, edit the config file:
|
|
60
|
+
|
|
44
61
|
1. Open your global OpenCode config file in a text editor:
|
|
45
62
|
- Windows: `C:\Users\<your-user-name>\.config\opencode\opencode.json`
|
|
46
63
|
- macOS and Linux: `~/.config/opencode/opencode.json`
|
|
@@ -77,7 +94,7 @@ See [Update the router](./USAGE.md#12-update-the-router).
|
|
|
77
94
|
| Command | What it does |
|
|
78
95
|
| :-- | :-- |
|
|
79
96
|
| `/refresh-routing` | Calculate the routing table again, apply it, and show the scorecard |
|
|
80
|
-
| `/routing-status` | Show the model in use for each agent (after failover) and
|
|
97
|
+
| `/routing-status` | Show the model in use for each agent (after failover) and the estimated use of the Go allowance |
|
|
81
98
|
| `/routing-plan go-plus` | Save a plan profile (`go`, `go-plus`, or `free`) and apply it immediately |
|
|
82
99
|
| `/routing-eval` | Send one test prompt to the `build` models and write a report |
|
|
83
100
|
| `/routing-update` | Look for a newer version and remove the old copy that OpenCode keeps. Then restart OpenCode |
|
|
@@ -135,8 +152,8 @@ or a value that is not valid, the router ignores it and shows a note in the scor
|
|
|
135
152
|
| Key | Default | Meaning |
|
|
136
153
|
| :-- | :-- | :-- |
|
|
137
154
|
| `plan` | detected from your catalog | `go`, `go-plus`, or `free` |
|
|
138
|
-
| `chainDepth` | 4 for agents, 3 for helpers | Number of models in each failover list. `roles.<role>.chainDepth` wins over it |
|
|
139
|
-
| `softThreshold` | `0.85` |
|
|
155
|
+
| `chainDepth` | 4 for agents, 3 for helpers | Number of models in each failover list, including the free model at the end. `roles.<role>.chainDepth` wins over it |
|
|
156
|
+
| `softThreshold` | `0.85` | Move the agents to their free model when the use of the shared Go allowance reaches this part of a window's limit (0.85 = 85%) |
|
|
140
157
|
| `privacy.freeModelsOk` | `true` | `false` keeps `plan`, `build`, and `general` off free models |
|
|
141
158
|
| `denyModels` | `[]` | Model IDs to never use. `*` and `?` wildcards are permitted |
|
|
142
159
|
| `allowModels` | `[]` | If not empty, use only these model IDs |
|
|
@@ -175,7 +192,7 @@ score = w_intelligence * intelligence
|
|
|
175
192
|
|
|
176
193
|
| File | Contents |
|
|
177
194
|
| :-- | :-- |
|
|
178
|
-
| `~/.config/opencode/router/ledger.json` | Spend and tokens per model
|
|
195
|
+
| `~/.config/opencode/router/ledger.json` | Spend and tokens per model, the use of the shared Go allowance in the last 30 days, and cooldowns |
|
|
179
196
|
| `~/.config/opencode/router/routing-table.json` | The last routing table |
|
|
180
197
|
| `~/.config/opencode/router/last-apply.json` | The result of the last live apply |
|
|
181
198
|
| `~/.config/opencode/router/eval-report.md` | The report of the last `/routing-eval` |
|
|
@@ -190,6 +207,8 @@ If `XDG_CONFIG_HOME` is set, the router uses `$XDG_CONFIG_HOME/opencode` in plac
|
|
|
190
207
|
Models with `-free` in their name can collect your prompts for training. The scorecard
|
|
191
208
|
marks them `free*`. To keep your main work off these models, set
|
|
192
209
|
`"privacy": { "freeModelsOk": false }`. To never use them, set `"denyModels": ["*-free"]`.
|
|
210
|
+
With either setting, the agents have no free model to move to, so they stop when the Go
|
|
211
|
+
allowance runs out.
|
|
193
212
|
|
|
194
213
|
Once each 24 hours, the router sends one request to `registry.npmjs.org` to get the
|
|
195
214
|
newest version number of the router. It sends no other data. To stop this, set
|
|
@@ -210,6 +229,12 @@ To use your local build in OpenCode, put the absolute path of the folder in the
|
|
|
210
229
|
list. Example for Windows: `"plugin": ["file:///C:/code/Opencode_router"]`. Run
|
|
211
230
|
`npm run build` after each change, then restart OpenCode.
|
|
212
231
|
|
|
232
|
+
## Contributing
|
|
233
|
+
|
|
234
|
+
Bug reports, model scores, and code changes are welcome. Read
|
|
235
|
+
[CONTRIBUTING.md](./CONTRIBUTING.md). For questions and ideas, use
|
|
236
|
+
[Discussions](https://github.com/s-berdia/Opencode_router/discussions).
|
|
237
|
+
|
|
213
238
|
## License
|
|
214
239
|
|
|
215
240
|
[MIT](./LICENSE)
|
package/USAGE.md
CHANGED
|
@@ -42,7 +42,22 @@ Make sure that you have these items:
|
|
|
42
42
|
|
|
43
43
|
## 2. Install the plugin
|
|
44
44
|
|
|
45
|
-
### Option A:
|
|
45
|
+
### Option A: with the OpenCode command-line tool (fastest)
|
|
46
|
+
|
|
47
|
+
1. Run this command. It works in PowerShell, CMD, Git Bash, macOS, and Linux:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
opencode plugin @s-berdia/opencode-router --global
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The command downloads the plugin and adds `"@s-berdia/opencode-router"` to the
|
|
54
|
+
`plugin` list of your global config file.
|
|
55
|
+
2. Close OpenCode. Start OpenCode again.
|
|
56
|
+
|
|
57
|
+
If the `opencode` command is not found, use Option B. Or install the command-line tool
|
|
58
|
+
with `npm install -g opencode-ai`, and then do step 1 again.
|
|
59
|
+
|
|
60
|
+
### Option B: from npm, by hand
|
|
46
61
|
|
|
47
62
|
1. Open the global OpenCode config file.
|
|
48
63
|
|
|
@@ -82,7 +97,7 @@ Make sure that you have these items:
|
|
|
82
97
|
To use the router in one project only, put the `plugin` entry in `opencode.json` in the
|
|
83
98
|
root folder of that project, not in the global file.
|
|
84
99
|
|
|
85
|
-
### Option
|
|
100
|
+
### Option C: from source
|
|
86
101
|
|
|
87
102
|
Use this option to test changes to the code.
|
|
88
103
|
|
|
@@ -142,7 +157,9 @@ message automatically.
|
|
|
142
157
|
## 4. Read the scorecard
|
|
143
158
|
|
|
144
159
|
The scorecard has one block of rows for each role. The first row of a block is the model
|
|
145
|
-
that the role uses. The other rows are the failover models, in sequence.
|
|
160
|
+
that the role uses. The other rows are the failover models, in sequence. On Go and Go
|
|
161
|
+
Plus, the last row of each agent is a free model (see
|
|
162
|
+
[How the router changes models](#how-the-router-changes-models)).
|
|
146
163
|
|
|
147
164
|
| Column | Meaning |
|
|
148
165
|
| :-- | :-- |
|
|
@@ -153,13 +170,38 @@ that the role uses. The other rows are the failover models, in sequence.
|
|
|
153
170
|
| Score | The total score, 0 to 100 |
|
|
154
171
|
| Intel | Intelligence score, 0 to 100 |
|
|
155
172
|
| Speed | Speed score, 0 to 100 |
|
|
156
|
-
| Endur | Endurance score, 0 to 100. A high value means that the model
|
|
157
|
-
| Tokens/5h | The estimated number of tokens
|
|
158
|
-
| Cap/mo | The monthly
|
|
173
|
+
| Endur | Endurance score, 0 to 100. A high value means that the model uses the Go allowance slowly |
|
|
174
|
+
| Tokens/5h | The estimated number of tokens in 5 hours if the agent uses only this model. `unlimited` means no limit |
|
|
175
|
+
| Cap/mo | The monthly limit of the model on your plan. `inf` means no limit |
|
|
159
176
|
| Class | `go` = included in your Go subscription. `free*` = free (your data can be collected). `paid` = Zen pay-as-you-go |
|
|
160
177
|
|
|
161
178
|
The **Notes** section under the table shows problems, for example a missing catalog.
|
|
162
179
|
|
|
180
|
+
### How the router changes models
|
|
181
|
+
|
|
182
|
+
- All `go` models share one usage allowance. Each message uses a part of it: the cost of
|
|
183
|
+
the message divided by the monthly limit of its model (`Cap/mo`). A model with a low
|
|
184
|
+
limit uses the allowance faster.
|
|
185
|
+
- The allowance has 3 windows: 5 hours (20% of the monthly allowance), 7 days (50%), and
|
|
186
|
+
30 days (100%). The router estimates the use in each window from the messages that it
|
|
187
|
+
records.
|
|
188
|
+
- When the use in a window reaches `softThreshold` (default 85%), the `plan`, `build`,
|
|
189
|
+
`general`, and `explore` agents use the free model at the end of their list. When the
|
|
190
|
+
use falls under the threshold again, they go back to their first model.
|
|
191
|
+
- After a usage-limit error from a `go` model, the router stops all `go` models for
|
|
192
|
+
1 hour. The agents use their free models in that time.
|
|
193
|
+
- After a different rate-limit or overload error, the router stops only that model for
|
|
194
|
+
1 hour. The agent uses the next model in its list.
|
|
195
|
+
|
|
196
|
+
`/routing-status` shows the estimated use of the allowance and the state of each agent:
|
|
197
|
+
|
|
198
|
+
| State | Meaning |
|
|
199
|
+
| :-- | :-- |
|
|
200
|
+
| `healthy` | The agent uses its first model |
|
|
201
|
+
| `failover` | The agent uses a later model, because an earlier model is in cooldown |
|
|
202
|
+
| `free-fallback` | The agent uses its free model, because the Go allowance is near its limit or in cooldown |
|
|
203
|
+
| `limited` | The agent has no usable model. The router keeps the first model |
|
|
204
|
+
|
|
163
205
|
## 5. Select your plan
|
|
164
206
|
|
|
165
207
|
The router finds your plan from the models in your catalog. If it selects the wrong plan,
|
|
@@ -205,6 +247,9 @@ use free models.
|
|
|
205
247
|
}
|
|
206
248
|
```
|
|
207
249
|
|
|
250
|
+
With this setting, these agents have no free model at the end of their list. When the
|
|
251
|
+
Go allowance runs out, they stop until it resets.
|
|
252
|
+
|
|
208
253
|
### Never use some models
|
|
209
254
|
|
|
210
255
|
`*` matches any text. `?` matches one character.
|
|
@@ -227,8 +272,9 @@ If `allowModels` is not empty, the router uses only the models in the list.
|
|
|
227
272
|
|
|
228
273
|
### Change models earlier or later
|
|
229
274
|
|
|
230
|
-
`softThreshold` sets when the
|
|
231
|
-
the 5
|
|
275
|
+
`softThreshold` sets when the agents change to their free model. `0.7` changes when the
|
|
276
|
+
use of the shared Go allowance reaches 70% in one of its windows (5 hours, 7 days, or
|
|
277
|
+
30 days). The default is `0.85`.
|
|
232
278
|
|
|
233
279
|
```json
|
|
234
280
|
{
|
|
@@ -265,7 +311,8 @@ Use this setting when you know that a model is better or worse than its score.
|
|
|
265
311
|
|
|
266
312
|
`chainDepth` sets the number of models in each failover list. The default is 4 for the
|
|
267
313
|
agents and 3 for the internal helpers. A value under `roles.<role>` wins over the
|
|
268
|
-
top-level value.
|
|
314
|
+
top-level value. On Go and Go Plus, the last model of each list is a free model, if your
|
|
315
|
+
privacy setting permits free models.
|
|
269
316
|
|
|
270
317
|
```json
|
|
271
318
|
{
|
|
@@ -366,7 +413,7 @@ These commands work in PowerShell, CMD, Git Bash, macOS, and Linux.
|
|
|
366
413
|
| `opencode-router refresh --scope project` | Write the routing table to `opencode.json` in the current folder |
|
|
367
414
|
| `opencode-router refresh --plan free` | Use the `free` profile for this run only |
|
|
368
415
|
| `opencode-router refresh --json` | Print the result as JSON |
|
|
369
|
-
| `opencode-router status` | Show the model in use for each role (after failover) and
|
|
416
|
+
| `opencode-router status` | Show the model in use for each role (after failover) and the estimated use of the Go allowance |
|
|
370
417
|
| `opencode-router plan go-plus --scope global` | Save the plan in the global settings, then calculate and write the routing table |
|
|
371
418
|
| `opencode-router cat` | Show the catalog path, its date, and the number of models |
|
|
372
419
|
| `opencode-router install --scope global` | Write the slash commands as files in `~/.config/opencode/command/`. If `router.config.json` does not exist, it also writes an empty one |
|
|
@@ -447,7 +494,7 @@ The router writes these files:
|
|
|
447
494
|
|
|
448
495
|
| File | Written by | Contents |
|
|
449
496
|
| :-- | :-- | :-- |
|
|
450
|
-
| `~/.config/opencode/router/ledger.json` | Plugin | Spend and tokens per model
|
|
497
|
+
| `~/.config/opencode/router/ledger.json` | Plugin | Spend and tokens per model, the use of the shared Go allowance in the last 30 days, and cooldowns |
|
|
451
498
|
| `~/.config/opencode/router/routing-table.json` | Plugin | The last routing table |
|
|
452
499
|
| `~/.config/opencode/router/last-apply.json` | Plugin | The result of the last live apply |
|
|
453
500
|
| `~/.config/opencode/router/eval-report.md` | `/routing-eval` | The test report |
|
|
@@ -478,11 +525,12 @@ Do not commit the `router/` folder. It contains your usage data.
|
|
|
478
525
|
| "live PATCH not attempted (no OPENCODE_SERVER_URL set)" | Normal when you run `refresh` in a terminal | No action. Or see [Apply to a running OpenCode server](#apply-to-a-running-opencode-server) |
|
|
479
526
|
| "hot-apply failed: ..." | OpenCode did not accept the change | Restart OpenCode. The file `opencode.json` has the new models |
|
|
480
527
|
| A model that you selected by hand changes back | The router selects the model at each message | See [Known limits](#11-known-limits) |
|
|
528
|
+
| An agent uses a free model (`free-fallback` in `/routing-status`) | The Go allowance is near its limit, or a usage-limit error occurred | No action. The agent goes back to its first model when the use falls or the cooldown ends. To change the limit, set [`softThreshold`](#change-models-earlier-or-later) |
|
|
481
529
|
| The scores look wrong | A model has no entry in `model-scores.json` | Run `npx @s-berdia/opencode-router refresh --dry-run --verbose`. If the source is `heuristic` or `default`, add the model to `scores` |
|
|
482
530
|
|
|
483
531
|
## 11. Known limits
|
|
484
532
|
|
|
485
|
-
These are the limits of version 1.
|
|
533
|
+
These are the limits of version 1.2.0.
|
|
486
534
|
|
|
487
535
|
1. The router sets the model of the `plan`, `build`, `general`, and `explore` agents at
|
|
488
536
|
each message. If you select a different model by hand, the router replaces it. To
|
|
@@ -492,12 +540,18 @@ These are the limits of version 1.1.0.
|
|
|
492
540
|
3. The slash commands always write the global files (`~/.config/opencode/`). To write the
|
|
493
541
|
files of one project, use the terminal tool with `--scope project`.
|
|
494
542
|
4. The slash commands need Node.js 20 or later on the `PATH`.
|
|
495
|
-
5. Use numbers are estimates. The router
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
543
|
+
5. Use numbers are estimates. The router counts only the messages that it records. Use
|
|
544
|
+
from another computer is not included. After an update from version 1.1 or earlier,
|
|
545
|
+
the estimate starts at 0%. If two OpenCode windows write at the same moment, one
|
|
546
|
+
record can be lost.
|
|
547
|
+
6. The router treats the 3 windows as rolling windows. If OpenCode resets a window at a
|
|
548
|
+
fixed time, the estimate can differ from the console.
|
|
549
|
+
7. After a usage-limit error, all `go` models go into a 1-hour cooldown. After a
|
|
550
|
+
different rate-limit or overload error, only that model goes into a cooldown.
|
|
551
|
+
8. With `"privacy": { "freeModelsOk": false }`, the `plan`, `build`, and `general` agents
|
|
552
|
+
have no free model. When the Go allowance runs out, they stop until it resets.
|
|
553
|
+
9. The router does not know about **Extra usage** in the OpenCode console. It moves the
|
|
554
|
+
agents to free models at the threshold, also when Extra usage is on.
|
|
501
555
|
|
|
502
556
|
## 12. Update the router
|
|
503
557
|
|
|
@@ -544,7 +598,7 @@ To select the version yourself, write it in the `plugin` list:
|
|
|
544
598
|
|
|
545
599
|
```json
|
|
546
600
|
{
|
|
547
|
-
"plugin": ["@s-berdia/opencode-router@1.
|
|
601
|
+
"plugin": ["@s-berdia/opencode-router@1.2.0"]
|
|
548
602
|
}
|
|
549
603
|
```
|
|
550
604
|
|
package/dist/bin.js
CHANGED
|
@@ -1657,6 +1657,7 @@ function catalogMeta(file = catalogCacheFile()) {
|
|
|
1657
1657
|
|
|
1658
1658
|
// src/scoring.ts
|
|
1659
1659
|
var FIVE_HOUR_SHARE = 0.2;
|
|
1660
|
+
var WEEK_SHARE = 0.5;
|
|
1660
1661
|
var FREE_ROLE_BONUS = 6;
|
|
1661
1662
|
var DEFAULT_UNVETTED_PENALTY = 8;
|
|
1662
1663
|
function modelClass(model) {
|
|
@@ -2169,6 +2170,13 @@ function rankRole(catalog, roleName, table, cfg, feeds) {
|
|
|
2169
2170
|
chosen.push(entry);
|
|
2170
2171
|
}
|
|
2171
2172
|
}
|
|
2173
|
+
if (cfg.plan !== "free" && !chosen.some((entry) => entry.model.free)) {
|
|
2174
|
+
const free = scored.find((entry) => entry.model.free);
|
|
2175
|
+
if (free) {
|
|
2176
|
+
if (chosen.length >= depth && chosen.length > 1) chosen.pop();
|
|
2177
|
+
chosen.push(free);
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2172
2180
|
return chosen.map((entry, index) => ({
|
|
2173
2181
|
rank: index + 1,
|
|
2174
2182
|
providerID: entry.model.providerID,
|
|
@@ -2230,27 +2238,27 @@ async function buildRoutingTable(opts = {}) {
|
|
|
2230
2238
|
};
|
|
2231
2239
|
return { table, cfg, scoreTable, catalog };
|
|
2232
2240
|
}
|
|
2233
|
-
function
|
|
2234
|
-
return ROLE_NAMES.flatMap((role) => table.active[role] ?? []);
|
|
2235
|
-
}
|
|
2236
|
-
function pickForRole(table, role, isHealthy) {
|
|
2241
|
+
function pickForRole(table, role, isUsable) {
|
|
2237
2242
|
const chain = table.active[role];
|
|
2238
2243
|
if (!chain || chain.length === 0) return void 0;
|
|
2239
|
-
return chain.find(
|
|
2244
|
+
return chain.find(isUsable) ?? chain[0];
|
|
2240
2245
|
}
|
|
2241
2246
|
function healthCheck(result, ledger) {
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
function usagePercent(result, ledger, modelId) {
|
|
2245
|
-
return ledger.usage(modelId, monthlyCapFor(modelId, result.scoreTable, result.cfg.plan)).pct5h * 100;
|
|
2247
|
+
const allowanceOk = ledger.allowanceAvailable(result.cfg.softThreshold);
|
|
2248
|
+
return (entry) => !ledger.inCooldown(entry.id) && (allowanceOk || entry.class !== "subscription");
|
|
2246
2249
|
}
|
|
2247
2250
|
function statusRows(result, ledger) {
|
|
2248
|
-
const
|
|
2251
|
+
const isUsable = healthCheck(result, ledger);
|
|
2252
|
+
const allowanceOk = ledger.allowanceAvailable(result.cfg.softThreshold);
|
|
2249
2253
|
return ROLE_NAMES.map((role) => {
|
|
2250
|
-
const entry = isAgentRole(role) ? pickForRole(result.table, role,
|
|
2251
|
-
if (!entry) return { role,
|
|
2252
|
-
const
|
|
2253
|
-
|
|
2254
|
+
const entry = isAgentRole(role) ? pickForRole(result.table, role, isUsable) : result.table.active[role]?.[0];
|
|
2255
|
+
if (!entry) return { role, state: "none" };
|
|
2256
|
+
const first = result.table.active[role]?.[0];
|
|
2257
|
+
let state = "healthy";
|
|
2258
|
+
if (!isUsable(entry)) state = "limited";
|
|
2259
|
+
else if (entry.rank > 1 && entry.class === "free" && first?.class === "subscription" && !allowanceOk) state = "free-fallback";
|
|
2260
|
+
else if (entry.rank > 1) state = "failover";
|
|
2261
|
+
return { role, entry, state };
|
|
2254
2262
|
});
|
|
2255
2263
|
}
|
|
2256
2264
|
|
|
@@ -2276,6 +2284,11 @@ function formatScorecard(table, opts = {}) {
|
|
|
2276
2284
|
lines.push(`- Plan: **${table.plan}**`);
|
|
2277
2285
|
lines.push(`- Updated: ${when} (UTC)`);
|
|
2278
2286
|
lines.push(`- Legend: go = Go subscription, free* = $0 (data may be collected), paid = Zen pay-as-you-go`);
|
|
2287
|
+
if (table.plan !== "free") {
|
|
2288
|
+
lines.push(
|
|
2289
|
+
"- All `go` models share one usage allowance. When it is near its limit, each agent uses the free model at the end of its list."
|
|
2290
|
+
);
|
|
2291
|
+
}
|
|
2279
2292
|
lines.push("");
|
|
2280
2293
|
lines.push("| Role | # | Model | Var | Score | Intel | Speed | Endur | Tokens/5h | Cap/mo | Class |");
|
|
2281
2294
|
lines.push("| :-- | --: | :-- | :-- | --: | --: | --: | --: | --: | --: | :-- |");
|
|
@@ -2305,23 +2318,37 @@ function formatScorecard(table, opts = {}) {
|
|
|
2305
2318
|
);
|
|
2306
2319
|
return lines.join("\n");
|
|
2307
2320
|
}
|
|
2308
|
-
function
|
|
2321
|
+
function percent(part) {
|
|
2322
|
+
return `${(part * 100).toFixed(0)}%`;
|
|
2323
|
+
}
|
|
2324
|
+
function formatStatus(table, rows, allowance) {
|
|
2309
2325
|
const lines = [];
|
|
2310
2326
|
lines.push(`## opencode-router status`);
|
|
2311
2327
|
lines.push("");
|
|
2312
2328
|
lines.push(`- Plan: **${table.plan}**`);
|
|
2329
|
+
if (allowance) {
|
|
2330
|
+
const { usage } = allowance;
|
|
2331
|
+
lines.push(
|
|
2332
|
+
`- Go allowance used (estimate): 5 hours ${percent(usage.pct5h)}, 7 days ${percent(usage.pctWeek)}, 30 days ${percent(usage.pctMonth)}`
|
|
2333
|
+
);
|
|
2334
|
+
if (usage.cooldownUntil !== null) {
|
|
2335
|
+
const until = new Date(usage.cooldownUntil).toISOString().slice(11, 16);
|
|
2336
|
+
lines.push(`- Go allowance cooldown until ${until} UTC (${usage.cooldownReason ?? "usage limit"})`);
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
const freeFallback = allowance ? `free-fallback = a free model, because the Go allowance is at ${percent(allowance.softThreshold)} or more or in cooldown; ` : "";
|
|
2313
2340
|
lines.push(
|
|
2314
|
-
`- State: healthy = first model; failover = a later model, because
|
|
2341
|
+
`- State: healthy = first model; failover = a later model, because an earlier model is in cooldown; ${freeFallback}limited = no usable model`
|
|
2315
2342
|
);
|
|
2316
2343
|
lines.push("");
|
|
2317
|
-
lines.push("| Role | Model in use | Rank |
|
|
2318
|
-
lines.push("| :-- | :-- | --: |
|
|
2344
|
+
lines.push("| Role | Model in use | Rank | State |");
|
|
2345
|
+
lines.push("| :-- | :-- | --: | :-- |");
|
|
2319
2346
|
for (const row of rows) {
|
|
2320
2347
|
if (!row.entry) {
|
|
2321
|
-
lines.push(`| ${row.role} | _none_ | - | -
|
|
2348
|
+
lines.push(`| ${row.role} | _none_ | - | - |`);
|
|
2322
2349
|
continue;
|
|
2323
2350
|
}
|
|
2324
|
-
lines.push(`| ${row.role} | \`${row.entry.id}\` | ${row.entry.rank} | ${row.
|
|
2351
|
+
lines.push(`| ${row.role} | \`${row.entry.id}\` | ${row.entry.rank} | ${row.state} |`);
|
|
2325
2352
|
}
|
|
2326
2353
|
return lines.join("\n");
|
|
2327
2354
|
}
|
|
@@ -2399,7 +2426,6 @@ var DAY = 24 * HOUR;
|
|
|
2399
2426
|
var WINDOW_5H = 5 * HOUR;
|
|
2400
2427
|
var WINDOW_WEEK = 7 * DAY;
|
|
2401
2428
|
var WINDOW_MONTH = 30 * DAY;
|
|
2402
|
-
var MONTHLY_LIMIT = 0.95;
|
|
2403
2429
|
function emptyState(now) {
|
|
2404
2430
|
return {
|
|
2405
2431
|
cost5h: 0,
|
|
@@ -2482,7 +2508,8 @@ var LedgerStore = class {
|
|
|
2482
2508
|
}
|
|
2483
2509
|
return this.roll(entry, now);
|
|
2484
2510
|
}
|
|
2485
|
-
|
|
2511
|
+
/** `allowanceShare`: the cost divided by the model's monthly limit; 0 for a model outside the Go allowance. */
|
|
2512
|
+
record(modelId, costUSD, tokens, allowanceShare = 0) {
|
|
2486
2513
|
const state = this.state(modelId);
|
|
2487
2514
|
state.cost5h += costUSD;
|
|
2488
2515
|
state.costWeek += costUSD;
|
|
@@ -2491,8 +2518,23 @@ var LedgerStore = class {
|
|
|
2491
2518
|
state.tokensWeek += tokens;
|
|
2492
2519
|
state.tokensMonth += tokens;
|
|
2493
2520
|
state.lastUsedAt = this.now();
|
|
2521
|
+
if (allowanceShare > 0) this.addShare(allowanceShare);
|
|
2494
2522
|
this.save();
|
|
2495
2523
|
}
|
|
2524
|
+
/** Call after sync() or state(), never before: a reload would drop changes to the old data. */
|
|
2525
|
+
sharedAllowance() {
|
|
2526
|
+
if (!this.data.allowance || !Array.isArray(this.data.allowance.buckets)) this.data.allowance = { buckets: [] };
|
|
2527
|
+
return this.data.allowance;
|
|
2528
|
+
}
|
|
2529
|
+
addShare(share) {
|
|
2530
|
+
const allowance = this.sharedAllowance();
|
|
2531
|
+
const now = this.now();
|
|
2532
|
+
const hour = Math.floor(now / HOUR) * HOUR;
|
|
2533
|
+
allowance.buckets = allowance.buckets.filter(([start]) => start + HOUR > now - WINDOW_MONTH);
|
|
2534
|
+
const bucket = allowance.buckets.find(([start]) => start === hour);
|
|
2535
|
+
if (bucket) bucket[1] += share;
|
|
2536
|
+
else allowance.buckets.push([hour, share]);
|
|
2537
|
+
}
|
|
2496
2538
|
cooldown(modelId, durationMs, reason) {
|
|
2497
2539
|
const state = this.state(modelId);
|
|
2498
2540
|
state.cooldownUntil = this.now() + durationMs;
|
|
@@ -2504,15 +2546,46 @@ var LedgerStore = class {
|
|
|
2504
2546
|
state.cooldownUntil = void 0;
|
|
2505
2547
|
this.save();
|
|
2506
2548
|
}
|
|
2507
|
-
|
|
2508
|
-
|
|
2549
|
+
inCooldown(modelId) {
|
|
2550
|
+
const until = this.state(modelId).cooldownUntil;
|
|
2551
|
+
return until !== void 0 && this.now() < until;
|
|
2509
2552
|
}
|
|
2510
|
-
/**
|
|
2511
|
-
|
|
2553
|
+
/** After a usage-limit error: every model that uses the shared allowance is limited. */
|
|
2554
|
+
cooldownAllowance(durationMs, reason) {
|
|
2555
|
+
this.sync();
|
|
2556
|
+
const allowance = this.sharedAllowance();
|
|
2557
|
+
allowance.cooldownUntil = this.now() + durationMs;
|
|
2558
|
+
if (reason) allowance.lastError = reason;
|
|
2559
|
+
this.save();
|
|
2560
|
+
}
|
|
2561
|
+
usage(modelId) {
|
|
2512
2562
|
const state = this.state(modelId);
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2563
|
+
return {
|
|
2564
|
+
cost5h: state.cost5h,
|
|
2565
|
+
costMonth: state.costMonth,
|
|
2566
|
+
tokens5h: state.tokens5h,
|
|
2567
|
+
tokensMonth: state.tokensMonth,
|
|
2568
|
+
cooldownUntil: state.cooldownUntil ?? null
|
|
2569
|
+
};
|
|
2570
|
+
}
|
|
2571
|
+
/** Rolling use of the shared allowance in the last 5 hours, 7 days, and 30 days. */
|
|
2572
|
+
allowance() {
|
|
2573
|
+
this.sync();
|
|
2574
|
+
const allowance = this.sharedAllowance();
|
|
2575
|
+
const now = this.now();
|
|
2576
|
+
const cooldownUntil = allowance.cooldownUntil !== void 0 && now < allowance.cooldownUntil ? allowance.cooldownUntil : null;
|
|
2577
|
+
return {
|
|
2578
|
+
pct5h: windowShare(allowance.buckets, now, WINDOW_5H) / FIVE_HOUR_SHARE,
|
|
2579
|
+
pctWeek: windowShare(allowance.buckets, now, WINDOW_WEEK) / WEEK_SHARE,
|
|
2580
|
+
pctMonth: windowShare(allowance.buckets, now, WINDOW_MONTH),
|
|
2581
|
+
cooldownUntil,
|
|
2582
|
+
cooldownReason: cooldownUntil === null ? null : allowance.lastError ?? null
|
|
2583
|
+
};
|
|
2584
|
+
}
|
|
2585
|
+
/** True when no window has reached `softThreshold` and no usage-limit cooldown is active. */
|
|
2586
|
+
allowanceAvailable(softThreshold) {
|
|
2587
|
+
const u = this.allowance();
|
|
2588
|
+
return u.cooldownUntil === null && u.pct5h < softThreshold && u.pctWeek < softThreshold && u.pctMonth < softThreshold;
|
|
2516
2589
|
}
|
|
2517
2590
|
snapshot() {
|
|
2518
2591
|
return structuredClone(this.data);
|
|
@@ -2523,17 +2596,16 @@ var LedgerStore = class {
|
|
|
2523
2596
|
this.loadedMtime = mtimeOf(this.file);
|
|
2524
2597
|
}
|
|
2525
2598
|
};
|
|
2526
|
-
function
|
|
2527
|
-
const
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
};
|
|
2599
|
+
function windowShare(buckets, now, windowMs) {
|
|
2600
|
+
const from = now - windowMs;
|
|
2601
|
+
let sum = 0;
|
|
2602
|
+
for (const [start, share] of buckets) {
|
|
2603
|
+
const end = Math.min(start + HOUR, now);
|
|
2604
|
+
if (end <= from || start > now) continue;
|
|
2605
|
+
const span = end - start;
|
|
2606
|
+
sum += span <= 0 ? share : share * (end - Math.max(start, from)) / span;
|
|
2607
|
+
}
|
|
2608
|
+
return sum;
|
|
2537
2609
|
}
|
|
2538
2610
|
|
|
2539
2611
|
// src/install.ts
|
|
@@ -2959,12 +3031,12 @@ async function cmdStatus(opts) {
|
|
|
2959
3031
|
const result = await build(opts);
|
|
2960
3032
|
const ledger = new LedgerStore(ledgerFile());
|
|
2961
3033
|
const rows = statusRows(result, ledger);
|
|
3034
|
+
const allowance = result.cfg.plan === "free" ? void 0 : ledger.allowance();
|
|
2962
3035
|
if (opts.json) {
|
|
2963
|
-
|
|
2964
|
-
for (const entry of activeEntries(result.table)) usage[entry.id] = { pct: usagePercent(result, ledger, entry.id) };
|
|
2965
|
-
process.stdout.write(JSON.stringify({ table: result.table, rows, usage }, null, 2) + "\n");
|
|
3036
|
+
process.stdout.write(JSON.stringify({ table: result.table, rows, allowance: allowance ?? null }, null, 2) + "\n");
|
|
2966
3037
|
} else {
|
|
2967
|
-
|
|
3038
|
+
const status = allowance && { usage: allowance, softThreshold: result.cfg.softThreshold };
|
|
3039
|
+
process.stdout.write(formatStatus(result.table, rows, status) + "\n");
|
|
2968
3040
|
await writeUpdateNotice(opts);
|
|
2969
3041
|
}
|
|
2970
3042
|
return 0;
|