@rizal72/oc-models-pro 0.1.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -20
- package/aa.ts +129 -0
- package/bench.ts +8 -4
- package/dist/gui-experimental.cjs +58 -0
- package/index.ts +143 -2
- package/multipliers.ts +9 -0
- package/package.json +9 -5
- package/prefs.ts +92 -0
- package/rows.ts +301 -0
- package/rpc.ts +108 -0
- package/tps.ts +212 -0
- package/tui.js +117 -200
- package/vite.config.ts +6 -0
package/README.md
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
# @rizal72/oc-models-pro
|
|
2
2
|
|
|
3
|
-
A model picker
|
|
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
|
-
##
|
|
18
|
+
## TUI
|
|
22
19
|
|
|
23
|
-
`/models-pro`, or `Ctrl+X` then `p
|
|
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
|
-
##
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
51
|
+
## Optional: tok/s for models you have not run
|
|
45
52
|
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
11
|
-
// speed column
|
|
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, {
|
|
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
|
|
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.
|
|
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
|
+
}
|