llm-relay 0.43.0 → 0.45.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.
Files changed (68) hide show
  1. package/README.md +1 -0
  2. package/dist/accounting-store-schema.d.ts +2 -2
  3. package/dist/accounting.d.ts +5 -5
  4. package/dist/accounting.js +4 -4
  5. package/dist/accounting.js.map +1 -1
  6. package/dist/authEnv.d.ts +20 -12
  7. package/dist/authEnv.js +76 -36
  8. package/dist/authEnv.js.map +1 -1
  9. package/dist/candidates.d.ts +2 -0
  10. package/dist/candidates.js +2 -1
  11. package/dist/candidates.js.map +1 -1
  12. package/dist/circuit-breaker.d.ts +22 -0
  13. package/dist/circuit-breaker.js +66 -0
  14. package/dist/circuit-breaker.js.map +1 -1
  15. package/dist/cli.d.ts +18 -2
  16. package/dist/cli.js +508 -6
  17. package/dist/cli.js.map +1 -1
  18. package/dist/config.d.ts +4 -3
  19. package/dist/config.js +87 -7
  20. package/dist/config.js.map +1 -1
  21. package/dist/cooldown-clear.d.ts +32 -0
  22. package/dist/cooldown-clear.js +56 -0
  23. package/dist/cooldown-clear.js.map +1 -0
  24. package/dist/credential-fleet.d.ts +5 -4
  25. package/dist/credential-fleet.js +9 -9
  26. package/dist/credential-fleet.js.map +1 -1
  27. package/dist/dotenv.d.ts +2 -0
  28. package/dist/dotenv.js +9 -0
  29. package/dist/dotenv.js.map +1 -1
  30. package/dist/key-checker.d.ts +1 -0
  31. package/dist/key-checker.js +3 -2
  32. package/dist/key-checker.js.map +1 -1
  33. package/dist/key-import.d.ts +7 -0
  34. package/dist/key-import.js +52 -0
  35. package/dist/key-import.js.map +1 -1
  36. package/dist/keys-cli.d.ts +48 -0
  37. package/dist/keys-cli.js +693 -0
  38. package/dist/keys-cli.js.map +1 -0
  39. package/dist/keystore.d.ts +130 -0
  40. package/dist/keystore.js +1195 -0
  41. package/dist/keystore.js.map +1 -0
  42. package/dist/metadata.d.ts +2 -0
  43. package/dist/metadata.js +5 -1
  44. package/dist/metadata.js.map +1 -1
  45. package/dist/os-keyring.d.ts +88 -0
  46. package/dist/os-keyring.js +476 -0
  47. package/dist/os-keyring.js.map +1 -0
  48. package/dist/registry.d.ts +2 -1
  49. package/dist/registry.js +1 -0
  50. package/dist/registry.js.map +1 -1
  51. package/dist/resolved-attempt.d.ts +2 -1
  52. package/dist/resolved-attempt.js +3 -2
  53. package/dist/resolved-attempt.js.map +1 -1
  54. package/dist/routes/admin.js +41 -0
  55. package/dist/routes/admin.js.map +1 -1
  56. package/dist/secret-file-acl.d.ts +47 -12
  57. package/dist/secret-file-acl.js +151 -16
  58. package/dist/secret-file-acl.js.map +1 -1
  59. package/dist/server.d.ts +1 -0
  60. package/dist/server.js +28 -7
  61. package/dist/server.js.map +1 -1
  62. package/dist/target-facts.d.ts +19 -0
  63. package/dist/target-facts.js +65 -0
  64. package/dist/target-facts.js.map +1 -1
  65. package/dist/usage-observer.d.ts +5 -3
  66. package/dist/usage-observer.js +452 -25
  67. package/dist/usage-observer.js.map +1 -1
  68. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -8,7 +8,7 @@ import { loadEnvFile } from "./dotenv.js";
8
8
  import { recoverWindowsEnv } from "./winenv.js";
9
9
  import { offloadState, setOffload } from "./offload.js";
10
10
  import { buildCandidates } from "./candidates.js";
11
- import { makeCredentialId } from "./credential-id.js";
11
+ import { CREDENTIAL_LABEL_PATTERN, makeCredentialId, parseCredentialId } from "./credential-id.js";
12
12
  import { providerCredentialSlots, slotAllowsModel } from "./credential-fleet.js";
13
13
  import { loadLaneManifest, verifyModel } from "./lane-manifest.js";
14
14
  import { probeLanes } from "./lane-probe.js";
@@ -33,6 +33,7 @@ import { DASHBOARD_MEDIA_TYPE, isDashboardUtcTimestamp } from "./dashboard-contr
33
33
  import { DASHBOARD_BOOTSTRAP_SCHEMA, DASHBOARD_BOOTSTRAP_REQUEST_SCHEMA } from "./dashboard-routes.js";
34
34
  import { flushRuntimeTelemetry } from "./ping/runtime-telemetry.js";
35
35
  import { flushProbeCache } from "./ping/probe-cache.js";
36
+ import { runKeysAddLocal, runKeysDisableLocal, runKeysEnableLocal, runKeysExportLocal, runKeysImportLocal, runKeysListLocal, runKeysRemoveLocal, runKeysRevokeLocal, runKeysRotateLocal, runKeysUnlockLocal, } from "./keys-cli.js";
36
37
  const VALUE_FLAGS = new Set([
37
38
  "--config", "-config", "-c",
38
39
  "--provider", "-provider", "-p",
@@ -51,6 +52,7 @@ const VALUE_FLAGS = new Set([
51
52
  "--host", "-host",
52
53
  "--client", "-client",
53
54
  "--scope", "-scope",
55
+ "--credential", "-credential",
54
56
  "--include", "-include",
55
57
  "--window", "-window",
56
58
  "--by", "-by",
@@ -62,6 +64,9 @@ const VALUE_FLAGS = new Set([
62
64
  "--reset-field", "-reset-field",
63
65
  "--reset-ms", "-reset-ms",
64
66
  "--import", "-import",
67
+ "--label", "-label",
68
+ "--env-name", "-env-name",
69
+ "--out", "-out",
65
70
  ]);
66
71
  let cachedArgv;
67
72
  let cachedParsedArgs;
@@ -157,7 +162,8 @@ ${formatTextTable([
157
162
  ["llm-relay [options]", "Start proxy."],
158
163
  ["llm-relay onboard [--import <file>] [--force]", "Set up or import provider keys."],
159
164
  ["llm-relay setup [target]", "target: claude-cli | claude-desktop."],
160
- ["llm-relay keys | check-keys", "Check every configured credential slot."],
165
+ ["llm-relay keys | keys check | check-keys", "Check every configured credential slot."],
166
+ ["llm-relay keys <action> ...", "action: add|list|rotate|revoke|remove|disable|enable|export|import|unlock."],
161
167
  ["llm-relay pools [--probe]", "List members; --probe tests each deployment once."],
162
168
  ["llm-relay pools <action> <name> [<spec>...]", "action: set|add|remove|delete."],
163
169
  ["llm-relay routing <action> ...", "action: show|get|default|tier|subagent|sort|benchmark|set|unset."],
@@ -168,6 +174,7 @@ ${formatTextTable([
168
174
  ["llm-relay telemetry", "Print telemetry JSON."],
169
175
  ["llm-relay offload [status]", "Show current offload rules."],
170
176
  ["llm-relay offload <harness> <on|off> [--scope <scope>]", "Toggle one harness (claude | codex); scope: subagents | all."],
177
+ ["llm-relay cooldowns clear <provider>[/<model>] [--credential <label>]", "Clear live cooling state; requires the running relay."],
171
178
  ["llm-relay candidates [-p <name>]", "Compare deployment x credential-slot targets."],
172
179
  ["llm-relay cost [--window <w>] [--by <d>] [--include-repair]", "Summarise spend from the local accounting ledger."],
173
180
  ["llm-relay eligibility", "What backends said about themselves; refusals awaiting interpretation."],
@@ -262,6 +269,7 @@ ${formatTextTable([
262
269
  ["GET /candidates", "Deployment x credential policy/state/quota/breaker cells."],
263
270
  ["GET|POST /offload", "Read/set rules; accepts ?client=<name>."],
264
271
  ["GET|POST /dispatch", "Read/set next lane; POST {\"exhausted\":\"<lane>\"}."],
272
+ ["POST /cooldowns/clear", "Clear scoped live cooling state."],
265
273
  ["GET /telemetry", "Provider telemetry."],
266
274
  ["GET /ping", "Run health probe."],
267
275
  ["GET /health", "Provider health."],
@@ -666,6 +674,43 @@ export async function runCheckKeys() {
666
674
  ];
667
675
  process.stdout.write(formatTextTable(rows) + "\n");
668
676
  }
677
+ /** `llm-relay keys add` — CLI-local credential insertion; secret input is never argv. */
678
+ export async function runKeysAdd(provider = getPositionalArgs(process.argv)[2], deps = {}) {
679
+ await runKeysAddLocal(deps.config ?? loadOrExit(), provider, {
680
+ ...(argValue("--label", "-label") === undefined
681
+ ? {} : { label: argValue("--label", "-label") }),
682
+ ...(argValue("--env-name", "-env-name") === undefined
683
+ ? {} : { envName: argValue("--env-name", "-env-name") }),
684
+ check: hasFlag("--check", "-check"),
685
+ }, deps);
686
+ }
687
+ export async function runKeysList(deps = {}) {
688
+ runKeysListLocal(deps.config ?? loadOrExit(), deps);
689
+ }
690
+ export async function runKeysRotate(credential = getPositionalArgs(process.argv)[2], deps = {}) {
691
+ await runKeysRotateLocal(deps.config ?? loadOrExit(), credential, deps);
692
+ }
693
+ export async function runKeysRevoke(credential = getPositionalArgs(process.argv)[2], deps = {}) {
694
+ runKeysRevokeLocal(credential, deps);
695
+ }
696
+ export async function runKeysRemove(credential = getPositionalArgs(process.argv)[2], deps = {}) {
697
+ runKeysRemoveLocal(credential, hasFlag("--purge", "-purge"), deps);
698
+ }
699
+ export async function runKeysDisable(credential = getPositionalArgs(process.argv)[2], deps = {}) {
700
+ runKeysDisableLocal(credential, deps);
701
+ }
702
+ export async function runKeysEnable(credential = getPositionalArgs(process.argv)[2], deps = {}) {
703
+ runKeysEnableLocal(credential, deps);
704
+ }
705
+ export async function runKeysExport(deps = {}) {
706
+ await runKeysExportLocal(argValue("--out", "-out"), deps);
707
+ }
708
+ export async function runKeysImport(importPath = getPositionalArgs(process.argv)[2], deps = {}) {
709
+ await runKeysImportLocal(deps.config ?? loadOrExit(), importPath, deps);
710
+ }
711
+ export async function runKeysUnlock(deps = {}) {
712
+ await runKeysUnlockLocal(deps);
713
+ }
669
714
  /**
670
715
  * Talk to a RUNNING proxy if there is one. The offload switch has to reach the live process to
671
716
  * take effect without a restart, and `candidates` gets better data from it (warm ping history,
@@ -702,6 +747,301 @@ export function proxyUrl(cfg, path) {
702
747
  const host = cfg.host.includes(":") ? `[${cfg.host}]` : cfg.host;
703
748
  return `http://${host}:${cfg.port}${path}`;
704
749
  }
750
+ function cooldownCommandFailure(message) {
751
+ process.stderr.write(`llm-relay cooldowns: ${message}\n`);
752
+ process.exit(1);
753
+ }
754
+ function isRecord(value) {
755
+ return typeof value === "object" && value !== null && !Array.isArray(value);
756
+ }
757
+ function hasExactKeys(value, keys) {
758
+ const actual = Object.keys(value);
759
+ return actual.length === keys.length && actual.every((key) => keys.includes(key));
760
+ }
761
+ function nonEmptyString(value) {
762
+ return typeof value === "string" && value.length > 0;
763
+ }
764
+ const COOLING_FACT_KINDS = new Set([
765
+ "allowance-exhausted",
766
+ "rate-limited",
767
+ "credential-invalid",
768
+ ]);
769
+ function credentialIdBelongsTo(provider, value) {
770
+ if (typeof value !== "string")
771
+ return false;
772
+ return parseCredentialId(value)?.provider === provider;
773
+ }
774
+ function isCooldownFactScope(value) {
775
+ if (!isRecord(value))
776
+ return false;
777
+ switch (value.kind) {
778
+ case "attempt":
779
+ return hasExactKeys(value, ["kind", "provider", "credentialId", "model"]) &&
780
+ nonEmptyString(value.provider) &&
781
+ credentialIdBelongsTo(value.provider, value.credentialId) &&
782
+ nonEmptyString(value.model);
783
+ case "group": {
784
+ const keys = Object.hasOwn(value, "credentialId")
785
+ ? ["kind", "provider", "credentialId", "members"]
786
+ : ["kind", "provider", "members"];
787
+ return hasExactKeys(value, keys) &&
788
+ nonEmptyString(value.provider) &&
789
+ Array.isArray(value.members) &&
790
+ value.members.length > 0 &&
791
+ value.members.every(nonEmptyString) &&
792
+ (!Object.hasOwn(value, "credentialId") || credentialIdBelongsTo(value.provider, value.credentialId));
793
+ }
794
+ case "deployment":
795
+ return hasExactKeys(value, ["kind", "provider", "model"]) &&
796
+ nonEmptyString(value.provider) && nonEmptyString(value.model);
797
+ case "credential":
798
+ return hasExactKeys(value, ["kind", "provider", "credentialId"]) &&
799
+ nonEmptyString(value.provider) && credentialIdBelongsTo(value.provider, value.credentialId);
800
+ case "provider":
801
+ return hasExactKeys(value, ["kind", "provider"]) && nonEmptyString(value.provider);
802
+ case "model":
803
+ return hasExactKeys(value, ["kind", "model"]) && nonEmptyString(value.model);
804
+ default:
805
+ return false;
806
+ }
807
+ }
808
+ function scopeIsContainedByTarget(scope, target) {
809
+ const credentialId = target.credential === undefined
810
+ ? undefined
811
+ : makeCredentialId(target.provider, target.credential);
812
+ switch (scope.kind) {
813
+ case "attempt":
814
+ return scope.provider === target.provider &&
815
+ (target.model === undefined || scope.model === target.model) &&
816
+ (credentialId === undefined || scope.credentialId === credentialId);
817
+ case "group":
818
+ return scope.provider === target.provider &&
819
+ (target.model === undefined || scope.members.every((member) => member === target.model)) &&
820
+ (credentialId === undefined || scope.credentialId === credentialId);
821
+ case "deployment":
822
+ return scope.provider === target.provider && credentialId === undefined &&
823
+ (target.model === undefined || scope.model === target.model);
824
+ case "credential":
825
+ return scope.provider === target.provider && target.model === undefined &&
826
+ (credentialId === undefined || scope.credentialId === credentialId);
827
+ case "provider":
828
+ return scope.provider === target.provider && target.model === undefined && credentialId === undefined;
829
+ case "model":
830
+ return false;
831
+ }
832
+ }
833
+ function isCooldownCell(value, target) {
834
+ if (!isRecord(value) || !hasExactKeys(value, ["provider", "model", "credential"]))
835
+ return false;
836
+ if (!nonEmptyString(value.provider) || value.provider !== target.provider)
837
+ return false;
838
+ if (!(value.model === null || nonEmptyString(value.model)))
839
+ return false;
840
+ if (!nonEmptyString(value.credential) || !CREDENTIAL_LABEL_PATTERN.test(value.credential))
841
+ return false;
842
+ return (target.model === undefined || value.model === target.model) &&
843
+ (target.credential === undefined || value.credential === target.credential);
844
+ }
845
+ function isCooldownFact(value, target) {
846
+ return isRecord(value) &&
847
+ hasExactKeys(value, ["kind", "scope"]) &&
848
+ typeof value.kind === "string" &&
849
+ COOLING_FACT_KINDS.has(value.kind) &&
850
+ isCooldownFactScope(value.scope) &&
851
+ scopeIsContainedByTarget(value.scope, target);
852
+ }
853
+ function isClearedGroup(value, isItem) {
854
+ if (!isRecord(value) || !hasExactKeys(value, ["count", "items"]) ||
855
+ typeof value.count !== "number" || !Number.isSafeInteger(value.count) ||
856
+ value.count < 0 || !Array.isArray(value.items)) {
857
+ return false;
858
+ }
859
+ return value.count === value.items.length && value.items.every(isItem);
860
+ }
861
+ function isExactCooldownTarget(value, expected) {
862
+ if (!isRecord(value))
863
+ return false;
864
+ const keys = [
865
+ "provider",
866
+ ...(expected.model === undefined ? [] : ["model"]),
867
+ ...(expected.credential === undefined ? [] : ["credential"]),
868
+ ];
869
+ return hasExactKeys(value, keys) &&
870
+ value.provider === expected.provider &&
871
+ (expected.model === undefined || value.model === expected.model) &&
872
+ (expected.credential === undefined || value.credential === expected.credential);
873
+ }
874
+ function isCooldownClearResult(value, target) {
875
+ if (!isRecord(value) || !hasExactKeys(value, ["target", "cleared"]) ||
876
+ !isExactCooldownTarget(value.target, target) || !isRecord(value.cleared) ||
877
+ !hasExactKeys(value.cleared, ["breakerCells", "credentialFaults", "facts"])) {
878
+ return false;
879
+ }
880
+ return isClearedGroup(value.cleared.breakerCells, (item) => isCooldownCell(item, target)) &&
881
+ isClearedGroup(value.cleared.credentialFaults, (item) => isCooldownCell(item, target)) &&
882
+ isClearedGroup(value.cleared.facts, (item) => isCooldownFact(item, target));
883
+ }
884
+ const COOLDOWN_CLEAR_OPTIONS = new Map([
885
+ ["--credential", { semantic: "credential", takesValue: true }],
886
+ ["--json", { semantic: "json", takesValue: false }],
887
+ ["--config", { semantic: "config", takesValue: true }],
888
+ ["-c", { semantic: "config", takesValue: true }],
889
+ ["--provider", { semantic: "provider", takesValue: true }],
890
+ ["-p", { semantic: "provider", takesValue: true }],
891
+ ["--refresh", { semantic: "refresh", takesValue: false }],
892
+ ["-r", { semantic: "refresh", takesValue: false }],
893
+ ]);
894
+ const CLI_COMMAND_NAMES = new Set([
895
+ "onboard", "setup", "keys", "check-keys", "models", "ping", "dashboard", "telemetry",
896
+ "offload", "lanes", "dispatch", "cooldowns", "eligibility", "candidates", "cost", "pools",
897
+ "routing", "route", "config", "help", "version",
898
+ ]);
899
+ /** Find a real command token without letting a typoed option/value pair hide a later mutation. */
900
+ function rawCliCommand(argv) {
901
+ for (let index = 2; index < argv.length; index++) {
902
+ const arg = argv[index];
903
+ if (!arg.startsWith("-")) {
904
+ if (CLI_COMMAND_NAMES.has(arg))
905
+ return arg;
906
+ continue;
907
+ }
908
+ const equalsAt = arg.indexOf("=");
909
+ const flag = equalsAt === -1 ? arg : arg.slice(0, equalsAt);
910
+ if (equalsAt === -1 && VALUE_FLAGS.has(flag)) {
911
+ // A missing value before the mutation must reach the strict parser, where it exits 1.
912
+ if (argv[index + 1] === "cooldowns" && argv[index + 2] === "clear")
913
+ return "cooldowns";
914
+ index += 1;
915
+ }
916
+ }
917
+ return undefined;
918
+ }
919
+ function parseCooldownClearArgs(argv) {
920
+ const positionals = [];
921
+ const seen = new Set();
922
+ let credential;
923
+ let json = false;
924
+ for (let index = 2; index < argv.length; index++) {
925
+ const arg = argv[index];
926
+ if (!arg.startsWith("-")) {
927
+ positionals.push(arg);
928
+ continue;
929
+ }
930
+ const equalsAt = arg.indexOf("=");
931
+ const flag = equalsAt === -1 ? arg : arg.slice(0, equalsAt);
932
+ const option = COOLDOWN_CLEAR_OPTIONS.get(flag);
933
+ if (option === undefined)
934
+ cooldownCommandFailure(`unknown option "${flag}"`);
935
+ if (seen.has(option.semantic))
936
+ cooldownCommandFailure(`duplicate option "${flag}"`);
937
+ seen.add(option.semantic);
938
+ if (!option.takesValue) {
939
+ if (equalsAt !== -1)
940
+ cooldownCommandFailure(`${flag} does not take a value`);
941
+ if (option.semantic === "json")
942
+ json = true;
943
+ continue;
944
+ }
945
+ const value = equalsAt === -1 ? argv[index + 1] : arg.slice(equalsAt + 1);
946
+ if (value === undefined || value.length === 0 || (equalsAt === -1 && value.startsWith("-"))) {
947
+ cooldownCommandFailure(`${flag} requires a value`);
948
+ }
949
+ if (equalsAt === -1)
950
+ index += 1;
951
+ if (option.semantic === "credential")
952
+ credential = value;
953
+ }
954
+ if (positionals.length !== 3 || positionals[0] !== "cooldowns" || positionals[1] !== "clear") {
955
+ cooldownCommandFailure("usage: llm-relay cooldowns clear <provider>[/<model>] [--credential <label>] [--json]");
956
+ }
957
+ return {
958
+ spec: positionals[2],
959
+ ...(credential === undefined ? {} : { credential }),
960
+ json,
961
+ };
962
+ }
963
+ function controlErrorMessage(value) {
964
+ if (!isRecord(value) || !isRecord(value.error) || typeof value.error.message !== "string")
965
+ return null;
966
+ return value.error.message;
967
+ }
968
+ /** Clear process-local routing cooldowns through the protected live control plane only. */
969
+ export async function runCooldowns(_action, _spec) {
970
+ const parsed = parseCooldownClearArgs(process.argv);
971
+ const { spec, credential } = parsed;
972
+ const { provider, model } = splitSpec(spec);
973
+ if (provider.length === 0 || model === "") {
974
+ cooldownCommandFailure("target must be <provider> or <provider>/<model>");
975
+ }
976
+ if (credential !== undefined && !CREDENTIAL_LABEL_PATTERN.test(credential)) {
977
+ cooldownCommandFailure("--credential must match [A-Za-z0-9_.-]{1,32}");
978
+ }
979
+ const target = {
980
+ provider,
981
+ ...(model === undefined ? {} : { model }),
982
+ ...(credential === undefined ? {} : { credential }),
983
+ };
984
+ const cfg = loadOrExit();
985
+ if (!Object.hasOwn(cfg.providers, provider)) {
986
+ cooldownCommandFailure(`no provider "${provider}" configured`);
987
+ }
988
+ let authorization;
989
+ try {
990
+ authorization = createControlAuthorization(resolveControlAuthorizationConfigDir(cfg.sourcePath));
991
+ }
992
+ catch {
993
+ cooldownCommandFailure("control authorization is unavailable; the relay must be running with this config");
994
+ }
995
+ let response = null;
996
+ try {
997
+ response = await fetch(proxyUrl(cfg, "/cooldowns/clear"), {
998
+ method: "POST",
999
+ headers: authorization.attach({ "content-type": "application/json" }),
1000
+ body: JSON.stringify(target),
1001
+ signal: AbortSignal.timeout(5000),
1002
+ });
1003
+ }
1004
+ catch {
1005
+ // Report below with the same explicit no-file-fallback outcome for every transport failure.
1006
+ }
1007
+ if (response === null) {
1008
+ cooldownCommandFailure("the relay must be running to clear cooldowns");
1009
+ }
1010
+ let payload;
1011
+ try {
1012
+ payload = await response.json();
1013
+ }
1014
+ catch {
1015
+ cooldownCommandFailure("the running relay returned an invalid cooldown-clear response");
1016
+ }
1017
+ if (!response.ok) {
1018
+ const detail = controlErrorMessage(payload);
1019
+ cooldownCommandFailure(`the running relay rejected the clear${detail === null ? "" : `: ${detail}`}`);
1020
+ }
1021
+ if (!isCooldownClearResult(payload, target)) {
1022
+ cooldownCommandFailure("the running relay returned an invalid cooldown-clear response");
1023
+ }
1024
+ if (parsed.json) {
1025
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
1026
+ return;
1027
+ }
1028
+ const targetLabel = `${provider}${model === undefined ? "" : `/${model}`}${credential === undefined ? "" : ` [credential ${credential}]`}`;
1029
+ process.stdout.write(`Cleared cooldown state for ${targetLabel}\n`);
1030
+ const groups = [
1031
+ ["breaker cells", payload.cleared.breakerCells],
1032
+ ["credential faults", payload.cleared.credentialFaults],
1033
+ ];
1034
+ for (const [label, group] of groups) {
1035
+ process.stdout.write(` ${label}: ${group.count}\n`);
1036
+ for (const cell of group.items) {
1037
+ process.stdout.write(` ${cell.provider}/${cell.model ?? "*"} [credential ${cell.credential}]\n`);
1038
+ }
1039
+ }
1040
+ process.stdout.write(` cooling facts: ${payload.cleared.facts.count}\n`);
1041
+ for (const fact of payload.cleared.facts.items) {
1042
+ process.stdout.write(` ${fact.kind} ${describeScope(fact.scope)}\n`);
1043
+ }
1044
+ }
705
1045
  export const DASHBOARD_BROWSER_OPEN_TIMEOUT_MS = 5_000;
706
1046
  function isDashboardBootstrapWire(value, now) {
707
1047
  if (typeof value !== "object" || value === null || Array.isArray(value))
@@ -2418,11 +2758,107 @@ import { importKeysFromFile } from "./key-import.js";
2418
2758
  import { setupClaudeCli, setupClaudeDesktop } from "./setup-claude.js";
2419
2759
  import { getTelemetryReport } from "./telemetry.js";
2420
2760
  import { globalCircuitBreaker } from "./circuit-breaker.js";
2761
+ const KEYS_SUBCOMMANDS = [
2762
+ "add", "list", "rotate", "revoke", "remove", "disable", "enable", "export", "import",
2763
+ "unlock", "check",
2764
+ ];
2765
+ const KEYS_GLOBAL_FLAGS = ["--config", "-config", "-c"];
2766
+ const KEYS_FLAGS = {
2767
+ add: ["--label", "-label", "--env-name", "-env-name", "--check", "-check"],
2768
+ list: [],
2769
+ rotate: [],
2770
+ revoke: [],
2771
+ remove: ["--purge", "-purge"],
2772
+ disable: [],
2773
+ enable: [],
2774
+ export: ["--out", "-out"],
2775
+ import: [],
2776
+ unlock: [],
2777
+ check: [],
2778
+ };
2779
+ const KEYS_POSITIONAL_COUNT = {
2780
+ add: 3,
2781
+ list: 2,
2782
+ rotate: 3,
2783
+ revoke: 3,
2784
+ remove: 3,
2785
+ disable: 3,
2786
+ enable: 3,
2787
+ export: 2,
2788
+ import: 3,
2789
+ unlock: 2,
2790
+ check: 2,
2791
+ };
2792
+ /** Strict, secret-safe keys parser. Diagnostics deliberately never echo rejected argv tokens. */
2793
+ export function validateKeysCommandArgs(argv) {
2794
+ const positionals = getPositionalArgs(argv);
2795
+ const candidate = positionals[1];
2796
+ if (candidate !== undefined && !KEYS_SUBCOMMANDS.includes(candidate)) {
2797
+ return `unknown subcommand; valid subcommands: ${KEYS_SUBCOMMANDS.join(", ")}`;
2798
+ }
2799
+ const subcommand = candidate;
2800
+ const allowed = new Set(KEYS_GLOBAL_FLAGS);
2801
+ if (subcommand !== undefined) {
2802
+ for (const flag of KEYS_FLAGS[subcommand])
2803
+ allowed.add(flag);
2804
+ }
2805
+ for (let index = 2; index < argv.length; index += 1) {
2806
+ const arg = argv[index];
2807
+ if (arg === undefined || !arg.startsWith("-"))
2808
+ continue;
2809
+ const equalsAt = arg.indexOf("=");
2810
+ const flag = equalsAt === -1 ? arg : arg.slice(0, equalsAt);
2811
+ if (!allowed.has(flag))
2812
+ return "unsupported option";
2813
+ if (VALUE_FLAGS.has(flag)) {
2814
+ if (equalsAt !== -1) {
2815
+ if (arg.slice(equalsAt + 1).length === 0)
2816
+ return "invalid arguments";
2817
+ }
2818
+ else {
2819
+ const value = argv[index + 1];
2820
+ if (value === undefined || value.startsWith("-"))
2821
+ return "invalid arguments";
2822
+ index += 1;
2823
+ }
2824
+ }
2825
+ else if (equalsAt !== -1) {
2826
+ return "invalid arguments";
2827
+ }
2828
+ }
2829
+ const expected = subcommand === undefined ? 1 : KEYS_POSITIONAL_COUNT[subcommand];
2830
+ return positionals.length === expected ? null : "invalid arguments";
2831
+ }
2832
+ function reportKeysCommandError(error) {
2833
+ process.stderr.write(`llm-relay keys: ${error.message}\n`);
2834
+ process.exit(1);
2835
+ }
2836
+ function runKeysPromise(operation) {
2837
+ operation.catch(reportKeysCommandError);
2838
+ }
2421
2839
  export function main() {
2840
+ const rawCommand = rawCliCommand(process.argv);
2422
2841
  const positionals = getPositionalArgs(process.argv);
2423
2842
  const arg2 = positionals[0];
2424
2843
  const arg3 = positionals[1];
2425
2844
  const arg4 = positionals[2];
2845
+ if (arg2 === "keys") {
2846
+ const validationError = validateKeysCommandArgs(process.argv);
2847
+ if (validationError !== null) {
2848
+ process.stderr.write(`llm-relay keys: ${validationError}\n`);
2849
+ process.exit(1);
2850
+ return;
2851
+ }
2852
+ }
2853
+ // Mutation parsing owns its raw argv so help/version-shaped typos cannot bypass fail-closed
2854
+ // validation and turn a scoped clear into a wider request.
2855
+ if (rawCommand === "cooldowns" || arg2 === "cooldowns") {
2856
+ // Keep validation synchronous at the entrypoint; `runCooldowns` is async, and otherwise a
2857
+ // parser failure would become an unobserved rejected promise after main returned.
2858
+ parseCooldownClearArgs(process.argv);
2859
+ void runCooldowns(arg3, arg4);
2860
+ return;
2861
+ }
2426
2862
  if (hasFlag("--help", "-h") || arg2 === "help") {
2427
2863
  process.stdout.write(HELP);
2428
2864
  process.exit(0);
@@ -2471,13 +2907,57 @@ export function main() {
2471
2907
  process.stdout.write(JSON.stringify(report, null, 2) + "\n");
2472
2908
  return;
2473
2909
  }
2474
- if (arg2 === "keys" || arg2 === "check-keys") {
2910
+ if (arg2 === "check-keys") {
2475
2911
  runCheckKeys().catch((e) => {
2476
2912
  process.stderr.write(`llm-relay check-keys: ${e.message}\n`);
2477
2913
  process.exit(1);
2478
2914
  });
2479
2915
  return;
2480
2916
  }
2917
+ if (arg2 === "keys") {
2918
+ switch (arg3) {
2919
+ case undefined:
2920
+ case "check":
2921
+ // Bare `keys` remains byte-for-byte the same status command as `check-keys`.
2922
+ runCheckKeys().catch((e) => {
2923
+ process.stderr.write(`llm-relay check-keys: ${e.message}\n`);
2924
+ process.exit(1);
2925
+ });
2926
+ return;
2927
+ case "add":
2928
+ runKeysPromise(runKeysAdd(arg4));
2929
+ return;
2930
+ case "list":
2931
+ runKeysPromise(runKeysList());
2932
+ return;
2933
+ case "rotate":
2934
+ runKeysPromise(runKeysRotate(arg4));
2935
+ return;
2936
+ case "revoke":
2937
+ runKeysPromise(runKeysRevoke(arg4));
2938
+ return;
2939
+ case "remove":
2940
+ runKeysPromise(runKeysRemove(arg4));
2941
+ return;
2942
+ case "disable":
2943
+ runKeysPromise(runKeysDisable(arg4));
2944
+ return;
2945
+ case "enable":
2946
+ runKeysPromise(runKeysEnable(arg4));
2947
+ return;
2948
+ case "export":
2949
+ runKeysPromise(runKeysExport());
2950
+ return;
2951
+ case "import":
2952
+ runKeysPromise(runKeysImport(arg4));
2953
+ return;
2954
+ case "unlock":
2955
+ runKeysPromise(runKeysUnlock());
2956
+ return;
2957
+ default:
2958
+ throw new Error("unreachable keys subcommand after validation");
2959
+ }
2960
+ }
2481
2961
  if (arg2 === "models") {
2482
2962
  runModels().catch((e) => {
2483
2963
  process.stderr.write(`llm-relay: ${e.message}\n`);
@@ -2623,6 +3103,15 @@ export function classifyCommand(argv) {
2623
3103
  return arg4 === "on" || arg4 === "enable" || arg4 === "off" || arg4 === "disable"
2624
3104
  ? "mutating"
2625
3105
  : "read-only";
3106
+ // Clears process-local relay state through the authenticated mutation plane.
3107
+ case "cooldowns":
3108
+ return arg3 === "clear" ? "mutating" : "read-only";
3109
+ case "keys":
3110
+ return arg3 === "add" || arg3 === "rotate" || arg3 === "revoke" ||
3111
+ arg3 === "remove" || arg3 === "disable" || arg3 === "enable" ||
3112
+ arg3 === "export" || arg3 === "import"
3113
+ ? "mutating"
3114
+ : "read-only";
2626
3115
  case "config":
2627
3116
  return arg3 === "set" || arg3 === "unset" ? "mutating" : "read-only";
2628
3117
  case "routing":
@@ -2641,7 +3130,7 @@ export function classifyCommand(argv) {
2641
3130
  return arg3 === "accept" || arg3 === "reject" || arg3 === "propose" ? "mutating" : "read-only";
2642
3131
  case "dashboard":
2643
3132
  return "read-only";
2644
- // keys, check-keys, models, telemetry, dispatch, candidates, pools, ping, help, version —
3133
+ // check-keys, models, telemetry, dispatch, candidates, pools, ping, help, version —
2645
3134
  // and anything not yet listed. `dispatch -x` is included on purpose: it reports spend to a
2646
3135
  // running proxy's in-memory cooldowns and changes nothing on this machine.
2647
3136
  default:
@@ -2649,10 +3138,23 @@ export function classifyCommand(argv) {
2649
3138
  }
2650
3139
  }
2651
3140
  /**
2652
- * Entrypoint: currency gate first (may replace this install and re-exec), then
2653
- * the command itself. `main` stays synchronous so its exit paths are direct.
3141
+ * Entrypoint: fail-closed mutation syntax, then the currency gate (which may replace this install
3142
+ * and re-exec), then the command itself. `main` stays synchronous so its exit paths are direct.
2654
3143
  */
2655
3144
  export async function run() {
3145
+ // Reject malformed mutation argv before the self-update gate can perform any network request
3146
+ // or replace the installed CLI. Keys diagnostics stay generic so rejected argv is never echoed.
3147
+ const command = rawCliCommand(process.argv);
3148
+ if (command === "cooldowns")
3149
+ parseCooldownClearArgs(process.argv);
3150
+ if (command === "keys") {
3151
+ const validationError = validateKeysCommandArgs(process.argv);
3152
+ if (validationError !== null) {
3153
+ process.stderr.write(`llm-relay keys: ${validationError}\n`);
3154
+ process.exit(1);
3155
+ return;
3156
+ }
3157
+ }
2656
3158
  if (shouldCheckUpdates(process.argv, process.env, classifyCommand(process.argv))) {
2657
3159
  try {
2658
3160
  await ensureUpToDate();