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/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
|
+

|
|
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
|
+
}
|