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 +21 -0
- package/README.md +170 -0
- package/custom-models.json +1 -0
- package/deprecated-models.json +24 -0
- package/index.ts +386 -0
- package/models.json +86 -0
- package/package.json +53 -0
- package/patch.json +97 -0
- package/scripts/update-models.js +489 -0
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
|
+
[](https://github.com/earendil-works/pi-coding-agent)
|
|
10
|
+
[](./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();
|