@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.kxm/README.md +1 -0
- package/CHANGELOG.md +9 -0
- package/docs/README.md +1 -0
- package/docs/architecture/index.md +1 -0
- package/docs/contributing/ci-and-release.md +2 -0
- package/docs/contributing/development.md +22 -0
- package/docs/glossary.md +1 -1
- package/docs/guides/context-and-memory.md +2 -0
- package/docs/reference/config-reference.md +35 -0
- package/docs/reference/harness-routing.md +15 -0
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +31 -16
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/model-catalog-refresh.ts +572 -0
- package/plugins/kxm/src/model-inventory.ts +42 -15
- package/scripts/refresh-model-catalog.mjs +67 -0
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 |
|
|
@@ -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
|
@@ -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.
|
|
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",
|
package/plugins/kxm/dist/cli.js
CHANGED
|
@@ -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) ??
|
|
28818
|
-
|
|
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
|
-
|
|
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.
|
|
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();
|
package/plugins/kxm/package.json
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
83
|
-
|
|
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;
|