@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 +52 -2
- package/bench.ts +142 -0
- package/fit.ts +98 -0
- package/index.ts +7 -0
- package/multipliers.ts +224 -0
- package/package.json +13 -5
- package/tui.tsx +961 -0
package/README.md
CHANGED
|
@@ -1,3 +1,53 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @rizal72/oc-models-pro
|
|
2
2
|
|
|
3
|
-
|
|
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
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(/ /g, " ")
|
|
64
|
+
.replace(/&/g, "&")
|
|
65
|
+
.replace(/>/g, ">")
|
|
66
|
+
.replace(/</g, "<")
|
|
67
|
+
.replace(/'|’/g, "'")
|
|
68
|
+
.replace(/"/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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
+
}
|