instar 1.3.779 → 1.3.781

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.
Files changed (54) hide show
  1. package/dashboard/index.html +161 -0
  2. package/dist/core/IntelligenceRouter.d.ts +13 -2
  3. package/dist/core/IntelligenceRouter.d.ts.map +1 -1
  4. package/dist/core/IntelligenceRouter.js +90 -2
  5. package/dist/core/IntelligenceRouter.js.map +1 -1
  6. package/dist/core/PostUpdateMigrator.d.ts +21 -0
  7. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  8. package/dist/core/PostUpdateMigrator.js +59 -0
  9. package/dist/core/PostUpdateMigrator.js.map +1 -1
  10. package/dist/core/routingPriceAuthority.d.ts +0 -0
  11. package/dist/core/routingPriceAuthority.d.ts.map +1 -0
  12. package/dist/core/routingPriceAuthority.js +0 -0
  13. package/dist/core/routingPriceAuthority.js.map +1 -0
  14. package/dist/core/routingSpendView.d.ts +172 -0
  15. package/dist/core/routingSpendView.d.ts.map +1 -0
  16. package/dist/core/routingSpendView.js +215 -0
  17. package/dist/core/routingSpendView.js.map +1 -0
  18. package/dist/core/types.d.ts +25 -0
  19. package/dist/core/types.d.ts.map +1 -1
  20. package/dist/core/types.js.map +1 -1
  21. package/dist/data/llmBenchCoverage.d.ts +20 -0
  22. package/dist/data/llmBenchCoverage.d.ts.map +1 -1
  23. package/dist/data/llmBenchCoverage.js +38 -0
  24. package/dist/data/llmBenchCoverage.js.map +1 -1
  25. package/dist/monitoring/FeatureMetricsLedger.d.ts +74 -2
  26. package/dist/monitoring/FeatureMetricsLedger.d.ts.map +1 -1
  27. package/dist/monitoring/FeatureMetricsLedger.js +233 -8
  28. package/dist/monitoring/FeatureMetricsLedger.js.map +1 -1
  29. package/dist/scaffold/templates.d.ts.map +1 -1
  30. package/dist/scaffold/templates.js +2 -1
  31. package/dist/scaffold/templates.js.map +1 -1
  32. package/dist/server/AgentServer.d.ts +1 -0
  33. package/dist/server/AgentServer.d.ts.map +1 -1
  34. package/dist/server/AgentServer.js +35 -2
  35. package/dist/server/AgentServer.js.map +1 -1
  36. package/dist/server/CapabilityIndex.d.ts.map +1 -1
  37. package/dist/server/CapabilityIndex.js +12 -0
  38. package/dist/server/CapabilityIndex.js.map +1 -1
  39. package/dist/server/routes.d.ts +3 -0
  40. package/dist/server/routes.d.ts.map +1 -1
  41. package/dist/server/routes.js +46 -0
  42. package/dist/server/routes.js.map +1 -1
  43. package/package.json +1 -1
  44. package/scripts/lint-nature-chains.mjs +210 -8
  45. package/scripts/routing-price-refresh.mjs +216 -0
  46. package/scripts/routing-prices.manifest.json +64 -0
  47. package/src/data/builtin-manifest.json +64 -64
  48. package/src/data/llmBenchCoverage.ts +41 -0
  49. package/src/scaffold/templates/jobs/instar/routing-price-refresh.md +30 -0
  50. package/src/scaffold/templates.ts +2 -1
  51. package/upgrades/1.3.780.md +28 -0
  52. package/upgrades/1.3.781.md +25 -0
  53. package/upgrades/side-effects/nature-axis-routing-fd42-rrule-lints.md +63 -0
  54. package/upgrades/side-effects/routing-spend-increment-a.md +84 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "1.3.779",
3
+ "version": "1.3.781",
4
4
  "description": "Coherence infrastructure for self-evolving AI agents — on the Claude Code or Codex subscription you already have.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -166,6 +166,204 @@ export function runNatureChainsLint(src) {
166
166
  return { violations, reserveId, chains, labelMap };
167
167
  }
168
168
 
169
+ /* ────────────────────────────────────────────────────────────────────────────
170
+ * FD4.2 — the R-rule STRUCTURAL-EXCLUSION lints (R3–R8), enforced at BUILD time.
171
+ * Spec: docs/specs/nature-axis-routing.md FD5(c) §296-314; LLM-ROUTING-REGISTRY
172
+ * hard rules #3/#5/#6.
173
+ *
174
+ * R3/R4/R5/R7 are POSITION bans over the authored chains — MIRRORING the pure TS
175
+ * predicate IntelligenceRouter.validateChainPositionRRule (a companion drift-guard
176
+ * test asserts the two agree). R6/R8 are COMPONENT-scoped structural pins over the
177
+ * per-component maps that live config can NEVER override, so they are BUILD-LINT
178
+ * ONLY. All are fail-closed: an unparseable map is a build failure, not a silent pass.
179
+ * ──────────────────────────────────────────────────────────────────────────── */
180
+
181
+ const STRICT_FORMAT_CHAINS = new Set(['FAST', 'SORT']);
182
+ const R3_QWEN = /qwen/i;
183
+ const R4_GEMINI_CLI_DOOR = 'gemini-cli';
184
+ const R5_WEAK_GATE = /gpt-oss-20b|llama-4-scout/i;
185
+ const R7_DEEPSEEK = /deepseek/i;
186
+
187
+ /**
188
+ * The FD4.2 R-rule POSITION-ban predicate over one authored position (R3/R4/R5/R7) —
189
+ * MIRRORS IntelligenceRouter.validateChainPositionRRule. Returns a violation or null.
190
+ */
191
+ export function rruleViolationForPosition(chain, pos, index, labelMap) {
192
+ const resolvedModelId = labelMap[pos.door]?.[pos.model] ?? pos.model;
193
+ const modelText = `${pos.model} ${resolvedModelId}`;
194
+ const v = (rule, detail) => ({ chain, index, door: pos.door, model: pos.model, resolvedModelId, rule, detail });
195
+
196
+ if (STRICT_FORMAT_CHAINS.has(chain) && R3_QWEN.test(modelText)) {
197
+ return v(
198
+ 'rrule-r3-qwen-strict-format',
199
+ `${chain}[${index}] ${pos.door}/'${pos.model}' is a qwen-tier model in a strict-format (FAST/SORT) ` +
200
+ `position — R3: qwen-tier chronically reason-burns and self-clips bounded-contract JSON (0.116/0.028).`,
201
+ );
202
+ }
203
+ if (chain !== 'JUDGE') return null;
204
+ if (pos.door === R4_GEMINI_CLI_DOOR) {
205
+ return v(
206
+ 'rrule-r4-gemini-cli-judge',
207
+ `JUDGE[${index}] is the '${R4_GEMINI_CLI_DOOR}' door — R4: consumer Flash 2.5 fell for a judge-directed ` +
208
+ `injection; it may never take an injection-exposed JUDGE (safety-gate) position.`,
209
+ );
210
+ }
211
+ if (R5_WEAK_GATE.test(modelText)) {
212
+ return v(
213
+ 'rrule-r5-weak-model-judge',
214
+ `JUDGE[${index}] ${pos.door}/'${pos.model}' — R5: gpt-oss-20b / llama-4-scout may never take a gate ` +
215
+ `(JUDGE) verdict position (injection-credulous / over-conservative contract-breakers).`,
216
+ );
217
+ }
218
+ if (R7_DEEPSEEK.test(modelText) || R7_DEEPSEEK.test(pos.door)) {
219
+ return v(
220
+ 'rrule-r7-deepseek-judge',
221
+ `JUDGE[${index}] ${pos.door}/'${pos.model}' is a DeepSeek door/model — R7: DeepSeek may never take an ` +
222
+ `injection-exposed JUDGE (safety-gate) position.`,
223
+ );
224
+ }
225
+ return null;
226
+ }
227
+
228
+ /** Extract the quoted string members of a `new Set([ ... ])` const (fail-closed on absence). */
229
+ export function extractStringSet(src, name) {
230
+ const start = src.indexOf(`export const ${name}`);
231
+ if (start < 0) throw new Error(`${name} not found in ${path.basename(COVERAGE_SRC)}`);
232
+ const open = src.indexOf('[', start);
233
+ const close = src.indexOf(']', open);
234
+ if (open < 0 || close < 0) throw new Error(`${name}: 'new Set([...])' body not found`);
235
+ const body = src.slice(open + 1, close);
236
+ const out = [];
237
+ const re = /'([^']+)'/g;
238
+ let m;
239
+ while ((m = re.exec(body)) !== null) out.push(m[1]);
240
+ return out;
241
+ }
242
+
243
+ /** Extract LLM_ROUTING_NATURE as { [component]: chain }. */
244
+ export function extractNatureMap(src) {
245
+ const body = sliceConstBlock(src, 'LLM_ROUTING_NATURE');
246
+ const map = {};
247
+ const re = /(?:'([^']+)'|([A-Za-z_][\w-]*))\s*:\s*\{\s*nature:\s*'[^']+'\s*,\s*chain:\s*'([^']+)'\s*\}/g;
248
+ let m;
249
+ while ((m = re.exec(body)) !== null) map[m[1] || m[2]] = m[3];
250
+ if (Object.keys(map).length === 0) throw new Error('LLM_ROUTING_NATURE parsed empty');
251
+ return map;
252
+ }
253
+
254
+ /** Extract LLM_ROUTING_INJECTION_EXPOSURE as { [component]: boolean exposed } from the exposed()/notExposed() sugar. */
255
+ export function extractExposureMap(src) {
256
+ const body = sliceConstBlock(src, 'LLM_ROUTING_INJECTION_EXPOSURE');
257
+ const map = {};
258
+ const re = /(?:'([^']+)'|([A-Za-z_][\w-]*))\s*:\s*(exposed|notExposed)\(/g;
259
+ let m;
260
+ while ((m = re.exec(body)) !== null) map[m[1] || m[2]] = m[3] === 'exposed';
261
+ if (Object.keys(map).length === 0) throw new Error('LLM_ROUTING_INJECTION_EXPOSURE parsed empty');
262
+ return map;
263
+ }
264
+
265
+ /**
266
+ * R6 (build-lint only) — every claude-banned (doc-tree/cartographer) component that HAS a
267
+ * nature/chain must route through a chain with NO claude-code door position. Structural pin:
268
+ * a future edit giving a doc-tree component a Claude-containing chain fails the build.
269
+ */
270
+ export function r6Violations(src, chains) {
271
+ const banned = extractStringSet(src, 'NATURE_ROUTING_CLAUDE_BANNED_COMPONENTS');
272
+ if (banned.length === 0) throw new Error('NATURE_ROUTING_CLAUDE_BANNED_COMPONENTS parsed empty (fail-closed)');
273
+ const natureChain = extractNatureMap(src);
274
+ const out = [];
275
+ for (const comp of banned) {
276
+ const chain = natureChain[comp];
277
+ if (!chain) continue; // no nature/chain yet ⇒ structurally cannot route anywhere (vacuously off-Claude)
278
+ const positions = chains[chain] ?? [];
279
+ positions.forEach((pos, i) => {
280
+ if (pos.door === 'claude-code') {
281
+ out.push({
282
+ chain,
283
+ index: i,
284
+ door: pos.door,
285
+ model: pos.model,
286
+ resolvedModelId: pos.model,
287
+ rule: 'rrule-r6-claude-banned-component',
288
+ detail:
289
+ `component '${comp}' is Claude-banned (doc-tree/cartographer, R6) but its ${chain} chain routes to a ` +
290
+ `claude-code door at [${i}] — R6 (absolute): a doc-tree component may never route to any claude-code door.`,
291
+ });
292
+ }
293
+ });
294
+ }
295
+ return out;
296
+ }
297
+
298
+ /**
299
+ * R8 (build-lint only) — the input-classifier components are pinned off Flash-Lite. Assert
300
+ * (a) each is `exposed: true` in the injection map (so the FD5b gate skips non-injection doors
301
+ * for them), and (b) every authored `flash-lite` position sits on a METERED door — so it is
302
+ * structurally skipped in Increment A and no input-classifier can reach it.
303
+ */
304
+ export function r8Violations(src, chains) {
305
+ const inputClassifiers = extractStringSet(src, 'NATURE_ROUTING_INPUT_CLASSIFIER_COMPONENTS');
306
+ if (inputClassifiers.length === 0)
307
+ throw new Error('NATURE_ROUTING_INPUT_CLASSIFIER_COMPONENTS parsed empty (fail-closed)');
308
+ const exposure = extractExposureMap(src);
309
+ const metered = new Set(extractStringSet(src, 'METERED_ROUTING_DOORS'));
310
+ const out = [];
311
+ // (a) each input-classifier must be statically injection-exposed.
312
+ for (const comp of inputClassifiers) {
313
+ if (exposure[comp] !== true) {
314
+ out.push({
315
+ chain: 'n/a',
316
+ index: -1,
317
+ door: 'n/a',
318
+ model: comp,
319
+ resolvedModelId: comp,
320
+ rule: 'rrule-r8-input-classifier-not-exposed',
321
+ detail:
322
+ `input-classifier component '${comp}' must be marked injection-exposed (exposed: true) in ` +
323
+ `LLM_ROUTING_INJECTION_EXPOSURE — R8: so the FD5b gate skips non-injection doors for it. ` +
324
+ `(found: ${exposure[comp] === false ? 'exposed: false' : 'absent'})`,
325
+ });
326
+ }
327
+ }
328
+ // (b) any authored flash-lite position must be behind the metered gate (unreachable in Increment A).
329
+ for (const chain of ['FAST', 'SORT', 'JUDGE', 'WRITE']) {
330
+ (chains[chain] ?? []).forEach((pos, i) => {
331
+ if (/flash-lite/i.test(pos.model) && !metered.has(pos.door)) {
332
+ out.push({
333
+ chain,
334
+ index: i,
335
+ door: pos.door,
336
+ model: pos.model,
337
+ resolvedModelId: pos.model,
338
+ rule: 'rrule-r8-flash-lite-reachable',
339
+ detail:
340
+ `${chain}[${i}] places flash-lite on non-metered door '${pos.door}' — R8: Flash-Lite must stay behind ` +
341
+ `the metered gate so input-classifier components (which walk FAST/SORT) can never land on it.`,
342
+ });
343
+ }
344
+ });
345
+ }
346
+ return out;
347
+ }
348
+
349
+ /** Run ALL FD4.2 R-rule lints (R3–R8) over source text. Returns { violations }. */
350
+ export function runNatureRuleLints(src) {
351
+ const labelMap = extractLabelMap(src);
352
+ const chains = extractChains(src);
353
+ const violations = [];
354
+ // R3/R4/R5/R7 — position bans over the authored chains.
355
+ for (const chain of ['FAST', 'SORT', 'JUDGE', 'WRITE']) {
356
+ chains[chain].forEach((pos, i) => {
357
+ const v = rruleViolationForPosition(chain, pos, i, labelMap);
358
+ if (v) violations.push(v);
359
+ });
360
+ }
361
+ // R6/R8 — component-scoped structural pins over the maps.
362
+ violations.push(...r6Violations(src, chains));
363
+ violations.push(...r8Violations(src, chains));
364
+ return { violations, chains, labelMap };
365
+ }
366
+
169
367
  function main() {
170
368
  let src;
171
369
  try {
@@ -175,26 +373,30 @@ function main() {
175
373
  process.exit(1);
176
374
  }
177
375
  let result;
376
+ let ruleResult;
178
377
  try {
179
378
  result = runNatureChainsLint(src);
379
+ ruleResult = runNatureRuleLints(src);
180
380
  } catch (err) {
181
381
  console.error(`lint-nature-chains: parse error (fail-closed — an unparseable map is a build failure) — ${err.message}`);
182
382
  process.exit(1);
183
383
  }
184
- if (result.violations.length === 0) {
384
+ const allViolations = [...result.violations, ...ruleResult.violations];
385
+ if (allViolations.length === 0) {
185
386
  console.log(
186
- `lint-nature-chains: OK — every FAST/SORT/JUDGE claude-code position is the pinned reserve ` +
187
- `'${result.reserveId}', and no chain emits Fable (FD4 harness-door ban clean).`,
387
+ `lint-nature-chains: OK — FD4 harness-door ban clean (claude-code FAST/SORT/JUDGE is the pinned reserve ` +
388
+ `'${result.reserveId}', no Fable), AND the FD4.2 R-rule structural exclusions (R3–R8) all hold.`,
188
389
  );
189
390
  process.exit(0);
190
391
  }
191
392
  console.error(
192
- 'lint-nature-chains: FD4 HARNESS-DOOR BAN VIOLATION(S) in NATURE_ROUTING_DEFAULT_CHAINS ' +
193
- '(src/data/llmBenchCoverage.ts):\n' +
194
- result.violations.map((v) => ` - [${v.rule}] ${v.detail}`).join('\n') +
393
+ 'lint-nature-chains: NATURE-CHAIN VIOLATION(S) in src/data/llmBenchCoverage.ts ' +
394
+ '(FD4 harness-door ban and/or FD4.2 R-rule structural exclusions R3–R8):\n' +
395
+ allViolations.map((v) => ` - [${v.rule}] ${v.detail}`).join('\n') +
195
396
  `\n\nThe one permitted claude-code FAST/SORT/JUDGE position is the pinned reserve id ` +
196
- `'${result.reserveId}' (author it as the registry-pinned 'balanced' label or the concrete id). ` +
197
- `WRITE is exempt for Opus-via-CLI, but NO chain may emit Fable. Spec: docs/specs/nature-axis-routing.md FD4/FD8.`,
397
+ `'${result.reserveId}'; WRITE is exempt for Opus-via-CLI but NO chain may emit Fable; and the R-rules ` +
398
+ `(R3 qwen∉strict-format, R4 gemini-cli∉JUDGE, R5 gpt-oss-20b/llama-4-scout∉JUDGE, R6 doc-tree∉claude-code, ` +
399
+ `R7 DeepSeek∉JUDGE, R8 input-classifiers off Flash-Lite) are structural. Spec: docs/specs/nature-axis-routing.md FD4/FD5c/FD8.`,
198
400
  );
199
401
  process.exit(1);
200
402
  }
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * routing-price-refresh.mjs — the deterministic prober behind the OFF-by-default
4
+ * `routing-price-refresh` job (docs/specs/routing-control-room-spend-alerts.md, FD-8).
5
+ *
6
+ * It re-confirms published per-token prices for the metered doors and writes them into
7
+ * the MACHINE-LOCAL OBSERVED CACHE ONLY (`.instar/routing-prices.observed.json`) —
8
+ * STRUCTURALLY never the canonical manifest (a lint + unit test assert this). Observed
9
+ * points feed the REPORTING view + the promote-me drift hint; they are gate-INELIGIBLE
10
+ * by construction (in Increment B the money gate reads only the canonical manifest).
11
+ *
12
+ * FD-8 discipline (all enforced here):
13
+ * - FORWARD-ONLY: every written point has `effectiveAt = today (UTC, day-aligned)`,
14
+ * `corrects: null`. It can never write a backdated point or a correction.
15
+ * - FREE-PROBE FIRST: `--scope free-probes` (default) queries only public, no-auth
16
+ * model-list endpoints (OpenRouter). Metered / web-verify probes are MANUAL-ONLY and
17
+ * refused unless a positive `--budget-usd` is passed (default 0 → refuse) — an
18
+ * unknown price refuses rather than guesses.
19
+ * - SANE-PRICE VALIDATION: a candidate failing the range / cached≤input checks is
20
+ * dropped (never written), matching the reporting authority's fail-closed load.
21
+ *
22
+ * Pure core (parse / forward-only-merge / validate) is unit-tested with fixtures — no
23
+ * network in tests. Usage:
24
+ * node scripts/routing-price-refresh.mjs [--scope free-probes|+liveness|+web-verify]
25
+ * [--budget-usd N] [--dry-run] [--out <path>] [--project-dir <dir>] [--state-dir <dir>]
26
+ */
27
+
28
+ import * as fs from 'node:fs';
29
+ import * as path from 'node:path';
30
+
31
+ /** The metered (door → canonical model ids) we track prices for — kept in sync with the manifest. */
32
+ export const TRACKED = {
33
+ 'openrouter-api': ['openai/gpt-5.5', 'anthropic/claude-opus-4-8'],
34
+ 'gemini-api': ['gemini-3.1-flash-lite'],
35
+ 'groq-api': ['openai/gpt-oss-120b'],
36
+ };
37
+
38
+ /** UTC-day-aligned ISO (T00:00:00Z) for a timestamp — every observed point is day-aligned (FD-18). */
39
+ export function dayAlignedIso(ms) {
40
+ const d = new Date(ms);
41
+ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())).toISOString();
42
+ }
43
+
44
+ function canonical(id) {
45
+ return String(id ?? '').trim().toLowerCase();
46
+ }
47
+
48
+ /** Sane-price validation (mirrors routingPriceAuthority.isValidPricePoint's money-safety checks). */
49
+ export function isSanePoint(p) {
50
+ if (!p || typeof p !== 'object') return false;
51
+ if (typeof p.door !== 'string' || !p.door.trim()) return false;
52
+ if (typeof p.modelId !== 'string' || !p.modelId.trim()) return false;
53
+ if (typeof p.inPerMtok !== 'number' || !Number.isFinite(p.inPerMtok) || p.inPerMtok < 0) return false;
54
+ if (typeof p.outPerMtok !== 'number' || !Number.isFinite(p.outPerMtok) || p.outPerMtok < 0) return false;
55
+ if (p.cachedInPerMtok !== undefined) {
56
+ if (typeof p.cachedInPerMtok !== 'number' || !Number.isFinite(p.cachedInPerMtok) || p.cachedInPerMtok < 0 || p.cachedInPerMtok > p.inPerMtok) return false;
57
+ }
58
+ if (typeof p.effectiveAt !== 'string' || dayAlignedIso(Date.parse(p.effectiveAt)) !== p.effectiveAt) return false;
59
+ return true;
60
+ }
61
+
62
+ /**
63
+ * Parse OpenRouter's public /models payload into candidate observed points for the
64
+ * TRACKED openrouter models. OpenRouter reports `pricing.prompt`/`.completion` as USD
65
+ * PER TOKEN (string); we convert to USD per MILLION tokens. Pure.
66
+ */
67
+ export function parseOpenRouterModels(payload, nowMs) {
68
+ const effectiveAt = dayAlignedIso(nowMs);
69
+ const out = [];
70
+ const data = payload && Array.isArray(payload.data) ? payload.data : [];
71
+ const tracked = new Set(TRACKED['openrouter-api'].map(canonical));
72
+ for (const m of data) {
73
+ if (!m || typeof m !== 'object') continue;
74
+ const modelId = canonical(m.id);
75
+ if (!tracked.has(modelId)) continue;
76
+ const pr = m.pricing;
77
+ if (!pr) continue;
78
+ const inPerMtok = Number(pr.prompt) * 1e6;
79
+ const outPerMtok = Number(pr.completion) * 1e6;
80
+ const point = {
81
+ door: 'openrouter-api',
82
+ modelId,
83
+ inPerMtok: Number.isFinite(inPerMtok) ? round6(inPerMtok) : NaN,
84
+ outPerMtok: Number.isFinite(outPerMtok) ? round6(outPerMtok) : NaN,
85
+ effectiveAt,
86
+ recordedAt: new Date(nowMs).toISOString(),
87
+ source: 'openrouter-models-api',
88
+ corrects: null,
89
+ };
90
+ if (isSanePoint(point)) out.push(point);
91
+ }
92
+ return out;
93
+ }
94
+
95
+ /**
96
+ * FORWARD-ONLY merge into the observed cache: keep existing observed points, and add a
97
+ * new candidate ONLY when its effectiveAt is ≥ every existing point for the same
98
+ * (door, model) — never rewrite history, never a same-day duplicate. Pure.
99
+ */
100
+ export function mergeForwardOnly(existingPoints, candidates) {
101
+ const points = Array.isArray(existingPoints) ? existingPoints.slice() : [];
102
+ const latestByKey = new Map();
103
+ for (const p of points) {
104
+ const key = `${p.door} ${canonical(p.modelId)}`;
105
+ const eff = Date.parse(p.effectiveAt);
106
+ if (!latestByKey.has(key) || eff > latestByKey.get(key)) latestByKey.set(key, eff);
107
+ }
108
+ const added = [];
109
+ for (const c of candidates) {
110
+ if (!isSanePoint(c)) continue;
111
+ const key = `${c.door} ${canonical(c.modelId)}`;
112
+ const eff = Date.parse(c.effectiveAt);
113
+ const latest = latestByKey.get(key);
114
+ if (latest !== undefined && eff <= latest) continue; // forward-only: no backdate, no same-day dup
115
+ points.push(c);
116
+ latestByKey.set(key, eff);
117
+ added.push(c);
118
+ }
119
+ return { points, added };
120
+ }
121
+
122
+ function round6(n) {
123
+ return Math.round(n * 1e6) / 1e6;
124
+ }
125
+
126
+ function readObserved(outPath) {
127
+ try {
128
+ const raw = JSON.parse(fs.readFileSync(outPath, 'utf-8'));
129
+ return Array.isArray(raw?.points) ? raw.points : [];
130
+ } catch {
131
+ return [];
132
+ }
133
+ }
134
+
135
+ function writeObserved(outPath, points) {
136
+ const body = {
137
+ schemaVersion: 1,
138
+ _doc: 'MACHINE-LOCAL observed price cache written ONLY by scripts/routing-price-refresh.mjs (FD-8). REPORTING-ONLY — never gate-eligible; promote to the canonical manifest via the reviewed git/PIN path. Append/forward-only.',
139
+ points,
140
+ };
141
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
142
+ fs.writeFileSync(outPath, `${JSON.stringify(body, null, 2)}\n`);
143
+ }
144
+
145
+ async function fetchOpenRouterModels(timeoutMs = 8000) {
146
+ const ctrl = new AbortController();
147
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
148
+ try {
149
+ const resp = await fetch('https://openrouter.ai/api/v1/models', { signal: ctrl.signal });
150
+ if (!resp.ok) throw new Error(`openrouter models HTTP ${resp.status}`);
151
+ return await resp.json();
152
+ } finally {
153
+ clearTimeout(t);
154
+ }
155
+ }
156
+
157
+ function parseArgs(argv) {
158
+ const args = { scope: 'free-probes', budgetUsd: 0, dryRun: false, out: null, projectDir: process.cwd(), stateDir: null };
159
+ for (let i = 0; i < argv.length; i++) {
160
+ const a = argv[i];
161
+ if (a === '--scope') args.scope = argv[++i];
162
+ else if (a === '--budget-usd') args.budgetUsd = Number(argv[++i]);
163
+ else if (a === '--dry-run') args.dryRun = true;
164
+ else if (a === '--out') args.out = argv[++i];
165
+ else if (a === '--project-dir') args.projectDir = argv[++i];
166
+ else if (a === '--state-dir') args.stateDir = argv[++i];
167
+ }
168
+ return args;
169
+ }
170
+
171
+ async function main() {
172
+ const args = parseArgs(process.argv.slice(2));
173
+ const stateDir = args.stateDir ?? path.join(args.projectDir, '.instar');
174
+ const outPath = args.out ?? path.join(stateDir, 'routing-prices.observed.json');
175
+ const now = Date.now();
176
+
177
+ if (args.scope !== 'free-probes' && !(args.budgetUsd > 0)) {
178
+ // Metered / web-verify probes are MANUAL-ONLY + budget-capped (FD-8). No budget → refuse.
179
+ console.error(`[routing-price-refresh] scope '${args.scope}' requires a positive --budget-usd (metered probes are manual-only, budget-fail-closed). Refusing.`);
180
+ process.exit(2);
181
+ return;
182
+ }
183
+
184
+ const candidates = [];
185
+ const notes = [];
186
+ // Free scope: only publicly queryable doors. OpenRouter's /models is public + no-auth.
187
+ try {
188
+ const payload = await fetchOpenRouterModels();
189
+ const pts = parseOpenRouterModels(payload, now);
190
+ candidates.push(...pts);
191
+ notes.push(`openrouter-api: ${pts.length} tracked model price(s) probed`);
192
+ } catch (err) {
193
+ notes.push(`openrouter-api: probe failed (${err?.message ?? err}) — skipped, no data written for this door`);
194
+ }
195
+ // gemini-api / groq-api need a key → out of the free scope. Honestly reported, never guessed.
196
+ notes.push('gemini-api, groq-api: need an API key → not in free-probe scope (manual metered probe only)');
197
+
198
+ const existing = readObserved(outPath);
199
+ const { points, added } = mergeForwardOnly(existing, candidates);
200
+
201
+ if (args.dryRun) {
202
+ console.log(JSON.stringify({ dryRun: true, wouldAdd: added, notes }, null, 2));
203
+ return;
204
+ }
205
+ if (added.length > 0) writeObserved(outPath, points);
206
+ console.log(JSON.stringify({ added: added.length, totalObserved: points.length, out: outPath, notes }, null, 2));
207
+ }
208
+
209
+ // Only run when invoked directly (import for unit tests stays side-effect-free).
210
+ const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname);
211
+ if (invokedDirectly) {
212
+ main().catch((err) => {
213
+ console.error('[routing-price-refresh] fatal:', err?.message ?? err);
214
+ process.exit(1);
215
+ });
216
+ }
@@ -0,0 +1,64 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "version": 1,
4
+ "_doc": "Canonical, human-reviewed price authority for the Routing Control Room spend view (docs/specs/routing-control-room-spend-alerts.md, Layer 1). USD per MILLION tokens, per door+model, effective-dated. APPEND-ONLY: a change or correction ADDS a point; points are never edited in place. GENERIC PUBLISHED prices only — operator-specific deals live in the machine-local overlay (.instar/routing-prices.overlay.json), never here. Written ONLY by a human path (a reviewed git commit, or — in Increment B — the PIN-gated promotion action). Every point's effectiveAt MUST be UTC-day-aligned (T00:00:00Z). In Increment A this manifest feeds the REPORTING view only; no money gate consumes it yet (the gate is Increment B).",
5
+ "doors": {
6
+ "gemini-api": {
7
+ "freshnessSlaDays": 45,
8
+ "staleMode": "book-conservative-max",
9
+ "conservativeMax": { "inPerMtok": 2.0, "outPerMtok": 12.0 }
10
+ },
11
+ "openrouter-api": {
12
+ "freshnessSlaDays": 45,
13
+ "staleMode": "book-conservative-max",
14
+ "conservativeMax": { "inPerMtok": 15.0, "outPerMtok": 90.0 }
15
+ },
16
+ "groq-api": {
17
+ "freshnessSlaDays": 45,
18
+ "staleMode": "book-conservative-max",
19
+ "conservativeMax": { "inPerMtok": 2.0, "outPerMtok": 8.0 }
20
+ }
21
+ },
22
+ "points": [
23
+ {
24
+ "door": "gemini-api",
25
+ "modelId": "gemini-3.1-flash-lite",
26
+ "inPerMtok": 0.25,
27
+ "outPerMtok": 1.5,
28
+ "effectiveAt": "2026-07-01T00:00:00Z",
29
+ "recordedAt": "2026-07-05T00:00:00Z",
30
+ "source": "published-generic-list",
31
+ "corrects": null
32
+ },
33
+ {
34
+ "door": "openrouter-api",
35
+ "modelId": "openai/gpt-5.5",
36
+ "inPerMtok": 5.0,
37
+ "outPerMtok": 30.0,
38
+ "effectiveAt": "2026-07-01T00:00:00Z",
39
+ "recordedAt": "2026-07-05T00:00:00Z",
40
+ "source": "published-generic-list",
41
+ "corrects": null
42
+ },
43
+ {
44
+ "door": "openrouter-api",
45
+ "modelId": "anthropic/claude-opus-4-8",
46
+ "inPerMtok": 5.0,
47
+ "outPerMtok": 25.0,
48
+ "effectiveAt": "2026-07-01T00:00:00Z",
49
+ "recordedAt": "2026-07-05T00:00:00Z",
50
+ "source": "published-generic-list",
51
+ "corrects": null
52
+ },
53
+ {
54
+ "door": "groq-api",
55
+ "modelId": "openai/gpt-oss-120b",
56
+ "inPerMtok": 0.2,
57
+ "outPerMtok": 0.8,
58
+ "effectiveAt": "2026-07-01T00:00:00Z",
59
+ "recordedAt": "2026-07-05T00:00:00Z",
60
+ "source": "published-generic-list",
61
+ "corrects": null
62
+ }
63
+ ]
64
+ }