pi-runinfra-provider 1.0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 monotykamary
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,170 @@
1
+ <div align="center">
2
+
3
+ # 🏃 pi-runinfra-provider
4
+
5
+ **DeepSeek V4, Nemotron 3.5 Lightning & Qwen3.8 through [RunInfra](https://runinfra.ai/)**
6
+
7
+ _A [pi](https://github.com/earendil-works/pi-coding-agent) provider extension for RunInfra's OpenAI-compatible inference gateway._
8
+
9
+ [![pi extension](https://img.shields.io/badge/pi-extension-blueviolet)](https://github.com/earendil-works/pi-coding-agent)
10
+ [![license](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
11
+
12
+ </div>
13
+
14
+ ---
15
+
16
+ ## Features
17
+
18
+ - **5 AI Models** including DeepSeek V4 Flash, DeepSeek V4 Pro, Nemotron 3.5 Lightning 30B, Qwen3.8 2.4T A95B, and Qwen3.8 27B
19
+ - **OpenAI-Compatible API** — Just change the base URL and API key
20
+ - **Cost Tracking** — Per-model pricing for budget management
21
+ - **Reasoning Models** — Extended thinking via `reasoning_content` field
22
+ - **Reasoning Effort** — Control reasoning depth on DeepSeek V4 (including `max`) and other reasoning models
23
+ - **Request Tracing** — Automatic `X-Client-Request-Id` UUID header on every request for server-side observability
24
+
25
+ ## Installation
26
+
27
+ ### Option 1: Using `pi install` (Recommended)
28
+
29
+ Install directly from GitHub:
30
+
31
+ ```bash
32
+ pi install https://github.com/monotykamary/pi-runinfra-provider
33
+ ```
34
+
35
+ Then set your API key and run pi:
36
+ ```bash
37
+ # Recommended: add to auth.json
38
+ # See Authentication section below
39
+
40
+ # Or set as environment variable
41
+ export RUNINFRA_GATEWAY_KEY=your-api-key-here
42
+
43
+ pi
44
+ ```
45
+
46
+ ### Option 2: Manual Clone
47
+
48
+ 1. Clone this repository:
49
+ ```bash
50
+ git clone https://github.com/monotykamary/pi-runinfra-provider.git
51
+ cd pi-runinfra-provider
52
+ ```
53
+
54
+ 2. Set your RunInfra gateway key:
55
+ ```bash
56
+ # Recommended: add to auth.json
57
+ # See Authentication section below
58
+
59
+ # Or set as environment variable
60
+ export RUNINFRA_GATEWAY_KEY=your-api-key-here
61
+ ```
62
+
63
+ 3. Run pi with the extension:
64
+ ```bash
65
+ pi -e /path/to/pi-runinfra-provider
66
+ ```
67
+
68
+ ## Available Models
69
+
70
+ | Model | Context | Vision | Reasoning | Input $/M | Cache Read $/M | Output $/M |
71
+ |-------|---------|--------|-----------|-----------|-----------------|------------|
72
+ | DeepSeek V4 Flash | 1.0M | ❌ | ✅ | $0.13 | $0.01 | $0.27 |
73
+ | DeepSeek V4 Pro | 1.0M | ❌ | ✅ | $0.60 | $0.03 | $1.90 |
74
+ | Nemotron 3.5 Lightning 30B | 262K | ❌ | ✅ | $0.05 | — | $0.15 |
75
+ | Qwen3.8 2.4T A95B | 262K | ❌ | ✅ | $2.00 | $0.20 | $6.00 |
76
+ | Qwen3.8 27B | 262K | ❌ | ✅ | $0.10 | $0.01 | $0.40 |
77
+
78
+ *Costs are per million tokens. Prices subject to change — check RunInfra for current pricing.*
79
+
80
+ ## Usage
81
+
82
+ After loading the extension, use the `/model` command in pi to select your preferred model:
83
+
84
+ ```
85
+ /model runinfra deepseek-v4-flash
86
+ ```
87
+
88
+ Or start pi directly with a RunInfra model:
89
+
90
+ ```bash
91
+ pi --provider runinfra --model deepseek-v4-flash
92
+ ```
93
+
94
+ ## Authentication
95
+
96
+ The RunInfra gateway key can be configured in multiple ways (resolved in this order):
97
+
98
+ 1. **`auth.json`** (recommended) — Add to `~/.pi/agent/auth.json`:
99
+ ```json
100
+ { "runinfra": { "type": "api_key", "key": "your-api-key" } }
101
+ ```
102
+ The `key` field supports literal values, env var names, and shell commands (prefix with `!`). See [pi's auth file docs](https://github.com/badlogic/pi-mono) for details.
103
+ 2. **Runtime override** — Use the `--api-key` CLI flag
104
+ 3. **Environment variable** — Set `RUNINFRA_GATEWAY_KEY`
105
+
106
+ ## Environment Variables
107
+
108
+ | Variable | Required | Description |
109
+ |----------|----------|-------------|
110
+ | `RUNINFRA_GATEWAY_KEY` | No | Your RunInfra gateway key (fallback if not in auth.json) |
111
+
112
+ ## Configuration
113
+
114
+ Add to your pi configuration for automatic loading:
115
+
116
+ ```json
117
+ {
118
+ "extensions": [
119
+ "/path/to/pi-runinfra-provider"
120
+ ]
121
+ }
122
+ ```
123
+
124
+ ### Compat Settings
125
+
126
+ RunInfra's API follows the OpenAI Chat Completions API:
127
+
128
+ - **`supportsDeveloperRole: false`** — All models. RunInfra uses standard OpenAI roles.
129
+ - **`maxTokensField: "max_tokens"`** — All models. RunInfra accepts `max_tokens`.
130
+ - **`thinkingFormat: "deepseek"`** — DeepSeek V4 Flash / V4 Pro. Reasoning is toggled via a `thinking` parameter and effort is sent as `reasoning_effort` (pi maps Shift+Tab levels to `low`/`high`/`max`).
131
+ - **`thinkingFormat: "openai"`** — Nemotron 3.5 Lightning, Qwen3.8. Returns thinking in the `reasoning_content` field.
132
+ - **`supportsReasoningEffort: true`** — All reasoning models. Supports the `reasoning_effort` parameter.
133
+ - **`supportsStore: false`** — All models. RunInfra doesn't support the `store` parameter.
134
+
135
+ ### Request Tracing
136
+
137
+ Every request to RunInfra automatically includes a fresh `X-Client-Request-Id` header containing a UUID. This matches RunInfra's recommended tracing header and makes it easy to correlate client sessions with server-side logs.
138
+
139
+ ### Patch Overrides
140
+
141
+ The `patch.json` file contains overrides that are applied on top of `models.json` data. This is useful for:
142
+ - Marking models as reasoning-capable when the API doesn't report it
143
+ - Filling in pricing for models where the API returns empty values
144
+ - Adding compat settings (thinking format, reasoning effort, level maps)
145
+ - Setting `thinkingFormat: "deepseek"` and `thinkingLevelMap` for the DeepSeek V4 models
146
+
147
+ ### Custom Models
148
+
149
+ The `custom-models.json` file contains full model definitions for models that need manual curation beyond what the API provides, or models not yet available from the API. These are merged in after patch application, taking precedence for matching IDs.
150
+
151
+ Merge order: `[live|cache|embedded] → patch.json → custom-models.json`
152
+
153
+ ## Updating Models
154
+
155
+ Run the update script to fetch the latest models from RunInfra's API:
156
+
157
+ ```bash
158
+ export RUNINFRA_GATEWAY_KEY=your-api-key
159
+ node scripts/update-models.js
160
+ ```
161
+
162
+ This will:
163
+ 1. Fetch models from `https://api.runinfra.ai/v1/models`
164
+ 2. Preserve existing model data (pricing, compat) for known models
165
+ 3. Apply overrides from `patch.json`
166
+ 4. Update `models.json` and the README model table
167
+
168
+ ## License
169
+
170
+ MIT
@@ -0,0 +1 @@
1
+ []
@@ -0,0 +1,24 @@
1
+ {
2
+ "nemotron-3-5-lightning-30b": {
3
+ "id": "nemotron-3-5-lightning-30b",
4
+ "name": "Nemotron 3.5 Lightning 30B",
5
+ "reasoning": true,
6
+ "input": [
7
+ "text"
8
+ ],
9
+ "cost": {
10
+ "input": 0.05,
11
+ "output": 0.15,
12
+ "cacheRead": 0,
13
+ "cacheWrite": 0
14
+ },
15
+ "contextWindow": 262144,
16
+ "maxTokens": 32768,
17
+ "compat": {
18
+ "supportsDeveloperRole": false,
19
+ "supportsStore": false,
20
+ "maxTokensField": "max_tokens"
21
+ },
22
+ "deprecatedAt": "2026-08-18T16:43:21.091Z"
23
+ }
24
+ }
package/index.ts ADDED
@@ -0,0 +1,386 @@
1
+ /**
2
+ * RunInfra Provider Extension
3
+ *
4
+ * Registers RunInfra as a custom provider using the openai-completions API.
5
+ * Base URL: https://api.runinfra.ai/v1
6
+ *
7
+ * Model resolution strategy: Stale-While-Revalidate
8
+ * 1. Serve stale immediately: disk cache → embedded models.json (zero-latency)
9
+ * 2. Revalidate in background: live API /models → merge with embedded → cache → hot-swap
10
+ * 3. patch.json + custom-models.json applied on top of whichever source won
11
+ *
12
+ * Merge order: [live|cache|embedded] → apply patch.json → merge custom-models.json
13
+ *
14
+ * Usage:
15
+ * # Option 1: Store in auth.json (recommended)
16
+ * # Add to ~/.pi/agent/auth.json:
17
+ * # "runinfra": { "type": "api_key", "key": "your-api-key" }
18
+ *
19
+ * # Option 2: Set as environment variable
20
+ * export RUNINFRA_GATEWAY_KEY=your-api-key
21
+ *
22
+ * # Run pi with the extension
23
+ * pi -e /path/to/pi-runinfra-provider
24
+ *
25
+ * Then use /model to select from available models
26
+ */
27
+
28
+ import { getAgentDir, type ExtensionAPI, type ModelRegistry } from "@earendil-works/pi-coding-agent";
29
+ import { randomUUID } from "crypto";
30
+ import modelsData from "./models.json" with { type: "json" };
31
+ import customModelsData from "./custom-models.json" with { type: "json" };
32
+ import patchData from "./patch.json" with { type: "json" };
33
+ import deprecatedData from "./deprecated-models.json" with { type: "json" };
34
+ import fs from "fs";
35
+ import path from "path";
36
+
37
+ // ─── Types ────────────────────────────────────────────────────────────────────
38
+
39
+ interface JsonModel {
40
+ id: string;
41
+ name: string;
42
+ reasoning: boolean;
43
+ input: ("text" | "image")[];
44
+ cost: {
45
+ input: number;
46
+ output: number;
47
+ cacheRead: number;
48
+ cacheWrite: number;
49
+ };
50
+ contextWindow: number;
51
+ maxTokens: number;
52
+ thinkingLevelMap?: {
53
+ off?: string | null;
54
+ minimal?: string | null;
55
+ low?: string | null;
56
+ medium?: string | null;
57
+ high?: string | null;
58
+ xhigh?: string | null;
59
+ max?: string | null;
60
+ };
61
+ compat?: {
62
+ supportsDeveloperRole?: boolean;
63
+ supportsStore?: boolean;
64
+ maxTokensField?: "max_completion_tokens" | "max_tokens";
65
+ thinkingFormat?: "openai" | "deepseek" | "zai" | "qwen" | "qwen-chat-template" | "chat-template";
66
+ supportsReasoningEffort?: boolean;
67
+ requiresReasoningContentOnAssistantMessages?: boolean;
68
+ chatTemplateKwargs?: Record<string, unknown>;
69
+ };
70
+ }
71
+
72
+ interface PatchEntry {
73
+ name?: string;
74
+ reasoning?: boolean;
75
+ input?: ("text" | "image")[];
76
+ cost?: {
77
+ input?: number;
78
+ output?: number;
79
+ cacheRead?: number;
80
+ cacheWrite?: number;
81
+ };
82
+ contextWindow?: number;
83
+ maxTokens?: number;
84
+ thinkingLevelMap?: Record<string, string | null>;
85
+ compat?: Record<string, unknown>;
86
+ }
87
+
88
+ type PatchData = Record<string, PatchEntry>;
89
+
90
+ // ─── Patch Application ────────────────────────────────────────────────────────
91
+
92
+ function applyPatch(model: JsonModel, patch: PatchEntry): JsonModel {
93
+ const result = { ...model };
94
+
95
+ if (patch.name !== undefined) result.name = patch.name;
96
+ if (patch.reasoning !== undefined) result.reasoning = patch.reasoning;
97
+ if (patch.input !== undefined) result.input = patch.input;
98
+ if (patch.contextWindow !== undefined) result.contextWindow = patch.contextWindow;
99
+ if (patch.maxTokens !== undefined) result.maxTokens = patch.maxTokens;
100
+ if (patch.thinkingLevelMap !== undefined) result.thinkingLevelMap = patch.thinkingLevelMap as JsonModel["thinkingLevelMap"];
101
+
102
+ if (patch.cost) {
103
+ result.cost = {
104
+ input: patch.cost.input ?? result.cost.input,
105
+ output: patch.cost.output ?? result.cost.output,
106
+ cacheRead: patch.cost.cacheRead ?? result.cost.cacheRead,
107
+ cacheWrite: patch.cost.cacheWrite ?? result.cost.cacheWrite,
108
+ };
109
+ }
110
+ if (patch.compat) {
111
+ result.compat = { ...(result.compat || {}), ...patch.compat };
112
+ }
113
+
114
+ if (!result.reasoning && result.compat?.thinkingFormat) {
115
+ delete result.compat.thinkingFormat;
116
+ }
117
+ if (!result.reasoning && result.thinkingLevelMap) {
118
+ delete result.thinkingLevelMap;
119
+ }
120
+ if (result.compat && Object.keys(result.compat).length === 0) {
121
+ delete result.compat;
122
+ }
123
+
124
+ return result;
125
+ }
126
+
127
+ /** Full pipeline: base models → patch → custom → result */
128
+ function buildModels(base: JsonModel[], custom: JsonModel[], patch: PatchData): JsonModel[] {
129
+ const modelMap = new Map<string, JsonModel>();
130
+
131
+ // Seed with the base list plus grace-period deprecated models so patch.json
132
+ // entries apply to deprecated models exactly as while the model was live
133
+ // (withDeprecated keeps live data on id conflicts).
134
+ for (const model of withDeprecated(base)) {
135
+ modelMap.set(model.id, model);
136
+ }
137
+
138
+ for (const [id, patchEntry] of Object.entries(patch)) {
139
+ const existing = modelMap.get(id);
140
+ if (existing) {
141
+ modelMap.set(id, applyPatch(existing, patchEntry));
142
+ }
143
+ }
144
+
145
+ for (const model of custom) {
146
+ const existing = modelMap.get(model.id);
147
+ const patchEntry = patch[model.id];
148
+ if (existing && patchEntry) {
149
+ modelMap.set(model.id, applyPatch(model, patchEntry));
150
+ } else if (existing) {
151
+ modelMap.set(model.id, model);
152
+ } else if (patchEntry) {
153
+ modelMap.set(model.id, applyPatch(model, patchEntry));
154
+ } else {
155
+ modelMap.set(model.id, model);
156
+ }
157
+ }
158
+
159
+ return Array.from(modelMap.values());
160
+ }
161
+
162
+ // ─── Stale-While-Revalidate Model Sync ────────────────────────────────────────
163
+
164
+ const PROVIDER_ID = "runinfra";
165
+ const BASE_URL = "https://api.runinfra.ai/v1";
166
+ const MODELS_URL = `${BASE_URL}/models`;
167
+ const CACHE_DIR = path.join(getAgentDir(), "cache");
168
+ const CACHE_PATH = path.join(CACHE_DIR, `${PROVIDER_ID}-models.json`);
169
+ const LIVE_FETCH_TIMEOUT_MS = 8000;
170
+
171
+ /**
172
+ * Transform a model from the RunInfra /v1/models API.
173
+ * RunInfra already reports pricing in per-million-token USD and exposes
174
+ * context_window / max_output_tokens / cached_input_price_usd_per_mtok.
175
+ */
176
+ function transformApiModel(apiModel: any): JsonModel | null {
177
+ if (!apiModel?.id) return null;
178
+ const pricing = apiModel.pricing || {};
179
+ const model: JsonModel = {
180
+ id: apiModel.id,
181
+ name: apiModel.name || apiModel.id,
182
+ reasoning: false,
183
+ input: ["text"],
184
+ cost: {
185
+ input: typeof pricing.input === "number" ? pricing.input : 0,
186
+ output: typeof pricing.output === "number" ? pricing.output : 0,
187
+ cacheRead: typeof apiModel.cached_input_price_usd_per_mtok === "number" ? apiModel.cached_input_price_usd_per_mtok : 0,
188
+ cacheWrite: 0,
189
+ },
190
+ contextWindow: apiModel.context_window || apiModel.context_length || 131072,
191
+ maxTokens: apiModel.max_output_tokens || 32768,
192
+ };
193
+ model.compat = {
194
+ supportsDeveloperRole: false,
195
+ supportsStore: false,
196
+ maxTokensField: "max_tokens",
197
+ };
198
+ return model;
199
+ }
200
+
201
+ async function fetchLiveModels(apiKey: string, signal?: AbortSignal): Promise<JsonModel[] | null> {
202
+ try {
203
+ const response = await fetch(MODELS_URL, {
204
+ headers: { Authorization: `Bearer ${apiKey}` },
205
+ signal: signal ? AbortSignal.any([AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS), signal]) : AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS),
206
+ });
207
+ if (!response.ok) return null;
208
+ const data = await response.json();
209
+ const apiModels = Array.isArray(data) ? data : (data.data || []);
210
+ if (!Array.isArray(apiModels) || apiModels.length === 0) return null;
211
+ return apiModels.map(transformApiModel).filter((m): m is JsonModel => m !== null);
212
+ } catch {
213
+ return null;
214
+ }
215
+ }
216
+
217
+ function loadCachedModels(): JsonModel[] | null {
218
+ try {
219
+ const data = JSON.parse(fs.readFileSync(CACHE_PATH, "utf8"));
220
+ return Array.isArray(data) ? data : null;
221
+ } catch {
222
+ return null;
223
+ }
224
+ }
225
+
226
+ function cacheModels(models: JsonModel[]): void {
227
+ try {
228
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
229
+ fs.writeFileSync(CACHE_PATH, JSON.stringify(models, null, 2) + "\n");
230
+ } catch {
231
+ // Cache write failure is non-fatal
232
+ }
233
+ }
234
+
235
+ function mergeWithEmbedded(liveModels: JsonModel[], embeddedModels: JsonModel[]): JsonModel[] {
236
+ const embeddedMap = new Map(embeddedModels.map(m => [m.id, m]));
237
+ const seen = new Set<string>();
238
+ const result: JsonModel[] = [];
239
+ for (const liveModel of liveModels) {
240
+ const embedded = embeddedMap.get(liveModel.id);
241
+ seen.add(liveModel.id);
242
+ if (embedded) {
243
+ // Self-heal: live API pricing is authoritative field-by-field. Prefer the
244
+ // live cost when the API reports it (non-zero); fall back to embedded when
245
+ // the API is silent (0) so curated cacheRead/cacheWrite isn't clobbered and
246
+ // providers whose /models endpoint exposes no pricing keep their curated
247
+ // cost. Curation (reasoning/input/compat/name) still wins via ...embedded.
248
+ result.push({
249
+ ...liveModel,
250
+ ...embedded,
251
+ cost: {
252
+ input: liveModel.cost.input || embedded.cost.input,
253
+ output: liveModel.cost.output || embedded.cost.output,
254
+ cacheRead: liveModel.cost.cacheRead || embedded.cost.cacheRead,
255
+ cacheWrite: liveModel.cost.cacheWrite || embedded.cost.cacheWrite,
256
+ },
257
+ contextWindow: liveModel.contextWindow || embedded.contextWindow,
258
+ });
259
+ } else {
260
+ result.push(liveModel);
261
+ }
262
+ }
263
+ // Append any embedded models that the live API didn't return
264
+ for (const em of embeddedModels) {
265
+ if (!seen.has(em.id)) {
266
+ result.push(em);
267
+ }
268
+ }
269
+ return result;
270
+ }
271
+
272
+ // Grace period for delisted models. When the provider API stops listing a
273
+ // model, update-models.js moves its last-known definition into
274
+ // deprecated-models.json (stamped with deprecatedAt) instead of dropping it.
275
+ // For 14 days the model keeps working here so in-flight sessions and saved
276
+ // model settings do not break; afterwards it is evicted permanently.
277
+ const DEPRECATED_MODEL_TTL_MS = 14 * 24 * 60 * 60 * 1000;
278
+
279
+ // Grace-period deprecated models with deprecation metadata stripped.
280
+ function activeDeprecatedModels(): JsonModel[] {
281
+ const now = Date.now();
282
+ const result: JsonModel[] = [];
283
+ for (const entry of Object.values(deprecatedData as Record<string, JsonModel & { deprecatedAt?: string }>)) {
284
+ if (!entry?.id) continue;
285
+ const removedAt = Date.parse(entry.deprecatedAt ?? "");
286
+ if (Number.isNaN(removedAt) || now - removedAt > DEPRECATED_MODEL_TTL_MS) continue;
287
+ const model = { ...entry } as JsonModel & { deprecatedAt?: string };
288
+ delete model.deprecatedAt;
289
+ result.push(model);
290
+ }
291
+ return result;
292
+ }
293
+
294
+ // Append grace-period deprecated models the list does not already have (live data wins).
295
+ function withDeprecated(models: JsonModel[]): JsonModel[] {
296
+ const seen = new Set(models.map((m) => m.id));
297
+ const extras = activeDeprecatedModels().filter((m) => !seen.has(m.id));
298
+ return extras.length > 0 ? [...models, ...extras] : models;
299
+ }
300
+
301
+ function loadStaleModels(embeddedModels: JsonModel[]): JsonModel[] {
302
+ const cached = loadCachedModels();
303
+ if (!cached || cached.length === 0) return embeddedModels;
304
+
305
+ // Merge embedded models that are missing from cache (newly added models)
306
+ const cachedMap = new Map(cached.map(m => [m.id, m]));
307
+ for (const em of embeddedModels) {
308
+ if (!cachedMap.has(em.id)) {
309
+ cached.push(em);
310
+ }
311
+ }
312
+ return cached;
313
+ }
314
+
315
+ async function revalidateModels(apiKey: string | undefined, embeddedModels: JsonModel[], signal?: AbortSignal): Promise<JsonModel[] | null> {
316
+ if (!apiKey) return null;
317
+ const liveModels = await fetchLiveModels(apiKey, signal);
318
+ if (!liveModels || liveModels.length === 0) return null;
319
+ const merged = mergeWithEmbedded(liveModels, embeddedModels);
320
+ cacheModels(merged);
321
+ return merged;
322
+ }
323
+
324
+ // ─── API Key Resolution (via ModelRegistry) ────────────────────────────────────
325
+
326
+ let cachedApiKey: string | undefined;
327
+ let revalidateAbort: AbortController | null = null;
328
+
329
+ async function resolveApiKey(modelRegistry: ModelRegistry): Promise<void> {
330
+ cachedApiKey = await modelRegistry.getApiKeyForProvider(PROVIDER_ID) ?? undefined;
331
+ }
332
+
333
+ // ─── Extension Entry Point ────────────────────────────────────────────────────
334
+
335
+ export default function (pi: ExtensionAPI) {
336
+ const embeddedModels = modelsData as JsonModel[];
337
+ const customModels = customModelsData as JsonModel[];
338
+ const patches = patchData as PatchData;
339
+
340
+ const staleBase = loadStaleModels(embeddedModels);
341
+ const staleModels = buildModels(staleBase, customModels, patches);
342
+
343
+ pi.registerProvider(PROVIDER_ID, {
344
+ baseUrl: BASE_URL,
345
+ apiKey: "$RUNINFRA_GATEWAY_KEY",
346
+ api: "openai-completions",
347
+ models: staleModels,
348
+ });
349
+
350
+ // Attach a fresh X-Client-Request-Id UUID to every RunInfra request.
351
+ // RunInfra uses this header for request tracing; a unique value per call
352
+ // keeps server-side logs and idempotency scoped to a single attempt.
353
+ pi.on("before_provider_request", (event, ctx) => {
354
+ const model = ctx.model;
355
+ if (!model || model.provider !== PROVIDER_ID) return;
356
+ const payload = event.payload as Record<string, unknown>;
357
+ if (!payload || typeof payload !== "object") return;
358
+ (payload as any).headers = {
359
+ ...((payload as any).headers || {}),
360
+ "X-Client-Request-Id": randomUUID(),
361
+ };
362
+ return payload;
363
+ });
364
+
365
+ pi.on("session_start", async (_event, ctx) => {
366
+ revalidateAbort?.abort();
367
+ revalidateAbort = new AbortController();
368
+ const signal = revalidateAbort.signal;
369
+ resolveApiKey(ctx.modelRegistry).then(() => {
370
+ revalidateModels(cachedApiKey, embeddedModels, signal).then((freshBase) => {
371
+ if (freshBase && !signal.aborted) {
372
+ pi.registerProvider(PROVIDER_ID, {
373
+ baseUrl: BASE_URL,
374
+ apiKey: "$RUNINFRA_GATEWAY_KEY",
375
+ api: "openai-completions",
376
+ models: buildModels(freshBase, customModels, patches),
377
+ });
378
+ }
379
+ });
380
+ });
381
+ });
382
+
383
+ pi.on("session_shutdown", () => {
384
+ revalidateAbort?.abort();
385
+ });
386
+ }
package/models.json ADDED
@@ -0,0 +1,86 @@
1
+ [
2
+ {
3
+ "id": "deepseek-v4-flash",
4
+ "name": "DeepSeek V4 Flash",
5
+ "reasoning": true,
6
+ "input": [
7
+ "text"
8
+ ],
9
+ "cost": {
10
+ "input": 0.13,
11
+ "output": 0.27,
12
+ "cacheRead": 0.01,
13
+ "cacheWrite": 0
14
+ },
15
+ "contextWindow": 1048576,
16
+ "maxTokens": 32768,
17
+ "compat": {
18
+ "supportsDeveloperRole": false,
19
+ "supportsStore": false,
20
+ "maxTokensField": "max_tokens"
21
+ }
22
+ },
23
+ {
24
+ "id": "deepseek-v4-pro",
25
+ "name": "DeepSeek V4 Pro",
26
+ "reasoning": true,
27
+ "input": [
28
+ "text"
29
+ ],
30
+ "cost": {
31
+ "input": 0.6,
32
+ "output": 1.9,
33
+ "cacheRead": 0.03,
34
+ "cacheWrite": 0
35
+ },
36
+ "contextWindow": 1048576,
37
+ "maxTokens": 32768,
38
+ "compat": {
39
+ "supportsDeveloperRole": false,
40
+ "supportsStore": false,
41
+ "maxTokensField": "max_tokens"
42
+ }
43
+ },
44
+ {
45
+ "id": "qwen3-8-2-4t-a95b",
46
+ "name": "Qwen3.8 2.4T A95B",
47
+ "reasoning": true,
48
+ "input": [
49
+ "text"
50
+ ],
51
+ "cost": {
52
+ "input": 2,
53
+ "output": 6,
54
+ "cacheRead": 0.2,
55
+ "cacheWrite": 0
56
+ },
57
+ "contextWindow": 262144,
58
+ "maxTokens": 32768,
59
+ "compat": {
60
+ "supportsDeveloperRole": false,
61
+ "supportsStore": false,
62
+ "maxTokensField": "max_tokens"
63
+ }
64
+ },
65
+ {
66
+ "id": "qwen3-8-27b",
67
+ "name": "Qwen3.8 27B",
68
+ "reasoning": true,
69
+ "input": [
70
+ "text"
71
+ ],
72
+ "cost": {
73
+ "input": 0.1,
74
+ "output": 0.4,
75
+ "cacheRead": 0.01,
76
+ "cacheWrite": 0
77
+ },
78
+ "contextWindow": 262144,
79
+ "maxTokens": 32768,
80
+ "compat": {
81
+ "supportsDeveloperRole": false,
82
+ "supportsStore": false,
83
+ "maxTokensField": "max_tokens"
84
+ }
85
+ }
86
+ ]
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "pi-runinfra-provider",
3
+ "version": "1.0.0",
4
+ "description": "RunInfra provider extension for pi - Access DeepSeek V4, Nemotron 3.5 Lightning, and Qwen3.8 models through the RunInfra gateway",
5
+ "author": "monotykamary",
6
+ "homepage": "https://github.com/monotykamary/pi-runinfra-provider#readme",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/monotykamary/pi-runinfra-provider.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/monotykamary/pi-runinfra-provider/issues"
13
+ },
14
+ "type": "module",
15
+ "main": "index.ts",
16
+ "keywords": [
17
+ "pi",
18
+ "extension",
19
+ "provider",
20
+ "runinfra",
21
+ "ai",
22
+ "llm",
23
+ "deepseek",
24
+ "nemotron",
25
+ "qwen"
26
+ ],
27
+ "license": "MIT",
28
+ "files": [
29
+ "index.ts",
30
+ "models.json",
31
+ "deprecated-models.json",
32
+ "custom-models.json",
33
+ "patch.json",
34
+ "scripts/update-models.js",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "devDependencies": {
39
+ "@types/node": "^22.20.0",
40
+ "typescript": "6.0.3"
41
+ },
42
+ "pi": {
43
+ "extensions": [
44
+ "./index.ts"
45
+ ]
46
+ },
47
+ "scripts": {
48
+ "clean": "echo 'nothing to clean'",
49
+ "build": "echo 'nothing to build'",
50
+ "check": "tsc --noEmit",
51
+ "update-models": "node scripts/update-models.js"
52
+ }
53
+ }
package/patch.json ADDED
@@ -0,0 +1,97 @@
1
+ {
2
+ "deepseek-v4-flash": {
3
+ "name": "DeepSeek V4 Flash",
4
+ "reasoning": true,
5
+ "thinkingLevelMap": {
6
+ "off": "none",
7
+ "minimal": "minimal",
8
+ "low": "low",
9
+ "medium": "medium",
10
+ "high": "high",
11
+ "xhigh": "xhigh",
12
+ "max": "max"
13
+ },
14
+ "compat": {
15
+ "thinkingFormat": "deepseek",
16
+ "supportsReasoningEffort": true,
17
+ "requiresReasoningContentOnAssistantMessages": true,
18
+ "maxTokensField": "max_tokens"
19
+ }
20
+ },
21
+ "deepseek-v4-pro": {
22
+ "name": "DeepSeek V4 Pro",
23
+ "reasoning": true,
24
+ "thinkingLevelMap": {
25
+ "off": "none",
26
+ "minimal": "minimal",
27
+ "low": "low",
28
+ "medium": "medium",
29
+ "high": "high",
30
+ "xhigh": "xhigh",
31
+ "max": "max"
32
+ },
33
+ "compat": {
34
+ "thinkingFormat": "deepseek",
35
+ "supportsReasoningEffort": true,
36
+ "requiresReasoningContentOnAssistantMessages": true,
37
+ "maxTokensField": "max_tokens"
38
+ }
39
+ },
40
+ "nemotron-3-5-lightning-30b": {
41
+ "name": "Nemotron 3.5 Lightning 30B",
42
+ "reasoning": true,
43
+ "thinkingLevelMap": {
44
+ "off": "none",
45
+ "minimal": "low",
46
+ "low": "low",
47
+ "medium": "medium",
48
+ "high": "xhigh",
49
+ "xhigh": "xhigh",
50
+ "max": "xhigh"
51
+ },
52
+ "compat": {
53
+ "thinkingFormat": "openai",
54
+ "supportsReasoningEffort": true,
55
+ "requiresReasoningContentOnAssistantMessages": true,
56
+ "maxTokensField": "max_tokens"
57
+ }
58
+ },
59
+ "qwen3-8-2-4t-a95b": {
60
+ "name": "Qwen3.8 2.4T A95B",
61
+ "reasoning": true,
62
+ "thinkingLevelMap": {
63
+ "off": "none",
64
+ "minimal": "low",
65
+ "low": "low",
66
+ "medium": "medium",
67
+ "high": "xhigh",
68
+ "xhigh": "xhigh",
69
+ "max": "xhigh"
70
+ },
71
+ "compat": {
72
+ "thinkingFormat": "openai",
73
+ "supportsReasoningEffort": true,
74
+ "requiresReasoningContentOnAssistantMessages": true,
75
+ "maxTokensField": "max_tokens"
76
+ }
77
+ },
78
+ "qwen3-8-27b": {
79
+ "name": "Qwen3.8 27B",
80
+ "reasoning": true,
81
+ "thinkingLevelMap": {
82
+ "off": "none",
83
+ "minimal": "low",
84
+ "low": "low",
85
+ "medium": "medium",
86
+ "high": "xhigh",
87
+ "xhigh": "xhigh",
88
+ "max": "xhigh"
89
+ },
90
+ "compat": {
91
+ "thinkingFormat": "openai",
92
+ "supportsReasoningEffort": true,
93
+ "requiresReasoningContentOnAssistantMessages": true,
94
+ "maxTokensField": "max_tokens"
95
+ }
96
+ }
97
+ }
@@ -0,0 +1,489 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Update RunInfra models from API
4
+ *
5
+ * Fetches models from https://api.runinfra.ai/v1/models and updates:
6
+ * - models.json: Provider model definitions (enriched with pricing & compat)
7
+ * - README.md: Model table in the Available Models section
8
+ *
9
+ * The RunInfra /v1/models API already reports pricing in per-million-token USD
10
+ * and exposes context_window / max_output_tokens / cached_input_price_usd_per_mtok,
11
+ * so no unit conversion is needed.
12
+ *
13
+ * models.json is the source of truth for curated specs — the script preserves
14
+ * existing data and only adds new models with API-derived defaults.
15
+ * Curate models.json manually after new model discovery.
16
+ *
17
+ * patch.json and custom-models.json are applied at runtime by the provider.
18
+ * They are NOT baked into models.json, but ARE used to generate the README table.
19
+ *
20
+ * API key: the stored `runinfra` credential in ~/.pi/agent/auth.json wins, then
21
+ * the RUNINFRA_GATEWAY_KEY environment variable. The script refuses to run without one.
22
+ */
23
+
24
+ import fs from 'fs';
25
+ import os from 'os';
26
+ import { execSync } from 'child_process';
27
+ import path from 'path';
28
+ import { fileURLToPath } from 'url';
29
+
30
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
31
+
32
+ // pi's agent directory: PI_CODING_AGENT_DIR (with ~ expansion) or ~/.pi/agent.
33
+ function piAgentDir() {
34
+ const envDir = process.env.PI_CODING_AGENT_DIR;
35
+ if (envDir) {
36
+ return envDir.startsWith('~/') || envDir === '~'
37
+ ? path.join(os.homedir(), envDir.slice(1))
38
+ : envDir;
39
+ }
40
+ return path.join(os.homedir(), '.pi', 'agent');
41
+ }
42
+
43
+ const AUTH_JSON_PATH = path.join(piAgentDir(), 'auth.json');
44
+
45
+ /**
46
+ * Resolve a configured value using pi's semantics (resolve-config-value.ts in
47
+ * pi-mono): "!command" runs via the shell (10s timeout) and uses trimmed
48
+ * stdout; "$VAR" / "${VAR}" interpolate environment variables ("$$" escapes a
49
+ * literal "$", "$!" a literal "!"); anything else is a literal. Returns
50
+ * undefined when a referenced env var is unset or a command fails.
51
+ */
52
+ function resolveConfigValue(config, env) {
53
+ if (typeof config !== 'string' || config.length === 0) return undefined;
54
+ if (config.startsWith('!')) {
55
+ try {
56
+ const out = execSync(config.slice(1), {
57
+ encoding: 'utf8',
58
+ timeout: 10000,
59
+ stdio: ['ignore', 'pipe', 'ignore'],
60
+ });
61
+ return out.trim() || undefined;
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ }
66
+ const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
67
+ let resolved = '';
68
+ let index = 0;
69
+ while (index < config.length) {
70
+ const dollar = config.indexOf('$', index);
71
+ if (dollar < 0) {
72
+ resolved += config.slice(index);
73
+ break;
74
+ }
75
+ resolved += config.slice(index, dollar);
76
+ const next = config[dollar + 1];
77
+ let name;
78
+ if (next === '$' || next === '!') {
79
+ resolved += next;
80
+ index = dollar + 2;
81
+ continue;
82
+ } else if (next === '{') {
83
+ const end = config.indexOf('}', dollar + 2);
84
+ if (end < 0) {
85
+ resolved += '$';
86
+ index = dollar + 1;
87
+ continue;
88
+ }
89
+ const inner = config.slice(dollar + 2, end);
90
+ if (!ENV_NAME_RE.test(inner)) {
91
+ resolved += config.slice(dollar, end + 1);
92
+ index = end + 1;
93
+ continue;
94
+ }
95
+ name = inner;
96
+ index = end + 1;
97
+ } else {
98
+ const match = config.slice(dollar + 1).match(/^[A-Za-z_][A-Za-z0-9_]*/);
99
+ if (!match) {
100
+ resolved += '$';
101
+ index = dollar + 1;
102
+ continue;
103
+ }
104
+ name = match[0];
105
+ index = dollar + 1 + name.length;
106
+ }
107
+ const value = (env && env[name]) || process.env[name] || undefined;
108
+ if (value === undefined) return undefined;
109
+ resolved += value;
110
+ }
111
+ return resolved;
112
+ }
113
+
114
+ /**
115
+ * The API key, resolved the way pi itself resolves it for this provider: the
116
+ * stored `runinfra` credential in ~/.pi/agent/auth.json wins, then
117
+ * the RUNINFRA_GATEWAY_KEY environment variable.
118
+ */
119
+ function resolveApiKey() {
120
+ try {
121
+ const auth = JSON.parse(fs.readFileSync(AUTH_JSON_PATH, 'utf8'));
122
+ const credential = auth?.runinfra;
123
+ if (credential && credential.type === 'api_key' && typeof credential.key === 'string') {
124
+ const key = resolveConfigValue(credential.key, credential.env);
125
+ if (key) return key;
126
+ }
127
+ } catch {
128
+ // Missing or unparseable auth.json: fall through to the env var.
129
+ }
130
+ return process.env.RUNINFRA_GATEWAY_KEY || undefined;
131
+ }
132
+
133
+ const MODELS_API_URL = 'https://api.runinfra.ai/v1/models';
134
+ const MODELS_JSON_PATH = path.join(__dirname, '..', 'models.json');
135
+ const PATCH_JSON_PATH = path.join(__dirname, '..', 'patch.json');
136
+ const CUSTOM_MODELS_JSON_PATH = path.join(__dirname, '..', 'custom-models.json');
137
+ const README_PATH = path.join(__dirname, '..', 'README.md');
138
+
139
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
140
+
141
+ function loadJson(filePath) {
142
+ try {
143
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
144
+ } catch {
145
+ return {};
146
+ }
147
+ }
148
+
149
+ function saveJson(filePath, data) {
150
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
151
+ console.log(`✓ Saved ${path.basename(filePath)}`);
152
+ }
153
+
154
+ // ─── API fetch ───────────────────────────────────────────────────────────────
155
+
156
+ async function fetchModels() {
157
+ const apiKey = resolveApiKey();
158
+ if (!apiKey) {
159
+ throw new Error('No API key found: no `runinfra` credential resolved from ' + AUTH_JSON_PATH + ' and RUNINFRA_GATEWAY_KEY is not set');
160
+ }
161
+
162
+ console.log(`Fetching models from ${MODELS_API_URL}...`);
163
+ const response = await fetch(MODELS_API_URL, {
164
+ headers: { 'Authorization': `Bearer ${apiKey}` },
165
+ });
166
+
167
+ if (!response.ok) {
168
+ throw new Error(`API error: ${response.status} ${response.statusText}`);
169
+ }
170
+
171
+ const data = await response.json();
172
+ const models = data.data || [];
173
+ console.log(`✓ Fetched ${models.length} models from API`);
174
+ return models;
175
+ }
176
+
177
+ // ─── Transform API model → models.json entry ────────────────────────────────
178
+
179
+ function generateDisplayName(id) {
180
+ return id
181
+ .split(/[-_/]/)
182
+ .filter(Boolean)
183
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1))
184
+ .join(' ');
185
+ }
186
+
187
+ function transformApiModel(apiModel, existingModelsMap) {
188
+ const id = apiModel.id;
189
+ const pricing = apiModel.pricing || {};
190
+ const cacheRead = typeof apiModel.cached_input_price_usd_per_mtok === 'number'
191
+ ? apiModel.cached_input_price_usd_per_mtok
192
+ : 0;
193
+
194
+ // Preserve existing curated data (pricing, reasoning, compat, etc.)
195
+ if (existingModelsMap[id]) {
196
+ const existing = { ...existingModelsMap[id] };
197
+ // Update context window from API if changed
198
+ const ctx = apiModel.context_window || apiModel.context_length;
199
+ if (ctx) existing.contextWindow = ctx;
200
+ // Update max output tokens from API
201
+ if (apiModel.max_output_tokens) existing.maxTokens = apiModel.max_output_tokens;
202
+ // Update pricing from API (already per-million)
203
+ if (typeof pricing.input === 'number') existing.cost.input = pricing.input;
204
+ if (typeof pricing.output === 'number') existing.cost.output = pricing.output;
205
+ existing.cost.cacheRead = cacheRead;
206
+ return existing;
207
+ }
208
+
209
+ // New model — build from API data + sensible defaults
210
+ const model = {
211
+ id,
212
+ name: apiModel.name || generateDisplayName(id),
213
+ reasoning: false,
214
+ input: ['text'],
215
+ cost: {
216
+ input: typeof pricing.input === 'number' ? pricing.input : 0,
217
+ output: typeof pricing.output === 'number' ? pricing.output : 0,
218
+ cacheRead,
219
+ cacheWrite: 0,
220
+ },
221
+ contextWindow: apiModel.context_window || apiModel.context_length || 131072,
222
+ maxTokens: apiModel.max_output_tokens || 32768,
223
+ compat: {
224
+ supportsDeveloperRole: false,
225
+ supportsStore: false,
226
+ maxTokensField: 'max_tokens',
227
+ },
228
+ };
229
+ return model;
230
+ }
231
+
232
+ // ─── Patch Application (mirrors index.ts) ────────────────────────────────────
233
+
234
+ function applyPatch(model, patch) {
235
+ const result = { ...model };
236
+ if (patch.name !== undefined) result.name = patch.name;
237
+ if (patch.reasoning !== undefined) result.reasoning = patch.reasoning;
238
+ if (patch.input !== undefined) result.input = patch.input;
239
+ if (patch.contextWindow !== undefined) result.contextWindow = patch.contextWindow;
240
+ if (patch.maxTokens !== undefined) result.maxTokens = patch.maxTokens;
241
+ if (patch.thinkingLevelMap !== undefined) result.thinkingLevelMap = patch.thinkingLevelMap;
242
+ if (patch.cost) {
243
+ result.cost = {
244
+ input: patch.cost.input ?? result.cost.input,
245
+ output: patch.cost.output ?? result.cost.output,
246
+ cacheRead: patch.cost.cacheRead ?? result.cost.cacheRead,
247
+ cacheWrite: patch.cost.cacheWrite ?? result.cost.cacheWrite,
248
+ };
249
+ }
250
+ if (patch.compat) {
251
+ result.compat = { ...(result.compat || {}), ...patch.compat };
252
+ }
253
+ if (!result.reasoning && result.compat?.thinkingFormat) {
254
+ delete result.compat.thinkingFormat;
255
+ }
256
+ if (!result.reasoning && result.thinkingLevelMap) {
257
+ delete result.thinkingLevelMap;
258
+ }
259
+ if (result.compat && Object.keys(result.compat).length === 0) {
260
+ delete result.compat;
261
+ }
262
+ return result;
263
+ }
264
+
265
+ /** Full pipeline: base models → patch → custom → result */
266
+ function buildModels(baseModels, customModels, patchData) {
267
+ const modelMap = new Map();
268
+ for (const model of baseModels) {
269
+ modelMap.set(model.id, model);
270
+ }
271
+ for (const [id, patchEntry] of Object.entries(patchData)) {
272
+ const existing = modelMap.get(id);
273
+ if (existing) {
274
+ modelMap.set(id, applyPatch(existing, patchEntry));
275
+ }
276
+ }
277
+ for (const model of customModels) {
278
+ const existing = modelMap.get(model.id);
279
+ const patchEntry = patchData[model.id];
280
+ if (existing && patchEntry) {
281
+ modelMap.set(model.id, applyPatch(model, patchEntry));
282
+ } else if (existing) {
283
+ modelMap.set(model.id, model);
284
+ } else if (patchEntry) {
285
+ modelMap.set(model.id, applyPatch(model, patchEntry));
286
+ } else {
287
+ modelMap.set(model.id, model);
288
+ }
289
+ }
290
+ return Array.from(modelMap.values());
291
+ }
292
+
293
+ // ─── README generation ──────────────────────────────────────────────────────
294
+
295
+ function formatContext(n) {
296
+ if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
297
+ if (n >= 1000) return `${Math.round(n / 1000)}K`;
298
+ return n.toString();
299
+ }
300
+
301
+ function formatCost(cost) {
302
+ if (cost === 0) return '—';
303
+ if (cost === null || cost === undefined) return '—';
304
+ return `$${cost.toFixed(2)}`;
305
+ }
306
+
307
+ function generateReadmeTable(models) {
308
+ const lines = [
309
+ '| Model | Context | Vision | Reasoning | Input $/M | Cache Read $/M | Output $/M |',
310
+ '|-------|---------|--------|-----------|-----------|-----------------|------------|',
311
+ ];
312
+
313
+ for (const model of models) {
314
+ const context = formatContext(model.contextWindow);
315
+ const vision = model.input.includes('image') ? '✅' : '❌';
316
+ const reasoning = model.reasoning ? '✅' : '❌';
317
+ const inputCost = formatCost(model.cost.input);
318
+ const cacheReadCost = formatCost(model.cost.cacheRead);
319
+ const outputCost = formatCost(model.cost.output);
320
+
321
+ lines.push(`| ${model.name} | ${context} | ${vision} | ${reasoning} | ${inputCost} | ${cacheReadCost} | ${outputCost} |`);
322
+ }
323
+
324
+ return lines.join('\n');
325
+ }
326
+
327
+ function updateReadme(models) {
328
+ let readme = fs.readFileSync(README_PATH, 'utf8');
329
+ const newTable = generateReadmeTable(models);
330
+
331
+ const tableRegex = /(## Available Models\n\n)\| Model \|[^\n]+\|\n\|[-| ]+\|(\n\|[^\n]+\|)*\n*/;
332
+
333
+ if (tableRegex.test(readme)) {
334
+ readme = readme.replace(tableRegex, (match, header) => `${header}${newTable}\n\n`);
335
+ fs.writeFileSync(README_PATH, readme);
336
+ console.log('✓ Updated README.md');
337
+ } else {
338
+ console.warn('⚠ Could not find model table in "## Available Models" section');
339
+ }
340
+ }
341
+
342
+ // ─── Main ────────────────────────────────────────────────────────────────────
343
+
344
+ // Grace period for delisted models: update-models.js moves models the API no
345
+ // longer lists into deprecated-models.json (stamped with deprecatedAt) instead
346
+ // of dropping them; the runtime appends them back so sessions and saved model
347
+ // settings keep working, and after 14 days they are evicted permanently.
348
+ const DEPRECATED_MODEL_TTL_MS = 14 * 24 * 60 * 60 * 1000;
349
+
350
+ /**
351
+ * Reconcile deprecated-models.json against the freshly fetched model list.
352
+ * - in old models.json but not the API: moved into the deprecated file
353
+ * (deprecatedAt = now; preserved on repeat runs so the grace clock is not reset)
354
+ * - back in the API: resurrected (dropped from the deprecated file)
355
+ * - deprecatedAt older than 14 days: evicted permanently
356
+ * Must run BEFORE the new models.json is written; it reads the old file itself.
357
+ */
358
+ function updateDeprecatedModels(modelsJsonPath, newModels) {
359
+ const deprecatedPath = path.join(path.dirname(modelsJsonPath), 'deprecated-models.json');
360
+
361
+ let oldModels = [];
362
+ try {
363
+ const parsed = JSON.parse(fs.readFileSync(modelsJsonPath, 'utf8'));
364
+ if (Array.isArray(parsed)) oldModels = parsed;
365
+ } catch { /* first run: no previous models.json */ }
366
+
367
+ let deprecated = {};
368
+ try {
369
+ const parsed = JSON.parse(fs.readFileSync(deprecatedPath, 'utf8'));
370
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) deprecated = parsed;
371
+ } catch { /* no graveyard yet */ }
372
+
373
+ const currentIds = new Set(newModels.map(m => m.id));
374
+ const now = new Date().toISOString();
375
+ const added = [];
376
+ const resurrected = [];
377
+ const evicted = [];
378
+
379
+ for (const old of oldModels) {
380
+ if (old && old.id && !currentIds.has(old.id) && !deprecated[old.id]) {
381
+ deprecated[old.id] = { ...old, deprecatedAt: now };
382
+ added.push(old.id);
383
+ }
384
+ }
385
+
386
+ for (const [id, entry] of Object.entries(deprecated)) {
387
+ if (currentIds.has(id)) {
388
+ delete deprecated[id];
389
+ resurrected.push(id);
390
+ continue;
391
+ }
392
+ const removedAt = Date.parse(entry && entry.deprecatedAt ? entry.deprecatedAt : '');
393
+ if (Number.isNaN(removedAt) || Date.now() - removedAt > DEPRECATED_MODEL_TTL_MS) {
394
+ delete deprecated[id];
395
+ evicted.push(id);
396
+ }
397
+ }
398
+
399
+ if (added.length > 0 || resurrected.length > 0 || evicted.length > 0) {
400
+ fs.writeFileSync(deprecatedPath, JSON.stringify(deprecated, null, 2) + '\n');
401
+ console.log('Updated deprecated-models.json ' + JSON.stringify({ added, resurrected, evicted }));
402
+ }
403
+ }
404
+
405
+ /**
406
+ * Grace-period deprecated models (deprecatedAt within TTL) with metadata stripped.
407
+ * Used so the README table keeps serving models that are delisted/paused but still
408
+ * within their 14-day grace window.
409
+ */
410
+ function withDeprecatedForReadme(models) {
411
+ const deprecatedPath = path.join(path.dirname(MODELS_JSON_PATH), 'deprecated-models.json');
412
+ let deprecated = {};
413
+ try {
414
+ const parsed = JSON.parse(fs.readFileSync(deprecatedPath, 'utf8'));
415
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) deprecated = parsed;
416
+ } catch { /* no graveyard yet */ }
417
+ const now = Date.now();
418
+ const seen = new Set(models.map(m => m.id));
419
+ const extras = [];
420
+ for (const entry of Object.values(deprecated)) {
421
+ if (!entry || !entry.id || seen.has(entry.id)) continue;
422
+ const removedAt = Date.parse(entry.deprecatedAt || '');
423
+ if (Number.isNaN(removedAt) || now - removedAt > DEPRECATED_MODEL_TTL_MS) continue;
424
+ const m = { ...entry };
425
+ delete m.deprecatedAt;
426
+ extras.push(m);
427
+ }
428
+ return extras.length > 0 ? [...models, ...extras] : models;
429
+ }
430
+
431
+ async function main() {
432
+ try {
433
+ const apiModels = await fetchModels();
434
+
435
+ // Load existing models.json — source of truth for curated specs
436
+ const existingModels = loadJson(MODELS_JSON_PATH);
437
+ const existingModelsMap = {};
438
+ for (const m of (Array.isArray(existingModels) ? existingModels : [])) {
439
+ existingModelsMap[m.id] = m;
440
+ }
441
+
442
+ // Transform API models, preserving existing data where available
443
+ let models = apiModels.map(m =>
444
+ transformApiModel(m, existingModelsMap)
445
+ );
446
+
447
+ // Live API is authoritative — models absent from API are removed
448
+ // (embedded data is already used for enrichment in transformApiModel)
449
+
450
+ // Sort by model name
451
+ models.sort((a, b) => a.name.localeCompare(b.name));
452
+
453
+ // Save models.json (pure API output, no patch/custom baked in)
454
+ // Move delisted models to deprecated-models.json BEFORE models.json is overwritten
455
+ updateDeprecatedModels(MODELS_JSON_PATH, models);
456
+ saveJson(MODELS_JSON_PATH, models);
457
+
458
+ // Build full model list for README: (base + grace-period deprecated) → patch → custom.
459
+ // Including active deprecated models keeps temporarily-delisted/paused models (e.g. a
460
+ // provider-paused model mid-grace-period) listed until they are evicted permanently.
461
+ const patchData = loadJson(PATCH_JSON_PATH);
462
+ const customModels = loadJson(CUSTOM_MODELS_JSON_PATH);
463
+ const readmeBase = withDeprecatedForReadme(models);
464
+ const readmeModels = buildModels(readmeBase, Array.isArray(customModels) ? customModels : [], patchData);
465
+ readmeModels.sort((a, b) => a.name.localeCompare(b.name));
466
+
467
+ // Update README
468
+ updateReadme(readmeModels);
469
+
470
+ // Summary
471
+ const newIds = new Set(models.map(m => m.id));
472
+ const oldIds = new Set(Object.keys(existingModelsMap));
473
+ const added = [...newIds].filter(id => !oldIds.has(id));
474
+ const removed = [...oldIds].filter(id => !newIds.has(id));
475
+
476
+ console.log('\n--- Summary ---');
477
+ console.log(`Total models: ${models.length}`);
478
+ console.log(`Reasoning models: ${models.filter(m => m.reasoning).length}`);
479
+ console.log(`Vision models: ${models.filter(m => m.input.includes('image')).length}`);
480
+ if (added.length > 0) console.log(`New models: ${added.join(', ')} — curate models.json manually`);
481
+ if (removed.length > 0) console.log(`Removed models: ${removed.join(', ')}`);
482
+
483
+ } catch (error) {
484
+ console.error('Error:', error.message);
485
+ process.exit(1);
486
+ }
487
+ }
488
+
489
+ main();