@flame0510/project-aether 1.5.2 → 1.6.1
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/app/api/gateway/provider/route.ts +16 -10
- package/app/api/models/details/route.ts +30 -0
- package/app/api/system-health/route.ts +3 -3
- package/app/api/update-check/route.ts +27 -11
- package/app/components/SessionDrawer.tsx +4 -4
- package/app/components/VersionBanner.tsx +101 -56
- package/app/components/ui/Metric.tsx +7 -2
- package/app/gateway/ModelDetailsModal.tsx +322 -0
- package/app/gateway/PageClient.tsx +28 -10
- package/app/globals.css +4 -1
- package/app/lib/model-format.ts +21 -0
- package/docs/FRONTEND-ARCHITECTURE.md +2 -1
- package/docs/dev/API-REFERENCE.md +41 -2
- package/docs/dev/GATEWAY.md +58 -0
- package/lib/memory-context.ts +2 -2
- package/lib/model-catalogue.ts +17 -0
- package/lib/model-details.ts +124 -0
- package/lib/provider-labels.ts +21 -0
- package/model-details.json +16360 -0
- package/model-pricing.json +59 -59
- package/models.config.json +75 -9
- package/package.json +4 -2
- package/scripts/check-language.mjs +76 -0
- package/scripts/lib/model-upstream.mjs +61 -0
- package/scripts/model-info-suggest.mjs +149 -0
- package/scripts/refresh-model-pricing.mjs +175 -16
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Suggests what to write in a catalogue entry's curated `info` block — the facts no API
|
|
4
|
+
* publishes in a usable shape: an active parameter count, real benchmark numbers, the
|
|
5
|
+
* vendor's documentation link, notes.
|
|
6
|
+
*
|
|
7
|
+
* **It never writes.** Prints the material and, with `--json`, a skeleton to complete:
|
|
8
|
+
* the assistant or the maintainer reads the model card, transcribes the numbers and
|
|
9
|
+
* records the source, exactly as the plan requires (plans/MODEL-DETAILS-PLAN.md: facts
|
|
10
|
+
* plus curation, never derived).
|
|
11
|
+
*
|
|
12
|
+
* Sources, in order: the model card on Hugging Face (a README, so readable), then the
|
|
13
|
+
* vendor's documentation, which the card usually links. No card and no citable page means
|
|
14
|
+
* the model keeps its automatic data only.
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* node scripts/model-info-suggest.mjs <model id> [<model id> …]
|
|
18
|
+
* node scripts/model-info-suggest.mjs <model id> --json
|
|
19
|
+
* node scripts/model-info-suggest.mjs --all # every model with a card
|
|
20
|
+
*/
|
|
21
|
+
import * as fs from 'node:fs';
|
|
22
|
+
import * as path from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { fetchUpstream, matchUpstream } from './lib/model-upstream.mjs';
|
|
25
|
+
|
|
26
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
27
|
+
const MODELS_FILE = path.join(ROOT, 'models.config.json');
|
|
28
|
+
const DETAILS_FILE = path.join(ROOT, 'model-details.json');
|
|
29
|
+
|
|
30
|
+
const args = process.argv.slice(2);
|
|
31
|
+
const asJson = args.includes('--json');
|
|
32
|
+
const ids = args.filter((a) => !a.startsWith('--'));
|
|
33
|
+
const catalogue = JSON.parse(fs.readFileSync(MODELS_FILE, 'utf-8')).models;
|
|
34
|
+
const details = (() => {
|
|
35
|
+
try { return JSON.parse(fs.readFileSync(DETAILS_FILE, 'utf-8')).models ?? {}; } catch { return {}; }
|
|
36
|
+
})();
|
|
37
|
+
|
|
38
|
+
const readmeUrl = (hfId) => `https://huggingface.co/${hfId}/raw/main/README.md`;
|
|
39
|
+
|
|
40
|
+
/** Lines that read like the vendor describing its own size or sparsity. */
|
|
41
|
+
function sizeSentences(md) {
|
|
42
|
+
// Raw markdown carries HTML blocks and images with no sentence boundaries: strip them
|
|
43
|
+
// first, or the first "sentence" is the whole badge header.
|
|
44
|
+
const text = md
|
|
45
|
+
.replace(/<[^>]*>/g, ' ')
|
|
46
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
|
|
47
|
+
.replace(/\|/g, ' ');
|
|
48
|
+
return text
|
|
49
|
+
.split(/(?<=[.!?])\s+/)
|
|
50
|
+
.map((s) => s.replace(/\s+/g, ' ').trim())
|
|
51
|
+
.map((s) => s.replace(/\s+/g, ' ').trim())
|
|
52
|
+
.filter((s) => s.length > 20 && s.length < 320)
|
|
53
|
+
.filter((s) => /(active|activated|backbone|total)\s+(parameter|params)|Mixture-of-Experts|number of experts|routed experts/i.test(s))
|
|
54
|
+
.slice(0, 6);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Markdown tables that look like benchmark results, header included. */
|
|
58
|
+
function benchmarkTables(md) {
|
|
59
|
+
const lines = md.split('\n');
|
|
60
|
+
const tables = [];
|
|
61
|
+
for (let i = 0; i < lines.length; i++) {
|
|
62
|
+
if (!/^\s*\|/.test(lines[i])) continue;
|
|
63
|
+
const block = [];
|
|
64
|
+
while (i < lines.length && /^\s*\|/.test(lines[i])) { block.push(lines[i].trim()); i++; }
|
|
65
|
+
const head = block[0] ?? '';
|
|
66
|
+
const body = block.slice(1);
|
|
67
|
+
const numeric = body.filter((row) => (row.match(/\b\d+(\.\d+)?\b/g) ?? []).length >= 2).length;
|
|
68
|
+
if (/bench|eval|score|metric/i.test(head) || numeric >= 3) tables.push(block);
|
|
69
|
+
}
|
|
70
|
+
return tables.slice(0, 3);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Candidate documentation links the card points at. */
|
|
74
|
+
function docLinks(md) {
|
|
75
|
+
const urls = new Set();
|
|
76
|
+
for (const m of md.matchAll(/https?:\/\/[^\s)"'<>\]]+/g)) {
|
|
77
|
+
const url = m[0].replace(/[.,;]$/, '');
|
|
78
|
+
if (/docs\.|github\.com|arxiv\.org|platform\.|api-docs/.test(url)) urls.add(url);
|
|
79
|
+
}
|
|
80
|
+
return [...urls].slice(0, 8);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function suggest(entry, live) {
|
|
84
|
+
const upstream = matchUpstream(live, entry.id);
|
|
85
|
+
const detail = details[entry.id] ?? {};
|
|
86
|
+
const hfId = detail.hf?.id ?? upstream?.hugging_face_id ?? null;
|
|
87
|
+
|
|
88
|
+
const out = {
|
|
89
|
+
id: entry.id,
|
|
90
|
+
name: entry.name,
|
|
91
|
+
provider: entry.provider,
|
|
92
|
+
currentInfo: entry.info ?? null,
|
|
93
|
+
upstreamId: upstream?.id ?? null,
|
|
94
|
+
huggingFaceId: hfId,
|
|
95
|
+
cardUrl: hfId ? `https://huggingface.co/${hfId}` : null,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
if (!hfId) {
|
|
99
|
+
console.log(`${entry.id} — no Hugging Face card: look for the vendor's own model page (no card → no citable size).`);
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const res = await fetch(readmeUrl(hfId), { signal: AbortSignal.timeout(20_000) });
|
|
104
|
+
if (!res.ok) {
|
|
105
|
+
console.log(`${entry.id} — card ${hfId}: README returned HTTP ${res.status}`);
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
const md = await res.text();
|
|
109
|
+
out.paramsSentences = sizeSentences(md);
|
|
110
|
+
out.benchmarkTables = benchmarkTables(md);
|
|
111
|
+
out.docLinks = docLinks(md);
|
|
112
|
+
|
|
113
|
+
if (!asJson) {
|
|
114
|
+
console.log(`\n=== ${entry.id} — ${entry.name}`);
|
|
115
|
+
console.log(` card: ${out.cardUrl}`);
|
|
116
|
+
if (detail.hf) {
|
|
117
|
+
const h = detail.hf;
|
|
118
|
+
console.log(` automatic: total ${h.total ?? '—'}, experts ${h.moe ? `${h.moe.experts} (${h.moe.perToken ?? '?'}/token)` : '—'}, licence ${h.license ?? '—'}, published ${h.published ?? '—'}`);
|
|
119
|
+
}
|
|
120
|
+
if (out.paramsSentences.length) {
|
|
121
|
+
console.log(' size sentences (candidates for info.params):');
|
|
122
|
+
for (const s of out.paramsSentences) console.log(` · ${s}`);
|
|
123
|
+
}
|
|
124
|
+
for (const t of out.benchmarkTables) {
|
|
125
|
+
console.log(' benchmark table (pick the model\'s own column):');
|
|
126
|
+
for (const row of t.slice(0, 10)) console.log(` ${row}`);
|
|
127
|
+
}
|
|
128
|
+
if (out.docLinks.length) {
|
|
129
|
+
console.log(` doc link candidates: ${out.docLinks.join(' , ')}`);
|
|
130
|
+
}
|
|
131
|
+
if (out.currentInfo) console.log(` current info: ${JSON.stringify(out.currentInfo)}`);
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function main() {
|
|
137
|
+
const live = await fetchUpstream();
|
|
138
|
+
const targets = args.includes('--all')
|
|
139
|
+
? catalogue.filter((m) => details[m.id]?.hf?.id)
|
|
140
|
+
: ids.map((id) => catalogue.find((m) => m.id === id) ?? { id, name: '(not in the catalogue)', provider: '?' });
|
|
141
|
+
const out = [];
|
|
142
|
+
for (const entry of targets) out.push(await suggest(entry, live));
|
|
143
|
+
if (asJson) console.log(JSON.stringify(out, null, 2));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
main().catch((e) => {
|
|
147
|
+
console.error(`✗ ${e instanceof Error ? e.message : String(e)}`);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
});
|
|
@@ -31,10 +31,39 @@ import { fileURLToPath } from 'node:url';
|
|
|
31
31
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
32
32
|
const PRICING_FILE = path.join(ROOT, 'model-pricing.json');
|
|
33
33
|
const MODELS_FILE = path.join(ROOT, 'models.config.json');
|
|
34
|
-
|
|
34
|
+
/** Generated: descriptions, specs, benchmarks. Never hand-edited (see the plan). */
|
|
35
|
+
const DETAILS_FILE = path.join(ROOT, 'model-details.json');
|
|
36
|
+
import { CATALOGUE_URL, fetchUpstream, matchUpstream } from './lib/model-upstream.mjs';
|
|
35
37
|
|
|
36
38
|
const dryRun = process.argv.includes('--dry-run');
|
|
37
39
|
|
|
40
|
+
/**
|
|
41
|
+
* A direct provider's OpenRouter vendor name, when it differs from ours:
|
|
42
|
+
* `glm/glm-5.3` is `z-ai/glm-5.3` upstream, `kimi/kimi-k3` is `moonshotai/kimi-k3`.
|
|
43
|
+
* Used for details only — prices for direct entries come from the vendor's own price list.
|
|
44
|
+
*/
|
|
45
|
+
const VENDOR_ALIAS = { glm: 'z-ai', kimi: 'moonshotai' };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Same model, different name on OpenRouter. Only exact same-model cases belong here: a
|
|
49
|
+
* different tier (`-highspeed`) or another vendor's copy of the weights is a curated
|
|
50
|
+
* `info` block, not an alias, because description and benchmarks would be someone else's.
|
|
51
|
+
*/
|
|
52
|
+
const MODEL_ALIAS = {
|
|
53
|
+
'deepseek/deepseek-flash': 'deepseek/deepseek-v4.1-flash', // our name for DeepSeek's V4.1
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Try the id with its version separators swapped: our `claude-haiku-4-5` is OpenRouter's
|
|
58
|
+
* `claude-haiku-4.5`. A normalisation, not an alias: the name is otherwise identical.
|
|
59
|
+
*/
|
|
60
|
+
function separatorVariants(id) {
|
|
61
|
+
return [
|
|
62
|
+
id.replace(/-(\d+)-(\d+)$/, '-$1.$2'), // claude-haiku-4-5 → claude-haiku-4.5
|
|
63
|
+
id.replace(/-(\d+)\.(\d+)$/, '-$1-$2'), // and the other way round
|
|
64
|
+
].filter((v) => v !== id);
|
|
65
|
+
}
|
|
66
|
+
|
|
38
67
|
function perMillion(raw) {
|
|
39
68
|
if (raw === undefined || raw === null) return null;
|
|
40
69
|
const n = parseFloat(raw);
|
|
@@ -72,19 +101,119 @@ function upstreamCandidates(id) {
|
|
|
72
101
|
return stripped === id ? [id] : [stripped, id];
|
|
73
102
|
}
|
|
74
103
|
|
|
104
|
+
/** The fields the details modal shows, taken from the OpenRouter entry. */
|
|
105
|
+
function detailsFor(up) {
|
|
106
|
+
const a = up.architecture ?? {};
|
|
107
|
+
const tp = up.top_provider ?? {};
|
|
108
|
+
const fields = {
|
|
109
|
+
description: up.description,
|
|
110
|
+
created: up.created ? new Date(up.created * 1000).toISOString().slice(0, 10) : undefined,
|
|
111
|
+
context: up.context_length,
|
|
112
|
+
providerContext: tp.context_length,
|
|
113
|
+
maxOutput: tp.max_completion_tokens,
|
|
114
|
+
tokenizer: a.tokenizer,
|
|
115
|
+
instructType: a.instruct_type,
|
|
116
|
+
knowledgeCutoff: up.knowledge_cutoff,
|
|
117
|
+
huggingFaceId: up.hugging_face_id,
|
|
118
|
+
canonicalSlug: up.canonical_slug,
|
|
119
|
+
url: `https://openrouter.ai/${up.id}`,
|
|
120
|
+
reasoning: up.reasoning,
|
|
121
|
+
supportedParameters: up.supported_parameters,
|
|
122
|
+
moderated: tp.is_moderated === true ? true : undefined,
|
|
123
|
+
benchmarks: up.benchmarks,
|
|
124
|
+
};
|
|
125
|
+
// Drop what upstream does not have: the modal hides absent fields, it never prints "n/a".
|
|
126
|
+
return Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined && v !== null));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Parameter count and architecture, from the model's Hugging Face card. OpenRouter does
|
|
131
|
+
* not publish sizes and only the open-weight models have a card: a model without one (or
|
|
132
|
+
* a card without safetensors metadata) simply gets no `params`, and the modal hides the
|
|
133
|
+
* row. `--no-hf` skips the lookups entirely.
|
|
134
|
+
*/
|
|
135
|
+
async function huggingFaceFacts(hfId) {
|
|
136
|
+
try {
|
|
137
|
+
const [meta, config] = await Promise.all([
|
|
138
|
+
fetch(`https://huggingface.co/api/models/${hfId}?blobs=true`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(20_000) })
|
|
139
|
+
.then((r) => (r.ok ? r.json() : null)).catch(() => null),
|
|
140
|
+
// The API's summary config is reduced; the raw file is what carries the architecture.
|
|
141
|
+
fetch(`https://huggingface.co/${hfId}/raw/main/config.json`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(20_000) })
|
|
142
|
+
.then((r) => (r.ok ? r.json() : null)).catch(() => null),
|
|
143
|
+
]);
|
|
144
|
+
if (!meta && !config) return null;
|
|
145
|
+
|
|
146
|
+
const out = {};
|
|
147
|
+
const total = meta?.safetensors?.total;
|
|
148
|
+
if (typeof total === 'number' && total > 0) out.params = total;
|
|
149
|
+
|
|
150
|
+
// A multimodal model nests its language model under text_config; text-only ones are flat.
|
|
151
|
+
const cfg = config ?? meta?.config ?? {};
|
|
152
|
+
const text = cfg.text_config ?? cfg;
|
|
153
|
+
const num = (v) => (typeof v === 'number' ? v : undefined);
|
|
154
|
+
const experts = num(text.num_experts) ?? num(text.n_routed_experts);
|
|
155
|
+
const perToken = num(text.num_experts_per_tok) ?? num(text.n_experts_per_tok);
|
|
156
|
+
const byDtype = meta?.safetensors?.parameters;
|
|
157
|
+
const tags = Array.isArray(meta?.tags) ? meta.tags : [];
|
|
158
|
+
const card = meta?.cardData ?? {};
|
|
159
|
+
// The weights' on-disk size: what pulling this model actually costs.
|
|
160
|
+
const weightsBytes = (Array.isArray(meta?.siblings) ? meta.siblings : [])
|
|
161
|
+
.filter((f) => typeof f?.rfilename === 'string' && f.rfilename.endsWith('.safetensors'))
|
|
162
|
+
.reduce((sum, f) => sum + (Number(f.size) || 0), 0);
|
|
163
|
+
|
|
164
|
+
const hf = {
|
|
165
|
+
id: hfId,
|
|
166
|
+
total,
|
|
167
|
+
byDtype: byDtype && Object.keys(byDtype).length ? byDtype : undefined,
|
|
168
|
+
family: (Array.isArray(cfg.architectures) ? cfg.architectures[0] : undefined) ?? undefined,
|
|
169
|
+
modelType: typeof cfg.model_type === 'string' ? cfg.model_type : undefined,
|
|
170
|
+
moe: experts ? { experts, perToken, shared: num(text.n_shared_experts) } : undefined,
|
|
171
|
+
layers: num(text.num_hidden_layers),
|
|
172
|
+
hidden: num(text.hidden_size),
|
|
173
|
+
heads: num(text.num_attention_heads),
|
|
174
|
+
kvHeads: num(text.num_key_value_heads),
|
|
175
|
+
vocab: num(text.vocab_size),
|
|
176
|
+
context: num(text.max_position_embeddings),
|
|
177
|
+
vision: cfg.vision_config ? true : undefined,
|
|
178
|
+
quantization: cfg.quantization_config?.quant_method ?? (tags.includes('compressed-tensors') ? 'compressed-tensors' : undefined),
|
|
179
|
+
task: typeof meta?.pipeline_tag === 'string' ? meta.pipeline_tag : undefined,
|
|
180
|
+
languages: Array.isArray(card.language) ? card.language : undefined,
|
|
181
|
+
license: typeof card.license === 'string' ? card.license : undefined,
|
|
182
|
+
// `license: other` carries a custom name in license_name (often the model's own, not an id).
|
|
183
|
+
licenseName: typeof card.license_name === 'string' && card.license_name !== card.license ? card.license_name : undefined,
|
|
184
|
+
published: typeof meta?.createdAt === 'string' ? meta.createdAt.slice(0, 10) : undefined,
|
|
185
|
+
updated: typeof meta?.lastModified === 'string' ? meta.lastModified.slice(0, 10) : undefined,
|
|
186
|
+
weightsBytes: weightsBytes > 0 ? weightsBytes : undefined,
|
|
187
|
+
// Keep the tags that say something about the model; drop the plumbing
|
|
188
|
+
// (transformers, safetensors, the model type, the task, languages, licence/arxiv markers).
|
|
189
|
+
tags: (() => {
|
|
190
|
+
const drop = new Set(['transformers', 'safetensors', 'eval-results', 'endpoints_compatible', 'region:us',
|
|
191
|
+
'text-generation', 'image-text-to-text', 'feature-extraction', 'conversational', cfg.model_type]);
|
|
192
|
+
const kept = tags.filter((t) => typeof t === 'string' && !t.includes(':') && !drop.has(t) && !/^[a-z]{2}$/.test(t));
|
|
193
|
+
return kept.length ? kept : undefined;
|
|
194
|
+
})(),
|
|
195
|
+
downloads: num(meta?.downloads),
|
|
196
|
+
likes: num(meta?.likes),
|
|
197
|
+
};
|
|
198
|
+
const cleaned = Object.fromEntries(Object.entries(hf).filter(([, v]) => v !== undefined));
|
|
199
|
+
if (Object.keys(cleaned).length > 1) out.hf = cleaned; // > 1: the id alone says nothing
|
|
200
|
+
out.paramsSource = 'huggingface';
|
|
201
|
+
return out;
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function readJson(file) {
|
|
208
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf-8')); } catch { return null; }
|
|
209
|
+
}
|
|
210
|
+
|
|
75
211
|
async function main() {
|
|
76
212
|
console.log(`Fetching ${CATALOGUE_URL} …`);
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
console.error(`✗ Catalogue returned HTTP ${res.status}`);
|
|
80
|
-
process.exit(1);
|
|
81
|
-
}
|
|
82
|
-
const body = await res.json();
|
|
83
|
-
if (!Array.isArray(body?.data) || body.data.length === 0) {
|
|
84
|
-
console.error('✗ Unexpected catalogue shape');
|
|
213
|
+
const live = await fetchUpstream().catch((e) => {
|
|
214
|
+
console.error(`✗ ${e instanceof Error ? e.message : String(e)}`);
|
|
85
215
|
process.exit(1);
|
|
86
|
-
}
|
|
87
|
-
const live = new Map(body.data.map((m) => [m.id, m]));
|
|
216
|
+
});
|
|
88
217
|
console.log(` ${live.size} models upstream\n`);
|
|
89
218
|
|
|
90
219
|
const modelsDoc = JSON.parse(fs.readFileSync(MODELS_FILE, 'utf-8'));
|
|
@@ -99,10 +228,7 @@ async function main() {
|
|
|
99
228
|
for (const m of models) {
|
|
100
229
|
if (m.provider !== 'openrouter') continue;
|
|
101
230
|
|
|
102
|
-
|
|
103
|
-
for (const candidate of upstreamCandidates(m.id)) {
|
|
104
|
-
if (live.has(candidate)) { up = live.get(candidate); break; }
|
|
105
|
-
}
|
|
231
|
+
const up = matchUpstream(live, m.id);
|
|
106
232
|
if (!up) {
|
|
107
233
|
missing++;
|
|
108
234
|
console.log(` ? ${m.id} — not in the upstream catalogue, left as-is`);
|
|
@@ -140,6 +266,38 @@ async function main() {
|
|
|
140
266
|
console.log(`\nPricing: ${priceChanges.length} changed`);
|
|
141
267
|
for (const c of priceChanges) console.log(` ${c.id}\n ${c.from} → ${c.to}`);
|
|
142
268
|
|
|
269
|
+
// ── Details ────────────────────────────────────────────────────────────────
|
|
270
|
+
// One entry per catalogue model we can match upstream, so the file's key space is
|
|
271
|
+
// our catalogue's. Direct entries are matched through VENDOR_ALIAS for the data
|
|
272
|
+
// only; their price stays in model-pricing.json, from their own vendor.
|
|
273
|
+
const upstreamMatch = (id) => matchUpstream(live, id);
|
|
274
|
+
const details = {};
|
|
275
|
+
for (const m of models) {
|
|
276
|
+
const up = upstreamMatch(m.id);
|
|
277
|
+
if (up) details[m.id] = detailsFor(up);
|
|
278
|
+
}
|
|
279
|
+
// Hugging Face pass: sizes for the open-weight models, small concurrency.
|
|
280
|
+
if (!process.argv.includes('--no-hf')) {
|
|
281
|
+
const withHf = Object.entries(details).filter(([, v]) => v.huggingFaceId);
|
|
282
|
+
const queue = [...withHf];
|
|
283
|
+
const worker = async () => {
|
|
284
|
+
while (queue.length) {
|
|
285
|
+
const [id, entry] = queue.shift();
|
|
286
|
+
const facts = await huggingFaceFacts(entry.huggingFaceId);
|
|
287
|
+
if (facts) Object.assign(entry, facts, { paramsSource: 'huggingface' });
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
await Promise.all(Array.from({ length: 8 }, worker));
|
|
291
|
+
console.log(`\nParameters: ${withHf.filter(([, v]) => v.params).length} of ${withHf.length} Hugging Face cards answered`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const previous = readJson(DETAILS_FILE);
|
|
295
|
+
const sameDetails = previous?.models && JSON.stringify(previous.models) === JSON.stringify(details);
|
|
296
|
+
const generatedAt = sameDetails && previous.generatedAt ? previous.generatedAt : new Date().toISOString();
|
|
297
|
+
|
|
298
|
+
console.log(`\nDetails: ${Object.keys(details).length} of ${models.length} models matched upstream` +
|
|
299
|
+
(sameDetails ? ' (unchanged)' : ''));
|
|
300
|
+
|
|
143
301
|
console.log(`\nModality: ${modalityChanges.length} changed`);
|
|
144
302
|
for (const c of modalityChanges) console.log(` ${c.id}\n ${c.from} → ${c.to}`);
|
|
145
303
|
|
|
@@ -149,7 +307,7 @@ async function main() {
|
|
|
149
307
|
`${direct} direct-provider (different vendor, different rates — update by hand)`,
|
|
150
308
|
);
|
|
151
309
|
|
|
152
|
-
if (priceChanges.length === 0 && modalityChanges.length === 0) {
|
|
310
|
+
if (priceChanges.length === 0 && modalityChanges.length === 0 && sameDetails) {
|
|
153
311
|
console.log('\n✓ Already up to date.');
|
|
154
312
|
return;
|
|
155
313
|
}
|
|
@@ -161,6 +319,7 @@ async function main() {
|
|
|
161
319
|
|
|
162
320
|
fs.writeFileSync(PRICING_FILE, serialisePricing(pricing), 'utf-8');
|
|
163
321
|
fs.writeFileSync(MODELS_FILE, JSON.stringify(modelsDoc, null, 2) + '\n', 'utf-8');
|
|
322
|
+
fs.writeFileSync(DETAILS_FILE, JSON.stringify({ source: 'openrouter', generatedAt, models: details }, null, 2) + '\n', 'utf-8');
|
|
164
323
|
console.log('\n✓ Wrote model-pricing.json and models.config.json');
|
|
165
324
|
console.log(' Review the diff before committing.');
|
|
166
325
|
}
|