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