llm-relay 0.43.0 → 0.44.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.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";
@@ -51,6 +51,7 @@ const VALUE_FLAGS = new Set([
51
51
  "--host", "-host",
52
52
  "--client", "-client",
53
53
  "--scope", "-scope",
54
+ "--credential", "-credential",
54
55
  "--include", "-include",
55
56
  "--window", "-window",
56
57
  "--by", "-by",
@@ -168,6 +169,7 @@ ${formatTextTable([
168
169
  ["llm-relay telemetry", "Print telemetry JSON."],
169
170
  ["llm-relay offload [status]", "Show current offload rules."],
170
171
  ["llm-relay offload <harness> <on|off> [--scope <scope>]", "Toggle one harness (claude | codex); scope: subagents | all."],
172
+ ["llm-relay cooldowns clear <provider>[/<model>] [--credential <label>]", "Clear live cooling state; requires the running relay."],
171
173
  ["llm-relay candidates [-p <name>]", "Compare deployment x credential-slot targets."],
172
174
  ["llm-relay cost [--window <w>] [--by <d>] [--include-repair]", "Summarise spend from the local accounting ledger."],
173
175
  ["llm-relay eligibility", "What backends said about themselves; refusals awaiting interpretation."],
@@ -262,6 +264,7 @@ ${formatTextTable([
262
264
  ["GET /candidates", "Deployment x credential policy/state/quota/breaker cells."],
263
265
  ["GET|POST /offload", "Read/set rules; accepts ?client=<name>."],
264
266
  ["GET|POST /dispatch", "Read/set next lane; POST {\"exhausted\":\"<lane>\"}."],
267
+ ["POST /cooldowns/clear", "Clear scoped live cooling state."],
265
268
  ["GET /telemetry", "Provider telemetry."],
266
269
  ["GET /ping", "Run health probe."],
267
270
  ["GET /health", "Provider health."],
@@ -702,6 +705,301 @@ export function proxyUrl(cfg, path) {
702
705
  const host = cfg.host.includes(":") ? `[${cfg.host}]` : cfg.host;
703
706
  return `http://${host}:${cfg.port}${path}`;
704
707
  }
708
+ function cooldownCommandFailure(message) {
709
+ process.stderr.write(`llm-relay cooldowns: ${message}\n`);
710
+ process.exit(1);
711
+ }
712
+ function isRecord(value) {
713
+ return typeof value === "object" && value !== null && !Array.isArray(value);
714
+ }
715
+ function hasExactKeys(value, keys) {
716
+ const actual = Object.keys(value);
717
+ return actual.length === keys.length && actual.every((key) => keys.includes(key));
718
+ }
719
+ function nonEmptyString(value) {
720
+ return typeof value === "string" && value.length > 0;
721
+ }
722
+ const COOLING_FACT_KINDS = new Set([
723
+ "allowance-exhausted",
724
+ "rate-limited",
725
+ "credential-invalid",
726
+ ]);
727
+ function credentialIdBelongsTo(provider, value) {
728
+ if (typeof value !== "string")
729
+ return false;
730
+ return parseCredentialId(value)?.provider === provider;
731
+ }
732
+ function isCooldownFactScope(value) {
733
+ if (!isRecord(value))
734
+ return false;
735
+ switch (value.kind) {
736
+ case "attempt":
737
+ return hasExactKeys(value, ["kind", "provider", "credentialId", "model"]) &&
738
+ nonEmptyString(value.provider) &&
739
+ credentialIdBelongsTo(value.provider, value.credentialId) &&
740
+ nonEmptyString(value.model);
741
+ case "group": {
742
+ const keys = Object.hasOwn(value, "credentialId")
743
+ ? ["kind", "provider", "credentialId", "members"]
744
+ : ["kind", "provider", "members"];
745
+ return hasExactKeys(value, keys) &&
746
+ nonEmptyString(value.provider) &&
747
+ Array.isArray(value.members) &&
748
+ value.members.length > 0 &&
749
+ value.members.every(nonEmptyString) &&
750
+ (!Object.hasOwn(value, "credentialId") || credentialIdBelongsTo(value.provider, value.credentialId));
751
+ }
752
+ case "deployment":
753
+ return hasExactKeys(value, ["kind", "provider", "model"]) &&
754
+ nonEmptyString(value.provider) && nonEmptyString(value.model);
755
+ case "credential":
756
+ return hasExactKeys(value, ["kind", "provider", "credentialId"]) &&
757
+ nonEmptyString(value.provider) && credentialIdBelongsTo(value.provider, value.credentialId);
758
+ case "provider":
759
+ return hasExactKeys(value, ["kind", "provider"]) && nonEmptyString(value.provider);
760
+ case "model":
761
+ return hasExactKeys(value, ["kind", "model"]) && nonEmptyString(value.model);
762
+ default:
763
+ return false;
764
+ }
765
+ }
766
+ function scopeIsContainedByTarget(scope, target) {
767
+ const credentialId = target.credential === undefined
768
+ ? undefined
769
+ : makeCredentialId(target.provider, target.credential);
770
+ switch (scope.kind) {
771
+ case "attempt":
772
+ return scope.provider === target.provider &&
773
+ (target.model === undefined || scope.model === target.model) &&
774
+ (credentialId === undefined || scope.credentialId === credentialId);
775
+ case "group":
776
+ return scope.provider === target.provider &&
777
+ (target.model === undefined || scope.members.every((member) => member === target.model)) &&
778
+ (credentialId === undefined || scope.credentialId === credentialId);
779
+ case "deployment":
780
+ return scope.provider === target.provider && credentialId === undefined &&
781
+ (target.model === undefined || scope.model === target.model);
782
+ case "credential":
783
+ return scope.provider === target.provider && target.model === undefined &&
784
+ (credentialId === undefined || scope.credentialId === credentialId);
785
+ case "provider":
786
+ return scope.provider === target.provider && target.model === undefined && credentialId === undefined;
787
+ case "model":
788
+ return false;
789
+ }
790
+ }
791
+ function isCooldownCell(value, target) {
792
+ if (!isRecord(value) || !hasExactKeys(value, ["provider", "model", "credential"]))
793
+ return false;
794
+ if (!nonEmptyString(value.provider) || value.provider !== target.provider)
795
+ return false;
796
+ if (!(value.model === null || nonEmptyString(value.model)))
797
+ return false;
798
+ if (!nonEmptyString(value.credential) || !CREDENTIAL_LABEL_PATTERN.test(value.credential))
799
+ return false;
800
+ return (target.model === undefined || value.model === target.model) &&
801
+ (target.credential === undefined || value.credential === target.credential);
802
+ }
803
+ function isCooldownFact(value, target) {
804
+ return isRecord(value) &&
805
+ hasExactKeys(value, ["kind", "scope"]) &&
806
+ typeof value.kind === "string" &&
807
+ COOLING_FACT_KINDS.has(value.kind) &&
808
+ isCooldownFactScope(value.scope) &&
809
+ scopeIsContainedByTarget(value.scope, target);
810
+ }
811
+ function isClearedGroup(value, isItem) {
812
+ if (!isRecord(value) || !hasExactKeys(value, ["count", "items"]) ||
813
+ typeof value.count !== "number" || !Number.isSafeInteger(value.count) ||
814
+ value.count < 0 || !Array.isArray(value.items)) {
815
+ return false;
816
+ }
817
+ return value.count === value.items.length && value.items.every(isItem);
818
+ }
819
+ function isExactCooldownTarget(value, expected) {
820
+ if (!isRecord(value))
821
+ return false;
822
+ const keys = [
823
+ "provider",
824
+ ...(expected.model === undefined ? [] : ["model"]),
825
+ ...(expected.credential === undefined ? [] : ["credential"]),
826
+ ];
827
+ return hasExactKeys(value, keys) &&
828
+ value.provider === expected.provider &&
829
+ (expected.model === undefined || value.model === expected.model) &&
830
+ (expected.credential === undefined || value.credential === expected.credential);
831
+ }
832
+ function isCooldownClearResult(value, target) {
833
+ if (!isRecord(value) || !hasExactKeys(value, ["target", "cleared"]) ||
834
+ !isExactCooldownTarget(value.target, target) || !isRecord(value.cleared) ||
835
+ !hasExactKeys(value.cleared, ["breakerCells", "credentialFaults", "facts"])) {
836
+ return false;
837
+ }
838
+ return isClearedGroup(value.cleared.breakerCells, (item) => isCooldownCell(item, target)) &&
839
+ isClearedGroup(value.cleared.credentialFaults, (item) => isCooldownCell(item, target)) &&
840
+ isClearedGroup(value.cleared.facts, (item) => isCooldownFact(item, target));
841
+ }
842
+ const COOLDOWN_CLEAR_OPTIONS = new Map([
843
+ ["--credential", { semantic: "credential", takesValue: true }],
844
+ ["--json", { semantic: "json", takesValue: false }],
845
+ ["--config", { semantic: "config", takesValue: true }],
846
+ ["-c", { semantic: "config", takesValue: true }],
847
+ ["--provider", { semantic: "provider", takesValue: true }],
848
+ ["-p", { semantic: "provider", takesValue: true }],
849
+ ["--refresh", { semantic: "refresh", takesValue: false }],
850
+ ["-r", { semantic: "refresh", takesValue: false }],
851
+ ]);
852
+ const CLI_COMMAND_NAMES = new Set([
853
+ "onboard", "setup", "keys", "check-keys", "models", "ping", "dashboard", "telemetry",
854
+ "offload", "lanes", "dispatch", "cooldowns", "eligibility", "candidates", "cost", "pools",
855
+ "routing", "route", "config", "help", "version",
856
+ ]);
857
+ /** Find a real command token without letting a typoed option/value pair hide a later mutation. */
858
+ function rawCliCommand(argv) {
859
+ for (let index = 2; index < argv.length; index++) {
860
+ const arg = argv[index];
861
+ if (!arg.startsWith("-")) {
862
+ if (CLI_COMMAND_NAMES.has(arg))
863
+ return arg;
864
+ continue;
865
+ }
866
+ const equalsAt = arg.indexOf("=");
867
+ const flag = equalsAt === -1 ? arg : arg.slice(0, equalsAt);
868
+ if (equalsAt === -1 && VALUE_FLAGS.has(flag)) {
869
+ // A missing value before the mutation must reach the strict parser, where it exits 1.
870
+ if (argv[index + 1] === "cooldowns" && argv[index + 2] === "clear")
871
+ return "cooldowns";
872
+ index += 1;
873
+ }
874
+ }
875
+ return undefined;
876
+ }
877
+ function parseCooldownClearArgs(argv) {
878
+ const positionals = [];
879
+ const seen = new Set();
880
+ let credential;
881
+ let json = false;
882
+ for (let index = 2; index < argv.length; index++) {
883
+ const arg = argv[index];
884
+ if (!arg.startsWith("-")) {
885
+ positionals.push(arg);
886
+ continue;
887
+ }
888
+ const equalsAt = arg.indexOf("=");
889
+ const flag = equalsAt === -1 ? arg : arg.slice(0, equalsAt);
890
+ const option = COOLDOWN_CLEAR_OPTIONS.get(flag);
891
+ if (option === undefined)
892
+ cooldownCommandFailure(`unknown option "${flag}"`);
893
+ if (seen.has(option.semantic))
894
+ cooldownCommandFailure(`duplicate option "${flag}"`);
895
+ seen.add(option.semantic);
896
+ if (!option.takesValue) {
897
+ if (equalsAt !== -1)
898
+ cooldownCommandFailure(`${flag} does not take a value`);
899
+ if (option.semantic === "json")
900
+ json = true;
901
+ continue;
902
+ }
903
+ const value = equalsAt === -1 ? argv[index + 1] : arg.slice(equalsAt + 1);
904
+ if (value === undefined || value.length === 0 || (equalsAt === -1 && value.startsWith("-"))) {
905
+ cooldownCommandFailure(`${flag} requires a value`);
906
+ }
907
+ if (equalsAt === -1)
908
+ index += 1;
909
+ if (option.semantic === "credential")
910
+ credential = value;
911
+ }
912
+ if (positionals.length !== 3 || positionals[0] !== "cooldowns" || positionals[1] !== "clear") {
913
+ cooldownCommandFailure("usage: llm-relay cooldowns clear <provider>[/<model>] [--credential <label>] [--json]");
914
+ }
915
+ return {
916
+ spec: positionals[2],
917
+ ...(credential === undefined ? {} : { credential }),
918
+ json,
919
+ };
920
+ }
921
+ function controlErrorMessage(value) {
922
+ if (!isRecord(value) || !isRecord(value.error) || typeof value.error.message !== "string")
923
+ return null;
924
+ return value.error.message;
925
+ }
926
+ /** Clear process-local routing cooldowns through the protected live control plane only. */
927
+ export async function runCooldowns(_action, _spec) {
928
+ const parsed = parseCooldownClearArgs(process.argv);
929
+ const { spec, credential } = parsed;
930
+ const { provider, model } = splitSpec(spec);
931
+ if (provider.length === 0 || model === "") {
932
+ cooldownCommandFailure("target must be <provider> or <provider>/<model>");
933
+ }
934
+ if (credential !== undefined && !CREDENTIAL_LABEL_PATTERN.test(credential)) {
935
+ cooldownCommandFailure("--credential must match [A-Za-z0-9_.-]{1,32}");
936
+ }
937
+ const target = {
938
+ provider,
939
+ ...(model === undefined ? {} : { model }),
940
+ ...(credential === undefined ? {} : { credential }),
941
+ };
942
+ const cfg = loadOrExit();
943
+ if (!Object.hasOwn(cfg.providers, provider)) {
944
+ cooldownCommandFailure(`no provider "${provider}" configured`);
945
+ }
946
+ let authorization;
947
+ try {
948
+ authorization = createControlAuthorization(resolveControlAuthorizationConfigDir(cfg.sourcePath));
949
+ }
950
+ catch {
951
+ cooldownCommandFailure("control authorization is unavailable; the relay must be running with this config");
952
+ }
953
+ let response = null;
954
+ try {
955
+ response = await fetch(proxyUrl(cfg, "/cooldowns/clear"), {
956
+ method: "POST",
957
+ headers: authorization.attach({ "content-type": "application/json" }),
958
+ body: JSON.stringify(target),
959
+ signal: AbortSignal.timeout(5000),
960
+ });
961
+ }
962
+ catch {
963
+ // Report below with the same explicit no-file-fallback outcome for every transport failure.
964
+ }
965
+ if (response === null) {
966
+ cooldownCommandFailure("the relay must be running to clear cooldowns");
967
+ }
968
+ let payload;
969
+ try {
970
+ payload = await response.json();
971
+ }
972
+ catch {
973
+ cooldownCommandFailure("the running relay returned an invalid cooldown-clear response");
974
+ }
975
+ if (!response.ok) {
976
+ const detail = controlErrorMessage(payload);
977
+ cooldownCommandFailure(`the running relay rejected the clear${detail === null ? "" : `: ${detail}`}`);
978
+ }
979
+ if (!isCooldownClearResult(payload, target)) {
980
+ cooldownCommandFailure("the running relay returned an invalid cooldown-clear response");
981
+ }
982
+ if (parsed.json) {
983
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
984
+ return;
985
+ }
986
+ const targetLabel = `${provider}${model === undefined ? "" : `/${model}`}${credential === undefined ? "" : ` [credential ${credential}]`}`;
987
+ process.stdout.write(`Cleared cooldown state for ${targetLabel}\n`);
988
+ const groups = [
989
+ ["breaker cells", payload.cleared.breakerCells],
990
+ ["credential faults", payload.cleared.credentialFaults],
991
+ ];
992
+ for (const [label, group] of groups) {
993
+ process.stdout.write(` ${label}: ${group.count}\n`);
994
+ for (const cell of group.items) {
995
+ process.stdout.write(` ${cell.provider}/${cell.model ?? "*"} [credential ${cell.credential}]\n`);
996
+ }
997
+ }
998
+ process.stdout.write(` cooling facts: ${payload.cleared.facts.count}\n`);
999
+ for (const fact of payload.cleared.facts.items) {
1000
+ process.stdout.write(` ${fact.kind} ${describeScope(fact.scope)}\n`);
1001
+ }
1002
+ }
705
1003
  export const DASHBOARD_BROWSER_OPEN_TIMEOUT_MS = 5_000;
706
1004
  function isDashboardBootstrapWire(value, now) {
707
1005
  if (typeof value !== "object" || value === null || Array.isArray(value))
@@ -2419,10 +2717,20 @@ import { setupClaudeCli, setupClaudeDesktop } from "./setup-claude.js";
2419
2717
  import { getTelemetryReport } from "./telemetry.js";
2420
2718
  import { globalCircuitBreaker } from "./circuit-breaker.js";
2421
2719
  export function main() {
2720
+ const rawCommand = rawCliCommand(process.argv);
2422
2721
  const positionals = getPositionalArgs(process.argv);
2423
2722
  const arg2 = positionals[0];
2424
2723
  const arg3 = positionals[1];
2425
2724
  const arg4 = positionals[2];
2725
+ // Mutation parsing owns its raw argv so help/version-shaped typos cannot bypass fail-closed
2726
+ // validation and turn a scoped clear into a wider request.
2727
+ if (rawCommand === "cooldowns" || arg2 === "cooldowns") {
2728
+ // Keep validation synchronous at the entrypoint; `runCooldowns` is async, and otherwise a
2729
+ // parser failure would become an unobserved rejected promise after main returned.
2730
+ parseCooldownClearArgs(process.argv);
2731
+ void runCooldowns(arg3, arg4);
2732
+ return;
2733
+ }
2426
2734
  if (hasFlag("--help", "-h") || arg2 === "help") {
2427
2735
  process.stdout.write(HELP);
2428
2736
  process.exit(0);
@@ -2623,6 +2931,9 @@ export function classifyCommand(argv) {
2623
2931
  return arg4 === "on" || arg4 === "enable" || arg4 === "off" || arg4 === "disable"
2624
2932
  ? "mutating"
2625
2933
  : "read-only";
2934
+ // Clears process-local relay state through the authenticated mutation plane.
2935
+ case "cooldowns":
2936
+ return arg3 === "clear" ? "mutating" : "read-only";
2626
2937
  case "config":
2627
2938
  return arg3 === "set" || arg3 === "unset" ? "mutating" : "read-only";
2628
2939
  case "routing":
@@ -2649,10 +2960,13 @@ export function classifyCommand(argv) {
2649
2960
  }
2650
2961
  }
2651
2962
  /**
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.
2963
+ * Entrypoint: fail-closed mutation syntax, then the currency gate (which may replace this install
2964
+ * and re-exec), then the command itself. `main` stays synchronous so its exit paths are direct.
2654
2965
  */
2655
2966
  export async function run() {
2967
+ // Reject malformed mutation argv before the self-update gate can perform any network request.
2968
+ if (rawCliCommand(process.argv) === "cooldowns")
2969
+ parseCooldownClearArgs(process.argv);
2656
2970
  if (shouldCheckUpdates(process.argv, process.env, classifyCommand(process.argv))) {
2657
2971
  try {
2658
2972
  await ensureUpToDate();