llm-relay 0.71.1 → 0.72.0

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/dist/cli.d.ts CHANGED
@@ -3,6 +3,7 @@ import { splitSpec, type Config } from "./config.js";
3
3
  import { type Candidate, type CandidateAvailability } from "./candidates.js";
4
4
  import { type DispatchView } from "./dispatch.js";
5
5
  import type { DispatchedQuotaReport } from "./mcp/lane-runner.js";
6
+ import type { DispatchedTelemetryReport } from "./dispatch-lane-stats.js";
6
7
  import { ModelCatalog } from "./catalog.js";
7
8
  import { type CommandEffect } from "./self-update.js";
8
9
  import { type KeysCliDependencies } from "./keys-cli.js";
@@ -154,6 +155,14 @@ export declare function runTelemetry(deps?: TelemetryDeps): Promise<void>;
154
155
  export type DispatchReportRequest = (cfg: Config, path: string, init: RequestInit) => Promise<unknown | null>;
155
156
  /** Forward positive MCP lane quota evidence to the live relay's authenticated control route. */
156
157
  export declare function reportMcpExhaustion(cfg: Config, report: DispatchedQuotaReport, request?: DispatchReportRequest): Promise<void>;
158
+ /**
159
+ * Forward one MCP lane-execution report to the live relay's `POST /dispatch/telemetry`.
160
+ *
161
+ * ⚠ Asymmetry with `reportMcpExhaustion` is deliberate: it throws because a lost quota
162
+ * report changes routing; a lost telemetry row changes only a ledger, so this is
163
+ * best-effort — false when no proxy answered, and it NEVER throws.
164
+ */
165
+ export declare function reportMcpTelemetry(cfg: Config, report: DispatchedTelemetryReport, request?: DispatchReportRequest): Promise<boolean>;
157
166
  /** Render a normalized listener address as an HTTP URL, including required IPv6 brackets. */
158
167
  export declare function proxyUrl(cfg: Pick<Config, "host" | "port">, path: string): string;
159
168
  export declare const CLI_COMMAND_NAMES: ReadonlySet<string>;
@@ -227,6 +236,12 @@ export interface CostCommandDependencies {
227
236
  readonly exit?: (code: number) => never;
228
237
  /** Overrides the store directory; defaults to the production `~/.llm-relay/usage/`. */
229
238
  readonly usageDir?: string;
239
+ /**
240
+ * Loads the relay config for the `--by model` lane-id footnote. Defaults to the live
241
+ * `loadOrExit`; tests inject a config holding the ladder under test. Read ONLY when
242
+ * `--by model` is asked — every other dimension must keep working config-less.
243
+ */
244
+ readonly loadConfig?: () => Config;
230
245
  }
231
246
  /**
232
247
  * `llm-relay cost` — the C1 roll-up (open-decisions-2026-08-16.md C1): what did this
@@ -287,6 +302,13 @@ export type WireDispatchView = Omit<DispatchView, "host"> & {
287
302
  };
288
303
  /** Normalize structured output from an older live proxy before exposing it to this host. */
289
304
  export declare function normalizeDispatchCommands(view: WireDispatchView, platform?: NodeJS.Platform): DispatchView;
305
+ /**
306
+ * Substitute `{task}` placeholder into lane arguments and set `view.task`.
307
+ * Applied locally so dispatch view queries never need to send arbitrary task text in HTTP query strings.
308
+ */
309
+ export declare function substituteTaskInView(view: DispatchView, task: string | undefined): DispatchView;
310
+ /** Safely reload configuration from disk without exiting if unreadable. */
311
+ export declare function loadConfigSafely(): Config | null;
290
312
  /** Which shell's literal-quoting rules a rendered command line is written for. */
291
313
  export type RenderShell = "sh" | "pwsh";
292
314
  /** Human name for the shell a line was quoted for. Precise on purpose — see `quoteArg`. */
package/dist/cli.js CHANGED
@@ -14,7 +14,7 @@ import { CREDENTIAL_LABEL_PATTERN, makeCredentialId } from "./credential-id.js";
14
14
  import { providerCredentialSlots, slotAllowsModel } from "./credential-fleet.js";
15
15
  import { loadLaneManifest, rosterIsStale, verifyModel } from "./lane-manifest.js";
16
16
  import { probeLanes } from "./lane-probe.js";
17
- import { buildDispatch, normalizeCliCommand, restoreExhaustedRows, specContextWindow, CONTEXT_TOKEN } from "./dispatch.js";
17
+ import { buildDispatch, allLadderRungs, normalizeCliCommand, restoreExhaustedRows, specContextWindow, CONTEXT_TOKEN, TASK_TOKEN, formatLaneStats } from "./dispatch.js";
18
18
  import { McpDispatchServer } from "./mcp/server.js";
19
19
  import { loadExhaustedRows } from "./dispatch-exhaustion-persistence.js";
20
20
  import { detectHostRouting, parseHostRoutingState } from "./host-routing.js";
@@ -851,6 +851,19 @@ export async function reportMcpExhaustion(cfg, report, request = tryServer) {
851
851
  if (live === null)
852
852
  throw new Error("no proxy running — quota report not recorded");
853
853
  }
854
+ export async function reportMcpTelemetry(cfg, report, request = tryServer) {
855
+ try {
856
+ const live = await request(cfg, "/dispatch/telemetry", {
857
+ method: "POST",
858
+ headers: { "content-type": "application/json" },
859
+ body: JSON.stringify(report),
860
+ });
861
+ return live !== null;
862
+ }
863
+ catch {
864
+ return false;
865
+ }
866
+ }
854
867
  export function proxyUrl(cfg, path) {
855
868
  const host = cfg.host.includes(":") ? `[${cfg.host}]` : cfg.host;
856
869
  return `http://${host}:${cfg.port}${path}`;
@@ -1318,7 +1331,17 @@ export async function runCostCommand(dependencies = {}) {
1318
1331
  write(JSON.stringify(report, null, 2) + "\n");
1319
1332
  return;
1320
1333
  }
1321
- renderCostReport(report, write);
1334
+ let cliLaneIds;
1335
+ if (by === "model") {
1336
+ try {
1337
+ const cfg = (dependencies.loadConfig ?? loadOrExit)();
1338
+ cliLaneIds = new Set(allLadderRungs(cfg).filter((rung) => rung.kind === "cli").map((rung) => rung.id));
1339
+ }
1340
+ catch {
1341
+ cliLaneIds = undefined;
1342
+ }
1343
+ }
1344
+ renderCostReport(report, write, cliLaneIds === undefined ? {} : { cliLaneIds });
1322
1345
  }
1323
1346
  finally {
1324
1347
  store.close();
@@ -1399,7 +1422,7 @@ function writeCoveredPeriod(from, to, generatedAt, write) {
1399
1422
  write(`The window ends on a bucket boundary, so the most recent ${minutes} ` +
1400
1423
  `${minutes === 1 ? "minute is" : "minutes are"} not counted.\n`);
1401
1424
  }
1402
- function renderCostReport(report, write) {
1425
+ function renderCostReport(report, write, opts = {}) {
1403
1426
  if (report.coverage === "empty") {
1404
1427
  write("No accounting data yet.\n\nThe relay records per-request usage under ~/.llm-relay/usage/ once it serves\ntraffic through configured providers. Run the proxy, send a request, then retry.\n");
1405
1428
  return;
@@ -1416,6 +1439,17 @@ function renderCostReport(report, write) {
1416
1439
  write("The lifetime window cannot prove the serve/repair split; use a day-bounded window (--window 1h|24h|7d|30d) for the repair share.\n");
1417
1440
  }
1418
1441
  write(`${formatTextTable(rows)}\n`);
1442
+ if (report.by === "client" && report.rows.some((row) => row.key === "mcp-dispatch")) {
1443
+ write("mcp-dispatch rows are the estimated dispatch envelope (task text + lane output), not the lane's provider consumption, which the relay cannot see; they are unpriced.\n");
1444
+ }
1445
+ if (report.by === "model" && opts.cliLaneIds !== undefined) {
1446
+ const laneKeys = [...new Set(report.rows
1447
+ .map((row) => row.key)
1448
+ .filter((key) => opts.cliLaneIds.has(key) || opts.cliLaneIds.has(key.slice(key.indexOf("/") + 1))))].sort();
1449
+ if (laneKeys.length > 0) {
1450
+ write(`Rows keyed by cli lane id (${laneKeys.join(", ")}) are the estimated dispatch envelope (task text + lane output), not the lane's provider consumption, which the relay cannot see; they are unpriced.\n`);
1451
+ }
1452
+ }
1419
1453
  writeRepairShare(report, write);
1420
1454
  writeAbandonedShare(report, write);
1421
1455
  if (report.coverage === "partial") {
@@ -1457,6 +1491,42 @@ export function normalizeDispatchCommands(view, platform = process.platform) {
1457
1491
  next: view.next ? normalizeLane(view.next) : null,
1458
1492
  };
1459
1493
  }
1494
+ export function substituteTaskInView(view, task) {
1495
+ if (!task)
1496
+ return view;
1497
+ const substituteArgs = (args) => args.map((a) => a.split(TASK_TOKEN).join(task));
1498
+ const substituteLane = (lane) => {
1499
+ if (!lane.invoke)
1500
+ return lane;
1501
+ return {
1502
+ ...lane,
1503
+ invoke: {
1504
+ ...lane.invoke,
1505
+ args: substituteArgs(lane.invoke.args),
1506
+ },
1507
+ };
1508
+ };
1509
+ return {
1510
+ ...view,
1511
+ task,
1512
+ ladder: view.ladder.map(substituteLane),
1513
+ next: view.next ? substituteLane(view.next) : null,
1514
+ };
1515
+ }
1516
+ export function loadConfigSafely() {
1517
+ try {
1518
+ const configPath = resolveConfigPath();
1519
+ const overrides = {
1520
+ routeDefault: argValue("--default", "-d"),
1521
+ mode: argValue("--mode", "-m"),
1522
+ listen: argValue("--listen", "-l"),
1523
+ };
1524
+ return loadConfig(configPath, overrides);
1525
+ }
1526
+ catch {
1527
+ return null;
1528
+ }
1529
+ }
1460
1530
  export const SHELL_LABEL = {
1461
1531
  sh: "sh/bash",
1462
1532
  pwsh: "PowerShell 7+ (pwsh)",
@@ -1580,8 +1650,6 @@ ${lane} — ${entry.models.length} models, probed ${entry.probedAt} via \`${entr
1580
1650
  export async function resolveDispatchView(opts) {
1581
1651
  const cfg = opts.cfg ?? loadOrExit();
1582
1652
  const qs = new URLSearchParams();
1583
- if (opts.task)
1584
- qs.set("task", opts.task);
1585
1653
  if (opts.lane)
1586
1654
  qs.set("lane", opts.lane);
1587
1655
  if (opts.tier)
@@ -1599,16 +1667,20 @@ export async function resolveDispatchView(opts) {
1599
1667
  const liveRaw = (await tryServer(cfg, `/dispatch?${qs}`));
1600
1668
  const live = liveRaw !== null && liveRaw.host === "bypassed" ? liveRaw : null;
1601
1669
  restoreExhaustedRows(cfg, loadExhaustedRows());
1602
- return normalizeDispatchCommands(live ??
1603
- buildDispatch(cfg, {
1604
- ...(opts.task ? { task: opts.task } : {}),
1605
- ...(opts.lane ? { lane: opts.lane } : {}),
1606
- ...(opts.tier ? { tier: opts.tier } : {}),
1607
- ...(opts.client ? { client: opts.client } : {}),
1608
- host: "bypassed",
1609
- publishedContextWindow: cachedContextWindow,
1610
- manifest: loadLaneManifest(),
1611
- }));
1670
+ if (live !== null) {
1671
+ const view = normalizeDispatchCommands(live);
1672
+ return substituteTaskInView({ ...view, source: "daemon" }, opts.task);
1673
+ }
1674
+ const fallbackCfg = loadConfigSafely() ?? cfg;
1675
+ const fallbackView = normalizeDispatchCommands(buildDispatch(fallbackCfg, {
1676
+ ...(opts.lane ? { lane: opts.lane } : {}),
1677
+ ...(opts.tier ? { tier: opts.tier } : {}),
1678
+ ...(opts.client ? { client: opts.client } : {}),
1679
+ host: "bypassed",
1680
+ publishedContextWindow: cachedContextWindow,
1681
+ manifest: loadLaneManifest(),
1682
+ }));
1683
+ return substituteTaskInView({ ...fallbackView, source: "local-fallback" }, opts.task);
1612
1684
  }
1613
1685
  export async function runMcp() {
1614
1686
  const cfg = loadOrExit();
@@ -1619,6 +1691,7 @@ export async function runMcp() {
1619
1691
  ...(allowedRoots ? { allowedRoots } : {}),
1620
1692
  version: currentVersion(),
1621
1693
  reportExhaustion: (report) => reportMcpExhaustion(cfg, report),
1694
+ reportTelemetry: (report) => { void reportMcpTelemetry(cfg, report); },
1622
1695
  write: (chunk) => process.stdout.write(chunk),
1623
1696
  });
1624
1697
  const stop = () => {
@@ -1693,8 +1766,6 @@ export async function runDispatch(arg) {
1693
1766
  await recordSpentDispatchLane(cfg, spent, { tier, client, outcome, retryAfterMs });
1694
1767
  }
1695
1768
  const qs = new URLSearchParams();
1696
- if (task)
1697
- qs.set("task", task);
1698
1769
  if (lane)
1699
1770
  qs.set("lane", lane);
1700
1771
  if (after)
@@ -1725,9 +1796,8 @@ export async function runDispatch(arg) {
1725
1796
  const staleProxy = hostStale || windowStale;
1726
1797
  const live = staleProxy ? null : liveRaw;
1727
1798
  restoreExhaustedRows(cfg, loadExhaustedRows());
1728
- const view = normalizeDispatchCommands(live ??
1799
+ const rawView = normalizeDispatchCommands(live ??
1729
1800
  buildDispatch(cfg, {
1730
- ...(task ? { task } : {}),
1731
1801
  ...(lane ? { lane } : {}),
1732
1802
  ...(after ? { after } : {}),
1733
1803
  ...(tier ? { tier } : {}),
@@ -1737,6 +1807,7 @@ export async function runDispatch(arg) {
1737
1807
  publishedContextWindow: cachedContextWindow,
1738
1808
  manifest: loadLaneManifest(),
1739
1809
  }));
1810
+ const view = substituteTaskInView(rawView, task);
1740
1811
  if (hasFlag("--json")) {
1741
1812
  process.stdout.write(JSON.stringify(view, null, 2) + "\n");
1742
1813
  return;
@@ -1799,6 +1870,8 @@ export async function runDispatch(arg) {
1799
1870
  }
1800
1871
  if (l.note)
1801
1872
  process.stdout.write(` note: ${l.note}\n`);
1873
+ if (l.stats)
1874
+ process.stdout.write(` ${formatLaneStats(l.stats)}\n`);
1802
1875
  }
1803
1876
  if (renderedCli) {
1804
1877
  process.stdout.write(`\ncli lines are quoted for ${SHELL_LABEL[shell]} — the task is a single argument (--shell to change)\n`);