llm-relay 0.46.2 → 0.47.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 (69) hide show
  1. package/dist/accounting-store-io.js +1 -9
  2. package/dist/accounting-store-io.js.map +1 -1
  3. package/dist/accounting-store-schema.d.ts +0 -35
  4. package/dist/accounting-store-schema.js +4 -77
  5. package/dist/accounting-store-schema.js.map +1 -1
  6. package/dist/accounting-store.d.ts +7 -0
  7. package/dist/accounting-store.js +3 -1
  8. package/dist/accounting-store.js.map +1 -1
  9. package/dist/availability-snapshot.js +12 -33
  10. package/dist/availability-snapshot.js.map +1 -1
  11. package/dist/availability.d.ts +31 -4
  12. package/dist/availability.js +36 -0
  13. package/dist/availability.js.map +1 -1
  14. package/dist/backend.js +1 -3
  15. package/dist/backend.js.map +1 -1
  16. package/dist/candidates.js +10 -34
  17. package/dist/candidates.js.map +1 -1
  18. package/dist/cli.d.ts +28 -0
  19. package/dist/cli.js +140 -133
  20. package/dist/cli.js.map +1 -1
  21. package/dist/cooldown-clear.d.ts +3 -0
  22. package/dist/cooldown-clear.js +158 -2
  23. package/dist/cooldown-clear.js.map +1 -1
  24. package/dist/dashboard/.vite/manifest.json +1 -1
  25. package/dist/dashboard/assets/{index-u-Ez_oE6.js → index-BA4GdK4p.js} +1 -1
  26. package/dist/dashboard/index.html +1 -1
  27. package/dist/dashboard-contract.js +1 -5
  28. package/dist/dashboard-contract.js.map +1 -1
  29. package/dist/dashboard-routes.js +1 -9
  30. package/dist/dashboard-routes.js.map +1 -1
  31. package/dist/dashboard-static.js +1 -3
  32. package/dist/dashboard-static.js.map +1 -1
  33. package/dist/dialect-stream.js +13 -21
  34. package/dist/dialect-stream.js.map +1 -1
  35. package/dist/json-shape.d.ts +22 -0
  36. package/dist/json-shape.js +40 -0
  37. package/dist/json-shape.js.map +1 -0
  38. package/dist/keys-cli.js +4 -60
  39. package/dist/keys-cli.js.map +1 -1
  40. package/dist/keystore.js +11 -18
  41. package/dist/keystore.js.map +1 -1
  42. package/dist/openai-dialect.js +46 -40
  43. package/dist/openai-dialect.js.map +1 -1
  44. package/dist/openai-request.d.ts +0 -26
  45. package/dist/openai-request.js +1 -3
  46. package/dist/openai-request.js.map +1 -1
  47. package/dist/quota-demotion.js +9 -34
  48. package/dist/quota-demotion.js.map +1 -1
  49. package/dist/refusal-interpretation.js +1 -4
  50. package/dist/refusal-interpretation.js.map +1 -1
  51. package/dist/responses-request.d.ts +0 -76
  52. package/dist/responses-request.js +1 -3
  53. package/dist/responses-request.js.map +1 -1
  54. package/dist/server.js +120 -137
  55. package/dist/server.js.map +1 -1
  56. package/dist/sse-frames.d.ts +39 -0
  57. package/dist/sse-frames.js +65 -0
  58. package/dist/sse-frames.js.map +1 -0
  59. package/dist/stream-commit.js +14 -24
  60. package/dist/stream-commit.js.map +1 -1
  61. package/dist/target-facts.d.ts +6 -0
  62. package/dist/target-facts.js +8 -5
  63. package/dist/target-facts.js.map +1 -1
  64. package/dist/think-tags.js +11 -30
  65. package/dist/think-tags.js.map +1 -1
  66. package/dist/tool-use-ids.d.ts +0 -31
  67. package/dist/tool-use-ids.js +11 -63
  68. package/dist/tool-use-ids.js.map +1 -1
  69. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { isRecord } from "./json-shape.js";
2
3
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
4
  import { join } from "node:path";
4
5
  import { homedir } from "node:os";
@@ -8,7 +9,7 @@ import { loadEnvFile } from "./dotenv.js";
8
9
  import { recoverWindowsEnv } from "./winenv.js";
9
10
  import { offloadState, setOffload } from "./offload.js";
10
11
  import { buildCandidates } from "./candidates.js";
11
- import { CREDENTIAL_LABEL_PATTERN, makeCredentialId, parseCredentialId } from "./credential-id.js";
12
+ import { CREDENTIAL_LABEL_PATTERN, makeCredentialId } from "./credential-id.js";
12
13
  import { providerCredentialSlots, slotAllowsModel } from "./credential-fleet.js";
13
14
  import { loadLaneManifest, verifyModel } from "./lane-manifest.js";
14
15
  import { probeLanes } from "./lane-probe.js";
@@ -28,6 +29,7 @@ import { materializeDynamicPools } from "./dynamic-pools.js";
28
29
  import { currentVersion, ensureUpToDate, shouldCheckUpdates } from "./self-update.js";
29
30
  import { deleteConfigPath, parseConfigValue, readConfigDocument, readConfigPath, updateConfigDocument, writeConfigPath, } from "./config-edit.js";
30
31
  import { createControlAuthorization, resolveControlAuthorizationConfigDir } from "./control-authorization.js";
32
+ import { isCooldownClearResult } from "./cooldown-clear.js";
31
33
  import { createDashboardSnapshotReadPort } from "./dashboard-snapshot.js";
32
34
  import { DASHBOARD_MEDIA_TYPE, isDashboardUtcTimestamp } from "./dashboard-contract.js";
33
35
  import { DASHBOARD_BOOTSTRAP_SCHEMA, DASHBOARD_BOOTSTRAP_REQUEST_SCHEMA } from "./dashboard-routes.js";
@@ -751,136 +753,6 @@ function cooldownCommandFailure(message) {
751
753
  process.stderr.write(`llm-relay cooldowns: ${message}\n`);
752
754
  process.exit(1);
753
755
  }
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
756
  const COOLDOWN_CLEAR_OPTIONS = new Map([
885
757
  ["--credential", { semantic: "credential", takesValue: true }],
886
758
  ["--json", { semantic: "json", takesValue: false }],
@@ -891,11 +763,119 @@ const COOLDOWN_CLEAR_OPTIONS = new Map([
891
763
  ["--refresh", { semantic: "refresh", takesValue: false }],
892
764
  ["-r", { semantic: "refresh", takesValue: false }],
893
765
  ]);
894
- const CLI_COMMAND_NAMES = new Set([
766
+ export const CLI_COMMAND_NAMES = new Set([
895
767
  "onboard", "setup", "keys", "check-keys", "models", "ping", "dashboard", "telemetry",
896
768
  "offload", "lanes", "dispatch", "cooldowns", "eligibility", "candidates", "cost", "pools",
897
769
  "routing", "route", "config", "help", "version",
898
770
  ]);
771
+ /**
772
+ * Positional-ARITY bounds per command — the shared half of "an ignored argument is a lie".
773
+ *
774
+ * `llm-relay cost --window 1h 7d` silently dropped `7d` and reported 24h; `llm-relay models nim`
775
+ * listed every provider. Exact arity existed only on the mutating/custody surfaces, so the whole
776
+ * read-only family accepted and discarded whatever it did not understand. Counting INCLUDES the
777
+ * command token: `llm-relay cost` is 1, `llm-relay config set a b` is 4.
778
+ *
779
+ * ⚠ Every bound is derived from what the DISPATCHER READS — `arg3`/`arg4`/`positionals[n]`/
780
+ * `.slice()` — never from HELP, which drifts: `lanes` and the `route` alias appear nowhere in it,
781
+ * and `setup`'s documented `claude-cli` target matches no branch at all (it works by fall-through).
782
+ * A table built from the help text would have left two commands unguarded and mis-bounded a third.
783
+ *
784
+ * ⚠ DELIBERATELY ABSENT — do not "complete" this table with them:
785
+ * `keys` — `validateKeysCommandArgs` is EXACT per subcommand and runs above this guard. It
786
+ * is also secret-safe by design ("diagnostics deliberately never echo rejected
787
+ * argv tokens", since a pasted credential can land in argv). A generic entry would
788
+ * be dead code at best and a second, laxer refusal at worst.
789
+ * `cooldowns` — `parseCooldownClearArgs` requires exactly 3 and its branch RETURNS before this
790
+ * guard; it is also reachable through `rawCliCommand`'s special case, which keys
791
+ * on raw argv this guard cannot see.
792
+ * `help` / — both `process.exit(0)` above this guard, which is what makes `llm-relay <cmd>
793
+ * `version` --help` work for every command. A bound here could never fire.
794
+ */
795
+ const COMMAND_ARITY = {
796
+ // Reads no positional; `--import` is a VALUE_FLAG and `--force` is a boolean.
797
+ onboard: { min: 1, max: 1 },
798
+ // `arg3` is the last slot read: `claude-desktop` / `desktop`, else fall-through to the CLI setup.
799
+ setup: { min: 1, max: 2 },
800
+ // Handled inline in main(); arg3/arg4 are in scope and never referenced.
801
+ telemetry: { min: 1, max: 1 },
802
+ // `runCheckKeys()` takes no arguments. ⚠ No hint: it has no provider filter — it hands the whole
803
+ // config to `validateProviderKeys`, so suggesting `-p` would point at a flag that does nothing.
804
+ "check-keys": { min: 1, max: 1 },
805
+ models: { min: 1, max: 1, hint: "llm-relay models -p <name>" },
806
+ ping: { min: 1, max: 1, hint: "llm-relay ping -p <name>" },
807
+ candidates: { min: 1, max: 1, hint: "llm-relay candidates -p <name>" },
808
+ // `runCostCommand` takes only a test seam; window/by/include-repair are all flags.
809
+ cost: { min: 1, max: 1, hint: "llm-relay cost --window <w> --by <d>" },
810
+ // ⚠ Absent from HELP entirely, so its users cannot check usage — and the adjacent `dispatch`
811
+ // DOES take a positional lane, which is exactly the confusion worth naming.
812
+ lanes: { min: 1, max: 1, hint: "llm-relay dispatch <lane>" },
813
+ dashboard: { min: 1, max: 1 },
814
+ // `runDispatch(arg3)` — one optional lane positional.
815
+ dispatch: { min: 1, max: 2 },
816
+ // `runOffload(arg3, arg4)`. The semantic checks inside it (refusing a bare `offload on`, and
817
+ // `offload status <anything>`) sit INSIDE this bound and stay where they are.
818
+ offload: { min: 1, max: 3 },
819
+ // `runEligibility(arg3, arg4)`.
820
+ eligibility: { min: 1, max: 3 },
821
+ // `positionals[3]` is the highest fixed slot anywhere in this file. `config set`/`unset` cap
822
+ // themselves tighter; this is the outer bound only.
823
+ config: { min: 1, max: 4 },
824
+ // VARIADIC: `positionals.slice(3)` — `pools set <name> <spec> [<spec>...]`.
825
+ pools: { min: 1, max: -1 },
826
+ // VARIADIC twice over: `slice(2)` for `routing default`, `slice(3)` for `routing tier`.
827
+ // Multi-candidate specs are a documented routing feature, so any finite max is a false positive.
828
+ routing: { min: 1, max: -1 },
829
+ // Same handler as `routing`; omitting the alias would leave it unguarded.
830
+ route: { min: 1, max: -1 },
831
+ };
832
+ /**
833
+ * Refuse a command given more positionals than it can read, or fewer than it needs.
834
+ *
835
+ * Pure and exported so the whole table is testable without driving `main()`.
836
+ *
837
+ * ⚠ Returns null on an EMPTY positional list. That is bare `llm-relay` — the documented way to
838
+ * start the proxy — and also the `--ping` flag entry, which reaches the ping command with no
839
+ * positional at all. Applying a minimum there is the one guaranteed false positive available.
840
+ *
841
+ * ⚠ Returns null on a table MISS. An unknown command name belongs to `dispatchDashboardOrProxy`,
842
+ * which already refuses it against `CLI_COMMAND_NAMES`; two refusals for one mistake would be
843
+ * worse than one.
844
+ *
845
+ * ⚠ The extra token is deliberately NOT echoed, unlike the unknown-command guard. An unknown
846
+ * COMMAND is by definition not a secret-bearing position and naming it is the whole diagnostic;
847
+ * a stray POSITIONAL can be anything the user pasted — and `check-keys` here is the same command
848
+ * as `keys check`, whose parser never echoes argv for exactly that reason. The count plus a
849
+ * verified hint is enough to act on, and the user can still see what they typed.
850
+ */
851
+ /**
852
+ * Commands whose arity is owned elsewhere, exported so the coverage test can state WHY each is
853
+ * absent from `COMMAND_ARITY` rather than letting a future omission look identical to an oversight.
854
+ */
855
+ export const ARITY_EXEMPT = new Set(["keys", "cooldowns", "help", "version"]);
856
+ /** The commands this guard bounds — exported for the drift test, not for dispatch. */
857
+ export const ARITY_GUARDED_COMMANDS = Object.keys(COMMAND_ARITY);
858
+ export function commandArityError(positionals) {
859
+ const name = positionals[0];
860
+ if (name === undefined)
861
+ return null;
862
+ const arity = COMMAND_ARITY[name];
863
+ if (!arity)
864
+ return null;
865
+ const hint = arity.hint ? ` Did you mean \`${arity.hint}\`?` : "";
866
+ if (arity.max >= 0 && positionals.length > arity.max) {
867
+ const extra = positionals.length - arity.max;
868
+ const allowed = arity.max - 1;
869
+ const takes = allowed === 0
870
+ ? "takes no arguments after the command"
871
+ : `takes at most ${allowed} argument${allowed === 1 ? "" : "s"} after the command`;
872
+ return `llm-relay ${name}: ${takes} (got ${extra} too many).${hint} Run 'llm-relay help' for usage.`;
873
+ }
874
+ if (positionals.length < arity.min) {
875
+ return `llm-relay ${name}: missing required argument.${hint} Run 'llm-relay help' for usage.`;
876
+ }
877
+ return null;
878
+ }
899
879
  /** Find a real command token without letting a typoed option/value pair hide a later mutation. */
900
880
  function rawCliCommand(argv) {
901
881
  for (let index = 2; index < argv.length; index++) {
@@ -981,6 +961,11 @@ export async function runCooldowns(_action, _spec) {
981
961
  ...(model === undefined ? {} : { model }),
982
962
  ...(credential === undefined ? {} : { credential }),
983
963
  };
964
+ const acceptedTargetKeys = [
965
+ "provider",
966
+ ...(model === undefined ? [] : ["model"]),
967
+ ...(credential === undefined ? [] : ["credential"]),
968
+ ];
984
969
  const cfg = loadOrExit();
985
970
  if (!Object.hasOwn(cfg.providers, provider)) {
986
971
  cooldownCommandFailure(`no provider "${provider}" configured`);
@@ -1018,7 +1003,7 @@ export async function runCooldowns(_action, _spec) {
1018
1003
  const detail = controlErrorMessage(payload);
1019
1004
  cooldownCommandFailure(`the running relay rejected the clear${detail === null ? "" : `: ${detail}`}`);
1020
1005
  }
1021
- if (!isCooldownClearResult(payload, target)) {
1006
+ if (!isCooldownClearResult(payload, target, acceptedTargetKeys)) {
1022
1007
  cooldownCommandFailure("the running relay returned an invalid cooldown-clear response");
1023
1008
  }
1024
1009
  if (parsed.json) {
@@ -2888,6 +2873,28 @@ export function main() {
2888
2873
  process.stdout.write(`${currentVersion()}\n`);
2889
2874
  process.exit(0);
2890
2875
  }
2876
+ // ⚠ THIS LINE, not one earlier and not one later.
2877
+ //
2878
+ // BELOW it is the first side effect in the whole ladder: the very next branch calls
2879
+ // `loadOrExit()`, whose `resolveConfigPath` fall-through CREATES `~/.llm-relay/config.json`. Past
2880
+ // that lie probes of every model (`ping`), authenticated provider calls (`check-keys`), spawned
2881
+ // lane commands (`lanes --probe`), POSTs to the running relay, a minted one-use dashboard
2882
+ // bootstrap, and the accounting store. Refusing here costs none of them.
2883
+ //
2884
+ // ABOVE it, nothing durable has happened — but two things MUST stay above: `keys` and
2885
+ // `cooldowns` own strict, fail-closed, secret-safe parsers (and cooldowns returns), and
2886
+ // `--help`/`--version` short-circuit, which is what makes `llm-relay <cmd> --help` work for
2887
+ // every command. Moving this check above them would flip `llm-relay cost extra --help` from
2888
+ // printing help to exit 1.
2889
+ //
2890
+ // Keyed on `positionals[0]`, never on `rawCliCommand`: that scans PAST a leading non-command
2891
+ // token, so `llm-relay bogus cost` would arity-check `cost` while main() dispatches `bogus`.
2892
+ const arityError = commandArityError(positionals);
2893
+ if (arityError !== null) {
2894
+ process.stderr.write(`${arityError}\n`);
2895
+ process.exit(1);
2896
+ return;
2897
+ }
2891
2898
  if (arg2 === "onboard") {
2892
2899
  const cfg = loadOrExit();
2893
2900
  if (hasFlag("--import", "-import")) {