@rizal72/oc-models-pro 0.2.0 → 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 +11 -5
- package/bench.ts +5 -2
- package/dist/gui-experimental.cjs +58 -0
- package/index.ts +143 -2
- package/multipliers.ts +9 -0
- package/package.json +6 -3
- package/prefs.ts +92 -0
- package/rows.ts +301 -0
- package/rpc.ts +108 -0
- package/tui.js +25 -172
- package/vite.config.ts +6 -0
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @rizal72/oc-models-pro
|
|
2
2
|
|
|
3
|
-
A model picker that shows what a request costs: price in and out, context, intelligence, `tok/s` and
|
|
4
|
-
|
|
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.
|
|
5
5
|
|
|
6
6
|
## Install
|
|
7
7
|
|
|
@@ -15,7 +15,7 @@ Or by hand, one line in `~/.config/opencode/opencode.jsonc`:
|
|
|
15
15
|
"plugins": ["@rizal72/oc-models-pro"]
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
##
|
|
18
|
+
## TUI
|
|
19
19
|
|
|
20
20
|
`/models-pro`, or `Ctrl+X` then `p`, inside a session.
|
|
21
21
|
|
|
@@ -30,7 +30,12 @@ Or by hand, one line in `~/.config/opencode/opencode.jsonc`:
|
|
|
30
30
|
| `ctrl+u` | clear the search |
|
|
31
31
|
| `esc` | clear the search, else close |
|
|
32
32
|
|
|
33
|
-
##
|
|
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
|
|
34
39
|
|
|
35
40
|
| column | source |
|
|
36
41
|
|---|---|
|
|
@@ -40,7 +45,8 @@ Or by hand, one line in `~/.config/opencode/opencode.jsonc`:
|
|
|
40
45
|
| ~tok/s | Artificial Analysis, with a free key |
|
|
41
46
|
| cred | the Go plan page, cached 1 day, `opencode-go` only |
|
|
42
47
|
|
|
43
|
-
Favourites and recents are the native picker's (`~/.local/state/opencode/model.json`), so both agree.
|
|
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.
|
|
44
50
|
|
|
45
51
|
## Optional: tok/s for models you have not run
|
|
46
52
|
|
package/bench.ts
CHANGED
|
@@ -91,7 +91,10 @@ export async function benchIndex(): Promise<Map<string, Bench>> {
|
|
|
91
91
|
const previous = await cached()
|
|
92
92
|
if (previous && Date.now() - previous.fetched < GRACE_MS) return indexOf(previous)
|
|
93
93
|
try {
|
|
94
|
-
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
|
+
})
|
|
95
98
|
if (!response.ok) throw new Error("HTTP " + response.status)
|
|
96
99
|
const built = buildIndex(await response.json())
|
|
97
100
|
if (built.size === 0) throw new Error("catalog shape changed")
|
|
@@ -115,7 +118,7 @@ export function benchFor(index: Map<string, Bench>, name: string, id: string): B
|
|
|
115
118
|
// Self-check: node plugins/oc-models-pro/bench.ts --self-check
|
|
116
119
|
if (process.argv[2] === "--self-check") {
|
|
117
120
|
const run = async () => {
|
|
118
|
-
const response = await fetch(CATALOG)
|
|
121
|
+
const response = await fetch(CATALOG, { signal: AbortSignal.timeout(20_000) })
|
|
119
122
|
if (!response.ok) throw new Error("self-check: HTTP " + response.status)
|
|
120
123
|
const index = buildIndex(await response.json())
|
|
121
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rizal72/oc-models-pro",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenCode v2 model picker with prices, context, intelligence, tok/s, Go credit and the cost of a request",
|
|
6
6
|
"repository": {
|
|
@@ -11,7 +11,8 @@
|
|
|
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",
|
|
@@ -25,6 +26,8 @@
|
|
|
25
26
|
},
|
|
26
27
|
"exports": {
|
|
27
28
|
".": "./index.ts",
|
|
28
|
-
"./tui": "./tui.js"
|
|
29
|
+
"./tui": "./tui.js",
|
|
30
|
+
"./rpc": "./rpc.ts",
|
|
31
|
+
"./gui-experimental": "./dist/gui-experimental.cjs"
|
|
29
32
|
}
|
|
30
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
|
+
}
|
package/rows.ts
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// oc-models-pro/rows.ts — the facts both surfaces read the same way.
|
|
2
|
+
//
|
|
3
|
+
// The terminal draws a monospace table and the desktop draws a grid, but what a row *means* is not the
|
|
4
|
+
// renderer's business: the effective price, what counts as free, and the ten orders are one definition
|
|
5
|
+
// here. Two copies would drift the way they already did once — the terminal kept free models out of the
|
|
6
|
+
// price order and the desktop did not.
|
|
7
|
+
//
|
|
8
|
+
// Runnable standalone for its self-check: node plugins/oc-models-pro/rows.ts --self-check
|
|
9
|
+
|
|
10
|
+
export interface Row {
|
|
11
|
+
name: string
|
|
12
|
+
providerID: string
|
|
13
|
+
modelID: string
|
|
14
|
+
provider: string
|
|
15
|
+
/** Two letters from `tags.ts`: the provider's, unique in the catalogue. */
|
|
16
|
+
tag: string
|
|
17
|
+
/** Undefined when the catalogue has no price for the model: an unknown figure, not a free one. */
|
|
18
|
+
input?: number
|
|
19
|
+
output?: number
|
|
20
|
+
/** Undefined when the model carries no limit. */
|
|
21
|
+
context?: number
|
|
22
|
+
/** Monthly limit ÷ the $10 plan: the ordering multiplier, shown so the order explains itself. */
|
|
23
|
+
credit: number
|
|
24
|
+
/** Allowance ÷ its normal one, when the page marks a promotion. */
|
|
25
|
+
promo?: number
|
|
26
|
+
/** Peak price ÷ off-peak price, for a model with two tariffs. */
|
|
27
|
+
peak?: number
|
|
28
|
+
/** Stencil's intelligence score and its speed; absent when the catalogue has no row for the model. */
|
|
29
|
+
int?: number
|
|
30
|
+
tps?: number
|
|
31
|
+
/** Tokens per second measured here, on this provider. Outranks `tps` and carries no tilde. */
|
|
32
|
+
measured?: number
|
|
33
|
+
enabled: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What the Go plan makes a model cost: the figure the effective order sorts by. An unknown price is
|
|
37
|
+
* `undefined`, not infinity, so an order can tell "not measured" from "very expensive". */
|
|
38
|
+
export function effective(row: Row): number | undefined {
|
|
39
|
+
if (row.input === undefined) return undefined
|
|
40
|
+
return row.input / (row.credit || 1)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A missing price is not a free one: both figures must be there, and be zero. */
|
|
44
|
+
export function isFree(row: Row): boolean {
|
|
45
|
+
return row.input === 0 && row.output === 0
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The speed to sort and print: what this machine measured first, the borrowed figure after. */
|
|
49
|
+
export function speedOf(row: Row): number | undefined {
|
|
50
|
+
return row.measured ?? row.tps
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type SortKey =
|
|
54
|
+
| "effective"
|
|
55
|
+
| "input"
|
|
56
|
+
| "output"
|
|
57
|
+
| "inputSum"
|
|
58
|
+
| "context"
|
|
59
|
+
| "int"
|
|
60
|
+
| "speed"
|
|
61
|
+
| "recent"
|
|
62
|
+
| "provider"
|
|
63
|
+
| "name"
|
|
64
|
+
|
|
65
|
+
export interface SortContext {
|
|
66
|
+
/** `providerID/modelID` → its place in the recent list; smaller is more recent. */
|
|
67
|
+
recent: Map<string, number>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface Order {
|
|
71
|
+
/** The long name: a footer, a menu, a title. */
|
|
72
|
+
label: string
|
|
73
|
+
/** The short name the terminal prints in its title bar. */
|
|
74
|
+
short: string
|
|
75
|
+
/** True when more is better, so the first row is the largest figure. */
|
|
76
|
+
desc?: true
|
|
77
|
+
/** `undefined` when the order cannot measure this row — the caller sorts it last, in either direction. */
|
|
78
|
+
value(row: Row, context: SortContext): number | undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Whatever an order cannot measure sorts last, never first, and in either direction: an unknown price is
|
|
82
|
+
* not the cheapest thing on the list, and a missing score is not the highest. */
|
|
83
|
+
|
|
84
|
+
export const ORDERS: Record<SortKey, Order> = {
|
|
85
|
+
effective: { label: "effective price", short: "effective↑", value: effective },
|
|
86
|
+
input: { label: "input price", short: "in↑", value: (row) => row.input },
|
|
87
|
+
output: { label: "output price", short: "out↑", value: (row) => row.output },
|
|
88
|
+
inputSum: {
|
|
89
|
+
label: "input + output",
|
|
90
|
+
short: "in+out↑",
|
|
91
|
+
value: (row) =>
|
|
92
|
+
row.input === undefined || row.output === undefined ? undefined : (row.input + row.output) / (row.credit || 1),
|
|
93
|
+
},
|
|
94
|
+
context: { label: "context window", short: "ctx↓", desc: true, value: (row) => row.context },
|
|
95
|
+
// A missing score sorts last, never first: Pi's rule, and the honest one.
|
|
96
|
+
int: { label: "intelligence", short: "int↓", desc: true, value: (row) => row.int },
|
|
97
|
+
speed: { label: "speed", short: "speed↓", desc: true, value: (row) => speedOf(row) },
|
|
98
|
+
recent: {
|
|
99
|
+
label: "recently used",
|
|
100
|
+
short: "recent",
|
|
101
|
+
value: (row, context) => context.recent.get(row.providerID + "/" + row.modelID),
|
|
102
|
+
},
|
|
103
|
+
provider: { label: "provider", short: "provider", value: () => 0 },
|
|
104
|
+
name: { label: "name", short: "name", value: () => 0 },
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The orders in the cycle the terminal walks with `ctrl+o`, in the same sequence. */
|
|
108
|
+
export const ORDER: SortKey[] = [
|
|
109
|
+
"effective",
|
|
110
|
+
"input",
|
|
111
|
+
"output",
|
|
112
|
+
"inputSum",
|
|
113
|
+
"context",
|
|
114
|
+
"int",
|
|
115
|
+
"speed",
|
|
116
|
+
"recent",
|
|
117
|
+
"provider",
|
|
118
|
+
"name",
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
/** The direction an order reads in when nothing else says otherwise. */
|
|
122
|
+
export function direction(key: SortKey): 1 | -1 {
|
|
123
|
+
return ORDERS[key].desc ? -1 : 1
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const id = (row: Row) => row.providerID + "/" + row.modelID
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The comparator both surfaces sort with. The two label orders compare text; every other order compares
|
|
130
|
+
* a number, and the direction is passed in because the desktop lets the reader flip it. Ties fall back to
|
|
131
|
+
* the name so the list never reshuffles between two reads of the same data.
|
|
132
|
+
*/
|
|
133
|
+
export function compare(key: SortKey, a: Row, b: Row, context: SortContext, sign: 1 | -1 = direction(key)): number {
|
|
134
|
+
if (key === "name" || key === "provider") {
|
|
135
|
+
const left = key === "name" ? a.name : a.provider
|
|
136
|
+
const right = key === "name" ? b.name : b.provider
|
|
137
|
+
const delta = left.localeCompare(right)
|
|
138
|
+
return delta !== 0 ? delta * sign : id(a).localeCompare(id(b))
|
|
139
|
+
}
|
|
140
|
+
const left = ORDERS[key].value(a, context)
|
|
141
|
+
const right = ORDERS[key].value(b, context)
|
|
142
|
+
// The direction flips what can be measured, never what cannot: an unknown figure goes last when the
|
|
143
|
+
// arrow points up and when it points down, or flipping a column would head the list with blanks.
|
|
144
|
+
if (left === undefined || right === undefined) {
|
|
145
|
+
if (left === right) return a.name.localeCompare(b.name)
|
|
146
|
+
return left === undefined ? 1 : -1
|
|
147
|
+
}
|
|
148
|
+
if (left === right) return a.name.localeCompare(b.name)
|
|
149
|
+
return (left < right ? -1 : 1) * sign
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** What a list of rows looks like once it is drawn: one section per provider, or the flat blocks. */
|
|
153
|
+
export interface Section {
|
|
154
|
+
/** The provider's name, or `Free` for the free bucket. `undefined` when the block needs no label: the
|
|
155
|
+
* leading one, and the paying block after it in the flat layout. */
|
|
156
|
+
title?: string
|
|
157
|
+
rows: Row[]
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The order the surfaces draw, which is not the order they sort: free models never enter a price order,
|
|
162
|
+
* so they follow their provider's paying rows; and the model in use is never buried — it leads its
|
|
163
|
+
* provider, and its provider leads the list. Both surfaces held a copy of this (`gui.tsx` and
|
|
164
|
+
* `tui.tsx`), and the copies drifted the moment a filter was added.
|
|
165
|
+
*/
|
|
166
|
+
export function arrange(list: Row[], options: { inUse?: string; grouped: boolean }): Section[] {
|
|
167
|
+
const used = options.inUse === undefined ? undefined : list.find((row) => id(row) === options.inUse)
|
|
168
|
+
|
|
169
|
+
if (!options.grouped) {
|
|
170
|
+
const rest = used ? list.filter((row) => row !== used) : list
|
|
171
|
+
const paying = rest.filter((row) => !isFree(row))
|
|
172
|
+
const free = rest.filter((row) => isFree(row))
|
|
173
|
+
return [
|
|
174
|
+
...(used ? [{ rows: [used] }] : []),
|
|
175
|
+
...(paying.length ? [{ rows: paying }] : []),
|
|
176
|
+
...(free.length ? [{ title: "Free", rows: free }] : []),
|
|
177
|
+
]
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Built from the whole list, the in-use row included: leaving it out left its provider with no group
|
|
181
|
+
// at all when it was the only row a filter kept, and the row surfaced under the first header.
|
|
182
|
+
const groups = new Map<string, Section>()
|
|
183
|
+
for (const row of list) {
|
|
184
|
+
const group = groups.get(row.providerID) ?? { title: row.provider, rows: [] }
|
|
185
|
+
// Paying first, then free: `isFree` sorts false before true.
|
|
186
|
+
const at = group.rows.findIndex((entry) => isFree(entry) && !isFree(row))
|
|
187
|
+
if (at === -1) group.rows.push(row)
|
|
188
|
+
else group.rows.splice(at, 0, row)
|
|
189
|
+
groups.set(row.providerID, group)
|
|
190
|
+
}
|
|
191
|
+
// By name, and by id when two providers share one: the id decides, the name only labels.
|
|
192
|
+
const sections = [...groups.entries()]
|
|
193
|
+
.sort(([idA, a], [idB, b]) => (a.title ?? "").localeCompare(b.title ?? "") || idA.localeCompare(idB))
|
|
194
|
+
.map(([, section]) => section)
|
|
195
|
+
if (used) {
|
|
196
|
+
// The in-use provider leads, and its model leads inside it.
|
|
197
|
+
const at = sections.indexOf(groups.get(used.providerID)!)
|
|
198
|
+
if (at > 0) sections.unshift(...sections.splice(at, 1))
|
|
199
|
+
const first = sections[0]
|
|
200
|
+
if (first) first.rows = [used, ...first.rows.filter((row) => row !== used)]
|
|
201
|
+
}
|
|
202
|
+
return sections
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Self-check: node plugins/oc-models-pro/rows.ts --self-check
|
|
206
|
+
// Guarded: this module is also bundled into the desktop window, where `process` does not exist.
|
|
207
|
+
if (typeof process !== "undefined" && process.argv?.[2] === "--self-check") {
|
|
208
|
+
const row = (over: Partial<Row> = {}): Row => ({
|
|
209
|
+
name: "Model",
|
|
210
|
+
providerID: "p",
|
|
211
|
+
modelID: "m",
|
|
212
|
+
provider: "Provider",
|
|
213
|
+
tag: "PR",
|
|
214
|
+
credit: 1,
|
|
215
|
+
enabled: true,
|
|
216
|
+
...over,
|
|
217
|
+
})
|
|
218
|
+
const context: SortContext = { recent: new Map([["p/m", 0]]) }
|
|
219
|
+
const check = (ok: boolean, message: string) => {
|
|
220
|
+
if (!ok) throw new Error("self-check: " + message)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
check(effective(row()) === undefined, "an unknown input price is not zero")
|
|
224
|
+
check(effective(row({ input: 0.8, credit: 8 })) === 0.1, "the credit divides the input price")
|
|
225
|
+
check(effective(row({ input: 0.8, credit: 0 })) === 0.8, "a zero credit must not divide by zero")
|
|
226
|
+
check(!isFree(row()), "a missing price is not free")
|
|
227
|
+
check(!isFree(row({ input: 0, output: 2 })), "a zero input with a paid output is not free")
|
|
228
|
+
check(isFree(row({ input: 0, output: 0 })), "both at zero is free")
|
|
229
|
+
check(speedOf(row({ tps: 40, measured: 12 })) === 12, "a measured speed outranks a borrowed one")
|
|
230
|
+
check(speedOf(row()) === undefined, "no speed is no speed")
|
|
231
|
+
|
|
232
|
+
// The price orders put an unknown figure last, and the descending ones put a missing figure last too.
|
|
233
|
+
const sorts = (key: SortKey, rows: Row[]) => [...rows].sort((a, b) => compare(key, a, b, context))
|
|
234
|
+
const cheap = row({ name: "cheap", input: 1, output: 1 })
|
|
235
|
+
const dear = row({ name: "dear", input: 10, output: 10 })
|
|
236
|
+
const unknown = row({ name: "unknown" })
|
|
237
|
+
check(sorts("input", [unknown, dear, cheap]).map((r) => r.name)[0] === "cheap", "in↑ starts at the cheapest")
|
|
238
|
+
check(sorts("input", [unknown, dear, cheap]).map((r) => r.name)[2] === "unknown", "an unknown price sorts last")
|
|
239
|
+
const winning: Row[] = [row({ name: "few", context: 1000, int: 10 }), row({ name: "many", context: 100_000, int: 50 }), unknown]
|
|
240
|
+
check(sorts("context", winning)[0].name === "many", "ctx↓ starts at the widest window")
|
|
241
|
+
check(sorts("context", winning)[2].name === "unknown", "a missing window sorts last, not first")
|
|
242
|
+
check(sorts("int", winning)[0].name === "many", "int↓ starts at the highest score")
|
|
243
|
+
check(sorts("int", winning)[2].name === "unknown", "a missing score sorts last, not first")
|
|
244
|
+
check(sorts("speed", winning)[2].name === "unknown", "a missing speed sorts last, not first")
|
|
245
|
+
// Flipping a column is the reader's right, and it must never bring the blanks to the top: this is the
|
|
246
|
+
// state two clicks reach, and the one the sentinels used to get wrong.
|
|
247
|
+
const flipped = (key: SortKey, rows: Row[]) => [...rows].sort((a, b) => compare(key, a, b, context, -1))
|
|
248
|
+
check(flipped("input", [unknown, dear, cheap])[2].name === "unknown", "in↓ puts an unknown price last too")
|
|
249
|
+
check(flipped("input", [unknown, dear, cheap])[0].name === "dear", "in↓ starts at the dearest")
|
|
250
|
+
check(flipped("context", winning)[2].name === "unknown", "ctx↑ puts a missing window last too")
|
|
251
|
+
check(flipped("int", winning)[2].name === "unknown", "int↑ puts a missing score last too")
|
|
252
|
+
check(flipped("speed", winning)[2].name === "unknown", "speed↑ puts a missing speed last too")
|
|
253
|
+
check(flipped("effective", [unknown, dear, cheap])[2].name === "unknown", "effective↓ puts an unknown price last too")
|
|
254
|
+
const recentContext: SortContext = { recent: new Map([["p/fresh", 0]]) }
|
|
255
|
+
const fresh = row({ name: "fresh", modelID: "fresh" })
|
|
256
|
+
const stale = row({ name: "stale", modelID: "stale" })
|
|
257
|
+
check(
|
|
258
|
+
[stale, fresh].sort((a, b) => compare("recent", a, b, recentContext))[0].name === "fresh",
|
|
259
|
+
"the recent list orders by its index",
|
|
260
|
+
)
|
|
261
|
+
check(compare("input", cheap, dear, context, -1) > 0, "the sign the caller passes flips the order")
|
|
262
|
+
check(compare("name", row({ name: "a" }), row({ name: "b" }), context) < 0, "name compares text")
|
|
263
|
+
check(compare("provider", row({ provider: "a" }), row({ provider: "b" }), context) < 0, "provider compares text")
|
|
264
|
+
check(direction("effective") === 1 && direction("int") === -1, "the default direction follows the order")
|
|
265
|
+
|
|
266
|
+
// The order the surfaces draw: the cases that each cost a bug.
|
|
267
|
+
const a = row({ name: "a", modelID: "a", providerID: "pa", provider: "A", input: 2, output: 2 })
|
|
268
|
+
const b = row({ name: "b", modelID: "b", providerID: "pa", provider: "A", input: 0, output: 0 })
|
|
269
|
+
const c = row({ name: "c", modelID: "c", providerID: "pb", provider: "B", input: 3, output: 3 })
|
|
270
|
+
|
|
271
|
+
const grouped = arrange([a, b, c], { inUse: "pb/c", grouped: true })
|
|
272
|
+
check(grouped.map((s) => s.title).join() === "B,A", "the provider in use leads the list")
|
|
273
|
+
check(grouped[0].rows[0] === c, "and its model leads inside it")
|
|
274
|
+
check(grouped[1].rows.map((r) => r.name).join() === "a,b", "paying before free inside a group")
|
|
275
|
+
|
|
276
|
+
// The in-use row shares its group here: hoisting the provider is not enough, the row itself must move,
|
|
277
|
+
// and this is the shape the panel depends on (its sort does not pre-hoist, unlike the terminal's).
|
|
278
|
+
const inner = arrange([a, b, c], { inUse: "pa/b", grouped: true })
|
|
279
|
+
check(inner[0].title === "A" && inner[0].rows[0] === b, "the in-use row leads inside a group that has others")
|
|
280
|
+
check(inner[0].rows.map((r) => r.name).join() === "b,a", "and the rest of the group keeps its order")
|
|
281
|
+
|
|
282
|
+
// Free in use: it leads the list in both layouts, and never falls into the free bucket.
|
|
283
|
+
const freeUsed = arrange([a, b, c], { inUse: "pa/b", grouped: false })
|
|
284
|
+
check(freeUsed[0].rows[0] === b, "flat: a free model in use still leads")
|
|
285
|
+
check(!freeUsed.some((s) => s.title === "Free"), "flat: and is not repeated in the free bucket")
|
|
286
|
+
|
|
287
|
+
const solo = arrange([c], { inUse: "pb/c", grouped: true })
|
|
288
|
+
check(solo.length === 1 && solo[0].title === "B", "the only row a filter kept still has its own group")
|
|
289
|
+
|
|
290
|
+
const flat = arrange([b, c, a], { inUse: "pb/c", grouped: false })
|
|
291
|
+
check(flat[0].rows[0] === c, "flat: the model in use leads the list")
|
|
292
|
+
check(flat[flat.length - 1].title === "Free", "flat: the free bucket is last")
|
|
293
|
+
check(flat[flat.length - 1].rows[0] === b, "flat: and holds the free row")
|
|
294
|
+
|
|
295
|
+
const twin = row({ name: "twin", modelID: "twin", providerID: "p2", provider: "A", input: 4, output: 4 })
|
|
296
|
+
const twins = arrange([a, twin], { inUse: "p2/twin", grouped: true })
|
|
297
|
+
check(twins.length === 2, "two providers sharing a display name stay two groups")
|
|
298
|
+
check(twins[0].rows[0] === twin, "and the one in use leads by id, not by the shared name")
|
|
299
|
+
|
|
300
|
+
console.log("self-check ok: " + ORDER.length + " orders, unknown figures last in every one of them")
|
|
301
|
+
}
|
package/rpc.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// What the desktop's panel reads: a plugin window has no Node built-ins, so every file and every fetch
|
|
2
|
+
// stays in the server half and the window asks for the finished table.
|
|
3
|
+
import { Rpc } from "@opencode/plugin/rpc"
|
|
4
|
+
|
|
5
|
+
import type { Row } from "./rows.ts"
|
|
6
|
+
|
|
7
|
+
const Count = { type: "number" }
|
|
8
|
+
|
|
9
|
+
const Row = {
|
|
10
|
+
type: "object",
|
|
11
|
+
properties: {
|
|
12
|
+
providerID: { type: "string" },
|
|
13
|
+
modelID: { type: "string" },
|
|
14
|
+
name: { type: "string" },
|
|
15
|
+
/** The provider's display name, account first as the host writes it. */
|
|
16
|
+
provider: { type: "string" },
|
|
17
|
+
/** Two letters from `tags.ts`. */
|
|
18
|
+
tag: { type: "string" },
|
|
19
|
+
/** Prices per million tokens; absent when the catalogue has none — an unknown figure, not a free one. */
|
|
20
|
+
input: Count,
|
|
21
|
+
output: Count,
|
|
22
|
+
context: Count,
|
|
23
|
+
/** Monthly limit ÷ the $10 plan. 1 when the plan is unknown. */
|
|
24
|
+
credit: Count,
|
|
25
|
+
promo: Count,
|
|
26
|
+
peak: Count,
|
|
27
|
+
int: Count,
|
|
28
|
+
/** Borrowed from Artificial Analysis, or the catalogue's own when it still carries one. */
|
|
29
|
+
tps: Count,
|
|
30
|
+
/** Measured on this machine, on this provider. Outranks `tps` and carries no tilde. */
|
|
31
|
+
measured: Count,
|
|
32
|
+
enabled: { type: "boolean" },
|
|
33
|
+
},
|
|
34
|
+
required: ["providerID", "modelID", "name", "provider", "tag", "credit", "enabled"],
|
|
35
|
+
additionalProperties: false,
|
|
36
|
+
} as const
|
|
37
|
+
|
|
38
|
+
const Snapshot = {
|
|
39
|
+
type: "object",
|
|
40
|
+
properties: {
|
|
41
|
+
rows: { type: "array", items: Row },
|
|
42
|
+
/** `providerID/modelID` of the models the host keeps as favourites, from `model.json`. */
|
|
43
|
+
favorites: { type: "array", items: { type: "string" } },
|
|
44
|
+
/** How many offers the catalogue holds, and how many came out with a speed — the pair that tells a
|
|
45
|
+
* thin source from a broken key. */
|
|
46
|
+
offers: Count,
|
|
47
|
+
withSpeed: Count,
|
|
48
|
+
/** False when the Go plan could not be read and no cache was there: every credit is 1. */
|
|
49
|
+
credits: { type: "boolean" },
|
|
50
|
+
/** What failed, in the words the panel shows. Empty when everything came back. */
|
|
51
|
+
problems: { type: "array", items: { type: "string" } },
|
|
52
|
+
},
|
|
53
|
+
required: ["rows", "favorites", "offers", "withSpeed", "credits"],
|
|
54
|
+
additionalProperties: false,
|
|
55
|
+
} as const
|
|
56
|
+
|
|
57
|
+
export const Models = Rpc.define({
|
|
58
|
+
id: "models",
|
|
59
|
+
methods: {
|
|
60
|
+
snapshot: {
|
|
61
|
+
input: {
|
|
62
|
+
type: "object",
|
|
63
|
+
properties: { force: { type: "boolean" } },
|
|
64
|
+
additionalProperties: false,
|
|
65
|
+
},
|
|
66
|
+
output: Snapshot,
|
|
67
|
+
},
|
|
68
|
+
setFavorite: {
|
|
69
|
+
input: {
|
|
70
|
+
type: "object",
|
|
71
|
+
properties: {
|
|
72
|
+
providerID: { type: "string" },
|
|
73
|
+
modelID: { type: "string" },
|
|
74
|
+
/** Absent toggles. */
|
|
75
|
+
on: { type: "boolean" },
|
|
76
|
+
},
|
|
77
|
+
required: ["providerID", "modelID"],
|
|
78
|
+
additionalProperties: false,
|
|
79
|
+
},
|
|
80
|
+
output: {
|
|
81
|
+
type: "object",
|
|
82
|
+
properties: {
|
|
83
|
+
/** False when the file could not be read or written: the panel says so instead of pretending. */
|
|
84
|
+
ok: { type: "boolean" },
|
|
85
|
+
favorites: { type: "array", items: { type: "string" } },
|
|
86
|
+
},
|
|
87
|
+
required: ["ok", "favorites"],
|
|
88
|
+
additionalProperties: false,
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
// Present (empty) on purpose: a missing events map fails the whole plugin.
|
|
93
|
+
events: {},
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
/** The wire form is the row both surfaces sort: one shape, declared once. */
|
|
97
|
+
export type ModelRow = Row
|
|
98
|
+
|
|
99
|
+
export interface ModelsSnapshot {
|
|
100
|
+
rows: ModelRow[]
|
|
101
|
+
favorites: string[]
|
|
102
|
+
offers: number
|
|
103
|
+
withSpeed: number
|
|
104
|
+
credits: boolean
|
|
105
|
+
problems?: string[]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export default Models
|
package/tui.js
CHANGED
|
@@ -18,139 +18,24 @@ import { createElement as _$createElement } from "@opentui/solid";
|
|
|
18
18
|
// is the first row whatever it costs: the free/paying split never buries it.
|
|
19
19
|
import { Plugin } from "@opencode/plugin/tui";
|
|
20
20
|
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
|
|
21
|
-
import { appendFileSync, readFileSync,
|
|
21
|
+
import { appendFileSync, readFileSync, watch, writeFileSync } from "node:fs";
|
|
22
22
|
import { homedir, tmpdir } from "node:os";
|
|
23
23
|
import { join } from "node:path";
|
|
24
24
|
import { benchFor, benchIndex, benchKey } from "./bench.ts";
|
|
25
25
|
import { aaIndex } from "./aa.ts";
|
|
26
26
|
import { speedIndex } from "./tps.ts";
|
|
27
|
-
import { credits, goPlan, key } from "./multipliers.ts";
|
|
27
|
+
import { credits, goPlan, key, nativePeak } from "./multipliers.ts";
|
|
28
28
|
import { LEGEND_MIN, fit, hintLines, rowWidth } from "./fit.ts";
|
|
29
29
|
import { tagsFor } from "./tags.ts";
|
|
30
|
+
import { prefKey, readFavourites, readPrefs, toggleFavorite, writeFavourites, PREFS_FILE } from "./prefs.ts";
|
|
31
|
+
import { ORDER, ORDERS, arrange, compare, isFree } from "./rows.ts";
|
|
30
32
|
const SCOPES = ["all", "favorites", "paying"];
|
|
31
|
-
const prefKey = (providerID, modelID) => providerID + "/" + modelID;
|
|
32
|
-
|
|
33
|
-
/** The picker's own state file: `favorite[]` and `recent[]` are `{ providerID, modelID }`. */
|
|
34
|
-
|
|
35
|
-
const PREFS_FILE = join(homedir(), ".local/state/opencode/model.json");
|
|
36
|
-
/** Same directory, a leading dot: the host watches `model.json.*`, not this. */
|
|
37
|
-
const TEMP_FILE = join(homedir(), ".local/state/opencode", ".model.json.tmp");
|
|
38
33
|
let prefs = {
|
|
39
34
|
favorite: new Set(),
|
|
40
35
|
recent: new Map(),
|
|
41
36
|
variants: new Map()
|
|
42
37
|
};
|
|
43
38
|
|
|
44
|
-
/** The host's `favorite[]` as it is in the file: a key cannot be split back — a model id may hold a slash. */
|
|
45
|
-
function readFavourites() {
|
|
46
|
-
try {
|
|
47
|
-
const data = JSON.parse(readFileSync(PREFS_FILE, "utf8"));
|
|
48
|
-
if (!Array.isArray(data.favorite)) return [];
|
|
49
|
-
return data.favorite.filter(entry => typeof entry?.providerID === "string" && typeof entry?.modelID === "string");
|
|
50
|
-
} catch (error) {
|
|
51
|
-
// A missing file is not an error: starting empty creates it on the write. Anything else is.
|
|
52
|
-
if (error?.code === "ENOENT") return [];
|
|
53
|
-
trace("favourites: " + (error instanceof Error ? error.message : String(error)));
|
|
54
|
-
return undefined;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Rewrites `favorite[]` with the rest of the file untouched. The host watches this path and
|
|
59
|
-
* `model.json.*`, so the atomic rename is seen and the native picker, `model.cycle_favorite` and this
|
|
60
|
-
* list stay in step; a dot in front keeps the temp name outside that glob. */
|
|
61
|
-
function writeFavourites(list) {
|
|
62
|
-
try {
|
|
63
|
-
let raw;
|
|
64
|
-
try {
|
|
65
|
-
raw = JSON.parse(readFileSync(PREFS_FILE, "utf8"));
|
|
66
|
-
} catch (error) {
|
|
67
|
-
if (error?.code !== "ENOENT") throw error;
|
|
68
|
-
raw = {};
|
|
69
|
-
}
|
|
70
|
-
raw.favorite = list;
|
|
71
|
-
writeFileSync(TEMP_FILE, JSON.stringify(raw));
|
|
72
|
-
renameSync(TEMP_FILE, PREFS_FILE);
|
|
73
|
-
return true;
|
|
74
|
-
} catch (error) {
|
|
75
|
-
trace("favourites: " + (error instanceof Error ? error.message : String(error)));
|
|
76
|
-
return false;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
function toggleFavorite(providerID, modelID) {
|
|
80
|
-
const key = prefKey(providerID, modelID);
|
|
81
|
-
const list = readFavourites();
|
|
82
|
-
if (!list) return undefined;
|
|
83
|
-
const kept = list.filter(entry => prefKey(entry.providerID, entry.modelID) !== key);
|
|
84
|
-
const on = kept.length === list.length;
|
|
85
|
-
return writeFavourites(on ? [...kept, {
|
|
86
|
-
providerID,
|
|
87
|
-
modelID
|
|
88
|
-
}] : kept) ? on : undefined;
|
|
89
|
-
}
|
|
90
|
-
function readPrefs() {
|
|
91
|
-
const parsed = {
|
|
92
|
-
favorite: new Set(),
|
|
93
|
-
recent: new Map(),
|
|
94
|
-
variants: new Map()
|
|
95
|
-
};
|
|
96
|
-
try {
|
|
97
|
-
const data = JSON.parse(readFileSync(PREFS_FILE, "utf8"));
|
|
98
|
-
for (const entry of data.favorite ?? []) parsed.favorite.add(prefKey(entry.providerID, entry.modelID));
|
|
99
|
-
for (const [index, entry] of (data.recent ?? []).entries()) parsed.recent.set(prefKey(entry.providerID, entry.modelID), index);
|
|
100
|
-
for (const [model, variant] of Object.entries(data.variant ?? {})) if (typeof variant === "string") parsed.variants.set(model, variant);
|
|
101
|
-
} catch (error) {
|
|
102
|
-
// A corrupt state file empties the favourites and the recent list: say so instead of pretending.
|
|
103
|
-
trace("prefs unreadable: " + (error instanceof Error ? error.message : String(error)));
|
|
104
|
-
}
|
|
105
|
-
return parsed;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** What the Go plan makes a model cost: the figure the effective order sorts by. An unknown price is
|
|
109
|
-
* not zero — it sorts last, or it would sit at the top of the cheapest list forever. */
|
|
110
|
-
function effective(row) {
|
|
111
|
-
if (row.input === undefined) return Number.POSITIVE_INFINITY;
|
|
112
|
-
return row.input / (row.credit || 1);
|
|
113
|
-
}
|
|
114
|
-
const SORTS = [{
|
|
115
|
-
label: "effective price",
|
|
116
|
-
short: "effective↑",
|
|
117
|
-
value: effective
|
|
118
|
-
}, {
|
|
119
|
-
label: "input price",
|
|
120
|
-
short: "in↑",
|
|
121
|
-
value: row => row.input ?? Number.POSITIVE_INFINITY
|
|
122
|
-
}, {
|
|
123
|
-
label: "output price",
|
|
124
|
-
short: "out↑",
|
|
125
|
-
value: row => row.output ?? Number.POSITIVE_INFINITY
|
|
126
|
-
}, {
|
|
127
|
-
label: "input + output",
|
|
128
|
-
short: "in+out↑",
|
|
129
|
-
value: row => row.input === undefined || row.output === undefined ? Number.POSITIVE_INFINITY : (row.input + row.output) / (row.credit || 1)
|
|
130
|
-
}, {
|
|
131
|
-
label: "context window",
|
|
132
|
-
short: "ctx↓",
|
|
133
|
-
value: row => row.context === undefined ? Number.POSITIVE_INFINITY : -row.context
|
|
134
|
-
},
|
|
135
|
-
// A missing score sorts last, never first: Pi's rule, and the honest one.
|
|
136
|
-
{
|
|
137
|
-
label: "intelligence",
|
|
138
|
-
short: "int↓",
|
|
139
|
-
value: row => -(row.int ?? -1)
|
|
140
|
-
}, {
|
|
141
|
-
label: "speed",
|
|
142
|
-
short: "speed↓",
|
|
143
|
-
value: row => -(row.measured ?? row.tps ?? -1)
|
|
144
|
-
}, {
|
|
145
|
-
label: "recently used",
|
|
146
|
-
short: "recent",
|
|
147
|
-
value: row => prefs.recent.get(prefKey(row.providerID, row.modelID)) ?? 1e9
|
|
148
|
-
}, {
|
|
149
|
-
label: "name",
|
|
150
|
-
short: "name",
|
|
151
|
-
value: row => 0
|
|
152
|
-
}];
|
|
153
|
-
|
|
154
39
|
/** Fallbacks, used only when the theme defines none of the candidates. */
|
|
155
40
|
const FALLBACK_DIM = "#8a8a8a";
|
|
156
41
|
const FALLBACK_ACCENT = "#5a9fd4";
|
|
@@ -193,9 +78,6 @@ function price(value, width) {
|
|
|
193
78
|
function isGo(providerID) {
|
|
194
79
|
return providerID === "opencode-go";
|
|
195
80
|
}
|
|
196
|
-
function free(row) {
|
|
197
|
-
return row.input === 0 && row.output === 0;
|
|
198
|
-
}
|
|
199
81
|
function headerRow(f) {
|
|
200
82
|
return [" ", " ", "model".padEnd(f.name), cell("in", 6), cell("out", 6), ...(f.ctx ? [cell("ctx", 5)] : []), ...(f.int ? [cell("int", 5)] : []), ...(f.tkps ? [cell("tok/s", 6)] : []), cell("cred", 4), ...(f.note ? [cell("note", 6)] : [])].join(" ");
|
|
201
83
|
}
|
|
@@ -289,7 +171,7 @@ export default Plugin.define({
|
|
|
289
171
|
let creditsKnown = false;
|
|
290
172
|
// Signals, not plain variables: the header shows them, and a plain variable left the title
|
|
291
173
|
// saying "effective price" whatever the list was actually sorted by.
|
|
292
|
-
const [sort, setSort] = createSignal(
|
|
174
|
+
const [sort, setSort] = createSignal("effective");
|
|
293
175
|
const [grouped, setGrouped] = createSignal(true);
|
|
294
176
|
const [scope, setScope] = createSignal("all");
|
|
295
177
|
function fail(label, error) {
|
|
@@ -349,7 +231,9 @@ export default Plugin.define({
|
|
|
349
231
|
// "DeepSeek V4.1 Flash" inherited the Go credit.
|
|
350
232
|
credit: isGo(model.providerID) ? creditIndex.get(key(model.modelID))?.credit ?? 1 : 1,
|
|
351
233
|
promo: isGo(model.providerID) ? creditIndex.get(key(model.modelID))?.promo : undefined,
|
|
352
|
-
|
|
234
|
+
// Measured on the Go plan first, then the native rule: the catalogue lists one
|
|
235
|
+
// DeepSeek price, so only the second can say it doubles.
|
|
236
|
+
peak: (isGo(model.providerID) ? creditIndex.get(key(model.modelID))?.peak : undefined) ?? nativePeak(model.providerID),
|
|
353
237
|
int: score?.int,
|
|
354
238
|
tps: theirs,
|
|
355
239
|
measured: mine,
|
|
@@ -502,66 +386,35 @@ export default Plugin.define({
|
|
|
502
386
|
const needle = query().trim().toLowerCase();
|
|
503
387
|
let list = rows().filter(row => row.enabled);
|
|
504
388
|
if (scope() === "favorites") list = list.filter(row => prefs.favorite.has(prefKey(row.providerID, row.modelID)) || prefKey(row.providerID, row.modelID) === mine);
|
|
505
|
-
if (scope() === "paying") list = list.filter(row => !
|
|
389
|
+
if (scope() === "paying") list = list.filter(row => !isFree(row) || prefKey(row.providerID, row.modelID) === mine);
|
|
506
390
|
if (needle) list = list.filter(row => (row.name + " " + row.provider + " " + row.providerID).toLowerCase().includes(needle));
|
|
507
391
|
list = [...list].sort((a, b) => {
|
|
508
392
|
const mineA = prefKey(a.providerID, a.modelID) === mine;
|
|
509
393
|
const mineB = prefKey(b.providerID, b.modelID) === mine;
|
|
510
394
|
if (mineA !== mineB) return mineA ? -1 : 1;
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
}
|
|
515
|
-
return a.name.localeCompare(b.name);
|
|
395
|
+
return compare(sort(), a, b, {
|
|
396
|
+
recent: prefs.recent
|
|
397
|
+
});
|
|
516
398
|
});
|
|
517
399
|
|
|
518
400
|
// Free models never enter the price order: they are not cheap, they are unmetered, and at
|
|
519
401
|
// the top they hide everything that costs something. Grouped, each one still sits under
|
|
520
402
|
// its own provider, after that provider's paying rows — the group answers "who serves
|
|
521
403
|
// this", and a global "Free" bucket would answer it wrong. Flat, they keep one bucket.
|
|
404
|
+
// What is drawn, and in which order, is `arrange`'s business — the same function the
|
|
405
|
+
// desktop panel calls. This half only turns sections into the headers and rows a
|
|
406
|
+
// terminal list wants.
|
|
522
407
|
const items = [];
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
}
|
|
530
|
-
// Two bits: the model in use first, then the active order inside each of the two classes.
|
|
531
|
-
// It leads its group even when it is free — it is the state of the session, not a price.
|
|
532
|
-
for (const bucket of groups.values()) bucket.sort((a, b) => {
|
|
533
|
-
const mineA = prefKey(a.providerID, a.modelID) === mine;
|
|
534
|
-
const mineB = prefKey(b.providerID, b.modelID) === mine;
|
|
535
|
-
if (mineA !== mineB) return mineA ? -1 : 1;
|
|
536
|
-
return Number(free(a)) - Number(free(b));
|
|
408
|
+
for (const section of arrange(list, {
|
|
409
|
+
inUse: mine,
|
|
410
|
+
grouped: grouped()
|
|
411
|
+
})) {
|
|
412
|
+
if (section.title) items.push({
|
|
413
|
+
header: section.title
|
|
537
414
|
});
|
|
538
|
-
const
|
|
539
|
-
// The provider of the model in use comes first, like the native picker.
|
|
540
|
-
const mineProvider = list.find(row => prefKey(row.providerID, row.modelID) === mine)?.provider;
|
|
541
|
-
if (mineProvider) names.sort((a, b) => a === mineProvider ? -1 : b === mineProvider ? 1 : 0);
|
|
542
|
-
for (const name of names) {
|
|
543
|
-
items.push({
|
|
544
|
-
header: name
|
|
545
|
-
});
|
|
546
|
-
for (const row of groups.get(name)) items.push({
|
|
547
|
-
row
|
|
548
|
-
});
|
|
549
|
-
}
|
|
550
|
-
} else {
|
|
551
|
-
// The model in use leads the list, free or not: it is the state of the session, not a
|
|
552
|
-
// price to compare. The other free models keep the bucket at the end.
|
|
553
|
-
for (const row of list) if (!free(row) || prefKey(row.providerID, row.modelID) === mine) items.push({
|
|
415
|
+
for (const row of section.rows) items.push({
|
|
554
416
|
row
|
|
555
417
|
});
|
|
556
|
-
const freeRows = list.filter(row => free(row) && prefKey(row.providerID, row.modelID) !== mine);
|
|
557
|
-
if (freeRows.length) {
|
|
558
|
-
items.push({
|
|
559
|
-
header: "Free"
|
|
560
|
-
});
|
|
561
|
-
for (const row of freeRows) items.push({
|
|
562
|
-
row
|
|
563
|
-
});
|
|
564
|
-
}
|
|
565
418
|
}
|
|
566
419
|
return items;
|
|
567
420
|
});
|
|
@@ -713,10 +566,10 @@ export default Plugin.define({
|
|
|
713
566
|
trace("variant set: " + chosen + " -> " + applied);
|
|
714
567
|
}
|
|
715
568
|
function cycleSort() {
|
|
716
|
-
setSort((sort() + 1) %
|
|
569
|
+
setSort(ORDER[(ORDER.indexOf(sort()) + 1) % ORDER.length]);
|
|
717
570
|
}
|
|
718
571
|
function recentSort() {
|
|
719
|
-
setSort(
|
|
572
|
+
setSort("recent");
|
|
720
573
|
}
|
|
721
574
|
function toggleGroups() {
|
|
722
575
|
setGrouped(!grouped());
|
|
@@ -873,7 +726,7 @@ export default Plugin.define({
|
|
|
873
726
|
_$insertNode(_el$6, _el$8);
|
|
874
727
|
_$setProp(_el$6, "fg", DIM);
|
|
875
728
|
_$setProp(_el$6, "wrapMode", "none");
|
|
876
|
-
_$insert(_el$6, () => "sort: " +
|
|
729
|
+
_$insert(_el$6, () => "sort: " + ORDERS[sort()].short, _el$8);
|
|
877
730
|
_$insertNode(_el$9, _el$0);
|
|
878
731
|
_$setProp(_el$9, "fg", BASE);
|
|
879
732
|
_$setProp(_el$9, "wrapMode", "none");
|