opencode-go-usage-tui 1.1.0 → 1.3.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/src/pricing.ts ADDED
@@ -0,0 +1,348 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"
2
+ import { join, dirname } from "node:path"
3
+
4
+ declare const process: { env: Record<string, string | undefined> } | undefined
5
+
6
+ const DOCS_URL = "https://opencode.ai/docs/go"
7
+ const PRICING_DIR = process?.env?.OPENCODE_CONFIG_DIR
8
+ || (process?.env?.USERPROFILE ? `${process.env.USERPROFILE}\\.config\\opencode` : "")
9
+ || process?.env?.HOME + "/.config/opencode"
10
+ const PRICING_FILE = join(PRICING_DIR, "go-pricing-data.json")
11
+
12
+ export interface ModelPrice {
13
+ id: string
14
+ name: string
15
+ variants: { label: string; pricing: { input: number | null; output: number | null; cachedRead: number | null; cachedWrite: number | null } }[]
16
+ pricing: {
17
+ input: number | null
18
+ output: number | null
19
+ cachedRead: number | null
20
+ cachedWrite: number | null
21
+ }
22
+ limits: {
23
+ fiveHour: number
24
+ weekly: number
25
+ monthly: number
26
+ }
27
+ usageLimit: number | null
28
+ endpoint: string
29
+ sdk: string
30
+ training: string
31
+ retention: string
32
+ }
33
+
34
+ export interface PricingData {
35
+ fetchTime: string
36
+ models: ModelPrice[]
37
+ }
38
+
39
+ export interface PricingChange {
40
+ type: "added" | "removed" | "pricing" | "limits" | "usageLimit"
41
+ modelId: string
42
+ modelName: string
43
+ changes?: { field: string; oldValue: string | number | null; newValue: string | number | null; changeType: string }[]
44
+ newValue?: ModelPrice
45
+ oldValue?: ModelPrice
46
+ }
47
+
48
+ export interface PricingCompareResult {
49
+ hasChanges: boolean
50
+ changes: PricingChange[]
51
+ summary: { total: number; added: number; removed: number; priceChanges: number; limitChanges: number }
52
+ }
53
+
54
+ export interface PricingStore {
55
+ lastFetch: string
56
+ current: PricingData
57
+ previous: PricingData | null
58
+ history: { fetchTime: string; changes: PricingChange[]; summary: PricingCompareResult["summary"] }[]
59
+ }
60
+
61
+ function cleanText(html: string): string {
62
+ return html
63
+ .replace(/<[^>]+>/g, "")
64
+ .replace(/&amp;/g, "&")
65
+ .replace(/&lt;/g, "<")
66
+ .replace(/&gt;/g, ">")
67
+ .replace(/&nbsp;/g, " ")
68
+ .replace(/&#x27;|&#39;/g, "'")
69
+ .trim()
70
+ }
71
+
72
+ function parsePrice(str: string): number | null {
73
+ const s = (str || "").replace(/[$\s]/g, "")
74
+ if (!s || s === "-" || s === "null" || s === "—" || s === "–") return null
75
+ const num = parseFloat(s.replace(/,/g, ""))
76
+ return isNaN(num) ? null : num
77
+ }
78
+
79
+ function parseCount(str: string): number {
80
+ const s = (str || "").replace(/[,\s]/g, "")
81
+ if (!s || s === "-" || s === "—" || s === "–") return 0
82
+ const m = s.match(/^([\d.]+)k$/i)
83
+ if (m) return Math.round(parseFloat(m[1]) * 1000)
84
+ const num = parseInt(s, 10)
85
+ return isNaN(num) ? 0 : num
86
+ }
87
+
88
+ function normalizeName(name: string): string {
89
+ return name.toLowerCase().replace(/[^a-z0-9]/g, "")
90
+ }
91
+
92
+ function generateId(name: string): string {
93
+ return name
94
+ .toLowerCase()
95
+ .replace(/[^a-z0-9]+/g, "-")
96
+ .replace(/^-+|-+$/g, "")
97
+ }
98
+
99
+ function extractTables(html: string): string[] {
100
+ const tables: string[] = []
101
+ const re = /<table[^>]*>([\s\S]*?)<\/table>/gi
102
+ let m: RegExpExecArray | null
103
+ while ((m = re.exec(html)) !== null) tables.push(m[1])
104
+ return tables
105
+ }
106
+
107
+ function extractRows(tableHtml: string): string[][] {
108
+ const rows: string[][] = []
109
+ const rowRe = /<tr[^>]*>([\s\S]*?)<\/tr>/gi
110
+ let rm: RegExpExecArray | null
111
+ while ((rm = rowRe.exec(tableHtml)) !== null) {
112
+ const cells: string[] = []
113
+ const cellRe = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi
114
+ let cm: RegExpExecArray | null
115
+ while ((cm = cellRe.exec(rm[1])) !== null) cells.push(cleanText(cm[1]))
116
+ if (cells.length > 0) rows.push(cells)
117
+ }
118
+ return rows
119
+ }
120
+
121
+ function extractLinks(html: string): string[] {
122
+ const links: string[] = []
123
+ const re = /<a[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi
124
+ let m: RegExpExecArray | null
125
+ while ((m = re.exec(html)) !== null) {
126
+ const href = m[1]
127
+ if (href.startsWith("http") || href.startsWith("/")) links.push(href)
128
+ }
129
+ return links
130
+ }
131
+
132
+ export async function fetchPricing(): Promise<PricingData> {
133
+ const res = await fetch(DOCS_URL, {
134
+ headers: {
135
+ "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
136
+ "accept": "text/html",
137
+ },
138
+ signal: AbortSignal.timeout(15000),
139
+ })
140
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
141
+ const html = await res.text()
142
+ const tables = extractTables(html)
143
+ if (tables.length < 4) throw new Error("Required tables not found")
144
+
145
+ const priceTable = tables[1]
146
+ const limitTable = tables[0]
147
+ const endpointTable = tables[2]
148
+ const retentionTable = tables[3]
149
+
150
+ const priceRows = extractRows(priceTable)
151
+ const limitRows = extractRows(limitTable)
152
+ const endpointRows = extractRows(endpointTable)
153
+ const retentionRows = extractRows(retentionTable)
154
+
155
+ const limitMap = new Map<string, string[]>()
156
+ for (const row of limitRows.slice(1)) {
157
+ if (row.length >= 4) limitMap.set(normalizeName(row[0]), row)
158
+ }
159
+ const endpointMap = new Map<string, string[]>()
160
+ for (const row of endpointRows.slice(1)) {
161
+ if (row.length >= 4) endpointMap.set(normalizeName(row[0]), row)
162
+ }
163
+ const retentionMap = new Map<string, string[]>()
164
+ for (const row of retentionRows.slice(1)) {
165
+ if (row.length >= 3) retentionMap.set(normalizeName(row[0]), row)
166
+ }
167
+
168
+ // 解析价格行:按基名分组保留变体(价格信息)
169
+ interface RawRow { baseName: string; variant: string; row: string[] }
170
+ const priceGroupMap = new Map<string, RawRow[]>()
171
+ for (const row of priceRows.slice(1)) {
172
+ if (row.length < 5) continue
173
+ const rawName = row[0]
174
+ const m = rawName.match(/^(.*?)\s*[((]\s*([^(()]*?)\s*[))]\s*$/)
175
+ const baseName = m ? m[1].trim() : rawName.trim()
176
+ const variant = m ? m[2].trim() : ""
177
+ const key = normalizeName(baseName)
178
+ if (!priceGroupMap.has(key)) priceGroupMap.set(key, [])
179
+ priceGroupMap.get(key)!.push({ baseName, variant, row })
180
+ }
181
+
182
+ // 以限额表为准构建模型列表(限额表是权威的当前模型来源)
183
+ const models: ModelPrice[] = []
184
+ for (const row of limitRows.slice(1)) {
185
+ if (row.length < 4) continue
186
+ const name = row[0]
187
+ const key = normalizeName(name)
188
+ const id = generateId(name)
189
+ const group = priceGroupMap.get(key)
190
+ const endpointRow = endpointMap.get(key)
191
+ const retentionRow = retentionMap.get(key)
192
+
193
+ const variants = (group ?? []).map((g) => ({
194
+ label: g.variant || "default",
195
+ pricing: {
196
+ input: parsePrice(g.row[1]),
197
+ output: parsePrice(g.row[2]),
198
+ cachedRead: parsePrice(g.row[3]),
199
+ cachedWrite: g.row.length > 4 ? parsePrice(g.row[4]) : null,
200
+ },
201
+ }))
202
+
203
+ const allInputs = variants.map((v) => v.pricing.input).filter((v): v is number => v !== null)
204
+ const allOutputs = variants.map((v) => v.pricing.output).filter((v): v is number => v !== null)
205
+ const allReads = variants.map((v) => v.pricing.cachedRead).filter((v): v is number => v !== null)
206
+ const allWrites = variants.map((v) => v.pricing.cachedWrite).filter((v): v is number => v !== null)
207
+
208
+ models.push({
209
+ id,
210
+ name,
211
+ variants,
212
+ pricing: {
213
+ input: variants.length === 1 ? variants[0].pricing.input : (allInputs.length ? Math.min(...allInputs) : null),
214
+ output: variants.length === 1 ? variants[0].pricing.output : (allOutputs.length ? Math.min(...allOutputs) : null),
215
+ cachedRead: variants.length === 1 ? variants[0].pricing.cachedRead : (allReads.length ? Math.min(...allReads) : null),
216
+ cachedWrite: variants.length === 1 ? variants[0].pricing.cachedWrite : (allWrites.length ? Math.min(...allWrites) : null),
217
+ },
218
+ limits: {
219
+ fiveHour: parseCount(row[1]),
220
+ weekly: parseCount(row[2]),
221
+ monthly: parseCount(row[3]),
222
+ },
223
+ usageLimit: group && group[0].row.length > 5 ? parsePrice(group[0].row[5]) : null,
224
+ endpoint: endpointRow ? (extractLinks(endpointRow.join(" "))[0] || "") : "",
225
+ sdk: endpointRow ? (endpointRow[3] || "") : "",
226
+ training: retentionRow ? (retentionRow[1] || "") : "",
227
+ retention: retentionRow ? (retentionRow[2] || "") : "",
228
+ })
229
+ }
230
+
231
+ return { fetchTime: new Date().toISOString(), models }
232
+ }
233
+
234
+ export function comparePricing(oldData: PricingData | null, newData: PricingData): PricingCompareResult {
235
+ const changes: PricingChange[] = []
236
+ if (!oldData || oldData.models.length === 0) {
237
+ return {
238
+ hasChanges: true,
239
+ changes: newData.models.map((m) => ({ type: "added", modelId: m.id, modelName: m.name, newValue: m })),
240
+ summary: { total: newData.models.length, added: newData.models.length, removed: 0, priceChanges: 0, limitChanges: 0 },
241
+ }
242
+ }
243
+
244
+ const oldIndex = new Map(oldData.models.map((m) => [m.id, m]))
245
+ for (const newModel of newData.models) {
246
+ const oldModel = oldIndex.get(newModel.id)
247
+ if (!oldModel) {
248
+ changes.push({ type: "added", modelId: newModel.id, modelName: newModel.name, newValue: newModel })
249
+ continue
250
+ }
251
+ const priceChanges: PricingChange["changes"] = []
252
+ const limitChanges: PricingChange["changes"] = []
253
+ for (const field of ["input", "output", "cachedRead", "cachedWrite"] as const) {
254
+ if (oldModel.pricing[field] !== newModel.pricing[field]) {
255
+ priceChanges.push({
256
+ field: `pricing.${field}`,
257
+ oldValue: oldModel.pricing[field],
258
+ newValue: newModel.pricing[field],
259
+ changeType: changeType(oldModel.pricing[field], newModel.pricing[field]),
260
+ })
261
+ }
262
+ }
263
+ if (priceChanges.length > 0) {
264
+ changes.push({ type: "pricing", modelId: newModel.id, modelName: newModel.name, changes: priceChanges })
265
+ }
266
+ for (const field of ["fiveHour", "weekly", "monthly"] as const) {
267
+ if (oldModel.limits[field] !== newModel.limits[field]) {
268
+ limitChanges.push({
269
+ field: `limits.${field}`,
270
+ oldValue: oldModel.limits[field],
271
+ newValue: newModel.limits[field],
272
+ changeType: changeType(oldModel.limits[field], newModel.limits[field]),
273
+ })
274
+ }
275
+ }
276
+ if (limitChanges.length > 0) {
277
+ changes.push({ type: "limits", modelId: newModel.id, modelName: newModel.name, changes: limitChanges })
278
+ }
279
+ if (oldModel.usageLimit !== newModel.usageLimit) {
280
+ changes.push({
281
+ type: "usageLimit",
282
+ modelId: newModel.id,
283
+ modelName: newModel.name,
284
+ changes: [{
285
+ field: "usageLimit",
286
+ oldValue: oldModel.usageLimit,
287
+ newValue: newModel.usageLimit,
288
+ changeType: changeType(oldModel.usageLimit, newModel.usageLimit),
289
+ }],
290
+ })
291
+ }
292
+ oldIndex.delete(newModel.id)
293
+ }
294
+ for (const [id, oldModel] of oldIndex) {
295
+ changes.push({ type: "removed", modelId: id, modelName: oldModel.name, oldValue: oldModel })
296
+ }
297
+
298
+ const summary = { total: changes.length, added: 0, removed: 0, priceChanges: 0, limitChanges: 0 }
299
+ for (const c of changes) {
300
+ if (c.type === "added") summary.added++
301
+ else if (c.type === "removed") summary.removed++
302
+ else if (c.type === "pricing") summary.priceChanges++
303
+ else if (c.type === "limits" || c.type === "usageLimit") summary.limitChanges++
304
+ }
305
+ return { hasChanges: changes.length > 0, changes, summary }
306
+ }
307
+
308
+ function changeType(oldVal: number | null, newVal: number | null): string {
309
+ if (oldVal === null && newVal !== null) return "added"
310
+ if (oldVal !== null && newVal === null) return "removed"
311
+ if (newVal !== null && oldVal !== null && newVal > oldVal) return "increase"
312
+ if (newVal !== null && oldVal !== null && newVal < oldVal) return "decrease"
313
+ return "unchanged"
314
+ }
315
+
316
+ export function loadPricingStore(): PricingStore {
317
+ try {
318
+ const data = JSON.parse(readFileSync(PRICING_FILE, "utf8")) as PricingStore
319
+ if (data && Array.isArray(data.current?.models)) return data
320
+ } catch { /* 无存储或损坏 */ }
321
+ return { lastFetch: "", current: { fetchTime: "", models: [] }, previous: null, history: [] }
322
+ }
323
+
324
+ export function savePricingStore(store: PricingStore): void {
325
+ try {
326
+ mkdirSync(dirname(PRICING_FILE), { recursive: true })
327
+ writeFileSync(PRICING_FILE, JSON.stringify(store, null, 2), "utf8")
328
+ } catch { /* 写入失败忽略 */ }
329
+ }
330
+
331
+ export function updatePricingStore(newData: PricingData): PricingStore {
332
+ const store = loadPricingStore()
333
+ const compare = comparePricing(store.current.models.length > 0 ? store.current : null, newData)
334
+ const prev = store.current
335
+ const newStore: PricingStore = {
336
+ lastFetch: newData.fetchTime,
337
+ current: newData,
338
+ previous: prev.models.length > 0 ? prev : null,
339
+ history: [
340
+ { fetchTime: newData.fetchTime, changes: compare.changes, summary: compare.summary },
341
+ ...store.history,
342
+ ].slice(0, 10),
343
+ }
344
+ savePricingStore(newStore)
345
+ return newStore
346
+ }
347
+
348
+ export { PRICING_FILE }