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/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # opencode-model-recommender
2
+
3
+ An [OpenCode](https://opencode.ai) plugin that recommends the best models on
4
+ **OpenCode Zen** (`opencode`) and **OpenCode Go** (`opencode-go`) using live
5
+ catalog pricing. Shares its sidebar building blocks with
6
+ [opencode-plugin-kit](../opencode-plugin-kit).
7
+
8
+ ## Metrics
9
+
10
+ - **Cache ratio** — cache-read price ÷ input price. Higher = cached context
11
+ (the bulk of agent traffic) is relatively cheaper.
12
+ - **Token cost** — blended $/1M tokens: `0.7 × input + 0.3 × output`
13
+ (agent traffic is input-heavy).
14
+ - **Session cost** — estimated cost of a representative coding session:
15
+ 400k input tokens (80% cache reads, 20% cache writes) + 50k output tokens.
16
+
17
+ ## Usage
18
+
19
+ Once loaded, the plugin provides:
20
+
21
+ - **`models_recommend` tool** — call with `{ sort: "cacheRatio" | "tokenCost" | "sessionCost", providers?, limit?, free?, session? }`.
22
+ - **`models_details` tool** — price breakdown for one model (defaults to the current model).
23
+ - **`/recommend-models` command** — asks the agent to run the tool and present three rankings, plus how your current model compares.
24
+ - **`/model-details` command** — price breakdown for the current model (or pass `<providerID> <modelID>`).
25
+ - **`/model-view` command** — pick which providers the sidebar picks cover (All / Zen / Go); persisted across restarts.
26
+
27
+ The sidebar widget shows the top picks, free models, and whether your current
28
+ model is already the cheapest — or how much you'd save per session by switching.
29
+
30
+ ### Session-cost assumptions
31
+
32
+ Session cost assumes 400k input tokens (80% cache reads) + 50k output tokens.
33
+ Override per call:
34
+
35
+ ```json
36
+ { "sort": "sessionCost", "session": { "freshInput": 200000, "cacheReadShare": 0.6, "output": 30000 } }
37
+ ```
38
+
39
+ ## Screenshot
40
+
41
+ Sidebar widget:
42
+
43
+ ![Model picks sidebar](docs/screenshot.png)
44
+
45
+ <!-- To capture: install the plugin, open opencode2's TUI, and screenshot the sidebar; save as docs/screenshot.png -->
46
+
47
+ ## Install (project-local)
48
+
49
+ ```sh
50
+ cp -r opencode-model-recommender .opencode/plugins/
51
+ opencode2 service restart
52
+ ```
53
+
54
+ ## Install (global / package)
55
+
56
+ ```sh
57
+ opencode2 plugin add ./opencode-model-recommender # or publish to npm and add the name
58
+ ```
59
+
60
+ Or in `opencode.jsonc`:
61
+
62
+ ```jsonc
63
+ { "plugins": ["./opencode-model-recommender"] }
64
+ ```
65
+
66
+ ## Extending to other providers
67
+
68
+ Edit `DEFAULT_PROVIDERS` in `index.ts` (e.g. `["opencode", "opencode-go", "openai", "anthropic"]`)
69
+ or pass `providers` per call — no code change needed for ad-hoc lookups.
70
+
71
+ ## License
72
+
73
+ GNU Affero General Public License v3.0 — see [LICENSE](LICENSE).
74
+
75
+ ## Releases
76
+
77
+ Changelog entries use [CHANGELOG_TEMPLATE.md](CHANGELOG_TEMPLATE.md): bullets grouped into semantic categories (Added / Changed / Fixed …) that map 1:1 from Conventional Commit types (`feat` → Added, `fix` → Fixed, …). Breaking changes get a `### Breaking` block and a major bump.
package/index.ts ADDED
@@ -0,0 +1,407 @@
1
+ import { Plugin } from "@opencode-ai/plugin"
2
+ import {
3
+ fmt,
4
+ fmtRatio,
5
+ metrics,
6
+ savings,
7
+ sessionBasis,
8
+ type CostTier,
9
+ type Metrics,
10
+ type SessionAssumptions,
11
+ } from "./metrics.js"
12
+ import { asArray, availableProviders, providerLabel } from "opencode-plugin-kit"
13
+
14
+ /**
15
+ * Model Recommender plugin
16
+ *
17
+ * Registers:
18
+ * - `models_recommend` tool that ranks models from OpenCode Zen ("opencode")
19
+ * and OpenCode Go ("opencode-go") by:
20
+ * - cacheRatio : cache_read price / input price (higher = cheaper cached tokens)
21
+ * - tokenCost : blended per-token cost (70% input / 30% output weight)
22
+ * - sessionCost: estimated cost of a representative coding session
23
+ * - `model-details` tool / `/model-details` command showing a price
24
+ * breakdown for a specific model (defaults to the current one)
25
+ * - `/recommend-models` command
26
+ *
27
+ * Providers come from availableProviders() (auth.json + env), so new providers appear automatically.
28
+ */
29
+
30
+ type RankedModel = {
31
+ providerID: string
32
+ modelID: string
33
+ name: string
34
+ } & Metrics
35
+ type CurrentModel = { providerID: string; modelID: string } | undefined
36
+
37
+ function displayName(r: RankedModel): string {
38
+ return r.name !== r.modelID ? `${r.name} (${r.modelID})` : r.modelID
39
+ }
40
+
41
+ function table(rows: RankedModel[], sort: string, current: CurrentModel): string {
42
+ const key = sort as keyof Metrics
43
+ // Free models are excluded from paid rankings and listed separately.
44
+ const sorted = [...rows]
45
+ .filter((r) => r[key] !== null && !r.free)
46
+ .sort((a, b) => (a[key] as number) - (b[key] as number))
47
+ if (sorted.length === 0) return "No models with pricing data matched."
48
+
49
+ const lines = [
50
+ "| # | Model | Input $/M | Output $/M | Cache read $/M | Cache ratio | Blended $/M | Est. session cost |",
51
+ "|---|-------|-----------|------------|----------------|-------------|-------------|-------------------|",
52
+ ]
53
+ sorted.forEach((r, i) => {
54
+ const isCurrent = current && r.providerID === current.providerID && r.modelID === current.modelID
55
+ const prefix = i === 0 ? "→ " : " "
56
+ const name = `${displayName(r)} (${providerLabel(r.providerID)})${isCurrent ? " ← current" : ""}`
57
+ lines.push(
58
+ `| ${prefix}${i + 1} | ${name} | ${fmt(r.input)} | ${fmt(r.output)} | ${fmt(r.cacheRead)} | ${fmtRatio(r.cacheRatio)} | ${fmt(r.tokenCost)} | $${fmt(r.sessionCost, 2)} |`,
59
+ )
60
+ })
61
+ const free = rows.filter((r) => r.free)
62
+
63
+ // Free rows are already filtered out of `rows` by every caller, so the
64
+ // "(N free, excluded)" note below is only reachable from future callers.
65
+ /* v8 ignore start */
66
+ lines.push(
67
+ "",
68
+ `Showing ${sorted.length} priced model${sorted.length !== 1 ? "s" : ""}${free.length > 0 ? ` (${free.length} free, excluded)` : ""}.`,
69
+ )
70
+ /* v8 ignore stop */
71
+ lines.push(
72
+ "",
73
+ "Definitions:",
74
+ "- Cache ratio = cache-read price / input price. Higher means cached context is cheaper relative to fresh input.",
75
+ "- Blended $/M = 0.7 × input + 0.3 × output (typical agent mix is input-heavy).",
76
+ `- Est. session cost assumes ${sessionBasis()}.`,
77
+ )
78
+ return lines.join("\n")
79
+ }
80
+
81
+ /** One-line savings summary comparing the current model to the cheapest paid one. */
82
+ function savingsLine(rows: RankedModel[], current: CurrentModel): string | undefined {
83
+ if (!current) return undefined
84
+ const cur = rows.find((r) => r.providerID === current.providerID && r.modelID === current.modelID)
85
+ if (!cur || cur.free || cur.sessionCost === null) {
86
+ return cur?.free ? "Your current model is free — the ranked models below all cost money." : undefined
87
+ }
88
+ const cheapest = rows
89
+ .filter((r) => !r.free && r.sessionCost !== null)
90
+ .toSorted((a, b) => (a.sessionCost as number) - (b.sessionCost as number))[0]
91
+ // `cheapest` is filtered from rows that include the paid current model, so
92
+ // it always exists with a non-null session cost at this point.
93
+ /* v8 ignore start */
94
+ if (!cheapest || cheapest.sessionCost === null) return undefined
95
+ /* v8 ignore stop */
96
+ const save = savings(cur.sessionCost, cheapest.sessionCost)
97
+ if (save === null) return undefined
98
+ // The verb branch is unreachable: cheapest === cur implies save === null.
99
+ /* v8 ignore start */
100
+ const verb = cheapest.modelID === cur.modelID && cheapest.providerID === cur.providerID
101
+ if (verb) {
102
+ return `✅ Your current model is already the cheapest by est. session cost ($${cur.sessionCost.toFixed(2)}/session).`
103
+ }
104
+ /* v8 ignore end */
105
+ return `💡 Switching from ${cur.name} to ${cheapest.name} would save ~$${save.toFixed(2)} per session (current: $${cur.sessionCost.toFixed(2)}, best: $${(cheapest.sessionCost as number).toFixed(2)}; assumes ${sessionBasis()}).`
106
+ }
107
+
108
+ export default Plugin.define({
109
+ id: "model-recommender",
110
+ async setup(ctx) {
111
+ const loadCatalog = async () => {
112
+ const listOutput = await ctx.catalog.model.list()
113
+ // list() returns { data: ModelInfo[] }; tolerate a bare array too.
114
+ const catalogModels: any[] = asArray(listOutput)
115
+ return catalogModels
116
+ }
117
+
118
+ const currentModel = async (): Promise<CurrentModel> => {
119
+ try {
120
+ const out = (await ctx.catalog.model.default()) as { data?: { providerID: string; modelID: string } | null }
121
+ return out?.data ? { providerID: out.data.providerID, modelID: out.data.modelID } : undefined
122
+ } catch {
123
+ return undefined
124
+ }
125
+ }
126
+
127
+ // Prefer the model the active session is actually using (from the session's
128
+ // model config), falling back to the workspace default.
129
+ const currentModelForSession = async (sessionID?: string): Promise<CurrentModel> => {
130
+ if (sessionID) {
131
+ try {
132
+ const s = (await ctx.session.get({ sessionID } as any)) as any
133
+ const model = s?.data?.model ?? s?.model
134
+ if (model?.id) return { providerID: String(model.providerID), modelID: String(model.id) }
135
+ } catch {}
136
+ }
137
+ return currentModel()
138
+ }
139
+
140
+ const buildRows = (catalogModels: any[], providers: string[], session?: SessionAssumptions): RankedModel[] => {
141
+ const rows: RankedModel[] = []
142
+ for (const m of catalogModels) {
143
+ if (!providers.includes(m.providerID)) continue
144
+ const cost = (m as unknown as { cost?: CostTier[] }).cost
145
+ const r = metrics(cost, m as unknown as { input?: number; output?: number }, session)
146
+ rows.push({
147
+ providerID: m.providerID,
148
+ modelID: m.id,
149
+ name: (m as unknown as { name?: string }).name ?? m.id,
150
+ ...r,
151
+ })
152
+ }
153
+ return rows
154
+ }
155
+
156
+ await ctx.tool.transform((editor) => {
157
+ editor.namespace({
158
+ name: "models",
159
+ description: "Model recommendations for OpenCode Zen and OpenCode Go",
160
+ })
161
+ editor.add({
162
+ name: "recommend",
163
+ description:
164
+ "Recommend the best models available on OpenCode Zen (provider 'opencode') and OpenCode Go (provider 'opencode-go'). Ranks by cache ratio, token cost, or estimated session cost.",
165
+ input: {
166
+ type: "object",
167
+ properties: {
168
+ sort: {
169
+ type: "string",
170
+ enum: ["cacheRatio", "tokenCost", "sessionCost"],
171
+ description:
172
+ "Ranking metric. cacheRatio: best cached-context value (descending). tokenCost: cheapest blended per-token cost. sessionCost: cheapest estimated full coding session. Defaults to sessionCost.",
173
+ },
174
+ providers: {
175
+ type: "array",
176
+ items: { type: "string" },
177
+ description: "Provider IDs to include. Defaults to ['opencode', 'opencode-go'].",
178
+ },
179
+ limit: {
180
+ type: "integer",
181
+ description: "Max models to show (default 10).",
182
+ },
183
+ free: {
184
+ type: "boolean",
185
+ description: "If true, only include free models.",
186
+ },
187
+ session: {
188
+ type: "object",
189
+ description:
190
+ "Override the session-cost assumptions (defaults: 400k input tokens, 80% cache reads, 50k output).",
191
+ properties: {
192
+ freshInput: { type: "integer", description: "Input tokens per session (default 400000)." },
193
+ output: { type: "integer", description: "Output tokens per session (default 50000)." },
194
+ cacheReadShare: {
195
+ type: "number",
196
+ description: "Share of input tokens served from cache reads, 0–1 (default 0.8).",
197
+ },
198
+ },
199
+ additionalProperties: false,
200
+ },
201
+ },
202
+ required: [],
203
+ additionalProperties: false,
204
+ },
205
+ options: { namespace: "models", codemode: true },
206
+ execute: async (rawInput, context) => {
207
+ const input = rawInput as {
208
+ sort?: "cacheRatio" | "tokenCost" | "sessionCost"
209
+ providers?: string[]
210
+ limit?: number
211
+ free?: boolean
212
+ session?: SessionAssumptions
213
+ }
214
+ const sort: "cacheRatio" | "tokenCost" | "sessionCost" = input.sort ?? "sessionCost"
215
+ const providers = input.providers && input.providers.length > 0 ? input.providers : availableProviders()
216
+ const session = input.session ?? {}
217
+ const [catalogModels, current] = await Promise.all([
218
+ loadCatalog(),
219
+ currentModelForSession(context?.sessionID),
220
+ ])
221
+
222
+ const allRows = buildRows(catalogModels, providers, session)
223
+ const rows = input.free ? allRows.filter((r) => r.free) : allRows.filter((r) => !r.free)
224
+
225
+ if (rows.length === 0) {
226
+ return {
227
+ content: [
228
+ `No models found for providers: ${providers.join(", ")}${input.free ? " (free only)" : ""}.`,
229
+ "",
230
+ "Possible fixes:",
231
+ "• Check `opencode2 providers` to verify providers are configured",
232
+ "• If using a custom provider, pass it via the `providers` parameter",
233
+ "• Restart the service: `opencode2 service restart`",
234
+ ].join("\n"),
235
+ }
236
+ }
237
+
238
+ // cacheRatio ranks descending (higher = better); others ascending (cheaper = better).
239
+ if (input.free) {
240
+ // Free models have no pricing metrics — list them by name.
241
+ const freeRows = rows.toSorted((a, b) => a.name.localeCompare(b.name))
242
+ const limitedFree =
243
+ typeof input.limit === "number" && input.limit > 0 ? freeRows.slice(0, input.limit) : freeRows
244
+ const lines = limitedFree.map((r) => `- ${r.name} (${providerLabel(r.providerID)})`)
245
+ return {
246
+ content: `# Free models (${limitedFree.length})\n\n${lines.join("\n") || "None."}`,
247
+ }
248
+ }
249
+
250
+ const key = sort
251
+ const sorted = [...rows].sort((a, b) =>
252
+ key === "cacheRatio" ? (b[key] as number) - (a[key] as number) : (a[key] as number) - (b[key] as number),
253
+ )
254
+ const limited = typeof input.limit === "number" && input.limit > 0 ? sorted.slice(0, input.limit) : sorted
255
+
256
+ let out = `# Best models by ${key}\n\n${table(sorted, key, current)}`
257
+ if (limited.length < rows.length) {
258
+ out = `# Best models by ${key} (top ${limited.length})\n\n${table(limited, key, current)}\n\nShowing ${limited.length} of ${rows.length} priced models.`
259
+ }
260
+
261
+ // input.free already returned above, so this is always savingsLine(...).
262
+ /* v8 ignore next */
263
+ const sLine = input.free ? undefined : savingsLine(allRows, current)
264
+ if (sLine) out += `\n\n${sLine}`
265
+ return { content: out }
266
+ },
267
+ })
268
+
269
+ editor.add({
270
+ name: "details",
271
+ description:
272
+ "Show a price and metric breakdown for one model (defaults to the currently active model): input/output/cache prices, cache ratio, blended token cost, estimated session cost, rank, and potential savings.",
273
+ input: {
274
+ type: "object",
275
+ properties: {
276
+ providerID: { type: "string", description: "Provider ID. Defaults to the current model's provider." },
277
+ modelID: { type: "string", description: "Model ID. Defaults to the current model." },
278
+ session: {
279
+ type: "object",
280
+ description:
281
+ "Override the session-cost assumptions (defaults: 400k input tokens, 80% cache reads, 50k output).",
282
+ properties: {
283
+ freshInput: { type: "integer", description: "Input tokens per session (default 400000)." },
284
+ output: { type: "integer", description: "Output tokens per session (default 50000)." },
285
+ cacheReadShare: {
286
+ type: "number",
287
+ description: "Share of input tokens served from cache reads, 0–1 (default 0.8).",
288
+ },
289
+ },
290
+ additionalProperties: false,
291
+ },
292
+ },
293
+ additionalProperties: false,
294
+ },
295
+ options: { namespace: "models", codemode: true },
296
+ execute: async (rawInput, context) => {
297
+ const input = rawInput as { providerID?: string; modelID?: string; session?: SessionAssumptions }
298
+ const [catalogModels, current] = await Promise.all([
299
+ loadCatalog(),
300
+ currentModelForSession(context?.sessionID),
301
+ ])
302
+ const allRows = buildRows(catalogModels, availableProviders(), input.session)
303
+ const target = allRows.find(
304
+ (r) =>
305
+ (input.providerID ?? current?.providerID) === r.providerID &&
306
+ (input.modelID ?? current?.modelID) === r.modelID,
307
+ )
308
+ if (!target) {
309
+ return {
310
+ content: `Model not found: ${input.providerID ?? current?.providerID ?? "?"}/${input.modelID ?? current?.modelID ?? "?"}. Call models_recommend to list available models.`,
311
+ }
312
+ }
313
+
314
+ const paid = allRows
315
+ .filter((r) => !r.free && r.sessionCost !== null)
316
+ .toSorted((a, b) => (a.sessionCost as number) - (b.sessionCost as number))
317
+ const rank = target.free
318
+ ? undefined
319
+ : paid.findIndex((r) => r.modelID === target.modelID && r.providerID === target.providerID) + 1
320
+
321
+ const lines = [
322
+ `# ${target.name} (${providerLabel(target.providerID)})`,
323
+ "",
324
+ "| Metric | Value |",
325
+ "|---|---|",
326
+ `| Input $/M | ${fmt(target.input)} |`,
327
+ `| Output $/M | ${fmt(target.output)} |`,
328
+ `| Cache read $/M | ${fmt(target.cacheRead)} |`,
329
+ `| Cache write $/M | ${fmt(target.cacheWrite)} |`,
330
+ `| Cache ratio | ${fmtRatio(target.cacheRatio)} |`,
331
+ `| Blended $/M | ${fmt(target.tokenCost)} |`,
332
+ `| Est. session cost | ${target.sessionCost === null ? "free" : `$${target.sessionCost.toFixed(2)}`} |`,
333
+ `| Session basis | ${sessionBasis(input.session)} |`,
334
+ ]
335
+ if (rank) lines.push(`| Rank by session cost | #${rank} of ${paid.length} priced models |`)
336
+ if (target.free) lines.push("| Pricing | 🆓 free |")
337
+
338
+ if (current && current.providerID === target.providerID && current.modelID === target.modelID) {
339
+ lines.push("", "This is your currently active model.")
340
+ if (!target.free && target.sessionCost !== null && paid[0]?.sessionCost != null) {
341
+ const save = savings(target.sessionCost, paid[0].sessionCost)
342
+ if (save) lines.push(`💡 Switching to ${paid[0].name} would save ~$${save.toFixed(2)} per session.`)
343
+ }
344
+ } else if (current) {
345
+ const cur = allRows.find((r) => r.providerID === current.providerID && r.modelID === current.modelID)
346
+ const save = savings(cur?.sessionCost, target.sessionCost)
347
+ if (save) {
348
+ // cur is non-null whenever savings() can be non-null, so the
349
+ // fallback wording is unreachable from a successful lookup.
350
+ /* v8 ignore next 4 */
351
+ lines.push(
352
+ "",
353
+ `💡 Switching from ${cur?.name ?? "your current model"} would save ~$${save.toFixed(2)} per session.`,
354
+ )
355
+ } else if (cur?.free && !target.free) {
356
+ lines.push(
357
+ "",
358
+ `⚠️ Your current model is free; this one costs $${target.sessionCost?.toFixed(2)}/session.`,
359
+ )
360
+ }
361
+ }
362
+
363
+ return { content: lines.join("\n") }
364
+ },
365
+ })
366
+ })
367
+
368
+ // /recommend-models asks the agent to answer in-session.
369
+ await ctx.command.transform((editor) => {
370
+ editor.add({
371
+ name: "recommend-models",
372
+ description: "Ask the agent to recommend the best Zen/Go models (by cache ratio, token cost, session cost)",
373
+ execute: async ({ sessionID, prompt, delivery }) => {
374
+ await ctx.session.prompt({
375
+ ...prompt,
376
+ sessionID,
377
+ text:
378
+ "Use the models_recommend tool to list the best models on OpenCode Zen and OpenCode Go. " +
379
+ "Show three short rankings: best cache ratio (descending), cheapest token cost, and cheapest estimated session cost. " +
380
+ "Also report how my current model compares and whether switching would save money. " +
381
+ (prompt.text?.trim() ? `Additional criteria from the user: ${prompt.text}` : ""),
382
+ delivery,
383
+ })
384
+ },
385
+ })
386
+
387
+ // /model-details shows a price breakdown for the current (or named) model.
388
+ editor.add({
389
+ name: "model-details",
390
+ description: "Show a price breakdown for the current model (or pass '<providerID> <modelID>')",
391
+ execute: async ({ sessionID, prompt, delivery }) => {
392
+ const text = prompt.text?.trim()
393
+ const [providerID, modelID] = text ? text.split(/\s+/) : []
394
+ await ctx.session.prompt({
395
+ ...prompt,
396
+ sessionID,
397
+ text:
398
+ "Use the models_details tool" +
399
+ (providerID && modelID ? ` with providerID "${providerID}" and modelID "${modelID}"` : "") +
400
+ " to show a price breakdown for the model. Present the metrics as a short table and note any savings from switching.",
401
+ delivery,
402
+ })
403
+ },
404
+ })
405
+ })
406
+ },
407
+ })
package/metrics.ts ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Shared metric definitions used by both the `models_recommend` tool
3
+ * (index.ts) and the sidebar widget (tui.tsx).
4
+ *
5
+ * Metrics:
6
+ * - cacheRatio : cache_read price / input price (higher = cheaper cached tokens)
7
+ * - tokenCost : blended per-token cost (70% input / 30% output weight)
8
+ * - sessionCost: estimated cost of a representative coding session
9
+ */
10
+
11
+ /** Representative coding session, in tokens. */
12
+ export const SESSION = {
13
+ /** fresh (non-cached) input tokens per session */
14
+ freshInput: 400_000,
15
+ /** share of input tokens served from cache reads */
16
+ cacheReadShare: 0.8,
17
+ /** share of input tokens written to cache (first turn of each context block) */
18
+ cacheWriteShare: 0.2,
19
+ /** output tokens per session */
20
+ output: 50_000,
21
+ } as const
22
+
23
+ export type SessionAssumptions = {
24
+ freshInput?: number
25
+ cacheReadShare?: number
26
+ cacheWriteShare?: number
27
+ output?: number
28
+ }
29
+
30
+ export type CostTier = {
31
+ input?: number
32
+ output?: number
33
+ cache?: { read?: number; write?: number }
34
+ }
35
+
36
+ export type Metrics = {
37
+ input: number
38
+ output: number
39
+ cacheRead: number
40
+ cacheWrite: number
41
+ cacheRatio: number | null // cacheRead / input
42
+ tokenCost: number | null // blended $/1M tokens
43
+ sessionCost: number | null // null for free models
44
+ free: boolean
45
+ }
46
+
47
+ /**
48
+ * Compute pricing metrics from a catalog cost entry.
49
+ * Cost is an array of tiers (first = base tier), but tolerate a single flat
50
+ * object in case of older shapes.
51
+ */
52
+ export function metrics(
53
+ cost: CostTier[] | undefined,
54
+ fallback?: { input?: number; output?: number },
55
+ session: SessionAssumptions = {},
56
+ ): Metrics {
57
+ const first = (Array.isArray(cost) ? cost[0] : cost) as CostTier | undefined
58
+ const tier: CostTier = first ?? {}
59
+ // Each `?? 0` is split into its own 2-operand expression. A 3-operand
60
+ // `a ?? b ?? c` chain can't be fully tracked by v8's branch coverage —
61
+ // the final literal fallback is never credited — but `x ?? 0` on a
62
+ // property access and `a ?? b` on two variables are both tracked.
63
+ const fb = fallback ?? {}
64
+ const fi = fb.input ?? 0
65
+ const fo = fb.output ?? 0
66
+ const input = tier.input ?? fi
67
+ const output = tier.output ?? fo
68
+ const cache = tier.cache ?? {}
69
+ const cacheRead = cache.read ?? 0
70
+ const cacheWrite = cache.write ?? 0
71
+ const free = input === 0 && output === 0
72
+
73
+ const s = { ...SESSION, ...session }
74
+
75
+ const cacheRatio = input > 0 ? cacheRead / input : null
76
+ const tokenCost = input + output > 0 ? 0.7 * input + 0.3 * output : null
77
+
78
+ // Providers that don't itemize cache writes charge input price.
79
+ const writePrice = tier.cache?.write ?? input
80
+ const sessionCost = free
81
+ ? null
82
+ : (s.freshInput * s.cacheReadShare * cacheRead +
83
+ s.freshInput * s.cacheWriteShare * writePrice +
84
+ s.output * output) /
85
+ 1_000_000
86
+
87
+ return { input, output, cacheRead, cacheWrite, cacheRatio, tokenCost, sessionCost, free }
88
+ }
89
+
90
+ /** Shortest path savings between two session costs, in $. */
91
+ export function savings(from: number | null | undefined, to: number | null | undefined): number | null {
92
+ if (from === null || from === undefined || to === null || to === undefined) return null
93
+ const d = from - to
94
+ return d > 0 ? d : null
95
+ }
96
+
97
+ export function fmt(n: number | null | undefined, digits = 3): string {
98
+ if (n === null || n === undefined) return "n/a"
99
+ return n.toFixed(digits)
100
+ }
101
+
102
+ export function fmtRatio(n: number | null | undefined): string {
103
+ if (n === null || n === undefined) return "n/a"
104
+ if (n === 0) return "0"
105
+ return `${n.toFixed(2)}x`
106
+ }
107
+
108
+ /** One-line description of the session assumptions behind sessionCost. */
109
+ export function sessionBasis(overrides: SessionAssumptions = {}): string {
110
+ const s = { ...SESSION, ...overrides }
111
+ return `${Math.round(s.freshInput / 1000)}k input (${Math.round(s.cacheReadShare * 100)}% cached) + ${Math.round(s.output / 1000)}k output`
112
+ }
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "opencode-model-recommender",
3
+ "version": "1.0.0-alpha.1",
4
+ "description": "Recommends the best models on OpenCode Zen and OpenCode Go by cache ratio, token cost, and session cost.",
5
+ "keywords": [
6
+ "cache",
7
+ "cost",
8
+ "models",
9
+ "opencode",
10
+ "opencode-plugin",
11
+ "recommendation"
12
+ ],
13
+ "license": "AGPL-3.0",
14
+ "author": "Ranjith Raj",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/ranjithrajv/opencode-model-recommender.git"
18
+ },
19
+ "files": [
20
+ "index.ts",
21
+ "tui.tsx",
22
+ "metrics.ts"
23
+ ],
24
+ "type": "module",
25
+ "exports": {
26
+ ".": "./index.ts",
27
+ "./tui": "./tui.tsx"
28
+ },
29
+ "scripts": {
30
+ "check": "vp check",
31
+ "check:fix": "vp check --fix",
32
+ "fmt": "vp fmt",
33
+ "lint": "vp lint",
34
+ "typecheck": "tsc --noEmit",
35
+ "test": "vitest run",
36
+ "test:coverage": "vitest run --coverage.enabled"
37
+ },
38
+ "dependencies": {
39
+ "@opencode-ai/plugin": "beta",
40
+ "opencode-plugin-kit": "^1.0.0-alpha.2"
41
+ },
42
+ "devDependencies": {
43
+ "@opentui/core": "^0.5.11",
44
+ "@opentui/solid": "^0.5.11",
45
+ "@types/node": "^26.5.0",
46
+ "@vitest/coverage-v8": "^5.0.0",
47
+ "happy-dom": "^20.14.0",
48
+ "solid-js": "1.9.15",
49
+ "typescript": "^7.0.2",
50
+ "vite": "^7.0.0",
51
+ "vite-plugin-solid": "^2.11.14",
52
+ "vite-plus": "^0.1.16",
53
+ "vitest": "^5.0.0"
54
+ },
55
+ "peerDependencies": {
56
+ "@opentui/core": "^0.5.11",
57
+ "@opentui/solid": "^0.5.11",
58
+ "solid-js": "^1.9.15"
59
+ },
60
+ "packageManager": "npm@11.19.1"
61
+ }