@rizal72/oc-models-pro 0.0.0-stage → 0.1.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 CHANGED
@@ -1,3 +1,53 @@
1
- # Temporary Holding Version
1
+ # @rizal72/oc-models-pro
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A model picker for OpenCode v2 that shows what a request costs, not just a list of names.
4
+
5
+ It lists every model with its input and output price, context window, intelligence index, `tok/s`
6
+ when a source has it, the Go credit multiplier, and the effective price per request; it sorts by
7
+ what a request actually costs and switches the model on Enter.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ opencode plugin add @rizal72/oc-models-pro
13
+ ```
14
+
15
+ Or by hand, one line in `~/.config/opencode/opencode.jsonc`:
16
+
17
+ ```jsonc
18
+ "plugins": ["@rizal72/oc-models-pro"]
19
+ ```
20
+
21
+ ## Use
22
+
23
+ `/models-pro`, the `<leader>p` keybind, or the command palette — inside a session.
24
+
25
+ | key | does |
26
+ |---|---|
27
+ | `↑` `↓` | move the selection |
28
+ | `Invio` | switch to the model and close |
29
+ | `ctrl+o` | sort: effective, in, out, in+out, ctx, int, speed, recent, name |
30
+ | `ctrl+s` | scope: all → favourites → paying |
31
+ | `ctrl+g` | provider groups on/off |
32
+ | `ctrl+f` | favourite on/off |
33
+ | `ctrl+u` | clear the search |
34
+ | `esc` | clear the search, else close |
35
+
36
+ ## What it reads
37
+
38
+ - prices and context from the host's own catalogue;
39
+ - the intelligence index from `catalog.stencil.so`, cached six hours;
40
+ - the Go credit from OpenCode's Go plan page, cached one day — `opencode-go` models only;
41
+ - favourites and recent models from the app's own `~/.local/state/opencode/model.json`, so they are
42
+ the same ones the native picker keeps.
43
+
44
+ No API key is needed, and nothing leaves the machine except those two public fetches.
45
+
46
+ ## One caveat
47
+
48
+ A plugin still cannot pick the model directly, so the panel switches it the long way: it borrows the
49
+ app's own switch command and restores what it borrowed a moment later. There is an open issue upstream
50
+ ([anomalyco/opencode#57771](https://github.com/anomalyco/opencode/issues/57771)) that would let a plugin
51
+ do it in one call — when it lands, the workaround goes and nothing else changes.
52
+
53
+ Requires OpenCode v2. TypeScript, no build step, no runtime dependencies.
package/bench.ts ADDED
@@ -0,0 +1,142 @@
1
+ // oc-models-pro: intelligence per model, from the stencil.so catalog.
2
+ //
3
+ // The catalog is keyed by gateway and its ids are qualified (`alibaba/deepseek-v4.1-flash`), so the
4
+ // comparison uses the last segment — and the key is built from *our* model's display name, never its
5
+ // id. That is what makes the aliases meet: the same model is `deepseek/deepseek-flash` here and
6
+ // "DeepSeek V4.1 Flash 1017" to the eye, while stencil calls it `deepseek-v4.1-flash`. The name is
7
+ // stripped of the labels a catalogue attaches (`latest`, `contributor`, `free`, `preview`) and of
8
+ // snapshot stamps (`1017`, `0731`); the model id is the fallback when the name finds nothing.
9
+ //
10
+ // `int` is on 4612 of 9050 entries; `tps` is on none of them today (measured 2026-10-19), so the
11
+ // speed column stands empty until a source carries it. A figure that is not there is never guessed.
12
+ //
13
+ // Runnable standalone for its self-check: node plugins/oc-models-pro/bench.ts --self-check
14
+
15
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises"
16
+ import { homedir } from "node:os"
17
+ import { dirname, join } from "node:path"
18
+
19
+ export const CATALOG = "https://catalog.stencil.so/models.json"
20
+
21
+ const CACHE = join(homedir(), ".cache", "oc-models-pro", "bench.json")
22
+ const GRACE_MS = 6 * 60 * 60 * 1000
23
+
24
+ export interface Bench {
25
+ int: number
26
+ tps?: number
27
+ }
28
+
29
+ interface Cached {
30
+ fetched: number
31
+ index: Record<string, Bench>
32
+ }
33
+
34
+ /** Labels a catalogue attaches to a model that are not part of its name. */
35
+ const LABELS = "-(?:latest|contributor|free|preview|exp|thinking)$"
36
+
37
+ /**
38
+ * "DeepSeek V4.1 Flash 1017" and `deepseek-v4.1-flash` have to meet somewhere: this is the meeting
39
+ * point. Labels and snapshot stamps come off in two rounds, since one can hide the other.
40
+ */
41
+ export function benchKey(name: string): string {
42
+ let key = name
43
+ .toLowerCase()
44
+ .replace(/[^a-z0-9]+/g, "-")
45
+ .replace(/-+/g, "-")
46
+ .replace(/^-|-$/g, "")
47
+ for (let round = 0; round < 2; round++) {
48
+ key = key.replace(new RegExp(LABELS, "g"), "")
49
+ // Four-digit floor, and never below four characters left: a real model number must survive.
50
+ key = key.replace(/-\d{4,6}$/, (stamp) => (key.length - stamp.length >= 4 ? "" : stamp))
51
+ }
52
+ return key.replace(/^-|-$/g, "")
53
+ }
54
+
55
+ export function buildIndex(catalog: Record<string, any>): Map<string, Bench> {
56
+ const index = new Map<string, Bench>()
57
+ for (const provider of Object.values(catalog)) {
58
+ for (const [id, meta] of Object.entries((provider as any)?.models ?? {})) {
59
+ const int = (meta as any)?.int
60
+ if (typeof int !== "number") continue
61
+ const key = benchKey(String(id).split("/").pop() ?? "")
62
+ if (!key) continue
63
+ const tps = (meta as any)?.tps
64
+ // A key met twice keeps the first row's int but takes the first speed that exists: the same
65
+ // model on several gateways is scored once, and the score without the figure must not hide it.
66
+ const previous = index.get(key)
67
+ if (!previous) index.set(key, { int, tps: typeof tps === "number" ? tps : undefined })
68
+ else if (previous.tps === undefined && typeof tps === "number")
69
+ index.set(key, { int: previous.int, tps })
70
+ }
71
+ }
72
+ return index
73
+ }
74
+
75
+ function indexOf(cached: Cached): Map<string, Bench> {
76
+ return new Map(Object.entries(cached.index ?? {}))
77
+ }
78
+
79
+ async function cached(): Promise<Cached | undefined> {
80
+ try {
81
+ return JSON.parse(await readFile(CACHE, "utf8")) as Cached
82
+ } catch {
83
+ return undefined
84
+ }
85
+ }
86
+
87
+ /** Six hours of grace: a benchmark does not move between two coffee breaks. */
88
+ export async function benchIndex(): Promise<Map<string, Bench>> {
89
+ const previous = await cached()
90
+ if (previous && Date.now() - previous.fetched < GRACE_MS) return indexOf(previous)
91
+ try {
92
+ const response = await fetch(CATALOG, { headers: { "user-agent": "oc-models-pro" } })
93
+ if (!response.ok) throw new Error("HTTP " + response.status)
94
+ const built = buildIndex(await response.json())
95
+ if (built.size === 0) throw new Error("catalog shape changed")
96
+ await mkdir(dirname(CACHE), { recursive: true })
97
+ await writeFile(CACHE + ".tmp", JSON.stringify({ fetched: Date.now(), index: Object.fromEntries(built) }))
98
+ await rename(CACHE + ".tmp", CACHE)
99
+ return built
100
+ } catch (error) {
101
+ console.error(
102
+ `[oc-models-pro] benchmarks not refreshed: ${error instanceof Error ? error.message : String(error)}`,
103
+ )
104
+ return previous ? indexOf(previous) : new Map()
105
+ }
106
+ }
107
+
108
+ /** Name first, id second: the name carries the model, the id carries the provider's label for it. */
109
+ export function benchFor(index: Map<string, Bench>, name: string, id: string): Bench | undefined {
110
+ return index.get(benchKey(name)) ?? index.get(benchKey(id))
111
+ }
112
+
113
+ // Self-check: node plugins/oc-models-pro/bench.ts --self-check
114
+ if (process.argv[2] === "--self-check") {
115
+ const run = async () => {
116
+ const response = await fetch(CATALOG)
117
+ if (!response.ok) throw new Error("self-check: HTTP " + response.status)
118
+ const index = buildIndex(await response.json())
119
+ if (index.size < 200) throw new Error("self-check: only " + index.size + " models keyed")
120
+ const cases: Array<[string, string, number]> = [
121
+ ["DeepSeek V4.1 Flash 1017", "deepseek-flash", 39.5],
122
+ ["DeepSeek V4 Flash Latest", "deepseek-v4-flash-latest", 34.3],
123
+ ["Muse Spark 1.3 Contributor", "muse-spark-1.3-contributor", 48.1],
124
+ ["Gemini 4 Argon Contributor", "gemini-4-argon-contributor", 52.6],
125
+ ]
126
+ const misses = cases.filter(([name, id, int]) => benchFor(index, name, id)?.int !== int)
127
+ if (misses.length) {
128
+ throw new Error("self-check: " + misses.map(([name]) => name).join(", ") + " did not match")
129
+ }
130
+ // The label must not swallow a model that is only labelled: the contributor shares the
131
+ // underlying model's score, but a different model must not inherit one.
132
+ if (benchFor(index, "MiMo V2.5", "mimo-v2.5")) throw new Error("self-check: MiMo V2.5 matched something")
133
+ console.log(
134
+ "self-check ok: " + index.size + " models keyed, DeepSeek -> " +
135
+ benchFor(index, "DeepSeek V4.1 Flash 1017", "deepseek-flash")!.int,
136
+ )
137
+ }
138
+ run().catch((error) => {
139
+ console.error(error)
140
+ process.exit(1)
141
+ })
142
+ }
package/fit.ts ADDED
@@ -0,0 +1,98 @@
1
+ // oc-models-pro — how a row and the footer fit a panel of a given width, in columns.
2
+ //
3
+ // The host hands `session.panel` a `width` in columns (pixels on the very first frame, which the
4
+ // clamp absorbs). Everything that depends on it lives here, so the thresholds sit in one place:
5
+ // node plugins/oc-models-pro/fit.ts --self-check
6
+
7
+ /** The name column at its widest: 32 + the fixed 53 is the 85-character table. */
8
+ export const NAME = 32
9
+ /** ...and at its narrowest. Below this a model name is unreadable, and a column is worth more. */
10
+ export const NAME_MIN = 24
11
+ /** The cells that never go: marker 2, tag 4, in 6, out 6, cred 4 and the nine separators. */
12
+ const FIXED = 53
13
+
14
+ export type Fit = { name: number; ctx: boolean; int: boolean; tkps: boolean; note: boolean }
15
+
16
+ /** Columns dropped as the panel narrows: one with no data, then the note, then context, then int. */
17
+ const DROPS = [
18
+ { key: "tkps", width: 7 },
19
+ { key: "note", width: 7 },
20
+ { key: "ctx", width: 6 },
21
+ { key: "int", width: 6 },
22
+ ] as const
23
+
24
+ /** The width of a row in columns, for a given fit. */
25
+ export function rowWidth(f: Fit): number {
26
+ let width = f.name + FIXED
27
+ if (!f.tkps) width -= 7
28
+ if (!f.note) width -= 7
29
+ if (!f.ctx) width -= 6
30
+ if (!f.int) width -= 6
31
+ return width
32
+ }
33
+
34
+ export function fit(width: number): Fit {
35
+ // A missing width (and the pixel value of the first frame) is "unknown", not "tiny": the wide
36
+ // layout is the safe answer. Anything positive is a real column count and takes the ladder.
37
+ const w = Number.isFinite(width) && width > 0 ? Math.floor(width) : Infinity
38
+ const out: Fit = { name: NAME, ctx: true, int: true, tkps: true, note: true }
39
+ let fixed = FIXED
40
+ for (const drop of DROPS) {
41
+ // Stop as soon as the name can give the rest: it shrinks before anything else goes.
42
+ if (Math.min(NAME, w - fixed) >= NAME_MIN) break
43
+ out[drop.key] = false
44
+ fixed -= drop.width
45
+ }
46
+ out.name = Math.max(NAME_MIN, Math.min(NAME, w - fixed))
47
+ return out
48
+ }
49
+
50
+ /** The panel width below which the legend shortens rather than being cut mid-sentence. */
51
+ export const LEGEND_MIN = 72
52
+
53
+ /** The footer hints, in the order they are read. Splitting on these keeps the line data-driven. */
54
+ const HINTS = ["↑↓ enter", "ctrl+o sort", "ctrl+s scope", "ctrl+g groups", "ctrl+r recent", "ctrl+f fav", "ctrl+y full", "esc"]
55
+
56
+ /** The same hints wrapped to `width` columns: one line when they fit, and never truncated. */
57
+ export function hintLines(width: number): string[] {
58
+ const w = Number.isFinite(width) && width > 0 ? Math.floor(width) : Infinity
59
+ const lines: string[] = []
60
+ let line = ""
61
+ for (const hint of HINTS) {
62
+ const next = line ? line + " · " + hint : hint
63
+ if (line && next.length >= w) {
64
+ lines.push(" " + line)
65
+ line = hint
66
+ } else line = next
67
+ }
68
+ if (line) lines.push(" " + line)
69
+ return lines
70
+ }
71
+
72
+ // Self-check: node plugins/oc-models-pro/fit.ts --self-check
73
+ if (process.argv[2] === "--self-check") {
74
+ const check = (ok: boolean, message: string) => {
75
+ if (!ok) throw new Error("self-check: " + message)
76
+ }
77
+ const wide = fit(200)
78
+ check(wide.name === NAME && wide.tkps && wide.note && wide.ctx && wide.int, "200 columns keeps every column")
79
+ check(rowWidth(wide) === 85, "the full table is 85 characters, got " + rowWidth(wide))
80
+ check(fit(1400).name === NAME, "a pixel width on the first frame resolves to the wide layout")
81
+ check(hintLines(200).length === 1 && hintLines(200)[0].length === 103, "the footer is one 103-column line when it fits")
82
+ check(hintLines(102).length === 2 && hintLines(102).every((l) => l.length <= 102), "at 102 the footer wraps to two lines")
83
+ check(hintLines(40).every((l) => l.length <= 40), "at 40 no line exceeds the panel")
84
+ check(fit(76).name === 30 && !fit(76).tkps, "at 76 tok/s goes and the name grows into its space")
85
+ check(!fit(70).tkps && fit(70).note && fit(70).name === 24, "at 70 tok/s alone frees enough and the note stays")
86
+ check(!fit(69).note && fit(69).name === 30, "at 69 the note column goes and the name grows into its space")
87
+ check(!fit(62).ctx && fit(62).int, "at 62 context is gone and int is still there")
88
+ check(!fit(56).int && fit(56).name >= NAME_MIN, "at 56 int is the last to go")
89
+ check(fit(5).name === NAME_MIN && !fit(5).int, "a genuinely tiny width takes the narrowest rung, not the wide one")
90
+ for (let w = 51; w <= 140; w++) {
91
+ check(fit(w).name >= NAME_MIN, "name floor holds at " + w)
92
+ check(rowWidth(fit(w)) <= w, "the row fits " + w + " columns, got " + rowWidth(fit(w)))
93
+ }
94
+ // A single hint longer than the panel cannot be split; nothing may overflow beyond that.
95
+ const longest = Math.max(...HINTS.map((hint) => hint.length)) + 1
96
+ for (let w = 12; w <= 60; w++) check(hintLines(w).every((l) => l.length <= Math.max(w, longest)), "the footer fits " + w)
97
+ console.log("self-check ok: 85 wide, " + hintLines(102).length + " footer lines at 102, rows fit 51..140")
98
+ }
package/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ // oc-models-pro — server half. Empty on purpose: the model list is TUI-only.
2
+ import { Plugin } from "@opencode/plugin"
3
+
4
+ export default Plugin.define({
5
+ id: "oc-models-pro",
6
+ async setup() {},
7
+ })
package/multipliers.ts ADDED
@@ -0,0 +1,224 @@
1
+ // oc-models-pro: what the Go plan gives per model, read from the live Go docs page.
2
+ //
3
+ // The table is server-rendered — no JS, no auth — so a plain fetch and a row regex are enough. The
4
+ // mirror that would have been cleaner, the `.mdx` on GitHub, is stale by design: it carries the old
5
+ // limits (a model at $60 where the deployed page says $80), so the page is the source and the cache
6
+ // carries the date it was read.
7
+ //
8
+ // Two different figures live on that page, and confusing them was the first bug here:
9
+ // - the **credit multiplier**: monthly limit ÷ plan price (DeepSeek V4.1 Flash: $80 on a $10 plan =
10
+ // 8x). Every model has one, and it is what orders the list by effective price;
11
+ // - the **promotion note** (`4x · Limited time`): limit ÷ the struck-through base, i.e. this model's
12
+ // allowance is 4x its normal one. Only a few rows carry it.
13
+ //
14
+ // The page has no model ids, rows match by display name; a model with two tariffs appears twice
15
+ // (`… (Off-Peak)` / `… (Peak)`), and the table repeats per plan (`go`, `go-plus`) — the first
16
+ // occurrence wins, so the values are the standard plan's.
17
+ //
18
+ // Runnable standalone for its self-check: node plugins/oc-models-pro/multipliers.ts --self-check
19
+
20
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises"
21
+ import { homedir } from "node:os"
22
+ import { dirname, join } from "node:path"
23
+
24
+ export const PAGE = "https://opencode.ai/v2/docs/console/go/"
25
+
26
+ export interface GoLimit {
27
+ /** Display name as the page writes it; the only key it offers. */
28
+ name: string
29
+ input?: number
30
+ output?: number
31
+ cacheRead?: number
32
+ cacheWrite?: number
33
+ /** Monthly limit in dollars: the promoted value when the cell carries one. */
34
+ limit?: number
35
+ /** The struck-through value a promotion replaces. */
36
+ base?: number
37
+ /** The page's own `4x` note; `1` when it says nothing. */
38
+ multiplier: number
39
+ }
40
+
41
+ export interface GoPlan {
42
+ /** Plan price in dollars per month. */
43
+ price: number
44
+ limits: GoLimit[]
45
+ }
46
+
47
+ /** What one model is worth on the plan, for the row note and for the ordering. */
48
+ export interface Credits {
49
+ /** Monthly limit ÷ plan price. */
50
+ credit: number
51
+ /** limit ÷ base, when the page marks a promotion. */
52
+ promo?: number
53
+ /** Peak input price ÷ off-peak input price, for a model with two tariffs. */
54
+ peak?: number
55
+ }
56
+
57
+ const CACHE = join(homedir(), ".cache", "oc-models-pro", "go-limits.json")
58
+
59
+ function text(html: string): string {
60
+ return html
61
+ .replace(/<br\s*\/?>/gi, " ")
62
+ .replace(/<[^>]+>/g, " ")
63
+ .replace(/&nbsp;/g, " ")
64
+ .replace(/&amp;/g, "&")
65
+ .replace(/&gt;/g, ">")
66
+ .replace(/&lt;/g, "<")
67
+ .replace(/&#39;|&rsquo;/g, "'")
68
+ .replace(/&quot;/g, '"')
69
+ .replace(/\s+/g, " ")
70
+ .trim()
71
+ }
72
+
73
+ function money(html: string): number | undefined {
74
+ const plain = text(html)
75
+ // Free models print the word, not a figure.
76
+ if (/free/i.test(plain)) return 0
77
+ const found = plain.match(/\$([\d.]+)/)
78
+ return found ? Number(found[1]) : undefined
79
+ }
80
+
81
+ function multiplierOf(html: string): number {
82
+ const found = html.match(/([\d.]+)\s*[x×]/i)
83
+ return found ? Number(found[1]) : 1
84
+ }
85
+
86
+ export function parseGoTable(html: string): GoLimit[] {
87
+ const rows = html.split(/<tr[\s>]/i).slice(1)
88
+ const limits: GoLimit[] = []
89
+ const seen = new Set<string>()
90
+ for (const raw of rows) {
91
+ const row = raw.split(/<\/tr>/i)[0] ?? ""
92
+ const cells = row.match(/<td[^>]*>[\s\S]*?<\/td>/gi) ?? []
93
+ if (cells.length < 6) continue
94
+ const name = text(cells[0] ?? "")
95
+ if (!name || /^model$/i.test(name)) continue
96
+ // The table repeats per plan; the first panel is the standard one.
97
+ if (seen.has(name)) continue
98
+ seen.add(name)
99
+ const monthly = cells[5] ?? ""
100
+ const promoted = monthly.match(/<strong[^>]*>([\s\S]*?)<\/strong>/i)
101
+ const struck = monthly.match(/<del[^>]*>([\s\S]*?)<\/del>/i)
102
+ limits.push({
103
+ name,
104
+ input: money(cells[1] ?? ""),
105
+ output: money(cells[2] ?? ""),
106
+ cacheRead: money(cells[3] ?? ""),
107
+ cacheWrite: money(cells[4] ?? ""),
108
+ limit: money(promoted ? promoted[1] : monthly),
109
+ base: struck ? money(struck[1]) : undefined,
110
+ multiplier: multiplierOf(monthly),
111
+ })
112
+ }
113
+ return limits
114
+ }
115
+
116
+ /** The plan price, `$10/month` in the `Go` heading; the first such figure on the page is Go's. */
117
+ export function parsePlanPrice(html: string): number | undefined {
118
+ const found = text(html).match(/\$\s?([\d.]+)\s*\/\s*month/i)
119
+ return found ? Number(found[1]) : undefined
120
+ }
121
+
122
+ /** "… (Off-Peak)" and "… (Peak)" are one model; the tariff is not part of its name. */
123
+ export function baseName(name: string): string {
124
+ return name.replace(/\s*\((?:off-?peak|peak)\)\s*$/i, "").trim()
125
+ }
126
+
127
+ /** "GLM-5.3-Flash" and "glm-5.3-flash" have to meet somewhere: this is the meeting point. */
128
+ export function key(name: string): string {
129
+ return baseName(name)
130
+ .toLowerCase()
131
+ .replace(/[^a-z0-9]+/g, "-")
132
+ .replace(/^-+|-+$/g, "")
133
+ }
134
+
135
+ export function credits(plan: GoPlan): Map<string, Credits> {
136
+ const grouped = new Map<string, GoLimit[]>()
137
+ for (const row of plan.limits) {
138
+ const id = key(row.name)
139
+ grouped.set(id, [...(grouped.get(id) ?? []), row])
140
+ }
141
+ const result = new Map<string, Credits>()
142
+ for (const [id, rows] of grouped) {
143
+ const off = rows.find((row) => /off-?peak/i.test(row.name)) ?? rows[0]
144
+ const peak = rows.find((row) => /peak/i.test(row.name) && !/off-?peak/i.test(row.name))
145
+ if (!off?.limit || !plan.price) continue
146
+ result.set(id, {
147
+ credit: Math.round((off.limit / plan.price) * 10) / 10,
148
+ promo: off.base && off.base > 0 ? Math.round((off.limit / off.base) * 10) / 10 : undefined,
149
+ peak: peak?.input && off.input ? Math.round(((peak.input / off.input) * 10)) / 10 : undefined,
150
+ })
151
+ }
152
+ return result
153
+ }
154
+
155
+ function today(): string {
156
+ return new Date().toLocaleDateString("en-CA")
157
+ }
158
+
159
+ async function cached(): Promise<{ fetched: string; plan: GoPlan } | undefined> {
160
+ try {
161
+ return JSON.parse(await readFile(CACHE, "utf8")) as { fetched: string; plan: GoPlan }
162
+ } catch {
163
+ return undefined
164
+ }
165
+ }
166
+
167
+ /** A day of grace: a promotion does not change hourly, and a stale copy beats no column. */
168
+ export async function goPlan(): Promise<GoPlan | undefined> {
169
+ const previous = await cached()
170
+ if (previous?.fetched === today()) return previous.plan
171
+ try {
172
+ const response = await fetch(PAGE, {
173
+ headers: { "user-agent": "oc-models-pro" },
174
+ signal: AbortSignal.timeout(20_000),
175
+ })
176
+ if (!response.ok) throw new Error("HTTP " + response.status)
177
+ const html = await response.text()
178
+ const limits = parseGoTable(html)
179
+ const price = parsePlanPrice(html)
180
+ if (limits.length === 0 || !price) throw new Error("page shape changed")
181
+ const plan = { price, limits }
182
+ await mkdir(dirname(CACHE), { recursive: true })
183
+ await writeFile(CACHE + ".tmp", JSON.stringify({ fetched: today(), plan }))
184
+ await rename(CACHE + ".tmp", CACHE)
185
+ return plan
186
+ } catch (error) {
187
+ console.error(`[oc-models-pro] Go plan not refreshed: ${error instanceof Error ? error.message : String(error)}`)
188
+ return previous?.plan
189
+ }
190
+ }
191
+
192
+ // Self-check: node plugins/oc-models-pro/multipliers.ts --self-check
193
+ if (process.argv[2] === "--self-check") {
194
+ const run = async () => {
195
+ const response = await fetch(PAGE)
196
+ if (!response.ok) throw new Error("self-check: HTTP " + response.status)
197
+ const html = await response.text()
198
+ const limits = parseGoTable(html)
199
+ const price = parsePlanPrice(html)
200
+ if (limits.length < 10) throw new Error("self-check: only " + limits.length + " rows parsed")
201
+ if (!price) throw new Error("self-check: plan price not found")
202
+ if (limits.some((l) => typeof l.input !== "number" || typeof l.output !== "number")) {
203
+ throw new Error("self-check: a row lost its price")
204
+ }
205
+ if (new Set(limits.map((l) => l.name)).size !== limits.length) throw new Error("self-check: duplicate names")
206
+ const table = credits({ price, limits })
207
+ if (table.size < 10) throw new Error("self-check: only " + table.size + " models keyed")
208
+ const promoted = [...table.values()].filter((c) => c.promo)
209
+ if (promoted.length === 0) throw new Error("self-check: no promotion found")
210
+ const deepseek = table.get("deepseek-v4-1-flash") ?? table.get("deepseek-v4.1-flash")
211
+ if (!deepseek) throw new Error("self-check: DeepSeek V4.1 Flash not matched")
212
+ if (Math.round(deepseek.credit) !== 8) throw new Error("self-check: DeepSeek credit is " + deepseek.credit)
213
+ if (!deepseek.peak) throw new Error("self-check: DeepSeek peak tariff lost")
214
+ console.log(
215
+ "self-check ok: plan $" + price + "/month, " + table.size + " models, " + promoted.length +
216
+ " promoted, DeepSeek V4.1 Flash -> $" + (8 * price) + " limit = " + deepseek.credit +
217
+ "x, peak " + deepseek.peak + "x",
218
+ )
219
+ }
220
+ run().catch((error) => {
221
+ console.error(error)
222
+ process.exit(1)
223
+ })
224
+ }
package/package.json CHANGED
@@ -1,6 +1,14 @@
1
1
  {
2
- "name": "@rizal72/oc-models-pro",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@rizal72/oc-models-pro",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "OpenCode v2 model picker with prices, context, intelligence, Go credit and the cost of a request",
6
+ "license": "MIT",
7
+ "files": ["*.ts", "*.tsx", "README.md"],
8
+ "keywords": ["opencode", "opencode-plugin", "models", "pricing"],
9
+ "publishConfig": { "access": "public" },
10
+ "exports": {
11
+ ".": "./index.ts",
12
+ "./tui": "./tui.tsx"
13
+ }
14
+ }