@kontextmind/kxm 0.7.186 → 0.7.188

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.
@@ -11,7 +11,7 @@
11
11
  "name": "kxm",
12
12
  "source": "./plugins/kxm",
13
13
  "description": "Durable workflows, peer agents, and kxm tui",
14
- "version": "0.7.186",
14
+ "version": "0.7.188",
15
15
  "category": "development",
16
16
  "tags": ["kxm", "multi-agent", "workflows", "mcp"]
17
17
  }
package/.kxm/README.md CHANGED
@@ -19,6 +19,7 @@ a `default` workflow, `gates.yaml` and `template-provenance.yaml`.
19
19
  | `models/inventory.yaml` | The discovered model catalog | Generated; track it for a reviewed snapshot |
20
20
  | `config.yaml` | Shared personalization settings | Tracked if the project shares them |
21
21
  | `memory/` | Project memory facts that `kxm memory` projects into `AGENTS.md`, `CLAUDE.md` and `GEMINI.md` | Tracked |
22
+ | `knowledge/` | Authored project wiki. The hub-compiled view, when written, is `knowledge/wiki/` | Tracked |
22
23
  | `assets/` | Intentional workflow inputs and outputs; `assets/generated/` is for machine output | Tracked intentionally; `generated/` ignored |
23
24
  | `tasks/` | Task records from `kxm task` | Your choice |
24
25
  | `logs/` | Hub, worker and agent logs | Ignored |
package/CHANGELOG.md CHANGED
@@ -6,6 +6,15 @@ All notable user-facing changes are documented here. The project follows [Semant
6
6
 
7
7
  ### Added
8
8
 
9
+ - **OpenRouter model prices and the inventory refresh weekly.**
10
+ `.github/workflows/refresh-models.yml` runs Monday at 13:00 UTC, or on
11
+ demand, and opens one pull request on `automation/model-refresh` when
12
+ `.kxm/prices.yaml` or the OpenRouter slice of `.kxm/models/inventory.yaml`
13
+ changes. A model still named by `.kxm/routes.yaml` or a roster is kept when
14
+ it leaves the catalog, and the `model-catalog-guard` check fails until a
15
+ human decides. Run it locally with
16
+ `node --disable-warning=ExperimentalWarning --experimental-strip-types scripts/refresh-model-catalog.mjs`.
17
+
9
18
  - **The witnessed MG1 Python control-plane foundation is now reproducible from this repository.**
10
19
  `python/kxm-control` provides the pinned Python 3.13 uv workspace, fixed
11
20
  `kxmd` Postgres/Temporal caller, Authentik user and runner verification
package/docs/README.md CHANGED
@@ -110,6 +110,7 @@ KXM connects coding agents through a durable, authenticated [hub](glossary.md#hu
110
110
  |---|---|---|
111
111
  | [Develop KXM](contributing/development.md) | Contributors | Setup, build and verify, packaging, and the repository layout |
112
112
  | [CI and release](contributing/ci-and-release.md) | Maintainers | What CI runs on each change and how releases ship |
113
+ | [Project knowledge wiki](../.kxm/knowledge/index.md) | Maintainers | Authored notes on architecture, routing, release, and CI, with file-path sources |
113
114
  | [Write KXM documentation](contributing/writing-docs.md) | Contributors | The style rules, the page template and the docs checks |
114
115
  | [Test matrix](contributing/test-matrix.md) | Maintainers | Features and use cases mapped to automated evidence |
115
116
  | [Assignment runner](contributing/assignment-runner.md) | Maintainers | Developer assignments, witness verification and dual-critic acceptance |
@@ -18,3 +18,4 @@ against the current checkout.
18
18
 
19
19
  - [Roadmap](../roadmap/dashboard.md)
20
20
  - [Trust model](../concepts/trust-model.md)
21
+ - [Project knowledge wiki](../../.kxm/knowledge/architecture/overview.md)
@@ -46,6 +46,7 @@ together.
46
46
  | Push to `main`, or manual | `ci.yml` (Validate matrix) | `validate:pr` on Linux and Windows for Node 22.19.0 and Node 24; not on a pull request |
47
47
  | Code pull request and code push | `ci.yml` (Plugin validation) | `claude plugin validate --strict` on the marketplace and the plugin |
48
48
  | Daily at 04:00 UTC, or manual | `nightly.yml` | `test:coverage:complete`, `check`, `check:generated`, `npm pack --dry-run` |
49
+ | Monday at 13:00 UTC, or manual | `refresh-models.yml` | Fetches the public OpenRouter catalog, and opens or updates one pull request on `automation/model-refresh` when `.kxm/prices.yaml` or `.kxm/models/inventory.yaml` changes. Uses `GITHUB_TOKEN` only, then dispatches `ci.yml` on that branch |
49
50
  | Merged pull request | `auto-release.yml` | Tags the merge commit and dispatches `release.yml` |
50
51
  | Tag push or dispatch | `release.yml` | Verifies, packs and publishes (see [Release flow](#release-flow)) |
51
52
  | Manual only | `smoke.yml` | Real Pi smoke, currently disabled (see [Smoke tests](#smoke-tests)) |
@@ -298,3 +299,4 @@ implying automated coverage.
298
299
  - [Test matrix](test-matrix.md): which test proves which behavior
299
300
  - [Write KXM documentation](writing-docs.md): doc gates and pinned paths
300
301
  - [Upgrade KXM](../operations/upgrade.md): the operator side of a new version
302
+ - [Project knowledge wiki](../../.kxm/knowledge/index.md): authored notes for [CI](../../.kxm/knowledge/patterns/ci.md) and [release](../../.kxm/knowledge/patterns/release-flow.md)
@@ -400,6 +400,28 @@ authentication are the protocol. A change to any of them needs:
400
400
  Prefer additive changes. KXM does not keep backward-compatible aliases for
401
401
  retired names: an old name fails closed instead.
402
402
 
403
+ ## Refresh model prices and inventory
404
+
405
+ OpenRouter list prices and the model inventory are refreshed by
406
+ `.github/workflows/refresh-models.yml` every Monday at 13:00 UTC. To run the
407
+ same refresh before that, from the repository root:
408
+
409
+ ```bash
410
+ node --disable-warning=ExperimentalWarning --experimental-strip-types scripts/refresh-model-catalog.mjs --dry-run
411
+ ```
412
+
413
+ `--dry-run` prints models added, removed, and repriced, and writes nothing.
414
+ Drop `--dry-run` to update `.kxm/prices.yaml` and `.kxm/models/inventory.yaml`.
415
+ Exit 2 means a model named by `.kxm/routes.yaml` or a roster left the OpenRouter
416
+ catalog; the refresh keeps that inventory row and sets `missingUpstream: true`.
417
+ Review the diff and commit it yourself, or wait for the Monday pull request on
418
+ `automation/model-refresh`. The field reference is
419
+ [Weekly OpenRouter refresh](../reference/config-reference.md#weekly-openrouter-refresh).
420
+
421
+ `kxm models inventory-refresh` still rewrites the whole inventory from Pi, Grok,
422
+ agy, OpenRouter, and Nous, including a new `fetchedAt` timestamp. The weekly
423
+ script does not call those local CLIs.
424
+
403
425
  ## Troubleshooting
404
426
 
405
427
  | Symptom | Cause | Fix |
package/docs/glossary.md CHANGED
@@ -397,7 +397,7 @@ Project facts stored as state keys with a `proposed → current → superseded`
397
397
 
398
398
  ### Wiki
399
399
 
400
- Markdown pages compiled from a project's context records by `kxm context wiki-compile` (a dry run unless you pass `--out`), with state, decisions, contradictions and history linked to their evidence. `kxm context wiki-lint` checks the result.
400
+ Markdown pages compiled from a project's context records by `kxm context wiki-compile` (a dry run unless you pass `--out`), with state, decisions, contradictions and history linked to their evidence. `kxm context wiki-lint` checks the result. This repository's authored notes live at [`.kxm/knowledge/`](../.kxm/knowledge/index.md) and are not that compiled view.
401
401
 
402
402
  ## Configuration and routing
403
403
 
@@ -230,6 +230,8 @@ kxm context wiki-lint demo
230
230
 
231
231
  Pages land under `.kxm/knowledge/wiki/`: one page per section (`architecture`, `decisions`, `incidents`, `patterns`), a temporal-state page that keeps superseded values visible, a contradictions page, a superseded-history page and `index.md`. Every claim links its record id and source. Compilation never resolves a contradiction. Output is deterministic except the timestamp in `index.md`, so diffs stay small. `wiki-lint` checks for broken references, orphan pages, stale state links and hidden contradictions, and exits 1 on an error.
232
232
 
233
+ This repository also keeps an authored project wiki at [`.kxm/knowledge/`](../../.kxm/knowledge/index.md). Those pages are maintainer notes with file-path sources. `kxm context wiki-compile` does not read them. With `--out`, the compiled view is still written under `.kxm/knowledge/wiki/`.
234
+
233
235
  ## Author Git memory
234
236
 
235
237
  Git memory is the durable, reviewed layer: facts a person has approved, versioned with the code. Each fact is a Markdown file with `kxm.memory.v1` front matter.
@@ -1264,10 +1264,44 @@ file even when a source fails, and exits 1 if any source failed.
1264
1264
  | `models[].sources` | Which sources listed the model |
1265
1265
  | `models[].standard` | OpenRouter per-million rates: `inputPerMillion`, `outputPerMillion`, `cacheReadPerMillion`, `cacheWritePerMillion` |
1266
1266
  | `models[].discount` | Nous per-million rates, same fields |
1267
+ | `models[].missingUpstream` | `true` when the weekly refresh kept a row that OpenRouter no longer lists because a route or roster still names it |
1267
1268
 
1268
1269
  The inventory is discovery data, not admission. Admitting a route is a
1269
1270
  separate, reviewed change to `.kxm/routes.yaml`.
1270
1271
 
1272
+ ### Weekly OpenRouter refresh
1273
+
1274
+ `.github/workflows/refresh-models.yml` runs every Monday at 13:00 UTC and on
1275
+ `workflow_dispatch`. It fetches `https://openrouter.ai/api/v1/models` and
1276
+ updates the OpenRouter `standard` rates in this inventory, plus the matching
1277
+ single-tier rows in `.kxm/prices.yaml`. Nous `discount` rates and the `pi`,
1278
+ `grok`, and `agy` source blocks stay as the last `kxm models inventory-refresh`
1279
+ left them. Output is sorted by model id. `prices.yaml` `date` and the
1280
+ inventory `fetchedAt` (midnight UTC of that date) change only when the catalog
1281
+ content changes, so an unchanged week does not open a pull request.
1282
+
1283
+ A model named by `.kxm/routes.yaml` (`admitted` or `disabled`) or by a roster
1284
+ entry in `.kxm/roles/*.yaml` is not deleted when it leaves OpenRouter. The
1285
+ refresh keeps the previous inventory row, sets `missingUpstream: true`, and
1286
+ the workflow's `model-catalog-guard` check fails. The pull request lists that
1287
+ model, and any referenced model whose input or output rate moved by more than
1288
+ 20 percent, at the top. A multi-tier price row is left unchanged and called
1289
+ out the same way. `anthropic/fable` is matched through its aliases; when those
1290
+ OpenRouter rows disagree, the price row is left unchanged.
1291
+
1292
+ Run the same refresh from a checkout:
1293
+
1294
+ ```bash
1295
+ node --disable-warning=ExperimentalWarning --experimental-strip-types scripts/refresh-model-catalog.mjs --dry-run
1296
+ node --disable-warning=ExperimentalWarning --experimental-strip-types scripts/refresh-model-catalog.mjs
1297
+ ```
1298
+
1299
+ `--dry-run` prints the summary and writes nothing. Exit 2 means a referenced
1300
+ model would be kept after leaving the catalog. The workflow pushes a non-empty
1301
+ diff to `automation/model-refresh` and opens or updates one pull request with
1302
+ `GITHUB_TOKEN`. It then dispatches `ci.yml` on that branch, because a push
1303
+ made with `GITHUB_TOKEN` does not start CI by itself.
1304
+
1271
1305
  ## Personalization settings (`kxm.config.v1`)
1272
1306
 
1273
1307
  Personal and workflow preferences, merged in three layers: built-in defaults
@@ -1699,6 +1733,7 @@ Everything under `.kxm/` at the project root falls into one of three groups.
1699
1733
  | `roles/`, `routes.yaml`, `prices.yaml`, `modes.yaml`, `the role files` | Tracked configuration outside the bundle | You and their commands |
1700
1734
  | `config.yaml` | Tracked if the project wants shared preferences; otherwise ignore it | `kxm config set` |
1701
1735
  | `memory/`, `skills/`, `candidates/`, `goals/` | Tracked durable records | Their commands |
1736
+ | `knowledge/` | Tracked authored wiki. `knowledge/wiki/` is the compiled view from `kxm context wiki-compile --out` | Maintainers, and `kxm context wiki-compile` for `wiki/` |
1702
1737
  | `models/inventory.yaml` | Generated; track it if you want a reviewed snapshot | `kxm models inventory-refresh` |
1703
1738
  | `assets/` | Tracked intentionally; `assets/generated/` is ignored | Workflows, `kxm improve`, `kxm session start` |
1704
1739
  | `tasks/` | Ignored in the KXM repository; your choice | `kxm task` |
@@ -520,6 +520,21 @@ kxm models refresh
520
520
  kxm routing report --equivalent-list-cost
521
521
  ```
522
522
 
523
+ ## Weekly price and inventory refresh
524
+
525
+ OpenRouter list prices in `.kxm/prices.yaml` and `.kxm/models/inventory.yaml`
526
+ go stale as models are added, removed, and repriced. A Monday 13:00 UTC
527
+ workflow, `.github/workflows/refresh-models.yml`, fetches the public OpenRouter
528
+ models API and opens one pull request on `automation/model-refresh` when the
529
+ catalog changed. The pull request body lists models added, removed, and
530
+ repriced. A route or roster model that disappeared, or whose input or output
531
+ rate moved by more than 20 percent, is listed first. A disappeared referenced
532
+ model stays in the inventory with `missingUpstream: true`, and the
533
+ `model-catalog-guard` check fails until someone removes the reference or
534
+ restores the model.
535
+
536
+ Manual run, and the file fields, are in [Weekly OpenRouter refresh](config-reference.md#weekly-openrouter-refresh). `kxm models inventory-refresh` remains the local command that also queries Pi, Grok, agy, and Nous.
537
+
523
538
  ## Where the code is looser than the rules
524
539
 
525
540
  These are places where the code decides less than the [routing rules](#routing-rules) say. Each one is an open operator policy question, not a defect with an obvious fix. Until it is decided, follow the rule.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontextmind/kxm",
3
- "version": "0.7.186",
3
+ "version": "0.7.188",
4
4
  "description": "KXM local-first multi-agent orchestration and operator dashboard",
5
5
  "type": "module",
6
6
  "author": "KontextMind",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "kxm",
4
4
  "displayName": "KXM",
5
- "version": "0.7.186",
5
+ "version": "0.7.188",
6
6
  "description": "Headless multi-agent orchestration, durable workflows, and a live operator dashboard for Pi and Claude Code",
7
7
  "author": {
8
8
  "name": "KontextMind",
@@ -28793,6 +28793,32 @@ function rates(raw) {
28793
28793
  function modelId(id) {
28794
28794
  return id.trim().toLowerCase();
28795
28795
  }
28796
+ function blankModel(id, item) {
28797
+ return {
28798
+ id,
28799
+ ...typeof item?.name === "string" ? { name: item.name } : {},
28800
+ ...typeof item?.context_length === "number" ? { contextLength: item.context_length } : {},
28801
+ capabilities: { thinking: { supported: null, source: "unknown" }, speed: { fast: null, source: "unknown" } },
28802
+ sources: []
28803
+ };
28804
+ }
28805
+ function applyAggregatorItem(current, item, source, kind) {
28806
+ if (!current.sources.includes(source)) current.sources.push(source);
28807
+ const parsed = rates(item.pricing);
28808
+ if (parsed) {
28809
+ if (kind === "standard") current.standard = parsed;
28810
+ else current.discount = parsed;
28811
+ }
28812
+ if (!current.name && typeof item.name === "string") current.name = item.name;
28813
+ if (!current.contextLength && typeof item.context_length === "number") current.contextLength = item.context_length;
28814
+ const parameters = Array.isArray(item.supported_parameters) ? item.supported_parameters.filter((value) => typeof value === "string") : [];
28815
+ if (parameters.some((value) => /reasoning|thinking/i.test(value))) current.capabilities.thinking = { supported: true, source };
28816
+ if (parameters.some((value) => /speed|service.?tier|fast/i.test(value))) current.capabilities.speed = { fast: true, tiers: ["fast"], source };
28817
+ if (Array.isArray(item.thinkingLevels) && item.thinkingLevels.length > 0) {
28818
+ current.capabilities.thinking = { supported: true, levels: item.thinkingLevels, ...item.defaultThinking ? { default: item.defaultThinking } : {}, source };
28819
+ }
28820
+ if (item.fast === true) current.capabilities.speed = { fast: true, tiers: ["fast"], source };
28821
+ }
28796
28822
  async function fetchModels(url, authorization) {
28797
28823
  try {
28798
28824
  const headers = { Accept: "application/json" };
@@ -28814,20 +28840,8 @@ async function refreshModelInventory(options) {
28814
28840
  const add = (item, source, kind) => {
28815
28841
  if (!item || typeof item.id !== "string" || !item.id.trim()) return;
28816
28842
  const key = modelId(item.id);
28817
- const current = byId.get(key) ?? { id: item.id, ...typeof item.name === "string" ? { name: item.name } : {}, ...typeof item.context_length === "number" ? { contextLength: item.context_length } : {}, capabilities: { thinking: { supported: null, source: "unknown" }, speed: { fast: null, source: "unknown" } }, sources: [] };
28818
- if (!current.sources.includes(source)) current.sources.push(source);
28819
- const parsed = rates(item.pricing);
28820
- if (parsed) {
28821
- if (kind === "standard") current.standard = parsed;
28822
- else current.discount = parsed;
28823
- }
28824
- if (!current.name && typeof item.name === "string") current.name = item.name;
28825
- if (!current.contextLength && typeof item.context_length === "number") current.contextLength = item.context_length;
28826
- const parameters = Array.isArray(item.supported_parameters) ? item.supported_parameters.filter((value) => typeof value === "string") : [];
28827
- if (parameters.some((value) => /reasoning|thinking/i.test(value))) current.capabilities.thinking = { supported: true, source };
28828
- if (parameters.some((value) => /speed|service.?tier|fast/i.test(value))) current.capabilities.speed = { fast: true, tiers: ["fast"], source };
28829
- if (Array.isArray(item.thinkingLevels) && item.thinkingLevels.length > 0) current.capabilities.thinking = { supported: true, levels: item.thinkingLevels, ...item.defaultThinking ? { default: item.defaultThinking } : {}, source };
28830
- if (item.fast === true) current.capabilities.speed = { fast: true, tiers: ["fast"], source };
28843
+ const current = byId.get(key) ?? blankModel(item.id, item);
28844
+ applyAggregatorItem(current, item, source, kind);
28831
28845
  byId.set(key, current);
28832
28846
  };
28833
28847
  const native2 = async (command, args, source) => {
@@ -28860,13 +28874,14 @@ async function refreshModelInventory(options) {
28860
28874
  writeFileSync18(join28(options.outputRoot, ".kxm", "models", "inventory.yaml"), (0, import_yaml17.stringify)(inventory), "utf8");
28861
28875
  return inventory;
28862
28876
  }
28863
- var import_yaml17, OR_URL, NOUS_URL;
28877
+ var import_yaml17, OPENROUTER_MODELS_URL, OR_URL, NOUS_URL;
28864
28878
  var init_model_inventory = __esm({
28865
28879
  "plugins/kxm/src/model-inventory.ts"() {
28866
28880
  "use strict";
28867
28881
  import_yaml17 = __toESM(require_dist(), 1);
28868
28882
  init_oneshot_process();
28869
- OR_URL = "https://openrouter.ai/api/v1/models";
28883
+ OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
28884
+ OR_URL = OPENROUTER_MODELS_URL;
28870
28885
  NOUS_URL = "https://inference-api.nousresearch.com/v1/models";
28871
28886
  }
28872
28887
  });
@@ -25462,7 +25462,7 @@ function sessionTokenFixHint(policy) {
25462
25462
  }
25463
25463
 
25464
25464
  // plugins/kxm/src/mcp-server.ts
25465
- var VERSION = "0.7.186";
25465
+ var VERSION = "0.7.188";
25466
25466
  var CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
25467
25467
  var inbox = /* @__PURE__ */ new Map();
25468
25468
  var notifiedInbox = /* @__PURE__ */ new Set();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plugin",
3
- "version": "0.7.186",
3
+ "version": "0.7.188",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
@@ -11,7 +11,7 @@ import { deliverInboxNotification } from "./inbox.ts";
11
11
  import type { HubEvent, MessageRecord } from "./protocol.ts";
12
12
  import { sessionTokenFixHint } from "./session-token-hint.ts";
13
13
 
14
- const VERSION = "0.7.186";
14
+ const VERSION = "0.7.188";
15
15
  const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
16
16
  const inbox = new Map<string, MessageRecord>();
17
17
  const notifiedInbox = new Set<string>();
@@ -0,0 +1,572 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { isAlias, isMap, isSeq, parse, parseDocument, stringify, type YAMLMap } from "yaml";
4
+ import type { PriceCatalog } from "./price-calc.ts";
5
+ import { hashPriceCatalog, parsePriceCatalog } from "./prices.ts";
6
+ import {
7
+ OPENROUTER_MODELS_URL,
8
+ inventoryModelFromAggregatorItem,
9
+ type InventoryModel,
10
+ type InventoryPrice,
11
+ type ModelInventory,
12
+ } from "./model-inventory.ts";
13
+
14
+ export { OPENROUTER_MODELS_URL };
15
+
16
+ const RATE_FIELDS = ["inputPerMillion", "outputPerMillion", "cacheReadPerMillion", "cacheWritePerMillion"] as const;
17
+ type RateField = (typeof RATE_FIELDS)[number];
18
+ const LIST_CAP = 40;
19
+
20
+ export interface CatalogReference {
21
+ label: string;
22
+ ids: readonly string[];
23
+ }
24
+
25
+ export interface RateChange {
26
+ field: RateField;
27
+ before: number | null;
28
+ after: number | null;
29
+ /** Absolute relative change. Infinite when a numeric rate becomes null or leaves zero. */
30
+ ratio: number;
31
+ }
32
+
33
+ export interface FlaggedModel {
34
+ id: string;
35
+ kind: "disappeared" | "repriced";
36
+ detail: string;
37
+ references: string[];
38
+ }
39
+
40
+ export interface CatalogRefreshPlan {
41
+ inventoryText: string;
42
+ pricesText: string;
43
+ changed: boolean;
44
+ guardFailed: boolean;
45
+ summary: string;
46
+ added: string[];
47
+ removed: string[];
48
+ repriced: Array<{ id: string; changes: RateChange[] }>;
49
+ priceRepriced: Array<{ id: string; changes: RateChange[] }>;
50
+ retained: FlaggedModel[];
51
+ flaggedRepriced: FlaggedModel[];
52
+ tieredUnchanged: FlaggedModel[];
53
+ ambiguousPrices: string[];
54
+ date: string;
55
+ pricedDate: string | null;
56
+ }
57
+
58
+ interface LoosePriceRow {
59
+ id: string;
60
+ provider: string;
61
+ model: string;
62
+ aliases?: string[];
63
+ tiers: Array<Record<string, unknown>>;
64
+ }
65
+
66
+ function nearlyEqual(left: number, right: number): boolean {
67
+ const scale = Math.max(1, Math.abs(left), Math.abs(right));
68
+ return Math.abs(left - right) <= 1e-8 * scale;
69
+ }
70
+
71
+ function canonicalRate(value: number | null): number | null {
72
+ if (value === null) return null;
73
+ return Number(value.toFixed(10));
74
+ }
75
+
76
+ function ratesNearlyEqual(left: InventoryPrice | undefined, right: InventoryPrice | undefined): boolean {
77
+ return rateChanges(left, right).length === 0;
78
+ }
79
+
80
+ export function rateChanges(before: InventoryPrice | undefined, after: InventoryPrice | undefined): RateChange[] {
81
+ const changes: RateChange[] = [];
82
+ for (const field of RATE_FIELDS) {
83
+ const previous = before?.[field] ?? null;
84
+ const next = after?.[field] ?? null;
85
+ if (previous === null && next === null) continue;
86
+ if (previous !== null && next !== null && nearlyEqual(previous, next)) continue;
87
+ let ratio = Infinity;
88
+ if (previous !== null && next !== null) {
89
+ ratio = previous === 0 ? (next === 0 ? 0 : Infinity) : Math.abs(next - previous) / Math.abs(previous);
90
+ }
91
+ if (ratio === 0) continue;
92
+ changes.push({ field, before: previous, after: next, ratio });
93
+ }
94
+ return changes;
95
+ }
96
+
97
+ function isSignificant(change: RateChange): boolean {
98
+ if (change.before === null) return false;
99
+ if (!Number.isFinite(change.ratio)) {
100
+ return change.field === "inputPerMillion" || change.field === "outputPerMillion";
101
+ }
102
+ return change.ratio > 0.2;
103
+ }
104
+
105
+ function expandIds(raw: string): string[] {
106
+ const out = new Set<string>();
107
+ const addOne = (value: string): void => {
108
+ const token = value.trim().toLowerCase();
109
+ if (!token || out.has(token)) return;
110
+ out.add(token);
111
+ if (token.startsWith("openrouter/")) addOne(token.slice("openrouter/".length));
112
+ const slash = token.indexOf("/");
113
+ if (slash <= 0) return;
114
+ const vendor = token.slice(0, slash);
115
+ const rest = token.slice(slash + 1);
116
+ if (vendor === "xai") addOne(`x-ai/${rest}`);
117
+ else if (vendor === "x-ai") addOne(`xai/${rest}`);
118
+ const effort = rest.match(/^(.*)-(low|medium|high|max)$/);
119
+ if (effort?.[1]) addOne(`${vendor}/${effort[1]}`);
120
+ };
121
+ addOne(raw);
122
+ return [...out];
123
+ }
124
+
125
+ function priceRowTokens(row: LoosePriceRow): string[] {
126
+ const tokens = new Set<string>();
127
+ const add = (value: string): void => {
128
+ for (const id of expandIds(value)) tokens.add(id);
129
+ };
130
+ add(row.id);
131
+ if (row.model.includes("/")) add(row.model);
132
+ else add(`${row.provider}/${row.model}`);
133
+ // Aliases on native and OpenRouter rows point at the catalog id. A different
134
+ // plan (qwen-token-plan, zai-coding-cn) keeps its own rate unless it shares a YAML anchor.
135
+ const trackAliases = new Set(["anthropic", "openai", "google", "xai", "openrouter"]);
136
+ if (trackAliases.has(row.provider)) {
137
+ for (const alias of row.aliases ?? []) add(alias.includes("/") ? alias : `${row.provider}/${alias}`);
138
+ }
139
+ return [...tokens];
140
+ }
141
+
142
+ function readLoosePrices(text: string): { date: string; rows: LoosePriceRow[] } {
143
+ const parsed = parse(text) as { date?: unknown; models?: unknown };
144
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.models)) {
145
+ throw new Error("price catalog must contain a models array");
146
+ }
147
+ if (typeof parsed.date !== "string") throw new Error("price catalog date must be a string");
148
+ const rows: LoosePriceRow[] = parsed.models.map((item, index) => {
149
+ if (!item || typeof item !== "object") throw new Error(`price catalog model at index ${index} must be an object`);
150
+ const row = item as Record<string, unknown>;
151
+ if (typeof row.id !== "string" || typeof row.provider !== "string" || typeof row.model !== "string") {
152
+ throw new Error(`price catalog model at index ${index} is missing id, provider, or model`);
153
+ }
154
+ const aliases = Array.isArray(row.aliases) ? row.aliases.filter((alias): alias is string => typeof alias === "string") : undefined;
155
+ const tiers = Array.isArray(row.tiers) ? row.tiers.filter((tier): tier is Record<string, unknown> => Boolean(tier) && typeof tier === "object") : [];
156
+ return { id: row.id, provider: row.provider, model: row.model, ...(aliases ? { aliases } : {}), tiers };
157
+ });
158
+ return { date: parsed.date, rows };
159
+ }
160
+
161
+ function tierRates(tier: Record<string, unknown> | undefined): InventoryPrice | undefined {
162
+ if (!tier) return undefined;
163
+ const num = (value: unknown): number | null => (typeof value === "number" && Number.isFinite(value) ? value : null);
164
+ const input = num(tier.inputPerMillion);
165
+ const output = num(tier.outputPerMillion);
166
+ if (input === null && output === null) return undefined;
167
+ return {
168
+ inputPerMillion: input,
169
+ outputPerMillion: output,
170
+ cacheReadPerMillion: num(tier.cacheReadPerMillion),
171
+ cacheWritePerMillion: num(tier.cacheWritePerMillion),
172
+ };
173
+ }
174
+
175
+ function formatRate(value: number | null): string {
176
+ if (value === null) return "none";
177
+ if (Number.isInteger(value)) return String(value);
178
+ return String(Number(value.toFixed(10)));
179
+ }
180
+
181
+ function formatRatio(change: RateChange): string {
182
+ if (!Number.isFinite(change.ratio) || change.before === null || change.after === null || change.before === 0) return "";
183
+ const signed = (change.after - change.before) / Math.abs(change.before);
184
+ const pct = Math.round(signed * 1000) / 10;
185
+ const text = Number.isInteger(pct) ? String(pct) : pct.toFixed(1);
186
+ return ` (${pct > 0 ? "+" : ""}${text}%)`;
187
+ }
188
+
189
+ export function formatRateChanges(changes: readonly RateChange[]): string {
190
+ return changes.map((change) => `${change.field} ${formatRate(change.before)} → ${formatRate(change.after)}${formatRatio(change)}`).join(", ");
191
+ }
192
+
193
+ function remember(map: Map<string, string[]>, token: string, label: string): void {
194
+ const labels = map.get(token) ?? [];
195
+ if (!labels.includes(label)) labels.push(label);
196
+ map.set(token, labels);
197
+ }
198
+
199
+ function labelsFor(map: Map<string, string[]>, id: string): string[] {
200
+ const labels = new Set<string>();
201
+ for (const token of expandIds(id)) {
202
+ for (const label of map.get(token) ?? []) labels.add(label);
203
+ }
204
+ return [...labels].sort((left, right) => left.localeCompare(right));
205
+ }
206
+
207
+ function indexOpenRouter(items: readonly unknown[]): Map<string, InventoryModel> {
208
+ const byId = new Map<string, InventoryModel>();
209
+ for (const item of items) {
210
+ const model = inventoryModelFromAggregatorItem(item, "openrouter", "standard");
211
+ if (!model) continue;
212
+ byId.set(model.id.toLowerCase(), model);
213
+ }
214
+ if (byId.size === 0) throw new Error("OpenRouter catalog is empty");
215
+ return byId;
216
+ }
217
+
218
+ function assignRates(target: InventoryPrice, incoming: InventoryPrice): boolean {
219
+ let changed = false;
220
+ for (const field of RATE_FIELDS) {
221
+ const before = target[field];
222
+ const after = canonicalRate(incoming[field]);
223
+ if (before === null && after === null) continue;
224
+ if (before !== null && after !== null && nearlyEqual(before, after)) continue;
225
+ target[field] = after;
226
+ changed = true;
227
+ }
228
+ return changed;
229
+ }
230
+
231
+ function desiredRates(matches: readonly InventoryModel[]): { rates?: InventoryPrice; ambiguous: boolean } {
232
+ const priced = matches.filter((model) => model.standard);
233
+ if (priced.length === 0) return { ambiguous: false };
234
+ const first = priced[0]?.standard;
235
+ if (!first) return { ambiguous: false };
236
+ const ambiguous = priced.slice(1).some((model) => !ratesNearlyEqual(first, model.standard));
237
+ return ambiguous ? { ambiguous: true } : { rates: first, ambiguous: false };
238
+ }
239
+
240
+ function writeTierRates(map: YAMLMap, previous: InventoryPrice, incoming: InventoryPrice): boolean {
241
+ let changed = false;
242
+ for (const field of RATE_FIELDS) {
243
+ const before = previous[field];
244
+ const after = canonicalRate(incoming[field]);
245
+ if (before === null && after === null) continue;
246
+ if (before !== null && after !== null && nearlyEqual(before, after)) continue;
247
+ map.set(field, after);
248
+ changed = true;
249
+ }
250
+ return changed;
251
+ }
252
+
253
+ function updatePrices(text: string, updates: Map<string, InventoryPrice>, date: string): { text: string; dateChanged: boolean } {
254
+ if (updates.size === 0) return { text, dateChanged: false };
255
+ const doc = parseDocument(text);
256
+ const models = doc.get("models", true);
257
+ if (!isSeq(models)) throw new Error("price catalog models must be a sequence");
258
+ const maps = new Map<string, YAMLMap>();
259
+ let wrote = false;
260
+ for (const row of models.items) {
261
+ if (!isMap(row)) continue;
262
+ const tiers = row.get("tiers", true);
263
+ if (!isSeq(tiers) || tiers.items.length !== 1) continue;
264
+ const tier = tiers.items[0];
265
+ if (isMap(tier) && tier.anchor) maps.set(String(tier.anchor), tier);
266
+ }
267
+ const seen = new Set<YAMLMap>();
268
+ for (const row of models.items) {
269
+ if (!isMap(row)) continue;
270
+ const id = row.get("id");
271
+ if (typeof id !== "string" || !updates.has(id)) continue;
272
+ const incoming = updates.get(id);
273
+ if (!incoming) continue;
274
+ const tiers = row.get("tiers", true);
275
+ if (!isSeq(tiers) || tiers.items.length !== 1) continue;
276
+ const tier = tiers.items[0];
277
+ const target = isMap(tier) ? tier : (isAlias(tier) ? maps.get(String(tier.source)) : undefined);
278
+ if (!target || seen.has(target)) continue;
279
+ seen.add(target);
280
+ const previous = tierRates(target.toJSON() as Record<string, unknown>);
281
+ if (!previous) continue;
282
+ if (writeTierRates(target, previous, incoming)) wrote = true;
283
+ }
284
+ if (!wrote) return { text, dateChanged: false };
285
+ doc.set("date", date);
286
+ const draft = doc.toString();
287
+ const body = parse(draft) as { sha256?: string; schema: "kxm.prices.v1"; date: string; currency?: string; models: LoosePriceRow[] };
288
+ const { sha256: _ignored, ...rest } = body;
289
+ doc.set("sha256", hashPriceCatalog(rest as unknown as Omit<PriceCatalog, "sha256">));
290
+ const next = doc.toString();
291
+ parsePriceCatalog(next);
292
+ return { text: next, dateChanged: true };
293
+ }
294
+
295
+ function listLines(title: string, ids: readonly string[]): string[] {
296
+ const lines = [`- ${title}: ${ids.length}`];
297
+ const shown = ids.slice(0, LIST_CAP);
298
+ for (const id of shown) lines.push(` - \`${id}\``);
299
+ if (ids.length > LIST_CAP) lines.push(` - … and ${ids.length - LIST_CAP} more`);
300
+ return lines;
301
+ }
302
+
303
+ export function renderCatalogSummary(plan: Omit<CatalogRefreshPlan, "summary">): string {
304
+ const lines: string[] = ["## Needs a human", ""];
305
+ if (plan.retained.length === 0 && plan.flaggedRepriced.length === 0 && plan.tieredUnchanged.length === 0) {
306
+ lines.push("No admitted route or roster model disappeared or was repriced by more than 20%.");
307
+ } else {
308
+ for (const flag of plan.retained) {
309
+ lines.push(`- **Disappeared, kept:** \`${flag.id}\` — ${flag.references.join("; ") || "referenced model"}`);
310
+ }
311
+ for (const flag of plan.flaggedRepriced) {
312
+ lines.push(`- **Repriced more than 20%:** \`${flag.id}\` ${flag.detail}${flag.references.length ? ` — ${flag.references.join("; ")}` : ""}`);
313
+ }
314
+ for (const flag of plan.tieredUnchanged) {
315
+ lines.push(`- **Tiered price left unchanged:** \`${flag.id}\` ${flag.detail}${flag.references.length ? ` — ${flag.references.join("; ")}` : ""}`);
316
+ }
317
+ }
318
+ lines.push("", "## Inventory", "");
319
+ lines.push(...listLines("Added", plan.added));
320
+ lines.push(...listLines("Removed", plan.removed));
321
+ lines.push(`- Repriced: ${plan.repriced.length}`);
322
+ for (const row of plan.repriced.slice(0, LIST_CAP)) lines.push(` - \`${row.id}\` ${formatRateChanges(row.changes)}`);
323
+ if (plan.repriced.length > LIST_CAP) lines.push(` - … and ${plan.repriced.length - LIST_CAP} more`);
324
+ lines.push("", "## Prices", "");
325
+ lines.push(`- Repriced: ${plan.priceRepriced.length}`);
326
+ for (const row of plan.priceRepriced.slice(0, LIST_CAP)) lines.push(` - \`${row.id}\` ${formatRateChanges(row.changes)}`);
327
+ if (plan.priceRepriced.length > LIST_CAP) lines.push(` - … and ${plan.priceRepriced.length - LIST_CAP} more`);
328
+ if (plan.pricedDate) lines.push(`- Date: ${plan.pricedDate}`);
329
+ if (plan.ambiguousPrices.length > 0) {
330
+ lines.push(`- Ambiguous OpenRouter matches left unchanged: ${plan.ambiguousPrices.length}`);
331
+ for (const id of plan.ambiguousPrices.slice(0, LIST_CAP)) lines.push(` - \`${id}\``);
332
+ }
333
+ lines.push("");
334
+ return lines.join("\n");
335
+ }
336
+
337
+ function sortFlags(flags: FlaggedModel[]): FlaggedModel[] {
338
+ return flags.sort((left, right) => left.id.localeCompare(right.id));
339
+ }
340
+
341
+ /**
342
+ * Applies an OpenRouter models payload to the checked-in inventory and price catalog.
343
+ * The dated price field and inventory `fetchedAt` change only when the catalog content changes.
344
+ * `fetchedAt` is midnight UTC on `date`, not a clock timestamp.
345
+ */
346
+ export function planModelCatalogRefresh(input: {
347
+ inventory: ModelInventory;
348
+ inventoryText: string;
349
+ pricesText: string;
350
+ openRouterItems: readonly unknown[];
351
+ references: readonly CatalogReference[];
352
+ date: string;
353
+ openRouterUrl?: string;
354
+ }): CatalogRefreshPlan {
355
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(input.date)) throw new Error("date must be YYYY-MM-DD");
356
+ const feed = indexOpenRouter(input.openRouterItems);
357
+ const loose = readLoosePrices(input.pricesText);
358
+ const labelMap = new Map<string, string[]>();
359
+ for (const reference of input.references) {
360
+ for (const id of reference.ids) {
361
+ for (const token of expandIds(id)) remember(labelMap, token, reference.label);
362
+ }
363
+ }
364
+ for (const row of loose.rows) {
365
+ const tokens = priceRowTokens(row);
366
+ const labels = [...new Set(tokens.flatMap((token) => labelMap.get(token) ?? []))].sort((left, right) => left.localeCompare(right));
367
+ if (labels.length === 0) continue;
368
+ for (const token of tokens) {
369
+ for (const label of labels) remember(labelMap, token, label);
370
+ }
371
+ }
372
+ const referenced = (id: string): string[] => labelsFor(labelMap, id);
373
+
374
+ const next = structuredClone(input.inventory) as ModelInventory;
375
+ const previousRates = new Map(input.inventory.models.map((model) => [model.id, model.standard ? { ...model.standard } : undefined]));
376
+ const present = new Set(feed.keys());
377
+ const added: string[] = [];
378
+ const removed: string[] = [];
379
+ const retained: FlaggedModel[] = [];
380
+
381
+ for (const [id, incoming] of feed) {
382
+ const current = next.models.find((model) => model.id.toLowerCase() === id);
383
+ if (!current) {
384
+ const created: InventoryModel = {
385
+ id: incoming.id,
386
+ ...(incoming.name ? { name: incoming.name } : {}),
387
+ ...(incoming.contextLength !== undefined ? { contextLength: incoming.contextLength } : {}),
388
+ capabilities: incoming.capabilities,
389
+ sources: ["openrouter"],
390
+ ...(incoming.standard ? { standard: incoming.standard } : {}),
391
+ };
392
+ next.models.push(created);
393
+ added.push(incoming.id);
394
+ continue;
395
+ }
396
+ if (incoming.standard) {
397
+ if (current.standard) assignRates(current.standard, incoming.standard);
398
+ else current.standard = { ...incoming.standard };
399
+ }
400
+ if (!current.sources.includes("openrouter")) current.sources.push("openrouter");
401
+ if (!current.name && incoming.name) current.name = incoming.name;
402
+ if (current.contextLength === undefined && incoming.contextLength !== undefined) current.contextLength = incoming.contextLength;
403
+ if (current.missingUpstream) delete current.missingUpstream;
404
+ }
405
+
406
+ next.models = next.models.filter((model) => {
407
+ const key = model.id.toLowerCase();
408
+ if (present.has(key) || !model.sources.includes("openrouter")) return true;
409
+ const references = referenced(model.id);
410
+ if (references.length > 0) {
411
+ if (!model.missingUpstream) {
412
+ model.missingUpstream = true;
413
+ retained.push({ id: model.id, kind: "disappeared", detail: "left the OpenRouter catalog; previous row kept", references });
414
+ }
415
+ return true;
416
+ }
417
+ model.sources = model.sources.filter((source) => source !== "openrouter");
418
+ delete model.standard;
419
+ if (model.sources.length === 0) {
420
+ removed.push(model.id);
421
+ return false;
422
+ }
423
+ return true;
424
+ });
425
+ next.models.sort((left, right) => left.id.localeCompare(right.id));
426
+ added.sort((left, right) => left.localeCompare(right));
427
+ removed.sort((left, right) => left.localeCompare(right));
428
+
429
+ const url = input.openRouterUrl ?? OPENROUTER_MODELS_URL;
430
+ const source = next.sources.openrouter ?? { url, ok: true };
431
+ if (source.url !== url || source.ok !== true || source.error) {
432
+ next.sources.openrouter = { url, ok: true };
433
+ } else if (source !== next.sources.openrouter) {
434
+ next.sources.openrouter = source;
435
+ }
436
+
437
+ const repriced: Array<{ id: string; changes: RateChange[] }> = [];
438
+ const flaggedRepriced: FlaggedModel[] = [];
439
+ for (const model of next.models) {
440
+ if (!previousRates.has(model.id)) continue;
441
+ const changes = rateChanges(previousRates.get(model.id), model.standard);
442
+ if (changes.length === 0) continue;
443
+ repriced.push({ id: model.id, changes });
444
+ if (!changes.some(isSignificant)) continue;
445
+ const references = referenced(model.id);
446
+ if (references.length === 0) continue;
447
+ flaggedRepriced.push({ id: model.id, kind: "repriced", detail: formatRateChanges(changes), references });
448
+ }
449
+ repriced.sort((left, right) => left.id.localeCompare(right.id));
450
+
451
+ const priceUpdates = new Map<string, InventoryPrice>();
452
+ const tieredUnchanged: FlaggedModel[] = [];
453
+ const ambiguousPrices: string[] = [];
454
+ const beforeTiers = new Map(loose.rows.map((row) => [row.id, tierRates(row.tiers[0])]));
455
+ for (const row of loose.rows) {
456
+ const matched = new Map<string, InventoryModel>();
457
+ for (const token of priceRowTokens(row)) {
458
+ const hit = feed.get(token);
459
+ if (hit) matched.set(hit.id.toLowerCase(), hit);
460
+ }
461
+ const desired = desiredRates([...matched.values()].sort((left, right) => left.id.localeCompare(right.id)));
462
+ const current = beforeTiers.get(row.id);
463
+ if (desired.ambiguous) {
464
+ ambiguousPrices.push(row.id);
465
+ continue;
466
+ }
467
+ if (!desired.rates || !current) continue;
468
+ const canonical: InventoryPrice = {
469
+ inputPerMillion: canonicalRate(desired.rates.inputPerMillion),
470
+ outputPerMillion: canonicalRate(desired.rates.outputPerMillion),
471
+ cacheReadPerMillion: canonicalRate(desired.rates.cacheReadPerMillion),
472
+ cacheWritePerMillion: canonicalRate(desired.rates.cacheWritePerMillion),
473
+ };
474
+ const changes = rateChanges(current, canonical);
475
+ if (changes.length === 0) continue;
476
+ const references = referenced(row.id);
477
+ if (row.tiers.length !== 1) {
478
+ if (references.length > 0 && changes.some(isSignificant)) {
479
+ tieredUnchanged.push({ id: row.id, kind: "repriced", detail: formatRateChanges(changes), references });
480
+ }
481
+ continue;
482
+ }
483
+ priceUpdates.set(row.id, canonical);
484
+ }
485
+ ambiguousPrices.sort((left, right) => left.localeCompare(right));
486
+
487
+ const priced = updatePrices(input.pricesText, priceUpdates, input.date);
488
+ const priceRepriced: Array<{ id: string; changes: RateChange[] }> = [];
489
+ for (const row of readLoosePrices(priced.text).rows) {
490
+ const changes = rateChanges(beforeTiers.get(row.id), tierRates(row.tiers[0]));
491
+ if (changes.length === 0) continue;
492
+ priceRepriced.push({ id: row.id, changes });
493
+ const references = referenced(row.id);
494
+ if (references.length > 0 && changes.some(isSignificant)) {
495
+ flaggedRepriced.push({ id: row.id, kind: "repriced", detail: formatRateChanges(changes), references });
496
+ }
497
+ }
498
+ priceRepriced.sort((left, right) => left.id.localeCompare(right.id));
499
+ const inventoryChanged = JSON.stringify({ sources: next.sources, models: next.models }) !== JSON.stringify({ sources: input.inventory.sources, models: input.inventory.models });
500
+ let inventoryText = input.inventoryText;
501
+ if (inventoryChanged) {
502
+ next.fetchedAt = `${input.date}T00:00:00.000Z`;
503
+ inventoryText = stringify(next);
504
+ }
505
+ const pricedDate = priced.dateChanged ? `${loose.date} → ${input.date}` : null;
506
+ const plan: Omit<CatalogRefreshPlan, "summary"> = {
507
+ inventoryText,
508
+ pricesText: priced.text,
509
+ changed: inventoryText !== input.inventoryText || priced.text !== input.pricesText,
510
+ guardFailed: retained.length > 0,
511
+ added,
512
+ removed,
513
+ repriced,
514
+ priceRepriced,
515
+ retained: sortFlags(retained),
516
+ flaggedRepriced: sortFlags(flaggedRepriced),
517
+ tieredUnchanged: sortFlags(tieredUnchanged),
518
+ ambiguousPrices,
519
+ date: input.date,
520
+ pricedDate,
521
+ };
522
+ return { ...plan, summary: renderCatalogSummary(plan) };
523
+ }
524
+
525
+ export function loadCatalogReferences(root: string): CatalogReference[] {
526
+ const references: CatalogReference[] = [];
527
+ const routesPath = join(root, ".kxm", "routes.yaml");
528
+ if (!existsSync(routesPath)) throw new Error(`missing ${routesPath}`);
529
+ const routes = parse(readFileSync(routesPath, "utf8")) as { admitted?: unknown; disabled?: unknown };
530
+ const collect = (ids: unknown, kind: "admitted" | "disabled"): void => {
531
+ if (!Array.isArray(ids)) return;
532
+ for (const id of ids) {
533
+ if (typeof id !== "string" || !id) continue;
534
+ references.push({ label: `routes.yaml ${kind} \`${id}\``, ids: [id] });
535
+ }
536
+ };
537
+ collect(routes.admitted, "admitted");
538
+ collect(routes.disabled, "disabled");
539
+ const rolesDir = join(root, ".kxm", "roles");
540
+ if (!existsSync(rolesDir)) return references;
541
+ for (const file of readdirSync(rolesDir).filter((name) => name.endsWith(".yaml")).sort()) {
542
+ const role = parse(readFileSync(join(rolesDir, file), "utf8")) as { id?: unknown; roster?: unknown };
543
+ const roleId = typeof role.id === "string" ? role.id : file.replace(/\.yaml$/, "");
544
+ if (!Array.isArray(role.roster)) continue;
545
+ for (const entry of role.roster) {
546
+ if (!entry || typeof entry !== "object") continue;
547
+ const route = (entry as { route?: unknown }).route;
548
+ if (typeof route !== "string" || !route) continue;
549
+ const ids = [route];
550
+ const modelPath = join(root, ".kxm", "models", `${route}.yaml`);
551
+ if (existsSync(modelPath)) {
552
+ const model = parse(readFileSync(modelPath, "utf8")) as { model?: unknown; vendor?: unknown };
553
+ if (typeof model.model === "string" && model.model) {
554
+ ids.push(model.model);
555
+ if (typeof model.vendor === "string" && model.vendor && !model.model.includes("/")) ids.push(`${model.vendor}/${model.model}`);
556
+ }
557
+ }
558
+ references.push({ label: `roster ${roleId} \`${route}\``, ids });
559
+ }
560
+ }
561
+ return references;
562
+ }
563
+
564
+ export function readCatalogFiles(root: string): { inventory: ModelInventory; inventoryText: string; pricesText: string } {
565
+ const inventoryText = readFileSync(join(root, ".kxm", "models", "inventory.yaml"), "utf8");
566
+ const pricesText = readFileSync(join(root, ".kxm", "prices.yaml"), "utf8");
567
+ const inventory = parse(inventoryText) as ModelInventory;
568
+ if (inventory.schema !== "kxm.model-inventory.v1" || !Array.isArray(inventory.models)) {
569
+ throw new Error("inventory.yaml is not a kxm.model-inventory.v1 catalog");
570
+ }
571
+ return { inventory, inventoryText, pricesText };
572
+ }
@@ -23,6 +23,8 @@ export interface InventoryModel {
23
23
  standard?: InventoryPrice;
24
24
  discount?: InventoryPrice;
25
25
  sources: string[];
26
+ /** Set when a referenced model left the OpenRouter catalog and the refresh kept the previous row. */
27
+ missingUpstream?: true;
26
28
  }
27
29
 
28
30
  export interface ModelInventory {
@@ -33,7 +35,8 @@ export interface ModelInventory {
33
35
  models: InventoryModel[];
34
36
  }
35
37
 
36
- const OR_URL = "https://openrouter.ai/api/v1/models";
38
+ export const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
39
+ const OR_URL = OPENROUTER_MODELS_URL;
37
40
  const NOUS_URL = "https://inference-api.nousresearch.com/v1/models";
38
41
 
39
42
  function price(value: unknown): number | null {
@@ -56,6 +59,42 @@ function rates(raw: any): InventoryPrice | undefined {
56
59
  function modelId(id: string): string {
57
60
  return id.trim().toLowerCase();
58
61
  }
62
+
63
+ function blankModel(id: string, item?: { name?: unknown; context_length?: unknown }): InventoryModel {
64
+ return {
65
+ id,
66
+ ...(typeof item?.name === "string" ? { name: item.name } : {}),
67
+ ...(typeof item?.context_length === "number" ? { contextLength: item.context_length } : {}),
68
+ capabilities: { thinking: { supported: null, source: "unknown" }, speed: { fast: null, source: "unknown" } },
69
+ sources: [],
70
+ };
71
+ }
72
+
73
+ function applyAggregatorItem(current: InventoryModel, item: any, source: string, kind: "standard" | "discount"): void {
74
+ if (!current.sources.includes(source)) current.sources.push(source);
75
+ const parsed = rates(item.pricing);
76
+ if (parsed) {
77
+ if (kind === "standard") current.standard = parsed;
78
+ else current.discount = parsed;
79
+ }
80
+ if (!current.name && typeof item.name === "string") current.name = item.name;
81
+ if (!current.contextLength && typeof item.context_length === "number") current.contextLength = item.context_length;
82
+ const parameters = Array.isArray(item.supported_parameters) ? item.supported_parameters.filter((value: unknown): value is string => typeof value === "string") : [];
83
+ if (parameters.some((value: string) => /reasoning|thinking/i.test(value))) current.capabilities.thinking = { supported: true, source };
84
+ if (parameters.some((value: string) => /speed|service.?tier|fast/i.test(value))) current.capabilities.speed = { fast: true, tiers: ["fast"], source };
85
+ if (Array.isArray(item.thinkingLevels) && item.thinkingLevels.length > 0) {
86
+ current.capabilities.thinking = { supported: true, levels: item.thinkingLevels, ...(item.defaultThinking ? { default: item.defaultThinking } : {}), source };
87
+ }
88
+ if (item.fast === true) current.capabilities.speed = { fast: true, tiers: ["fast"], source };
89
+ }
90
+
91
+ /** One OpenRouter or Nous payload row, using the same price and capability rules as inventory refresh. */
92
+ export function inventoryModelFromAggregatorItem(item: any, source: string, kind: "standard" | "discount"): InventoryModel | undefined {
93
+ if (!item || typeof item.id !== "string" || !item.id.trim()) return undefined;
94
+ const current = blankModel(item.id, item);
95
+ applyAggregatorItem(current, item, source, kind);
96
+ return current;
97
+ }
59
98
  async function fetchModels(url: string, authorization?: string): Promise<{ data: any[]; error?: string }> {
60
99
  try {
61
100
  const headers: Record<string, string> = { Accept: "application/json" };
@@ -79,20 +118,8 @@ export async function refreshModelInventory(options: { outputRoot: string; env?:
79
118
  const add = (item: any, source: string, kind: "standard" | "discount") => {
80
119
  if (!item || typeof item.id !== "string" || !item.id.trim()) return;
81
120
  const key = modelId(item.id);
82
- const current: InventoryModel = byId.get(key) ?? { id: item.id, ...(typeof item.name === "string" ? { name: item.name } : {}), ...(typeof item.context_length === "number" ? { contextLength: item.context_length } : {}), capabilities: { thinking: { supported: null, source: "unknown" }, speed: { fast: null, source: "unknown" } }, sources: [] };
83
- if (!current.sources.includes(source)) current.sources.push(source);
84
- const parsed = rates(item.pricing);
85
- if (parsed) {
86
- if (kind === "standard") current.standard = parsed;
87
- else current.discount = parsed;
88
- }
89
- if (!current.name && typeof item.name === "string") current.name = item.name;
90
- if (!current.contextLength && typeof item.context_length === "number") current.contextLength = item.context_length;
91
- const parameters = Array.isArray(item.supported_parameters) ? item.supported_parameters.filter((value: unknown): value is string => typeof value === "string") : [];
92
- if (parameters.some((value: string) => /reasoning|thinking/i.test(value))) current.capabilities.thinking = { supported: true, source };
93
- if (parameters.some((value: string) => /speed|service.?tier|fast/i.test(value))) current.capabilities.speed = { fast: true, tiers: ["fast"], source };
94
- if (Array.isArray(item.thinkingLevels) && item.thinkingLevels.length > 0) current.capabilities.thinking = { supported: true, levels: item.thinkingLevels, ...(item.defaultThinking ? { default: item.defaultThinking } : {}), source };
95
- if (item.fast === true) current.capabilities.speed = { fast: true, tiers: ["fast"], source };
121
+ const current = byId.get(key) ?? blankModel(item.id, item);
122
+ applyAggregatorItem(current, item, source, kind);
96
123
  byId.set(key, current);
97
124
  };
98
125
  const native = async (command: string, args: string[], source: string) => {
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ // Refresh .kxm/prices.yaml and the OpenRouter slice of .kxm/models/inventory.yaml.
3
+ // Usage:
4
+ // node --disable-warning=ExperimentalWarning --experimental-strip-types scripts/refresh-model-catalog.mjs [--dry-run] [--json] [--date YYYY-MM-DD] [--root dir] [--openrouter-file path]
5
+ import { readFileSync, writeFileSync } from "node:fs";
6
+ import { resolve } from "node:path";
7
+ import { OPENROUTER_MODELS_URL, loadCatalogReferences, planModelCatalogRefresh, readCatalogFiles } from "../plugins/kxm/src/model-catalog-refresh.ts";
8
+
9
+ const args = process.argv.slice(2);
10
+ const option = (name) => {
11
+ const index = args.indexOf(name);
12
+ return index >= 0 ? args[index + 1] : undefined;
13
+ };
14
+ const dryRun = args.includes("--dry-run");
15
+ const json = args.includes("--json");
16
+ const root = resolve(option("--root") ?? process.cwd());
17
+ const date = option("--date") ?? new Date().toISOString().slice(0, 10);
18
+ const fixture = option("--openrouter-file");
19
+
20
+ async function loadOpenRouter() {
21
+ if (fixture) return JSON.parse(readFileSync(resolve(fixture), "utf8"));
22
+ const url = process.env.KXM_OPENROUTER_MODELS_URL?.trim() || OPENROUTER_MODELS_URL;
23
+ const headers = { Accept: "application/json" };
24
+ if (process.env.OPENROUTER_API_KEY?.trim()) headers.Authorization = `Bearer ${process.env.OPENROUTER_API_KEY.trim()}`;
25
+ const response = await fetch(url, { headers, signal: AbortSignal.timeout(60_000) });
26
+ if (!response.ok) throw new Error(`OpenRouter models request failed: HTTP ${response.status}`);
27
+ const payload = await response.json();
28
+ if (!payload || typeof payload !== "object" || !Array.isArray(payload.data)) {
29
+ throw new Error("OpenRouter models response has no data array");
30
+ }
31
+ return payload.data;
32
+ }
33
+
34
+ const items = await loadOpenRouter();
35
+ const catalog = readCatalogFiles(root);
36
+ const plan = planModelCatalogRefresh({
37
+ ...catalog,
38
+ openRouterItems: items,
39
+ references: loadCatalogReferences(root),
40
+ date,
41
+ openRouterUrl: process.env.KXM_OPENROUTER_MODELS_URL?.trim() || OPENROUTER_MODELS_URL,
42
+ });
43
+
44
+ if (!dryRun && plan.changed) {
45
+ writeFileSync(resolve(root, ".kxm", "models", "inventory.yaml"), plan.inventoryText);
46
+ writeFileSync(resolve(root, ".kxm", "prices.yaml"), plan.pricesText);
47
+ }
48
+
49
+ const report = {
50
+ ok: !plan.guardFailed,
51
+ changed: plan.changed,
52
+ guardFailed: plan.guardFailed,
53
+ dryRun,
54
+ date,
55
+ added: plan.added,
56
+ removed: plan.removed,
57
+ repriced: plan.repriced.map((row) => row.id),
58
+ priceRepriced: plan.priceRepriced.map((row) => row.id),
59
+ retained: plan.retained.map((row) => row.id),
60
+ flaggedRepriced: plan.flaggedRepriced.map((row) => row.id),
61
+ summary: plan.summary,
62
+ };
63
+
64
+ if (json) console.log(JSON.stringify(report, null, 2));
65
+ else console.log(plan.summary);
66
+
67
+ process.exitCode = plan.guardFailed ? 2 : 0;