@kontextmind/kxm 0.7.185 → 0.7.187
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 -1
- package/.kxm/roles/writer.yaml +1 -1
- package/CHANGELOG.md +18 -0
- package/docs/contributing/assignment-runner.md +2 -2
- package/docs/contributing/ci-and-release.md +2 -1
- package/docs/contributing/development.md +22 -0
- package/docs/contributing/harness-routing-internals.md +12 -12
- package/docs/glossary.md +1 -1
- package/docs/guides/governed-skills.md +1 -1
- package/docs/guides/pi-workers.md +2 -2
- package/docs/guides/provenance-gates.md +1 -1
- package/docs/operations/troubleshooting.md +1 -1
- package/docs/reference/cli-reference.md +11 -11
- package/docs/reference/config-reference.md +44 -10
- package/docs/reference/configuration.md +2 -2
- package/docs/reference/harness-routing.md +27 -12
- package/package.json +1 -1
- package/packages/core/tui/dist/index.js +1 -1
- package/packages/core/tui/src/tui/optimizer.ts +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +47 -28
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/runtime-supervisor.js +6 -2
- package/plugins/kxm/dist/runtime.js +7 -3
- package/plugins/kxm/dist/server.js +0 -1
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/cli/system.ts +1 -1
- package/plugins/kxm/src/init-guide-setup.ts +9 -9
- 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/plugins/kxm/src/oneshot-producer.ts +2 -2
- package/plugins/kxm/src/pi-producer.ts +1 -1
- package/plugins/kxm/src/subagent-control.ts +1 -1
- package/plugins/kxm/src/workforce-names.mjs +12 -4
- package/scripts/harness-run.d.mts +1 -1
- package/scripts/harness-run.mjs +2 -2
- package/scripts/refresh-model-catalog.mjs +67 -0
|
@@ -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) => {
|
|
@@ -270,7 +270,7 @@ export function createKxmOneShotProducer(options: KxmOneShotProducerOptions = {}
|
|
|
270
270
|
): { provider: string; model: string; thinking?: string | undefined } {
|
|
271
271
|
if (request.model) {
|
|
272
272
|
// A separate provider field means the model id is bare and may itself be
|
|
273
|
-
// namespaced (openrouter + qwen/qwen3-
|
|
273
|
+
// namespaced (openrouter + qwen/qwen3.7-plus); reparsing it as a
|
|
274
274
|
// provider/model selector strips the namespace — the same defect #262 fixed
|
|
275
275
|
// in the pi producer. Only parse when the provider must come from the string.
|
|
276
276
|
if (request.provider) {
|
|
@@ -401,7 +401,7 @@ export function createKxmOneShotProducer(options: KxmOneShotProducerOptions = {}
|
|
|
401
401
|
// pi's model flag takes the provider-qualified id — exactly the auth probe's
|
|
402
402
|
// rule (#265): prefix with the provider unless the model already starts with it.
|
|
403
403
|
// A bare "contains a slash" test is wrong for provider-local namespaces
|
|
404
|
-
// (openrouter + qwen/qwen3-
|
|
404
|
+
// (openrouter + qwen/qwen3.7-plus must spawn as the full three-part id).
|
|
405
405
|
const qualified = resolved.provider && resolved.model.startsWith(`${resolved.provider}/`)
|
|
406
406
|
? resolved.model
|
|
407
407
|
: resolved.provider ? `${resolved.provider}/${resolved.model}` : resolved.model;
|
|
@@ -463,7 +463,7 @@ export function createKxmPiProducer(options: KxmPiProducerOptions = {}): KxmPiPr
|
|
|
463
463
|
function resolveModelForRequest(request: KxmProducerRequest): { provider: string; model: string; thinking?: string | undefined } {
|
|
464
464
|
if (request.model) {
|
|
465
465
|
// A separate provider field means the model id is bare and may itself be
|
|
466
|
-
// namespaced (`qwen/qwen3-
|
|
466
|
+
// namespaced (`qwen/qwen3.7-plus` under `openrouter`); reparsing it as a
|
|
467
467
|
// provider/model selector strips the namespace and sends the wrong model. Only
|
|
468
468
|
// parse when the provider must come from the string itself.
|
|
469
469
|
if (request.provider) {
|