llm-relay 0.67.0 → 0.68.1

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
@@ -2,6 +2,7 @@
2
2
  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
+ import type { DispatchedQuotaReport } from "./mcp/lane-runner.js";
5
6
  import { ModelCatalog } from "./catalog.js";
6
7
  import { type CommandEffect } from "./self-update.js";
7
8
  import { type KeysCliDependencies } from "./keys-cli.js";
@@ -130,6 +131,17 @@ export declare function runKeysEnable(credential?: string | undefined, deps?: Ke
130
131
  export declare function runKeysExport(deps?: KeysCliDependencies): Promise<void>;
131
132
  export declare function runKeysImport(importPath?: string | undefined, deps?: KeysCliDependencies): Promise<void>;
132
133
  export declare function runKeysUnlock(deps?: KeysCliDependencies): Promise<void>;
134
+ export interface TelemetryDeps {
135
+ readonly loadConfig?: () => Config;
136
+ readonly request?: (cfg: Config) => Promise<unknown | null>;
137
+ readonly localReport?: (cfg: Config) => unknown;
138
+ readonly write?: (text: string) => void;
139
+ }
140
+ /** Print telemetry from the live relay when available, falling back to the local snapshot. */
141
+ export declare function runTelemetry(deps?: TelemetryDeps): Promise<void>;
142
+ export type DispatchReportRequest = (cfg: Config, path: string, init: RequestInit) => Promise<unknown | null>;
143
+ /** Forward positive MCP lane quota evidence to the live relay's authenticated control route. */
144
+ export declare function reportMcpExhaustion(cfg: Config, report: DispatchedQuotaReport, request?: DispatchReportRequest): Promise<void>;
133
145
  /** Render a normalized listener address as an HTTP URL, including required IPv6 brackets. */
134
146
  export declare function proxyUrl(cfg: Pick<Config, "host" | "port">, path: string): string;
135
147
  export declare const CLI_COMMAND_NAMES: ReadonlySet<string>;
@@ -159,6 +171,8 @@ export declare const CLI_COMMAND_NAMES: ReadonlySet<string>;
159
171
  export declare const ARITY_EXEMPT: ReadonlySet<string>;
160
172
  /** The commands this guard bounds — exported for the drift test, not for dispatch. */
161
173
  export declare const ARITY_GUARDED_COMMANDS: readonly string[];
174
+ /** Return a bounded, value-free diagnostic for an option not accepted by this command/action. */
175
+ export declare function commandOptionError(argv: readonly string[]): string | null;
162
176
  export declare function commandArityError(positionals: readonly string[]): string | null;
163
177
  /** Clear process-local routing cooldowns through the protected live control plane only. */
164
178
  export declare function runCooldowns(_action: string | undefined, _spec: string | undefined): Promise<void>;
package/dist/cli.js CHANGED
@@ -176,6 +176,7 @@ ${formatTextTable([
176
176
  ["llm-relay keys | keys check | check-keys", "Check every configured credential slot."],
177
177
  ["llm-relay keys <action> ...", "action: add|list|rotate|revoke|remove|disable|enable|export|import|unlock."],
178
178
  ["llm-relay pools [--probe]", "List members; --probe tests each deployment once."],
179
+ ["llm-relay pools list | pools <name> | pools show <name> [--json]", "List pool names or one pool's effective members."],
179
180
  ["llm-relay pools <action> <name> [<spec>...]", "action: set|add|remove|delete."],
180
181
  ["llm-relay routing <action> ...", "action: show|get|default|tier|subagent|sort|benchmark|set|unset|answered."],
181
182
  ["llm-relay route <action> ...", "Alias for routing."],
@@ -191,7 +192,7 @@ ${formatTextTable([
191
192
  ["llm-relay candidates [-p <name>]", "Compare deployment x credential-slot targets."],
192
193
  ["llm-relay cost [--window <w>] [--by <d>] [--include-repair]", "Summarise spend from the local accounting ledger."],
193
194
  ["llm-relay eligibility", "What backends said about themselves; refusals awaiting interpretation."],
194
- ["llm-relay eligibility <propose|accept> <n> [--sig <digest>] --class <kind> --scope <scope>", "Scopes: attempt | group | deployment | credential | provider | model. --sig pins the item across queue reorderings."],
195
+ ["llm-relay eligibility <propose|accept|reject> <n> [--sig <digest>] --class <kind> --scope <scope>", "Scopes: attempt | group | deployment | credential | provider | model. --sig pins the item across queue reorderings."],
195
196
  ["llm-relay eligibility ... --scope group --members <id,id,...> [--all-credentials]", "Groups default to the current credential slot; the flag explicitly widens them."],
196
197
  ["llm-relay eligibility ... --cost-class free|paid|unknown", "Cover only deployments of that cost class, resolved live from catalog prices."],
197
198
  ["eligibility scope breadth", "credential = current credential slot; provider = all credentials for that provider."],
@@ -800,6 +801,26 @@ async function tryServer(cfg, path, init) {
800
801
  return null;
801
802
  }
802
803
  }
804
+ export async function runTelemetry(deps = {}) {
805
+ const cfg = (deps.loadConfig ?? loadOrExit)();
806
+ const live = await (deps.request ?? ((config) => tryServer(config, "/telemetry")))(cfg);
807
+ const report = live ?? (deps.localReport ? deps.localReport(cfg) : getTelemetryReport(cfg, globalCircuitBreaker));
808
+ (deps.write ?? ((text) => process.stdout.write(text)))(`${JSON.stringify(report, null, 2)}\n`);
809
+ }
810
+ export async function reportMcpExhaustion(cfg, report, request = tryServer) {
811
+ const live = await request(cfg, "/dispatch", {
812
+ method: "POST",
813
+ headers: { "content-type": "application/json" },
814
+ body: JSON.stringify({
815
+ exhausted: report.laneId,
816
+ ...(report.tier ? { tier: report.tier } : {}),
817
+ outcome: report.outcome,
818
+ ...(report.retryAfterMs !== undefined ? { retryAfterMs: report.retryAfterMs } : {}),
819
+ }),
820
+ });
821
+ if (live === null)
822
+ throw new Error("no proxy running — quota report not recorded");
823
+ }
803
824
  export function proxyUrl(cfg, path) {
804
825
  const host = cfg.host.includes(":") ? `[${cfg.host}]` : cfg.host;
805
826
  return `http://${host}:${cfg.port}${path}`;
@@ -846,6 +867,69 @@ const COMMAND_ARITY = {
846
867
  };
847
868
  export const ARITY_EXEMPT = new Set(["keys", "cooldowns", "help", "version"]);
848
869
  export const ARITY_GUARDED_COMMANDS = Object.keys(COMMAND_ARITY);
870
+ const CLI_OPTIONS = {
871
+ proxy: ["--config", "--default", "--mode", "--listen", "--ping"],
872
+ onboard: ["--config", "--import", "--force"],
873
+ setup: [],
874
+ "check-keys": ["--config"],
875
+ models: ["--config", "--provider", "--refresh"],
876
+ ping: ["--config", "--provider", "--ping"],
877
+ dashboard: ["--config"],
878
+ telemetry: ["--config"],
879
+ offload: ["--config", "--scope"],
880
+ lanes: ["--config", "--probe"],
881
+ dispatch: ["--config", "--task", "--exhausted", "--outcome", "--retry-after-ms", "--after", "--lane", "--tier", "--client", "--host", "--shell", "--next-command", "--json"],
882
+ eligibility: ["--config", "--sig", "--class", "--rationale", "--scope", "--members", "--all-credentials", "--reset-field", "--reset-ms", "--cost-class"],
883
+ candidates: ["--config", "--provider"],
884
+ cost: ["--window", "--by", "--include-repair", "--json"],
885
+ pools: ["--config", "--probe", "--json"],
886
+ routing: ["--config", "--clear"],
887
+ route: ["--config", "--clear"],
888
+ config: ["--config"],
889
+ "delegate-gate": ["--repo", "--fix"],
890
+ mcp: ["--config"],
891
+ };
892
+ const ACTION_OPTIONS = {
893
+ offload: { on: ["--config", "--scope"], off: ["--config", "--scope"], enable: ["--config", "--scope"], disable: ["--config", "--scope"], status: ["--config"] },
894
+ pools: {
895
+ list: ["--config", "--json", "--probe"], show: ["--config", "--json"],
896
+ set: ["--config", "--include", "--free", "--effort"], add: ["--config", "--include", "--free", "--effort"],
897
+ remove: ["--config", "--include", "--free", "--effort"], delete: ["--config"], rm: ["--config"],
898
+ },
899
+ eligibility: {
900
+ status: ["--config"], reject: ["--config", "--sig"],
901
+ propose: ["--config", "--sig", "--class", "--rationale", "--scope", "--members", "--all-credentials", "--reset-field", "--reset-ms", "--cost-class"],
902
+ accept: ["--config", "--sig", "--class", "--scope", "--members", "--all-credentials", "--reset-field", "--reset-ms", "--cost-class"],
903
+ },
904
+ routing: {
905
+ tier: ["--config", "--clear"], subagent: ["--config", "--clear"],
906
+ show: ["--config"], get: ["--config"], default: ["--config"], sort: ["--config"],
907
+ benchmark: ["--config"], set: ["--config"], unset: ["--config"], answered: ["--config"],
908
+ },
909
+ };
910
+ export function commandOptionError(argv) {
911
+ const hasHelp = argv.slice(2).some((a) => a === "--help" || a === "-h" || a === "--version" || a === "-v");
912
+ if (hasHelp)
913
+ return null;
914
+ const positionals = getPositionalArgs([...argv]);
915
+ const command = positionals[0] ?? "proxy";
916
+ if (command === "keys" || command === "cooldowns" || command === "help" || command === "version")
917
+ return null;
918
+ const base = CLI_OPTIONS[command];
919
+ if (!base)
920
+ return null;
921
+ const action = positionals[1];
922
+ const actionOptions = ACTION_OPTIONS[command];
923
+ const allowed = actionOptions
924
+ ? actionOptions[action ?? ""] ?? (action === undefined ? base : ["--config", "--json"])
925
+ : base;
926
+ const aliases = expandFlagAliases([...allowed]);
927
+ for (const flag of parseCliArgs([...argv]).flags) {
928
+ if (!aliases.has(flag))
929
+ return `llm-relay ${command}: unsupported option`;
930
+ }
931
+ return null;
932
+ }
849
933
  export function commandArityError(positionals) {
850
934
  const name = positionals[0];
851
935
  if (name === undefined)
@@ -1273,6 +1357,18 @@ function writeAbandonedShare(report, write) {
1273
1357
  `${formatTextTable(rows)}\n` +
1274
1358
  `The totals above are what the answers you received cost. Add this table for what the requests cost.\n`);
1275
1359
  }
1360
+ function writeCoveredPeriod(from, to, generatedAt, write) {
1361
+ write(from === null ? `Covering everything up to ${to} (UTC).\n` : `Covering ${from} → ${to} (UTC).\n`);
1362
+ const end = Date.parse(to);
1363
+ const now = Date.parse(generatedAt);
1364
+ if (!Number.isFinite(end) || !Number.isFinite(now))
1365
+ return;
1366
+ const minutes = Math.floor((now - end) / 60_000);
1367
+ if (minutes < 1)
1368
+ return;
1369
+ write(`The window ends on a bucket boundary, so the most recent ${minutes} ` +
1370
+ `${minutes === 1 ? "minute is" : "minutes are"} not counted.\n`);
1371
+ }
1276
1372
  function renderCostReport(report, write) {
1277
1373
  if (report.coverage === "empty") {
1278
1374
  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");
@@ -1285,6 +1381,7 @@ function renderCostReport(report, write) {
1285
1381
  const rows = costTableRows(report);
1286
1382
  const repairDeclinedByWindow = report.includeRepair && report.repair === null && report.window === "lifetime";
1287
1383
  write(`llm-relay cost — ${report.window}${report.includeRepair && !repairDeclinedByWindow ? ", repair included" : ""}\n`);
1384
+ writeCoveredPeriod(report.from, report.to, report.generatedAt, write);
1288
1385
  if (repairDeclinedByWindow) {
1289
1386
  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");
1290
1387
  }
@@ -1491,6 +1588,7 @@ export async function runMcp() {
1491
1588
  buildView: (o) => resolveDispatchView({ ...o, cfg }),
1492
1589
  ...(allowedRoots ? { allowedRoots } : {}),
1493
1590
  version: currentVersion(),
1591
+ reportExhaustion: (report) => reportMcpExhaustion(cfg, report),
1494
1592
  write: (chunk) => process.stdout.write(chunk),
1495
1593
  });
1496
1594
  const stop = () => {
@@ -2654,6 +2752,12 @@ export function main() {
2654
2752
  process.stdout.write(`${currentVersion()}\n`);
2655
2753
  process.exit(0);
2656
2754
  }
2755
+ const optionError = commandOptionError(process.argv);
2756
+ if (optionError !== null) {
2757
+ process.stderr.write(`${optionError}\n`);
2758
+ process.exit(1);
2759
+ return;
2760
+ }
2657
2761
  const arityError = commandArityError(positionals);
2658
2762
  if (arityError !== null) {
2659
2763
  process.stderr.write(`${arityError}\n`);
@@ -2698,9 +2802,10 @@ export function main() {
2698
2802
  return;
2699
2803
  }
2700
2804
  if (arg2 === "telemetry") {
2701
- const cfg = loadOrExit();
2702
- const report = getTelemetryReport(cfg, globalCircuitBreaker);
2703
- process.stdout.write(JSON.stringify(report, null, 2) + "\n");
2805
+ void runTelemetry().catch((e) => {
2806
+ process.stderr.write(`llm-relay telemetry: ${e.message}\n`);
2807
+ process.exit(1);
2808
+ });
2704
2809
  return;
2705
2810
  }
2706
2811
  if (arg2 === "check-keys") {
@@ -2917,14 +3022,22 @@ export function classifyCommand(argv) {
2917
3022
  case "dashboard":
2918
3023
  return "read-only";
2919
3024
  case "delegate-gate":
2920
- return "read-only";
3025
+ return argv.some((a) => a === "--fix" || a === "-fix") ? "mutating" : "read-only";
2921
3026
  case "mcp":
2922
3027
  return "read-only";
3028
+ case "dispatch":
3029
+ return argv.some((a) => a === "--exhausted" || a === "-x") ? "mutating" : "read-only";
2923
3030
  default:
2924
3031
  return "read-only";
2925
3032
  }
2926
3033
  }
2927
3034
  export async function run() {
3035
+ const optionError = commandOptionError(process.argv);
3036
+ if (optionError !== null) {
3037
+ process.stderr.write(`${optionError}\n`);
3038
+ process.exit(1);
3039
+ return;
3040
+ }
2928
3041
  const command = rawCliCommand(process.argv);
2929
3042
  if (command === "cooldowns")
2930
3043
  parseCooldownClearArgs(process.argv);