pi-tarmis-provider 1.0.7
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/AGENTS.md +82 -0
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/custom-models.json +1 -0
- package/deprecated-models.json +1 -0
- package/index.ts +463 -0
- package/models.json +143 -0
- package/package.json +61 -0
- package/patch.json +34 -0
- package/scripts/update-models.js +501 -0
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Update Tarmis models from API
|
|
4
|
+
*
|
|
5
|
+
* Fetches models from https://api.tarmis.ai/v1/models (public — no API key
|
|
6
|
+
* required) and updates:
|
|
7
|
+
* - models.json: model definitions built from the API's rich metadata
|
|
8
|
+
* - README.md: Model table with patch.json overrides applied
|
|
9
|
+
*
|
|
10
|
+
* Tarmis /v1/models returns per-model metadata (name, per-token pricing,
|
|
11
|
+
* context_length, max_output_length, input_modalities, supported_features, etc.).
|
|
12
|
+
* transformModel derives reasoning, vision, pricing, context window, and max
|
|
13
|
+
* output directly from that metadata. patch.json overrides anything the API
|
|
14
|
+
* can't tell us (e.g. a model's reasoning wire format).
|
|
15
|
+
*
|
|
16
|
+
* Merge order for README: models.json -> apply patch.json -> merge custom-models.json
|
|
17
|
+
*
|
|
18
|
+
* API key: optional. The stored `tarmis` credential in ~/.pi/agent/auth.json (or
|
|
19
|
+
* TARMIS_API_KEY) is sent when it resolves; the public endpoint returned the full
|
|
20
|
+
* catalog unauthenticated at last check, so running without one is acceptable.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import fs from 'fs';
|
|
24
|
+
import os from 'os';
|
|
25
|
+
import { execSync } from 'child_process';
|
|
26
|
+
import path from 'path';
|
|
27
|
+
import { fileURLToPath } from 'url';
|
|
28
|
+
|
|
29
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
|
|
31
|
+
// pi's agent directory: PI_CODING_AGENT_DIR (with ~ expansion) or ~/.pi/agent.
|
|
32
|
+
function piAgentDir() {
|
|
33
|
+
const envDir = process.env.PI_CODING_AGENT_DIR;
|
|
34
|
+
if (envDir) {
|
|
35
|
+
return envDir.startsWith('~/') || envDir === '~'
|
|
36
|
+
? path.join(os.homedir(), envDir.slice(1))
|
|
37
|
+
: envDir;
|
|
38
|
+
}
|
|
39
|
+
return path.join(os.homedir(), '.pi', 'agent');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const AUTH_JSON_PATH = path.join(piAgentDir(), 'auth.json');
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve a configured value using pi's semantics (resolve-config-value.ts in
|
|
46
|
+
* pi-mono): "!command" runs via the shell (10s timeout) and uses trimmed
|
|
47
|
+
* stdout; "$VAR" / "${VAR}" interpolate environment variables ("$$" escapes a
|
|
48
|
+
* literal "$", "$!" a literal "!"); anything else is a literal. Returns
|
|
49
|
+
* undefined when a referenced env var is unset or a command fails.
|
|
50
|
+
*/
|
|
51
|
+
function resolveConfigValue(config, env) {
|
|
52
|
+
if (typeof config !== 'string' || config.length === 0) return undefined;
|
|
53
|
+
if (config.startsWith('!')) {
|
|
54
|
+
try {
|
|
55
|
+
const out = execSync(config.slice(1), {
|
|
56
|
+
encoding: 'utf8',
|
|
57
|
+
timeout: 10000,
|
|
58
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
59
|
+
});
|
|
60
|
+
return out.trim() || undefined;
|
|
61
|
+
} catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
66
|
+
let resolved = '';
|
|
67
|
+
let index = 0;
|
|
68
|
+
while (index < config.length) {
|
|
69
|
+
const dollar = config.indexOf('$', index);
|
|
70
|
+
if (dollar < 0) {
|
|
71
|
+
resolved += config.slice(index);
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
resolved += config.slice(index, dollar);
|
|
75
|
+
const next = config[dollar + 1];
|
|
76
|
+
let name;
|
|
77
|
+
if (next === '$' || next === '!') {
|
|
78
|
+
resolved += next;
|
|
79
|
+
index = dollar + 2;
|
|
80
|
+
continue;
|
|
81
|
+
} else if (next === '{') {
|
|
82
|
+
const end = config.indexOf('}', dollar + 2);
|
|
83
|
+
if (end < 0) {
|
|
84
|
+
resolved += '$';
|
|
85
|
+
index = dollar + 1;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const inner = config.slice(dollar + 2, end);
|
|
89
|
+
if (!ENV_NAME_RE.test(inner)) {
|
|
90
|
+
resolved += config.slice(dollar, end + 1);
|
|
91
|
+
index = end + 1;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
name = inner;
|
|
95
|
+
index = end + 1;
|
|
96
|
+
} else {
|
|
97
|
+
const match = config.slice(dollar + 1).match(/^[A-Za-z_][A-Za-z0-9_]*/);
|
|
98
|
+
if (!match) {
|
|
99
|
+
resolved += '$';
|
|
100
|
+
index = dollar + 1;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
name = match[0];
|
|
104
|
+
index = dollar + 1 + name.length;
|
|
105
|
+
}
|
|
106
|
+
const value = (env && env[name]) || process.env[name] || undefined;
|
|
107
|
+
if (value === undefined) return undefined;
|
|
108
|
+
resolved += value;
|
|
109
|
+
}
|
|
110
|
+
return resolved;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The API key, resolved the way pi itself resolves it for this provider: the
|
|
115
|
+
* stored `tarmis` credential in ~/.pi/agent/auth.json wins, then
|
|
116
|
+
* the TARMIS_API_KEY environment variable.
|
|
117
|
+
*/
|
|
118
|
+
function resolveApiKey() {
|
|
119
|
+
try {
|
|
120
|
+
const auth = JSON.parse(fs.readFileSync(AUTH_JSON_PATH, 'utf8'));
|
|
121
|
+
const credential = auth?.tarmis;
|
|
122
|
+
if (credential && credential.type === 'api_key' && typeof credential.key === 'string') {
|
|
123
|
+
const key = resolveConfigValue(credential.key, credential.env);
|
|
124
|
+
if (key) return key;
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
// Missing or unparseable auth.json: fall through to the env var.
|
|
128
|
+
}
|
|
129
|
+
return process.env.TARMIS_API_KEY || undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const MODELS_API_URL = 'https://api.tarmis.ai/v1/models';
|
|
133
|
+
const MODELS_JSON_PATH = path.join(__dirname, '..', 'models.json');
|
|
134
|
+
const PATCH_JSON_PATH = path.join(__dirname, '..', 'patch.json');
|
|
135
|
+
const CUSTOM_MODELS_JSON_PATH = path.join(__dirname, '..', 'custom-models.json');
|
|
136
|
+
const README_PATH = path.join(__dirname, '..', 'README.md');
|
|
137
|
+
|
|
138
|
+
const PER_TOKEN_TO_PER_MILLION = 1_000_000;
|
|
139
|
+
const COST_ROUND_FACTOR = 1_000_000;
|
|
140
|
+
|
|
141
|
+
// ─── Patch application ────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
function applyPatch(model, patch) {
|
|
144
|
+
const result = { ...model };
|
|
145
|
+
|
|
146
|
+
if (patch.name !== undefined) result.name = patch.name;
|
|
147
|
+
if (patch.reasoning !== undefined) result.reasoning = patch.reasoning;
|
|
148
|
+
if (patch.input !== undefined) result.input = patch.input;
|
|
149
|
+
if (patch.contextWindow !== undefined) result.contextWindow = patch.contextWindow;
|
|
150
|
+
if (patch.maxTokens !== undefined) result.maxTokens = patch.maxTokens;
|
|
151
|
+
if (patch.thinkingLevelMap !== undefined) result.thinkingLevelMap = { ...patch.thinkingLevelMap };
|
|
152
|
+
|
|
153
|
+
if (patch.cost) {
|
|
154
|
+
result.cost = {
|
|
155
|
+
input: patch.cost.input ?? result.cost.input,
|
|
156
|
+
output: patch.cost.output ?? result.cost.output,
|
|
157
|
+
cacheRead: patch.cost.cacheRead ?? result.cost.cacheRead,
|
|
158
|
+
cacheWrite: patch.cost.cacheWrite ?? result.cost.cacheWrite,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (patch.compat) {
|
|
162
|
+
result.compat = { ...(result.compat || {}), ...patch.compat };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!result.reasoning && result.compat?.thinkingFormat) {
|
|
166
|
+
delete result.compat.thinkingFormat;
|
|
167
|
+
}
|
|
168
|
+
if (result.compat && Object.keys(result.compat).length === 0) {
|
|
169
|
+
delete result.compat;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function buildModels(baseModels, customModels, patchData) {
|
|
176
|
+
const modelMap = new Map();
|
|
177
|
+
|
|
178
|
+
for (const model of baseModels) {
|
|
179
|
+
modelMap.set(model.id, model);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (const [id, patchEntry] of Object.entries(patchData)) {
|
|
183
|
+
const existing = modelMap.get(id);
|
|
184
|
+
if (existing) {
|
|
185
|
+
modelMap.set(id, applyPatch(existing, patchEntry));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const model of customModels) {
|
|
190
|
+
const existing = modelMap.get(model.id);
|
|
191
|
+
const patchEntry = patchData[model.id];
|
|
192
|
+
if (existing && patchEntry) {
|
|
193
|
+
modelMap.set(model.id, applyPatch(model, patchEntry));
|
|
194
|
+
} else if (existing) {
|
|
195
|
+
modelMap.set(model.id, model);
|
|
196
|
+
} else if (patchEntry) {
|
|
197
|
+
modelMap.set(model.id, applyPatch(model, patchEntry));
|
|
198
|
+
} else {
|
|
199
|
+
modelMap.set(model.id, model);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return Array.from(modelMap.values());
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ─── Tarmis /v1/models transform ─────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
function toPerMillion(perToken) {
|
|
209
|
+
if (perToken === undefined || perToken === null) return 0;
|
|
210
|
+
const n = typeof perToken === 'number' ? perToken : parseFloat(String(perToken));
|
|
211
|
+
if (!Number.isFinite(n) || n <= 0) return 0;
|
|
212
|
+
const perMillion = n * PER_TOKEN_TO_PER_MILLION;
|
|
213
|
+
return Math.round(perMillion * COST_ROUND_FACTOR) / COST_ROUND_FACTOR;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function cleanDisplayName(name, id) {
|
|
217
|
+
if (typeof name === 'string' && name.trim().length > 0) {
|
|
218
|
+
const stripped = name.replace(/\s*\([^)]*\)\s*$/, '').trim();
|
|
219
|
+
if (stripped.length > 0) return stripped;
|
|
220
|
+
}
|
|
221
|
+
const parts = id.split('/');
|
|
222
|
+
const rawName = parts.length > 1 ? parts.slice(1).join('/') : id;
|
|
223
|
+
return rawName.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function transformModel(apiModel) {
|
|
227
|
+
if (!apiModel || typeof apiModel.id !== 'string' || apiModel.id.length === 0) return null;
|
|
228
|
+
|
|
229
|
+
const features = Array.isArray(apiModel.supported_features) ? apiModel.supported_features : [];
|
|
230
|
+
const reasoning = features.some((f) => typeof f === 'string' && f.toLowerCase() === 'reasoning');
|
|
231
|
+
|
|
232
|
+
const inputMods = Array.isArray(apiModel.input_modalities) ? apiModel.input_modalities : ['text'];
|
|
233
|
+
const hasImage = inputMods.some((m) => typeof m === 'string' && m.toLowerCase().includes('image'));
|
|
234
|
+
|
|
235
|
+
const pricingEntry =
|
|
236
|
+
Array.isArray(apiModel.pricing) && apiModel.pricing.length > 0 && typeof apiModel.pricing[0] === 'object'
|
|
237
|
+
? apiModel.pricing[0]
|
|
238
|
+
: {};
|
|
239
|
+
|
|
240
|
+
const inputCost = toPerMillion(pricingEntry.prompt ?? pricingEntry.input);
|
|
241
|
+
const outputCost = toPerMillion(pricingEntry.completion ?? pricingEntry.output);
|
|
242
|
+
const cacheRead = toPerMillion(pricingEntry.input_cache_read ?? pricingEntry.cache_read);
|
|
243
|
+
|
|
244
|
+
const isFree = apiModel.is_free === true || (inputCost === 0 && outputCost === 0);
|
|
245
|
+
|
|
246
|
+
const contextLength = Number(apiModel.context_length);
|
|
247
|
+
const maxOutputLength = Number(apiModel.max_output_length);
|
|
248
|
+
const contextWindow = Number.isFinite(contextLength) && contextLength > 0 ? contextLength : 131072;
|
|
249
|
+
const maxTokens =
|
|
250
|
+
Number.isFinite(maxOutputLength) && maxOutputLength > 0 ? maxOutputLength : contextWindow;
|
|
251
|
+
|
|
252
|
+
const model = {
|
|
253
|
+
id: apiModel.id,
|
|
254
|
+
name: cleanDisplayName(apiModel.name, apiModel.id),
|
|
255
|
+
reasoning,
|
|
256
|
+
input: hasImage ? ['text', 'image'] : ['text'],
|
|
257
|
+
cost: { input: inputCost, output: outputCost, cacheRead, cacheWrite: 0 },
|
|
258
|
+
contextWindow,
|
|
259
|
+
maxTokens,
|
|
260
|
+
_meta: { isFree },
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
if (reasoning) {
|
|
264
|
+
model.compat = {
|
|
265
|
+
thinkingFormat: 'openai',
|
|
266
|
+
supportsReasoningEffort: true,
|
|
267
|
+
supportsDeveloperRole: false,
|
|
268
|
+
};
|
|
269
|
+
model.thinkingLevelMap = { minimal: null, low: null, medium: null, high: 'high', max: 'max' };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return model;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ─── File I/O ─────────────────────────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
function loadJson(filePath, fallback) {
|
|
278
|
+
try {
|
|
279
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
280
|
+
} catch {
|
|
281
|
+
return fallback;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ─── README generation ────────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
function formatCost(cost, isFree) {
|
|
288
|
+
if (isFree) return '**Free**';
|
|
289
|
+
if (cost === 0) return '-';
|
|
290
|
+
return `$${cost.toFixed(2)}`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function formatContextWindow(n) {
|
|
294
|
+
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
|
|
295
|
+
if (n >= 1000) return `${Math.round(n / 1000)}K`;
|
|
296
|
+
return n.toString();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function generateReadmeTable(models) {
|
|
300
|
+
const lines = [
|
|
301
|
+
'| Model | Context | Vision | Reasoning | Input $/M | Output $/M |',
|
|
302
|
+
'|-------|---------|--------|-----------|-----------|------------|',
|
|
303
|
+
];
|
|
304
|
+
|
|
305
|
+
for (const model of models) {
|
|
306
|
+
const name = model.name;
|
|
307
|
+
const context = formatContextWindow(model.contextWindow);
|
|
308
|
+
const vision = model.input.includes('image') ? '✅' : '❌';
|
|
309
|
+
const reasoning = model.reasoning ? '✅' : '❌';
|
|
310
|
+
const isFree = model._meta?.isFree ?? (model.cost.input === 0 && model.cost.output === 0);
|
|
311
|
+
const inputCost = formatCost(model.cost.input, isFree);
|
|
312
|
+
const outputCost = formatCost(model.cost.output, isFree);
|
|
313
|
+
|
|
314
|
+
lines.push(`| ${name} | ${context} | ${vision} | ${reasoning} | ${inputCost} | ${outputCost} |`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return lines.join('\n');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function updateReadme(models) {
|
|
321
|
+
let readme = fs.readFileSync(README_PATH, 'utf8');
|
|
322
|
+
const newTable = generateReadmeTable(models);
|
|
323
|
+
|
|
324
|
+
const tableRegex = /(## Available Models\n\n)\| Model \| Context \| Vision \| Reasoning \| Input \$\/M \| Output \$\/M \|\n\|[-| ]+\|(\n\|[^\n]+\|)*\n*/;
|
|
325
|
+
|
|
326
|
+
if (tableRegex.test(readme)) {
|
|
327
|
+
readme = readme.replace(tableRegex, (match, header) => `${header}${newTable}\n\n`);
|
|
328
|
+
fs.writeFileSync(README_PATH, readme);
|
|
329
|
+
console.log('✓ Updated README.md');
|
|
330
|
+
} else {
|
|
331
|
+
console.warn('⚠ Could not find model table in "## Available Models" section');
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function cleanModelForJson(model) {
|
|
336
|
+
const { _meta, ...cleanModel } = model;
|
|
337
|
+
return cleanModel;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ─── Main ──────────────────────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
// Grace period for delisted models: update-models.js moves models the API no
|
|
343
|
+
// longer lists into deprecated-models.json (stamped with deprecatedAt) instead
|
|
344
|
+
// of dropping them; the runtime appends them back so sessions and saved model
|
|
345
|
+
// settings keep working, and after 14 days they are evicted permanently.
|
|
346
|
+
const DEPRECATED_MODEL_TTL_MS = 14 * 24 * 60 * 60 * 1000;
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Reconcile deprecated-models.json against the freshly fetched model list.
|
|
350
|
+
* - in old models.json but not the API: moved into the deprecated file
|
|
351
|
+
* (deprecatedAt = now; preserved on repeat runs so the grace clock is not reset)
|
|
352
|
+
* - back in the API: resurrected (dropped from the deprecated file)
|
|
353
|
+
* - deprecatedAt older than 14 days: evicted permanently
|
|
354
|
+
* Must run BEFORE the new models.json is written; it reads the old file itself.
|
|
355
|
+
*/
|
|
356
|
+
function updateDeprecatedModels(modelsJsonPath, newModels) {
|
|
357
|
+
const deprecatedPath = path.join(path.dirname(modelsJsonPath), 'deprecated-models.json');
|
|
358
|
+
|
|
359
|
+
let oldModels = [];
|
|
360
|
+
try {
|
|
361
|
+
const parsed = JSON.parse(fs.readFileSync(modelsJsonPath, 'utf8'));
|
|
362
|
+
if (Array.isArray(parsed)) oldModels = parsed;
|
|
363
|
+
} catch { /* first run: no previous models.json */ }
|
|
364
|
+
|
|
365
|
+
let deprecated = {};
|
|
366
|
+
try {
|
|
367
|
+
const parsed = JSON.parse(fs.readFileSync(deprecatedPath, 'utf8'));
|
|
368
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) deprecated = parsed;
|
|
369
|
+
} catch { /* no graveyard yet */ }
|
|
370
|
+
|
|
371
|
+
const currentIds = new Set(newModels.map((m) => m.id));
|
|
372
|
+
const now = new Date().toISOString();
|
|
373
|
+
const added = [];
|
|
374
|
+
const resurrected = [];
|
|
375
|
+
const evicted = [];
|
|
376
|
+
|
|
377
|
+
for (const old of oldModels) {
|
|
378
|
+
if (old && old.id && !currentIds.has(old.id) && !deprecated[old.id]) {
|
|
379
|
+
deprecated[old.id] = { ...old, deprecatedAt: now };
|
|
380
|
+
added.push(old.id);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
for (const [id, entry] of Object.entries(deprecated)) {
|
|
385
|
+
if (currentIds.has(id)) {
|
|
386
|
+
delete deprecated[id];
|
|
387
|
+
resurrected.push(id);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
const removedAt = Date.parse(entry && entry.deprecatedAt ? entry.deprecatedAt : '');
|
|
391
|
+
if (Number.isNaN(removedAt) || Date.now() - removedAt > DEPRECATED_MODEL_TTL_MS) {
|
|
392
|
+
delete deprecated[id];
|
|
393
|
+
evicted.push(id);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (added.length > 0 || resurrected.length > 0 || evicted.length > 0) {
|
|
398
|
+
fs.writeFileSync(deprecatedPath, JSON.stringify(deprecated, null, 2) + '\n');
|
|
399
|
+
console.log('Updated deprecated-models.json ' + JSON.stringify({ added, resurrected, evicted }));
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function main() {
|
|
404
|
+
console.log(`Fetching models from ${MODELS_API_URL}...`);
|
|
405
|
+
|
|
406
|
+
try {
|
|
407
|
+
const apiKey = resolveApiKey();
|
|
408
|
+
// Tarmis /v1/models is public and complete (verified against the curated
|
|
409
|
+
// catalog); send the credential when one resolves, proceed without it.
|
|
410
|
+
const headers = apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
|
|
411
|
+
|
|
412
|
+
const response = await fetch(MODELS_API_URL, { headers });
|
|
413
|
+
if (!response.ok) {
|
|
414
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const apiResponse = await response.json();
|
|
418
|
+
const apiModels = apiResponse.data || apiResponse;
|
|
419
|
+
|
|
420
|
+
if (!Array.isArray(apiModels)) {
|
|
421
|
+
throw new Error('API response does not contain an array of models');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
console.log(`✓ Fetched ${apiModels.length} models from API`);
|
|
425
|
+
|
|
426
|
+
// Transform models from API metadata (filter out anything unparseable)
|
|
427
|
+
let apiTransformed = apiModels
|
|
428
|
+
.map(transformModel)
|
|
429
|
+
.filter((m) => m !== null);
|
|
430
|
+
console.log(`✓ Transformed ${apiTransformed.length} chat models`);
|
|
431
|
+
|
|
432
|
+
// Sort by id for stable diffs
|
|
433
|
+
apiTransformed.sort((a, b) => a.id.localeCompare(b.id));
|
|
434
|
+
|
|
435
|
+
// Load existing models.json — used to flag what's new, and as the prior
|
|
436
|
+
// snapshot for deprecated-models.json reconciliation.
|
|
437
|
+
let existingModels = [];
|
|
438
|
+
try {
|
|
439
|
+
existingModels = JSON.parse(fs.readFileSync(MODELS_JSON_PATH, 'utf8'));
|
|
440
|
+
} catch {
|
|
441
|
+
// File might not exist or be invalid
|
|
442
|
+
}
|
|
443
|
+
const existingModelsMap = {};
|
|
444
|
+
for (const m of existingModels) {
|
|
445
|
+
existingModelsMap[m.id] = m;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Update models.json — API-derived model list
|
|
449
|
+
const cleanModels = apiTransformed.map(cleanModelForJson);
|
|
450
|
+
// Move delisted models to deprecated-models.json BEFORE models.json is overwritten
|
|
451
|
+
updateDeprecatedModels(MODELS_JSON_PATH, cleanModels);
|
|
452
|
+
fs.writeFileSync(MODELS_JSON_PATH, JSON.stringify(cleanModels, null, 2) + '\n');
|
|
453
|
+
console.log('✓ Updated models.json (API-derived model list)');
|
|
454
|
+
|
|
455
|
+
// Log new models not yet in patch.json
|
|
456
|
+
const patch = loadJson(PATCH_JSON_PATH, {});
|
|
457
|
+
for (const m of apiTransformed) {
|
|
458
|
+
if (!patch[m.id] && !existingModelsMap[m.id]) {
|
|
459
|
+
const feature = m.reasoning ? ' (reasoning)' : '';
|
|
460
|
+
console.log(` 🆕 New model: ${m.id} (${m.name})${feature} — add to patch.json for reasoning compat overrides if needed`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Build full model list for README: base -> patch -> custom
|
|
465
|
+
const customModels = loadJson(CUSTOM_MODELS_JSON_PATH, []);
|
|
466
|
+
const readmeModels = buildModels(
|
|
467
|
+
apiTransformed,
|
|
468
|
+
Array.isArray(customModels) ? customModels : [],
|
|
469
|
+
patch
|
|
470
|
+
);
|
|
471
|
+
readmeModels.sort((a, b) => a.name.localeCompare(b.name));
|
|
472
|
+
console.log('✓ Built model list (base -> patch -> custom) for README');
|
|
473
|
+
|
|
474
|
+
// Update README.md with patched data
|
|
475
|
+
updateReadme(readmeModels);
|
|
476
|
+
|
|
477
|
+
// Summary
|
|
478
|
+
console.log('\n--- Summary ---');
|
|
479
|
+
console.log(`Total models: ${readmeModels.length}`);
|
|
480
|
+
console.log(`Reasoning models: ${readmeModels.filter((m) => m.reasoning).length}`);
|
|
481
|
+
console.log(`Vision models: ${readmeModels.filter((m) => m.input.includes('image')).length}`);
|
|
482
|
+
|
|
483
|
+
const newIds = new Set(apiTransformed.map((m) => m.id));
|
|
484
|
+
const oldIds = new Set(existingModels.map((m) => m.id));
|
|
485
|
+
|
|
486
|
+
const added = [...newIds].filter((id) => !oldIds.has(id));
|
|
487
|
+
const removed = [...oldIds].filter((id) => !newIds.has(id));
|
|
488
|
+
|
|
489
|
+
if (added.length > 0) {
|
|
490
|
+
console.log(`\nNew models: ${added.join(', ')}`);
|
|
491
|
+
}
|
|
492
|
+
if (removed.length > 0) {
|
|
493
|
+
console.log(`\nRemoved models: ${removed.join(', ')}`);
|
|
494
|
+
}
|
|
495
|
+
} catch (error) {
|
|
496
|
+
console.error('Error:', error.message);
|
|
497
|
+
process.exit(1);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
main();
|