@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 CHANGED
@@ -1,15 +1,21 @@
1
1
  # opencode-router
2
2
 
3
- Automatic model routing for [OpenCode](https://opencode.ai) on the **Go**, **Go Plus**, and **Free** plans.
3
+ [![npm version](https://img.shields.io/npm/v/@s-berdia/opencode-router)](https://www.npmjs.com/package/@s-berdia/opencode-router)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@s-berdia/opencode-router)](https://www.npmjs.com/package/@s-berdia/opencode-router)
5
+ [![CI](https://github.com/s-berdia/Opencode_router/actions/workflows/ci.yml/badge.svg)](https://github.com/s-berdia/Opencode_router/actions/workflows/ci.yml)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
4
7
 
5
- ![opencode-router demo: the scorecard gives each agent a model, and the build agent changes to the next model at 85% of its 5-hour limit](docs/router-demo.gif)
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
+ ![opencode-router demo: the scorecard gives each agent a model, and the build agent moves to a free model at 85% of the shared Go allowance](docs/router-demo.gif)
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
- It records how much of your quota each model uses. When a model is near its limit, the
11
- router moves the agent to the next model in its list. When the limit resets, the router
12
- moves the agent back.
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. The router changes to the next model before a limit is
27
- reached, and after a rate-limit error.
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 its 5-hour quota use |
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` | Change to the next model when 5-hour use reaches this part of the 5-hour limit (0.85 = 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 for 5 hours, 7 days, and 30 days |
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: from npm (recommended)
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 B: from source
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 can run for a long time on your quota |
157
- | Tokens/5h | The estimated number of tokens that your quota gives in 5 hours. `unlimited` means no limit |
158
- | Cap/mo | The monthly dollar allowance for the model on your plan. `inf` means no limit |
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 router changes to the next model. `0.7` changes at 70% of
231
- the 5-hour limit. The default is `0.85`.
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 its 5-hour use |
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 for 5 hours, 7 days, and 30 days, and cooldowns |
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.1.0.
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 takes 20% of the monthly allowance as the
496
- 5-hour limit. The 5-hour period starts at the first message after the last period
497
- ended. If two OpenCode windows write at the same moment, one record can be lost.
498
- 6. After a rate-limit or quota error, the model goes into a 1-hour cooldown. A
499
- rate-limit error for a full session puts only the model of that session into a
500
- cooldown.
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.1.0"]
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 activeEntries(table) {
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((entry) => isHealthy(entry.id)) ?? chain[0];
2244
+ return chain.find(isUsable) ?? chain[0];
2240
2245
  }
2241
2246
  function healthCheck(result, ledger) {
2242
- return (modelId) => ledger.isHealthy(modelId, monthlyCapFor(modelId, result.scoreTable, result.cfg.plan), result.cfg.softThreshold);
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 isHealthy = healthCheck(result, ledger);
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, isHealthy) : result.table.active[role]?.[0];
2251
- if (!entry) return { role, pct5h: 0, state: "none" };
2252
- const state = !isHealthy(entry.id) ? "limited" : entry.rank > 1 ? "failover" : "healthy";
2253
- return { role, entry, pct5h: usagePercent(result, ledger, entry.id), state };
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 formatStatus(table, rows) {
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 the first is limited; limited = the model in use is near its limit or in cooldown`
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 | Usage (5h) | State |");
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.pct5h.toFixed(0)}% | ${row.state} |`);
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
- record(modelId, costUSD, tokens) {
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
- usage(modelId, monthlyCapUSD) {
2508
- return usageOf(this.state(modelId), monthlyCapUSD);
2549
+ inCooldown(modelId) {
2550
+ const until = this.state(modelId).cooldownUntil;
2551
+ return until !== void 0 && this.now() < until;
2509
2552
  }
2510
- /** Healthy = not cooling down, under the soft threshold for 5 hours, and under the monthly limit. */
2511
- isHealthy(modelId, monthlyCapUSD, softThreshold) {
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
- if (state.cooldownUntil && this.now() < state.cooldownUntil) return false;
2514
- const u = usageOf(state, monthlyCapUSD);
2515
- return u.pct5h < softThreshold && u.pctMonth < MONTHLY_LIMIT;
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 usageOf(state, monthlyCapUSD) {
2527
- const cap5h = monthlyCapUSD === null ? null : monthlyCapUSD * FIVE_HOUR_SHARE;
2528
- return {
2529
- cost5h: state.cost5h,
2530
- costMonth: state.costMonth,
2531
- tokens5h: state.tokens5h,
2532
- tokensMonth: state.tokensMonth,
2533
- pct5h: cap5h === null || cap5h <= 0 ? 0 : state.cost5h / cap5h,
2534
- pctMonth: monthlyCapUSD === null || monthlyCapUSD <= 0 ? 0 : state.costMonth / monthlyCapUSD,
2535
- cooldownUntil: state.cooldownUntil ?? null
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
- const usage = {};
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
- process.stdout.write(formatStatus(result.table, rows) + "\n");
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;