@saifymatteo/pi-extensions 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/LICENSE +661 -0
- package/NOTICE.md +20 -0
- package/README.md +151 -2
- package/inventory.json +73 -0
- package/package.json +74 -4
- package/packages/pi-bg-bash/LICENSE +661 -0
- package/packages/pi-bg-bash/NOTICE.md +3 -0
- package/packages/pi-bg-bash/README.md +41 -0
- package/packages/pi-bg-bash/examples/settings.example.json +6 -0
- package/packages/pi-bg-bash/package.json +38 -0
- package/packages/pi-bg-bash/src/index.ts +969 -0
- package/packages/pi-bg-bash/src/lib/async-work.ts +27 -0
- package/packages/pi-bg-bash/test/bg-bash.test.mjs +34 -0
- package/packages/pi-claude-usage/LICENSE +661 -0
- package/packages/pi-claude-usage/NOTICE.md +3 -0
- package/packages/pi-claude-usage/README.md +27 -0
- package/packages/pi-claude-usage/package.json +35 -0
- package/packages/pi-claude-usage/src/index.ts +130 -0
- package/packages/pi-clipboard-image/LICENSE +661 -0
- package/packages/pi-clipboard-image/NOTICE.md +3 -0
- package/packages/pi-clipboard-image/README.md +27 -0
- package/packages/pi-clipboard-image/package.json +36 -0
- package/packages/pi-clipboard-image/src/index.ts +527 -0
- package/packages/pi-codeblock-borders/LICENSE +661 -0
- package/packages/pi-codeblock-borders/NOTICE.md +3 -0
- package/packages/pi-codeblock-borders/README.md +30 -0
- package/packages/pi-codeblock-borders/package.json +35 -0
- package/packages/pi-codeblock-borders/src/index.ts +302 -0
- package/packages/pi-codegraph/LICENSE +661 -0
- package/packages/pi-codegraph/NOTICE.md +5 -0
- package/packages/pi-codegraph/README.md +35 -0
- package/packages/pi-codegraph/examples/codegraph.example.json +4 -0
- package/packages/pi-codegraph/package.json +37 -0
- package/packages/pi-codegraph/src/index.ts +573 -0
- package/packages/pi-codegraph/test/codegraph.test.mjs +21 -0
- package/packages/pi-compact-tools/LICENSE +661 -0
- package/packages/pi-compact-tools/NOTICE.md +3 -0
- package/packages/pi-compact-tools/README.md +27 -0
- package/packages/pi-compact-tools/package.json +36 -0
- package/packages/pi-compact-tools/src/index.ts +1161 -0
- package/packages/pi-decisions/LICENSE +661 -0
- package/packages/pi-decisions/NOTICE.md +3 -0
- package/packages/pi-decisions/README.md +37 -0
- package/packages/pi-decisions/examples/service-models.example.json +27 -0
- package/packages/pi-decisions/package.json +37 -0
- package/packages/pi-decisions/src/index.ts +364 -0
- package/packages/pi-decisions/src/lib/service-models.ts +95 -0
- package/packages/pi-decisions/test/service-models.test.mjs +71 -0
- package/packages/pi-load-pi-dotenv/LICENSE +661 -0
- package/packages/pi-load-pi-dotenv/NOTICE.md +3 -0
- package/packages/pi-load-pi-dotenv/README.md +27 -0
- package/packages/pi-load-pi-dotenv/package.json +32 -0
- package/packages/pi-load-pi-dotenv/src/index.ts +66 -0
- package/packages/pi-model-go/LICENSE +661 -0
- package/packages/pi-model-go/NOTICE.md +5 -0
- package/packages/pi-model-go/README.md +33 -0
- package/packages/pi-model-go/package.json +36 -0
- package/packages/pi-model-go/src/index.ts +171 -0
- package/packages/pi-model-go/src/lib/dialog.ts +31 -0
- package/packages/pi-model-go/src/lib/model-go.ts +239 -0
- package/packages/pi-model-go/test/behavior.test.mjs +17 -0
- package/packages/pi-prestart-working/LICENSE +661 -0
- package/packages/pi-prestart-working/NOTICE.md +3 -0
- package/packages/pi-prestart-working/README.md +25 -0
- package/packages/pi-prestart-working/package.json +35 -0
- package/packages/pi-prestart-working/src/index.ts +86 -0
- package/packages/pi-prestart-working/test/behavior.test.mjs +56 -0
- package/packages/pi-root-guard/LICENSE +661 -0
- package/packages/pi-root-guard/NOTICE.md +3 -0
- package/packages/pi-root-guard/README.md +27 -0
- package/packages/pi-root-guard/package.json +32 -0
- package/packages/pi-root-guard/src/index.ts +202 -0
- package/packages/pi-session-finder/LICENSE +661 -0
- package/packages/pi-session-finder/NOTICE.md +3 -0
- package/packages/pi-session-finder/README.md +33 -0
- package/packages/pi-session-finder/package.json +36 -0
- package/packages/pi-session-finder/src/index.ts +306 -0
- package/packages/pi-session-finder/src/lib/dialog.ts +31 -0
- package/packages/pi-session-finder/test/behavior.test.mjs +18 -0
- package/packages/pi-session-id/LICENSE +661 -0
- package/packages/pi-session-id/NOTICE.md +3 -0
- package/packages/pi-session-id/README.md +31 -0
- package/packages/pi-session-id/package.json +35 -0
- package/packages/pi-session-id/src/index.ts +20 -0
- package/packages/pi-session-id/test/behavior.test.mjs +25 -0
- package/packages/pi-session-nav/LICENSE +661 -0
- package/packages/pi-session-nav/NOTICE.md +3 -0
- package/packages/pi-session-nav/README.md +32 -0
- package/packages/pi-session-nav/package.json +36 -0
- package/packages/pi-session-nav/src/index.ts +203 -0
- package/packages/pi-session-nav/src/lib/dialog.ts +31 -0
- package/packages/pi-session-nav/test/behavior.test.mjs +36 -0
- package/packages/pi-session-rename/LICENSE +661 -0
- package/packages/pi-session-rename/NOTICE.md +3 -0
- package/packages/pi-session-rename/README.md +31 -0
- package/packages/pi-session-rename/package.json +35 -0
- package/packages/pi-session-rename/src/index.ts +456 -0
- package/packages/pi-session-rename/test/behavior.test.mjs +10 -0
- package/packages/pi-session-retention/LICENSE +661 -0
- package/packages/pi-session-retention/NOTICE.md +3 -0
- package/packages/pi-session-retention/README.md +62 -0
- package/packages/pi-session-retention/package.json +36 -0
- package/packages/pi-session-retention/src/README.md +42 -0
- package/packages/pi-session-retention/src/core.ts +333 -0
- package/packages/pi-session-retention/src/dialog.ts +118 -0
- package/packages/pi-session-retention/src/index.ts +311 -0
- package/packages/pi-session-retention/src/lib/dialog.ts +31 -0
- package/packages/pi-session-retention/src/safety.ts +50 -0
- package/packages/pi-session-retention/test/behavior.test.mjs +42 -0
- package/packages/pi-skill-usage-log/LICENSE +661 -0
- package/packages/pi-skill-usage-log/NOTICE.md +3 -0
- package/packages/pi-skill-usage-log/README.md +32 -0
- package/packages/pi-skill-usage-log/package.json +36 -0
- package/packages/pi-skill-usage-log/src/index.ts +316 -0
- package/packages/pi-skill-usage-log/src/lib/dialog.ts +31 -0
- package/packages/pi-skill-usage-log/src/lib/usage-log.ts +62 -0
- package/packages/pi-skill-usage-log/test/behavior.test.mjs +34 -0
- package/packages/pi-tool-usage-log/LICENSE +661 -0
- package/packages/pi-tool-usage-log/NOTICE.md +5 -0
- package/packages/pi-tool-usage-log/README.md +32 -0
- package/packages/pi-tool-usage-log/package.json +36 -0
- package/packages/pi-tool-usage-log/src/index.ts +233 -0
- package/packages/pi-tool-usage-log/src/lib/dialog.ts +31 -0
- package/packages/pi-tool-usage-log/src/lib/usage-log.ts +62 -0
- package/packages/pi-tool-usage-log/src/skill-usage-log.ts +316 -0
- package/packages/pi-tool-usage-log/test/behavior.test.mjs +23 -0
- package/packages/pi-tts/LICENSE +661 -0
- package/packages/pi-tts/NOTICE.md +3 -0
- package/packages/pi-tts/README.md +37 -0
- package/packages/pi-tts/examples/service-models.example.json +27 -0
- package/packages/pi-tts/package.json +37 -0
- package/packages/pi-tts/src/index.ts +135 -0
- package/packages/pi-tts/src/lib/service-models.ts +95 -0
- package/packages/pi-tts/test/tts-output.test.mjs +47 -0
- package/packages/pi-tts/test/tts.test.mjs +57 -0
- package/packages/pi-user-image-previews/LICENSE +661 -0
- package/packages/pi-user-image-previews/NOTICE.md +3 -0
- package/packages/pi-user-image-previews/README.md +27 -0
- package/packages/pi-user-image-previews/package.json +36 -0
- package/packages/pi-user-image-previews/src/index.ts +168 -0
- package/packages/pi-vision/LICENSE +661 -0
- package/packages/pi-vision/NOTICE.md +5 -0
- package/packages/pi-vision/README.md +43 -0
- package/packages/pi-vision/examples/vision-toolkit.example.json +12 -0
- package/packages/pi-vision/package.json +38 -0
- package/packages/pi-vision/skills/vision-tools/LICENSE +21 -0
- package/packages/pi-vision/skills/vision-tools/SKILL.md +134 -0
- package/packages/pi-vision/skills/vision-tools/agents/openai.yaml +4 -0
- package/packages/pi-vision/skills/vision-tools/bin/crop +94 -0
- package/packages/pi-vision/skills/vision-tools/bin/detect +18 -0
- package/packages/pi-vision/skills/vision-tools/bin/glance +98 -0
- package/packages/pi-vision/skills/vision-tools/bin/ground +18 -0
- package/packages/pi-vision/skills/vision-tools/bin/trace +134 -0
- package/packages/pi-vision/skills/vision-tools/bin/vision-doctor +168 -0
- package/packages/pi-vision/skills/vision-tools/detect.py +56 -0
- package/packages/pi-vision/skills/vision-tools/ground.py +216 -0
- package/packages/pi-vision/skills/vision-tools/references/gui.md +93 -0
- package/packages/pi-vision/skills/vision-tools/references/long-screenshot-ocr.md +103 -0
- package/packages/pi-vision/skills/vision-tools/references/restore-graphic.md +92 -0
- package/packages/pi-vision/skills/vision-tools/references/restore-structure.md +47 -0
- package/packages/pi-vision/skills/vision-tools/references/restore-ui.md +200 -0
- package/packages/pi-vision/skills/vision-tools/scripts/dominant_colors.py +224 -0
- package/packages/pi-vision/skills/vision-tools/scripts/extract_fg.py +278 -0
- package/packages/pi-vision/skills/vision-tools/scripts/html_shot.py +111 -0
- package/packages/pi-vision/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
- package/packages/pi-vision/skills/vision-tools/scripts/pixel_diff.py +88 -0
- package/packages/pi-vision/skills/vision-tools/vision_client.py +581 -0
- package/packages/pi-vision/src/index.ts +728 -0
- package/packages/pi-vision/test/vision.test.mjs +30 -0
- package/packages/pi-wait/LICENSE +661 -0
- package/packages/pi-wait/NOTICE.md +3 -0
- package/packages/pi-wait/README.md +37 -0
- package/packages/pi-wait/package.json +37 -0
- package/packages/pi-wait/src/index.ts +88 -0
- package/packages/pi-wait/src/lib/async-work.ts +27 -0
- package/packages/pi-wait/test/wait.test.mjs +43 -0
- package/packages/pi-zvec/LICENSE +661 -0
- package/packages/pi-zvec/NOTICE.md +3 -0
- package/packages/pi-zvec/README.md +37 -0
- package/packages/pi-zvec/examples/zvec.example.json +5 -0
- package/packages/pi-zvec/package.json +37 -0
- package/packages/pi-zvec/src/index.ts +754 -0
- package/packages/pi-zvec/test/zvec.test.mjs +19 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# pi-model-go
|
|
2
|
+
|
|
3
|
+
Cost-aware model picker showing registry prices/context, OpenCode Go credit multipliers and stencil.so intelligence scores. Source describes it as a port of rizal72/oc-models-pro.
|
|
4
|
+
|
|
5
|
+
## Running locally
|
|
6
|
+
|
|
7
|
+
From the monorepo root:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pi -e ./packages/pi-model-go
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
**Warning:** this extension uses your existing Pi resources and settings unless you load it in an isolated home (see the root README). Do not load two copies of the same extension (for example an older standalone file alongside this package): commands and hooks can duplicate. The package never installs or relocates personal settings.
|
|
14
|
+
|
|
15
|
+
## Commands and shortcuts
|
|
16
|
+
|
|
17
|
+
- `/model-go [query]` — opens the picker; Enter switches the selected model.
|
|
18
|
+
- Up/Down or PageUp/PageDown to move; Ctrl+O cycles sort; Ctrl+S cycles Go/with-key/all scopes; Ctrl+U clears search.
|
|
19
|
+
- Escape clears a nonempty search, then closes; Ctrl+C closes immediately.
|
|
20
|
+
|
|
21
|
+
## Settings and storage
|
|
22
|
+
|
|
23
|
+
Uses Pi's model registry and existing credentials (`OPENCODE_API_KEY` or `/login` for OpenCode Go). Caches data in `<agent-dir>/state/model-go/{go-plan,bench}.json`; `<agent-dir>` is `PI_CODING_AGENT_DIR` or `~/.pi/agent`. Go data is cached for one day, benchmark data for six hours; stale data can be used with warnings.
|
|
24
|
+
|
|
25
|
+
## Privacy and operations
|
|
26
|
+
|
|
27
|
+
On command, fetches `https://opencode.ai/v2/docs/console/go/` and `https://catalog.stencil.so/models.json` with 20-second request timeouts and writes cache files. These fetches request public data, not conversation content. Picking changes the active model; later prompts may have different cost/provider privacy. No model completion is requested by the picker.
|
|
28
|
+
|
|
29
|
+
## Compatibility
|
|
30
|
+
|
|
31
|
+
Node.js >=24 for development checks; run extensions through Pi's TypeScript loader. Host imports are wildcard peers, not bundled runtime copies. Interactive TUI only; other modes are told to use `/model`. Remote HTML/catalog formats and intelligence/price matching may change; figures are estimates, not billing guarantees. No token-per-second metric is supplied. Source helper retains its optional offline/live self-check; no live check is run by tests.
|
|
32
|
+
|
|
33
|
+
See [NOTICE.md](NOTICE.md) for provenance. Tests use the monorepo's TypeScript dev dependency and VM loader; they do not contact services or write to user-home resources.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@saifymatteo/pi-model-go",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "AGPL-3.0-only",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package"
|
|
8
|
+
],
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"README.md",
|
|
12
|
+
"NOTICE.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"pi": {
|
|
16
|
+
"extensions": [
|
|
17
|
+
"./src/index.ts"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
22
|
+
"@earendil-works/pi-tui": "*"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=24"
|
|
26
|
+
},
|
|
27
|
+
"description": "/model-go: cost-aware model picker with Go-plan pricing and intelligence scores.",
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/saifymatteo/pi-extensions.git",
|
|
34
|
+
"directory": "packages/pi-model-go"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// /model-go: a cost-aware alternative to the built-in /model, ported from rizal72/oc-models-pro.
|
|
2
|
+
// Rows come from Pi's own model registry (prices, context); the Go plan credit multiplier and the
|
|
3
|
+
// stencil.so intelligence score are decoration (lib/model-go.ts). Enter switches via pi.setModel.
|
|
4
|
+
// tok/s is not ported: Pi session entries carry no first/last streamed-token timestamps.
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { matchesKey, truncateToWidth, visibleWidth, type Component, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
7
|
+
import { DIALOG_OVERLAY, dialogRows, wheelAsArrows } from "./lib/dialog.ts";
|
|
8
|
+
import { GO_PROVIDER, SORTS, compare, effective, loadBench, loadGoCredits, matches, toRow, type Row, type SortKey } from "./lib/model-go.ts";
|
|
9
|
+
|
|
10
|
+
type Scope = "go" | "ready" | "all";
|
|
11
|
+
const SCOPES: Scope[] = ["go", "ready", "all"];
|
|
12
|
+
const SCOPE_LABEL: Record<Scope, string> = { go: "OpenCode Go", ready: "with key", all: "all" };
|
|
13
|
+
const SORT_LABEL: Record<SortKey, string> = { effective: "effective ↑", input: "input ↑", output: "output ↑", context: "context ↓", int: "intelligence ↓", name: "name" };
|
|
14
|
+
const PAD_X = 2; // spaces between the border and the content
|
|
15
|
+
const GAP = 2; // spaces between columns
|
|
16
|
+
// Explicit bright white, bold: headers stand out on any dark theme.
|
|
17
|
+
const header = (s: string) => `\x1b[1m\x1b[97m${s}\x1b[39m\x1b[22m`;
|
|
18
|
+
|
|
19
|
+
const price = (n?: number) => (n === undefined ? "—" : n === 0 ? "free" : n < 10 ? n.toFixed(2) : n.toFixed(1));
|
|
20
|
+
const ctxText = (n?: number) => (n === undefined ? "—" : n >= 1e6 ? `${+(n / 1e6).toFixed(1)}M` : `${Math.round(n / 1e3)}K`);
|
|
21
|
+
const pad = (s: string, w: number, right = false) => {
|
|
22
|
+
const t = truncateToWidth(s, w, "…");
|
|
23
|
+
const gap = " ".repeat(Math.max(0, w - visibleWidth(t)));
|
|
24
|
+
return right ? gap + t : t + gap;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
class Picker implements Component {
|
|
28
|
+
private query = "";
|
|
29
|
+
private scope: Scope;
|
|
30
|
+
private sort: SortKey = "effective";
|
|
31
|
+
private selected = 0;
|
|
32
|
+
private offset = 0;
|
|
33
|
+
private pageSize = 10;
|
|
34
|
+
|
|
35
|
+
private tui: any;
|
|
36
|
+
private theme: any;
|
|
37
|
+
private all: Row[];
|
|
38
|
+
private current: string | undefined;
|
|
39
|
+
private notes: string[];
|
|
40
|
+
private done: (row: Row | null) => void;
|
|
41
|
+
|
|
42
|
+
constructor(tui: any, theme: any, all: Row[], current: string | undefined, notes: string[], done: (row: Row | null) => void, query: string) {
|
|
43
|
+
this.tui = tui;
|
|
44
|
+
this.theme = theme;
|
|
45
|
+
this.all = all;
|
|
46
|
+
this.current = current;
|
|
47
|
+
this.notes = notes;
|
|
48
|
+
this.done = done;
|
|
49
|
+
this.query = query;
|
|
50
|
+
this.scope = all.some((r) => r.provider === GO_PROVIDER) ? "go" : "all";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private list(): Row[] {
|
|
54
|
+
return this.all
|
|
55
|
+
.filter((r) => (this.scope === "go" ? r.provider === GO_PROVIDER : this.scope === "ready" ? r.hasAuth : true))
|
|
56
|
+
.filter((r) => matches(r, this.query))
|
|
57
|
+
.sort((a, b) => compare(this.sort, a, b));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
render(width: number): string[] {
|
|
61
|
+
const t = this.theme;
|
|
62
|
+
const border = (s: string) => t.fg("border", s);
|
|
63
|
+
const inner = Math.max(20, width - 2);
|
|
64
|
+
const body = inner - 2 * PAD_X;
|
|
65
|
+
const line = (s: string) => border("┃") + " ".repeat(PAD_X) + pad(s, body) + " ".repeat(PAD_X) + border("┃");
|
|
66
|
+
const rows = this.list();
|
|
67
|
+
// Everything except the table rows: 2 borders, 4 blank padding lines, search, header, detail, hint, notes.
|
|
68
|
+
const chrome = 10 + (this.notes.length ? 1 : 0);
|
|
69
|
+
this.pageSize = dialogRows(this.tui, chrome, 5);
|
|
70
|
+
this.selected = Math.min(this.selected, Math.max(0, rows.length - 1));
|
|
71
|
+
this.offset = Math.max(0, Math.min(this.offset, this.selected, rows.length - this.pageSize));
|
|
72
|
+
if (this.selected >= this.offset + this.pageSize) this.offset = this.selected - this.pageSize + 1;
|
|
73
|
+
|
|
74
|
+
// Fixed numeric columns; the name takes what is left and the widest-but-least-useful columns drop first.
|
|
75
|
+
const cols = [
|
|
76
|
+
{ key: "EFFECTIVE", w: 9, get: (r: Row) => price(effective(r)) },
|
|
77
|
+
{ key: "INPUT", w: 6, get: (r: Row) => price(r.input) },
|
|
78
|
+
{ key: "OUTPUT", w: 6, get: (r: Row) => price(r.output) },
|
|
79
|
+
{ key: "CONTEXT", w: 7, get: (r: Row) => ctxText(r.context) },
|
|
80
|
+
{ key: "INTELLIGENCE", w: 12, get: (r: Row) => (r.int === undefined ? "—" : r.int.toFixed(0)) },
|
|
81
|
+
{ key: "CREDIT", w: 9, get: (r: Row) => (r.provider !== GO_PROVIDER ? "" : `${r.credit}x${r.promo ? "↑" : ""}${r.peak ? " pk" : ""}`) },
|
|
82
|
+
];
|
|
83
|
+
const nameMin = 22;
|
|
84
|
+
const dropOrder = ["OUTPUT", "CREDIT", "INPUT", "CONTEXT", "INTELLIGENCE"];
|
|
85
|
+
let shown = cols;
|
|
86
|
+
const used = () => shown.reduce((s, c) => s + c.w + GAP, 0);
|
|
87
|
+
for (const k of dropOrder) if (body - 2 - used() < nameMin) shown = shown.filter((c) => c.key !== k);
|
|
88
|
+
const nameW = Math.max(8, body - 2 - used());
|
|
89
|
+
const fmt = (mark: string, name: string, cells: string[]) => mark + " " + pad(name, nameW) + cells.map((c, i) => " ".repeat(GAP) + pad(c, shown[i].w, true)).join("");
|
|
90
|
+
|
|
91
|
+
const title = ` /model-go · ${SCOPE_LABEL[this.scope]} · sorted by ${SORT_LABEL[this.sort]} · ${rows.length} models `;
|
|
92
|
+
const out = [border(`┏${pad(title, inner).replace(/ +$/, (s) => "━".repeat(s.length))}┓`)];
|
|
93
|
+
out.push(line(""));
|
|
94
|
+
out.push(line(t.fg("warning", `Search: ${this.query}▏`)));
|
|
95
|
+
out.push(line(""));
|
|
96
|
+
out.push(line(header(fmt(" ", "MODEL", shown.map((c) => c.key)))));
|
|
97
|
+
const page = rows.slice(this.offset, this.offset + this.pageSize);
|
|
98
|
+
for (let i = 0; i < this.pageSize; i++) {
|
|
99
|
+
const r = page[i];
|
|
100
|
+
if (!r) { out.push(line("")); continue; }
|
|
101
|
+
const id = `${r.provider}/${r.id}`;
|
|
102
|
+
const isSel = this.offset + i === this.selected;
|
|
103
|
+
const isCur = id === this.current;
|
|
104
|
+
const label = r.provider === GO_PROVIDER && this.scope === "go" ? r.id : id;
|
|
105
|
+
const text = fmt(isSel ? "▶" : isCur ? "●" : " ", label, shown.map((c) => c.get(r)));
|
|
106
|
+
out.push(line(isSel ? t.fg("accent", text) : !r.hasAuth ? t.fg("dim", text) : isCur ? t.fg("success", text) : t.fg("text", text)));
|
|
107
|
+
}
|
|
108
|
+
const sel = rows[this.selected];
|
|
109
|
+
out.push(line(""));
|
|
110
|
+
const detail = sel && sel.provider === GO_PROVIDER
|
|
111
|
+
? `${sel.name}: ${sel.credit}x plan credit${sel.promo ? ` (promo ${sel.promo}x)` : ""}${sel.peak ? `, peak price ${sel.peak}x` : ""}${sel.hasAuth ? "" : " · set OPENCODE_API_KEY to use"}`
|
|
112
|
+
: sel ? `${sel.name}${sel.hasAuth ? "" : " · no API key configured"}` : "no match";
|
|
113
|
+
out.push(line(t.fg("muted", detail)));
|
|
114
|
+
if (this.notes.length) out.push(line(t.fg("warning", "⚠ " + this.notes.join(" · "))));
|
|
115
|
+
out.push(line(t.fg("dim", "↑↓ move · Enter switch · ^O sort · ^S scope · ^U clear · Esc close · $/M tokens, effective = input ÷ credit")));
|
|
116
|
+
out.push(line(""));
|
|
117
|
+
out.push(border(`┗${"━".repeat(inner)}┛`));
|
|
118
|
+
return out.map((l) => truncateToWidth(l, width));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
handleMouse(event: TuiMouseEvent) { return wheelAsArrows(event, (key) => this.handleInput(key)); }
|
|
122
|
+
|
|
123
|
+
handleInput(data: string): void {
|
|
124
|
+
const rows = this.list();
|
|
125
|
+
if (matchesKey(data, "escape")) {
|
|
126
|
+
if (!this.query) return this.done(null);
|
|
127
|
+
this.query = "";
|
|
128
|
+
} else if (matchesKey(data, "ctrl+c")) return this.done(null);
|
|
129
|
+
else if (matchesKey(data, "enter")) return rows[this.selected] ? this.done(rows[this.selected]) : undefined;
|
|
130
|
+
else if (matchesKey(data, "up")) this.selected = Math.max(0, this.selected - 1);
|
|
131
|
+
else if (matchesKey(data, "down")) this.selected = Math.min(rows.length - 1, this.selected + 1);
|
|
132
|
+
else if (matchesKey(data, "pageUp")) this.selected = Math.max(0, this.selected - this.pageSize);
|
|
133
|
+
else if (matchesKey(data, "pageDown")) this.selected = Math.min(rows.length - 1, this.selected + this.pageSize);
|
|
134
|
+
else if (matchesKey(data, "ctrl+o")) this.sort = SORTS[(SORTS.indexOf(this.sort) + 1) % SORTS.length];
|
|
135
|
+
else if (matchesKey(data, "ctrl+s")) { this.scope = SCOPES[(SCOPES.indexOf(this.scope) + 1) % SCOPES.length]; this.selected = 0; }
|
|
136
|
+
else if (matchesKey(data, "ctrl+u")) { this.query = ""; this.selected = 0; }
|
|
137
|
+
else if (matchesKey(data, "backspace")) { this.query = this.query.slice(0, -1); this.selected = 0; }
|
|
138
|
+
else if (!/[\x00-\x1f\x7f]/.test(data)) { this.query += data; this.selected = 0; }
|
|
139
|
+
this.tui.requestRender();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
invalidate(): void {}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export default function (pi: ExtensionAPI): void {
|
|
146
|
+
pi.registerCommand("model-go", {
|
|
147
|
+
description: "Model picker with price, context, intelligence and OpenCode Go plan credits",
|
|
148
|
+
handler: async (args, ctx) => {
|
|
149
|
+
if (ctx.mode !== "tui") return ctx.ui.notify("/model-go needs the interactive TUI; use /model", "warning");
|
|
150
|
+
const [go, bench] = await Promise.all([loadGoCredits(), loadBench()]);
|
|
151
|
+
const registry = ctx.modelRegistry;
|
|
152
|
+
const rows = registry.getAll()
|
|
153
|
+
.filter((m: any) => !m.type || m.type === "chat")
|
|
154
|
+
.map((m) => toRow(m, registry.hasConfiguredAuth(m), go.credits, bench.bench));
|
|
155
|
+
const notes = [go.error, bench.error].filter((e): e is string => !!e);
|
|
156
|
+
if (!rows.some((r) => r.provider === GO_PROVIDER))
|
|
157
|
+
notes.unshift("no opencode-go models in the registry: set OPENCODE_API_KEY (or /login) to list them");
|
|
158
|
+
const current = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
|
|
159
|
+
const picked = await ctx.ui.custom<Row | null>(
|
|
160
|
+
(tui, theme, _kb, done) => new Picker(tui, theme, rows, current, notes, done, (args ?? "").trim()),
|
|
161
|
+
DIALOG_OVERLAY,
|
|
162
|
+
);
|
|
163
|
+
if (!picked) return;
|
|
164
|
+
const model = registry.find(picked.provider, picked.id);
|
|
165
|
+
if (!model) return ctx.ui.notify(`Model ${picked.provider}/${picked.id} not found`, "error");
|
|
166
|
+
if (!(await pi.setModel(model)))
|
|
167
|
+
return ctx.ui.notify(`No API key for ${picked.provider}${picked.provider === GO_PROVIDER ? ": set OPENCODE_API_KEY or /login" : ""}`, "warning");
|
|
168
|
+
ctx.ui.notify(`Model: ${picked.provider}/${picked.id}`, "info");
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// One size for every overlay dialog in this package. Change DIALOG_HEIGHT here and every dialog follows:
|
|
2
|
+
// the overlay is capped at that share of the terminal and each dialog pads its list to fill it.
|
|
3
|
+
|
|
4
|
+
/** Share of the terminal height every dialog fills. */
|
|
5
|
+
export const DIALOG_HEIGHT = 0.8
|
|
6
|
+
|
|
7
|
+
/** `ctx.ui.custom(factory, DIALOG_OVERLAY)`: centered, 92% wide, DIALOG_HEIGHT tall. */
|
|
8
|
+
export const DIALOG_OVERLAY = {
|
|
9
|
+
overlay: true,
|
|
10
|
+
overlayOptions: { anchor: 'center', width: '92%', maxHeight: `${DIALOG_HEIGHT * 100}%`, margin: 1 },
|
|
11
|
+
} as const
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Scrolling rows that fit once `chrome` fixed lines (borders, search, header, footer) are drawn.
|
|
15
|
+
* Same floor as the overlay's own percentage, so dialog and overlay agree; read it in render() so a
|
|
16
|
+
* resize takes effect on the next frame.
|
|
17
|
+
*/
|
|
18
|
+
export const dialogRows = (tui: { terminal?: { rows: number } }, chrome: number, min = 1): number =>
|
|
19
|
+
Math.max(min, Math.floor((tui.terminal?.rows ?? 24) * DIALOG_HEIGHT) - chrome)
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Mouse wheel → ↑/↓ key presses, so a dialog reuses its own keyboard handling (clamping, render).
|
|
23
|
+
* Use as `handleMouse(e) { return wheelAsArrows(e, (k) => this.handleInput(k)) }`. Fullscreen mode only:
|
|
24
|
+
* regular mode leaves the mouse to the terminal. Handling the wheel also stops it scrolling the transcript behind.
|
|
25
|
+
*/
|
|
26
|
+
export function wheelAsArrows(event: { type: string; wheelDelta?: number }, handleInput: (data: string) => void): { handled: true } | undefined {
|
|
27
|
+
if (event.type !== 'wheel' || !event.wheelDelta) return undefined
|
|
28
|
+
const key = event.wheelDelta < 0 ? '\x1b[A' : '\x1b[B'
|
|
29
|
+
for (let i = 0; i < Math.min(Math.abs(event.wheelDelta), 20); i++) handleInput(key)
|
|
30
|
+
return { handled: true }
|
|
31
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// Data half of /model-go (port of rizal72/oc-models-pro): OpenCode Go plan credits and
|
|
2
|
+
// stencil.so intelligence, matched onto Pi's own model catalogue. No Pi imports, so it runs bare:
|
|
3
|
+
// node pi/extensions/lib/model-go.ts --self-check (offline: parsing, matching, sorting)
|
|
4
|
+
// node pi/extensions/lib/model-go.ts --self-check --live (plus both live fetches)
|
|
5
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
6
|
+
import { homedir } from 'node:os'
|
|
7
|
+
import { dirname, join } from 'node:path'
|
|
8
|
+
|
|
9
|
+
export const GO_PROVIDER = 'opencode-go'
|
|
10
|
+
export const GO_PAGE = 'https://opencode.ai/v2/docs/console/go/'
|
|
11
|
+
export const CATALOG = 'https://catalog.stencil.so/models.json'
|
|
12
|
+
const CACHE_DIR = join(process.env.PI_CODING_AGENT_DIR || join(homedir(), '.pi', 'agent'), 'state', 'model-go')
|
|
13
|
+
const DAY_MS = 86_400_000
|
|
14
|
+
const SIX_HOURS_MS = 6 * 60 * 60 * 1000
|
|
15
|
+
|
|
16
|
+
// ---------- Go plan page ----------
|
|
17
|
+
|
|
18
|
+
export interface GoLimit { name: string; input?: number; limit?: number; base?: number }
|
|
19
|
+
export interface GoPlan { price: number; limits: GoLimit[] }
|
|
20
|
+
/** credit = monthly limit ÷ plan price; promo = limit ÷ struck-through base; peak = peak ÷ off-peak input. */
|
|
21
|
+
export interface Credits { credit: number; promo?: number; peak?: number }
|
|
22
|
+
|
|
23
|
+
const text = (html: string) => html.replace(/<br\s*\/?>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/ /g, ' ')
|
|
24
|
+
.replace(/&/g, '&').replace(/'|’/g, "'").replace(/"/g, '"').replace(/\s+/g, ' ').trim()
|
|
25
|
+
const money = (html: string): number | undefined => {
|
|
26
|
+
const plain = text(html)
|
|
27
|
+
if (/free/i.test(plain)) return 0
|
|
28
|
+
const found = plain.match(/\$([\d.]+)/)
|
|
29
|
+
return found ? Number(found[1]) : undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Server-rendered table: model | input | output | cache read | cache write | monthly limit. Repeats per plan; first wins. */
|
|
33
|
+
export function parseGoTable(html: string): GoLimit[] {
|
|
34
|
+
const limits: GoLimit[] = []
|
|
35
|
+
const seen = new Set<string>()
|
|
36
|
+
for (const raw of html.split(/<tr[\s>]/i).slice(1)) {
|
|
37
|
+
const cells = raw.split(/<\/tr>/i)[0].match(/<td[^>]*>[\s\S]*?<\/td>/gi) ?? []
|
|
38
|
+
if (cells.length < 6) continue
|
|
39
|
+
const name = text(cells[0])
|
|
40
|
+
if (!name || /^model$/i.test(name) || seen.has(name)) continue
|
|
41
|
+
seen.add(name)
|
|
42
|
+
const monthly = cells[5]
|
|
43
|
+
const promoted = monthly.match(/<strong[^>]*>([\s\S]*?)<\/strong>/i)
|
|
44
|
+
const struck = monthly.match(/<del[^>]*>([\s\S]*?)<\/del>/i)
|
|
45
|
+
limits.push({ name, input: money(cells[1]), limit: money(promoted ? promoted[1] : monthly), base: struck ? money(struck[1]) : undefined })
|
|
46
|
+
}
|
|
47
|
+
return limits
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const parsePlanPrice = (html: string): number | undefined => {
|
|
51
|
+
const found = text(html).match(/\$\s?([\d.]+)\s*\/\s*month/i)
|
|
52
|
+
return found ? Number(found[1]) : undefined
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** "GLM-5.3-Flash", "glm-5.3-flash" and "DeepSeek V4.1 Flash (Peak)" meet here. */
|
|
56
|
+
export const goKey = (name: string) => name.replace(/\s*\((?:off-?peak|peak)\)\s*$/i, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
|
57
|
+
|
|
58
|
+
export function credits(plan: GoPlan): Map<string, Credits> {
|
|
59
|
+
const grouped = new Map<string, GoLimit[]>()
|
|
60
|
+
for (const row of plan.limits) grouped.set(goKey(row.name), [...(grouped.get(goKey(row.name)) ?? []), row])
|
|
61
|
+
const out = new Map<string, Credits>()
|
|
62
|
+
const r1 = (n: number) => Math.round(n * 10) / 10
|
|
63
|
+
for (const [id, rows] of grouped) {
|
|
64
|
+
const off = rows.find((r) => /off-?peak/i.test(r.name)) ?? rows[0]
|
|
65
|
+
const peak = rows.find((r) => /\(peak\)/i.test(r.name))
|
|
66
|
+
if (!off.limit || !plan.price) continue
|
|
67
|
+
out.set(id, {
|
|
68
|
+
credit: r1(off.limit / plan.price),
|
|
69
|
+
...(off.base ? { promo: r1(off.limit / off.base) } : {}),
|
|
70
|
+
...(peak?.input && off.input ? { peak: r1(peak.input / off.input) } : {}),
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
return out
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------- stencil.so intelligence ----------
|
|
77
|
+
|
|
78
|
+
/** "DeepSeek V4.1 Flash 1017" and `deepseek-v4.1-flash` meet here: labels and snapshot stamps come off. */
|
|
79
|
+
export function benchKey(name: string): string {
|
|
80
|
+
let key = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
|
81
|
+
for (let round = 0; round < 2; round++) {
|
|
82
|
+
key = key.replace(/-(?:latest|contributor|free|preview|exp|thinking)$/g, '')
|
|
83
|
+
key = key.replace(/-\d{4,6}$/, (stamp) => (key.length - stamp.length >= 4 ? '' : stamp))
|
|
84
|
+
}
|
|
85
|
+
return key.replace(/^-|-$/g, '')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function buildBenchIndex(catalog: Record<string, any>): Map<string, number> {
|
|
89
|
+
const index = new Map<string, number>()
|
|
90
|
+
for (const provider of Object.values(catalog ?? {}))
|
|
91
|
+
for (const [id, meta] of Object.entries((provider as any)?.models ?? {})) {
|
|
92
|
+
const int = (meta as any)?.int
|
|
93
|
+
const key = benchKey(String(id).split('/').pop() ?? '')
|
|
94
|
+
if (typeof int === 'number' && key && !index.has(key)) index.set(key, int)
|
|
95
|
+
}
|
|
96
|
+
return index
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------- cached fetch ----------
|
|
100
|
+
|
|
101
|
+
async function cachedFetch<T>(file: string, maxAgeMs: number, load: () => Promise<T>): Promise<{ value?: T; error?: string }> {
|
|
102
|
+
const path = join(CACHE_DIR, file)
|
|
103
|
+
let previous: { fetched: number; value: T } | undefined
|
|
104
|
+
try { previous = JSON.parse(await readFile(path, 'utf8')) } catch {}
|
|
105
|
+
if (previous && Date.now() - previous.fetched < maxAgeMs) return { value: previous.value }
|
|
106
|
+
try {
|
|
107
|
+
const value = await load()
|
|
108
|
+
await mkdir(dirname(path), { recursive: true })
|
|
109
|
+
await writeFile(path + '.tmp', JSON.stringify({ fetched: Date.now(), value }))
|
|
110
|
+
await rename(path + '.tmp', path)
|
|
111
|
+
return { value }
|
|
112
|
+
} catch (error) {
|
|
113
|
+
// A stale copy beats an empty column; the error is still reported.
|
|
114
|
+
return { value: previous?.value, error: error instanceof Error ? error.message : String(error) }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const get = async (url: string) => {
|
|
119
|
+
const response = await fetch(url, { headers: { 'user-agent': 'pi-model-go' }, signal: AbortSignal.timeout(20_000) })
|
|
120
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
121
|
+
return response
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function loadGoCredits(): Promise<{ credits?: Map<string, Credits>; error?: string }> {
|
|
125
|
+
const { value, error } = await cachedFetch<GoPlan>('go-plan.json', DAY_MS, async () => {
|
|
126
|
+
const html = await (await get(GO_PAGE)).text()
|
|
127
|
+
const plan = { price: parsePlanPrice(html) ?? 0, limits: parseGoTable(html) }
|
|
128
|
+
if (!plan.price || !plan.limits.length) throw new Error('Go page shape changed')
|
|
129
|
+
return plan
|
|
130
|
+
})
|
|
131
|
+
return { credits: value ? credits(value) : undefined, error: error && `Go plan: ${error}` }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function loadBench(): Promise<{ bench?: Map<string, number>; error?: string }> {
|
|
135
|
+
const { value, error } = await cachedFetch<Record<string, number>>('bench.json', SIX_HOURS_MS, async () => {
|
|
136
|
+
const index = buildBenchIndex(await (await get(CATALOG)).json())
|
|
137
|
+
if (!index.size) throw new Error('catalog shape changed')
|
|
138
|
+
return Object.fromEntries(index)
|
|
139
|
+
})
|
|
140
|
+
return { bench: value ? new Map(Object.entries(value)) : undefined, error: error && `stencil: ${error}` }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------- rows ----------
|
|
144
|
+
|
|
145
|
+
export interface Row {
|
|
146
|
+
provider: string
|
|
147
|
+
id: string
|
|
148
|
+
name: string
|
|
149
|
+
/** $/M tokens; undefined = unknown, not free. */
|
|
150
|
+
input?: number
|
|
151
|
+
output?: number
|
|
152
|
+
context?: number
|
|
153
|
+
int?: number
|
|
154
|
+
/** Go plan multiplier; 1 off the Go plan. */
|
|
155
|
+
credit: number
|
|
156
|
+
promo?: number
|
|
157
|
+
peak?: number
|
|
158
|
+
hasAuth: boolean
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function toRow(model: { provider: string; id: string; name?: string; cost?: { input?: number; output?: number }; contextWindow?: number },
|
|
162
|
+
hasAuth: boolean, goCredits?: Map<string, Credits>, bench?: Map<string, number>): Row {
|
|
163
|
+
const name = model.name || model.id
|
|
164
|
+
const plan = model.provider === GO_PROVIDER ? (goCredits?.get(goKey(name)) ?? goCredits?.get(goKey(model.id))) : undefined
|
|
165
|
+
const int = bench?.get(benchKey(name)) ?? bench?.get(benchKey(model.id))
|
|
166
|
+
const num = (n: unknown) => (typeof n === 'number' && Number.isFinite(n) ? n : undefined)
|
|
167
|
+
return {
|
|
168
|
+
provider: model.provider, id: model.id, name,
|
|
169
|
+
input: num(model.cost?.input), output: num(model.cost?.output), context: num(model.contextWindow),
|
|
170
|
+
...(int !== undefined ? { int } : {}),
|
|
171
|
+
credit: plan?.credit ?? 1,
|
|
172
|
+
...(plan?.promo ? { promo: plan.promo } : {}),
|
|
173
|
+
...(plan?.peak ? { peak: plan.peak } : {}),
|
|
174
|
+
hasAuth,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** What the Go plan makes a request cost: list input price ÷ credit multiplier. */
|
|
179
|
+
export const effective = (row: Row) => (row.input === undefined ? undefined : row.input / (row.credit || 1))
|
|
180
|
+
|
|
181
|
+
export type SortKey = 'effective' | 'input' | 'output' | 'context' | 'int' | 'name'
|
|
182
|
+
export const SORTS: SortKey[] = ['effective', 'input', 'output', 'context', 'int', 'name']
|
|
183
|
+
const VALUE: Record<Exclude<SortKey, 'name'>, [(r: Row) => number | undefined, 1 | -1]> = {
|
|
184
|
+
effective: [effective, 1],
|
|
185
|
+
input: [(r) => r.input, 1],
|
|
186
|
+
output: [(r) => r.output, 1],
|
|
187
|
+
context: [(r) => r.context, -1],
|
|
188
|
+
int: [(r) => r.int, -1],
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Unknown figures sort last in either direction; ties fall back to name so the list never reshuffles. */
|
|
192
|
+
export function compare(key: SortKey, a: Row, b: Row): number {
|
|
193
|
+
const byName = a.name.localeCompare(b.name) || a.provider.localeCompare(b.provider)
|
|
194
|
+
if (key === 'name') return byName
|
|
195
|
+
const [value, sign] = VALUE[key]
|
|
196
|
+
const left = value(a), right = value(b)
|
|
197
|
+
if (left === undefined || right === undefined) return left === right ? byName : left === undefined ? 1 : -1
|
|
198
|
+
return left === right ? byName : (left < right ? -1 : 1) * sign
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export const matches = (row: Row, query: string) =>
|
|
202
|
+
query.toLowerCase().split(/\s+/).filter(Boolean).every((word) => `${row.provider}/${row.id} ${row.name}`.toLowerCase().includes(word))
|
|
203
|
+
|
|
204
|
+
// ---------- self-check ----------
|
|
205
|
+
|
|
206
|
+
if (process.argv[2] === '--self-check') {
|
|
207
|
+
const check = (ok: boolean, message: string) => { if (!ok) throw new Error('self-check: ' + message) }
|
|
208
|
+
const html = `<h2>Go $10/month</h2><table><tr><th>Model</th></tr>
|
|
209
|
+
<tr><td>DeepSeek V4.1 Flash (Off-Peak)</td><td>$0.15</td><td>$0.60</td><td>$0.01</td><td>-</td><td><del>$20</del> <strong>$80</strong> 4x</td></tr>
|
|
210
|
+
<tr><td>DeepSeek V4.1 Flash (Peak)</td><td>$0.30</td><td>$1.20</td><td>$0.02</td><td>-</td><td>$80</td></tr>
|
|
211
|
+
<tr><td>GLM-5.3</td><td>$1.40</td><td>$4.40</td><td>$0.26</td><td>-</td><td>$15</td></tr>
|
|
212
|
+
<tr><td>GLM-5.3</td><td>$9</td><td>$9</td><td>$9</td><td>-</td><td>$99</td></tr></table>`
|
|
213
|
+
const plan = { price: parsePlanPrice(html)!, limits: parseGoTable(html) }
|
|
214
|
+
check(plan.price === 10, 'plan price')
|
|
215
|
+
check(plan.limits.length === 3, 'repeated plan table: first occurrence wins')
|
|
216
|
+
const table = credits(plan)
|
|
217
|
+
const ds = table.get('deepseek-v4-1-flash')
|
|
218
|
+
check(ds?.credit === 8 && ds.promo === 4 && ds.peak === 2, 'DeepSeek credit/promo/peak: ' + JSON.stringify(ds))
|
|
219
|
+
check(table.get('glm-5-3')?.credit === 1.5, 'GLM credit')
|
|
220
|
+
check(benchKey('DeepSeek V4.1 Flash 1017') === 'deepseek-v4-1-flash', 'bench key strips stamp')
|
|
221
|
+
check(benchKey('Muse Spark 1.3 Contributor') === benchKey('muse-spark-1.3'), 'bench key strips label')
|
|
222
|
+
const bench = buildBenchIndex({ a: { models: { 'x/deepseek-v4.1-flash': { int: 40 } } } })
|
|
223
|
+
const go = toRow({ provider: GO_PROVIDER, id: 'deepseek-v4.1-flash', name: 'DeepSeek V4.1 Flash', cost: { input: 0.15, output: 0.6 }, contextWindow: 1e6 }, false, table, bench)
|
|
224
|
+
check(go.credit === 8 && go.int === 40 && effective(go) === 0.15 / 8, 'Go row: ' + JSON.stringify(go))
|
|
225
|
+
const other = toRow({ provider: 'deepseek', id: 'deepseek-v4.1-flash', cost: { input: 0.15 } }, true, table, bench)
|
|
226
|
+
check(other.credit === 1, 'credits apply to opencode-go only')
|
|
227
|
+
const unknown = toRow({ provider: 'p', id: 'mystery' }, true)
|
|
228
|
+
const sorted = (k: SortKey) => [unknown, other, go].sort((a, b) => compare(k, a, b))
|
|
229
|
+
check(sorted('effective')[0] === go && sorted('effective')[2] === unknown, 'effective: cheapest first, unknown last')
|
|
230
|
+
check(sorted('int')[2] === unknown, 'int: missing score last')
|
|
231
|
+
check(matches(go, 'opencode-go flash') && !matches(go, 'glm'), 'search')
|
|
232
|
+
if (process.argv[3] !== '--live') console.log('self-check ok (offline)')
|
|
233
|
+
else {
|
|
234
|
+
const [g, b] = await Promise.all([loadGoCredits(), loadBench()])
|
|
235
|
+
check(!!g.credits && g.credits.size >= 10, 'live Go plan: ' + (g.error ?? g.credits?.size))
|
|
236
|
+
check(!!b.bench && b.bench.size >= 20, 'live stencil: ' + (b.error ?? b.bench?.size))
|
|
237
|
+
console.log(`self-check ok (live): ${g.credits!.size} Go models credited, ${b.bench!.size} benchmarked`)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { credits, parseGoTable, parsePlanPrice, toRow, effective, compare, matches, GO_PROVIDER } from '../src/lib/model-go.ts';
|
|
4
|
+
test('Go multipliers apply only to Go models and unknown prices sort last', () => {
|
|
5
|
+
const html = '<h2>$10/month</h2><table><tr><td>Example</td><td>$2</td><td>$3</td><td>0</td><td>0</td><td><del>$20</del><strong>$80</strong></td></tr></table>';
|
|
6
|
+
const data = credits({ price: parsePlanPrice(html), limits: parseGoTable(html) });
|
|
7
|
+
const go = toRow({ provider: GO_PROVIDER, id: 'example', cost: { input: 2 } }, true, data);
|
|
8
|
+
const other = toRow({ provider: 'other', id: 'example', cost: { input: 2 } }, true, data);
|
|
9
|
+
const unknown = toRow({ provider: 'other', id: 'unknown' }, false);
|
|
10
|
+
assert.equal(go.credit, 8);
|
|
11
|
+
assert.equal(go.promo, 4);
|
|
12
|
+
assert.equal(other.credit, 1);
|
|
13
|
+
assert.equal(effective(go), 0.25);
|
|
14
|
+
assert.equal([unknown, other, go].sort((a, b) => compare('effective', a, b))[0], go);
|
|
15
|
+
assert.ok(compare('input', unknown, other) > 0);
|
|
16
|
+
assert.ok(matches(go, 'opencode-go EXAMPLE'));
|
|
17
|
+
});
|