@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 CHANGED
@@ -1,10 +1,7 @@
1
1
  # @rizal72/oc-models-pro
2
2
 
3
- A model picker for OpenCode v2 that shows what a request costs, not just a list of names.
4
-
5
- It lists every model with its input and output price, context window, intelligence index, `tok/s`
6
- when a source has it, the Go credit multiplier, and the effective price per request; it sorts by
7
- what a request actually costs and switches the model on Enter.
3
+ A model picker that shows what a request costs: price in and out, context, intelligence, `tok/s` and the
4
+ Go credit, sorted by what a request actually costs. Enter switches.
8
5
 
9
6
  ## Install
10
7
 
@@ -18,9 +15,9 @@ Or by hand, one line in `~/.config/opencode/opencode.jsonc`:
18
15
  "plugins": ["@rizal72/oc-models-pro"]
19
16
  ```
20
17
 
21
- ## Use
18
+ ## TUI
22
19
 
23
- `/models-pro`, or `Ctrl+X` then `p` (the `<leader>p` bind), or the command palette — inside a session.
20
+ `/models-pro`, or `Ctrl+X` then `p`, inside a session.
24
21
 
25
22
  | key | does |
26
23
  |---|---|
@@ -33,21 +30,35 @@ Or by hand, one line in `~/.config/opencode/opencode.jsonc`:
33
30
  | `ctrl+u` | clear the search |
34
31
  | `esc` | clear the search, else close |
35
32
 
36
- ## What it reads
33
+ ## GUI
34
+
35
+ The same table as a side panel: `mod+shift+m`, `/models-pro`, or the session menu. Click a column to sort
36
+ by it, a star to favourite, a row to switch. The search box, the scope buttons and a refresh sit above it.
37
+
38
+ ## How it works
39
+
40
+ | column | source |
41
+ |---|---|
42
+ | price, ctx | the host's own catalogue |
43
+ | int | catalog.stencil.so, cached 6 h |
44
+ | tok/s | this machine's own sessions: last 7 days, or all history when that window is thin |
45
+ | ~tok/s | Artificial Analysis, with a free key |
46
+ | cred | the Go plan page, cached 1 day, `opencode-go` only |
37
47
 
38
- - prices and context from the host's own catalogue;
39
- - the intelligence index from `catalog.stencil.so`, cached six hours;
40
- - the Go credit from OpenCode's Go plan page, cached one day — `opencode-go` models only;
41
- - favourites and recent models from the app's own `~/.local/state/opencode/model.json`, so they are
42
- the same ones the native picker keeps.
48
+ Favourites and recents are the native picker's (`~/.local/state/opencode/model.json`), so both agree. The
49
+ switch is the app's own path, never a write to the session or that file.
43
50
 
44
- No API key is needed, and nothing leaves the machine except those two public fetches.
51
+ ## Optional: tok/s for models you have not run
45
52
 
46
- ## One caveat
53
+ `tok/s` is measured from your own sessions, so a model you never used has no number. A free key from
54
+ <https://artificialanalysis.ai/> (1,000 requests a day) fills those cells with a `~`:
47
55
 
48
- A plugin still cannot pick the model directly, so the panel switches it the long way: it borrows the
49
- app's own switch command and restores what it borrowed a moment later. There is an open issue upstream
50
- ([anomalyco/opencode#57771](https://github.com/anomalyco/opencode/issues/57771)) that would let a plugin
51
- do it in one call — when it lands, the workaround goes and nothing else changes.
56
+ ```sh
57
+ # macOS and Linux, zsh or bash
58
+ echo 'export ARTIFICIAL_ANALYSIS_API_KEY="your-key"' >> ~/.zshrc && source ~/.zshrc
59
+ ```
52
60
 
53
- Requires OpenCode v2. TypeScript, no build step, no runtime dependencies.
61
+ ```powershell
62
+ # Windows
63
+ setx ARTIFICIAL_ANALYSIS_API_KEY "your-key"
64
+ ```
package/aa.ts ADDED
@@ -0,0 +1,129 @@
1
+ // oc-models-pro/aa.ts — tokens per second for the models this machine has never run, from
2
+ // Artificial Analysis' free API. Attribution: https://artificialanalysis.ai/
3
+ //
4
+ // Their number is the same measurement as ours — "the average number of tokens received per second,
5
+ // after the first token is received" — so filling an empty cell with it does not mix two families,
6
+ // the way `int` and speed already do. The panel marks those values with a leading `~`, because they
7
+ // were measured on their infrastructure and not on this person's provider.
8
+ //
9
+ // No key, no calls: without ARTIFICIAL_ANALYSIS_API_KEY this module returns an empty map and nothing
10
+ // leaves the machine. With a key the response is cached six hours, which their terms ask for.
11
+ //
12
+ // Runnable standalone for its self-check: node plugins/oc-models-pro/aa.ts --self-check
13
+
14
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises"
15
+ import { homedir } from "node:os"
16
+ import { dirname, join } from "node:path"
17
+ import { benchKey } from "./bench.ts"
18
+
19
+ export const AA = "https://artificialanalysis.ai/api/v2/data/llms/models"
20
+
21
+ const CACHE_DIR = process.env.XDG_CACHE_HOME || join(homedir(), ".cache")
22
+ const CACHE = join(CACHE_DIR, "oc-models-pro", "aa.json")
23
+ const GRACE_MS = 6 * 60 * 60 * 1000
24
+ /** Optional decoration must never hold the panel: a hanging endpoint gives up after this. */
25
+ const FETCH_MS = 5_000
26
+
27
+ interface Cached {
28
+ fetched: number
29
+ index: Record<string, number>
30
+ }
31
+
32
+ export function apiKey(): string | undefined {
33
+ const key = process.env.ARTIFICIAL_ANALYSIS_API_KEY
34
+ return key && key.trim() ? key.trim() : undefined
35
+ }
36
+
37
+ /** Name first, slug second — the same meeting point `bench.ts` uses, so one model has one key. */
38
+ export function buildIndex(payload: any): Map<string, number> {
39
+ const index = new Map<string, number>()
40
+ for (const entry of payload?.data ?? []) {
41
+ const tps = entry?.median_output_tokens_per_second
42
+ if (typeof tps !== "number" || !(tps > 0)) continue
43
+ for (const candidate of [entry.name, entry.slug]) {
44
+ if (typeof candidate !== "string") continue
45
+ const key = benchKey(candidate)
46
+ // A key met twice keeps the faster figure: label stripping can fuse a variant into its base
47
+ // model, and the slower of the two would understate what the faster one shows.
48
+ if (key) index.set(key, Math.max(index.get(key) ?? 0, tps))
49
+ }
50
+ }
51
+ return index
52
+ }
53
+
54
+ /** A cache is a file on disk: a value that is not a positive number is not a measurement. */
55
+ function fromCache(previous: Cached): Map<string, number> {
56
+ const index = new Map<string, number>()
57
+ for (const [key, value] of Object.entries(previous.index ?? {})) {
58
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) index.set(key, value)
59
+ }
60
+ return index
61
+ }
62
+
63
+ async function cached(): Promise<Cached | undefined> {
64
+ try {
65
+ return JSON.parse(await readFile(CACHE, "utf8")) as Cached
66
+ } catch {
67
+ return undefined
68
+ }
69
+ }
70
+
71
+ /** Empty without a key: this is the whole cost of the column being optional. */
72
+ export async function aaIndex(): Promise<Map<string, number>> {
73
+ const key = apiKey()
74
+ if (!key) return new Map()
75
+ const previous = await cached()
76
+ if (previous && Date.now() - previous.fetched < GRACE_MS) return fromCache(previous)
77
+ try {
78
+ const response = await fetch(AA, {
79
+ headers: { "x-api-key": key, "user-agent": "oc-models-pro" },
80
+ signal: AbortSignal.timeout(FETCH_MS),
81
+ })
82
+ if (!response.ok) throw new Error("HTTP " + response.status)
83
+ const built = buildIndex(await response.json())
84
+ if (built.size === 0) throw new Error("response shape changed")
85
+ await mkdir(dirname(CACHE), { recursive: true })
86
+ await writeFile(CACHE + ".tmp", JSON.stringify({ fetched: Date.now(), index: Object.fromEntries(built) }))
87
+ await rename(CACHE + ".tmp", CACHE)
88
+ return built
89
+ } catch (error) {
90
+ console.error(
91
+ `[oc-models-pro] artificial analysis not refreshed: ${error instanceof Error ? error.message : String(error)}`,
92
+ )
93
+ return previous ? fromCache(previous) : new Map()
94
+ }
95
+ }
96
+
97
+ // Self-check: node plugins/oc-models-pro/aa.ts --self-check
98
+ if (process.argv[2] === "--self-check") {
99
+ const run = async () => {
100
+ // The mapping is the part that can be wrong without anyone noticing, so it is tested against
101
+ // their documented response shape — no key, no spend, no network.
102
+ const canned = {
103
+ data: [
104
+ { name: "o3-mini", slug: "o3-mini", median_output_tokens_per_second: 153.831 },
105
+ { name: "Claude Fable 5", slug: "claude-fable-5", median_output_tokens_per_second: 69.6 },
106
+ { name: "Foo", slug: "foo", median_output_tokens_per_second: 40 },
107
+ { name: "Foo Latest", slug: "foo-latest", median_output_tokens_per_second: 90 },
108
+ { name: "no speed", slug: "no-speed" },
109
+ { name: "zero", slug: "zero", median_output_tokens_per_second: 0 },
110
+ ],
111
+ }
112
+ const index = buildIndex(canned)
113
+ if (index.get(benchKey("o3-mini")) !== 153.831) throw new Error("self-check: o3-mini not mapped")
114
+ if (index.get("claude-fable-5") !== 69.6) throw new Error("self-check: slug not mapped")
115
+ if (index.has("no-speed") || index.has("zero")) throw new Error("self-check: an entry without a speed was mapped")
116
+ if (index.get(benchKey("Foo")) !== 90) throw new Error("self-check: two entries on one key did not keep the faster")
117
+ if (!apiKey()) {
118
+ console.log("self-check ok: mapping holds; no ARTIFICIAL_ANALYSIS_API_KEY, live path not exercised")
119
+ return
120
+ }
121
+ const live = await aaIndex()
122
+ if (live.size === 0) throw new Error("self-check: a key is set but nothing came back")
123
+ console.log("self-check ok: mapping holds; live " + live.size + " models keyed")
124
+ }
125
+ run().catch((error) => {
126
+ console.error(error)
127
+ process.exit(1)
128
+ })
129
+ }
package/bench.ts CHANGED
@@ -7,8 +7,9 @@
7
7
  // stripped of the labels a catalogue attaches (`latest`, `contributor`, `free`, `preview`) and of
8
8
  // snapshot stamps (`1017`, `0731`); the model id is the fallback when the name finds nothing.
9
9
  //
10
- // `int` is on 4612 of 9050 entries; `tps` is on none of them today (measured 2026-10-19), so the
11
- // speed column stands empty until a source carries it. A figure that is not there is never guessed.
10
+ // `int` is on most entries; `tps` was on none of them when this was last measured (2026-10-19), so the
11
+ // speed column is fed by `tps.ts` and `aa.ts` instead — this file's `tps` stays only as the last resort.
12
+ // A figure that is not there is never guessed.
12
13
  //
13
14
  // Runnable standalone for its self-check: node plugins/oc-models-pro/bench.ts --self-check
14
15
 
@@ -90,7 +91,10 @@ export async function benchIndex(): Promise<Map<string, Bench>> {
90
91
  const previous = await cached()
91
92
  if (previous && Date.now() - previous.fetched < GRACE_MS) return indexOf(previous)
92
93
  try {
93
- const response = await fetch(CATALOG, { headers: { "user-agent": "oc-models-pro" } })
94
+ const response = await fetch(CATALOG, {
95
+ headers: { "user-agent": "oc-models-pro" },
96
+ signal: AbortSignal.timeout(20_000),
97
+ })
94
98
  if (!response.ok) throw new Error("HTTP " + response.status)
95
99
  const built = buildIndex(await response.json())
96
100
  if (built.size === 0) throw new Error("catalog shape changed")
@@ -114,7 +118,7 @@ export function benchFor(index: Map<string, Bench>, name: string, id: string): B
114
118
  // Self-check: node plugins/oc-models-pro/bench.ts --self-check
115
119
  if (process.argv[2] === "--self-check") {
116
120
  const run = async () => {
117
- const response = await fetch(CATALOG)
121
+ const response = await fetch(CATALOG, { signal: AbortSignal.timeout(20_000) })
118
122
  if (!response.ok) throw new Error("self-check: HTTP " + response.status)
119
123
  const index = buildIndex(await response.json())
120
124
  if (index.size < 200) throw new Error("self-check: only " + index.size + " models keyed")
@@ -0,0 +1,58 @@
1
+ Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:`Module`}});let e=require("solid-js/web"),t=require("@opencode/plugin/gui"),n=require("solid-js"),r=require("@opencode/plugin/rpc");var i={type:`number`},a={type:`object`,properties:{rows:{type:`array`,items:{type:`object`,properties:{providerID:{type:`string`},modelID:{type:`string`},name:{type:`string`},provider:{type:`string`},tag:{type:`string`},input:i,output:i,context:i,credit:i,promo:i,peak:i,int:i,tps:i,measured:i,enabled:{type:`boolean`}},required:[`providerID`,`modelID`,`name`,`provider`,`tag`,`credit`,`enabled`],additionalProperties:!1}},favorites:{type:`array`,items:{type:`string`}},offers:i,withSpeed:i,credits:{type:`boolean`},problems:{type:`array`,items:{type:`string`}}},required:[`rows`,`favorites`,`offers`,`withSpeed`,`credits`],additionalProperties:!1},o=r.Rpc.define({id:`models`,methods:{snapshot:{input:{type:`object`,properties:{force:{type:`boolean`}},additionalProperties:!1},output:a},setFavorite:{input:{type:`object`,properties:{providerID:{type:`string`},modelID:{type:`string`},on:{type:`boolean`}},required:[`providerID`,`modelID`],additionalProperties:!1},output:{type:`object`,properties:{ok:{type:`boolean`},favorites:{type:`array`,items:{type:`string`}}},required:[`ok`,`favorites`],additionalProperties:!1}}},events:{}});function s(e){if(e.input!==void 0)return e.input/(e.credit||1)}function c(e){return e.input===0&&e.output===0}function l(e){return e.measured??e.tps}var u={effective:{label:`effective price`,short:`effective↑`,value:s},input:{label:`input price`,short:`in↑`,value:e=>e.input},output:{label:`output price`,short:`out↑`,value:e=>e.output},inputSum:{label:`input + output`,short:`in+out↑`,value:e=>e.input===void 0||e.output===void 0?void 0:(e.input+e.output)/(e.credit||1)},context:{label:`context window`,short:`ctx↓`,desc:!0,value:e=>e.context},int:{label:`intelligence`,short:`int↓`,desc:!0,value:e=>e.int},speed:{label:`speed`,short:`speed↓`,desc:!0,value:e=>l(e)},recent:{label:`recently used`,short:`recent`,value:(e,t)=>t.recent.get(e.providerID+`/`+e.modelID)},provider:{label:`provider`,short:`provider`,value:()=>0},name:{label:`name`,short:`name`,value:()=>0}},d=[`effective`,`input`,`output`,`inputSum`,`context`,`int`,`speed`,`recent`,`provider`,`name`];function f(e){return u[e].desc?-1:1}var p=e=>e.providerID+`/`+e.modelID;function m(e,t,n,r,i=f(e)){if(e===`name`||e===`provider`){let r=e===`name`?t.name:t.provider,a=e===`name`?n.name:n.provider,o=r.localeCompare(a);return o===0?p(t).localeCompare(p(n)):o*i}let a=u[e].value(t,r),o=u[e].value(n,r);return a===void 0||o===void 0?a===o?t.name.localeCompare(n.name):a===void 0?1:-1:a===o?t.name.localeCompare(n.name):(a<o?-1:1)*i}function h(e,t){let n=t.inUse===void 0?void 0:e.find(e=>p(e)===t.inUse);if(!t.grouped){let t=n?e.filter(e=>e!==n):e,r=t.filter(e=>!c(e)),i=t.filter(e=>c(e));return[...n?[{rows:[n]}]:[],...r.length?[{rows:r}]:[],...i.length?[{title:`Free`,rows:i}]:[]]}let r=new Map;for(let t of e){let e=r.get(t.providerID)??{title:t.provider,rows:[]},n=e.rows.findIndex(e=>c(e)&&!c(t));n===-1?e.rows.push(t):e.rows.splice(n,0,t),r.set(t.providerID,e)}let i=[...r.entries()].sort(([e,t],[n,r])=>(t.title??``).localeCompare(r.title??``)||e.localeCompare(n)).map(([,e])=>e);if(n){let e=i.indexOf(r.get(n.providerID));e>0&&i.unshift(...i.splice(e,1));let t=i[0];t&&(t.rows=[n,...t.rows.filter(e=>e!==n)])}return i}if(typeof process<`u`&&process.argv?.[2]===`--self-check`){let e=(e={})=>({name:`Model`,providerID:`p`,modelID:`m`,provider:`Provider`,tag:`PR`,credit:1,enabled:!0,...e}),t={recent:new Map([[`p/m`,0]])},n=(e,t)=>{if(!e)throw Error(`self-check: `+t)};n(s(e())===void 0,`an unknown input price is not zero`),n(s(e({input:.8,credit:8}))===.1,`the credit divides the input price`),n(s(e({input:.8,credit:0}))===.8,`a zero credit must not divide by zero`),n(!c(e()),`a missing price is not free`),n(!c(e({input:0,output:2})),`a zero input with a paid output is not free`),n(c(e({input:0,output:0})),`both at zero is free`),n(l(e({tps:40,measured:12}))===12,`a measured speed outranks a borrowed one`),n(l(e())===void 0,`no speed is no speed`);let r=(e,n)=>[...n].sort((n,r)=>m(e,n,r,t)),i=e({name:`cheap`,input:1,output:1}),a=e({name:`dear`,input:10,output:10}),o=e({name:`unknown`});n(r(`input`,[o,a,i]).map(e=>e.name)[0]===`cheap`,`in↑ starts at the cheapest`),n(r(`input`,[o,a,i]).map(e=>e.name)[2]===`unknown`,`an unknown price sorts last`);let u=[e({name:`few`,context:1e3,int:10}),e({name:`many`,context:1e5,int:50}),o];n(r(`context`,u)[0].name===`many`,`ctx↓ starts at the widest window`),n(r(`context`,u)[2].name===`unknown`,`a missing window sorts last, not first`),n(r(`int`,u)[0].name===`many`,`int↓ starts at the highest score`),n(r(`int`,u)[2].name===`unknown`,`a missing score sorts last, not first`),n(r(`speed`,u)[2].name===`unknown`,`a missing speed sorts last, not first`);let p=(e,n)=>[...n].sort((n,r)=>m(e,n,r,t,-1));n(p(`input`,[o,a,i])[2].name===`unknown`,`in↓ puts an unknown price last too`),n(p(`input`,[o,a,i])[0].name===`dear`,`in↓ starts at the dearest`),n(p(`context`,u)[2].name===`unknown`,`ctx↑ puts a missing window last too`),n(p(`int`,u)[2].name===`unknown`,`int↑ puts a missing score last too`),n(p(`speed`,u)[2].name===`unknown`,`speed↑ puts a missing speed last too`),n(p(`effective`,[o,a,i])[2].name===`unknown`,`effective↓ puts an unknown price last too`);let g={recent:new Map([[`p/fresh`,0]])},_=e({name:`fresh`,modelID:`fresh`});n([e({name:`stale`,modelID:`stale`}),_].sort((e,t)=>m(`recent`,e,t,g))[0].name===`fresh`,`the recent list orders by its index`),n(m(`input`,i,a,t,-1)>0,`the sign the caller passes flips the order`),n(m(`name`,e({name:`a`}),e({name:`b`}),t)<0,`name compares text`),n(m(`provider`,e({provider:`a`}),e({provider:`b`}),t)<0,`provider compares text`),n(f(`effective`)===1&&f(`int`)===-1,`the default direction follows the order`);let v=e({name:`a`,modelID:`a`,providerID:`pa`,provider:`A`,input:2,output:2}),y=e({name:`b`,modelID:`b`,providerID:`pa`,provider:`A`,input:0,output:0}),b=e({name:`c`,modelID:`c`,providerID:`pb`,provider:`B`,input:3,output:3}),x=h([v,y,b],{inUse:`pb/c`,grouped:!0});n(x.map(e=>e.title).join()===`B,A`,`the provider in use leads the list`),n(x[0].rows[0]===b,`and its model leads inside it`),n(x[1].rows.map(e=>e.name).join()===`a,b`,`paying before free inside a group`);let S=h([v,y,b],{inUse:`pa/b`,grouped:!0});n(S[0].title===`A`&&S[0].rows[0]===y,`the in-use row leads inside a group that has others`),n(S[0].rows.map(e=>e.name).join()===`b,a`,`and the rest of the group keeps its order`);let C=h([v,y,b],{inUse:`pa/b`,grouped:!1});n(C[0].rows[0]===y,`flat: a free model in use still leads`),n(!C.some(e=>e.title===`Free`),`flat: and is not repeated in the free bucket`);let w=h([b],{inUse:`pb/c`,grouped:!0});n(w.length===1&&w[0].title===`B`,`the only row a filter kept still has its own group`);let T=h([y,b,v],{inUse:`pb/c`,grouped:!1});n(T[0].rows[0]===b,`flat: the model in use leads the list`),n(T[T.length-1].title===`Free`,`flat: the free bucket is last`),n(T[T.length-1].rows[0]===y,`flat: and holds the free row`);let E=e({name:`twin`,modelID:`twin`,providerID:`p2`,provider:`A`,input:4,output:4}),D=h([v,E],{inUse:`p2/twin`,grouped:!0});n(D.length===2,`two providers sharing a display name stay two groups`),n(D[0].rows[0]===E,`and the one in use leads by id, not by the shared name`),console.log(`self-check ok: `+d.length+` orders, unknown figures last in every one of them`)}var g=(0,e.template)(`<button>`),_=(0,e.template)(`<span class=omp-dot>●`),v=(0,e.template)(`<div class=omp-row><div class=omp-name><button class=omp-fav></button><span class=omp-tag></span></div><div class=omp-num></div><div class="omp-num omp-out"></div><div class="omp-num omp-ctx"></div><div class="omp-num omp-int"></div><div class="omp-num omp-tps"></div><div class="omp-num omp-credit"></div><div class="omp-num omp-note">`),y=(0,e.template)(`<div class=omp-warn>`),b=(0,e.template)(`<div class=omp-warn>Go credit unknown: the plan page could not be read.`),x=(0,e.template)(`<div class=omp-empty>`),S=(0,e.template)(`<div class=omp><div class=omp-bar><div class=omp-scopes><button class=omp-scope>groups</button></div></div><div class=omp-head><span class="omp-num omp-credit">×</span><span class="omp-num omp-note">note</span></div><div class=omp-body></div><div class=omp-fine>*N = the tariff at peak · +N = a promotion`),C=(0,e.template)(`<button class=omp-scope>`),w=(0,e.template)(`<div class=omp-group>`),T=3e5,E=15e3,D=(e,t)=>new Promise((n,r)=>{let i=setTimeout(()=>r(Error(`rpc timed out after ${t}ms`)),t);e.then(e=>{clearTimeout(i),n(e)},e=>{clearTimeout(i),r(e)})}),O=`
2
+ .omp { container-type: inline-size; display: flex; flex-direction: column; height: 100%; min-height: 0;
3
+ font-size: 12px; }
4
+ .omp-bar { display: flex; align-items: center; gap: 8px; padding: 6px 8px; flex-wrap: wrap; }
5
+ .omp-scopes { display: flex; gap: 2px; margin-left: auto; }
6
+ .omp-scope { background: none; border: 0; border-radius: 4px; color: inherit; cursor: pointer; font: inherit;
7
+ opacity: .55; padding: 2px 6px; }
8
+ .omp-scope[data-on] { background: color-mix(in srgb, currentColor 12%, transparent); opacity: 1; }
9
+ .omp-head, .omp-row { display: grid; align-items: center; gap: 6px; padding: 2px 8px;
10
+ grid-template-columns: minmax(9rem, 1fr) 2.8rem 3.4rem 3.4rem 2.4rem 2.4rem 2.6rem 2.2rem; }
11
+ .omp-head { border-bottom: 1px solid color-mix(in srgb, currentColor 18%, transparent); opacity: .7;
12
+ padding-right: calc(8px + var(--omp-gutter, 0px)); }
13
+ .omp-head button { background: none; border: 0; color: inherit; cursor: pointer; font: inherit; padding: 0;
14
+ text-align: left; }
15
+ .omp-head .omp-num { text-align: right; }
16
+ .omp-body { overflow: auto; min-height: 0; }
17
+ .omp-group { padding: 8px 8px 4px; text-transform: uppercase; letter-spacing: .04em; font-size: 10px;
18
+ position: sticky; top: 0; z-index: 1;
19
+ background: var(--v2-background-bg-base, Canvas);
20
+ color: color-mix(in srgb, currentColor 55%, transparent);
21
+ border-bottom: 1px solid color-mix(in srgb, currentColor 10%, transparent); }
22
+ .omp-row { cursor: pointer; border-radius: 4px; }
23
+ .omp-row:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
24
+ .omp-row[data-mine] { background: color-mix(in srgb, currentColor 12%, transparent); }
25
+ /* A figure wider than its track is cut, as the terminal cuts every cell: it must never push into the
26
+ column beside it and read as that column's number. */
27
+ .omp-num { text-align: right; font-variant-numeric: tabular-nums; overflow: hidden;
28
+ text-overflow: ellipsis; white-space: nowrap; }
29
+ .omp-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
30
+ .omp-tag { opacity: .5; margin-right: 6px; font-variant-numeric: tabular-nums; }
31
+ .omp-mine { opacity: .9; }
32
+ .omp-dot { opacity: .8; margin-right: 4px; }
33
+ .omp-fav { background: none; border: 0; cursor: pointer; color: inherit; font: inherit; opacity: .35;
34
+ padding: 0 4px; }
35
+ .omp-fav[data-on] { opacity: 1; }
36
+ .omp-empty { padding: 12px 8px; opacity: .6; }
37
+ .omp-warn { padding: 4px 8px; opacity: .7; }
38
+ .omp-fine { padding: 4px 8px; opacity: .55; font-size: 11px;
39
+ border-top: 1px solid color-mix(in srgb, currentColor 8%, transparent); }
40
+ /* The terminal's cascade, in pixels: the name shrinks first (the track is elastic), then whole columns
41
+ go in the order fit.ts uses — tok/s, then context, then intelligence. The three price figures never
42
+ go: they are what the list is for. Below the last rung the name truncates instead of scrolling. */
43
+ @container (max-width: 560px) {
44
+ .omp-tps, .omp-note { display: none; }
45
+ .omp-head, .omp-row { grid-template-columns: minmax(9rem, 1fr) 2.8rem 3.4rem 3.4rem 2.4rem 2.6rem; }
46
+ }
47
+ @container (max-width: 500px) {
48
+ .omp-ctx { display: none; }
49
+ .omp-head, .omp-row { grid-template-columns: minmax(9rem, 1fr) 2.8rem 3.4rem 2.4rem 2.6rem; }
50
+ }
51
+ @container (max-width: 440px) {
52
+ .omp-int { display: none; }
53
+ .omp-head, .omp-row { grid-template-columns: minmax(9rem, 1fr) 2.8rem 3.4rem 2.6rem; }
54
+ }
55
+ @container (max-width: 390px) {
56
+ .omp-head, .omp-row { grid-template-columns: minmax(0, 1fr) 2.8rem 3.4rem 2.6rem; }
57
+ }
58
+ `,k=new Set([`name`,`input`,`output`,`context`,`int`,`speed`]),A=e=>e===void 0?``:e===0?`free`:String(Math.round(e*1e3)/1e3),j=e=>e===void 0?``:e>=1e6?(e/1e6).toFixed(e%1e6==0?0:1)+`M`:e>=1e3?Math.round(e/1e3)+`K`:String(e),M=e=>e.peak?`*`+e.peak:e.promo?`+`+e.promo:``,N=e=>e.providerID+`/`+e.modelID,P=t.Plugin.define({setup(r){let[i,a]=(0,n.createSignal)(void 0),[s,l]=(0,n.createSignal)(``),[p,P]=(0,n.createSignal)(`effective`),[F,I]=(0,n.createSignal)(!0),[L,R]=(0,n.createSignal)(`all`),[z,ee]=(0,n.createSignal)(!0),[B,V]=(0,n.createSignal)([]),[H,U]=(0,n.createSignal)(!1),W,G=async e=>{if(H())return;let t=K()?.server.client;if(t){U(!0);try{a(await D(t.rpc(o).snapshot(e?{force:!0}:{}),E))}catch(e){r.toast.show({title:`Models`,description:e instanceof Error?e.message:String(e),variant:`error`})}finally{U(!1)}}},K=()=>r.sessions.current(),q=()=>{let e=r.screen.current()?.composer.model();return e?e.providerID+`/`+e.modelID:void 0},J=(0,n.createMemo)(()=>{let e=i();if(!e)return[];let t=new Set(e.favorites),n=q(),r=s().trim().toLowerCase(),a=e.rows.filter(e=>!e.enabled||L()===`favorites`&&!t.has(N(e))&&N(e)!==n||L()===`paying`&&c(e)&&N(e)!==n?!1:!r||(e.name+` `+e.provider+` `+e.modelID).toLowerCase().includes(r)),o=k.has(p())&&!F()?-1:1,l={recent:new Map(B().map((e,t)=>[e,t]))};return[...a].sort((e,t)=>m(p(),e,t,l,o))}),Y=(0,n.createMemo)(()=>h(J(),{inUse:q(),grouped:z()})),X=e=>{let t=r.screen.current()?.composer;if(!t)return;let n=N(e);t.setModel({providerID:e.providerID,modelID:e.modelID})?V([n,...B().filter(e=>e!==n)].slice(0,20)):r.toast.show({title:`Models`,description:`${e.name} is not available on this server`,variant:`error`})},Z=e=>{let t=N(e),n=i()?.favorites.includes(t)??!1,s=K()?.server.client;s&&D(s.rpc(o).setFavorite({providerID:e.providerID,modelID:e.modelID,on:!n}),E).then(e=>{let t=e;if(!t.ok){r.toast.show({title:`Models`,description:`model.json could not be written`,variant:`error`});return}let n=i();n&&a({...n,favorites:t.favorites})}).catch(()=>{r.toast.show({title:`Models`,description:`the favourite did not reach the server`,variant:`error`})})},Q=(t,n,r=``)=>(()=>{var i=g();return i.$$click=()=>{p()===t?I(!F()):(P(t),I(f(t)===1))},(0,e.className)(i,r),(0,e.insert)(i,n,null),(0,e.insert)(i,(()=>{var n=(0,e.memo)(()=>p()===t);return()=>n()?F()?` ↑`:` ↓`:``})(),null),i})(),te=t=>{let r=t.row,a=N(r),o=()=>i()?.favorites.includes(a)??!1,s=()=>a===q();return(()=>{var t=v(),i=t.firstChild,a=i.firstChild,c=a.nextSibling,l=i.nextSibling,u=l.nextSibling,d=u.nextSibling,f=d.nextSibling,p=f.nextSibling,m=p.nextSibling,h=m.nextSibling;return t.$$click=()=>X(r),a.$$click=e=>{e.stopPropagation(),Z(r)},(0,e.insert)(a,()=>o()?`★`:`☆`),(0,e.insert)(i,(0,e.createComponent)(n.Show,{get when(){return s()},get children(){return _()}}),c),(0,e.insert)(c,()=>r.tag),(0,e.insert)(i,()=>r.name,null),(0,e.insert)(l,()=>A(r.input)),(0,e.insert)(u,()=>A(r.output)),(0,e.insert)(d,()=>j(r.context)),(0,e.insert)(f,(()=>{var t=(0,e.memo)(()=>r.int!==void 0);return()=>t()?r.int.toFixed(1):``})()),(0,e.insert)(p,(()=>{var t=(0,e.memo)(()=>r.measured!==void 0);return()=>t()?Math.round(r.measured):(0,e.memo)(()=>r.tps!==void 0)()?`~`+Math.round(r.tps):``})()),(0,e.insert)(m,(()=>{var t=(0,e.memo)(()=>r.credit!==1);return()=>t()?`×`+r.credit:``})()),(0,e.insert)(h,()=>M(r)),(0,e.effect)(n=>{var r=s()?``:void 0,c=!!s(),l=o()?``:void 0;return r!==n.e&&(0,e.setAttribute)(t,`data-mine`,n.e=r),c!==n.t&&i.classList.toggle(`omp-mine`,n.t=c),l!==n.a&&(0,e.setAttribute)(a,`data-on`,n.a=l),n},{e:void 0,t:void 0,a:void 0}),t})()},ne=()=>{let t=()=>i(),a,o=()=>{let e=a?.querySelector(`.omp-body`);a&&e&&a.style.setProperty(`--omp-gutter`,e.offsetWidth-e.clientWidth+`px`)};return(0,n.createEffect)(()=>{Y(),o()}),(0,n.createEffect)(()=>{let e=new ResizeObserver(o);a&&e.observe(a),(0,n.onCleanup)(()=>e.disconnect())}),(()=>{var i=S(),o=i.firstChild,c=o.firstChild,m=c.firstChild,h=o.nextSibling,g=h.firstChild,_=h.nextSibling;return(0,e.use)(e=>a=e,i),(0,e.insert)(o,(0,e.createComponent)(r.ui.TextInput,{label:`Search models`,placeholder:`Search models`,type:`search`,get value(){return s()},onInput:l}),c),(0,e.insert)(o,(0,e.createComponent)(r.ui.Menu,{trigger:`button`,get label(){return`${u[p()].label}${k.has(p())?F()?` ↑`:` ↓`:``}`},get items(){return d.map(e=>({label:u[e].label,icon:p()===e?`check`:void 0,onSelect:()=>{p()===e?k.has(e)&&I(!F()):(P(e),I(f(e)===1))}}))}}),c),(0,e.insert)(o,(0,e.createComponent)(r.ui.IconButton,{icon:`refresh`,label:`Refresh`,get busy(){return H()},onClick:()=>void G(!0)}),c),(0,e.insert)(c,(0,e.createComponent)(n.For,{each:[`all`,`favorites`,`paying`],children:t=>(()=>{var n=C();return n.$$click=()=>R(t),(0,e.insert)(n,t),(0,e.effect)(()=>(0,e.setAttribute)(n,`data-on`,L()===t?``:void 0)),n})()}),m),m.$$click=()=>ee(!z()),(0,e.insert)(i,(0,e.createComponent)(n.Show,{get when(){return t()?.problems?.length},get children(){var n=y();return(0,e.insert)(n,()=>t()?.problems?.join(` · `)),n}}),h),(0,e.insert)(i,(0,e.createComponent)(n.Show,{get when(){return(0,e.memo)(()=>!!t())()&&!t().credits},get children(){return b()}}),h),(0,e.insert)(h,()=>Q(`name`,`model`),g),(0,e.insert)(h,()=>Q(`input`,`$/M in`,`omp-num`),g),(0,e.insert)(h,()=>Q(`output`,`$/M out`,`omp-num omp-out`),g),(0,e.insert)(h,()=>Q(`context`,`context`,`omp-num omp-ctx`),g),(0,e.insert)(h,()=>Q(`int`,`int`,`omp-num omp-int`),g),(0,e.insert)(h,()=>Q(`speed`,`tok/s`,`omp-num omp-tps`),g),(0,e.insert)(_,(0,e.createComponent)(n.Show,{get when(){return Y().length===0},get children(){var n=x();return(0,e.insert)(n,()=>t()?`no model matches`:`reading the catalogue…`),n}}),null),(0,e.insert)(_,(0,e.createComponent)(n.For,{get each(){return Y()},children:t=>[(0,e.createComponent)(n.Show,{get when(){return t.title},get children(){var n=w();return(0,e.insert)(n,()=>t.title),n}}),(0,e.createComponent)(n.For,{get each(){return t.rows},children:t=>(0,e.createComponent)(te,{row:t})})]}),null),(0,e.effect)(()=>(0,e.setAttribute)(m,`data-on`,z()?``:void 0)),i})()},re={id:`main`,get title(){return`Models`},closable:`hover`};r.add(t.Style,O),r.add(t.Panel,{id:`main`,region:`side`,wide:!0,list:e=>e.open.includes(`main`)?[re]:[],render:()=>(0,e.createComponent)(ne,{}),focus:()=>void G(!1)});let $=()=>{let e=K();e&&(r.layout.toggle(`${r.id}:main`,e),G(!1))};return r.add(t.Command,()=>({id:`open`,title:`Models: the table`,description:`Prices, context, intelligence and the Go credit, side by side`,keywords:`model price context credit list`,section:`model`,bind:`mod+shift+m`,slash:{name:`models-pro`},featured:!0,enabled:!!K(),run:$})),r.add(t.MenuItem,()=>{let e=K();return{menu:`session.panel`,id:`models`,title:`Models`,icon:`models`,description:`compare and switch`,keybind:`${r.id}.open`,opened:!!e&&r.layout.state(`${r.id}:main`,e)!==`closed`,run:$}}),W=setInterval(()=>{let e=K();e&&r.layout.state(`${r.id}:main`,e)!==`closed`&&G(!1)},T),()=>{W&&clearInterval(W)}}});(0,e.delegateEvents)([`click`]),exports.default=P;
package/index.ts CHANGED
@@ -1,7 +1,148 @@
1
- // oc-models-pro — server half. Empty on purpose: the model list is TUI-only.
1
+ // oc-models-pro — server half, for the desktop.
2
+ //
3
+ // The terminal reads the catalogue and the four sources in-process. A plugin window cannot: `bun:sqlite`,
4
+ // `node:fs` and the three fetches are all outside its reach — and the AA key lives in this process's
5
+ // environment, which is where the server has it and the window does not. So the whole table is assembled
6
+ // here and the window asks for it over `models.snapshot`.
7
+ //
8
+ // The rows are the terminal's, field for field, so the two views can be compared: same catalogue, same
9
+ // credits, same speeds. Only the rendering differs.
2
10
  import { Plugin } from "@opencode/plugin"
3
11
 
12
+ import { aaIndex } from "./aa.ts"
13
+ import { benchFor, benchIndex, benchKey, type Bench } from "./bench.ts"
14
+ import { credits, goPlan, key as creditKey, nativePeak, type Credits } from "./multipliers.ts"
15
+ import { prefKey, readFavourites, toggleFavorite } from "./prefs.ts"
16
+ import { tagsFor } from "./tags.ts"
17
+ import { speedIndex } from "./tps.ts"
18
+ import { Models, type ModelsSnapshot } from "./rpc.ts"
19
+ import type { Row } from "./rows.ts"
20
+
21
+ /**
22
+ * The fetch-backed sources refresh on their own TTLs (6 h, 1 day, 6 h); the measured speeds cost about a
23
+ * second here and are worth keeping for a minute, which is also what keeps a poll cheap.
24
+ */
25
+ const CACHE_MS = 60_000
26
+
27
+ const isGo = (providerID: string) => providerID === "opencode-go"
28
+
29
+ interface Sources {
30
+ bench?: Map<string, Bench>
31
+ credits?: Map<string, Credits>
32
+ creditsKnown: boolean
33
+ estimated?: Map<string, number>
34
+ measured?: Map<string, number>
35
+ }
36
+
4
37
  export default Plugin.define({
5
38
  id: "oc-models-pro",
6
- async setup() {},
39
+ async setup(ctx) {
40
+ let cache: { at: number; value: ModelsSnapshot } | undefined
41
+
42
+ /** The four sources, each failure named instead of swallowed: a thin column must look thin. */
43
+ async function sources(problems: string[]): Promise<Sources> {
44
+ const [plan, bench, estimates, speeds] = await Promise.allSettled([
45
+ goPlan(),
46
+ benchIndex(),
47
+ aaIndex(),
48
+ speedIndex(),
49
+ ])
50
+ const out: Sources = { creditsKnown: false }
51
+ if (plan.status === "fulfilled" && plan.value) {
52
+ out.credits = credits(plan.value)
53
+ out.creditsKnown = true
54
+ } else problems.push("go plan: no credit multipliers")
55
+ if (bench.status === "fulfilled") out.bench = bench.value
56
+ else problems.push("bench: no intelligence")
57
+ if (estimates.status === "fulfilled") out.estimated = estimates.value
58
+ else problems.push("aa: no borrowed speed")
59
+ if (speeds.status === "fulfilled")
60
+ out.measured = new Map([...speeds.value.speeds].map(([key, speed]) => [key, speed.tps]))
61
+ else problems.push("measured: the session store could not be read")
62
+ return out
63
+ }
64
+
65
+ async function build(force: boolean): Promise<ModelsSnapshot> {
66
+ if (!force && cache && Date.now() - cache.at < CACHE_MS) return cache.value
67
+
68
+ const problems: string[] = []
69
+ const [models, providers, src] = await Promise.all([
70
+ ctx.model.list({}),
71
+ ctx.provider.list({}),
72
+ sources(problems),
73
+ ])
74
+ const offers: any[] = (models as any)?.data ?? (models as any) ?? []
75
+ const names = new Map<string, string>(
76
+ (((providers as any)?.data ?? (providers as any) ?? []) as any[]).map((provider) => [
77
+ provider.id,
78
+ provider.name,
79
+ ]),
80
+ )
81
+ const tags = tagsFor([...names].map(([id, name]) => ({ id, name })))
82
+
83
+ const rows: Row[] = []
84
+ let withSpeed = 0
85
+ for (const model of offers) {
86
+ const cost = (model.cost ?? []).find((entry: any) => !entry.tier) ?? model.cost?.[0]
87
+ const limit = model.limit ?? {}
88
+ const score = benchFor(src.bench ?? new Map(), model.name, model.modelID)
89
+ // Three sources, one order: what this machine measured, then Artificial Analysis, then the
90
+ // catalogue. A measured figure is never displaced by an estimate.
91
+ const mine = src.measured?.get(model.providerID + "/" + model.modelID)
92
+ const theirs =
93
+ mine === undefined
94
+ ? (src.estimated?.get(benchKey(model.name)) ?? src.estimated?.get(benchKey(model.modelID)) ?? score?.tps)
95
+ : undefined
96
+ if (mine !== undefined || theirs !== undefined) withSpeed += 1
97
+ // The measured ratio from the Go plan page first, the native rule second: one is data, the
98
+ // other is the fact no table reports (the catalogue lists a single DeepSeek price).
99
+ const planPeak = isGo(model.providerID) ? src.credits?.get(creditKey(model.modelID))?.peak : undefined
100
+ const peak = planPeak ?? nativePeak(model.providerID)
101
+ rows.push({
102
+ providerID: model.providerID,
103
+ modelID: model.modelID,
104
+ name: model.name,
105
+ provider: (names.get(model.providerID) ?? model.providerID).replace(/^(.+?) \/ (.+)$/, "$2 ($1)"),
106
+ tag: tags.get(model.providerID) ?? model.providerID.slice(0, 2).toUpperCase(),
107
+ ...(typeof cost?.input === "number" ? { input: cost.input } : {}),
108
+ ...(typeof cost?.output === "number" ? { output: cost.output } : {}),
109
+ ...(typeof limit.context === "number" ? { context: limit.context } : {}),
110
+ credit: isGo(model.providerID) ? (src.credits?.get(creditKey(model.modelID))?.credit ?? 1) : 1,
111
+ ...(isGo(model.providerID) && src.credits?.get(creditKey(model.modelID))?.promo !== undefined
112
+ ? { promo: src.credits.get(creditKey(model.modelID))!.promo }
113
+ : {}),
114
+ ...(peak !== undefined ? { peak } : {}),
115
+ ...(score?.int !== undefined ? { int: score.int } : {}),
116
+ ...(theirs !== undefined ? { tps: theirs } : {}),
117
+ ...(mine !== undefined ? { measured: mine } : {}),
118
+ enabled: model.enabled !== false,
119
+ })
120
+ }
121
+
122
+ const favourites = readFavourites()
123
+ if (!favourites) problems.push("model.json: favourites unreadable")
124
+ const value: ModelsSnapshot = {
125
+ rows,
126
+ favorites: (favourites ?? []).map((entry) => prefKey(entry.providerID, entry.modelID)),
127
+ offers: offers.length,
128
+ withSpeed,
129
+ credits: src.creditsKnown,
130
+ ...(problems.length ? { problems } : {}),
131
+ }
132
+ cache = { at: Date.now(), value }
133
+ return value
134
+ }
135
+
136
+ await ctx.rpc.register(Models, {
137
+ snapshot: async (input: any) => build(input?.force === true),
138
+ setFavorite: async (input: any) => {
139
+ const on = toggleFavorite(String(input.providerID), String(input.modelID), input.on)
140
+ // The rows carry no favourite flag, so the cache stays usable; the list is the only thing that moved.
141
+ const favourites = readFavourites() ?? []
142
+ const favorites = favourites.map((entry) => prefKey(entry.providerID, entry.modelID))
143
+ if (cache) cache = { at: cache.at, value: { ...cache.value, favorites } }
144
+ return { ok: on !== undefined, favorites }
145
+ },
146
+ })
147
+ },
7
148
  })
package/multipliers.ts CHANGED
@@ -54,6 +54,15 @@ export interface Credits {
54
54
  peak?: number
55
55
  }
56
56
 
57
+ /** The native DeepSeek tariff doubles at peak, and the catalogue lists the **off-peak** figure: measured
58
+ * 2026-10-28 against DeepSeek's own pricing page — `deepseek-flash` $0.15 off-peak / $0.30 peak, and the
59
+ * catalogue carries 0.15. Duplicated from `oc-budget/peak.ts` on purpose: the two packages are published
60
+ * apart and neither may import the other, so each carries the one fact it needs. That copy also owns the
61
+ * clock and the wallet; this one only knows which *list* price doubles, so the note stays static. */
62
+ export function nativePeak(providerID: string): number | undefined {
63
+ return providerID === "deepseek" ? 2 : undefined
64
+ }
65
+
57
66
  const CACHE_DIR = process.env.XDG_CACHE_HOME || join(homedir(), ".cache")
58
67
  const CACHE = join(CACHE_DIR, "oc-models-pro", "go-limits.json")
59
68
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@rizal72/oc-models-pro",
3
- "version": "0.1.5",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
- "description": "OpenCode v2 model picker with prices, context, intelligence, Go credit and the cost of a request",
5
+ "description": "OpenCode v2 model picker with prices, context, intelligence, tok/s, Go credit and the cost of a request",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/rizal72/opencode-plugins.git"
@@ -11,19 +11,23 @@
11
11
  "files": [
12
12
  "*.ts",
13
13
  "tui.js",
14
- "README.md"
14
+ "README.md",
15
+ "dist"
15
16
  ],
16
17
  "keywords": [
17
18
  "opencode",
18
19
  "opencode-plugin",
19
20
  "models",
20
- "pricing"
21
+ "pricing",
22
+ "throughput"
21
23
  ],
22
24
  "publishConfig": {
23
25
  "access": "public"
24
26
  },
25
27
  "exports": {
26
28
  ".": "./index.ts",
27
- "./tui": "./tui.js"
29
+ "./tui": "./tui.js",
30
+ "./rpc": "./rpc.ts",
31
+ "./gui-experimental": "./dist/gui-experimental.cjs"
28
32
  }
29
33
  }
package/prefs.ts ADDED
@@ -0,0 +1,92 @@
1
+ // oc-models-pro/prefs.ts — the picker's own state file, `~/.local/state/opencode/model.json`.
2
+ //
3
+ // Both halves need it: the terminal draws the list and toggles a favourite from the panel, the desktop
4
+ // writes the same file through its server half. It lives here so the recipe exists once — a second copy
5
+ // is a second way to lose `recent[]` or `variant{}`.
6
+ //
7
+ // The host watches this path and `model.json.*`. The write is `.model.json.tmp` then rename: the leading
8
+ // dot keeps the temp name outside that glob, so the host never sees half a file.
9
+ import { readFileSync, renameSync, writeFileSync } from "node:fs"
10
+ import { homedir } from "node:os"
11
+ import { join } from "node:path"
12
+
13
+ /** The picker's state file. Exported for the message a failed write shows. */
14
+ export const PREFS_FILE = join(homedir(), ".local/state/opencode/model.json")
15
+ const TEMP_FILE = join(homedir(), ".local/state/opencode", ".model.json.tmp")
16
+
17
+ export const prefKey = (providerID: string, modelID: string): string => providerID + "/" + modelID
18
+
19
+ /** `favorite[]` and `recent[]` are `{ providerID, modelID }`. */
20
+ export interface Entry {
21
+ providerID: string
22
+ modelID: string
23
+ }
24
+
25
+ export interface Prefs {
26
+ favorite: Set<string>
27
+ recent: Map<string, number>
28
+ /** `variant{ "provider/model": "high" }`: a saved variant per model, "default" included. */
29
+ variants: Map<string, string>
30
+ }
31
+
32
+ /** The host's `favorite[]` as it is in the file: a key cannot be split back — a model id may hold a slash. */
33
+ export function readFavourites(): Entry[] | undefined {
34
+ try {
35
+ const data = JSON.parse(readFileSync(PREFS_FILE, "utf8"))
36
+ if (!Array.isArray(data.favorite)) return []
37
+ return data.favorite.filter(
38
+ (entry: any) => typeof entry?.providerID === "string" && typeof entry?.modelID === "string",
39
+ )
40
+ } catch (error) {
41
+ // A missing file is not an error: starting empty creates it on the write. Anything else is.
42
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return []
43
+ console.error("[oc-models-pro] favourites: " + (error instanceof Error ? error.message : String(error)))
44
+ return undefined
45
+ }
46
+ }
47
+
48
+ /** Rewrites `favorite[]` with the rest of the file untouched. */
49
+ export function writeFavourites(list: Entry[]): boolean {
50
+ try {
51
+ let raw: any
52
+ try {
53
+ raw = JSON.parse(readFileSync(PREFS_FILE, "utf8"))
54
+ } catch (error) {
55
+ if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") throw error
56
+ raw = {}
57
+ }
58
+ raw.favorite = list
59
+ writeFileSync(TEMP_FILE, JSON.stringify(raw))
60
+ renameSync(TEMP_FILE, PREFS_FILE)
61
+ return true
62
+ } catch (error) {
63
+ console.error("[oc-models-pro] favourites: " + (error instanceof Error ? error.message : String(error)))
64
+ return false
65
+ }
66
+ }
67
+
68
+ /** Adds or removes one model; `undefined` when the file could not be read or written. */
69
+ export function toggleFavorite(providerID: string, modelID: string, on?: boolean): boolean | undefined {
70
+ const key = prefKey(providerID, modelID)
71
+ const list = readFavourites()
72
+ if (!list) return undefined
73
+ const kept = list.filter((entry) => prefKey(entry.providerID, entry.modelID) !== key)
74
+ const next = on ?? kept.length === list.length
75
+ return writeFavourites(next ? [...kept, { providerID, modelID }] : kept) ? next : undefined
76
+ }
77
+
78
+ export function readPrefs(): Prefs {
79
+ const parsed: Prefs = { favorite: new Set(), recent: new Map(), variants: new Map() }
80
+ try {
81
+ const data = JSON.parse(readFileSync(PREFS_FILE, "utf8"))
82
+ for (const entry of data.favorite ?? []) parsed.favorite.add(prefKey(entry.providerID, entry.modelID))
83
+ for (const [index, entry] of (data.recent ?? []).entries())
84
+ parsed.recent.set(prefKey(entry.providerID, entry.modelID), index)
85
+ for (const [model, variant] of Object.entries(data.variant ?? {}))
86
+ if (typeof variant === "string") parsed.variants.set(model, variant)
87
+ } catch (error) {
88
+ // A corrupt state file empties the favourites and the recent list: say so instead of pretending.
89
+ console.error("[oc-models-pro] prefs unreadable: " + (error instanceof Error ? error.message : String(error)))
90
+ }
91
+ return parsed
92
+ }