pi-hypercharm-provider 1.3.1 → 1.3.2
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/.github/FUNDING.yml +4 -0
- package/.pi/fabric/mcp-cache.json +298 -0
- package/.pi/fabric/mesh/state.json +32 -0
- package/.pi/messenger/session-id +1 -0
- package/AGENTS.md +58 -0
- package/custom-models.json +1 -0
- package/deprecated-models.json +1 -0
- package/models.json +781 -0
- package/package.json +1 -5
- package/patch.json +1 -0
- package/pnpm-workspace.yaml +15 -0
- package/scripts/update-models.js +529 -0
- package/status.ts +318 -0
- package/tests/status.smoke.ts +161 -0
- package/tsconfig.json +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hypercharm-provider",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.2",
|
|
4
4
|
"description": "HyperCharm provider extension for pi - Access DeepSeek, GLM, Kimi, Qwen, MiniMax, Gemma, and GPT-OSS models through the Charm Hyper API",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -34,10 +34,6 @@
|
|
|
34
34
|
"./index.ts"
|
|
35
35
|
]
|
|
36
36
|
},
|
|
37
|
-
"files": [
|
|
38
|
-
"README.md",
|
|
39
|
-
"LICENSE"
|
|
40
|
-
],
|
|
41
37
|
"scripts": {
|
|
42
38
|
"clean": "echo 'nothing to clean'",
|
|
43
39
|
"build": "echo 'nothing to build'",
|
package/patch.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
allowBuilds:
|
|
2
|
+
'@google/genai': set this to true or false
|
|
3
|
+
protobufjs: set this to true or false
|
|
4
|
+
onlyBuiltDependencies:
|
|
5
|
+
- '@google/genai'
|
|
6
|
+
- protobufjs
|
|
7
|
+
|
|
8
|
+
minimumReleaseAgeExclude:
|
|
9
|
+
- '@earendil-works/pi-agent-core@0.84.0 || 0.84.1 || 0.84.2'
|
|
10
|
+
- '@earendil-works/pi-ai@0.84.0 || 0.84.1 || 0.84.2'
|
|
11
|
+
- '@earendil-works/pi-client@0.84.0 || 0.84.1 || 0.84.2'
|
|
12
|
+
- '@earendil-works/pi-coding-agent@0.84.0 || 0.84.1 || 0.84.2'
|
|
13
|
+
- '@earendil-works/pi-protocol@0.84.0 || 0.84.1 || 0.84.2'
|
|
14
|
+
- '@earendil-works/pi-telemetry@0.84.0 || 0.84.1 || 0.84.2'
|
|
15
|
+
- '@earendil-works/pi-tui@0.84.0 || 0.84.1 || 0.84.2'
|
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Update HyperCharm models from Charm's official typed provider catalog
|
|
5
|
+
*
|
|
6
|
+
* Fetches models from https://hyper.charm.land/v1/provider and updates:
|
|
7
|
+
* - models.json: canonical API-owned metadata used by @charmland/pi-hyper-provider
|
|
8
|
+
* - README.md: model table with patch.json overrides applied
|
|
9
|
+
*
|
|
10
|
+
* The endpoint provides canonical names, $/M pricing, context/output limits,
|
|
11
|
+
* can_reason, optional reasoning levels, and attachment support. models.json is
|
|
12
|
+
* pure API data. patch.json is reserved for verified endpoint regressions and
|
|
13
|
+
* currently contains no overrides.
|
|
14
|
+
*
|
|
15
|
+
* Merge order for README: models.json → apply patch.json → merge custom-models.json
|
|
16
|
+
*
|
|
17
|
+
* API key: the stored `hypercharm` credential in ~/.pi/agent/auth.json wins, then
|
|
18
|
+
* the HYPERCHARM_API_KEY environment variable. The script refuses to run without one.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from 'fs';
|
|
22
|
+
import os from 'os';
|
|
23
|
+
import { execSync } from 'child_process';
|
|
24
|
+
import path from 'path';
|
|
25
|
+
import { fileURLToPath } from 'url';
|
|
26
|
+
|
|
27
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
|
|
29
|
+
// pi's agent directory: PI_CODING_AGENT_DIR (with ~ expansion) or ~/.pi/agent.
|
|
30
|
+
function piAgentDir() {
|
|
31
|
+
const envDir = process.env.PI_CODING_AGENT_DIR;
|
|
32
|
+
if (envDir) {
|
|
33
|
+
return envDir.startsWith('~/') || envDir === '~'
|
|
34
|
+
? path.join(os.homedir(), envDir.slice(1))
|
|
35
|
+
: envDir;
|
|
36
|
+
}
|
|
37
|
+
return path.join(os.homedir(), '.pi', 'agent');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const AUTH_JSON_PATH = path.join(piAgentDir(), 'auth.json');
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve a configured value using pi's semantics (resolve-config-value.ts in
|
|
44
|
+
* pi-mono): "!command" runs via the shell (10s timeout) and uses trimmed
|
|
45
|
+
* stdout; "$VAR" / "${VAR}" interpolate environment variables ("$$" escapes a
|
|
46
|
+
* literal "$", "$!" a literal "!"); anything else is a literal. Returns
|
|
47
|
+
* undefined when a referenced env var is unset or a command fails.
|
|
48
|
+
*/
|
|
49
|
+
function resolveConfigValue(config, env) {
|
|
50
|
+
if (typeof config !== 'string' || config.length === 0) return undefined;
|
|
51
|
+
if (config.startsWith('!')) {
|
|
52
|
+
try {
|
|
53
|
+
const out = execSync(config.slice(1), {
|
|
54
|
+
encoding: 'utf8',
|
|
55
|
+
timeout: 10000,
|
|
56
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
57
|
+
});
|
|
58
|
+
return out.trim() || undefined;
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
64
|
+
let resolved = '';
|
|
65
|
+
let index = 0;
|
|
66
|
+
while (index < config.length) {
|
|
67
|
+
const dollar = config.indexOf('$', index);
|
|
68
|
+
if (dollar < 0) {
|
|
69
|
+
resolved += config.slice(index);
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
resolved += config.slice(index, dollar);
|
|
73
|
+
const next = config[dollar + 1];
|
|
74
|
+
let name;
|
|
75
|
+
if (next === '$' || next === '!') {
|
|
76
|
+
resolved += next;
|
|
77
|
+
index = dollar + 2;
|
|
78
|
+
continue;
|
|
79
|
+
} else if (next === '{') {
|
|
80
|
+
const end = config.indexOf('}', dollar + 2);
|
|
81
|
+
if (end < 0) {
|
|
82
|
+
resolved += '$';
|
|
83
|
+
index = dollar + 1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const inner = config.slice(dollar + 2, end);
|
|
87
|
+
if (!ENV_NAME_RE.test(inner)) {
|
|
88
|
+
resolved += config.slice(dollar, end + 1);
|
|
89
|
+
index = end + 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
name = inner;
|
|
93
|
+
index = end + 1;
|
|
94
|
+
} else {
|
|
95
|
+
const match = config.slice(dollar + 1).match(/^[A-Za-z_][A-Za-z0-9_]*/);
|
|
96
|
+
if (!match) {
|
|
97
|
+
resolved += '$';
|
|
98
|
+
index = dollar + 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
name = match[0];
|
|
102
|
+
index = dollar + 1 + name.length;
|
|
103
|
+
}
|
|
104
|
+
const value = (env && env[name]) || process.env[name] || undefined;
|
|
105
|
+
if (value === undefined) return undefined;
|
|
106
|
+
resolved += value;
|
|
107
|
+
}
|
|
108
|
+
return resolved;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The API key, resolved the way pi itself resolves it for this provider: the
|
|
113
|
+
* stored `hypercharm` credential in ~/.pi/agent/auth.json wins, then
|
|
114
|
+
* the HYPERCHARM_API_KEY environment variable.
|
|
115
|
+
*/
|
|
116
|
+
function resolveApiKey() {
|
|
117
|
+
try {
|
|
118
|
+
const auth = JSON.parse(fs.readFileSync(AUTH_JSON_PATH, 'utf8'));
|
|
119
|
+
const credential = auth?.hypercharm;
|
|
120
|
+
if (credential && credential.type === 'api_key' && typeof credential.key === 'string') {
|
|
121
|
+
const key = resolveConfigValue(credential.key, credential.env);
|
|
122
|
+
if (key) return key;
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
// Missing or unparseable auth.json: fall through to the env var.
|
|
126
|
+
}
|
|
127
|
+
return process.env.HYPERCHARM_API_KEY || undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const MODELS_API_URL = 'https://hyper.charm.land/v1/provider';
|
|
131
|
+
const MODELS_JSON_PATH = path.join(__dirname, '..', 'models.json');
|
|
132
|
+
const PATCH_JSON_PATH = path.join(__dirname, '..', 'patch.json');
|
|
133
|
+
const CUSTOM_MODELS_JSON_PATH = path.join(__dirname, '..', 'custom-models.json');
|
|
134
|
+
const README_PATH = path.join(__dirname, '..', 'README.md');
|
|
135
|
+
|
|
136
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
function loadJson(filePath) {
|
|
139
|
+
try {
|
|
140
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
141
|
+
} catch {
|
|
142
|
+
return {};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function saveJson(filePath, data) {
|
|
147
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function convertPricing(v) {
|
|
151
|
+
if (!v) return 0;
|
|
152
|
+
const n = typeof v === 'string' ? parseFloat(v) : v;
|
|
153
|
+
// API returns $/M directly; round to 6 decimals to preserve sub-cent cache prices.
|
|
154
|
+
return Math.round(n * 1e6) / 1e6;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ─── Patch application ────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
function applyPatch(model, patch) {
|
|
160
|
+
const result = { ...model };
|
|
161
|
+
if (patch.name !== undefined) result.name = patch.name;
|
|
162
|
+
if (patch.reasoning !== undefined) result.reasoning = patch.reasoning;
|
|
163
|
+
if (patch.input !== undefined) result.input = patch.input;
|
|
164
|
+
if (patch.contextWindow !== undefined) result.contextWindow = patch.contextWindow;
|
|
165
|
+
if (patch.maxTokens !== undefined) result.maxTokens = patch.maxTokens;
|
|
166
|
+
if (patch.thinkingLevelMap !== undefined) result.thinkingLevelMap = { ...patch.thinkingLevelMap };
|
|
167
|
+
if (patch.cost) {
|
|
168
|
+
result.cost = {
|
|
169
|
+
input: patch.cost.input ?? result.cost.input,
|
|
170
|
+
output: patch.cost.output ?? result.cost.output,
|
|
171
|
+
cacheRead: patch.cost.cacheRead ?? result.cost.cacheRead,
|
|
172
|
+
cacheWrite: patch.cost.cacheWrite ?? result.cost.cacheWrite,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
if (patch.compat) {
|
|
176
|
+
result.compat = { ...(result.compat || {}), ...patch.compat };
|
|
177
|
+
}
|
|
178
|
+
if (!result.reasoning && result.compat?.thinkingFormat) {
|
|
179
|
+
delete result.compat.thinkingFormat;
|
|
180
|
+
}
|
|
181
|
+
if (!result.reasoning && result.thinkingLevelMap) {
|
|
182
|
+
delete result.thinkingLevelMap;
|
|
183
|
+
}
|
|
184
|
+
if (result.compat && Object.keys(result.compat).length === 0) {
|
|
185
|
+
delete result.compat;
|
|
186
|
+
}
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function buildModels(baseModels, customModels, patchData) {
|
|
191
|
+
const modelMap = new Map();
|
|
192
|
+
for (const model of baseModels) modelMap.set(model.id, model);
|
|
193
|
+
for (const [id, patchEntry] of Object.entries(patchData)) {
|
|
194
|
+
const existing = modelMap.get(id);
|
|
195
|
+
if (existing) modelMap.set(id, applyPatch(existing, patchEntry));
|
|
196
|
+
}
|
|
197
|
+
for (const model of customModels) {
|
|
198
|
+
const existing = modelMap.get(model.id);
|
|
199
|
+
const patchEntry = patchData[model.id];
|
|
200
|
+
if (existing && patchEntry) modelMap.set(model.id, applyPatch(model, patchEntry));
|
|
201
|
+
else if (existing) modelMap.set(model.id, model);
|
|
202
|
+
else if (patchEntry) modelMap.set(model.id, applyPatch(model, patchEntry));
|
|
203
|
+
else modelMap.set(model.id, model);
|
|
204
|
+
}
|
|
205
|
+
return Array.from(modelMap.values());
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ─── Model transformation ─────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
const PI_THINKING_LEVELS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
211
|
+
|
|
212
|
+
// Charm's official extension treats a reasoning-capable model with no levels as
|
|
213
|
+
// a boolean on/off model: Pi's max level selects the single on state.
|
|
214
|
+
const ON_OFF_THINKING_LEVEL_MAP = {
|
|
215
|
+
off: 'off',
|
|
216
|
+
minimal: null,
|
|
217
|
+
low: null,
|
|
218
|
+
medium: null,
|
|
219
|
+
high: null,
|
|
220
|
+
xhigh: null,
|
|
221
|
+
max: 'max',
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
function buildThinkingLevelMap(levels) {
|
|
225
|
+
if (levels.length === 0) return undefined;
|
|
226
|
+
const available = new Set(levels);
|
|
227
|
+
const result = {
|
|
228
|
+
// The provider enum uses "none" for the off state on newer deployments;
|
|
229
|
+
// the official extension looked only for the older "off" spelling.
|
|
230
|
+
off: available.has('off') ? 'off' : available.has('none') ? 'none' : null,
|
|
231
|
+
};
|
|
232
|
+
for (const level of PI_THINKING_LEVELS) {
|
|
233
|
+
result[level] = available.has(level) ? level : null;
|
|
234
|
+
}
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function transformModel(apiModel) {
|
|
239
|
+
const reasoningLevels = Array.isArray(apiModel.reasoning_levels)
|
|
240
|
+
? apiModel.reasoning_levels.filter(level => typeof level === 'string')
|
|
241
|
+
: [];
|
|
242
|
+
const supportsReasoningEffort = reasoningLevels.length > 0;
|
|
243
|
+
const thinkingLevelMap = supportsReasoningEffort
|
|
244
|
+
? buildThinkingLevelMap(reasoningLevels)
|
|
245
|
+
: apiModel.can_reason === true
|
|
246
|
+
? ON_OFF_THINKING_LEVEL_MAP
|
|
247
|
+
: undefined;
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
id: apiModel.id,
|
|
251
|
+
name: apiModel.name,
|
|
252
|
+
reasoning: apiModel.can_reason === true,
|
|
253
|
+
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
|
|
254
|
+
input: apiModel.supports_attachments === true ? ['text', 'image'] : ['text'],
|
|
255
|
+
cost: {
|
|
256
|
+
input: typeof apiModel.cost_per_1m_in === 'number' ? apiModel.cost_per_1m_in : 0,
|
|
257
|
+
output: typeof apiModel.cost_per_1m_out === 'number' ? apiModel.cost_per_1m_out : 0,
|
|
258
|
+
// Matches Charm's official extension: cacheRead is the discounted cached-output
|
|
259
|
+
// price, cacheWrite the cached-input price.
|
|
260
|
+
cacheRead: typeof apiModel.cost_per_1m_out_cached === 'number' ? apiModel.cost_per_1m_out_cached : 0,
|
|
261
|
+
cacheWrite: typeof apiModel.cost_per_1m_in_cached === 'number' ? apiModel.cost_per_1m_in_cached : 0,
|
|
262
|
+
},
|
|
263
|
+
contextWindow: apiModel.context_window || 0,
|
|
264
|
+
maxTokens: apiModel.default_max_tokens || apiModel.context_window || 0,
|
|
265
|
+
compat: {
|
|
266
|
+
supportsStore: false,
|
|
267
|
+
supportsReasoningEffort,
|
|
268
|
+
thinkingFormat: 'deepseek',
|
|
269
|
+
maxTokensField: 'max_tokens',
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ─── README generation ────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
function formatCost(cost) {
|
|
277
|
+
if (cost === 0) return '—';
|
|
278
|
+
if (cost === null || cost === undefined) return '—';
|
|
279
|
+
return '$' + cost.toFixed(2);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function formatNumber(num) {
|
|
283
|
+
if (num === null || num === undefined) return '-';
|
|
284
|
+
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
|
|
285
|
+
if (num >= 1000) return `${Math.round(num / 1000)}K`;
|
|
286
|
+
return num.toString();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function getInputTypes(inputTypes) {
|
|
290
|
+
const types = inputTypes || ['text'];
|
|
291
|
+
if (types.includes('image') && types.includes('text')) return 'Text + Image';
|
|
292
|
+
if (types.includes('image')) return 'Image';
|
|
293
|
+
return 'Text';
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function generateReadmeRow(model) {
|
|
297
|
+
const cost = model.cost || {};
|
|
298
|
+
return `| ${model.name} | ${getInputTypes(model.input)} | ${formatNumber(model.contextWindow)} | ${formatNumber(model.maxTokens)} | ${formatCost(cost.input)} | ${formatCost(cost.output)} |`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function updateReadme(models) {
|
|
302
|
+
let readme = fs.readFileSync(README_PATH, 'utf8');
|
|
303
|
+
|
|
304
|
+
const sortedModels = [...models].sort((a, b) => a.name.localeCompare(b.name));
|
|
305
|
+
const tableRows = sortedModels.map(generateReadmeRow).join('\n');
|
|
306
|
+
const newTable = `| Model | Type | Context | Max Tokens | Input Cost | Output Cost |
|
|
307
|
+
|-------|------|---------|------------|------------|-------------|
|
|
308
|
+
${tableRows}`;
|
|
309
|
+
|
|
310
|
+
const tableRegex = /\| Model \| Type \| Context \| Max Tokens \| Input Cost \| Output Cost \|[\s\S]*?(?=\n\*Costs are per million)/;
|
|
311
|
+
readme = readme.replace(tableRegex, newTable);
|
|
312
|
+
|
|
313
|
+
readme = readme.replace(/\*\*\d+\+ AI Models\*\*/, `**${models.length}+ AI Models**`);
|
|
314
|
+
|
|
315
|
+
fs.writeFileSync(README_PATH, readme);
|
|
316
|
+
console.log(`✓ Updated README.md with ${models.length} models`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
// Grace period for delisted models: update-models.js moves models the API no
|
|
322
|
+
// longer lists into deprecated-models.json (stamped with deprecatedAt) instead
|
|
323
|
+
// of dropping them; the runtime appends them back so sessions and saved model
|
|
324
|
+
// settings keep working, and after 14 days they are evicted permanently.
|
|
325
|
+
const DEPRECATED_MODEL_TTL_MS = 14 * 24 * 60 * 60 * 1000;
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Reconcile deprecated-models.json against the freshly fetched model list.
|
|
329
|
+
* - in old models.json but not the API: moved into the deprecated file
|
|
330
|
+
* (deprecatedAt = now; preserved on repeat runs so the grace clock is not reset)
|
|
331
|
+
* - back in the API: resurrected (dropped from the deprecated file)
|
|
332
|
+
* - deprecatedAt older than 14 days: evicted permanently
|
|
333
|
+
* Must run BEFORE the new models.json is written; it reads the old file itself.
|
|
334
|
+
*/
|
|
335
|
+
function updateDeprecatedModels(modelsJsonPath, newModels) {
|
|
336
|
+
const deprecatedPath = path.join(path.dirname(modelsJsonPath), 'deprecated-models.json');
|
|
337
|
+
|
|
338
|
+
let oldModels = [];
|
|
339
|
+
try {
|
|
340
|
+
const parsed = JSON.parse(fs.readFileSync(modelsJsonPath, 'utf8'));
|
|
341
|
+
if (Array.isArray(parsed)) oldModels = parsed;
|
|
342
|
+
} catch { /* first run: no previous models.json */ }
|
|
343
|
+
|
|
344
|
+
let deprecated = {};
|
|
345
|
+
try {
|
|
346
|
+
const parsed = JSON.parse(fs.readFileSync(deprecatedPath, 'utf8'));
|
|
347
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) deprecated = parsed;
|
|
348
|
+
} catch { /* no graveyard yet */ }
|
|
349
|
+
|
|
350
|
+
const currentIds = new Set(newModels.map(m => m.id));
|
|
351
|
+
const now = new Date().toISOString();
|
|
352
|
+
const added = [];
|
|
353
|
+
const resurrected = [];
|
|
354
|
+
const evicted = [];
|
|
355
|
+
|
|
356
|
+
for (const old of oldModels) {
|
|
357
|
+
if (old && old.id && !currentIds.has(old.id) && !deprecated[old.id]) {
|
|
358
|
+
deprecated[old.id] = { ...old, deprecatedAt: now };
|
|
359
|
+
added.push(old.id);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
for (const [id, entry] of Object.entries(deprecated)) {
|
|
364
|
+
if (currentIds.has(id)) {
|
|
365
|
+
delete deprecated[id];
|
|
366
|
+
resurrected.push(id);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
const removedAt = Date.parse(entry && entry.deprecatedAt ? entry.deprecatedAt : '');
|
|
370
|
+
if (Number.isNaN(removedAt) || Date.now() - removedAt > DEPRECATED_MODEL_TTL_MS) {
|
|
371
|
+
delete deprecated[id];
|
|
372
|
+
evicted.push(id);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (added.length > 0 || resurrected.length > 0 || evicted.length > 0) {
|
|
377
|
+
fs.writeFileSync(deprecatedPath, JSON.stringify(deprecated, null, 2) + '\n');
|
|
378
|
+
console.log('Updated deprecated-models.json ' + JSON.stringify({ added, resurrected, evicted }));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Grace-period deprecated models (deprecatedAt within TTL) with metadata stripped.
|
|
384
|
+
* Keeps the README table serving models that are delisted but still within their
|
|
385
|
+
* 14-day grace window.
|
|
386
|
+
*/
|
|
387
|
+
function withDeprecatedForReadme(models) {
|
|
388
|
+
const deprecatedPath = path.join(process.cwd(), 'deprecated-models.json');
|
|
389
|
+
let deprecated = {};
|
|
390
|
+
try {
|
|
391
|
+
const parsed = JSON.parse(fs.readFileSync(deprecatedPath, 'utf8'));
|
|
392
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) deprecated = parsed;
|
|
393
|
+
} catch { /* no graveyard yet */ }
|
|
394
|
+
const now = Date.now();
|
|
395
|
+
const seen = new Set(models.map(m => m.id));
|
|
396
|
+
const extras = [];
|
|
397
|
+
for (const entry of Object.values(deprecated)) {
|
|
398
|
+
if (!entry || !entry.id || seen.has(entry.id)) continue;
|
|
399
|
+
const removedAt = Date.parse(entry.deprecatedAt || '');
|
|
400
|
+
if (Number.isNaN(removedAt) || now - removedAt > DEPRECATED_MODEL_TTL_MS) continue;
|
|
401
|
+
const m = { ...entry };
|
|
402
|
+
delete m.deprecatedAt;
|
|
403
|
+
extras.push(m);
|
|
404
|
+
}
|
|
405
|
+
return extras.length > 0 ? [...models, ...extras] : models;
|
|
406
|
+
}
|
|
407
|
+
async function main() {
|
|
408
|
+
const apiKey = resolveApiKey();
|
|
409
|
+
if (!apiKey) {
|
|
410
|
+
console.error('Error: No API key found: no `hypercharm` credential resolved from ' + AUTH_JSON_PATH + ' and HYPERCHARM_API_KEY is not set');
|
|
411
|
+
console.error('Usage: HYPERCHARM_API_KEY=your-key node scripts/update-models.js');
|
|
412
|
+
process.exit(1);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
console.log(`Fetching models from ${MODELS_API_URL}...`);
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
const response = await fetch(MODELS_API_URL, {
|
|
419
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
420
|
+
});
|
|
421
|
+
if (!response.ok) {
|
|
422
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const apiResponse = await response.json();
|
|
426
|
+
const apiModels = Array.isArray(apiResponse)
|
|
427
|
+
? apiResponse
|
|
428
|
+
: (apiResponse.models || apiResponse.data || []);
|
|
429
|
+
|
|
430
|
+
if (!Array.isArray(apiModels)) {
|
|
431
|
+
throw new Error('API response does not contain an array of models');
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
console.log(`✓ Fetched ${apiModels.length} models from API`);
|
|
435
|
+
|
|
436
|
+
// Load existing models.json — source of truth for curated specs
|
|
437
|
+
let existingModels = [];
|
|
438
|
+
try {
|
|
439
|
+
existingModels = JSON.parse(fs.readFileSync(MODELS_JSON_PATH, 'utf8'));
|
|
440
|
+
} catch {
|
|
441
|
+
// File might not exist yet
|
|
442
|
+
}
|
|
443
|
+
const existingModelsMap = {};
|
|
444
|
+
for (const m of existingModels) {
|
|
445
|
+
existingModelsMap[m.id] = m;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Transform models from API, preserving existing curated data
|
|
449
|
+
let apiTransformed = apiModels.map(m => transformModel(m));
|
|
450
|
+
apiTransformed.sort((a, b) => a.name.localeCompare(b.name));
|
|
451
|
+
|
|
452
|
+
// Load patch overrides for README rendering. Canonical metadata already
|
|
453
|
+
// comes from /v1/provider, so new models do not require a patch entry.
|
|
454
|
+
const patch = loadJson(PATCH_JSON_PATH);
|
|
455
|
+
|
|
456
|
+
// Update models.json — curated API data
|
|
457
|
+
// Move delisted models to deprecated-models.json BEFORE models.json is overwritten
|
|
458
|
+
updateDeprecatedModels(MODELS_JSON_PATH, apiTransformed);
|
|
459
|
+
fs.writeFileSync(MODELS_JSON_PATH, JSON.stringify(apiTransformed, null, 2) + '\n');
|
|
460
|
+
console.log(`✓ Updated models.json (${apiTransformed.length} models)`);
|
|
461
|
+
|
|
462
|
+
// Load custom-models.json
|
|
463
|
+
const customModels = Array.isArray(loadJson(CUSTOM_MODELS_JSON_PATH))
|
|
464
|
+
? loadJson(CUSTOM_MODELS_JSON_PATH)
|
|
465
|
+
: [];
|
|
466
|
+
|
|
467
|
+
// Check for custom models now available upstream (remove duplicates)
|
|
468
|
+
const upstreamIds = new Set(apiTransformed.map(m => m.id));
|
|
469
|
+
const duplicates = customModels.filter(m => upstreamIds.has(m.id));
|
|
470
|
+
if (duplicates.length > 0) {
|
|
471
|
+
console.log(`\nFound ${duplicates.length} custom model(s) now available upstream:`);
|
|
472
|
+
for (const dup of duplicates) {
|
|
473
|
+
console.log(` - ${dup.id} (${dup.name})`);
|
|
474
|
+
}
|
|
475
|
+
const cleaned = customModels.filter(m => !upstreamIds.has(m.id));
|
|
476
|
+
saveJson(CUSTOM_MODELS_JSON_PATH, cleaned);
|
|
477
|
+
console.log(`✓ Removed ${duplicates.length} duplicate(s) from custom-models.json`);
|
|
478
|
+
customModels.length = 0;
|
|
479
|
+
customModels.push(...cleaned);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Build merged models with patches for README
|
|
483
|
+
const readmeModels = buildModels(withDeprecatedForReadme(apiTransformed), customModels, patch);
|
|
484
|
+
readmeModels.sort((a, b) => a.name.localeCompare(b.name));
|
|
485
|
+
|
|
486
|
+
// Update README
|
|
487
|
+
updateReadme(readmeModels);
|
|
488
|
+
|
|
489
|
+
// Summary
|
|
490
|
+
console.log('\n--- Summary ---');
|
|
491
|
+
console.log(`Total models: ${readmeModels.length}`);
|
|
492
|
+
console.log(`Reasoning models: ${readmeModels.filter(m => m.reasoning).length}`);
|
|
493
|
+
console.log(`Vision models: ${readmeModels.filter(m => m.input.includes('image')).length}`);
|
|
494
|
+
|
|
495
|
+
const newIds = new Set(apiTransformed.map(m => m.id));
|
|
496
|
+
const oldIds = new Set(existingModels.map(m => m.id));
|
|
497
|
+
|
|
498
|
+
const added = [...newIds].filter(id => !oldIds.has(id));
|
|
499
|
+
const removed = [...oldIds].filter(id => !newIds.has(id));
|
|
500
|
+
|
|
501
|
+
if (added.length > 0) console.log(`\nNew models: ${added.join(', ')}`);
|
|
502
|
+
if (removed.length > 0) console.log(`\nRemoved models: ${removed.join(', ')}`);
|
|
503
|
+
|
|
504
|
+
// Show pricing changes
|
|
505
|
+
for (const model of apiTransformed) {
|
|
506
|
+
const oldModel = existingModels.find(m => m.id === model.id);
|
|
507
|
+
if (oldModel) {
|
|
508
|
+
const oldInput = oldModel.cost?.input || 0;
|
|
509
|
+
const oldOutput = oldModel.cost?.output || 0;
|
|
510
|
+
if (oldInput !== model.cost.input || oldOutput !== model.cost.output) {
|
|
511
|
+
console.log(`\nPricing change for ${model.id}:`);
|
|
512
|
+
if (oldInput !== model.cost.input) {
|
|
513
|
+
console.log(` Input: $${oldInput}/M → $${model.cost.input}/M`);
|
|
514
|
+
}
|
|
515
|
+
if (oldOutput !== model.cost.output) {
|
|
516
|
+
console.log(` Output: $${oldOutput}/M → $${model.cost.output}/M`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
console.log('\nDone!');
|
|
523
|
+
} catch (error) {
|
|
524
|
+
console.error('Error:', error.message);
|
|
525
|
+
process.exit(1);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
main();
|