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.
- package/LICENSE +661 -0
- package/README.md +77 -0
- package/index.ts +407 -0
- package/metrics.ts +112 -0
- package/package.json +61 -0
- 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
|
+
})
|