@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/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
+ }