opencode-model-recommender 1.0.0-alpha.1

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.
Files changed (6) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +77 -0
  3. package/index.ts +407 -0
  4. package/metrics.ts +112 -0
  5. package/package.json +61 -0
  6. package/tui.tsx +194 -0
package/tui.tsx ADDED
@@ -0,0 +1,194 @@
1
+ import { Plugin, usePlugin } from "@opencode-ai/plugin/tui"
2
+ import { For, Show } from "solid-js"
3
+ import {
4
+ asArray,
5
+ availableProviders,
6
+ createCachedResource,
7
+ createCachedStore,
8
+ createViewPicker,
9
+ line,
10
+ providerLabel,
11
+ providerTitle,
12
+ resolveCurrentModel,
13
+ short,
14
+ type PickerOption,
15
+ } from "opencode-plugin-kit"
16
+ import { metrics, savings, sessionBasis, SESSION, type CostTier } from "./metrics.js"
17
+
18
+ type Row = {
19
+ providerID: string
20
+ modelID: string
21
+ name: string
22
+ cacheRatio: number | null
23
+ tokenCost: number | null
24
+ sessionCost: number | null
25
+ free: boolean
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Provider filter registry — the single extension point, mirroring the
30
+ // usage-quota plugin's VIEWS: adding a provider means appending one entry,
31
+ // and the picker, slash command, persistence, and widget renderer all derive
32
+ // from it.
33
+ // ---------------------------------------------------------------------------
34
+
35
+ interface ProviderFilter extends PickerOption {
36
+ /** Provider IDs included in this view; empty = all. */
37
+ readonly providers: string[]
38
+ }
39
+
40
+ // One filter per authenticated provider, plus "All". Built from the same
41
+ // discovery call the tool uses, so new providers appear without edits.
42
+ // Ids are the short labels (zen/go/google/zai/hf) — legacy persisted picks
43
+ // ("zen", "go") still resolve.
44
+ export function buildFilters(): ProviderFilter[] {
45
+ return [
46
+ { id: "all", title: "All", description: "Picks across all authenticated providers", providers: [] },
47
+ ...availableProviders().map((pid) => ({
48
+ id: providerLabel(pid),
49
+ title: providerTitle(pid),
50
+ description: `${providerTitle(pid)} models only`,
51
+ providers: [pid],
52
+ })),
53
+ ]
54
+ }
55
+
56
+ export default Plugin.define({
57
+ id: "model-recommender.cli",
58
+ setup(context: any) {
59
+ const picker = createViewPicker(context, {
60
+ registry: buildFilters(),
61
+ storageKey: "filter",
62
+ command: {
63
+ id: "models.view",
64
+ group: "Models",
65
+ name: "model-view",
66
+ aliases: ["models-view"],
67
+ title: (f) => `Model picks: provider filter (${f.title})`,
68
+ description: "Pick which providers the sidebar model recommendations cover",
69
+ },
70
+ dialog: { title: "Model picks", message: "Choose which providers the sidebar picks cover" },
71
+ toastPrefix: "Model picks",
72
+ })
73
+ picker.registerCommand()
74
+
75
+ // Durable cache of the catalog picks: the sidebar restores the last known
76
+ // view instantly after a TUI restart (stale-while-revalidate) instead of
77
+ // showing "loading…" until the catalog refetch completes.
78
+ type PicksData = { rows: Row[]; current: { data?: { providerID: string; modelID: string } | null } | undefined }
79
+ const picksCache = createCachedStore<PicksData | null>(context, "picks", {
80
+ initial: null,
81
+ staleAfterMs: 5 * 60_000,
82
+ })
83
+
84
+ async function loadPicks(ctx: any, sid?: string): Promise<PicksData> {
85
+ const out = await ctx.client.model.list()
86
+ const models = asArray<any>(out)
87
+ const rows: Row[] = models
88
+ .filter((m) => availableProviders().includes(m.providerID) && m.enabled !== false)
89
+ .map((m) => ({
90
+ providerID: m.providerID,
91
+ modelID: m.modelID ?? m.id,
92
+ name: m.name ?? m.modelID ?? m.id,
93
+ ...metrics(m.cost as CostTier[] | undefined),
94
+ }))
95
+ const sessionCurrent = resolveCurrentModel(ctx, sid)
96
+ const current = sessionCurrent
97
+ ? { data: sessionCurrent }
98
+ : await ctx.client.model.default().catch(() => undefined)
99
+ return { rows, current }
100
+ }
101
+
102
+ function Picks(props: { sessionID?: string }) {
103
+ const ctx = usePlugin()
104
+ const cached = createCachedResource(
105
+ () => props.sessionID,
106
+ (sid) => loadPicks(ctx, sid),
107
+ { cache: picksCache },
108
+ )
109
+ const picks = cached.data
110
+
111
+ return (
112
+ <Show when={!picks.error} fallback={<text>⚠ model picks unavailable</text>}>
113
+ <Show when={picks()} fallback={<text>model picks: loading…</text>}>
114
+ {(p) => {
115
+ // Apply the active provider filter, then derive picks.
116
+ const active = picker.current()
117
+ const scoped = active.providers.length
118
+ ? p().rows.filter((r) => active.providers.includes(r.providerID))
119
+ : p().rows
120
+ if (scoped.length === 0) {
121
+ return (
122
+ <box flexDirection="column">
123
+ <text>MODEL PICKS · {active.title.toLowerCase()}</text>
124
+ <text>no models for this provider</text>
125
+ </box>
126
+ )
127
+ }
128
+ const paid = scoped.filter((r) => !r.free)
129
+ const free = scoped.filter((r) => r.free).toSorted((a, b) => a.name.localeCompare(b.name))
130
+ // Paid rows always have numeric metrics, so the Infinity
131
+ // fallbacks below are unreachable ordering hints only.
132
+ /* v8 ignore start */
133
+ const b = {
134
+ session: paid.toSorted((a, b) => (a.sessionCost ?? Infinity) - (b.sessionCost ?? Infinity))[0],
135
+ cache: paid.toSorted((a, b) => (b.cacheRatio ?? -1) - (a.cacheRatio ?? -1))[0],
136
+ token: paid.toSorted((a, b) => (a.tokenCost ?? Infinity) - (b.tokenCost ?? Infinity))[0],
137
+ }
138
+ /* v8 ignore stop */
139
+
140
+ const cur = p().current?.data
141
+ const curScoped = cur && (active.providers.length === 0 || active.providers.includes(cur.providerID))
142
+ const currentRow = curScoped
143
+ ? scoped.find((r) => r.providerID === cur!.providerID && r.modelID === cur!.modelID)
144
+ : undefined
145
+ const save = currentRow ? savings(currentRow.sessionCost, b.session?.sessionCost) : undefined
146
+ const currentMatchesSession = !!cur && !!b.session && cur.modelID === b.session.modelID
147
+
148
+ // Dynamic column width so nothing silently truncates.
149
+ const shown = [b.session, b.cache, b.token, ...free.slice(0, 3)].filter(Boolean) as Row[]
150
+ const idWidth = Math.min(Math.max(10, ...shown.map((r) => short(r.modelID).length)), 30)
151
+
152
+ const row = (label: string, r: Row | undefined, value: string | undefined): string =>
153
+ r ? line(label, r.modelID, r.providerID, value, idWidth) : ""
154
+
155
+ const lines = [
156
+ `MODEL PICKS · ${active.title.toLowerCase()}${active.providers.length === 0 ? " (all)" : ""}`,
157
+ row("sess$", b.session, b.session ? `$${b.session.sessionCost?.toFixed(2)}` : undefined),
158
+ row("cache", b.cache, b.cache ? `${b.cache.cacheRatio?.toFixed(2)}x` : undefined),
159
+ row("token", b.token, b.token ? `$${b.token.tokenCost?.toFixed(3)}/M` : undefined),
160
+ ...free.slice(0, 3).map((r) => row("free", r, undefined)),
161
+ free.length > 3 ? ` +${free.length - 3} more free` : "",
162
+ "─".repeat(40),
163
+ // save is a number or undefined, never null, and the save line
164
+ // only renders when b.session exists — both guards are dead.
165
+ /* v8 ignore start */
166
+ save !== null && save !== undefined
167
+ ? `💡 save ~$${save.toFixed(2)}/session → ${short(b.session?.modelID ?? "", idWidth)}`
168
+ : currentMatchesSession
169
+ ? "✅ current model is the cheapest"
170
+ : "",
171
+ /* v8 ignore end */
172
+ curScoped && cur
173
+ ? `now ${short(cur.modelID, idWidth)} (${providerLabel(cur.providerID)})${currentMatchesSession ? " ✅" : ""}`
174
+ : "",
175
+ `basis ${sessionBasis(SESSION)}`,
176
+ ].filter(Boolean)
177
+
178
+ return (
179
+ <box flexDirection="column">
180
+ <For each={lines}>{(l) => <text>{l}</text>}</For>
181
+ </box>
182
+ )
183
+ }}
184
+ </Show>
185
+ </Show>
186
+ )
187
+ }
188
+
189
+ return context.ui.slot({
190
+ after: "sidebar.content",
191
+ render: ({ sessionID }: { sessionID?: string }) => <Picks sessionID={sessionID} />,
192
+ })
193
+ },
194
+ })