pi-zengate-models 1.0.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ ## [1.0.1] - 2026-11-04
4
+
5
+ - Remove the built-in gateway URL. Every machine sets `OPENCODE2API_BASE_URL`
6
+ or `baseUrl` in `<agent-dir>/zengate.json`.
7
+ - Warn once per session when no endpoint is configured, instead of guessing.
8
+ - Add a test that fails if the extension source gains a URL or a host.
9
+
10
+ ## [1.0.0] - 2026-11-04
11
+
12
+ - First release. Reads the ZenGate `/v1/models` catalog at startup and
13
+ registers the free models as Pi provider `opencode2api`.
14
+ - Keeps limits, input modes, zero cost, reasoning flag and thinking level map
15
+ from the gateway metadata.
16
+ - Sends `reasoning_effort` for every registered model, so the level you pick
17
+ reaches the gateway.
18
+ - Caches the catalog in `<agent-dir>/cache/zengate-models.json` and falls back
19
+ to it when the gateway is unreachable.
20
+ - Adds `/zengate` and `/zengate refresh`.
21
+ - Reads settings from the environment or `<agent-dir>/zengate.json`.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hamza (ham-zax)
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,112 @@
1
+ # pi-zengate-models
2
+
3
+ Pi provider extension for a ZenGate / opencode2api gateway. It reads the
4
+ gateway's `/v1/models` catalog, registers the free models as a Pi provider, and
5
+ keeps the metadata current on every start. No scheduler, no generated
6
+ `models.json`, no Python, and the same file on Linux, macOS and Windows.
7
+
8
+ This package ships no endpoint and no key. Each machine supplies both.
9
+
10
+ ## What you get
11
+
12
+ - Model list, context and output limits, input modes, zero cost and reasoning
13
+ levels come from the gateway catalog, which reads models.dev.
14
+ - Only free models reach Pi. The gateway marks paid models excluded, and this
15
+ extension also skips any model whose input, output, cache read or cache write
16
+ cost is not exactly `0`.
17
+ - The thinking level map follows the levels each model declares. Pi shows only
18
+ those levels in `/thinking` and sends only those values.
19
+ - A cached catalog keeps `pi --list-models` honest when the gateway is offline or
20
+ slow. A startup fetch has a 5 second cap.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pi install npm:pi-zengate-models
26
+ ```
27
+
28
+ From git, or from a local checkout:
29
+
30
+ ```bash
31
+ pi install git:github.com/ham-zax/pi-zengate@v1.0.1
32
+ pi install /path/to/pi-zengate
33
+ ```
34
+
35
+ ## Configure
36
+
37
+ Every machine needs its own gateway URL and key. Set them in the environment:
38
+
39
+ ```bash
40
+ export OPENCODE2API_BASE_URL="https://your-gateway.example/v1"
41
+ export OPENCODE2API_API_KEY="..." # Linux, macOS, WSL
42
+ setx OPENCODE2API_BASE_URL "https://your-gateway.example/v1" # Windows
43
+ setx OPENCODE2API_API_KEY "..." # Windows
44
+ ```
45
+
46
+ or in `<agent-dir>/zengate.json`, which avoids environment variables:
47
+
48
+ ```json
49
+ {
50
+ "baseUrl": "https://your-gateway.example/v1",
51
+ "apiKey": "..."
52
+ }
53
+ ```
54
+
55
+ `<agent-dir>` is `~/.pi/agent` by default, or `PI_CODING_AGENT_DIR` when set.
56
+ The environment wins over the file. Without a URL the extension registers no
57
+ provider and shows one warning per session.
58
+
59
+ ## Verify
60
+
61
+ ```bash
62
+ pi --list-models # opencode2api rows with limits and thinking=yes
63
+ pi
64
+ /zengate # count, base URL, key source, last catalog check
65
+ /zengate refresh # fetch the catalog now
66
+ ```
67
+
68
+ Then set the default model in `<agent-dir>/settings.json`:
69
+
70
+ ```json
71
+ {
72
+ "defaultProvider": "opencode2api",
73
+ "defaultModel": "deepseek-v4.1-flash-free"
74
+ }
75
+ ```
76
+
77
+ ## Remove the old generated provider
78
+
79
+ If `models.json` also defines `providers.opencode2api`, those static entries win
80
+ over the extension by model ID, so stale limits and a stale compat block return.
81
+ Delete that provider block, or leave `models.json` as:
82
+
83
+ ```json
84
+ { "providers": {} }
85
+ ```
86
+
87
+ ## Choosing a thinking level
88
+
89
+ Run `/thinking`, or pass `--thinking <level>` on the command line. Pi clamps a
90
+ level the model does not declare, which means `max` on a model without `max`
91
+ sends the highest level that model does declare. A model that declares no levels
92
+ sends nothing beyond `high`.
93
+
94
+ The compat flag `supportsReasoningEffort` is `true` for every model this provider
95
+ registers. When it is `false`, Pi drops the selected level before the request and
96
+ the model always runs at its own default.
97
+
98
+ ## Files it writes
99
+
100
+ - Cache: `<agent-dir>/cache/zengate-models.json`
101
+ - Nothing else. The extension never edits `models.json` or `settings.json`.
102
+
103
+ ## Develop
104
+
105
+ ```bash
106
+ npm test # mapping and settings tests, no network
107
+ pi -e ./extensions/zengate-models.ts # load from source
108
+ ```
109
+
110
+ ## License
111
+
112
+ MIT
@@ -0,0 +1,281 @@
1
+ /**
2
+ * ZenGate (opencode2api) model discovery for Pi.
3
+ *
4
+ * The gateway's /v1/models catalog is the authority for which free models
5
+ * exist and what they accept, cost and support. This extension registers the
6
+ * provider, reads that catalog at startup, and caches it, so every OS gets
7
+ * current models and metadata with no scheduled job and no generated
8
+ * models.json.
9
+ *
10
+ * The mapping mirrors scripts/sync-client-models.py in the gateway repository:
11
+ * `metadata` carries the models.dev values, non-free or non-text models are
12
+ * skipped, and Pi keeps only text and image inputs.
13
+ */
14
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { dirname, join } from "node:path";
18
+
19
+ export const PROVIDER_ID = "opencode2api";
20
+ export const PROVIDER_NAME = "ZenGate";
21
+ export const KEY_ENV = "OPENCODE2API_API_KEY";
22
+ export const BASE_URL_ENV = "OPENCODE2API_BASE_URL";
23
+
24
+ const FETCH_TIMEOUT_MS = 5000;
25
+ const LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
26
+
27
+ type Inputs = ("text" | "image")[];
28
+ export type PiModel = {
29
+ id: string;
30
+ name: string;
31
+ api: "openai-completions" | "openai-responses";
32
+ reasoning: boolean;
33
+ input: Inputs;
34
+ contextWindow: number;
35
+ maxTokens: number;
36
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
37
+ thinkingLevelMap?: Record<string, string | null>;
38
+ compat: {
39
+ supportsStore: boolean;
40
+ supportsDeveloperRole: boolean;
41
+ supportsReasoningEffort: boolean;
42
+ supportsUsageInStreaming: boolean;
43
+ };
44
+ };
45
+
46
+ type Meta = {
47
+ name?: unknown;
48
+ limit?: { context?: unknown; output?: unknown };
49
+ modalities?: { input?: unknown; output?: unknown };
50
+ reasoning?: unknown;
51
+ cost?: { input?: unknown; output?: unknown; cache_read?: unknown; cache_write?: unknown };
52
+ reasoning_options?: unknown;
53
+ };
54
+ type CatalogRecord = { id?: unknown; endpoint?: unknown; metadata?: Meta };
55
+
56
+ /**
57
+ * Pi sends reasoning_effort only when this is true. Every model in the current
58
+ * free catalog accepts the field, and the thinking level map decides which
59
+ * values Pi may send.
60
+ */
61
+ const COMPAT = {
62
+ supportsStore: false,
63
+ supportsDeveloperRole: false,
64
+ supportsReasoningEffort: true,
65
+ supportsUsageInStreaming: true,
66
+ };
67
+
68
+ const isText = (value: unknown): value is string => typeof value === "string" && value.length > 0;
69
+ const isCount = (value: unknown): value is number => Number.isInteger(value) && (value as number) > 0;
70
+ const isFree = (value: unknown): value is number => typeof value === "number" && value === 0;
71
+
72
+ /** Upstream effort levels for one model; empty when it has no graded control. */
73
+ export function effortValues(meta: Meta): string[] {
74
+ if (!Array.isArray(meta.reasoning_options)) return [];
75
+ for (const option of meta.reasoning_options) {
76
+ if (!option || typeof option !== "object") continue;
77
+ const { type, values } = option as { type?: unknown; values?: unknown };
78
+ if (type === "effort" && Array.isArray(values) && values.length > 0 && values.every(isText)) {
79
+ return [...values];
80
+ }
81
+ }
82
+ return [];
83
+ }
84
+
85
+ /**
86
+ * A supported level sends its own name. A level the model does not declare maps
87
+ * to null, so Pi hides it and never sends it.
88
+ */
89
+ export function thinkingLevelMap(values: string[]): Record<string, string | null> {
90
+ const map: Record<string, string | null> = {};
91
+ for (const level of LEVELS) {
92
+ if (level === "off") map[level] = values.includes("none") ? "none" : null;
93
+ else map[level] = values.includes(level) ? level : null;
94
+ }
95
+ return map;
96
+ }
97
+
98
+ /** Map one gateway catalog payload to Pi model configs. */
99
+ export function toPiModels(payload: unknown): PiModel[] {
100
+ const data = (payload as { data?: unknown } | null)?.data;
101
+ if (!Array.isArray(data)) throw new Error("gateway catalog has no data array");
102
+
103
+ const models: PiModel[] = [];
104
+ for (const record of data as CatalogRecord[]) {
105
+ const meta = record?.metadata;
106
+ if (!meta || !isText(record.id) || !isText(meta.name)) continue;
107
+ const contextWindow = meta.limit?.context;
108
+ const maxTokens = meta.limit?.output;
109
+ if (!isCount(contextWindow) || !isCount(maxTokens)) continue;
110
+ const outputs = meta.modalities?.output;
111
+ if (!Array.isArray(outputs) || !outputs.includes("text")) continue;
112
+ const cost = meta.cost;
113
+ if (!cost || !isFree(cost.input) || !isFree(cost.output)) continue;
114
+ if (!isFree(cost.cache_read ?? 0) || !isFree(cost.cache_write ?? 0)) continue;
115
+ const declared = Array.isArray(meta.modalities?.input) ? meta.modalities.input : [];
116
+ const input = ["text", "image"].filter((mode) => declared.includes(mode)) as Inputs;
117
+ if (input.length === 0) continue;
118
+ const efforts = effortValues(meta);
119
+ models.push({
120
+ id: record.id,
121
+ name: meta.name,
122
+ api: record.endpoint === "responses" ? "openai-responses" : "openai-completions",
123
+ reasoning: meta.reasoning === true,
124
+ input,
125
+ contextWindow,
126
+ maxTokens,
127
+ cost: {
128
+ input: cost.input,
129
+ output: cost.output,
130
+ cacheRead: cost.cache_read ?? 0,
131
+ cacheWrite: cost.cache_write ?? 0,
132
+ },
133
+ ...(efforts.length > 0 ? { thinkingLevelMap: thinkingLevelMap(efforts) } : {}),
134
+ compat: COMPAT,
135
+ });
136
+ }
137
+ if (models.length === 0) throw new Error("gateway catalog has no usable free models");
138
+ return models;
139
+ }
140
+
141
+ /** Pi config directory, honouring PI_CODING_AGENT_DIR. */
142
+ export function agentDir(): string {
143
+ return process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
144
+ }
145
+
146
+ export function configPath(): string {
147
+ return join(agentDir(), "zengate.json");
148
+ }
149
+
150
+ export function cachePath(): string {
151
+ return join(agentDir(), "cache", "zengate-models.json");
152
+ }
153
+
154
+ function readJson(path: string): Record<string, unknown> | undefined {
155
+ try {
156
+ const value = JSON.parse(readFileSync(path, "utf8")) as unknown;
157
+ return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
158
+ } catch {
159
+ return undefined;
160
+ }
161
+ }
162
+
163
+ function normalizeBaseUrl(value: string): string {
164
+ return value.trim().replace(/\/+$/, "");
165
+ }
166
+
167
+ /**
168
+ * Settings precedence: environment, then <agent-dir>/zengate.json.
169
+ * The config file lets a machine skip environment variables. This package ships
170
+ * no endpoint: every machine supplies its own.
171
+ */
172
+ export function readSettings(): { baseUrl?: string; apiKey?: string; keySource: string } {
173
+ const file = readJson(configPath()) ?? {};
174
+ const fileBase = typeof file.baseUrl === "string" && file.baseUrl.trim() ? file.baseUrl : undefined;
175
+ const fileKey = typeof file.apiKey === "string" && file.apiKey.trim() ? file.apiKey.trim() : undefined;
176
+ const envBase = process.env[BASE_URL_ENV];
177
+ const envKey = process.env[KEY_ENV]?.trim();
178
+ const base = envBase || fileBase;
179
+ return {
180
+ baseUrl: base ? normalizeBaseUrl(base) : undefined,
181
+ apiKey: envKey || fileKey,
182
+ keySource: envKey ? KEY_ENV : fileKey ? "zengate.json" : "none",
183
+ };
184
+ }
185
+
186
+ const configureHint = (): string => `Set ${BASE_URL_ENV} or baseUrl in ${configPath()} to your gateway /v1 URL.`;
187
+
188
+ export default async function (pi: ExtensionAPI) {
189
+ const settings = readSettings();
190
+ const key = settings.apiKey;
191
+ const cacheFile = cachePath();
192
+
193
+ // Without an endpoint there is nothing to discover. Say so once per session
194
+ // and leave the model list untouched.
195
+ if (!settings.baseUrl) {
196
+ pi.on("session_start", async (_event, ctx) => {
197
+ ctx.ui.notify(`${PROVIDER_NAME}: ${configureHint()}`, "warning");
198
+ });
199
+ return;
200
+ }
201
+ const baseUrl = settings.baseUrl;
202
+
203
+ const readCache = (): PiModel[] => {
204
+ const saved = readJson(cacheFile);
205
+ return Array.isArray(saved?.models) ? (saved.models as PiModel[]) : [];
206
+ };
207
+ const writeCache = (models: PiModel[]) => {
208
+ try {
209
+ mkdirSync(dirname(cacheFile), { recursive: true });
210
+ writeFileSync(
211
+ cacheFile,
212
+ `${JSON.stringify({ baseUrl, checkedAt: Date.now(), models }, null, 2)}\n`,
213
+ );
214
+ } catch {
215
+ // A read-only agent directory must not break model loading.
216
+ }
217
+ };
218
+
219
+ let latest = readCache();
220
+ let checkedAt = readJson(cacheFile)?.checkedAt as number | undefined;
221
+
222
+ const refresh = async (signal?: AbortSignal): Promise<PiModel[]> => {
223
+ const deadline = AbortSignal.timeout(FETCH_TIMEOUT_MS);
224
+ const combined = signal && "any" in AbortSignal ? AbortSignal.any([signal, deadline]) : deadline;
225
+ const response = await fetch(`${baseUrl}/models`, {
226
+ headers: { accept: "application/json", ...(key ? { authorization: `Bearer ${key}` } : {}) },
227
+ signal: combined,
228
+ });
229
+ if (!response.ok) throw new Error(`gateway catalog returned HTTP ${response.status}`);
230
+ latest = toPiModels(await response.json());
231
+ checkedAt = Date.now();
232
+ writeCache(latest);
233
+ return latest;
234
+ };
235
+
236
+ // Fetch before registering, so `pi --list-models` and every startup see the
237
+ // current catalog. `--list-models` runs offline-only, so it never refreshes.
238
+ if (key) {
239
+ try {
240
+ await refresh();
241
+ } catch {
242
+ // Offline or a gateway fault: the cached catalog above is still usable.
243
+ }
244
+ }
245
+
246
+ pi.registerProvider(PROVIDER_ID, {
247
+ name: PROVIDER_NAME,
248
+ baseUrl,
249
+ apiKey: key ?? `\${${KEY_ENV}}`,
250
+ api: "openai-completions",
251
+ models: latest,
252
+ async refreshModels(context) {
253
+ if (!context.allowNetwork) return latest;
254
+ const credential = context.credential?.type === "api_key" ? context.credential.key : undefined;
255
+ if (!credential && !key) return latest;
256
+ try {
257
+ return await refresh(context.signal);
258
+ } catch {
259
+ return latest;
260
+ }
261
+ },
262
+ });
263
+
264
+ pi.registerCommand("zengate", {
265
+ description: "Show or refresh the ZenGate model catalog",
266
+ handler: async (args, ctx) => {
267
+ if ((args ?? "").trim() === "refresh") {
268
+ try {
269
+ const models = await refresh();
270
+ ctx.ui.notify(`ZenGate: refreshed ${models.length} models from ${baseUrl}`, "info");
271
+ } catch (error) {
272
+ ctx.ui.notify(`ZenGate refresh failed: ${(error as Error).message}`, "error");
273
+ }
274
+ return;
275
+ }
276
+ const stamp = checkedAt ? new Date(checkedAt).toISOString() : "never";
277
+ const line = `${PROVIDER_NAME}: ${latest.length} models, ${baseUrl}, key ${settings.keySource}, catalog ${stamp}`;
278
+ ctx.ui.notify(line, "info");
279
+ },
280
+ });
281
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "pi-zengate-models",
3
+ "version": "1.0.1",
4
+ "description": "Pi provider extension that discovers free models and their metadata from an OpenAI-compatible gateway catalog, with a local cache and no scheduler.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "opencode2api",
9
+ "zengate",
10
+ "openai-compatible"
11
+ ],
12
+ "license": "MIT",
13
+ "type": "module",
14
+ "engines": {
15
+ "node": ">=20.19"
16
+ },
17
+ "files": [
18
+ "extensions",
19
+ "README.md",
20
+ "CHANGELOG.md",
21
+ "LICENSE"
22
+ ],
23
+ "pi": {
24
+ "extensions": [
25
+ "./extensions/zengate-models.ts"
26
+ ]
27
+ },
28
+ "scripts": {
29
+ "test": "node --test test/mapping.test.mjs"
30
+ }
31
+ }