@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.
- package/CHANGELOG.md +118 -0
- package/CONTRIBUTING.md +96 -0
- package/LICENSE +21 -0
- package/README.md +341 -2
- package/SECURITY.md +174 -0
- package/config.example.json +108 -0
- package/dist/allowance.js +487 -0
- package/dist/gui-experimental.cjs +1 -0
- package/dist/index.js +11111 -0
- package/dist/npm/index.js +40 -0
- package/dist/tui.js +587 -0
- package/docs/ARCHITECTURE.md +104 -0
- package/docs/DEBUGGING-GUI.md +70 -0
- package/docs/INSTALL.md +120 -0
- package/docs/LIMITATIONS.md +213 -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 +72 -0
- package/install +75 -0
- package/package.json +92 -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 +1030 -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 +203 -0
- package/src/core/models/seed.ts +771 -0
- package/src/core/offline/mode.ts +321 -0
- package/src/core/plan-page.ts +390 -0
- package/src/core/plans/catalog.ts +481 -0
- package/src/core/policy/calibration.ts +259 -0
- package/src/core/policy/eligibility.ts +578 -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 +628 -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 +1273 -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 +919 -0
- package/src/core/types.ts +825 -0
- package/src/core/ui.ts +236 -0
- package/src/core/util.ts +114 -0
- package/src/gui/index.tsx +1670 -0
- package/src/gui/viewmodel.ts +456 -0
- package/src/index.ts +48 -0
- package/src/rpc.ts +204 -0
- package/src/server/controller.ts +3281 -0
- package/src/server/route-tracker.ts +60 -0
- package/src/server/shared-core.ts +671 -0
- package/src/tui/index.tsx +168 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { parseResetIn } from "./config-writer.js"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Read any pasted OpenDesign console page and decide what it is.
|
|
5
|
+
*
|
|
6
|
+
* One normalisation pass, then a set of matchers run in confidence order. A
|
|
7
|
+
* user copies whatever page they happen to be on — Plan & balance, Usage,
|
|
8
|
+
* Billing — and the router works out which it got. Asking someone to choose the
|
|
9
|
+
* right page first is a step that exists only because the software was not
|
|
10
|
+
* paying attention.
|
|
11
|
+
*
|
|
12
|
+
* The two readings are different in kind and are kept apart deliberately:
|
|
13
|
+
*
|
|
14
|
+
* - **allowance** (Plan & balance): a percentage of a window. Cheap to
|
|
15
|
+
* produce, and the starting point for every estimate.
|
|
16
|
+
* - **usage** (Usage page): per-model tokens and, when the page shows it,
|
|
17
|
+
* credits or dollars actually charged.
|
|
18
|
+
*
|
|
19
|
+
* Neither is trusted over the other. A usage reading that agrees with the
|
|
20
|
+
* current estimate confirms it; one that disagrees is evidence the cost model is
|
|
21
|
+
* wrong, and that is worth knowing rather than averaging away.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export interface AllowanceReading {
|
|
25
|
+
readonly kind: "allowance"
|
|
26
|
+
readonly plan?: string
|
|
27
|
+
readonly designPlanUsedPct?: number
|
|
28
|
+
readonly pctOf?: "weekly" | "monthly"
|
|
29
|
+
readonly resetsAt?: number
|
|
30
|
+
readonly periodEndsAt?: number
|
|
31
|
+
readonly walletUsd?: number
|
|
32
|
+
readonly found: ReadonlyArray<string>
|
|
33
|
+
readonly missing: ReadonlyArray<string>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface UsageModelRow {
|
|
37
|
+
readonly modelName?: string
|
|
38
|
+
/** Present when the page shows token counts. */
|
|
39
|
+
readonly inputTokens?: number
|
|
40
|
+
readonly outputTokens?: number
|
|
41
|
+
readonly cacheReadTokens?: number
|
|
42
|
+
/** Present when the page shows credits or dollars. */
|
|
43
|
+
readonly chargedUsd?: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface UsageReading {
|
|
47
|
+
readonly kind: "usage"
|
|
48
|
+
readonly rows: ReadonlyArray<UsageModelRow>
|
|
49
|
+
readonly totalUsd?: number
|
|
50
|
+
readonly periodStart?: number
|
|
51
|
+
readonly periodEnd?: number
|
|
52
|
+
readonly found: ReadonlyArray<string>
|
|
53
|
+
readonly missing: ReadonlyArray<string>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type ConsoleReading = AllowanceReading | UsageReading
|
|
57
|
+
|
|
58
|
+
const normalize = (text: string): string =>
|
|
59
|
+
text
|
|
60
|
+
.replace(/\r\n?/g, "\n")
|
|
61
|
+
// Keep line structure; both pages are block-oriented and collapsing newlines
|
|
62
|
+
// would let a value from one card satisfy a label from another.
|
|
63
|
+
.replace(/[ \t\u00a0]+/g, " ")
|
|
64
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
65
|
+
|
|
66
|
+
const numberFrom = (raw: string | undefined): number | undefined => {
|
|
67
|
+
if (raw === undefined) return undefined
|
|
68
|
+
// "1,234.5" and "1 234,5" both appear in localised consoles.
|
|
69
|
+
const cleaned = raw.replace(/,/g, "".replace(/x/, ""))
|
|
70
|
+
const value = Number(cleaned)
|
|
71
|
+
return Number.isFinite(value) ? value : undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const CARD_LABELS = [
|
|
75
|
+
/Design Plan credits/i,
|
|
76
|
+
/Pay-as-you-go credits/i,
|
|
77
|
+
/Credit breakdown/i,
|
|
78
|
+
/Auto top-up/i,
|
|
79
|
+
/Top up/i,
|
|
80
|
+
/Reset cards/i,
|
|
81
|
+
/Manage subscription/i,
|
|
82
|
+
/Total/i,
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
/** The text belonging to one card: its label up to the next card or blank line. */
|
|
86
|
+
const cardAfter = (text: string, label: RegExp, limit = 200): string | undefined => {
|
|
87
|
+
const start = label.exec(text)
|
|
88
|
+
if (!start) return undefined
|
|
89
|
+
const rest = text.slice(start.index + start[0].length)
|
|
90
|
+
let end = Math.min(rest.length, limit)
|
|
91
|
+
for (const heading of CARD_LABELS) {
|
|
92
|
+
if (heading.source === label.source) continue
|
|
93
|
+
const found = heading.exec(rest)
|
|
94
|
+
if (found && found.index > 0) end = Math.min(end, found.index)
|
|
95
|
+
}
|
|
96
|
+
const blank = rest.indexOf("\n\n")
|
|
97
|
+
if (blank > 0) end = Math.min(end, blank)
|
|
98
|
+
return rest.slice(0, end)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const inCard = (text: string, label: RegExp, pattern: RegExp, limit = 200): string | undefined => {
|
|
102
|
+
const block = cardAfter(text, label, limit)
|
|
103
|
+
return block === undefined ? undefined : pattern.exec(block)?.[1]?.trim() || undefined
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Allowance: the Plan & balance page
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
const readAllowance = (text: string, now: number): AllowanceReading | undefined => {
|
|
111
|
+
const designPct = numberFrom(inCard(text, /Design Plan credits/i, /(\d{1,3})\s*%/, 120))
|
|
112
|
+
// An allowance page without a percentage is not an allowance page.
|
|
113
|
+
if (designPct === undefined) return undefined
|
|
114
|
+
|
|
115
|
+
const found = ["Design Plan usage"]
|
|
116
|
+
const missing: string[] = []
|
|
117
|
+
|
|
118
|
+
const planLine = /^\s*(Go|Plus|Pro|Max)\s*$/m.exec(text)?.[1]
|
|
119
|
+
if (planLine) found.push("plan")
|
|
120
|
+
else missing.push("plan")
|
|
121
|
+
|
|
122
|
+
let pctOf: "weekly" | "monthly" | undefined
|
|
123
|
+
if (/\d+\s*days?\s*of\s*quota/i.test(text) || /seven[-\s]?day/i.test(text)) pctOf = "weekly"
|
|
124
|
+
else if (/monthly\s*(allowance|quota|credits)/i.test(text)) pctOf = "monthly"
|
|
125
|
+
if (pctOf) found.push("allowance window")
|
|
126
|
+
|
|
127
|
+
const resetText = /\bReset in\s+([0-9]+\s*(?:days?|hours?|minutes?)[^\n]*)/i.exec(text)?.[1]
|
|
128
|
+
const resetsAt = resetText ? parseResetIn(resetText, now) : undefined
|
|
129
|
+
if (resetsAt !== undefined) found.push("reset time")
|
|
130
|
+
else missing.push("reset time")
|
|
131
|
+
|
|
132
|
+
const periodText = /This period ends on\s+([A-Z][a-z]+ \d{1,2},\s*\d{4})/i.exec(text)?.[1]
|
|
133
|
+
const periodEndsAt = periodText ? Date.parse(periodText) : undefined
|
|
134
|
+
if (periodEndsAt !== undefined && Number.isFinite(periodEndsAt)) found.push("billing period")
|
|
135
|
+
else missing.push("billing period")
|
|
136
|
+
|
|
137
|
+
const wallet = numberFrom(inCard(text, /Pay-as-you-go credits/i, /\$\s*([\d,]+(?:\.\d{1,2})?)/, 120))
|
|
138
|
+
if (wallet !== undefined) found.push("wallet balance")
|
|
139
|
+
else missing.push("wallet balance")
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
kind: "allowance",
|
|
143
|
+
...(planLine ? { plan: planLine } : {}),
|
|
144
|
+
designPlanUsedPct: Math.min(100, Math.max(0, designPct)),
|
|
145
|
+
...(pctOf ? { pctOf } : {}),
|
|
146
|
+
...(resetsAt !== undefined ? { resetsAt } : {}),
|
|
147
|
+
...(periodEndsAt !== undefined && Number.isFinite(periodEndsAt) ? { periodEndsAt } : {}),
|
|
148
|
+
...(wallet !== undefined ? { walletUsd: wallet } : {}),
|
|
149
|
+
found,
|
|
150
|
+
missing,
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Usage: the console's usage table
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
const datePatterns = [
|
|
159
|
+
/\b([A-Z][a-z]{2} \d{1,2},? \d{4})\b/,
|
|
160
|
+
/\b(\d{4}-\d{2}-\d{2})\b/,
|
|
161
|
+
/\b(\d{1,2}\/\d{1,2}\/\d{4})\b/,
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* A usage row looks like: model name, then some numbers, one of which may be a
|
|
166
|
+
* dollar amount. Columns are separated by whitespace or tabs once the page is
|
|
167
|
+
* normalised, and the order varies, so a row is recognised by its shape rather
|
|
168
|
+
* than by fixed positions.
|
|
169
|
+
*/
|
|
170
|
+
const parseUsageRow = (line: string): UsageModelRow | undefined => {
|
|
171
|
+
const usd = /\$\s*([\d,]+(?:\.\d{1,4})?)/.exec(line)?.[1]
|
|
172
|
+
const numerics = [...line.matchAll(/\b(\d{1,3}(?:,\d{3})+|\d+(?:\.\d+)?)\b/g)]
|
|
173
|
+
.map((m) => Number(m[1]!.replace(/,/g, "")))
|
|
174
|
+
.filter((n) => Number.isFinite(n))
|
|
175
|
+
|
|
176
|
+
// A row needs at least one number to be a usage row.
|
|
177
|
+
if (numerics.length === 0 && usd === undefined) return undefined
|
|
178
|
+
|
|
179
|
+
// The name is the leading text before the first number.
|
|
180
|
+
const firstNumberIndex = line.search(/\d/)
|
|
181
|
+
const name = firstNumberIndex > 2 ? line.slice(0, firstNumberIndex).trim().replace(/[-–—|:]+$/, "").trim() : undefined
|
|
182
|
+
if (!name || name.length < 2) return undefined
|
|
183
|
+
// Headers and navigation are not rows.
|
|
184
|
+
if (/^(model|member|caller|total|time|date|usage|credits|tokens|input|output|cached)$/i.test(name)) return undefined
|
|
185
|
+
|
|
186
|
+
const chargedUsd = usd !== undefined ? numberFrom(usd) : undefined
|
|
187
|
+
// Without a dollar column the numbers are tokens. Big numbers are tokens;
|
|
188
|
+
// small ones are more likely percentages or counts, which are not useful here.
|
|
189
|
+
const tokenCandidate = numerics.filter((n) => n >= 1_000)
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
modelName: name,
|
|
193
|
+
...(chargedUsd !== undefined ? { chargedUsd } : {}),
|
|
194
|
+
...(tokenCandidate[0] !== undefined ? { inputTokens: tokenCandidate[0] } : {}),
|
|
195
|
+
...(tokenCandidate[1] !== undefined ? { outputTokens: tokenCandidate[1] } : {}),
|
|
196
|
+
...(tokenCandidate[2] !== undefined ? { cacheReadTokens: tokenCandidate[2] } : {}),
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Usage rows, in the shape a person's clipboard actually produces.
|
|
202
|
+
*
|
|
203
|
+
* Copying a table gives the cells on separate lines — model name, then each
|
|
204
|
+
* number, then the next model. Copying a row gives them on one line. Both are
|
|
205
|
+
* accepted: the parser tracks a current row and attaches numbers to it until the
|
|
206
|
+
* next name appears.
|
|
207
|
+
*/
|
|
208
|
+
const HEADER = /^(model|member|caller|time|date|usage|credits?|tokens?|input|output|cached|cost|total|range|period)$/i
|
|
209
|
+
const NOISE = /^(usage|billing|plan & balance|api keys|members|settings|open design|invite colleagues|pricing|arena|models & pricing|help & docs|top up|credit breakdown|auto top-up|reset cards|manage subscription|upgrade)$/i
|
|
210
|
+
|
|
211
|
+
const looksLikeNumber = (line: string): boolean => /^\$?\s*[\d,]+(\.\d+)?%?$/.test(line)
|
|
212
|
+
const looksLikeName = (line: string): boolean =>
|
|
213
|
+
!looksLikeNumber(line) && !HEADER.test(line) && !NOISE.test(line) && /[A-Za-z]/.test(line) && line.length >= 2 && line.length <= 60
|
|
214
|
+
|
|
215
|
+
const readUsage = (text: string, now: number): UsageReading | undefined => {
|
|
216
|
+
const lines = text.split("\n").map((line) => line.trim()).filter(Boolean)
|
|
217
|
+
const rows: UsageModelRow[] = []
|
|
218
|
+
|
|
219
|
+
let name: string | undefined
|
|
220
|
+
let nums: number[] = []
|
|
221
|
+
let usd: number | undefined
|
|
222
|
+
|
|
223
|
+
const flush = () => {
|
|
224
|
+
if (!name || (nums.length === 0 && usd === undefined)) {
|
|
225
|
+
name = undefined
|
|
226
|
+
nums = []
|
|
227
|
+
usd = undefined
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
// Token counts are large; percentages and small counts are not usage.
|
|
231
|
+
const tokens = nums.filter((n) => n >= 1_000)
|
|
232
|
+
rows.push({
|
|
233
|
+
modelName: name,
|
|
234
|
+
...(usd !== undefined ? { chargedUsd: usd } : {}),
|
|
235
|
+
...(tokens[0] !== undefined ? { inputTokens: tokens[0] } : {}),
|
|
236
|
+
...(tokens[1] !== undefined ? { outputTokens: tokens[1] } : {}),
|
|
237
|
+
...(tokens[2] !== undefined ? { cacheReadTokens: tokens[2] } : {}),
|
|
238
|
+
})
|
|
239
|
+
name = undefined
|
|
240
|
+
nums = []
|
|
241
|
+
usd = undefined
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const DATE_LINE = /\b\d{4}\b.*\b\d{4}\b|\b[A-Z][a-z]{2} \d{1,2}, \d{4}\b/
|
|
245
|
+
// A name followed by whitespace-separated numeric tokens, e.g.
|
|
246
|
+
// "GLM-5.3 Flash 2,000,000 $0.15". The trailing group must be numbers only:
|
|
247
|
+
// "DeepSeek V4.1 Flash" ends in letters and is therefore a name, not a row.
|
|
248
|
+
const INLINE = /^(.+?)\s+((?:\$?[\d,]+(?:\.\d+)?%?\s*)+)$/
|
|
249
|
+
|
|
250
|
+
for (const line of lines) {
|
|
251
|
+
// A date range is period information, not a usage row.
|
|
252
|
+
if (DATE_LINE.test(line)) continue
|
|
253
|
+
|
|
254
|
+
const dollarOnly = /^\$\s*([\d,]+(?:\.\d{1,4})?)$/.exec(line)
|
|
255
|
+
if (dollarOnly) {
|
|
256
|
+
// A second dollar figure while a row is still open belongs to a new row
|
|
257
|
+
// (the Total line), not to the one in progress. Without this the total
|
|
258
|
+
// overwrites the last model's charge and every reconciliation after it is
|
|
259
|
+
// wrong in a way that looks like a catalog error.
|
|
260
|
+
if (usd !== undefined) {
|
|
261
|
+
flush()
|
|
262
|
+
}
|
|
263
|
+
usd = numberFrom(dollarOnly[1])
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
if (looksLikeNumber(line)) {
|
|
267
|
+
const value = Number(line.replace(/[$,%]/g, ""))
|
|
268
|
+
if (Number.isFinite(value)) nums.push(value)
|
|
269
|
+
continue
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const inline = INLINE.exec(line)
|
|
273
|
+
// A trailing "5.5" is part of a model name ("Claude Haiku 5.5"), not a
|
|
274
|
+
// measurement. An inline row must carry a dollar amount or a real token
|
|
275
|
+
// count; anything smaller is version numbering.
|
|
276
|
+
const trailing = inline?.[2] ?? ""
|
|
277
|
+
const carriesData = /\$\s*\d/.test(trailing) || /\d{4,}/.test(trailing) || /\d{1,3}(?:,\d{3})+/.test(trailing)
|
|
278
|
+
if (inline && carriesData) {
|
|
279
|
+
const candidate = inline[1]!.trim()
|
|
280
|
+
if (looksLikeName(candidate)) {
|
|
281
|
+
flush()
|
|
282
|
+
const dollar = /\$\s*([\d,]+(?:\.\d{1,4})?)/.exec(inline[2]!)
|
|
283
|
+
usd = dollar ? numberFrom(dollar[1]) : undefined
|
|
284
|
+
nums = [...inline[2]!.replace(/\$[\d,]+(?:\.\d+)?/g, " ").matchAll(/\b(\d{1,3}(?:,\d{3})+|\d+(?:\.\d+)?)\b/g)]
|
|
285
|
+
.map((m) => Number(m[1]!.replace(/,/g, "")))
|
|
286
|
+
.filter((n) => Number.isFinite(n))
|
|
287
|
+
name = candidate
|
|
288
|
+
flush()
|
|
289
|
+
}
|
|
290
|
+
continue
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (looksLikeName(line)) {
|
|
294
|
+
flush()
|
|
295
|
+
name = line
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
flush()
|
|
300
|
+
|
|
301
|
+
const withCharge = rows.filter((row) => row.chargedUsd !== undefined)
|
|
302
|
+
if (rows.length < 3 && withCharge.length === 0) return undefined
|
|
303
|
+
|
|
304
|
+
const found = [`${rows.length} usage rows`]
|
|
305
|
+
const totalRaw = /\bTotal[^\n$]*\$\s*([\d,]+(?:\.\d{1,2})?)/i.exec(text)?.[1]
|
|
306
|
+
const totalUsd = totalRaw !== undefined ? numberFrom(totalRaw) : undefined
|
|
307
|
+
if (totalUsd !== undefined) found.push("total")
|
|
308
|
+
|
|
309
|
+
const dates: number[] = []
|
|
310
|
+
for (const pattern of datePatterns) {
|
|
311
|
+
for (const match of text.matchAll(new RegExp(pattern.source, "g"))) {
|
|
312
|
+
const parsed = Date.parse(match[1]!)
|
|
313
|
+
if (Number.isFinite(parsed)) dates.push(parsed)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
dates.sort((a, b) => a - b)
|
|
317
|
+
const periodStart = dates[0]
|
|
318
|
+
const periodEnd = dates.length > 1 ? dates[dates.length - 1] : undefined
|
|
319
|
+
if (periodStart !== undefined) found.push("date range")
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
kind: "usage",
|
|
323
|
+
rows,
|
|
324
|
+
...(totalUsd !== undefined ? { totalUsd } : {}),
|
|
325
|
+
...(periodStart !== undefined ? { periodStart } : {}),
|
|
326
|
+
...(periodEnd !== undefined ? { periodEnd } : {}),
|
|
327
|
+
found,
|
|
328
|
+
missing: withCharge.length === 0 ? ["charged amounts (tokens only, credits cannot be confirmed)"] : [],
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Decide which page was pasted.
|
|
334
|
+
*
|
|
335
|
+
* Allowance is checked first: it requires a percentage inside a specific card,
|
|
336
|
+
* which a usage table does not have. Usage then requires several rows. A page
|
|
337
|
+
* matching neither is reported as unrecognised rather than half-read, because a
|
|
338
|
+
* half-read page produces a confident wrong number.
|
|
339
|
+
*/
|
|
340
|
+
export const parseConsolePage = (raw: string, now = Date.now()): ConsoleReading | undefined => {
|
|
341
|
+
const text = normalize(raw)
|
|
342
|
+
return readAllowance(text, now) ?? readUsage(text, now)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Kept for the earlier allowlist-only call sites. */
|
|
346
|
+
export const parsePlanPage = (raw: string, now = Date.now()): AllowanceReading => {
|
|
347
|
+
const reading = readAllowance(normalize(raw), now)
|
|
348
|
+
if (reading) return reading
|
|
349
|
+
return {
|
|
350
|
+
kind: "allowance",
|
|
351
|
+
found: [],
|
|
352
|
+
missing: ["Design Plan usage"],
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* One line a person can check at a glance before committing it.
|
|
360
|
+
*
|
|
361
|
+
* Written as a sentence rather than a table because it is read once, in a form,
|
|
362
|
+
* and the point is whether it sounds like the page they just copied.
|
|
363
|
+
*/
|
|
364
|
+
export const describeConsoleReading = (reading: ConsoleReading): string => {
|
|
365
|
+
if (reading.kind === "allowance") {
|
|
366
|
+
if (reading.designPlanUsedPct === undefined) {
|
|
367
|
+
return "Could not find a Design Plan percentage on that page. Copy the Plan & balance page and try again."
|
|
368
|
+
}
|
|
369
|
+
const parts = [
|
|
370
|
+
`${reading.designPlanUsedPct}% of the ${reading.pctOf === "monthly" ? "monthly" : "seven-day"} allowance`,
|
|
371
|
+
]
|
|
372
|
+
if (reading.resetsAt !== undefined) {
|
|
373
|
+
const hours = Math.max(0, Math.round((reading.resetsAt - Date.now()) / 3_600_000))
|
|
374
|
+
parts.push(hours >= 36 ? `resets in ${Math.round(hours / 24)} days` : `resets in ${hours} hours`)
|
|
375
|
+
}
|
|
376
|
+
if (reading.plan) parts.unshift(`${reading.plan} plan`)
|
|
377
|
+
if (reading.walletUsd !== undefined) parts.push(`wallet ${reading.walletUsd} USD (never auto-spent)`)
|
|
378
|
+
return parts.join(" · ")
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const charged = reading.rows.filter((row) => row.chargedUsd !== undefined)
|
|
382
|
+
if (charged.length > 0) {
|
|
383
|
+
const sum = charged.reduce((total, row) => total + (row.chargedUsd ?? 0), 0)
|
|
384
|
+
return `Usage page: ${plural(reading.rows.length, "model")}, ${sum.toFixed(2)} USD charged across ${plural(charged.length, "row")}.`
|
|
385
|
+
}
|
|
386
|
+
return `Usage page: ${plural(reading.rows.length, "model")} with token counts. No charged amounts, so this can refine the estimate but cannot confirm it.`
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Kept for call sites that only describe an allowance reading. */
|
|
390
|
+
export const describePlanPage = (reading: AllowanceReading): string => describeConsoleReading(reading)
|