opencode-go-usage-tui 1.2.0 → 1.3.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/README.md +39 -6
- package/README_EN.md +39 -6
- package/dist/tui.js +907 -58
- package/package.json +1 -1
- package/src/i18n.ts +88 -0
- package/src/index.tsx +433 -45
- package/src/pricing.ts +348 -0
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(/&/g, "&")
|
|
65
|
+
.replace(/</g, "<")
|
|
66
|
+
.replace(/>/g, ">")
|
|
67
|
+
.replace(/ /g, " ")
|
|
68
|
+
.replace(/'|'/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 }
|