pi-coralbricks-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 +227 -0
- package/custom-models.json +1 -0
- package/deprecated-models.json +1 -0
- package/index.ts +599 -0
- package/models.json +133 -0
- package/package.json +59 -0
- package/patch.json +1 -0
- package/scripts/probe-stream-fix.ts +62 -0
- package/scripts/update-models.js +572 -0
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Update CoralBricks models from API
|
|
4
|
+
*
|
|
5
|
+
* Fetches 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
|
+
* Sources, in order:
|
|
10
|
+
* 1. Authenticated https://inference.coralbricks.ai/v1/models — the authoritative
|
|
11
|
+
* per-key enabled set, with per-million USD pricing (input_per_m / output_per_m /
|
|
12
|
+
* cached_input_per_m — cached reads are $0), context_length, supports_image_input.
|
|
13
|
+
* 2. Unauthenticated public catalog https://www.coralbricks.ai/api/public/models —
|
|
14
|
+
* same control-plane data without a key (contextWindow like "1M", inputPerM,
|
|
15
|
+
* outputPerM). Used when no key is configured or /v1/models fails.
|
|
16
|
+
*
|
|
17
|
+
* models.json is the source of truth for curated specs — the script preserves
|
|
18
|
+
* existing data and only adds new models with API-derived defaults.
|
|
19
|
+
* Curate models.json manually after new model discovery.
|
|
20
|
+
*
|
|
21
|
+
* patch.json and custom-models.json are applied at runtime by the provider.
|
|
22
|
+
* They are NOT baked into models.json, but ARE used to generate the README table.
|
|
23
|
+
*
|
|
24
|
+
* API key: the stored `coralbricks` credential in ~/.pi/agent/auth.json wins, then
|
|
25
|
+
* the CORALBRICKS_API_KEY environment variable (localterm:
|
|
26
|
+
* `localterm secret set coralbricks_api_key`).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import fs from 'fs';
|
|
30
|
+
import os from 'os';
|
|
31
|
+
import { execSync } from 'child_process';
|
|
32
|
+
import path from 'path';
|
|
33
|
+
import { fileURLToPath } from 'url';
|
|
34
|
+
|
|
35
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
36
|
+
|
|
37
|
+
// pi's agent directory: PI_CODING_AGENT_DIR (with ~ expansion) or ~/.pi/agent.
|
|
38
|
+
function piAgentDir() {
|
|
39
|
+
const envDir = process.env.PI_CODING_AGENT_DIR;
|
|
40
|
+
if (envDir) {
|
|
41
|
+
return envDir.startsWith('~/') || envDir === '~'
|
|
42
|
+
? path.join(os.homedir(), envDir.slice(1))
|
|
43
|
+
: envDir;
|
|
44
|
+
}
|
|
45
|
+
return path.join(os.homedir(), '.pi', 'agent');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const AUTH_JSON_PATH = path.join(piAgentDir(), 'auth.json');
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve a configured value using pi's semantics (resolve-config-value.ts in
|
|
52
|
+
* pi-mono): "!command" runs via the shell (10s timeout) and uses trimmed
|
|
53
|
+
* stdout; "$VAR" / "${VAR}" interpolate environment variables ("$$" escapes a
|
|
54
|
+
* literal "$", "$!" a literal "!"); anything else is a literal. Returns
|
|
55
|
+
* undefined when a referenced env var is unset or a command fails.
|
|
56
|
+
*/
|
|
57
|
+
function resolveConfigValue(config, env) {
|
|
58
|
+
if (typeof config !== 'string' || config.length === 0) return undefined;
|
|
59
|
+
if (config.startsWith('!')) {
|
|
60
|
+
try {
|
|
61
|
+
const out = execSync(config.slice(1), {
|
|
62
|
+
encoding: 'utf8',
|
|
63
|
+
timeout: 10000,
|
|
64
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
65
|
+
});
|
|
66
|
+
return out.trim() || undefined;
|
|
67
|
+
} catch {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
72
|
+
let resolved = '';
|
|
73
|
+
let index = 0;
|
|
74
|
+
while (index < config.length) {
|
|
75
|
+
const dollar = config.indexOf('$', index);
|
|
76
|
+
if (dollar < 0) {
|
|
77
|
+
resolved += config.slice(index);
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
resolved += config.slice(index, dollar);
|
|
81
|
+
const next = config[dollar + 1];
|
|
82
|
+
let name;
|
|
83
|
+
if (next === '$' || next === '!') {
|
|
84
|
+
resolved += next;
|
|
85
|
+
index = dollar + 2;
|
|
86
|
+
continue;
|
|
87
|
+
} else if (next === '{') {
|
|
88
|
+
const end = config.indexOf('}', dollar + 2);
|
|
89
|
+
if (end < 0) {
|
|
90
|
+
resolved += '$';
|
|
91
|
+
index = dollar + 1;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const inner = config.slice(dollar + 2, end);
|
|
95
|
+
if (!ENV_NAME_RE.test(inner)) {
|
|
96
|
+
resolved += config.slice(dollar, end + 1);
|
|
97
|
+
index = end + 1;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
name = inner;
|
|
101
|
+
index = end + 1;
|
|
102
|
+
} else {
|
|
103
|
+
const match = config.slice(dollar + 1).match(/^[A-Za-z_][A-Za-z0-9_]*/);
|
|
104
|
+
if (!match) {
|
|
105
|
+
resolved += '$';
|
|
106
|
+
index = dollar + 1;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
name = match[0];
|
|
110
|
+
index = dollar + 1 + name.length;
|
|
111
|
+
}
|
|
112
|
+
const value = (env && env[name]) || process.env[name] || undefined;
|
|
113
|
+
if (value === undefined) return undefined;
|
|
114
|
+
resolved += value;
|
|
115
|
+
}
|
|
116
|
+
return resolved;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The API key, resolved the way pi itself resolves it for this provider: the
|
|
121
|
+
* stored `coralbricks` credential in ~/.pi/agent/auth.json wins, then
|
|
122
|
+
* the CORALBRICKS_API_KEY environment variable.
|
|
123
|
+
*/
|
|
124
|
+
function resolveApiKey() {
|
|
125
|
+
try {
|
|
126
|
+
const auth = JSON.parse(fs.readFileSync(AUTH_JSON_PATH, 'utf8'));
|
|
127
|
+
const credential = auth?.coralbricks;
|
|
128
|
+
if (credential && credential.type === 'api_key' && typeof credential.key === 'string') {
|
|
129
|
+
const key = resolveConfigValue(credential.key, credential.env);
|
|
130
|
+
if (key) return key;
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
// Missing or unparseable auth.json: fall through to the env var.
|
|
134
|
+
}
|
|
135
|
+
return process.env.CORALBRICKS_API_KEY || undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const MODELS_API_URL = 'https://inference.coralbricks.ai/v1/models';
|
|
139
|
+
const PUBLIC_CATALOG_URL = 'https://www.coralbricks.ai/api/public/models';
|
|
140
|
+
const MODELS_JSON_PATH = path.join(__dirname, '..', 'models.json');
|
|
141
|
+
const PATCH_JSON_PATH = path.join(__dirname, '..', 'patch.json');
|
|
142
|
+
const CUSTOM_MODELS_JSON_PATH = path.join(__dirname, '..', 'custom-models.json');
|
|
143
|
+
const README_PATH = path.join(__dirname, '..', 'README.md');
|
|
144
|
+
|
|
145
|
+
// Output-token fallbacks for models whose API rows do not report a limit.
|
|
146
|
+
const DEFAULT_MAX_TOKENS = { 'gpt-oss-120b': 40960 };
|
|
147
|
+
const FALLBACK_MAX_TOKENS = 32768;
|
|
148
|
+
|
|
149
|
+
// Helpers
|
|
150
|
+
|
|
151
|
+
function loadJson(filePath) {
|
|
152
|
+
try {
|
|
153
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
154
|
+
} catch {
|
|
155
|
+
return {};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function saveJson(filePath, data) {
|
|
160
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
|
|
161
|
+
console.log(`✓ Saved ${path.basename(filePath)}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function toNumber(value) {
|
|
165
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function generateDisplayName(id) {
|
|
169
|
+
return id
|
|
170
|
+
.split(/[-_/]/)
|
|
171
|
+
.filter(Boolean)
|
|
172
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
173
|
+
.join(' ');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Parse "1M" / "128K" / "1048576" / 1048576 into a token count. */
|
|
177
|
+
function parseContextWindow(value, fallback = 131072) {
|
|
178
|
+
if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value;
|
|
179
|
+
if (typeof value === 'string') {
|
|
180
|
+
const match = value.trim().match(/^([\d.]+)\s*([KkMm])$/);
|
|
181
|
+
if (match) {
|
|
182
|
+
const n = parseFloat(match[1]);
|
|
183
|
+
if (Number.isFinite(n) && n > 0) {
|
|
184
|
+
return Math.round(n * (match[2].toUpperCase() === 'M' ? 1024 * 1024 : 1024));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const n = parseInt(value, 10);
|
|
188
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
189
|
+
}
|
|
190
|
+
return fallback;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// API fetch
|
|
194
|
+
|
|
195
|
+
async function fetchV1Models(apiKey) {
|
|
196
|
+
console.log(`Fetching models from ${MODELS_API_URL}...`);
|
|
197
|
+
const response = await fetch(MODELS_API_URL, {
|
|
198
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
199
|
+
});
|
|
200
|
+
if (!response.ok) {
|
|
201
|
+
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
|
202
|
+
}
|
|
203
|
+
const data = await response.json();
|
|
204
|
+
const models = data.data || [];
|
|
205
|
+
console.log(`✓ Fetched ${models.length} models from API`);
|
|
206
|
+
return models;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function fetchPublicCatalog() {
|
|
210
|
+
console.log(`Fetching public catalog from ${PUBLIC_CATALOG_URL}...`);
|
|
211
|
+
const response = await fetch(PUBLIC_CATALOG_URL);
|
|
212
|
+
if (!response.ok) {
|
|
213
|
+
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
|
214
|
+
}
|
|
215
|
+
const data = await response.json();
|
|
216
|
+
const models = data.models || [];
|
|
217
|
+
console.log(`✓ Fetched ${models.length} models from public catalog`);
|
|
218
|
+
return models;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function fetchModels() {
|
|
222
|
+
const apiKey = resolveApiKey();
|
|
223
|
+
if (apiKey) {
|
|
224
|
+
try {
|
|
225
|
+
return await fetchV1Models(apiKey);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
console.warn(`⚠ /v1/models failed (${error.message}); falling back to public catalog`);
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
console.warn('⚠ No API key found (no `coralbricks` credential in ' + AUTH_JSON_PATH + ', CORALBRICKS_API_KEY unset); using the public catalog');
|
|
231
|
+
}
|
|
232
|
+
return fetchPublicCatalog();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Transform API model → models.json entry
|
|
236
|
+
|
|
237
|
+
function transformApiModel(apiModel, existingModelsMap) {
|
|
238
|
+
const id = apiModel.id;
|
|
239
|
+
const pricing = apiModel.pricing || {};
|
|
240
|
+
const input = apiModel.supports_image_input ? ['text', 'image'] : ['text'];
|
|
241
|
+
const contextWindow = parseContextWindow(apiModel.context_length);
|
|
242
|
+
const maxTokens = DEFAULT_MAX_TOKENS[id] ?? FALLBACK_MAX_TOKENS;
|
|
243
|
+
|
|
244
|
+
// Preserve existing curated data (pricing, reasoning, compat, etc.)
|
|
245
|
+
if (existingModelsMap[id]) {
|
|
246
|
+
const existing = { ...existingModelsMap[id] };
|
|
247
|
+
if (contextWindow) existing.contextWindow = contextWindow;
|
|
248
|
+
if (apiModel.max_output_tokens) existing.maxTokens = apiModel.max_output_tokens;
|
|
249
|
+
if (typeof pricing.input_per_m === 'number') existing.cost.input = pricing.input_per_m;
|
|
250
|
+
if (typeof pricing.output_per_m === 'number') existing.cost.output = pricing.output_per_m;
|
|
251
|
+
existing.cost.cacheRead = toNumber(pricing.cached_input_per_m);
|
|
252
|
+
existing.input = input;
|
|
253
|
+
return existing;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// New model — build from API data + sensible defaults
|
|
257
|
+
return {
|
|
258
|
+
id,
|
|
259
|
+
name: apiModel.name || generateDisplayName(id),
|
|
260
|
+
reasoning: false,
|
|
261
|
+
input,
|
|
262
|
+
cost: {
|
|
263
|
+
input: toNumber(pricing.input_per_m),
|
|
264
|
+
output: toNumber(pricing.output_per_m),
|
|
265
|
+
cacheRead: toNumber(pricing.cached_input_per_m),
|
|
266
|
+
cacheWrite: 0,
|
|
267
|
+
},
|
|
268
|
+
contextWindow,
|
|
269
|
+
maxTokens,
|
|
270
|
+
compat: {
|
|
271
|
+
supportsDeveloperRole: false,
|
|
272
|
+
supportsStore: false,
|
|
273
|
+
maxTokensField: 'max_tokens',
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function transformCatalogModel(entry, existingModelsMap) {
|
|
279
|
+
const id = entry.slug;
|
|
280
|
+
const contextWindow = parseContextWindow(entry.contextWindow);
|
|
281
|
+
const maxTokens = DEFAULT_MAX_TOKENS[id] ?? FALLBACK_MAX_TOKENS;
|
|
282
|
+
|
|
283
|
+
if (existingModelsMap[id]) {
|
|
284
|
+
const existing = { ...existingModelsMap[id] };
|
|
285
|
+
if (contextWindow) existing.contextWindow = contextWindow;
|
|
286
|
+
if (typeof entry.inputPerM === 'number') existing.cost.input = entry.inputPerM;
|
|
287
|
+
if (typeof entry.outputPerM === 'number') existing.cost.output = entry.outputPerM;
|
|
288
|
+
return existing;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
id,
|
|
293
|
+
name: entry.name || generateDisplayName(id),
|
|
294
|
+
reasoning: false,
|
|
295
|
+
input: ['text'],
|
|
296
|
+
cost: {
|
|
297
|
+
input: toNumber(entry.inputPerM),
|
|
298
|
+
output: toNumber(entry.outputPerM),
|
|
299
|
+
cacheRead: 0,
|
|
300
|
+
cacheWrite: 0,
|
|
301
|
+
},
|
|
302
|
+
contextWindow,
|
|
303
|
+
maxTokens,
|
|
304
|
+
compat: {
|
|
305
|
+
supportsDeveloperRole: false,
|
|
306
|
+
supportsStore: false,
|
|
307
|
+
maxTokensField: 'max_tokens',
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Patch Application (mirrors index.ts)
|
|
313
|
+
|
|
314
|
+
function applyPatch(model, patch) {
|
|
315
|
+
// Deep-copy nested objects: the delete branches below must not mutate the
|
|
316
|
+
// caller's (embedded or cached) source data through shared references.
|
|
317
|
+
const result = {
|
|
318
|
+
...model,
|
|
319
|
+
cost: { ...model.cost },
|
|
320
|
+
compat: model.compat ? { ...model.compat } : undefined,
|
|
321
|
+
thinkingLevelMap: model.thinkingLevelMap ? { ...model.thinkingLevelMap } : undefined,
|
|
322
|
+
};
|
|
323
|
+
if (patch.name !== undefined) result.name = patch.name;
|
|
324
|
+
if (patch.reasoning !== undefined) result.reasoning = patch.reasoning;
|
|
325
|
+
if (patch.input !== undefined) result.input = patch.input;
|
|
326
|
+
if (patch.contextWindow !== undefined) result.contextWindow = patch.contextWindow;
|
|
327
|
+
if (patch.maxTokens !== undefined) result.maxTokens = patch.maxTokens;
|
|
328
|
+
if (patch.thinkingLevelMap !== undefined) result.thinkingLevelMap = patch.thinkingLevelMap;
|
|
329
|
+
if (patch.cost) {
|
|
330
|
+
result.cost = {
|
|
331
|
+
input: patch.cost.input ?? result.cost.input,
|
|
332
|
+
output: patch.cost.output ?? result.cost.output,
|
|
333
|
+
cacheRead: patch.cost.cacheRead ?? result.cost.cacheRead,
|
|
334
|
+
cacheWrite: patch.cost.cacheWrite ?? result.cost.cacheWrite,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
if (patch.compat) {
|
|
338
|
+
result.compat = { ...(result.compat || {}), ...patch.compat };
|
|
339
|
+
}
|
|
340
|
+
if (!result.reasoning && result.compat?.thinkingFormat) {
|
|
341
|
+
delete result.compat.thinkingFormat;
|
|
342
|
+
}
|
|
343
|
+
if (!result.reasoning && result.thinkingLevelMap) {
|
|
344
|
+
delete result.thinkingLevelMap;
|
|
345
|
+
}
|
|
346
|
+
if (result.compat && Object.keys(result.compat).length === 0) {
|
|
347
|
+
delete result.compat;
|
|
348
|
+
}
|
|
349
|
+
return result;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Full pipeline: base models → patch → custom → result */
|
|
353
|
+
function buildModels(baseModels, customModels, patchData) {
|
|
354
|
+
const modelMap = new Map();
|
|
355
|
+
for (const model of baseModels) {
|
|
356
|
+
modelMap.set(model.id, model);
|
|
357
|
+
}
|
|
358
|
+
for (const [id, patchEntry] of Object.entries(patchData)) {
|
|
359
|
+
const existing = modelMap.get(id);
|
|
360
|
+
if (existing) {
|
|
361
|
+
modelMap.set(id, applyPatch(existing, patchEntry));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
for (const model of customModels) {
|
|
365
|
+
const existing = modelMap.get(model.id);
|
|
366
|
+
const patchEntry = patchData[model.id];
|
|
367
|
+
if (existing && patchEntry) {
|
|
368
|
+
modelMap.set(model.id, applyPatch(model, patchEntry));
|
|
369
|
+
} else if (existing) {
|
|
370
|
+
modelMap.set(model.id, model);
|
|
371
|
+
} else if (patchEntry) {
|
|
372
|
+
modelMap.set(model.id, applyPatch(model, patchEntry));
|
|
373
|
+
} else {
|
|
374
|
+
modelMap.set(model.id, model);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return Array.from(modelMap.values());
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// README generation
|
|
381
|
+
|
|
382
|
+
function formatContext(n) {
|
|
383
|
+
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
|
|
384
|
+
if (n >= 1000) return `${Math.round(n / 1000)}K`;
|
|
385
|
+
return n.toString();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function formatCost(cost) {
|
|
389
|
+
if (cost === 0 || cost === null || cost === undefined) return '—';
|
|
390
|
+
return `$${cost.toFixed(2)}`;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function generateReadmeTable(models) {
|
|
394
|
+
const lines = [
|
|
395
|
+
'| Model | Context | Vision | Reasoning | Input $/M | Cache Read $/M | Output $/M |',
|
|
396
|
+
'|-------|---------|--------|-----------|-----------|-----------------|------------|',
|
|
397
|
+
];
|
|
398
|
+
|
|
399
|
+
for (const model of models) {
|
|
400
|
+
const context = formatContext(model.contextWindow);
|
|
401
|
+
const vision = model.input.includes('image') ? '✅' : '❌';
|
|
402
|
+
const reasoning = model.reasoning ? '✅' : '❌';
|
|
403
|
+
const inputCost = formatCost(model.cost.input);
|
|
404
|
+
const cacheReadCost = formatCost(model.cost.cacheRead);
|
|
405
|
+
const outputCost = formatCost(model.cost.output);
|
|
406
|
+
|
|
407
|
+
lines.push(`| ${model.name} | ${context} | ${vision} | ${reasoning} | ${inputCost} | ${cacheReadCost} | ${outputCost} |`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return lines.join('\n');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function updateReadme(models) {
|
|
414
|
+
let readme = fs.readFileSync(README_PATH, 'utf8');
|
|
415
|
+
const newTable = generateReadmeTable(models);
|
|
416
|
+
|
|
417
|
+
const tableRegex = /(## Available Models\n\n)\| Model \|[^\n]+\|\n\|[-| ]+\|(\n\|[^\n]+\|)*\n*/;
|
|
418
|
+
|
|
419
|
+
if (tableRegex.test(readme)) {
|
|
420
|
+
readme = readme.replace(tableRegex, (match, header) => `${header}${newTable}\n\n`);
|
|
421
|
+
fs.writeFileSync(README_PATH, readme);
|
|
422
|
+
console.log('✓ Updated README.md');
|
|
423
|
+
} else {
|
|
424
|
+
console.warn('⚠ Could not find model table in "## Available Models" section');
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Grace period for delisted models: update-models.js moves models the API no
|
|
429
|
+
// longer lists into deprecated-models.json (stamped with deprecatedAt) instead
|
|
430
|
+
// of dropping them; the runtime appends them back so sessions and saved model
|
|
431
|
+
// settings keep working, and after 14 days they are evicted permanently.
|
|
432
|
+
const DEPRECATED_MODEL_TTL_MS = 14 * 24 * 60 * 60 * 1000;
|
|
433
|
+
|
|
434
|
+
function updateDeprecatedModels(modelsJsonPath, newModels) {
|
|
435
|
+
const deprecatedPath = path.join(path.dirname(modelsJsonPath), 'deprecated-models.json');
|
|
436
|
+
|
|
437
|
+
let oldModels = [];
|
|
438
|
+
try {
|
|
439
|
+
const parsed = JSON.parse(fs.readFileSync(modelsJsonPath, 'utf8'));
|
|
440
|
+
if (Array.isArray(parsed)) oldModels = parsed;
|
|
441
|
+
} catch { /* first run: no previous models.json */ }
|
|
442
|
+
|
|
443
|
+
let deprecated = {};
|
|
444
|
+
try {
|
|
445
|
+
const parsed = JSON.parse(fs.readFileSync(deprecatedPath, 'utf8'));
|
|
446
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) deprecated = parsed;
|
|
447
|
+
} catch { /* no graveyard yet */ }
|
|
448
|
+
|
|
449
|
+
const currentIds = new Set(newModels.map((m) => m.id));
|
|
450
|
+
const now = new Date().toISOString();
|
|
451
|
+
const added = [];
|
|
452
|
+
const resurrected = [];
|
|
453
|
+
const evicted = [];
|
|
454
|
+
|
|
455
|
+
for (const old of oldModels) {
|
|
456
|
+
if (old && old.id && !currentIds.has(old.id) && !deprecated[old.id]) {
|
|
457
|
+
deprecated[old.id] = { ...old, deprecatedAt: now };
|
|
458
|
+
added.push(old.id);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
for (const [id, entry] of Object.entries(deprecated)) {
|
|
463
|
+
if (currentIds.has(id)) {
|
|
464
|
+
delete deprecated[id];
|
|
465
|
+
resurrected.push(id);
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
const removedAt = Date.parse(entry && entry.deprecatedAt ? entry.deprecatedAt : '');
|
|
469
|
+
if (Number.isNaN(removedAt) || Date.now() - removedAt > DEPRECATED_MODEL_TTL_MS) {
|
|
470
|
+
delete deprecated[id];
|
|
471
|
+
evicted.push(id);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (added.length > 0 || resurrected.length > 0 || evicted.length > 0) {
|
|
476
|
+
fs.writeFileSync(deprecatedPath, JSON.stringify(deprecated, null, 2) + '\n');
|
|
477
|
+
console.log('Updated deprecated-models.json ' + JSON.stringify({ added, resurrected, evicted }));
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function withDeprecatedForReadme(models) {
|
|
482
|
+
const deprecatedPath = path.join(path.dirname(MODELS_JSON_PATH), 'deprecated-models.json');
|
|
483
|
+
let deprecated = {};
|
|
484
|
+
try {
|
|
485
|
+
const parsed = JSON.parse(fs.readFileSync(deprecatedPath, 'utf8'));
|
|
486
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) deprecated = parsed;
|
|
487
|
+
} catch { /* no graveyard yet */ }
|
|
488
|
+
const now = Date.now();
|
|
489
|
+
const seen = new Set(models.map((m) => m.id));
|
|
490
|
+
const extras = [];
|
|
491
|
+
for (const entry of Object.values(deprecated)) {
|
|
492
|
+
if (!entry || !entry.id || seen.has(entry.id)) continue;
|
|
493
|
+
const removedAt = Date.parse(entry.deprecatedAt || '');
|
|
494
|
+
if (Number.isNaN(removedAt) || now - removedAt > DEPRECATED_MODEL_TTL_MS) continue;
|
|
495
|
+
const m = { ...entry };
|
|
496
|
+
delete m.deprecatedAt;
|
|
497
|
+
extras.push(m);
|
|
498
|
+
}
|
|
499
|
+
return extras.length > 0 ? [...models, ...extras] : models;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function main() {
|
|
503
|
+
try {
|
|
504
|
+
// Regenerate the derived README table from local source data without an API
|
|
505
|
+
// key. This is useful for offline workflows while curating custom models.
|
|
506
|
+
if (process.argv.includes('--readme-only')) {
|
|
507
|
+
const baseModels = loadJson(MODELS_JSON_PATH);
|
|
508
|
+
const patchData = loadJson(PATCH_JSON_PATH);
|
|
509
|
+
const customModels = loadJson(CUSTOM_MODELS_JSON_PATH);
|
|
510
|
+
const readmeBase = withDeprecatedForReadme(Array.isArray(baseModels) ? baseModels : []);
|
|
511
|
+
const readmeModels = buildModels(readmeBase, Array.isArray(customModels) ? customModels : [], patchData);
|
|
512
|
+
readmeModels.sort((a, b) => a.name.localeCompare(b.name));
|
|
513
|
+
updateReadme(readmeModels);
|
|
514
|
+
console.log('✓ Regenerated README.md from local model data');
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const apiModels = await fetchModels();
|
|
519
|
+
|
|
520
|
+
// Load existing models.json — source of truth for curated specs
|
|
521
|
+
const existingModels = loadJson(MODELS_JSON_PATH);
|
|
522
|
+
const existingModelsMap = {};
|
|
523
|
+
for (const m of (Array.isArray(existingModels) ? existingModels : [])) {
|
|
524
|
+
existingModelsMap[m.id] = m;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Transform API models, preserving existing data where available
|
|
528
|
+
const isCatalogShape = apiModels.length > 0 && apiModels.every((m) => m && m.slug && m.id === undefined);
|
|
529
|
+
let models = apiModels.map((m) =>
|
|
530
|
+
isCatalogShape ? transformCatalogModel(m, existingModelsMap) : transformApiModel(m, existingModelsMap)
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
// Live API is authoritative — models absent from API are removed
|
|
534
|
+
// (embedded data is already used for enrichment in transformApiModel)
|
|
535
|
+
|
|
536
|
+
// Sort by model name
|
|
537
|
+
models.sort((a, b) => a.name.localeCompare(b.name));
|
|
538
|
+
|
|
539
|
+
// Save models.json (pure API output, no patch/custom baked in)
|
|
540
|
+
// Move delisted models to deprecated-models.json BEFORE models.json is overwritten
|
|
541
|
+
updateDeprecatedModels(MODELS_JSON_PATH, models);
|
|
542
|
+
saveJson(MODELS_JSON_PATH, models);
|
|
543
|
+
|
|
544
|
+
// Build full model list for README: (base + grace-period deprecated) → patch → custom.
|
|
545
|
+
const patchData = loadJson(PATCH_JSON_PATH);
|
|
546
|
+
const customModels = loadJson(CUSTOM_MODELS_JSON_PATH);
|
|
547
|
+
const readmeBase = withDeprecatedForReadme(models);
|
|
548
|
+
const readmeModels = buildModels(readmeBase, Array.isArray(customModels) ? customModels : [], patchData);
|
|
549
|
+
readmeModels.sort((a, b) => a.name.localeCompare(b.name));
|
|
550
|
+
|
|
551
|
+
// Update README
|
|
552
|
+
updateReadme(readmeModels);
|
|
553
|
+
|
|
554
|
+
// Summary
|
|
555
|
+
const newIds = new Set(models.map((m) => m.id));
|
|
556
|
+
const oldIds = new Set(Object.keys(existingModelsMap));
|
|
557
|
+
const added = [...newIds].filter((id) => !oldIds.has(id));
|
|
558
|
+
const removed = [...oldIds].filter((id) => !newIds.has(id));
|
|
559
|
+
|
|
560
|
+
console.log('\n--- Summary ---');
|
|
561
|
+
console.log(`Total models: ${models.length}`);
|
|
562
|
+
console.log(`Reasoning models: ${models.filter((m) => m.reasoning).length}`);
|
|
563
|
+
console.log(`Vision models: ${models.filter((m) => m.input.includes('image')).length}`);
|
|
564
|
+
if (added.length > 0) console.log(`New models: ${added.join(', ')} — curate models.json manually`);
|
|
565
|
+
if (removed.length > 0) console.log(`Removed models: ${removed.join(', ')}`);
|
|
566
|
+
} catch (error) {
|
|
567
|
+
console.error('Error:', error.message);
|
|
568
|
+
process.exit(1);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
main();
|