@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.
@@ -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
- const CATALOGUE_URL = 'https://openrouter.ai/api/v1/models';
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 res = await fetch(CATALOGUE_URL, { headers: { accept: 'application/json' } });
78
- if (!res.ok) {
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
- let up = null;
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
  }