@rizal72/oc-models-pro 0.1.5 → 0.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/README.md +31 -20
- package/aa.ts +129 -0
- package/bench.ts +8 -4
- package/dist/gui-experimental.cjs +58 -0
- package/index.ts +143 -2
- package/multipliers.ts +9 -0
- package/package.json +9 -5
- package/prefs.ts +92 -0
- package/rows.ts +301 -0
- package/rpc.ts +108 -0
- package/tps.ts +212 -0
- package/tui.js +117 -200
- package/vite.config.ts +6 -0
package/rows.ts
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// oc-models-pro/rows.ts — the facts both surfaces read the same way.
|
|
2
|
+
//
|
|
3
|
+
// The terminal draws a monospace table and the desktop draws a grid, but what a row *means* is not the
|
|
4
|
+
// renderer's business: the effective price, what counts as free, and the ten orders are one definition
|
|
5
|
+
// here. Two copies would drift the way they already did once — the terminal kept free models out of the
|
|
6
|
+
// price order and the desktop did not.
|
|
7
|
+
//
|
|
8
|
+
// Runnable standalone for its self-check: node plugins/oc-models-pro/rows.ts --self-check
|
|
9
|
+
|
|
10
|
+
export interface Row {
|
|
11
|
+
name: string
|
|
12
|
+
providerID: string
|
|
13
|
+
modelID: string
|
|
14
|
+
provider: string
|
|
15
|
+
/** Two letters from `tags.ts`: the provider's, unique in the catalogue. */
|
|
16
|
+
tag: string
|
|
17
|
+
/** Undefined when the catalogue has no price for the model: an unknown figure, not a free one. */
|
|
18
|
+
input?: number
|
|
19
|
+
output?: number
|
|
20
|
+
/** Undefined when the model carries no limit. */
|
|
21
|
+
context?: number
|
|
22
|
+
/** Monthly limit ÷ the $10 plan: the ordering multiplier, shown so the order explains itself. */
|
|
23
|
+
credit: number
|
|
24
|
+
/** Allowance ÷ its normal one, when the page marks a promotion. */
|
|
25
|
+
promo?: number
|
|
26
|
+
/** Peak price ÷ off-peak price, for a model with two tariffs. */
|
|
27
|
+
peak?: number
|
|
28
|
+
/** Stencil's intelligence score and its speed; absent when the catalogue has no row for the model. */
|
|
29
|
+
int?: number
|
|
30
|
+
tps?: number
|
|
31
|
+
/** Tokens per second measured here, on this provider. Outranks `tps` and carries no tilde. */
|
|
32
|
+
measured?: number
|
|
33
|
+
enabled: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What the Go plan makes a model cost: the figure the effective order sorts by. An unknown price is
|
|
37
|
+
* `undefined`, not infinity, so an order can tell "not measured" from "very expensive". */
|
|
38
|
+
export function effective(row: Row): number | undefined {
|
|
39
|
+
if (row.input === undefined) return undefined
|
|
40
|
+
return row.input / (row.credit || 1)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A missing price is not a free one: both figures must be there, and be zero. */
|
|
44
|
+
export function isFree(row: Row): boolean {
|
|
45
|
+
return row.input === 0 && row.output === 0
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The speed to sort and print: what this machine measured first, the borrowed figure after. */
|
|
49
|
+
export function speedOf(row: Row): number | undefined {
|
|
50
|
+
return row.measured ?? row.tps
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type SortKey =
|
|
54
|
+
| "effective"
|
|
55
|
+
| "input"
|
|
56
|
+
| "output"
|
|
57
|
+
| "inputSum"
|
|
58
|
+
| "context"
|
|
59
|
+
| "int"
|
|
60
|
+
| "speed"
|
|
61
|
+
| "recent"
|
|
62
|
+
| "provider"
|
|
63
|
+
| "name"
|
|
64
|
+
|
|
65
|
+
export interface SortContext {
|
|
66
|
+
/** `providerID/modelID` → its place in the recent list; smaller is more recent. */
|
|
67
|
+
recent: Map<string, number>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface Order {
|
|
71
|
+
/** The long name: a footer, a menu, a title. */
|
|
72
|
+
label: string
|
|
73
|
+
/** The short name the terminal prints in its title bar. */
|
|
74
|
+
short: string
|
|
75
|
+
/** True when more is better, so the first row is the largest figure. */
|
|
76
|
+
desc?: true
|
|
77
|
+
/** `undefined` when the order cannot measure this row — the caller sorts it last, in either direction. */
|
|
78
|
+
value(row: Row, context: SortContext): number | undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Whatever an order cannot measure sorts last, never first, and in either direction: an unknown price is
|
|
82
|
+
* not the cheapest thing on the list, and a missing score is not the highest. */
|
|
83
|
+
|
|
84
|
+
export const ORDERS: Record<SortKey, Order> = {
|
|
85
|
+
effective: { label: "effective price", short: "effective↑", value: effective },
|
|
86
|
+
input: { label: "input price", short: "in↑", value: (row) => row.input },
|
|
87
|
+
output: { label: "output price", short: "out↑", value: (row) => row.output },
|
|
88
|
+
inputSum: {
|
|
89
|
+
label: "input + output",
|
|
90
|
+
short: "in+out↑",
|
|
91
|
+
value: (row) =>
|
|
92
|
+
row.input === undefined || row.output === undefined ? undefined : (row.input + row.output) / (row.credit || 1),
|
|
93
|
+
},
|
|
94
|
+
context: { label: "context window", short: "ctx↓", desc: true, value: (row) => row.context },
|
|
95
|
+
// A missing score sorts last, never first: Pi's rule, and the honest one.
|
|
96
|
+
int: { label: "intelligence", short: "int↓", desc: true, value: (row) => row.int },
|
|
97
|
+
speed: { label: "speed", short: "speed↓", desc: true, value: (row) => speedOf(row) },
|
|
98
|
+
recent: {
|
|
99
|
+
label: "recently used",
|
|
100
|
+
short: "recent",
|
|
101
|
+
value: (row, context) => context.recent.get(row.providerID + "/" + row.modelID),
|
|
102
|
+
},
|
|
103
|
+
provider: { label: "provider", short: "provider", value: () => 0 },
|
|
104
|
+
name: { label: "name", short: "name", value: () => 0 },
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The orders in the cycle the terminal walks with `ctrl+o`, in the same sequence. */
|
|
108
|
+
export const ORDER: SortKey[] = [
|
|
109
|
+
"effective",
|
|
110
|
+
"input",
|
|
111
|
+
"output",
|
|
112
|
+
"inputSum",
|
|
113
|
+
"context",
|
|
114
|
+
"int",
|
|
115
|
+
"speed",
|
|
116
|
+
"recent",
|
|
117
|
+
"provider",
|
|
118
|
+
"name",
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
/** The direction an order reads in when nothing else says otherwise. */
|
|
122
|
+
export function direction(key: SortKey): 1 | -1 {
|
|
123
|
+
return ORDERS[key].desc ? -1 : 1
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const id = (row: Row) => row.providerID + "/" + row.modelID
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The comparator both surfaces sort with. The two label orders compare text; every other order compares
|
|
130
|
+
* a number, and the direction is passed in because the desktop lets the reader flip it. Ties fall back to
|
|
131
|
+
* the name so the list never reshuffles between two reads of the same data.
|
|
132
|
+
*/
|
|
133
|
+
export function compare(key: SortKey, a: Row, b: Row, context: SortContext, sign: 1 | -1 = direction(key)): number {
|
|
134
|
+
if (key === "name" || key === "provider") {
|
|
135
|
+
const left = key === "name" ? a.name : a.provider
|
|
136
|
+
const right = key === "name" ? b.name : b.provider
|
|
137
|
+
const delta = left.localeCompare(right)
|
|
138
|
+
return delta !== 0 ? delta * sign : id(a).localeCompare(id(b))
|
|
139
|
+
}
|
|
140
|
+
const left = ORDERS[key].value(a, context)
|
|
141
|
+
const right = ORDERS[key].value(b, context)
|
|
142
|
+
// The direction flips what can be measured, never what cannot: an unknown figure goes last when the
|
|
143
|
+
// arrow points up and when it points down, or flipping a column would head the list with blanks.
|
|
144
|
+
if (left === undefined || right === undefined) {
|
|
145
|
+
if (left === right) return a.name.localeCompare(b.name)
|
|
146
|
+
return left === undefined ? 1 : -1
|
|
147
|
+
}
|
|
148
|
+
if (left === right) return a.name.localeCompare(b.name)
|
|
149
|
+
return (left < right ? -1 : 1) * sign
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** What a list of rows looks like once it is drawn: one section per provider, or the flat blocks. */
|
|
153
|
+
export interface Section {
|
|
154
|
+
/** The provider's name, or `Free` for the free bucket. `undefined` when the block needs no label: the
|
|
155
|
+
* leading one, and the paying block after it in the flat layout. */
|
|
156
|
+
title?: string
|
|
157
|
+
rows: Row[]
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The order the surfaces draw, which is not the order they sort: free models never enter a price order,
|
|
162
|
+
* so they follow their provider's paying rows; and the model in use is never buried — it leads its
|
|
163
|
+
* provider, and its provider leads the list. Both surfaces held a copy of this (`gui.tsx` and
|
|
164
|
+
* `tui.tsx`), and the copies drifted the moment a filter was added.
|
|
165
|
+
*/
|
|
166
|
+
export function arrange(list: Row[], options: { inUse?: string; grouped: boolean }): Section[] {
|
|
167
|
+
const used = options.inUse === undefined ? undefined : list.find((row) => id(row) === options.inUse)
|
|
168
|
+
|
|
169
|
+
if (!options.grouped) {
|
|
170
|
+
const rest = used ? list.filter((row) => row !== used) : list
|
|
171
|
+
const paying = rest.filter((row) => !isFree(row))
|
|
172
|
+
const free = rest.filter((row) => isFree(row))
|
|
173
|
+
return [
|
|
174
|
+
...(used ? [{ rows: [used] }] : []),
|
|
175
|
+
...(paying.length ? [{ rows: paying }] : []),
|
|
176
|
+
...(free.length ? [{ title: "Free", rows: free }] : []),
|
|
177
|
+
]
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Built from the whole list, the in-use row included: leaving it out left its provider with no group
|
|
181
|
+
// at all when it was the only row a filter kept, and the row surfaced under the first header.
|
|
182
|
+
const groups = new Map<string, Section>()
|
|
183
|
+
for (const row of list) {
|
|
184
|
+
const group = groups.get(row.providerID) ?? { title: row.provider, rows: [] }
|
|
185
|
+
// Paying first, then free: `isFree` sorts false before true.
|
|
186
|
+
const at = group.rows.findIndex((entry) => isFree(entry) && !isFree(row))
|
|
187
|
+
if (at === -1) group.rows.push(row)
|
|
188
|
+
else group.rows.splice(at, 0, row)
|
|
189
|
+
groups.set(row.providerID, group)
|
|
190
|
+
}
|
|
191
|
+
// By name, and by id when two providers share one: the id decides, the name only labels.
|
|
192
|
+
const sections = [...groups.entries()]
|
|
193
|
+
.sort(([idA, a], [idB, b]) => (a.title ?? "").localeCompare(b.title ?? "") || idA.localeCompare(idB))
|
|
194
|
+
.map(([, section]) => section)
|
|
195
|
+
if (used) {
|
|
196
|
+
// The in-use provider leads, and its model leads inside it.
|
|
197
|
+
const at = sections.indexOf(groups.get(used.providerID)!)
|
|
198
|
+
if (at > 0) sections.unshift(...sections.splice(at, 1))
|
|
199
|
+
const first = sections[0]
|
|
200
|
+
if (first) first.rows = [used, ...first.rows.filter((row) => row !== used)]
|
|
201
|
+
}
|
|
202
|
+
return sections
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Self-check: node plugins/oc-models-pro/rows.ts --self-check
|
|
206
|
+
// Guarded: this module is also bundled into the desktop window, where `process` does not exist.
|
|
207
|
+
if (typeof process !== "undefined" && process.argv?.[2] === "--self-check") {
|
|
208
|
+
const row = (over: Partial<Row> = {}): Row => ({
|
|
209
|
+
name: "Model",
|
|
210
|
+
providerID: "p",
|
|
211
|
+
modelID: "m",
|
|
212
|
+
provider: "Provider",
|
|
213
|
+
tag: "PR",
|
|
214
|
+
credit: 1,
|
|
215
|
+
enabled: true,
|
|
216
|
+
...over,
|
|
217
|
+
})
|
|
218
|
+
const context: SortContext = { recent: new Map([["p/m", 0]]) }
|
|
219
|
+
const check = (ok: boolean, message: string) => {
|
|
220
|
+
if (!ok) throw new Error("self-check: " + message)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
check(effective(row()) === undefined, "an unknown input price is not zero")
|
|
224
|
+
check(effective(row({ input: 0.8, credit: 8 })) === 0.1, "the credit divides the input price")
|
|
225
|
+
check(effective(row({ input: 0.8, credit: 0 })) === 0.8, "a zero credit must not divide by zero")
|
|
226
|
+
check(!isFree(row()), "a missing price is not free")
|
|
227
|
+
check(!isFree(row({ input: 0, output: 2 })), "a zero input with a paid output is not free")
|
|
228
|
+
check(isFree(row({ input: 0, output: 0 })), "both at zero is free")
|
|
229
|
+
check(speedOf(row({ tps: 40, measured: 12 })) === 12, "a measured speed outranks a borrowed one")
|
|
230
|
+
check(speedOf(row()) === undefined, "no speed is no speed")
|
|
231
|
+
|
|
232
|
+
// The price orders put an unknown figure last, and the descending ones put a missing figure last too.
|
|
233
|
+
const sorts = (key: SortKey, rows: Row[]) => [...rows].sort((a, b) => compare(key, a, b, context))
|
|
234
|
+
const cheap = row({ name: "cheap", input: 1, output: 1 })
|
|
235
|
+
const dear = row({ name: "dear", input: 10, output: 10 })
|
|
236
|
+
const unknown = row({ name: "unknown" })
|
|
237
|
+
check(sorts("input", [unknown, dear, cheap]).map((r) => r.name)[0] === "cheap", "in↑ starts at the cheapest")
|
|
238
|
+
check(sorts("input", [unknown, dear, cheap]).map((r) => r.name)[2] === "unknown", "an unknown price sorts last")
|
|
239
|
+
const winning: Row[] = [row({ name: "few", context: 1000, int: 10 }), row({ name: "many", context: 100_000, int: 50 }), unknown]
|
|
240
|
+
check(sorts("context", winning)[0].name === "many", "ctx↓ starts at the widest window")
|
|
241
|
+
check(sorts("context", winning)[2].name === "unknown", "a missing window sorts last, not first")
|
|
242
|
+
check(sorts("int", winning)[0].name === "many", "int↓ starts at the highest score")
|
|
243
|
+
check(sorts("int", winning)[2].name === "unknown", "a missing score sorts last, not first")
|
|
244
|
+
check(sorts("speed", winning)[2].name === "unknown", "a missing speed sorts last, not first")
|
|
245
|
+
// Flipping a column is the reader's right, and it must never bring the blanks to the top: this is the
|
|
246
|
+
// state two clicks reach, and the one the sentinels used to get wrong.
|
|
247
|
+
const flipped = (key: SortKey, rows: Row[]) => [...rows].sort((a, b) => compare(key, a, b, context, -1))
|
|
248
|
+
check(flipped("input", [unknown, dear, cheap])[2].name === "unknown", "in↓ puts an unknown price last too")
|
|
249
|
+
check(flipped("input", [unknown, dear, cheap])[0].name === "dear", "in↓ starts at the dearest")
|
|
250
|
+
check(flipped("context", winning)[2].name === "unknown", "ctx↑ puts a missing window last too")
|
|
251
|
+
check(flipped("int", winning)[2].name === "unknown", "int↑ puts a missing score last too")
|
|
252
|
+
check(flipped("speed", winning)[2].name === "unknown", "speed↑ puts a missing speed last too")
|
|
253
|
+
check(flipped("effective", [unknown, dear, cheap])[2].name === "unknown", "effective↓ puts an unknown price last too")
|
|
254
|
+
const recentContext: SortContext = { recent: new Map([["p/fresh", 0]]) }
|
|
255
|
+
const fresh = row({ name: "fresh", modelID: "fresh" })
|
|
256
|
+
const stale = row({ name: "stale", modelID: "stale" })
|
|
257
|
+
check(
|
|
258
|
+
[stale, fresh].sort((a, b) => compare("recent", a, b, recentContext))[0].name === "fresh",
|
|
259
|
+
"the recent list orders by its index",
|
|
260
|
+
)
|
|
261
|
+
check(compare("input", cheap, dear, context, -1) > 0, "the sign the caller passes flips the order")
|
|
262
|
+
check(compare("name", row({ name: "a" }), row({ name: "b" }), context) < 0, "name compares text")
|
|
263
|
+
check(compare("provider", row({ provider: "a" }), row({ provider: "b" }), context) < 0, "provider compares text")
|
|
264
|
+
check(direction("effective") === 1 && direction("int") === -1, "the default direction follows the order")
|
|
265
|
+
|
|
266
|
+
// The order the surfaces draw: the cases that each cost a bug.
|
|
267
|
+
const a = row({ name: "a", modelID: "a", providerID: "pa", provider: "A", input: 2, output: 2 })
|
|
268
|
+
const b = row({ name: "b", modelID: "b", providerID: "pa", provider: "A", input: 0, output: 0 })
|
|
269
|
+
const c = row({ name: "c", modelID: "c", providerID: "pb", provider: "B", input: 3, output: 3 })
|
|
270
|
+
|
|
271
|
+
const grouped = arrange([a, b, c], { inUse: "pb/c", grouped: true })
|
|
272
|
+
check(grouped.map((s) => s.title).join() === "B,A", "the provider in use leads the list")
|
|
273
|
+
check(grouped[0].rows[0] === c, "and its model leads inside it")
|
|
274
|
+
check(grouped[1].rows.map((r) => r.name).join() === "a,b", "paying before free inside a group")
|
|
275
|
+
|
|
276
|
+
// The in-use row shares its group here: hoisting the provider is not enough, the row itself must move,
|
|
277
|
+
// and this is the shape the panel depends on (its sort does not pre-hoist, unlike the terminal's).
|
|
278
|
+
const inner = arrange([a, b, c], { inUse: "pa/b", grouped: true })
|
|
279
|
+
check(inner[0].title === "A" && inner[0].rows[0] === b, "the in-use row leads inside a group that has others")
|
|
280
|
+
check(inner[0].rows.map((r) => r.name).join() === "b,a", "and the rest of the group keeps its order")
|
|
281
|
+
|
|
282
|
+
// Free in use: it leads the list in both layouts, and never falls into the free bucket.
|
|
283
|
+
const freeUsed = arrange([a, b, c], { inUse: "pa/b", grouped: false })
|
|
284
|
+
check(freeUsed[0].rows[0] === b, "flat: a free model in use still leads")
|
|
285
|
+
check(!freeUsed.some((s) => s.title === "Free"), "flat: and is not repeated in the free bucket")
|
|
286
|
+
|
|
287
|
+
const solo = arrange([c], { inUse: "pb/c", grouped: true })
|
|
288
|
+
check(solo.length === 1 && solo[0].title === "B", "the only row a filter kept still has its own group")
|
|
289
|
+
|
|
290
|
+
const flat = arrange([b, c, a], { inUse: "pb/c", grouped: false })
|
|
291
|
+
check(flat[0].rows[0] === c, "flat: the model in use leads the list")
|
|
292
|
+
check(flat[flat.length - 1].title === "Free", "flat: the free bucket is last")
|
|
293
|
+
check(flat[flat.length - 1].rows[0] === b, "flat: and holds the free row")
|
|
294
|
+
|
|
295
|
+
const twin = row({ name: "twin", modelID: "twin", providerID: "p2", provider: "A", input: 4, output: 4 })
|
|
296
|
+
const twins = arrange([a, twin], { inUse: "p2/twin", grouped: true })
|
|
297
|
+
check(twins.length === 2, "two providers sharing a display name stay two groups")
|
|
298
|
+
check(twins[0].rows[0] === twin, "and the one in use leads by id, not by the shared name")
|
|
299
|
+
|
|
300
|
+
console.log("self-check ok: " + ORDER.length + " orders, unknown figures last in every one of them")
|
|
301
|
+
}
|
package/rpc.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// What the desktop's panel reads: a plugin window has no Node built-ins, so every file and every fetch
|
|
2
|
+
// stays in the server half and the window asks for the finished table.
|
|
3
|
+
import { Rpc } from "@opencode/plugin/rpc"
|
|
4
|
+
|
|
5
|
+
import type { Row } from "./rows.ts"
|
|
6
|
+
|
|
7
|
+
const Count = { type: "number" }
|
|
8
|
+
|
|
9
|
+
const Row = {
|
|
10
|
+
type: "object",
|
|
11
|
+
properties: {
|
|
12
|
+
providerID: { type: "string" },
|
|
13
|
+
modelID: { type: "string" },
|
|
14
|
+
name: { type: "string" },
|
|
15
|
+
/** The provider's display name, account first as the host writes it. */
|
|
16
|
+
provider: { type: "string" },
|
|
17
|
+
/** Two letters from `tags.ts`. */
|
|
18
|
+
tag: { type: "string" },
|
|
19
|
+
/** Prices per million tokens; absent when the catalogue has none — an unknown figure, not a free one. */
|
|
20
|
+
input: Count,
|
|
21
|
+
output: Count,
|
|
22
|
+
context: Count,
|
|
23
|
+
/** Monthly limit ÷ the $10 plan. 1 when the plan is unknown. */
|
|
24
|
+
credit: Count,
|
|
25
|
+
promo: Count,
|
|
26
|
+
peak: Count,
|
|
27
|
+
int: Count,
|
|
28
|
+
/** Borrowed from Artificial Analysis, or the catalogue's own when it still carries one. */
|
|
29
|
+
tps: Count,
|
|
30
|
+
/** Measured on this machine, on this provider. Outranks `tps` and carries no tilde. */
|
|
31
|
+
measured: Count,
|
|
32
|
+
enabled: { type: "boolean" },
|
|
33
|
+
},
|
|
34
|
+
required: ["providerID", "modelID", "name", "provider", "tag", "credit", "enabled"],
|
|
35
|
+
additionalProperties: false,
|
|
36
|
+
} as const
|
|
37
|
+
|
|
38
|
+
const Snapshot = {
|
|
39
|
+
type: "object",
|
|
40
|
+
properties: {
|
|
41
|
+
rows: { type: "array", items: Row },
|
|
42
|
+
/** `providerID/modelID` of the models the host keeps as favourites, from `model.json`. */
|
|
43
|
+
favorites: { type: "array", items: { type: "string" } },
|
|
44
|
+
/** How many offers the catalogue holds, and how many came out with a speed — the pair that tells a
|
|
45
|
+
* thin source from a broken key. */
|
|
46
|
+
offers: Count,
|
|
47
|
+
withSpeed: Count,
|
|
48
|
+
/** False when the Go plan could not be read and no cache was there: every credit is 1. */
|
|
49
|
+
credits: { type: "boolean" },
|
|
50
|
+
/** What failed, in the words the panel shows. Empty when everything came back. */
|
|
51
|
+
problems: { type: "array", items: { type: "string" } },
|
|
52
|
+
},
|
|
53
|
+
required: ["rows", "favorites", "offers", "withSpeed", "credits"],
|
|
54
|
+
additionalProperties: false,
|
|
55
|
+
} as const
|
|
56
|
+
|
|
57
|
+
export const Models = Rpc.define({
|
|
58
|
+
id: "models",
|
|
59
|
+
methods: {
|
|
60
|
+
snapshot: {
|
|
61
|
+
input: {
|
|
62
|
+
type: "object",
|
|
63
|
+
properties: { force: { type: "boolean" } },
|
|
64
|
+
additionalProperties: false,
|
|
65
|
+
},
|
|
66
|
+
output: Snapshot,
|
|
67
|
+
},
|
|
68
|
+
setFavorite: {
|
|
69
|
+
input: {
|
|
70
|
+
type: "object",
|
|
71
|
+
properties: {
|
|
72
|
+
providerID: { type: "string" },
|
|
73
|
+
modelID: { type: "string" },
|
|
74
|
+
/** Absent toggles. */
|
|
75
|
+
on: { type: "boolean" },
|
|
76
|
+
},
|
|
77
|
+
required: ["providerID", "modelID"],
|
|
78
|
+
additionalProperties: false,
|
|
79
|
+
},
|
|
80
|
+
output: {
|
|
81
|
+
type: "object",
|
|
82
|
+
properties: {
|
|
83
|
+
/** False when the file could not be read or written: the panel says so instead of pretending. */
|
|
84
|
+
ok: { type: "boolean" },
|
|
85
|
+
favorites: { type: "array", items: { type: "string" } },
|
|
86
|
+
},
|
|
87
|
+
required: ["ok", "favorites"],
|
|
88
|
+
additionalProperties: false,
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
// Present (empty) on purpose: a missing events map fails the whole plugin.
|
|
93
|
+
events: {},
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
/** The wire form is the row both surfaces sort: one shape, declared once. */
|
|
97
|
+
export type ModelRow = Row
|
|
98
|
+
|
|
99
|
+
export interface ModelsSnapshot {
|
|
100
|
+
rows: ModelRow[]
|
|
101
|
+
favorites: string[]
|
|
102
|
+
offers: number
|
|
103
|
+
withSpeed: number
|
|
104
|
+
credits: boolean
|
|
105
|
+
problems?: string[]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export default Models
|
package/tps.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// oc-models-pro/tps.ts — tokens per second, measured on this machine.
|
|
2
|
+
//
|
|
3
|
+
// Not an estimate from a catalogue: it is what this provider actually did, for the models this
|
|
4
|
+
// person actually ran. The number is the one the industry publishes, so it can be read next to
|
|
5
|
+
// anything online: (output + reasoning tokens) over the window from the first streamed token to the
|
|
6
|
+
// last, time-to-first-token excluded — Artificial Analysis' "output speed", and `completion_tokens`
|
|
7
|
+
// as OpenAI, Anthropic and OpenRouter bill it, where reasoning is part of the output.
|
|
8
|
+
//
|
|
9
|
+
// The window is the last seven days when it holds at least three turns, and all of history when it
|
|
10
|
+
// does not: a number from a provider's good week is worth less than the provider's behaviour now,
|
|
11
|
+
// but an empty cell for a model used once last month is worth less still.
|
|
12
|
+
//
|
|
13
|
+
// Runnable standalone for its self-check: node plugins/oc-models-pro/tps.ts --self-check
|
|
14
|
+
|
|
15
|
+
import { execFile } from "node:child_process"
|
|
16
|
+
import { existsSync } from "node:fs"
|
|
17
|
+
import { homedir } from "node:os"
|
|
18
|
+
import { join } from "node:path"
|
|
19
|
+
|
|
20
|
+
export interface Speed {
|
|
21
|
+
/** Median tokens per second for this provider/model. */
|
|
22
|
+
tps: number
|
|
23
|
+
/** How many turns the median came from. */
|
|
24
|
+
samples: number
|
|
25
|
+
/** True when the seven-day window was too thin and the whole history was used. */
|
|
26
|
+
allTime: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface Turn {
|
|
30
|
+
key: string
|
|
31
|
+
sid: string
|
|
32
|
+
tps: number
|
|
33
|
+
at: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A window thinner than this is noise, not a measurement. */
|
|
37
|
+
const RECENT_DAYS = 7
|
|
38
|
+
const MIN_SAMPLES = 3
|
|
39
|
+
/** A turn shorter than this cannot separate decode time from the rounding of the clock. */
|
|
40
|
+
const MIN_SPAN_MS = 200
|
|
41
|
+
const MIN_TOKENS = 20
|
|
42
|
+
|
|
43
|
+
export function databasePath(): string {
|
|
44
|
+
const data = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share")
|
|
45
|
+
return process.env.OPENCODE_DB || join(data, "opencode", "opencode.db")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The host's own SQLite. Absent outside Bun, where the CLI carries the self-check. */
|
|
49
|
+
async function sqlite(): Promise<typeof import("bun:sqlite") | undefined> {
|
|
50
|
+
try {
|
|
51
|
+
return await import("bun:sqlite")
|
|
52
|
+
} catch {
|
|
53
|
+
return undefined
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function read(database: string): Promise<Array<{ data: string; at: number; sid: string }>> {
|
|
58
|
+
if (!existsSync(database)) return []
|
|
59
|
+
const bun = await sqlite()
|
|
60
|
+
if (bun) {
|
|
61
|
+
try {
|
|
62
|
+
const db = new bun.Database(database, { readonly: true })
|
|
63
|
+
try {
|
|
64
|
+
return db
|
|
65
|
+
.query("select data, time_created as at, session_id as sid from session_message where type = 'assistant'")
|
|
66
|
+
.all() as Array<{ data: string; at: number; sid: string }>
|
|
67
|
+
} finally {
|
|
68
|
+
db.close()
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// A read-only open of a live WAL database is the one thing that differs per host.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
execFile(
|
|
76
|
+
"sqlite3",
|
|
77
|
+
[
|
|
78
|
+
"file:" + database + "?mode=ro",
|
|
79
|
+
"-json",
|
|
80
|
+
"select data, time_created as at, session_id as sid from session_message where type = 'assistant'",
|
|
81
|
+
],
|
|
82
|
+
{ maxBuffer: 256 * 1024 * 1024 },
|
|
83
|
+
(error, stdout) => (error ? reject(error) : resolve(JSON.parse(stdout || "[]"))),
|
|
84
|
+
)
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One assistant turn as a rate, or nothing when the clock or the token counts cannot carry one. */
|
|
89
|
+
export function turn(entry: any, sid = ""): Turn | undefined {
|
|
90
|
+
if (!entry || !entry.tokens || !entry.time || !Array.isArray(entry.content)) return undefined
|
|
91
|
+
const provider = entry.model?.providerID
|
|
92
|
+
const model = entry.model?.id
|
|
93
|
+
if (typeof provider !== "string" || typeof model !== "string") return undefined
|
|
94
|
+
// The window opens at the first content part that carries a timestamp — a part without one (a file
|
|
95
|
+
// reference, say) would start the clock late and overstate the rate.
|
|
96
|
+
const first = entry.content.find((part: any) => typeof part?.time?.created === "number")?.time?.created
|
|
97
|
+
// The last streamed token; `completed` only when the host recorded nothing (it carries the same
|
|
98
|
+
// instant, and post-stream bookkeeping would drag the rate down).
|
|
99
|
+
const last = entry.time.streamed ?? entry.time.completed
|
|
100
|
+
if (typeof first !== "number" || typeof last !== "number") return undefined
|
|
101
|
+
const tokens = (entry.tokens.output ?? 0) + (entry.tokens.reasoning ?? 0)
|
|
102
|
+
const span = last - first
|
|
103
|
+
if (!(span >= MIN_SPAN_MS) || tokens < MIN_TOKENS) return undefined
|
|
104
|
+
return { key: provider + "/" + model, sid, tps: tokens / (span / 1000), at: entry.time.created ?? 0 }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function median(values: number[]): number {
|
|
108
|
+
const sorted = [...values].sort((a, b) => a - b)
|
|
109
|
+
const middle = sorted.length >> 1
|
|
110
|
+
return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The rates of every model, keyed `providerID/modelID` as the host writes them. */
|
|
114
|
+
export async function speedIndex(database = databasePath()): Promise<{
|
|
115
|
+
speeds: Map<string, Speed>
|
|
116
|
+
turns: number
|
|
117
|
+
rows: number
|
|
118
|
+
}> {
|
|
119
|
+
let rows: Array<{ data: string; at: number; sid: string }>
|
|
120
|
+
try {
|
|
121
|
+
rows = await read(database)
|
|
122
|
+
} catch {
|
|
123
|
+
return { speeds: new Map(), turns: 0, rows: 0 }
|
|
124
|
+
}
|
|
125
|
+
const turns: Turn[] = []
|
|
126
|
+
for (const row of rows) {
|
|
127
|
+
let entry: any
|
|
128
|
+
try {
|
|
129
|
+
entry = JSON.parse(row.data)
|
|
130
|
+
} catch {
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
const measured = turn(entry, row.sid)
|
|
134
|
+
if (measured) turns.push({ ...measured, at: measured.at || row.at })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const since = Date.now() - RECENT_DAYS * 86_400_000
|
|
138
|
+
const speeds = new Map<string, Speed>()
|
|
139
|
+
for (const key of new Set(turns.map((t) => t.key))) {
|
|
140
|
+
const all = turns.filter((t) => t.key === key)
|
|
141
|
+
const recent = all.filter((t) => t.at >= since)
|
|
142
|
+
const use = recent.length >= MIN_SAMPLES ? recent : all
|
|
143
|
+
if (use.length === 0) continue
|
|
144
|
+
// One voice per session, then the median of those voices: without it a single long-running
|
|
145
|
+
// session elects the number on its own, and this person's sessions run for days.
|
|
146
|
+
const perSession = new Map<string, number[]>()
|
|
147
|
+
for (const t of use) {
|
|
148
|
+
const list = perSession.get(t.sid) ?? []
|
|
149
|
+
list.push(t.tps)
|
|
150
|
+
perSession.set(t.sid, list)
|
|
151
|
+
}
|
|
152
|
+
speeds.set(key, {
|
|
153
|
+
tps: median([...perSession.values()].map((list) => median(list))),
|
|
154
|
+
samples: use.length,
|
|
155
|
+
allTime: recent.length < MIN_SAMPLES,
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
return { speeds, turns: turns.length, rows: rows.length }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Self-check: node plugins/oc-models-pro/tps.ts --self-check
|
|
162
|
+
if (process.argv[2] === "--self-check") {
|
|
163
|
+
const run = async () => {
|
|
164
|
+
const started = Date.now()
|
|
165
|
+
const { speeds, turns, rows } = await speedIndex()
|
|
166
|
+
const ms = Date.now() - started
|
|
167
|
+
|
|
168
|
+
// The thresholds and the key, on turns made up here: real data cannot exercise a floor it never hits.
|
|
169
|
+
const base = {
|
|
170
|
+
model: { providerID: "p", id: "m" },
|
|
171
|
+
time: { created: 0, streamed: 1000 },
|
|
172
|
+
tokens: { output: 30, reasoning: 20 },
|
|
173
|
+
content: [{ type: "text", time: { created: 200 } }],
|
|
174
|
+
}
|
|
175
|
+
const good = turn(base, "ses_x")
|
|
176
|
+
if (!good || good.key !== "p/m" || good.sid !== "ses_x") throw new Error("self-check: a plain turn did not parse")
|
|
177
|
+
if (Math.abs(good.tps - 62.5) > 0.001) throw new Error("self-check: 50 tokens over 800 ms is not 62.5 t/s: " + good.tps)
|
|
178
|
+
const short = { ...base, time: { created: 0, streamed: 300 } }
|
|
179
|
+
if (turn(short)) throw new Error("self-check: a 100 ms window must be dropped")
|
|
180
|
+
const few = { ...base, tokens: { output: 5, reasoning: 0 } }
|
|
181
|
+
if (turn(few)) throw new Error("self-check: five tokens must be dropped")
|
|
182
|
+
if (turn({ time: {}, tokens: {}, content: [] })) throw new Error("self-check: a turn without a model must be dropped")
|
|
183
|
+
if (median([3, 1, 2]) !== 2 || median([1, 4]) !== 2.5) throw new Error("self-check: median is wrong")
|
|
184
|
+
|
|
185
|
+
if (rows === 0) {
|
|
186
|
+
console.log("self-check ok: no assistant messages on this machine, nothing to measure")
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
// Rows but no measurement is not an empty machine: it is a shape that moved, and it must fail.
|
|
190
|
+
if (turns === 0) throw new Error("self-check: " + rows + " assistant messages and none measurable, the message shape changed")
|
|
191
|
+
if (speeds.size === 0) throw new Error("self-check: " + turns + " turns but no model measured")
|
|
192
|
+
for (const [key, speed] of speeds) {
|
|
193
|
+
if (!Number.isFinite(speed.tps) || speed.tps <= 0) throw new Error("self-check: " + key + " not finite: " + speed.tps)
|
|
194
|
+
if (speed.samples < 1) throw new Error("self-check: " + key + " has no samples")
|
|
195
|
+
}
|
|
196
|
+
const best = [...speeds.entries()].sort((a, b) => b[1].samples - a[1].samples).slice(0, 3)
|
|
197
|
+
console.log(
|
|
198
|
+
"self-check ok: " +
|
|
199
|
+
turns +
|
|
200
|
+
" turns, " +
|
|
201
|
+
speeds.size +
|
|
202
|
+
" models, " +
|
|
203
|
+
ms +
|
|
204
|
+
" ms -> " +
|
|
205
|
+
best.map(([k, s]) => k + " " + s.tps.toFixed(1) + " t/s (" + s.samples + (s.allTime ? " all-time" : " 7d") + ")").join(", "),
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
run().catch((error) => {
|
|
209
|
+
console.error(error)
|
|
210
|
+
process.exit(1)
|
|
211
|
+
})
|
|
212
|
+
}
|