opencode-model-price-intel 0.0.0-stage → 1.1.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/LICENSE +21 -0
- package/NOTICE +37 -0
- package/README.md +163 -2
- package/package.json +63 -4
- package/src/aa-snapshot.ts +515 -0
- package/src/aa.ts +911 -0
- package/src/index.ts +411 -0
- package/src/pricing.ts +142 -0
- package/src/rpc.ts +65 -0
- package/src/table.ts +404 -0
- package/src/tui.tsx +207 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode-model-price-intel
|
|
3
|
+
*
|
|
4
|
+
* Makes the `/models` list show each model's input, output and cache prices
|
|
5
|
+
* together with its Artificial Analysis Intelligence Index score.
|
|
6
|
+
*
|
|
7
|
+
* Prices come from the live model registry (`Model.Info.cost`). Intelligence
|
|
8
|
+
* scores are fetched from artificialanalysis.ai and cached in plugin storage,
|
|
9
|
+
* with a periodic refresh while the session is open. Attribution:
|
|
10
|
+
* https://artificialanalysis.ai/
|
|
11
|
+
*
|
|
12
|
+
* The model transform is registered with the bundled snapshot *before* any
|
|
13
|
+
* storage or network work. That makes the first `/models` render immediate;
|
|
14
|
+
* cached or live scores are then adopted in the background and the registry is
|
|
15
|
+
* reloaded only when the score data actually changed.
|
|
16
|
+
*
|
|
17
|
+
* Because the built-in `/models` picker renders `Model.Info.name` as the row
|
|
18
|
+
* title, the pricing/score line is attached to the display name so `/models`
|
|
19
|
+
* lists it per model.
|
|
20
|
+
*
|
|
21
|
+
* Note: an experimental scrolling (marquee) mode for narrow screens existed
|
|
22
|
+
* here and was removed — periodic name repaints made client lists reset their
|
|
23
|
+
* scroll position (both the TUI and the web UI). Names are therefore static.
|
|
24
|
+
*
|
|
25
|
+
* The full table is exposed over RPC (`src/rpc.ts`). The TUI part
|
|
26
|
+
* (`src/tui.tsx`) adds `/model-pricing` and `/prices`, fetches structured rows
|
|
27
|
+
* from this server and renders them in a native dialog table. Keeping the
|
|
28
|
+
* command in the TUI avoids the old behavior where `ctx.session.synthetic`
|
|
29
|
+
* delivered the table to the model as a prompt instead of displaying it.
|
|
30
|
+
*
|
|
31
|
+
* Options (opencode.jsonc `plugins` entry):
|
|
32
|
+
* - decorateNames: boolean attach the line to model names (default true)
|
|
33
|
+
* - costFormat: "detailed" | "compact" cost line format (default detailed)
|
|
34
|
+
* - aaApiKey: string Artificial Analysis Data API key (optional;
|
|
35
|
+
* falls back to ARTIFICIALANALYSIS_API_KEY)
|
|
36
|
+
* - refreshHours: number score cache TTL / refresh interval in hours
|
|
37
|
+
* (default 12, clamped to 1..596)
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { Plugin } from "@opencode/plugin"
|
|
41
|
+
import { fetchAAScores, lookupScore, snapshotScores, type AAFetched } from "./aa.ts"
|
|
42
|
+
import { pricingSegment, type CostFormat } from "./pricing.ts"
|
|
43
|
+
import { ModelPricing } from "./rpc.ts"
|
|
44
|
+
import { pricingData, renderTable, type TableModel } from "./table.ts"
|
|
45
|
+
|
|
46
|
+
const CACHE_KEY = "aa-scores/v3"
|
|
47
|
+
const DEFAULT_REFRESH_HOURS = 12
|
|
48
|
+
const MIN_REFRESH_HOURS = 1
|
|
49
|
+
/** 596 h stays below the 2^31-1 ms setTimeout/setInterval ceiling. */
|
|
50
|
+
const MAX_REFRESH_HOURS = 596
|
|
51
|
+
/** Refuse absurdly old cache entries even if refreshHours is configured high. */
|
|
52
|
+
const MAX_CACHE_AGE_MS = 30 * 24 * 60 * 60_000
|
|
53
|
+
|
|
54
|
+
interface ResolvedOptions {
|
|
55
|
+
decorateNames: boolean
|
|
56
|
+
costFormat: CostFormat
|
|
57
|
+
aaApiKey?: string
|
|
58
|
+
refreshMs: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function modelKey(model: {
|
|
62
|
+
readonly providerID: string
|
|
63
|
+
readonly modelID?: string
|
|
64
|
+
readonly id: string
|
|
65
|
+
}): string {
|
|
66
|
+
return `${model.providerID}/${model.modelID || model.id}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function describeValue(value: unknown): string {
|
|
70
|
+
if (typeof value === "string") return JSON.stringify(value)
|
|
71
|
+
if (typeof value === "number" || typeof value === "boolean" || value == null) return String(value)
|
|
72
|
+
return typeof value
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Validate user options once, with safe fallbacks. A bad option must never
|
|
77
|
+
* prevent the plugin from loading.
|
|
78
|
+
*/
|
|
79
|
+
function resolveOptions(raw: unknown): ResolvedOptions {
|
|
80
|
+
const options: Record<string, unknown> =
|
|
81
|
+
raw !== null && typeof raw === "object" && !Array.isArray(raw)
|
|
82
|
+
? (raw as Record<string, unknown>)
|
|
83
|
+
: {}
|
|
84
|
+
const problems: string[] = []
|
|
85
|
+
|
|
86
|
+
let decorateNames = true
|
|
87
|
+
if (options.decorateNames !== undefined) {
|
|
88
|
+
if (typeof options.decorateNames === "boolean") decorateNames = options.decorateNames
|
|
89
|
+
else {
|
|
90
|
+
problems.push(
|
|
91
|
+
`decorateNames must be a boolean (got ${describeValue(options.decorateNames)}); using true`,
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let costFormat: CostFormat = "detailed"
|
|
97
|
+
if (options.costFormat !== undefined) {
|
|
98
|
+
if (options.costFormat === "detailed" || options.costFormat === "compact") {
|
|
99
|
+
costFormat = options.costFormat
|
|
100
|
+
} else {
|
|
101
|
+
problems.push(
|
|
102
|
+
`costFormat must be "detailed" or "compact" (got ${describeValue(options.costFormat)}); using "detailed"`,
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let aaApiKey: string | undefined
|
|
108
|
+
if (options.aaApiKey !== undefined) {
|
|
109
|
+
if (typeof options.aaApiKey === "string") aaApiKey = options.aaApiKey || undefined
|
|
110
|
+
else {
|
|
111
|
+
problems.push(
|
|
112
|
+
`aaApiKey must be a string (got ${describeValue(options.aaApiKey)}); using the environment fallback`,
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let hours = DEFAULT_REFRESH_HOURS
|
|
118
|
+
if (options.refreshHours !== undefined) {
|
|
119
|
+
const requested = options.refreshHours
|
|
120
|
+
if (typeof requested === "number" && Number.isFinite(requested) && requested > 0) {
|
|
121
|
+
hours = Math.min(MAX_REFRESH_HOURS, Math.max(MIN_REFRESH_HOURS, requested))
|
|
122
|
+
if (hours !== requested) {
|
|
123
|
+
problems.push(
|
|
124
|
+
`refreshHours ${requested} is outside ${MIN_REFRESH_HOURS}..${MAX_REFRESH_HOURS}; using ${hours}`,
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
} else {
|
|
128
|
+
problems.push(
|
|
129
|
+
`refreshHours must be a finite positive number (got ${describeValue(requested)}); using ${DEFAULT_REFRESH_HOURS}`,
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (problems.length > 0) {
|
|
135
|
+
console.warn(`[model-price-intel] invalid options — ${problems.join("; ")}`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
decorateNames,
|
|
140
|
+
costFormat,
|
|
141
|
+
aaApiKey,
|
|
142
|
+
refreshMs: Math.round(hours * 3_600_000),
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isScoreEntry(value: unknown): boolean {
|
|
147
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false
|
|
148
|
+
const entry = value as Record<string, unknown>
|
|
149
|
+
if (!("score" in entry)) return false
|
|
150
|
+
if (
|
|
151
|
+
entry.score !== null &&
|
|
152
|
+
(typeof entry.score !== "number" || !Number.isFinite(entry.score))
|
|
153
|
+
) {
|
|
154
|
+
return false
|
|
155
|
+
}
|
|
156
|
+
if (typeof entry.name !== "string") return false
|
|
157
|
+
if (entry.note !== undefined && typeof entry.note !== "string") return false
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Validate a cached `AAFetched` before trusting any part of it. */
|
|
162
|
+
function isAAFetched(value: unknown): value is AAFetched {
|
|
163
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false
|
|
164
|
+
const cached = value as Record<string, unknown>
|
|
165
|
+
if (
|
|
166
|
+
cached.scores === null ||
|
|
167
|
+
typeof cached.scores !== "object" ||
|
|
168
|
+
Array.isArray(cached.scores) ||
|
|
169
|
+
Object.keys(cached.scores as object).length === 0
|
|
170
|
+
) {
|
|
171
|
+
return false
|
|
172
|
+
}
|
|
173
|
+
if (typeof cached.version !== "string" || cached.version.length === 0) return false
|
|
174
|
+
if (typeof cached.source !== "string" || cached.source.length === 0) return false
|
|
175
|
+
if (typeof cached.fetchedAt !== "string") return false
|
|
176
|
+
const fetchedAt = Date.parse(cached.fetchedAt)
|
|
177
|
+
if (!Number.isFinite(fetchedAt)) return false
|
|
178
|
+
// A future timestamp can otherwise pin the cache far past refreshHours.
|
|
179
|
+
if (fetchedAt > Date.now()) return false
|
|
180
|
+
if (Date.now() - fetchedAt > MAX_CACHE_AGE_MS) return false
|
|
181
|
+
return Object.values(cached.scores as Record<string, unknown>).every(isScoreEntry)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Comparison key that ignores `fetchedAt`: adopting a cache/fetch with the
|
|
186
|
+
* same scores, version and source does not need a model-registry reload.
|
|
187
|
+
*/
|
|
188
|
+
function dataSignature(value: AAFetched): string {
|
|
189
|
+
const entries = Object.keys(value.scores)
|
|
190
|
+
.sort()
|
|
191
|
+
.map((key) => {
|
|
192
|
+
const entry = value.scores[key]
|
|
193
|
+
return [key, entry.score, entry.name, entry.note ?? ""]
|
|
194
|
+
})
|
|
195
|
+
return JSON.stringify([value.version, value.source, entries])
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
type JsonValue =
|
|
199
|
+
| null
|
|
200
|
+
| boolean
|
|
201
|
+
| number
|
|
202
|
+
| string
|
|
203
|
+
| JsonValue[]
|
|
204
|
+
| { readonly [key: string]: JsonValue }
|
|
205
|
+
|
|
206
|
+
/** Build a plain JSON value for `ctx.storage.set` (AAFetched has no index sig). */
|
|
207
|
+
function cachePayload(value: AAFetched): JsonValue {
|
|
208
|
+
const scores: Record<string, JsonValue> = {}
|
|
209
|
+
for (const [key, entry] of Object.entries(value.scores)) {
|
|
210
|
+
const score: Record<string, JsonValue> = { score: entry.score, name: entry.name }
|
|
211
|
+
if (entry.note !== undefined) score.note = entry.note
|
|
212
|
+
scores[key] = score
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
scores,
|
|
216
|
+
version: value.version,
|
|
217
|
+
source: value.source,
|
|
218
|
+
fetchedAt: value.fetchedAt,
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Remove our own decoration if a transform is replayed against a stale name. */
|
|
223
|
+
function undecoratedName(name: string, price: string): string {
|
|
224
|
+
const suffix = ` · ${price} · AA `
|
|
225
|
+
const index = name.lastIndexOf(suffix)
|
|
226
|
+
if (index < 0) return name
|
|
227
|
+
const tail = name.slice(index + suffix.length)
|
|
228
|
+
if (tail !== "n/a" && !/^-?\d+(?:\.\d+)?$/.test(tail)) return name
|
|
229
|
+
return name.slice(0, index)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export default Plugin.define({
|
|
233
|
+
id: "model-price-intel",
|
|
234
|
+
async setup(ctx) {
|
|
235
|
+
const options = resolveOptions(ctx.options)
|
|
236
|
+
|
|
237
|
+
// The snapshot is usable synchronously: register the transform with it
|
|
238
|
+
// before touching storage or the network so the first render is immediate.
|
|
239
|
+
let aa: AAFetched = snapshotScores()
|
|
240
|
+
let stopped = false
|
|
241
|
+
let inFlight = false
|
|
242
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
243
|
+
const abortController = new AbortController()
|
|
244
|
+
// Replay-safe base names: never derive the original name by splitting the
|
|
245
|
+
// decorated string, and never append the suffix twice.
|
|
246
|
+
const baseNames = new Map<string, string>()
|
|
247
|
+
|
|
248
|
+
function reloadModels(): void {
|
|
249
|
+
if (!options.decorateNames || stopped) return
|
|
250
|
+
try {
|
|
251
|
+
void Promise.resolve(ctx.model.reload()).catch((error) =>
|
|
252
|
+
console.warn("[model-price-intel] model reload failed:", error),
|
|
253
|
+
)
|
|
254
|
+
} catch (error) {
|
|
255
|
+
console.warn("[model-price-intel] model reload failed:", error)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function adopt(next: AAFetched): boolean {
|
|
260
|
+
const changed = dataSignature(next) !== dataSignature(aa)
|
|
261
|
+
aa = next
|
|
262
|
+
return changed
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function readCache(): Promise<AAFetched | undefined> {
|
|
266
|
+
let raw: unknown
|
|
267
|
+
try {
|
|
268
|
+
raw = await ctx.storage.get(CACHE_KEY)
|
|
269
|
+
} catch (error) {
|
|
270
|
+
console.warn("[model-price-intel] AA cache read failed; continuing without it:", error)
|
|
271
|
+
return undefined
|
|
272
|
+
}
|
|
273
|
+
if (raw === undefined) return undefined
|
|
274
|
+
if (!isAAFetched(raw)) {
|
|
275
|
+
console.warn("[model-price-intel] ignoring invalid AA cache; using the bundled snapshot")
|
|
276
|
+
return undefined
|
|
277
|
+
}
|
|
278
|
+
return raw
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function makeAbortableFetch(): typeof fetch {
|
|
282
|
+
return (input, init) => {
|
|
283
|
+
const signal = init?.signal
|
|
284
|
+
? AbortSignal.any([init.signal, abortController.signal])
|
|
285
|
+
: abortController.signal
|
|
286
|
+
return fetch(input, { ...init, signal })
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function refresh(force: boolean): Promise<void> {
|
|
291
|
+
if (stopped || inFlight) return
|
|
292
|
+
inFlight = true
|
|
293
|
+
try {
|
|
294
|
+
const cached = await readCache()
|
|
295
|
+
if (stopped) return
|
|
296
|
+
if (cached) {
|
|
297
|
+
const age = Date.now() - Date.parse(cached.fetchedAt)
|
|
298
|
+
if (age >= 0 && age < options.refreshMs) {
|
|
299
|
+
if (adopt(cached)) reloadModels()
|
|
300
|
+
if (!force) return
|
|
301
|
+
} else if (adopt(cached)) {
|
|
302
|
+
// Stale cache still beats the bundled snapshot while we refetch,
|
|
303
|
+
// but it is not authoritative: always try the network below.
|
|
304
|
+
reloadModels()
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
let live: AAFetched
|
|
309
|
+
try {
|
|
310
|
+
live = await fetchAAScores({ apiKey: options.aaApiKey, fetchImpl: makeAbortableFetch() })
|
|
311
|
+
} catch (error) {
|
|
312
|
+
if (!stopped) {
|
|
313
|
+
console.error("[model-price-intel] live Artificial Analysis fetch failed:", error)
|
|
314
|
+
}
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
if (stopped) return
|
|
318
|
+
|
|
319
|
+
const changed = adopt(live)
|
|
320
|
+
try {
|
|
321
|
+
await ctx.storage.set(CACHE_KEY, cachePayload(live))
|
|
322
|
+
} catch (error) {
|
|
323
|
+
// A cache write failure is not a fetch failure: keep live data.
|
|
324
|
+
console.warn("[model-price-intel] AA cache write failed; live data kept:", error)
|
|
325
|
+
}
|
|
326
|
+
if (stopped) return
|
|
327
|
+
console.log(
|
|
328
|
+
`[model-price-intel] loaded AA Intelligence Index v${live.version} from ${live.source}`,
|
|
329
|
+
)
|
|
330
|
+
if (changed) reloadModels()
|
|
331
|
+
} finally {
|
|
332
|
+
inFlight = false
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (options.decorateNames) {
|
|
337
|
+
await ctx.model.transform((editor) => {
|
|
338
|
+
try {
|
|
339
|
+
for (const model of editor.list()) {
|
|
340
|
+
try {
|
|
341
|
+
const key = modelKey(model)
|
|
342
|
+
const price = pricingSegment(model.cost, options.costFormat)
|
|
343
|
+
let base = baseNames.get(key)
|
|
344
|
+
if (base === undefined) {
|
|
345
|
+
base = undecoratedName(model.name, price)
|
|
346
|
+
baseNames.set(key, base)
|
|
347
|
+
}
|
|
348
|
+
const score = lookupScore(aa.scores, model.id, base)
|
|
349
|
+
const aaSegment =
|
|
350
|
+
score?.score != null ? `AA ${Math.round(score.score * 10) / 10}` : "AA n/a"
|
|
351
|
+
const decorated = `${base} · ${price} · ${aaSegment}`
|
|
352
|
+
if (model.name !== decorated) {
|
|
353
|
+
try {
|
|
354
|
+
editor.update(model.providerID, model.id, (draft) => {
|
|
355
|
+
draft.name = decorated
|
|
356
|
+
})
|
|
357
|
+
} catch (error) {
|
|
358
|
+
console.warn(
|
|
359
|
+
`[model-price-intel] editor.update failed for ${key}; mutating the draft:`,
|
|
360
|
+
error,
|
|
361
|
+
)
|
|
362
|
+
model.name = decorated
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
} catch (error) {
|
|
366
|
+
// One bad registry entry must never fail the whole transform.
|
|
367
|
+
console.warn("[model-price-intel] could not decorate a model:", error)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
} catch (error) {
|
|
371
|
+
console.warn("[model-price-intel] model transform failed:", error)
|
|
372
|
+
}
|
|
373
|
+
})
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// The TUI part calls this RPC to render the full table in a dialog. The
|
|
377
|
+
// server builds the rows because only it owns the live score cache.
|
|
378
|
+
await ctx.rpc.register(ModelPricing, {
|
|
379
|
+
table: async () => {
|
|
380
|
+
let models: readonly TableModel[] = []
|
|
381
|
+
try {
|
|
382
|
+
const { data } = await ctx.model.list()
|
|
383
|
+
models = Array.isArray(data) ? data : []
|
|
384
|
+
} catch (error) {
|
|
385
|
+
console.warn("[model-price-intel] could not list models for the pricing table:", error)
|
|
386
|
+
}
|
|
387
|
+
const data = pricingData(models, aa, baseNames, options.decorateNames)
|
|
388
|
+
return { ...data, text: renderTable(data) }
|
|
389
|
+
},
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
// Hydrate the cache and refresh live scores in the background. setup must
|
|
393
|
+
// not wait for storage or the network; the snapshot transform is already
|
|
394
|
+
// registered. Reload the registry only when the data actually changed.
|
|
395
|
+
void refresh(false).catch((error) => {
|
|
396
|
+
if (!stopped) console.error("[model-price-intel] background refresh failed:", error)
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
timer = setInterval(() => {
|
|
400
|
+
void refresh(true).catch((error) => {
|
|
401
|
+
if (!stopped) console.error("[model-price-intel] refresh failed:", error)
|
|
402
|
+
})
|
|
403
|
+
}, options.refreshMs)
|
|
404
|
+
|
|
405
|
+
return () => {
|
|
406
|
+
stopped = true
|
|
407
|
+
if (timer !== undefined) clearInterval(timer)
|
|
408
|
+
abortController.abort()
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
})
|
package/src/pricing.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formatting helpers for model pricing.
|
|
3
|
+
*
|
|
4
|
+
* Prices come from the LIVE model registry (`Model.Info.cost`), which OpenCode
|
|
5
|
+
* refreshes from provider catalogs — not from a bundled table. This module only
|
|
6
|
+
* formats and packages those values.
|
|
7
|
+
*
|
|
8
|
+
* `Model.Cost` shape (OpenCode schema):
|
|
9
|
+
* {
|
|
10
|
+
* tier?: { type: "context", size: number } // applies ABOVE `size` context tokens
|
|
11
|
+
* input: number // USD per 1M tokens
|
|
12
|
+
* output: number
|
|
13
|
+
* cache: { read: number, write: number }
|
|
14
|
+
* }
|
|
15
|
+
* `Model.Info.cost` is an ARRAY of such entries: an untiered base price
|
|
16
|
+
* followed by zero or more context overrides. OpenCode selects the matching
|
|
17
|
+
* override when context tokens exceed `tier.size`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface CostTier {
|
|
21
|
+
tier?: { type: string; size: number }
|
|
22
|
+
input: number
|
|
23
|
+
output: number
|
|
24
|
+
cache: { read: number; write: number }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface FormattedPrice {
|
|
28
|
+
input: string
|
|
29
|
+
output: string
|
|
30
|
+
cacheRead: string
|
|
31
|
+
cacheWrite: string
|
|
32
|
+
/** e.g. ">32K" when the entry is a context-tier override. */
|
|
33
|
+
tierLabel?: string
|
|
34
|
+
/** True only when input, output, cache read and cache write are all 0. */
|
|
35
|
+
free: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Expand exponent notation so prices never render as `1e+21`. */
|
|
39
|
+
function plainDecimal(value: number): string {
|
|
40
|
+
const text = String(value)
|
|
41
|
+
const match = text.match(/^(-?)(\d+)(?:\.(\d+))?e([+-]?\d+)$/i)
|
|
42
|
+
if (!match) return text
|
|
43
|
+
const [, sign, whole, fraction = "", rawExponent] = match
|
|
44
|
+
const exponent = Number(rawExponent)
|
|
45
|
+
const digits = whole + fraction
|
|
46
|
+
if (exponent >= 0) {
|
|
47
|
+
const zeros = exponent - fraction.length
|
|
48
|
+
if (zeros >= 0) return `${sign}${digits}${"0".repeat(zeros)}`
|
|
49
|
+
const split = digits.length + zeros
|
|
50
|
+
return `${sign}${digits.slice(0, split)}.${digits.slice(split)}`
|
|
51
|
+
}
|
|
52
|
+
const zeros = -exponent - whole.length
|
|
53
|
+
if (zeros >= 0) return `${sign}0.${"0".repeat(zeros)}${digits}`
|
|
54
|
+
const split = whole.length + exponent
|
|
55
|
+
return `${sign}${whole.slice(0, split)}.${whole.slice(split)}${fraction}`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Format a USD-per-1M-token price, preserving published precision:
|
|
60
|
+
* 10 -> "$10", 0.006 -> "$0.006", 0.003625 -> "$0.003625".
|
|
61
|
+
* Prices below $0.00005 render explicitly as "<$0.00005"; negative or
|
|
62
|
+
* non-finite values render "?".
|
|
63
|
+
*/
|
|
64
|
+
export function usd(value: number): string {
|
|
65
|
+
if (!Number.isFinite(value) || value < 0) return "?"
|
|
66
|
+
if (value === 0) return "$0"
|
|
67
|
+
if (value < 0.00005) return "<$0.00005"
|
|
68
|
+
// Registry prices are exact JSON decimals; this only removes floating-point
|
|
69
|
+
// noise from arithmetic while keeping every published digit.
|
|
70
|
+
const normalized = value < 1 ? Math.round(value * 1e12) / 1e12 : value
|
|
71
|
+
let text = plainDecimal(normalized)
|
|
72
|
+
if (text.includes(".")) text = text.replace(/0+$/, "").replace(/\.$/, "")
|
|
73
|
+
return `$${text}`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function contextBound(size: number): string {
|
|
77
|
+
if (Number.isFinite(size) && size >= 1_000 && size % 1_000 === 0) {
|
|
78
|
+
return `${size / 1_000}K`
|
|
79
|
+
}
|
|
80
|
+
return String(size)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function tierLabelFor(tier: CostTier): string | undefined {
|
|
84
|
+
// An entry with tier.type === "context" applies ABOVE tier.size.
|
|
85
|
+
if (tier.tier?.type !== "context") return undefined
|
|
86
|
+
return `>${contextBound(tier.tier.size)}`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isFree(tier: CostTier): boolean {
|
|
90
|
+
return (
|
|
91
|
+
tier.input === 0 &&
|
|
92
|
+
tier.output === 0 &&
|
|
93
|
+
(tier.cache?.read ?? 0) === 0 &&
|
|
94
|
+
(tier.cache?.write ?? 0) === 0
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function formatTier(tier: CostTier, _index = 0, _all: readonly CostTier[] = [tier]): FormattedPrice {
|
|
99
|
+
return {
|
|
100
|
+
input: usd(tier.input),
|
|
101
|
+
output: usd(tier.output),
|
|
102
|
+
cacheRead: usd(tier.cache?.read ?? 0),
|
|
103
|
+
cacheWrite: usd(tier.cache?.write ?? 0),
|
|
104
|
+
tierLabel: tierLabelFor(tier),
|
|
105
|
+
free: isFree(tier),
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function formatCost(cost: readonly CostTier[] | undefined): FormattedPrice[] {
|
|
110
|
+
return (cost ?? []).map((tier, index, all) => formatTier(tier, index, all))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** How the per-model cost line is rendered in `/models`. */
|
|
114
|
+
export type CostFormat = "detailed" | "compact"
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Compact per-model line used to decorate the model's display name so the
|
|
118
|
+
* built-in `/models` picker lists input, output and cache prices plus the
|
|
119
|
+
* Artificial Analysis intelligence score:
|
|
120
|
+
*
|
|
121
|
+
* detailed: "GPT 6 Astra · in $10 out $50 · cache read $1 write $12.5 · AA 56.9"
|
|
122
|
+
* compact: "GPT 6 Astra · $10/$50/$1/$12.5 · AA 56.9"
|
|
123
|
+
* free: "Big Pickle · free · AA n/a"
|
|
124
|
+
*
|
|
125
|
+
* The compact form (<in>/<out>/<read>/<write>) keeps rows readable on narrow
|
|
126
|
+
* screens such as mobile web sessions. Multi-tier models show their base tier
|
|
127
|
+
* and an `(N tiers)` marker.
|
|
128
|
+
*/
|
|
129
|
+
export function pricingSegment(
|
|
130
|
+
cost: readonly CostTier[] | undefined,
|
|
131
|
+
format: CostFormat = "detailed",
|
|
132
|
+
): string {
|
|
133
|
+
const tiers = formatCost(cost)
|
|
134
|
+
const base = tiers[0]
|
|
135
|
+
if (!base) return format === "compact" ? "n/a" : "pricing n/a"
|
|
136
|
+
if (base.free && tiers.every((tier) => tier.free)) return "free"
|
|
137
|
+
const tierSuffix = tiers.length > 1 ? ` (${tiers.length} tiers)` : ""
|
|
138
|
+
if (format === "compact") {
|
|
139
|
+
return `${base.input}/${base.output}/${base.cacheRead}/${base.cacheWrite}${tierSuffix}`
|
|
140
|
+
}
|
|
141
|
+
return `in ${base.input} out ${base.output} · cache read ${base.cacheRead} write ${base.cacheWrite}${tierSuffix}`
|
|
142
|
+
}
|
package/src/rpc.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** RPC contract shared by the server plugin and the TUI part. */
|
|
2
|
+
|
|
3
|
+
import { Rpc } from "@opencode/plugin/rpc"
|
|
4
|
+
|
|
5
|
+
/** One aligned table row; continuation tiers leave model/provider/aa blank. */
|
|
6
|
+
export interface PricingRow {
|
|
7
|
+
readonly model: string
|
|
8
|
+
readonly provider: string
|
|
9
|
+
readonly tier: string
|
|
10
|
+
readonly input: string
|
|
11
|
+
readonly output: string
|
|
12
|
+
readonly cache: string
|
|
13
|
+
readonly aa: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const ModelPricing = Rpc.define({
|
|
17
|
+
id: "model-price-intel",
|
|
18
|
+
methods: {
|
|
19
|
+
table: {
|
|
20
|
+
input: {
|
|
21
|
+
type: "object",
|
|
22
|
+
properties: {},
|
|
23
|
+
additionalProperties: false,
|
|
24
|
+
},
|
|
25
|
+
output: {
|
|
26
|
+
type: "object",
|
|
27
|
+
properties: {
|
|
28
|
+
/** Pre-rendered fallback table (also useful to copy for support). */
|
|
29
|
+
text: { type: "string" },
|
|
30
|
+
title: { type: "string" },
|
|
31
|
+
meta: { type: "string" },
|
|
32
|
+
footnote: { type: "string" },
|
|
33
|
+
rows: {
|
|
34
|
+
type: "array",
|
|
35
|
+
items: {
|
|
36
|
+
type: "object",
|
|
37
|
+
properties: {
|
|
38
|
+
model: { type: "string" },
|
|
39
|
+
provider: { type: "string" },
|
|
40
|
+
tier: { type: "string" },
|
|
41
|
+
input: { type: "string" },
|
|
42
|
+
output: { type: "string" },
|
|
43
|
+
cache: { type: "string" },
|
|
44
|
+
aa: { type: "string" },
|
|
45
|
+
},
|
|
46
|
+
required: ["model", "provider", "tier", "input", "output", "cache", "aa"],
|
|
47
|
+
additionalProperties: false,
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
required: ["text", "title", "meta", "footnote", "rows"],
|
|
52
|
+
additionalProperties: false,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
events: {},
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
export interface PricingTable {
|
|
60
|
+
readonly text: string
|
|
61
|
+
readonly title: string
|
|
62
|
+
readonly meta: string
|
|
63
|
+
readonly footnote: string
|
|
64
|
+
readonly rows: readonly PricingRow[]
|
|
65
|
+
}
|