@s-berdia/opencode-router 1.1.1 → 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
@@ -8,14 +8,14 @@
8
8
  **Switch models before you hit your [OpenCode](https://opencode.ai) limit.** Automatic
9
9
  model routing and quota failover for the **Go**, **Go Plus**, and **Free** plans.
10
10
 
11
- ![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)
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)
12
12
 
13
13
  [Watch the demo in full quality (MP4, 25 seconds)](docs/router-demo.mp4?raw=true)
14
14
 
15
15
  opencode-router gives each OpenCode agent the best model for its job and for your plan.
16
- It records how much of your quota each model uses. When a model is near its limit, the
17
- router moves the agent to the next model in its list. When the limit resets, the router
18
- 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.
19
19
 
20
20
  For full step-by-step instructions, read the [usage guide](./USAGE.md). Before you use
21
21
  the router, read its [known limits](./USAGE.md#11-known-limits).
@@ -29,8 +29,10 @@ the router, read its [known limits](./USAGE.md#11-known-limits).
29
29
  monthly allowance of each model to calculate how many tokens the model can supply in
30
30
  5 hours.
31
31
  - **Automatic failover.** The `plan`, `build`, `general`, and `explore` agents each have a
32
- ranked list of up to 4 models. The router changes to the next model before a limit is
33
- 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.
34
36
  - **Live apply.** The router changes the models in the running OpenCode session. You do
35
37
  not have to restart OpenCode after a refresh.
36
38
  - **Clear results.** A scorecard shows each model and its scores. With `--verbose`, it
@@ -92,7 +94,7 @@ See [Update the router](./USAGE.md#12-update-the-router).
92
94
  | Command | What it does |
93
95
  | :-- | :-- |
94
96
  | `/refresh-routing` | Calculate the routing table again, apply it, and show the scorecard |
95
- | `/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 |
96
98
  | `/routing-plan go-plus` | Save a plan profile (`go`, `go-plus`, or `free`) and apply it immediately |
97
99
  | `/routing-eval` | Send one test prompt to the `build` models and write a report |
98
100
  | `/routing-update` | Look for a newer version and remove the old copy that OpenCode keeps. Then restart OpenCode |
@@ -150,8 +152,8 @@ or a value that is not valid, the router ignores it and shows a note in the scor
150
152
  | Key | Default | Meaning |
151
153
  | :-- | :-- | :-- |
152
154
  | `plan` | detected from your catalog | `go`, `go-plus`, or `free` |
153
- | `chainDepth` | 4 for agents, 3 for helpers | Number of models in each failover list. `roles.<role>.chainDepth` wins over it |
154
- | `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%) |
155
157
  | `privacy.freeModelsOk` | `true` | `false` keeps `plan`, `build`, and `general` off free models |
156
158
  | `denyModels` | `[]` | Model IDs to never use. `*` and `?` wildcards are permitted |
157
159
  | `allowModels` | `[]` | If not empty, use only these model IDs |
@@ -190,7 +192,7 @@ score = w_intelligence * intelligence
190
192
 
191
193
  | File | Contents |
192
194
  | :-- | :-- |
193
- | `~/.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 |
194
196
  | `~/.config/opencode/router/routing-table.json` | The last routing table |
195
197
  | `~/.config/opencode/router/last-apply.json` | The result of the last live apply |
196
198
  | `~/.config/opencode/router/eval-report.md` | The report of the last `/routing-eval` |
@@ -205,6 +207,8 @@ If `XDG_CONFIG_HOME` is set, the router uses `$XDG_CONFIG_HOME/opencode` in plac
205
207
  Models with `-free` in their name can collect your prompts for training. The scorecard
206
208
  marks them `free*`. To keep your main work off these models, set
207
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.
208
212
 
209
213
  Once each 24 hours, the router sends one request to `registry.npmjs.org` to get the
210
214
  newest version number of the router. It sends no other data. To stop this, set
package/USAGE.md CHANGED
@@ -157,7 +157,9 @@ message automatically.
157
157
  ## 4. Read the scorecard
158
158
 
159
159
  The scorecard has one block of rows for each role. The first row of a block is the model
160
- 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)).
161
163
 
162
164
  | Column | Meaning |
163
165
  | :-- | :-- |
@@ -168,13 +170,38 @@ that the role uses. The other rows are the failover models, in sequence.
168
170
  | Score | The total score, 0 to 100 |
169
171
  | Intel | Intelligence score, 0 to 100 |
170
172
  | Speed | Speed score, 0 to 100 |
171
- | Endur | Endurance score, 0 to 100. A high value means that the model can run for a long time on your quota |
172
- | Tokens/5h | The estimated number of tokens that your quota gives in 5 hours. `unlimited` means no limit |
173
- | 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 |
174
176
  | Class | `go` = included in your Go subscription. `free*` = free (your data can be collected). `paid` = Zen pay-as-you-go |
175
177
 
176
178
  The **Notes** section under the table shows problems, for example a missing catalog.
177
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
+
178
205
  ## 5. Select your plan
179
206
 
180
207
  The router finds your plan from the models in your catalog. If it selects the wrong plan,
@@ -220,6 +247,9 @@ use free models.
220
247
  }
221
248
  ```
222
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
+
223
253
  ### Never use some models
224
254
 
225
255
  `*` matches any text. `?` matches one character.
@@ -242,8 +272,9 @@ If `allowModels` is not empty, the router uses only the models in the list.
242
272
 
243
273
  ### Change models earlier or later
244
274
 
245
- `softThreshold` sets when the router changes to the next model. `0.7` changes at 70% of
246
- 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`.
247
278
 
248
279
  ```json
249
280
  {
@@ -280,7 +311,8 @@ Use this setting when you know that a model is better or worse than its score.
280
311
 
281
312
  `chainDepth` sets the number of models in each failover list. The default is 4 for the
282
313
  agents and 3 for the internal helpers. A value under `roles.<role>` wins over the
283
- 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.
284
316
 
285
317
  ```json
286
318
  {
@@ -381,7 +413,7 @@ These commands work in PowerShell, CMD, Git Bash, macOS, and Linux.
381
413
  | `opencode-router refresh --scope project` | Write the routing table to `opencode.json` in the current folder |
382
414
  | `opencode-router refresh --plan free` | Use the `free` profile for this run only |
383
415
  | `opencode-router refresh --json` | Print the result as JSON |
384
- | `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 |
385
417
  | `opencode-router plan go-plus --scope global` | Save the plan in the global settings, then calculate and write the routing table |
386
418
  | `opencode-router cat` | Show the catalog path, its date, and the number of models |
387
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 |
@@ -462,7 +494,7 @@ The router writes these files:
462
494
 
463
495
  | File | Written by | Contents |
464
496
  | :-- | :-- | :-- |
465
- | `~/.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 |
466
498
  | `~/.config/opencode/router/routing-table.json` | Plugin | The last routing table |
467
499
  | `~/.config/opencode/router/last-apply.json` | Plugin | The result of the last live apply |
468
500
  | `~/.config/opencode/router/eval-report.md` | `/routing-eval` | The test report |
@@ -493,11 +525,12 @@ Do not commit the `router/` folder. It contains your usage data.
493
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) |
494
526
  | "hot-apply failed: ..." | OpenCode did not accept the change | Restart OpenCode. The file `opencode.json` has the new models |
495
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) |
496
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` |
497
530
 
498
531
  ## 11. Known limits
499
532
 
500
- These are the limits of version 1.1.1.
533
+ These are the limits of version 1.2.0.
501
534
 
502
535
  1. The router sets the model of the `plan`, `build`, `general`, and `explore` agents at
503
536
  each message. If you select a different model by hand, the router replaces it. To
@@ -507,12 +540,18 @@ These are the limits of version 1.1.1.
507
540
  3. The slash commands always write the global files (`~/.config/opencode/`). To write the
508
541
  files of one project, use the terminal tool with `--scope project`.
509
542
  4. The slash commands need Node.js 20 or later on the `PATH`.
510
- 5. Use numbers are estimates. The router takes 20% of the monthly allowance as the
511
- 5-hour limit. The 5-hour period starts at the first message after the last period
512
- ended. If two OpenCode windows write at the same moment, one record can be lost.
513
- 6. After a rate-limit or quota error, the model goes into a 1-hour cooldown. A
514
- rate-limit error for a full session puts only the model of that session into a
515
- 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.
516
555
 
517
556
  ## 12. Update the router
518
557
 
@@ -559,7 +598,7 @@ To select the version yourself, write it in the `plugin` list:
559
598
 
560
599
  ```json
561
600
  {
562
- "plugin": ["@s-berdia/opencode-router@1.1.1"]
601
+ "plugin": ["@s-berdia/opencode-router@1.2.0"]
563
602
  }
564
603
  ```
565
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;