@qadanm/opencode-smart-router 0.0.0-stage → 0.4.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/CONTRIBUTING.md +96 -0
  3. package/LICENSE +21 -0
  4. package/README.md +341 -2
  5. package/SECURITY.md +174 -0
  6. package/config.example.json +108 -0
  7. package/dist/allowance.js +487 -0
  8. package/dist/gui-experimental.cjs +1 -0
  9. package/dist/index.js +11111 -0
  10. package/dist/npm/index.js +40 -0
  11. package/dist/tui.js +587 -0
  12. package/docs/ARCHITECTURE.md +104 -0
  13. package/docs/DEBUGGING-GUI.md +70 -0
  14. package/docs/INSTALL.md +120 -0
  15. package/docs/LIMITATIONS.md +213 -0
  16. package/docs/MODELS.md +124 -0
  17. package/docs/OPENDESIGN-INTEGRATION.md +635 -0
  18. package/docs/QA.md +142 -0
  19. package/docs/SHARED-CORE.md +72 -0
  20. package/install +75 -0
  21. package/package.json +92 -4
  22. package/scripts/install.sh +324 -0
  23. package/scripts/uninstall.sh +91 -0
  24. package/src/allowance.ts +135 -0
  25. package/src/core/classify/classifier.ts +305 -0
  26. package/src/core/config-writer.ts +167 -0
  27. package/src/core/config.ts +1030 -0
  28. package/src/core/context/strategy.ts +188 -0
  29. package/src/core/context-health.ts +39 -0
  30. package/src/core/failures.ts +398 -0
  31. package/src/core/history/opencode.ts +173 -0
  32. package/src/core/models/protocol.ts +22 -0
  33. package/src/core/models/registry.ts +203 -0
  34. package/src/core/models/seed.ts +771 -0
  35. package/src/core/offline/mode.ts +321 -0
  36. package/src/core/plan-page.ts +390 -0
  37. package/src/core/plans/catalog.ts +481 -0
  38. package/src/core/policy/calibration.ts +259 -0
  39. package/src/core/policy/eligibility.ts +578 -0
  40. package/src/core/providers/enablement.ts +143 -0
  41. package/src/core/providers/opendesign-credits.ts +206 -0
  42. package/src/core/providers/opendesign-models.ts +628 -0
  43. package/src/core/providers/opendesign.ts +474 -0
  44. package/src/core/quota/calibration.ts +166 -0
  45. package/src/core/quota/commandcode.ts +471 -0
  46. package/src/core/quota/credentials.ts +490 -0
  47. package/src/core/quota/ledger.ts +1273 -0
  48. package/src/core/quota/opencode-go.ts +148 -0
  49. package/src/core/quota/opendesign.ts +1043 -0
  50. package/src/core/quota/reconcile.ts +155 -0
  51. package/src/core/quota/report.ts +261 -0
  52. package/src/core/quota/service.ts +273 -0
  53. package/src/core/reprompt.ts +48 -0
  54. package/src/core/routing/engine.ts +919 -0
  55. package/src/core/types.ts +825 -0
  56. package/src/core/ui.ts +236 -0
  57. package/src/core/util.ts +114 -0
  58. package/src/gui/index.tsx +1670 -0
  59. package/src/gui/viewmodel.ts +456 -0
  60. package/src/index.ts +48 -0
  61. package/src/rpc.ts +204 -0
  62. package/src/server/controller.ts +3281 -0
  63. package/src/server/route-tracker.ts +60 -0
  64. package/src/server/shared-core.ts +671 -0
  65. package/src/tui/index.tsx +168 -0
@@ -0,0 +1,481 @@
1
+ /**
2
+ * Subscription plan definitions.
3
+ *
4
+ * A plan is a set of credit pools plus the window rules that apply to each
5
+ * pool. Nothing in this file describes a router policy: it is the published
6
+ * shape of a subscription, so a different user with a different plan can be
7
+ * described without touching routing code.
8
+ *
9
+ * The distinction that matters most, and that the old Go-only model could not
10
+ * express, is that a plan may fund usage from more than one pool:
11
+ *
12
+ * - a pool with window limits (a five-hour and/or seven-day ceiling nested
13
+ * inside a monthly maximum), and
14
+ * - a pool with no windows at all that resets once per billing cycle.
15
+ *
16
+ * They are separate allowances, not one interchangeable pot of money. Adding
17
+ * them together would overstate what can be spent inside a seven-day window,
18
+ * and spending the second pool drains the capacity reserved for escalation.
19
+ *
20
+ * Sources (retrieved 2026-10-29):
21
+ * - https://open-design.ai/model-credits/
22
+ * - https://open-design.ai/pricing/
23
+ * - https://opencode.ai/v2/docs/console/go/
24
+ * - Command Code account endpoints (read live by the quota adapter)
25
+ */
26
+
27
+ /**
28
+ * How a pool's window resets.
29
+ *
30
+ * `fixed-7-day` is not `rolling-7-day`, and the difference is not cosmetic.
31
+ * OpenDesign documents that the seven-day window "starts when the subscription
32
+ * begins and resets every 7 days" — subscribe Wednesday at 10:00 and it resets
33
+ * the following Wednesday at 10:00. A rolling window would instead slide
34
+ * continuously, so a request made today would still count tomorrow, and the
35
+ * router would under-report headroom for most of the week.
36
+ */
37
+ export type PoolResetKind = "billing-cycle" | "fixed-7-day" | "rolling-7-day"
38
+
39
+ export interface CreditPoolDefinition {
40
+ readonly id: string
41
+ readonly label: string
42
+ /** Maximum credits per billing cycle, in USD-equivalent credits. */
43
+ readonly monthlyUsd: number
44
+ /** Ceiling inside a rolling seven-day window. Nested, never additional. */
45
+ readonly weeklyUsd?: number
46
+ /** Ceiling inside a rolling five-hour window. Nested, never additional. */
47
+ readonly fiveHourUsd?: number
48
+ /** Promotional credits included in `monthlyUsd`, reported separately. */
49
+ readonly bonusUsd?: number
50
+ readonly resetKind: PoolResetKind
51
+ /**
52
+ * Draw order. Lower numbers are consumed first. Two pools may share a
53
+ * number only when they are genuinely interchangeable, which the catalog
54
+ * never does.
55
+ */
56
+ readonly drawOrder: number
57
+ readonly note: string
58
+ }
59
+
60
+ export interface PerModelWindowRule {
61
+ /** Five-hour ceiling as a percentage of that model's monthly allowance. */
62
+ readonly fiveHourPct: number
63
+ /** Seven-day ceiling as a percentage of that model's monthly allowance. */
64
+ readonly weeklyPct: number
65
+ }
66
+
67
+ export interface PlanDefinition {
68
+ readonly providerID: string
69
+ readonly planID: string
70
+ readonly label: string
71
+ /** Regular monthly subscription price in USD. Never a credit amount. */
72
+ readonly monthlyPriceUsd: number
73
+ readonly pools: ReadonlyArray<CreditPoolDefinition>
74
+ /**
75
+ * Monthly allowance per model, in USD-equivalent credits, for providers
76
+ * whose allowance is metered per model rather than as one shared pool.
77
+ */
78
+ readonly perModelMonthlyUsd?: Readonly<Record<string, number>>
79
+ /** Applied to each per-model allowance to derive its nested windows. */
80
+ readonly perModelWindowRule?: PerModelWindowRule
81
+ readonly concurrency?: number
82
+ readonly notes: ReadonlyArray<string>
83
+ }
84
+
85
+ /**
86
+ * The OpenDesign models funded from the shared Design Plan allowance.
87
+ *
88
+ * Everything the plan also serves that is not in this set draws from plan
89
+ * model credits directly. That pool is the escalation budget, so routing must
90
+ * never treat the two as one balance.
91
+ */
92
+ export const OPENDESIGN_DESIGN_PLAN_MODELS: ReadonlySet<string> = new Set([
93
+ "deepseek-v4.1-flash",
94
+ "mimo-v2.6-flash",
95
+ "mimo-v2.6-pro",
96
+ "hy4-preview",
97
+ "deepseek-v4-flash",
98
+ "deepseek-v4-flash-vision-exp",
99
+ "deepseek-v4-pro",
100
+ "glm-5.3-flash",
101
+ "kimi-k2.7-code",
102
+ "claude-haiku-5.5",
103
+ "glm-5.3-flashx",
104
+ "gpt-6-luna",
105
+ ])
106
+
107
+ export const OPENDESIGN_DESIGN_PLAN_POOL = "design-plan"
108
+ export const OPENDESIGN_PLAN_MODEL_POOL = "plan-models"
109
+
110
+ const opendesign = (
111
+ planID: string,
112
+ label: string,
113
+ monthlyPriceUsd: number,
114
+ designPlanMonthlyUsd: number,
115
+ planModelMonthlyUsd: number,
116
+ weeklyUsd: number | undefined,
117
+ fiveHourUsd: number | undefined,
118
+ concurrency: number,
119
+ extraNotes: ReadonlyArray<string> = [],
120
+ ): PlanDefinition => {
121
+ const pools: CreditPoolDefinition[] = []
122
+ if (designPlanMonthlyUsd > 0) {
123
+ pools.push({
124
+ id: OPENDESIGN_DESIGN_PLAN_POOL,
125
+ label: "Design Plan credits",
126
+ monthlyUsd: designPlanMonthlyUsd,
127
+ ...(weeklyUsd !== undefined ? { weeklyUsd } : {}),
128
+ ...(fiveHourUsd !== undefined ? { fiveHourUsd } : {}),
129
+ resetKind: "fixed-7-day",
130
+ drawOrder: 1,
131
+ note:
132
+ fiveHourUsd === undefined
133
+ ? "Seven-day ceiling nested inside the monthly maximum. Used before plan model credits."
134
+ : "Five-hour and seven-day ceilings nested inside the monthly maximum. Used before plan model credits.",
135
+ })
136
+ }
137
+ if (planModelMonthlyUsd > 0) {
138
+ pools.push({
139
+ id: OPENDESIGN_PLAN_MODEL_POOL,
140
+ label: "Plan model credits",
141
+ monthlyUsd: planModelMonthlyUsd,
142
+ resetKind: "billing-cycle",
143
+ drawOrder: 2,
144
+ note:
145
+ "No five-hour or seven-day window. Funds plan models directly, and Design Plan models once their own credits are exhausted. Design Plan multipliers do not carry over to this pool.",
146
+ })
147
+ }
148
+ return {
149
+ providerID: "opendesign",
150
+ planID,
151
+ label,
152
+ monthlyPriceUsd,
153
+ pools,
154
+ ...(concurrency > 0 ? { concurrency } : {}),
155
+ notes: [
156
+ "Credits are not cash and are not a fixed request count.",
157
+ "Design Plan credits and plan model credits are separate pools, but they do spill: on Plus, Pro and Max, Design Plan usage draws from plan model credits once the Design Plan allowance is spent. Go has no plan model credits, so a spent window means waiting for the reset.",
158
+ "The seven-day Design Plan window starts when the subscription starts and repeats every seven days. It is not a rolling window and does not align to Monday or the first of the month.",
159
+ "Plan model credits have no five-hour or seven-day window; they are granted and reset per billing cycle.",
160
+ "Unused subscription credits do not roll over.",
161
+ ...extraNotes,
162
+ ],
163
+ }
164
+ }
165
+
166
+ /**
167
+ * OpenDesign publishes four tiers. Only the shape differs between them, so
168
+ * each is described as data rather than as a branch in the quota adapter.
169
+ */
170
+ const OPENDESIGN_PLANS: ReadonlyArray<PlanDefinition> = [
171
+ opendesign("go", "OpenDesign Go", 10, 120, 0, 30, 10, 10, [
172
+ "Go includes Design Plan credits only and no plan model credits.",
173
+ ]),
174
+ // The seven-day Design Plan ceiling for Plus is $40, not "no cap". It was
175
+ // undefined here, which made Plus look like it could spend the whole $160 in
176
+ // one week — understating the real limit by 4x for anyone on that tier.
177
+ opendesign("plus", "OpenDesign Plus", 20, 160, 20, 40, undefined, 20, [
178
+ "Plus has a seven-day Design Plan window and no five-hour window.",
179
+ ]),
180
+ opendesign("pro", "OpenDesign Pro", 100, 360, 120, 90, undefined, 30, [
181
+ "Pro has a seven-day Design Plan window and no five-hour window.",
182
+ "The $120 plan model pool currently carries a limited-time promotional bonus and resets per billing cycle.",
183
+ ]),
184
+ opendesign("max", "OpenDesign Max", 200, 600, 300, 150, undefined, 30, [
185
+ "Max has a seven-day Design Plan window and no five-hour window.",
186
+ ]),
187
+ ]
188
+
189
+ /**
190
+ * OpenCode Go per-model monthly allowances.
191
+ *
192
+ * Each model has its own monthly dollar allowance; the five-hour ceiling is
193
+ * 20% of that model's monthly figure and the weekly ceiling is 50%. The
194
+ * windows nest inside the monthly figure, so they are never added to it.
195
+ */
196
+ const GO_MODEL_ALLOWANCES: Readonly<Record<string, number>> = {
197
+ "deepseek-v4.1-flash": 80,
198
+ "deepseek-v4-pro": 15,
199
+ "glm-5.3-flash": 60,
200
+ "glm-5.3": 15,
201
+ "glm-5.2": 60,
202
+ "minimax-m3.1-flash": 20,
203
+ "mimo-v2.6-flash": 60,
204
+ "mimo-v2.6-pro": 15,
205
+ "claude-haiku-5-5": 15,
206
+ "gpt-6-luna": 15,
207
+ "gpt-6-luna-high": 15,
208
+ "kimi-k3": 15,
209
+ "kimi-k3.1-pro": 15,
210
+ "kimi-k2.7-code": 60,
211
+ "grok-4.7": 15,
212
+ "qwen3.8-max": 15,
213
+ "qwen3.8-flash": 30,
214
+ "step-5-preview": 15,
215
+ "longcat-2.0": 60,
216
+ hy3: 60,
217
+ "hy4-preview": 30,
218
+ "gemini-4-argon": 30,
219
+ }
220
+
221
+ const GO_PLUS_MODEL_ALLOWANCES: Readonly<Record<string, number>> = {
222
+ "deepseek-v4.1-flash": 160,
223
+ "deepseek-v4-pro": 60,
224
+ "glm-5.3-flash": 180,
225
+ "glm-5.3": 120,
226
+ "glm-5.2": 180,
227
+ "minimax-m3.1-flash": 80,
228
+ "mimo-v2.6-flash": 120,
229
+ "mimo-v2.6-pro": 60,
230
+ "claude-haiku-5-5": 60,
231
+ "gpt-6-luna": 60,
232
+ "kimi-k3": 60,
233
+ "kimi-k3.1-pro": 60,
234
+ "kimi-k2.7-code": 180,
235
+ "grok-4.7": 60,
236
+ "qwen3.8-max": 60,
237
+ "qwen3.8-flash": 90,
238
+ "step-5-preview": 60,
239
+ "longcat-2.0": 240,
240
+ hy3: 240,
241
+ "hy4-preview": 120,
242
+ "gemini-4-argon": 120,
243
+ }
244
+
245
+ const openCodeGoPlan = (
246
+ planID: string,
247
+ label: string,
248
+ monthlyPriceUsd: number,
249
+ allowances: Readonly<Record<string, number>>,
250
+ ): PlanDefinition => ({
251
+ providerID: "opencode-go",
252
+ planID,
253
+ label,
254
+ monthlyPriceUsd,
255
+ pools: [
256
+ {
257
+ id: "per-model",
258
+ label: "Per-model monthly allowances",
259
+ // There is no shared pool; the monthly total is the sum of the
260
+ // per-model ceilings for reporting only.
261
+ monthlyUsd: Object.values(allowances).reduce((sum, value) => sum + value, 0),
262
+ resetKind: "billing-cycle",
263
+ drawOrder: 1,
264
+ note:
265
+ "Each model has its own monthly dollar allowance. The five-hour and weekly ceilings are percentages of that model's allowance, not separate credit.",
266
+ },
267
+ ],
268
+ perModelMonthlyUsd: allowances,
269
+ perModelWindowRule: { fiveHourPct: 20, weeklyPct: 50 },
270
+ notes: [
271
+ "Allowances are per model and do not pool across models.",
272
+ "Token prices are identical on Go, Go Plus and Go Teams; only the allowances differ.",
273
+ ],
274
+ })
275
+
276
+ /**
277
+ * Command Code GOAT. The quota adapter reads the authoritative caps live from
278
+ * the account endpoints, so these values are the documented fallback only.
279
+ */
280
+ const COMMANDCODE_PLANS: ReadonlyArray<PlanDefinition> = [
281
+ {
282
+ providerID: "commandcode",
283
+ planID: "goat",
284
+ label: "Command Code GOAT",
285
+ monthlyPriceUsd: 10,
286
+ pools: [
287
+ {
288
+ id: "monthly",
289
+ label: "GOAT monthly credits",
290
+ monthlyUsd: 70,
291
+ weeklyUsd: 35,
292
+ fiveHourUsd: 14,
293
+ resetKind: "billing-cycle",
294
+ drawOrder: 1,
295
+ note: "One shared pool with five-hour and weekly ceilings nested inside the monthly maximum.",
296
+ },
297
+ ],
298
+ notes: ["Read from Command Code's read-only account endpoints; the account response is authoritative."],
299
+ },
300
+ ]
301
+
302
+ export const PLAN_CATALOG: ReadonlyArray<PlanDefinition> = [
303
+ ...OPENDESIGN_PLANS,
304
+ openCodeGoPlan("go", "OpenCode Go", 10, GO_MODEL_ALLOWANCES),
305
+ openCodeGoPlan("go-plus", "OpenCode Go Plus", 40, GO_PLUS_MODEL_ALLOWANCES),
306
+ ...COMMANDCODE_PLANS,
307
+ ]
308
+
309
+ const PLAN_BY_KEY: ReadonlyMap<string, PlanDefinition> = new Map(
310
+ PLAN_CATALOG.map((plan) => [`${plan.providerID}/${plan.planID}`, plan]),
311
+ )
312
+
313
+ /**
314
+ * The plan each provider assumes when settings name nothing usable.
315
+ *
316
+ * These are *fallbacks*, not recommendations. Every one of them is reported as
317
+ * `resolution: "default"` so a caller can warn that an assumption was made
318
+ * rather than silently pretending the user configured that tier.
319
+ */
320
+ export const DEFAULT_PLAN_ID: Readonly<Record<string, string>> = {
321
+ opendesign: "pro",
322
+ "opencode-go": "go",
323
+ commandcode: "goat",
324
+ }
325
+
326
+ /** The catalog default plan for a provider, if the provider is described. */
327
+ export const defaultPlanIdFor = (providerID: string): string | undefined => DEFAULT_PLAN_ID[providerID]
328
+
329
+ /**
330
+ * Providers whose plan IDs are aliases the router still encounters in older
331
+ * configuration files. Mapping them keeps an old config from silently
332
+ * describing a plan the user no longer has.
333
+ */
334
+ const PLAN_ALIASES: Readonly<Record<string, string>> = {
335
+ "opendesign/go-plus": "plus",
336
+ "opendesign/max-plan": "max",
337
+ "opencode-go/goplus": "go-plus",
338
+ "opencode-go/go+": "go-plus",
339
+ }
340
+
341
+ /**
342
+ * How a plan id was resolved.
343
+ *
344
+ * - `exact` the configured id is a catalog entry
345
+ * - `alias` a legacy id was mapped to its current equivalent
346
+ * - `default` no usable id was configured, so the provider default applies
347
+ * - `unknown` an id was configured but matches nothing, so the default applies
348
+ */
349
+ export type PlanResolution = "exact" | "alias" | "default" | "unknown"
350
+
351
+ export interface ResolvedPlan {
352
+ readonly plan: PlanDefinition | undefined
353
+ readonly resolution: PlanResolution
354
+ /** The id actually matched, useful for diagnostics. */
355
+ readonly planID: string | undefined
356
+ }
357
+
358
+ /**
359
+ * Resolve a plan id, reporting *how* it was resolved.
360
+ *
361
+ * A silent fallback is dangerous on a billing boundary: a typo in `plan` would
362
+ * otherwise grant whatever tier this repository happens to default to, which
363
+ * could overstate a user's allowance by an order of magnitude.
364
+ *
365
+ * An *unknown* id therefore resolves to no plan at all rather than to the
366
+ * default. Callers treat "no plan" as unknown capacity, not as the maintainer's
367
+ * tier. `resolution: "default"` is reserved for the case where no id was
368
+ * configured at all, which is a documented starting point rather than a typo.
369
+ */
370
+ export const resolvePlanWithReason = (
371
+ providerID: string,
372
+ planID: string | undefined,
373
+ ): ResolvedPlan => {
374
+ const requested = (planID ?? "").trim().toLowerCase()
375
+ if (requested) {
376
+ const aliasTarget = PLAN_ALIASES[`${providerID}/${requested}`]
377
+ if (aliasTarget) {
378
+ const plan = PLAN_BY_KEY.get(`${providerID}/${aliasTarget}`)
379
+ if (plan) return { plan, resolution: "alias", planID: plan.planID }
380
+ }
381
+ const direct = PLAN_BY_KEY.get(`${providerID}/${requested}`)
382
+ if (direct) return { plan: direct, resolution: "exact", planID: direct.planID }
383
+ // Configured but unrecognized: refuse to guess a tier.
384
+ return { plan: undefined, resolution: "unknown", planID: undefined }
385
+ }
386
+ const fallback = DEFAULT_PLAN_ID[providerID]
387
+ return {
388
+ plan: fallback ? PLAN_BY_KEY.get(`${providerID}/${fallback}`) : undefined,
389
+ resolution: fallback ? "default" : "unknown",
390
+ planID: fallback,
391
+ }
392
+ }
393
+
394
+ export const resolvePlan = (providerID: string, planID: string | undefined): PlanDefinition | undefined =>
395
+ resolvePlanWithReason(providerID, planID).plan
396
+
397
+ export const planById = (providerID: string, planID: string): PlanDefinition | undefined =>
398
+ PLAN_BY_KEY.get(`${providerID}/${planID}`)
399
+
400
+ /**
401
+ * Which pool funds a model.
402
+ *
403
+ * OpenDesign is the only provider in the catalog with more than one pool, so
404
+ * this is where the Design Plan / plan model split is applied. A provider
405
+ * whose model is not named in the Design Plan group draws from plan model
406
+ * credits directly.
407
+ */
408
+ export const fundingPoolFor = (plan: PlanDefinition, modelID: string): CreditPoolDefinition | undefined => {
409
+ if (plan.providerID === "opendesign") {
410
+ const designPlan = plan.pools.find((pool) => pool.id === OPENDESIGN_DESIGN_PLAN_POOL)
411
+ const planModels = plan.pools.find((pool) => pool.id === OPENDESIGN_PLAN_MODEL_POOL)
412
+ if (OPENDESIGN_DESIGN_PLAN_MODELS.has(modelID)) return designPlan ?? planModels
413
+ return planModels ?? designPlan
414
+ }
415
+ return [...plan.pools].sort((a, b) => a.drawOrder - b.drawOrder)[0]
416
+ }
417
+
418
+ /** True when the model is funded from a pool reserved for escalation. */
419
+ export const isEscalationOnlyPool = (plan: PlanDefinition, modelID: string): boolean => {
420
+ if (plan.providerID !== "opendesign") return false
421
+ return !OPENDESIGN_DESIGN_PLAN_MODELS.has(modelID)
422
+ }
423
+
424
+ /**
425
+ * Design Plan credits are consumed before plan model credits. Once the first
426
+ * pool is exhausted a Design Plan model keeps working, but the published
427
+ * multiplier does not follow it into the second pool, so the effective credit
428
+ * rate rises. Callers must not apply a Design Plan multiplier blindly.
429
+ */
430
+ export const poolDrawSequence = (
431
+ plan: PlanDefinition,
432
+ modelID: string,
433
+ ): ReadonlyArray<{ pool: CreditPoolDefinition; multiplierApplies: boolean }> => {
434
+ const primary = fundingPoolFor(plan, modelID)
435
+ if (!primary) return []
436
+ const sequence = [{ pool: primary, multiplierApplies: plan.providerID !== "opendesign" || primary.id === OPENDESIGN_DESIGN_PLAN_POOL }]
437
+ if (plan.providerID !== "opendesign") return sequence
438
+ const designPlan = plan.pools.find((pool) => pool.id === OPENDESIGN_DESIGN_PLAN_POOL)
439
+ const planModels = plan.pools.find((pool) => pool.id === OPENDESIGN_PLAN_MODEL_POOL)
440
+ if (primary.id === OPENDESIGN_DESIGN_PLAN_POOL && planModels) {
441
+ sequence.push({ pool: planModels, multiplierApplies: false })
442
+ } else if (primary.id === OPENDESIGN_PLAN_MODEL_POOL && designPlan && OPENDESIGN_DESIGN_PLAN_MODELS.has(modelID)) {
443
+ // A Design Plan model always draws its own pool first.
444
+ sequence.unshift({ pool: designPlan, multiplierApplies: true })
445
+ }
446
+ return sequence
447
+ }
448
+
449
+ /** Monthly per-model allowance for plan-metered providers. */
450
+ export const modelMonthlyAllowanceUsd = (plan: PlanDefinition, modelID: string): number => {
451
+ const table = plan.perModelMonthlyUsd
452
+ if (!table) return 0
453
+ return table[modelID] ?? 0
454
+ }
455
+
456
+ /** The nested window caps for one model, derived from its monthly allowance. */
457
+ export const modelWindowCaps = (
458
+ plan: PlanDefinition,
459
+ modelID: string,
460
+ ): { monthlyUsd: number; weeklyUsd: number; fiveHourUsd: number } => {
461
+ const monthlyUsd = modelMonthlyAllowanceUsd(plan, modelID)
462
+ const rule = plan.perModelWindowRule
463
+ if (!rule || monthlyUsd <= 0) return { monthlyUsd, weeklyUsd: 0, fiveHourUsd: 0 }
464
+ return {
465
+ monthlyUsd,
466
+ weeklyUsd: monthlyUsd * (rule.weeklyPct / 100),
467
+ fiveHourUsd: monthlyUsd * (rule.fiveHourPct / 100),
468
+ }
469
+ }
470
+
471
+ export const planSummaryLine = (plan: PlanDefinition): string => {
472
+ const parts = plan.pools.map((pool) => {
473
+ const windows = [
474
+ pool.fiveHourUsd !== undefined ? `5h $${pool.fiveHourUsd}` : undefined,
475
+ pool.weeklyUsd !== undefined ? `7d $${pool.weeklyUsd}` : undefined,
476
+ ].filter((entry): entry is string => Boolean(entry))
477
+ const suffix = windows.length > 0 ? ` (${windows.join(", ")} nested)` : ""
478
+ return `${pool.label} $${pool.monthlyUsd}/mo${suffix}`
479
+ })
480
+ return `${plan.label} ($${plan.monthlyPriceUsd}/mo): ${parts.join(" + ")}`
481
+ }