pi-sub2api 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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -0
  3. package/package.json +47 -0
  4. package/src/index.ts +166 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pacoyang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # pi-sub2api
2
+
3
+ A [pi](https://pi.dev) extension for a [sub2api](https://github.com/Wei-Shaw/sub2api) gateway.
4
+
5
+ - Fetches the model list from the gateway at startup and registers it as the `sub2api` provider.
6
+ - Adds a `/usage` command showing balance and today/total cost.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ pi install npm:pi-sub2api
12
+ ```
13
+
14
+ Or try it for a single run without installing:
15
+
16
+ ```bash
17
+ pi -e npm:pi-sub2api
18
+ ```
19
+
20
+ ## Configure
21
+
22
+ All configuration lives in `~/.pi/agent/auth.json` under the provider name `sub2api`. No shell
23
+ environment variables are needed.
24
+
25
+ ```json
26
+ {
27
+ "sub2api": {
28
+ "type": "api_key",
29
+ "key": "sk-...",
30
+ "env": { "SUB2API_BASE_URL": "https://your-sub2api-host" }
31
+ }
32
+ }
33
+ ```
34
+
35
+ ```bash
36
+ chmod 600 ~/.pi/agent/auth.json
37
+ ```
38
+
39
+ - `key` — an API key generated in the sub2api dashboard.
40
+ - `env.SUB2API_BASE_URL` — the gateway base URL, **without** a trailing `/v1`. Defaults to
41
+ `http://localhost:8080`.
42
+
43
+ pi resolves the key from `auth.json["sub2api"]` for inference; the extension reads the same entry
44
+ for its own `/v1/models` and `/v1/usage` calls. `SUB2API_KEY` / `SUB2API_BASE_URL` environment
45
+ variables are used as a fallback if the `auth.json` entry is absent.
46
+
47
+ ## Use
48
+
49
+ ```bash
50
+ pi --list-models # models appear under the sub2api provider
51
+ pi --provider sub2api --model gpt-5.5
52
+ ```
53
+
54
+ In an interactive session, `/usage` prints balance and cost:
55
+
56
+ ```
57
+ sub2api usage
58
+ balance -67.01 USD
59
+ today $0.27 · 9 req · 57,826 tok
60
+ total $184.21 · 5,965 req · 105,534,943 tok
61
+ top models (by cost)
62
+ gpt-5.5 $0.27 · 52,300 tok · 4 req
63
+ ```
64
+
65
+ ## Notes
66
+
67
+ - **API type**: the provider is registered with `openai-completions`. `openai-responses` and
68
+ `openai-codex-responses` did not work against this gateway.
69
+ - **Cost**: pi's per-token `cost` is set to `0`, because sub2api's `/v1/models` returns no pricing.
70
+ Cost comes from the gateway's own accounting via `/usage`, not from pi's estimate.
71
+ - **Model list**: embedding and image-generation models are filtered out; only chat-capable models
72
+ are registered.
73
+ - **Thinking levels**: reasoning models expose `off/low/medium/high/xhigh`. `minimal` is marked
74
+ unsupported because the upstream rejects `reasoning_effort: "minimal"`.
75
+ - **Model metadata**: `contextWindow` and `maxTokens` are set to 400000/128000 for every model,
76
+ since the gateway's model list does not report per-model limits.
77
+
78
+ ## License
79
+
80
+ MIT
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "pi-sub2api",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension for a sub2api gateway: dynamic model registration and a /usage command for balance and cost.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "pacoyang <yangbaigao@gmail.com>",
8
+ "homepage": "https://github.com/pacoyang/pi-sub2api#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/pacoyang/pi-sub2api.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/pacoyang/pi-sub2api/issues"
15
+ },
16
+ "keywords": [
17
+ "pi-package",
18
+ "pi-extension",
19
+ "sub2api"
20
+ ],
21
+ "files": [
22
+ "src",
23
+ "README.md",
24
+ "LICENSE",
25
+ "package.json"
26
+ ],
27
+ "pi": {
28
+ "extensions": [
29
+ "./src/index.ts"
30
+ ]
31
+ },
32
+ "engines": {
33
+ "node": ">=20"
34
+ },
35
+ "scripts": {
36
+ "check": "tsc --noEmit",
37
+ "pack:dry-run": "npm pack --dry-run"
38
+ },
39
+ "peerDependencies": {
40
+ "@earendil-works/pi-coding-agent": "*"
41
+ },
42
+ "devDependencies": {
43
+ "@earendil-works/pi-coding-agent": "^0.80.3",
44
+ "@types/node": "^24.0.0",
45
+ "typescript": "^5.9.0"
46
+ }
47
+ }
package/src/index.ts ADDED
@@ -0,0 +1,166 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+
6
+ /**
7
+ * pi extension for a sub2api gateway.
8
+ *
9
+ * - Fetches the model list from GET {BASE}/v1/models and registers them as the
10
+ * "sub2api" provider (openai-completions).
11
+ * - Registers a /usage command backed by GET {BASE}/v1/usage (balance and cost).
12
+ *
13
+ * Configuration lives entirely in ~/.pi/agent/auth.json under the provider name
14
+ * "sub2api" (no shell env exports needed):
15
+ *
16
+ * {
17
+ * "sub2api": {
18
+ * "type": "api_key",
19
+ * "key": "sk-...",
20
+ * "env": { "SUB2API_BASE_URL": "https://your-sub2api-host" }
21
+ * }
22
+ * }
23
+ *
24
+ * pi resolves the key from auth.json["sub2api"] for inference; this extension reads
25
+ * the same entry for its own /v1/models and /v1/usage calls.
26
+ *
27
+ * Note: only openai-completions works against this gateway; openai-responses and
28
+ * openai-codex-responses did not.
29
+ */
30
+
31
+ const DEFAULT_BASE = "http://localhost:8080";
32
+
33
+ function agentDir(): string {
34
+ const override = process.env.PI_CODING_AGENT_DIR;
35
+ return override ? override.replace(/^~(?=$|\/)/, homedir()) : join(homedir(), ".pi", "agent");
36
+ }
37
+
38
+ /** Resolve a stored value: literal, or $ENV_VAR interpolation. */
39
+ function resolveValue(raw: string | undefined, scopedEnv?: Record<string, string>): string {
40
+ if (!raw) return "";
41
+ const m = /^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$/.exec(raw);
42
+ if (m) return scopedEnv?.[m[1]] ?? process.env[m[1]] ?? "";
43
+ return raw;
44
+ }
45
+
46
+ function readSub2apiCredential(): { key: string; base: string } {
47
+ try {
48
+ const authPath = join(agentDir(), "auth.json");
49
+ const data = JSON.parse(readFileSync(authPath, "utf-8")) as Record<
50
+ string,
51
+ { type?: string; key?: string; env?: Record<string, string> }
52
+ >;
53
+ const cred = data.sub2api;
54
+ if (cred?.type === "api_key") {
55
+ return {
56
+ key: resolveValue(cred.key, cred.env),
57
+ base: (resolveValue(cred.env?.SUB2API_BASE_URL, cred.env) || DEFAULT_BASE).replace(/\/+$/, ""),
58
+ };
59
+ }
60
+ } catch {
61
+ // auth.json missing or unparseable; fall through to env/default.
62
+ }
63
+ return {
64
+ key: process.env.SUB2API_KEY ?? "",
65
+ base: (process.env.SUB2API_BASE_URL ?? DEFAULT_BASE).replace(/\/+$/, ""),
66
+ };
67
+ }
68
+
69
+ async function fetchJson(url: string, key: string, timeoutMs = 8000): Promise<any> {
70
+ const res = await fetch(url, {
71
+ headers: { Authorization: `Bearer ${key}`, Accept: "application/json" },
72
+ signal: AbortSignal.timeout(timeoutMs),
73
+ });
74
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
75
+ return res.json();
76
+ }
77
+
78
+ // Render the GET /v1/usage response as a compact multi-line report.
79
+ function formatUsage(data: any): string {
80
+ const lines = ["sub2api usage"];
81
+ const row = (label: string, rest: string) => lines.push(` ${label.padEnd(7)} ${rest}`);
82
+ const num = (v: unknown) => Number(v ?? 0);
83
+ if (data?.balance != null) {
84
+ row("balance", `${num(data.balance).toFixed(2)}${data.unit ? ` ${data.unit}` : ""}`);
85
+ }
86
+ const bucket = (label: string, b: any) => {
87
+ if (b) {
88
+ row(label, `$${num(b.cost).toFixed(2)} · ${num(b.requests).toLocaleString()} req · ${num(b.total_tokens).toLocaleString()} tok`);
89
+ }
90
+ };
91
+ bucket("today", data?.usage?.today);
92
+ bucket("total", data?.usage?.total);
93
+ const stats: any[] = Array.isArray(data?.model_stats) ? data.model_stats : [];
94
+ if (stats.length) {
95
+ lines.push(" top models (by cost)");
96
+ for (const s of [...stats].sort((a, b) => num(b.cost) - num(a.cost)).slice(0, 3)) {
97
+ lines.push(` ${s.model} $${num(s.cost).toFixed(2)} · ${num(s.total_tokens).toLocaleString()} tok · ${num(s.requests).toLocaleString()} req`);
98
+ }
99
+ }
100
+ return lines.join("\n");
101
+ }
102
+
103
+ export default async function (pi: ExtensionAPI) {
104
+ const { key, base } = readSub2apiCredential();
105
+
106
+ // Codex / gpt-5 / o-series are reasoning models; infer the flag from the id.
107
+ const isReasoning = (id: string) => /gpt-5|codex|^o[1-9]|reason|think/i.test(id);
108
+
109
+ let models: Array<{ id: string }> = [];
110
+ try {
111
+ const json = await fetchJson(`${base}/v1/models`, key);
112
+ // Keep chat-capable models only; drop embeddings and image-generation models.
113
+ models = ((json?.data as Array<{ id: string }>) ?? []).filter((m) => !/embedding|image/i.test(m.id));
114
+ } catch (err) {
115
+ console.warn(`[sub2api] models fetch failed: ${(err as Error).message}`);
116
+ }
117
+
118
+ if (models.length === 0) {
119
+ // Nothing fetched: skip registration to avoid an empty provider.
120
+ // Check the "sub2api" entry in ~/.pi/agent/auth.json and that the service is running.
121
+ console.warn("[sub2api] no models returned, skipping registration.");
122
+ return;
123
+ }
124
+
125
+ // apiKey is required by registerProvider whenever models are defined (presence is
126
+ // validated; auth.json is not consulted here), so pass the key read from auth.json.
127
+ // At request time pi still resolves auth.json["sub2api"] first.
128
+ pi.registerProvider("sub2api", {
129
+ name: "Sub2API",
130
+ baseUrl: `${base}/v1`,
131
+ api: "openai-completions",
132
+ apiKey: key,
133
+ models: models.map((m) => ({
134
+ id: m.id,
135
+ name: m.id,
136
+ reasoning: isReasoning(m.id),
137
+ // Upstream rejects reasoning_effort "minimal" (supported: none/low/medium/high/xhigh),
138
+ // so mark that level unsupported; pi hides it and clamps a request up to "low".
139
+ thinkingLevelMap: isReasoning(m.id) ? { minimal: null } : undefined,
140
+ input: ["text", "image"],
141
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
142
+ contextWindow: 400000,
143
+ maxTokens: 128000,
144
+ })),
145
+ });
146
+
147
+ console.log(`[sub2api] registered ${models.length} model(s): ${models.map((m) => m.id).join(", ")}`);
148
+
149
+ // /usage: sub2api tracks authoritative cost/balance at GET /v1/usage (same key).
150
+ pi.registerCommand("usage", {
151
+ description: "Show sub2api balance and today/total cost",
152
+ handler: async (_args, ctx) => {
153
+ if (!key) {
154
+ ctx.ui.notify("sub2api: no API key in auth.json", "error");
155
+ return;
156
+ }
157
+ ctx.ui.notify("Fetching sub2api usage…", "info");
158
+ try {
159
+ const data = await fetchJson(`${base}/v1/usage`, key);
160
+ ctx.ui.notify(formatUsage(data), "info");
161
+ } catch (e) {
162
+ ctx.ui.notify(`sub2api: usage unavailable — ${(e as Error).message}`, "warning");
163
+ }
164
+ },
165
+ });
166
+ }