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.
- package/LICENSE +21 -0
- package/README.md +80 -0
- package/package.json +47 -0
- 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
|
+
}
|