@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/tui.js CHANGED
@@ -18,137 +18,24 @@ import { createElement as _$createElement } from "@opentui/solid";
18
18
  // is the first row whatever it costs: the free/paying split never buries it.
19
19
  import { Plugin } from "@opencode/plugin/tui";
20
20
  import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
21
- import { appendFileSync, readFileSync, renameSync, watch, writeFileSync } from "node:fs";
21
+ import { appendFileSync, readFileSync, watch, writeFileSync } from "node:fs";
22
22
  import { homedir, tmpdir } from "node:os";
23
23
  import { join } from "node:path";
24
- import { benchFor, benchIndex } from "./bench.ts";
25
- import { credits, goPlan, key } from "./multipliers.ts";
24
+ import { benchFor, benchIndex, benchKey } from "./bench.ts";
25
+ import { aaIndex } from "./aa.ts";
26
+ import { speedIndex } from "./tps.ts";
27
+ import { credits, goPlan, key, nativePeak } from "./multipliers.ts";
26
28
  import { LEGEND_MIN, fit, hintLines, rowWidth } from "./fit.ts";
27
29
  import { tagsFor } from "./tags.ts";
30
+ import { prefKey, readFavourites, readPrefs, toggleFavorite, writeFavourites, PREFS_FILE } from "./prefs.ts";
31
+ import { ORDER, ORDERS, arrange, compare, isFree } from "./rows.ts";
28
32
  const SCOPES = ["all", "favorites", "paying"];
29
- const prefKey = (providerID, modelID) => providerID + "/" + modelID;
30
-
31
- /** The picker's own state file: `favorite[]` and `recent[]` are `{ providerID, modelID }`. */
32
-
33
- const PREFS_FILE = join(homedir(), ".local/state/opencode/model.json");
34
- /** Same directory, a leading dot: the host watches `model.json.*`, not this. */
35
- const TEMP_FILE = join(homedir(), ".local/state/opencode", ".model.json.tmp");
36
33
  let prefs = {
37
34
  favorite: new Set(),
38
35
  recent: new Map(),
39
36
  variants: new Map()
40
37
  };
41
38
 
42
- /** The host's `favorite[]` as it is in the file: a key cannot be split back — a model id may hold a slash. */
43
- function readFavourites() {
44
- try {
45
- const data = JSON.parse(readFileSync(PREFS_FILE, "utf8"));
46
- if (!Array.isArray(data.favorite)) return [];
47
- return data.favorite.filter(entry => typeof entry?.providerID === "string" && typeof entry?.modelID === "string");
48
- } catch (error) {
49
- // A missing file is not an error: starting empty creates it on the write. Anything else is.
50
- if (error?.code === "ENOENT") return [];
51
- trace("favourites: " + (error instanceof Error ? error.message : String(error)));
52
- return undefined;
53
- }
54
- }
55
-
56
- /** Rewrites `favorite[]` with the rest of the file untouched. The host watches this path and
57
- * `model.json.*`, so the atomic rename is seen and the native picker, `model.cycle_favorite` and this
58
- * list stay in step; a dot in front keeps the temp name outside that glob. */
59
- function writeFavourites(list) {
60
- try {
61
- let raw;
62
- try {
63
- raw = JSON.parse(readFileSync(PREFS_FILE, "utf8"));
64
- } catch (error) {
65
- if (error?.code !== "ENOENT") throw error;
66
- raw = {};
67
- }
68
- raw.favorite = list;
69
- writeFileSync(TEMP_FILE, JSON.stringify(raw));
70
- renameSync(TEMP_FILE, PREFS_FILE);
71
- return true;
72
- } catch (error) {
73
- trace("favourites: " + (error instanceof Error ? error.message : String(error)));
74
- return false;
75
- }
76
- }
77
- function toggleFavorite(providerID, modelID) {
78
- const key = prefKey(providerID, modelID);
79
- const list = readFavourites();
80
- if (!list) return undefined;
81
- const kept = list.filter(entry => prefKey(entry.providerID, entry.modelID) !== key);
82
- const on = kept.length === list.length;
83
- return writeFavourites(on ? [...kept, {
84
- providerID,
85
- modelID
86
- }] : kept) ? on : undefined;
87
- }
88
- function readPrefs() {
89
- const parsed = {
90
- favorite: new Set(),
91
- recent: new Map(),
92
- variants: new Map()
93
- };
94
- try {
95
- const data = JSON.parse(readFileSync(PREFS_FILE, "utf8"));
96
- for (const entry of data.favorite ?? []) parsed.favorite.add(prefKey(entry.providerID, entry.modelID));
97
- for (const [index, entry] of (data.recent ?? []).entries()) parsed.recent.set(prefKey(entry.providerID, entry.modelID), index);
98
- for (const [model, variant] of Object.entries(data.variant ?? {})) if (typeof variant === "string") parsed.variants.set(model, variant);
99
- } catch (error) {
100
- // A corrupt state file empties the favourites and the recent list: say so instead of pretending.
101
- trace("prefs unreadable: " + (error instanceof Error ? error.message : String(error)));
102
- }
103
- return parsed;
104
- }
105
-
106
- /** What the Go plan makes a model cost: the figure the effective order sorts by. An unknown price is
107
- * not zero — it sorts last, or it would sit at the top of the cheapest list forever. */
108
- function effective(row) {
109
- if (row.input === undefined) return Number.POSITIVE_INFINITY;
110
- return row.input / (row.credit || 1);
111
- }
112
- const SORTS = [{
113
- label: "effective price",
114
- short: "effective↑",
115
- value: effective
116
- }, {
117
- label: "input price",
118
- short: "in↑",
119
- value: row => row.input ?? Number.POSITIVE_INFINITY
120
- }, {
121
- label: "output price",
122
- short: "out↑",
123
- value: row => row.output ?? Number.POSITIVE_INFINITY
124
- }, {
125
- label: "input + output",
126
- short: "in+out↑",
127
- value: row => row.input === undefined || row.output === undefined ? Number.POSITIVE_INFINITY : (row.input + row.output) / (row.credit || 1)
128
- }, {
129
- label: "context window",
130
- short: "ctx↓",
131
- value: row => row.context === undefined ? Number.POSITIVE_INFINITY : -row.context
132
- },
133
- // A missing score sorts last, never first: Pi's rule, and the honest one.
134
- {
135
- label: "intelligence",
136
- short: "int↓",
137
- value: row => -(row.int ?? -1)
138
- }, {
139
- label: "speed",
140
- short: "speed↓",
141
- value: row => -(row.tps ?? -1)
142
- }, {
143
- label: "recently used",
144
- short: "recent",
145
- value: row => prefs.recent.get(prefKey(row.providerID, row.modelID)) ?? 1e9
146
- }, {
147
- label: "name",
148
- short: "name",
149
- value: row => 0
150
- }];
151
-
152
39
  /** Fallbacks, used only when the theme defines none of the candidates. */
153
40
  const FALLBACK_DIM = "#8a8a8a";
154
41
  const FALLBACK_ACCENT = "#5a9fd4";
@@ -191,17 +78,39 @@ function price(value, width) {
191
78
  function isGo(providerID) {
192
79
  return providerID === "opencode-go";
193
80
  }
194
- function free(row) {
195
- return row.input === 0 && row.output === 0;
196
- }
197
81
  function headerRow(f) {
198
82
  return [" ", " ", "model".padEnd(f.name), cell("in", 6), cell("out", 6), ...(f.ctx ? [cell("ctx", 5)] : []), ...(f.int ? [cell("int", 5)] : []), ...(f.tkps ? [cell("tok/s", 6)] : []), cell("cred", 4), ...(f.note ? [cell("note", 6)] : [])].join(" ");
199
83
  }
200
- function modelRow(row, mine, favourite, f) {
84
+
85
+ /** The speed cell on its own, so the renderer can colour it apart from the rest of the row. */
86
+ function speedCell(row, f) {
87
+ if (!f.tkps) return "";
88
+ const text = row.measured !== undefined ? String(Math.round(row.measured)) : row.tps !== undefined ? "~" + Math.round(row.tps) : "";
89
+ return cell(text, 6);
90
+ }
91
+
92
+ /** A row in three pieces: everything before the speed, the speed, everything after. */
93
+ function rowParts(row, mine, favourite, f) {
201
94
  const notes = [row.peak ? "peak*" + row.peak : "", row.promo ? "+" + row.promo + "x" : ""].filter(Boolean);
202
- return [(mine ? "●" : " ") + (favourite ? "*" : " "), padTag(row.tag), label(row.name, f.name), price(row.input, 6), price(row.output, 6), ...(f.ctx ? [cell(tokens(row.context), 5)] : []), ...(f.int ? [cell(row.int !== undefined ? row.int.toFixed(1) : "", 5)] : []), ...(f.tkps ? [cell(row.tps !== undefined ? "~" + Math.round(row.tps) : "", 6)] : []),
95
+ const head = [(mine ? "●" : " ") + (favourite ? "*" : " "), padTag(row.tag), label(row.name, f.name), price(row.input, 6), price(row.output, 6), ...(f.ctx ? [cell(tokens(row.context), 5)] : []), ...(f.int ? [cell(row.int !== undefined ? row.int.toFixed(1) : "", 5)] : [])];
96
+ const tail = [
203
97
  // Any credit other than 1 changes the effective price, so any of them is shown.
204
- cell(row.credit !== 1 ? "×" + row.credit : "", 4), ...(f.note ? [cell(notes.join(" "), 6)] : [])].join(" ");
98
+ cell(row.credit !== 1 ? "×" + row.credit : "", 4), ...(f.note ? [cell(notes.join(" "), 6)] : [])];
99
+ const speed = speedCell(row, f);
100
+ if (!speed) return {
101
+ before: head.concat(tail).join(" "),
102
+ speed: "",
103
+ after: ""
104
+ };
105
+ return {
106
+ before: head.join(" ") + " ",
107
+ speed,
108
+ after: tail.length ? " " + tail.join(" ") : ""
109
+ };
110
+ }
111
+ function modelRow(row, mine, favourite, f) {
112
+ const parts = rowParts(row, mine, favourite, f);
113
+ return parts.before + parts.speed + parts.after;
205
114
  }
206
115
  const TRACE = join(tmpdir(), "oc-models-pro.log");
207
116
 
@@ -254,11 +163,15 @@ export default Plugin.define({
254
163
  prefs = readPrefs();
255
164
  let creditIndex = new Map();
256
165
  let bench = new Map();
166
+ /** Measured on this machine, keyed `providerID/modelID`: it wins over any catalogue figure. */
167
+ let measured = new Map();
168
+ /** From Artificial Analysis, for the models this machine never ran. */
169
+ let estimated = new Map();
257
170
  /** False when the Go page could not be read and no cache was there: the credits are all 1. */
258
171
  let creditsKnown = false;
259
172
  // Signals, not plain variables: the header shows them, and a plain variable left the title
260
173
  // saying "effective price" whatever the list was actually sorted by.
261
- const [sort, setSort] = createSignal(0);
174
+ const [sort, setSort] = createSignal("effective");
262
175
  const [grouped, setGrouped] = createSignal(true);
263
176
  const [scope, setScope] = createSignal("all");
264
177
  function fail(label, error) {
@@ -296,6 +209,10 @@ export default Plugin.define({
296
209
  const cost = (model.cost ?? []).find(entry => !entry.tier) ?? model.cost?.[0];
297
210
  const limit = model.limit ?? {};
298
211
  const score = benchFor(bench, model.name, model.modelID);
212
+ // Three sources, one order: what this machine measured, then Artificial Analysis, then
213
+ // the catalogue. A measured figure is never displaced by an estimate.
214
+ const mine = measured.get(model.providerID + "/" + model.modelID);
215
+ const theirs = mine === undefined ? estimated.get(benchKey(model.name)) ?? estimated.get(benchKey(model.modelID)) ?? score?.tps : undefined;
299
216
  result.push({
300
217
  name: model.name,
301
218
  providerID: model.providerID,
@@ -314,9 +231,12 @@ export default Plugin.define({
314
231
  // "DeepSeek V4.1 Flash" inherited the Go credit.
315
232
  credit: isGo(model.providerID) ? creditIndex.get(key(model.modelID))?.credit ?? 1 : 1,
316
233
  promo: isGo(model.providerID) ? creditIndex.get(key(model.modelID))?.promo : undefined,
317
- peak: isGo(model.providerID) ? creditIndex.get(key(model.modelID))?.peak : undefined,
234
+ // Measured on the Go plan first, then the native rule: the catalogue lists one
235
+ // DeepSeek price, so only the second can say it doubles.
236
+ peak: (isGo(model.providerID) ? creditIndex.get(key(model.modelID))?.peak : undefined) ?? nativePeak(model.providerID),
318
237
  int: score?.int,
319
- tps: score?.tps,
238
+ tps: theirs,
239
+ measured: mine,
320
240
  enabled: model.enabled !== false
321
241
  });
322
242
  }
@@ -340,7 +260,21 @@ export default Plugin.define({
340
260
  // One fetch a day at most; a failure only costs the credit column and the effective ordering.
341
261
  const plan = await goPlan();
342
262
  creditIndex = plan ? credits(plan) : new Map();
343
- bench = await benchIndex();
263
+ // The three reads run together: the panel waits for the slowest, not for their sum. Measured
264
+ // comes first in the ordering below — the only figure that is this provider's own behaviour.
265
+ const started = Date.now();
266
+ const [benchmarks, speeds, estimates] = await Promise.all([benchIndex(), speedIndex(), aaIndex()]);
267
+ bench = benchmarks;
268
+ measured = new Map([...speeds.speeds].map(([key, speed]) => [key, speed.tps]));
269
+ estimated = estimates;
270
+ // How many of the models on offer actually come out with a figure: the one number that says
271
+ // whether a thin column is a thin source or a broken key.
272
+ const offer = listModels();
273
+ const withSpeed = offer.filter(model => {
274
+ if (measured.has(model.providerID + "/" + model.modelID)) return true;
275
+ return estimated.get(benchKey(model.name)) !== undefined || estimated.get(benchKey(model.modelID)) !== undefined;
276
+ }).length;
277
+ trace("open: tps=" + speeds.turns + " turns, " + measured.size + " measured, " + estimated.size + " from AA, " + withSpeed + "/" + offer.length + " with a speed, " + (Date.now() - started) + " ms");
344
278
  creditsKnown = creditIndex.size > 0;
345
279
  trace("open: models=" + listModels().length + " bench=" + bench.size + " plan=" + (plan ? "$" + plan.price + " " + plan.limits.length + " rows" : "none") + " credits=" + creditIndex.size + " prefs=" + prefs.favorite.size + "/" + prefs.recent.size);
346
280
  if (!ui.panel.open("oc-models-pro")) {
@@ -452,66 +386,35 @@ export default Plugin.define({
452
386
  const needle = query().trim().toLowerCase();
453
387
  let list = rows().filter(row => row.enabled);
454
388
  if (scope() === "favorites") list = list.filter(row => prefs.favorite.has(prefKey(row.providerID, row.modelID)) || prefKey(row.providerID, row.modelID) === mine);
455
- if (scope() === "paying") list = list.filter(row => !free(row) || prefKey(row.providerID, row.modelID) === mine);
389
+ if (scope() === "paying") list = list.filter(row => !isFree(row) || prefKey(row.providerID, row.modelID) === mine);
456
390
  if (needle) list = list.filter(row => (row.name + " " + row.provider + " " + row.providerID).toLowerCase().includes(needle));
457
391
  list = [...list].sort((a, b) => {
458
392
  const mineA = prefKey(a.providerID, a.modelID) === mine;
459
393
  const mineB = prefKey(b.providerID, b.modelID) === mine;
460
394
  if (mineA !== mineB) return mineA ? -1 : 1;
461
- if (SORTS[sort()].label !== "name") {
462
- const delta = SORTS[sort()].value(a) - SORTS[sort()].value(b);
463
- if (delta) return delta;
464
- }
465
- return a.name.localeCompare(b.name);
395
+ return compare(sort(), a, b, {
396
+ recent: prefs.recent
397
+ });
466
398
  });
467
399
 
468
400
  // Free models never enter the price order: they are not cheap, they are unmetered, and at
469
401
  // the top they hide everything that costs something. Grouped, each one still sits under
470
402
  // its own provider, after that provider's paying rows — the group answers "who serves
471
403
  // this", and a global "Free" bucket would answer it wrong. Flat, they keep one bucket.
404
+ // What is drawn, and in which order, is `arrange`'s business — the same function the
405
+ // desktop panel calls. This half only turns sections into the headers and rows a
406
+ // terminal list wants.
472
407
  const items = [];
473
- if (grouped()) {
474
- const groups = new Map();
475
- for (const row of list) {
476
- const bucket = groups.get(row.provider) ?? [];
477
- bucket.push(row);
478
- groups.set(row.provider, bucket);
479
- }
480
- // Two bits: the model in use first, then the active order inside each of the two classes.
481
- // It leads its group even when it is free — it is the state of the session, not a price.
482
- for (const bucket of groups.values()) bucket.sort((a, b) => {
483
- const mineA = prefKey(a.providerID, a.modelID) === mine;
484
- const mineB = prefKey(b.providerID, b.modelID) === mine;
485
- if (mineA !== mineB) return mineA ? -1 : 1;
486
- return Number(free(a)) - Number(free(b));
408
+ for (const section of arrange(list, {
409
+ inUse: mine,
410
+ grouped: grouped()
411
+ })) {
412
+ if (section.title) items.push({
413
+ header: section.title
487
414
  });
488
- const names = [...groups.keys()].sort((a, b) => a.localeCompare(b));
489
- // The provider of the model in use comes first, like the native picker.
490
- const mineProvider = list.find(row => prefKey(row.providerID, row.modelID) === mine)?.provider;
491
- if (mineProvider) names.sort((a, b) => a === mineProvider ? -1 : b === mineProvider ? 1 : 0);
492
- for (const name of names) {
493
- items.push({
494
- header: name
495
- });
496
- for (const row of groups.get(name)) items.push({
497
- row
498
- });
499
- }
500
- } else {
501
- // The model in use leads the list, free or not: it is the state of the session, not a
502
- // price to compare. The other free models keep the bucket at the end.
503
- for (const row of list) if (!free(row) || prefKey(row.providerID, row.modelID) === mine) items.push({
415
+ for (const row of section.rows) items.push({
504
416
  row
505
417
  });
506
- const freeRows = list.filter(row => free(row) && prefKey(row.providerID, row.modelID) !== mine);
507
- if (freeRows.length) {
508
- items.push({
509
- header: "Free"
510
- });
511
- for (const row of freeRows) items.push({
512
- row
513
- });
514
- }
515
418
  }
516
419
  return items;
517
420
  });
@@ -663,10 +566,10 @@ export default Plugin.define({
663
566
  trace("variant set: " + chosen + " -> " + applied);
664
567
  }
665
568
  function cycleSort() {
666
- setSort((sort() + 1) % SORTS.length);
569
+ setSort(ORDER[(ORDER.indexOf(sort()) + 1) % ORDER.length]);
667
570
  }
668
571
  function recentSort() {
669
- setSort(SORTS.findIndex(entry => entry.label === "recently used"));
572
+ setSort("recent");
670
573
  }
671
574
  function toggleGroups() {
672
575
  setGrouped(!grouped());
@@ -823,7 +726,7 @@ export default Plugin.define({
823
726
  _$insertNode(_el$6, _el$8);
824
727
  _$setProp(_el$6, "fg", DIM);
825
728
  _$setProp(_el$6, "wrapMode", "none");
826
- _$insert(_el$6, () => "sort: " + SORTS[sort()].short, _el$8);
729
+ _$insert(_el$6, () => "sort: " + ORDERS[sort()].short, _el$8);
827
730
  _$insertNode(_el$9, _el$0);
828
731
  _$setProp(_el$9, "fg", BASE);
829
732
  _$setProp(_el$9, "wrapMode", "none");
@@ -899,29 +802,43 @@ export default Plugin.define({
899
802
  return scope() !== "favorites" && prefs.favorite.has(key);
900
803
  };
901
804
  return (() => {
902
- var _el$19 = _$createElement("box"),
903
- _el$20 = _$createElement("text");
904
- _$insertNode(_el$19, _el$20);
805
+ var _el$19 = _$createElement("box");
905
806
  _$setProp(_el$19, "id", "mp-" + key);
906
807
  _$setProp(_el$19, "height", 1);
808
+ _$setProp(_el$19, "flexDirection", "row");
907
809
  _$setProp(_el$19, "onMouseOver", () => hover(key));
908
810
  _$setProp(_el$19, "onMouseMove", () => hover(key));
909
811
  _$setProp(_el$19, "onMouseDown", () => {
910
812
  setSelected(key);
911
813
  choose(row);
912
814
  });
913
- _$setProp(_el$20, "wrapMode", "none");
914
- _$insert(_el$20, () => modelRow(row, mine(), favourite(), fitted()));
915
- _$effect(_p$ => {
916
- var _v$6 = active() ? SELECTED : undefined,
917
- _v$7 = active() ? ON_SELECTED : mine() ? ACCENT : BASE;
918
- _v$6 !== _p$.e && (_p$.e = _$setProp(_el$19, "backgroundColor", _v$6, _p$.e));
919
- _v$7 !== _p$.t && (_p$.t = _$setProp(_el$20, "fg", _v$7, _p$.t));
920
- return _p$;
921
- }, {
922
- e: undefined,
923
- t: undefined
815
+ _$insert(_el$19, () => {
816
+ const parts = rowParts(row, mine(), favourite(), fitted());
817
+ const fg = active() ? ON_SELECTED : mine() ? ACCENT : BASE;
818
+ // Dim marks the borrowed figure; the tilde says it too, for a reader who
819
+ // cannot see the colour. Measured values keep the row's own colour.
820
+ const speedFg = active() ? ON_SELECTED : row.tps !== undefined ? DIM : fg;
821
+ return [(() => {
822
+ var _el$20 = _$createElement("text");
823
+ _$setProp(_el$20, "fg", fg);
824
+ _$setProp(_el$20, "wrapMode", "none");
825
+ _$insert(_el$20, () => parts.before);
826
+ return _el$20;
827
+ })(), _$memo(() => _$memo(() => !!parts.speed)() ? (() => {
828
+ var _el$21 = _$createElement("text");
829
+ _$setProp(_el$21, "fg", speedFg);
830
+ _$setProp(_el$21, "wrapMode", "none");
831
+ _$insert(_el$21, () => parts.speed);
832
+ return _el$21;
833
+ })() : null), _$memo(() => _$memo(() => !!parts.after)() ? (() => {
834
+ var _el$22 = _$createElement("text");
835
+ _$setProp(_el$22, "fg", fg);
836
+ _$setProp(_el$22, "wrapMode", "none");
837
+ _$insert(_el$22, () => parts.after);
838
+ return _el$22;
839
+ })() : null)];
924
840
  });
841
+ _$effect(_$p => _$setProp(_el$19, "backgroundColor", active() ? SELECTED : undefined, _$p));
925
842
  return _el$19;
926
843
  })();
927
844
  }
@@ -934,14 +851,14 @@ export default Plugin.define({
934
851
  return hints();
935
852
  },
936
853
  children: line => (() => {
937
- var _el$21 = _$createElement("box"),
938
- _el$22 = _$createElement("text");
939
- _$insertNode(_el$21, _el$22);
940
- _$setProp(_el$21, "height", 1);
941
- _$setProp(_el$22, "fg", DIM);
942
- _$setProp(_el$22, "wrapMode", "none");
943
- _$insert(_el$22, line);
944
- return _el$21;
854
+ var _el$23 = _$createElement("box"),
855
+ _el$24 = _$createElement("text");
856
+ _$insertNode(_el$23, _el$24);
857
+ _$setProp(_el$23, "height", 1);
858
+ _$setProp(_el$24, "fg", DIM);
859
+ _$setProp(_el$24, "wrapMode", "none");
860
+ _$insert(_el$24, line);
861
+ return _el$23;
945
862
  })()
946
863
  }));
947
864
  _$effect(_p$ => {
package/vite.config.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { defineConfig } from "vite"
2
+ import { gui } from "@opencode/plugin/gui/vite"
3
+
4
+ export default defineConfig({
5
+ plugins: [gui({ entry: "gui.tsx", outFile: "dist/gui-experimental.cjs" })],
6
+ })