llm-cli-gateway 2.16.0 → 2.17.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 (66) hide show
  1. package/.agents/skills/least-cost-routing/SKILL.md +123 -0
  2. package/CHANGELOG.md +37 -0
  3. package/README.md +12 -12
  4. package/dist/acp/client.js +5 -0
  5. package/dist/acp/flight-redaction.d.ts +2 -0
  6. package/dist/acp/flight-redaction.js +2 -0
  7. package/dist/acp/runtime.d.ts +1 -0
  8. package/dist/acp/runtime.js +20 -0
  9. package/dist/acp/types.d.ts +52 -0
  10. package/dist/acp/types.js +8 -0
  11. package/dist/api-provider.d.ts +1 -0
  12. package/dist/api-provider.js +1 -0
  13. package/dist/async-job-manager.d.ts +16 -0
  14. package/dist/async-job-manager.js +70 -22
  15. package/dist/claude-mcp-config.js +1 -1
  16. package/dist/compressor/transforms/ansi.js +12 -2
  17. package/dist/config.d.ts +31 -0
  18. package/dist/config.js +142 -7
  19. package/dist/db.js +4 -4
  20. package/dist/doctor.d.ts +34 -1
  21. package/dist/doctor.js +85 -3
  22. package/dist/executor.d.ts +5 -0
  23. package/dist/executor.js +11 -1
  24. package/dist/flight-recorder.d.ts +10 -0
  25. package/dist/flight-recorder.js +68 -2
  26. package/dist/http-transport.js +19 -21
  27. package/dist/index.d.ts +42 -3
  28. package/dist/index.js +666 -41
  29. package/dist/job-store.d.ts +10 -2
  30. package/dist/job-store.js +154 -43
  31. package/dist/lcr-priors.d.ts +60 -0
  32. package/dist/lcr-priors.js +190 -0
  33. package/dist/lcr-router-env.d.ts +20 -0
  34. package/dist/lcr-router-env.js +133 -0
  35. package/dist/lcr-telemetry.d.ts +2 -0
  36. package/dist/lcr-telemetry.js +17 -0
  37. package/dist/least-cost-router.d.ts +86 -0
  38. package/dist/least-cost-router.js +296 -0
  39. package/dist/least-cost-types.d.ts +34 -0
  40. package/dist/least-cost-types.js +1 -0
  41. package/dist/migrate-sessions.js +1 -1
  42. package/dist/migrate.js +1 -1
  43. package/dist/model-registry.js +1 -1
  44. package/dist/postgres-job-store-worker.js +56 -13
  45. package/dist/pricing.d.ts +17 -0
  46. package/dist/pricing.js +167 -0
  47. package/dist/provider-admin-tools.js +12 -4
  48. package/dist/provider-definitions.d.ts +4 -0
  49. package/dist/provider-definitions.js +33 -5
  50. package/dist/provider-tool-capabilities.js +6 -9
  51. package/dist/request-helpers.d.ts +3 -4
  52. package/dist/request-helpers.js +9 -10
  53. package/dist/resources.d.ts +37 -2
  54. package/dist/resources.js +96 -1
  55. package/dist/retry.d.ts +1 -0
  56. package/dist/retry.js +1 -1
  57. package/dist/token-estimator.d.ts +6 -0
  58. package/dist/token-estimator.js +59 -0
  59. package/dist/upstream-contracts.d.ts +4 -1
  60. package/dist/upstream-contracts.js +255 -71
  61. package/dist/validation-receipt.js +1 -1
  62. package/dist/validation-tools.d.ts +6 -0
  63. package/dist/validation-tools.js +174 -54
  64. package/npm-shrinkwrap.json +2 -2
  65. package/package.json +14 -5
  66. package/setup/status.schema.json +68 -0
@@ -20,6 +20,7 @@ const DEFAULT_LEASE_RUNTIME_CONFIG = {
20
20
  role: "gateway",
21
21
  };
22
22
  const MAX_CONSECUTIVE_HEARTBEAT_FAILURES = 3;
23
+ const MIN_CONSECUTIVE_HEARTBEAT_SUCCESSES_TO_RECOVER = 3;
23
24
  export function extractApiHttpStatus(error) {
24
25
  for (const candidate of [error, error?.cause]) {
25
26
  if (candidate instanceof ApiHttpError && typeof candidate.status === "number") {
@@ -72,6 +73,9 @@ export class JobSaturationError extends Error {
72
73
  this.name = "JobSaturationError";
73
74
  }
74
75
  }
76
+ export function providerAtCapacity(snapshot, provider) {
77
+ return (snapshot.runningByProvider[provider] ?? 0) >= snapshot.maxRunningPerProvider;
78
+ }
75
79
  class JobLimiter {
76
80
  cfg;
77
81
  logger;
@@ -258,6 +262,10 @@ export class AsyncJobManager {
258
262
  sweepTimer = null;
259
263
  durableAdmission;
260
264
  consecutiveHeartbeatFailures = 0;
265
+ consecutiveHeartbeatSuccesses = 0;
266
+ lastHeartbeatFailureAt = null;
267
+ lastHeartbeatRecoveryAt = null;
268
+ lastHeartbeatErrorName = null;
261
269
  skipSweepThisCycle = false;
262
270
  nextHeartbeatExpectedAt = 0;
263
271
  disposed = false;
@@ -278,25 +286,11 @@ export class AsyncJobManager {
278
286
  maxQueuedJobs: limits.maxQueuedJobs,
279
287
  queueTimeoutMs: limits.queueTimeoutMs,
280
288
  }, logger);
281
- this.durableAdmission = store !== null;
289
+ this.durableAdmission = false;
282
290
  if (this.store) {
283
- try {
284
- this.store.registerInstance({
285
- instanceId: this.instanceId,
286
- role: this.lease.role ?? "gateway",
287
- hostname: this.hostname,
288
- pid: this.instancePid,
289
- });
290
- }
291
- catch (err) {
292
- this.durableAdmission = false;
293
- this.logger.error("#139 registerInstance failed; durable async admission is disabled for this instance", err);
294
- }
295
- if (this.durableAdmission) {
296
- this.runOrphanSweep();
297
- this.startHeartbeat();
298
- this.startReaper();
299
- }
291
+ this.restoreDurableAdmission("startup");
292
+ this.startHeartbeat();
293
+ this.startReaper();
300
294
  }
301
295
  this.evictionTimer = setInterval(() => this.evictCompletedJobs(), EVICTION_INTERVAL_MS);
302
296
  if (this.evictionTimer.unref) {
@@ -310,6 +304,17 @@ export class AsyncJobManager {
310
304
  canAdmitDurableJobs() {
311
305
  return this.store !== null && this.durableAdmission;
312
306
  }
307
+ getDurableAdmissionHealth() {
308
+ return {
309
+ storeAttached: this.store !== null,
310
+ admitting: this.canAdmitDurableJobs(),
311
+ consecutiveHeartbeatFailures: this.consecutiveHeartbeatFailures,
312
+ consecutiveHeartbeatSuccesses: this.consecutiveHeartbeatSuccesses,
313
+ lastHeartbeatFailureAt: this.lastHeartbeatFailureAt,
314
+ lastHeartbeatRecoveryAt: this.lastHeartbeatRecoveryAt,
315
+ lastHeartbeatErrorName: this.lastHeartbeatErrorName,
316
+ };
317
+ }
313
318
  assertDurableAdmission(provider) {
314
319
  if (this.store && !this.durableAdmission) {
315
320
  throw new Error(`Durable async admission is disabled for ${provider}: this gateway instance could not register or lost its heartbeat lease. Retry after the gateway recovers.`);
@@ -394,13 +399,23 @@ export class AsyncJobManager {
394
399
  try {
395
400
  this.store.heartbeat(this.instanceId);
396
401
  this.consecutiveHeartbeatFailures = 0;
402
+ if (!this.durableAdmission) {
403
+ this.consecutiveHeartbeatSuccesses++;
404
+ if (this.consecutiveHeartbeatSuccesses >= MIN_CONSECUTIVE_HEARTBEAT_SUCCESSES_TO_RECOVER) {
405
+ this.restoreDurableAdmission("heartbeat recovery");
406
+ }
407
+ }
397
408
  }
398
409
  catch (err) {
399
410
  this.consecutiveHeartbeatFailures++;
411
+ this.consecutiveHeartbeatSuccesses = 0;
412
+ this.lastHeartbeatFailureAt = new Date().toISOString();
413
+ this.lastHeartbeatErrorName = err instanceof Error ? err.name : typeof err;
400
414
  this.logger.error(`#139 heartbeat failed (${this.consecutiveHeartbeatFailures}/${MAX_CONSECUTIVE_HEARTBEAT_FAILURES})`, err);
401
415
  if (this.consecutiveHeartbeatFailures >= MAX_CONSECUTIVE_HEARTBEAT_FAILURES) {
402
416
  if (this.durableAdmission) {
403
417
  this.durableAdmission = false;
418
+ this.consecutiveHeartbeatSuccesses = 0;
404
419
  this.logger.error("#139 sustained heartbeat failure; disabling durable admission and orphan sweeping on this instance");
405
420
  }
406
421
  }
@@ -412,8 +427,10 @@ export class AsyncJobManager {
412
427
  startReaper() {
413
428
  this.sweepTimer = setInterval(() => {
414
429
  this.runOrphanSweep();
430
+ if (!this.store || this.disposed || !this.durableAdmission)
431
+ return;
415
432
  try {
416
- this.store?.gcInstances(this.lease.instanceGcMs);
433
+ this.store.gcInstances(this.lease.instanceGcMs);
417
434
  }
418
435
  catch (err) {
419
436
  this.logger.error("#139 gateway_instances GC failed", err);
@@ -422,6 +439,35 @@ export class AsyncJobManager {
422
439
  if (this.sweepTimer.unref)
423
440
  this.sweepTimer.unref();
424
441
  }
442
+ restoreDurableAdmission(reason) {
443
+ if (!this.store || this.disposed)
444
+ return;
445
+ try {
446
+ this.store.registerInstance({
447
+ instanceId: this.instanceId,
448
+ role: this.lease.role ?? "gateway",
449
+ hostname: this.hostname,
450
+ pid: this.instancePid,
451
+ });
452
+ this.store.heartbeat(this.instanceId);
453
+ const wasDisabled = !this.durableAdmission;
454
+ this.durableAdmission = true;
455
+ this.consecutiveHeartbeatFailures = 0;
456
+ this.consecutiveHeartbeatSuccesses = 0;
457
+ this.lastHeartbeatRecoveryAt = new Date().toISOString();
458
+ if (wasDisabled && reason === "heartbeat recovery") {
459
+ this.logger.info("#139 durable heartbeat recovered; re-enabled async admission and orphan sweeping");
460
+ }
461
+ this.runOrphanSweep();
462
+ }
463
+ catch (err) {
464
+ this.durableAdmission = false;
465
+ this.consecutiveHeartbeatSuccesses = 0;
466
+ this.lastHeartbeatFailureAt = new Date().toISOString();
467
+ this.lastHeartbeatErrorName = err instanceof Error ? err.name : typeof err;
468
+ this.logger.error(`#139 ${reason} register/heartbeat failed; durable async admission remains disabled`, err);
469
+ }
470
+ }
425
471
  runOrphanSweep() {
426
472
  if (!this.store || this.disposed || !this.durableAdmission)
427
473
  return;
@@ -493,13 +539,13 @@ export class AsyncJobManager {
493
539
  acq.cancel();
494
540
  this.jobs.delete(job.id);
495
541
  this.logger.error(`#139 durable recordStart failed for job ${job.id}; failing the request (fail-closed)`, err);
496
- throw new Error(`Durable job admission failed for ${job.cli}: the job store rejected recordStart`);
542
+ throw new Error(`Durable job admission failed for ${job.cli}: the job store rejected recordStart`, { cause: err });
497
543
  }
498
544
  }
499
545
  markRunningDurable(job, pid, failClosed = false) {
500
546
  if (!this.store)
501
547
  return;
502
- let transitioned = false;
548
+ let transitioned;
503
549
  try {
504
550
  transitioned = this.store.markRunning(job.id, { pid });
505
551
  }
@@ -645,7 +691,7 @@ export class AsyncJobManager {
645
691
  if (evicted > 0) {
646
692
  this.logger.debug(`Evicted ${evicted} completed jobs from memory (durable store retains them)`);
647
693
  }
648
- if (this.store) {
694
+ if (this.store && this.durableAdmission) {
649
695
  try {
650
696
  const removed = this.store.evictExpired();
651
697
  if (removed > 0) {
@@ -938,6 +984,7 @@ export class AsyncJobManager {
938
984
  cacheReadTokens: usage.cacheReadTokens,
939
985
  cacheCreationTokens: usage.cacheCreationTokens,
940
986
  costUsd: usage.costUsd,
987
+ costBasis: usage.costBasis,
941
988
  providerSessionId: providerMeta?.sessionId,
942
989
  stopReason: providerMeta?.stopReason,
943
990
  });
@@ -966,6 +1013,7 @@ export class AsyncJobManager {
966
1013
  inputTokens: u.inputTokens,
967
1014
  outputTokens: u.outputTokens,
968
1015
  cacheReadTokens: u.cacheReadTokens,
1016
+ cacheCreationTokens: u.cacheCreationTokens,
969
1017
  costUsd: u.costUsd,
970
1018
  };
971
1019
  }
@@ -147,7 +147,7 @@ export function buildClaudeMcpConfig(servers) {
147
147
  }
148
148
  catch (error) {
149
149
  const message = error instanceof Error ? error.message : String(error);
150
- throw new Error(`Failed to write Claude MCP config: ${message}`);
150
+ throw new Error(`Failed to write Claude MCP config: ${message}`, { cause: error });
151
151
  }
152
152
  return { path: configPath, enabled, missing };
153
153
  }
@@ -45,7 +45,17 @@ function stripEscapes(text) {
45
45
  .replace(ESC_TWO_BYTE, "")
46
46
  .replace(STRAY_C0, "");
47
47
  }
48
- const ASCII_ONLY = /^[\x09\x0d\x20-\x7e]*$/;
48
+ function isSingleColumnAsciiFrame(text) {
49
+ for (const character of text) {
50
+ const codePoint = character.codePointAt(0);
51
+ if (codePoint !== 0x09 &&
52
+ codePoint !== 0x0d &&
53
+ (codePoint === undefined || codePoint < 0x20 || codePoint > 0x7e)) {
54
+ return false;
55
+ }
56
+ }
57
+ return true;
58
+ }
49
59
  function overlayFrames(frames) {
50
60
  const cells = [];
51
61
  for (const frame of frames) {
@@ -61,7 +71,7 @@ function collapseLine(line, out, counts) {
61
71
  out.push(line);
62
72
  return;
63
73
  }
64
- if (!ASCII_ONLY.test(body)) {
74
+ if (!isSingleColumnAsciiFrame(body)) {
65
75
  out.push(line);
66
76
  return;
67
77
  }
package/dist/config.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Logger } from "./logger.js";
2
2
  import type { RemoteOAuthConfig } from "./auth.js";
3
3
  import type { ApiProviderKind } from "./api-provider.js";
4
+ import type { QualityTier } from "./least-cost-types.js";
4
5
  export interface DatabaseConfig {
5
6
  connectionString: string;
6
7
  pool: {
@@ -119,6 +120,36 @@ export interface CompressionConfig {
119
120
  }
120
121
  export declare const DEFAULT_COMPRESSION_CONFIG: CompressionConfig;
121
122
  export declare function loadCompressionConfig(logger?: Logger): CompressionConfig;
123
+ export declare const LEAST_COST_PRIORS_SCOPES: readonly ["global", "principal", "off"];
124
+ export type LeastCostPriorsScope = (typeof LEAST_COST_PRIORS_SCOPES)[number];
125
+ export declare const DEFAULT_LEAST_COST_MAX_COST_USD = 0.5;
126
+ export declare const DEFAULT_LEAST_COST_EXPECTED_OUTPUT_TOKENS = 800;
127
+ export declare const DEFAULT_LEAST_COST_BUDGET_SAFETY_FACTOR = 1.5;
128
+ export declare const DEFAULT_LEAST_COST_MAX_REROUTES = 2;
129
+ export declare const DEFAULT_LEAST_COST_TIERS: Readonly<Record<string, QualityTier>>;
130
+ export interface LeastCostConfig {
131
+ enabled: boolean;
132
+ minTier: QualityTier;
133
+ maxCostUsd: number;
134
+ defaultExpectedOutputTokens: number;
135
+ budgetOutputSafetyFactor: number;
136
+ priorsScope: LeastCostPriorsScope;
137
+ allowUnpriced: boolean;
138
+ maxReroutes: number;
139
+ preferCatalogPrice: boolean;
140
+ preferenceOrder: string[];
141
+ tiers: Record<string, QualityTier>;
142
+ candidates: {
143
+ allow: string[];
144
+ deny: string[];
145
+ };
146
+ sources: {
147
+ configFile: string | null;
148
+ envOverrides: string[];
149
+ };
150
+ }
151
+ export declare function defaultLeastCostConfig(sourcePath?: string | null): LeastCostConfig;
152
+ export declare function loadLeastCostConfig(logger?: Logger): LeastCostConfig;
122
153
  export declare function minStableTokensForModel(config: CacheAwarenessConfig, modelName: string): number;
123
154
  export declare const DEFAULT_XAI_API_KEY_ENV = "XAI_API_KEY";
124
155
  export declare const DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1";
package/dist/config.js CHANGED
@@ -33,7 +33,7 @@ export function loadConfig() {
33
33
  DatabaseUrlSchema.parse(databaseUrl);
34
34
  }
35
35
  catch (error) {
36
- throw new Error(`Invalid database URL: ${error instanceof Error ? error.message : String(error)}`);
36
+ throw new Error(`Invalid database URL: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
37
37
  }
38
38
  return {
39
39
  database: {
@@ -87,6 +87,13 @@ const PersistenceSchema = z
87
87
  message: `httpJobGraceMs (${cfg.httpJobGraceMs}) must be >= instanceLeaseTtlMs (${cfg.instanceLeaseTtlMs})`,
88
88
  });
89
89
  }
90
+ if (cfg.instanceGcMs < cfg.instanceLeaseTtlMs) {
91
+ ctx.addIssue({
92
+ code: z.ZodIssueCode.custom,
93
+ path: ["instanceGcMs"],
94
+ message: `instanceGcMs (${cfg.instanceGcMs}) must be >= instanceLeaseTtlMs (${cfg.instanceLeaseTtlMs})`,
95
+ });
96
+ }
90
97
  });
91
98
  const DEFAULT_SQLITE_PATH = path.join(os.homedir(), ".llm-cli-gateway", "logs.db");
92
99
  function defaultPersistenceConfigPath() {
@@ -108,7 +115,9 @@ export function loadSkillsConfig(logger = noopLogger) {
108
115
  skillsParsed = SkillsSchema.parse(rawSkills);
109
116
  }
110
117
  catch (err) {
111
- throw new Error(`Invalid [skills] config: ${err instanceof Error ? err.message : String(err)}`);
118
+ throw new Error(`Invalid [skills] config: ${err instanceof Error ? err.message : String(err)}`, {
119
+ cause: err,
120
+ });
112
121
  }
113
122
  const paths = [...skillsParsed.paths];
114
123
  const envPaths = process.env.LLM_GATEWAY_SKILLS_PATH;
@@ -220,7 +229,7 @@ export function loadPersistenceConfig(logger = noopLogger) {
220
229
  parsed = PersistenceSchema.parse(merged);
221
230
  }
222
231
  catch (err) {
223
- throw new Error(`Invalid [persistence] config: ${err instanceof Error ? err.message : String(err)}`);
232
+ throw new Error(`Invalid [persistence] config: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
224
233
  }
225
234
  const backend = parsed.backend;
226
235
  const resolvedPath = backend === "sqlite" ? expandHome(parsed.path ?? DEFAULT_SQLITE_PATH) : null;
@@ -315,14 +324,16 @@ export function loadLimitsConfig(logger = noopLogger) {
315
324
  httpParsed = HttpSessionLimitsSchema.parse(rawHttp);
316
325
  }
317
326
  catch (err) {
318
- throw new Error(`Invalid [http] session-limit config: ${err instanceof Error ? err.message : String(err)}`);
327
+ throw new Error(`Invalid [http] session-limit config: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
319
328
  }
320
329
  let limitsParsed;
321
330
  try {
322
331
  limitsParsed = JobLimitsSchema.parse(rawLimits);
323
332
  }
324
333
  catch (err) {
325
- throw new Error(`Invalid [limits] config: ${err instanceof Error ? err.message : String(err)}`);
334
+ throw new Error(`Invalid [limits] config: ${err instanceof Error ? err.message : String(err)}`, {
335
+ cause: err,
336
+ });
326
337
  }
327
338
  return {
328
339
  http: {
@@ -398,7 +409,7 @@ export function loadCacheAwarenessConfig(logger = noopLogger) {
398
409
  parsed = CacheAwarenessSchema.parse(raw ?? {});
399
410
  }
400
411
  catch (err) {
401
- throw new Error(`Invalid [cache_awareness] config: ${err instanceof Error ? err.message : String(err)}`);
412
+ throw new Error(`Invalid [cache_awareness] config: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
402
413
  }
403
414
  return {
404
415
  emitAnthropicCacheControl: parsed.emit_anthropic_cache_control,
@@ -431,13 +442,137 @@ export function loadCompressionConfig(logger = noopLogger) {
431
442
  parsedCompression = CompressionSchema.parse(raw);
432
443
  }
433
444
  catch (err) {
434
- throw new Error(`Invalid [compression] config: ${err instanceof Error ? err.message : String(err)}`);
445
+ throw new Error(`Invalid [compression] config: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
435
446
  }
436
447
  return {
437
448
  enabled: parsedCompression.enabled,
438
449
  sources: { configFile: sourcePath },
439
450
  };
440
451
  }
452
+ export const LEAST_COST_PRIORS_SCOPES = ["global", "principal", "off"];
453
+ export const DEFAULT_LEAST_COST_MAX_COST_USD = 0.5;
454
+ export const DEFAULT_LEAST_COST_EXPECTED_OUTPUT_TOKENS = 800;
455
+ export const DEFAULT_LEAST_COST_BUDGET_SAFETY_FACTOR = 1.5;
456
+ export const DEFAULT_LEAST_COST_MAX_REROUTES = 2;
457
+ export const DEFAULT_LEAST_COST_TIERS = {
458
+ "claude:claude-haiku": "economy",
459
+ "claude:claude-sonnet": "standard",
460
+ "claude:claude-opus": "frontier",
461
+ "codex:openai-gpt5": "standard",
462
+ "gemini:gemini-2.5-flash": "economy",
463
+ "gemini:gemini-2.5-pro": "standard",
464
+ "gemini:gemini-3-pro": "frontier",
465
+ "grok:grok-build": "economy",
466
+ "grok:grok-4": "standard",
467
+ "mistral:mistral-devstral": "economy",
468
+ "mistral:mistral-medium": "standard",
469
+ };
470
+ const QualityTierSchema = z.enum(["economy", "standard", "frontier"]);
471
+ const LeastCostCandidatesSchema = z
472
+ .object({
473
+ allow: z.array(z.string().min(1)).default([]),
474
+ deny: z.array(z.string().min(1)).default([]),
475
+ })
476
+ .strict()
477
+ .default({ allow: [], deny: [] });
478
+ const LeastCostSchema = z
479
+ .object({
480
+ enabled: z.boolean().default(false),
481
+ min_tier: QualityTierSchema.default("standard"),
482
+ max_cost_usd: z.number().positive().default(DEFAULT_LEAST_COST_MAX_COST_USD),
483
+ default_expected_output_tokens: z
484
+ .number()
485
+ .int()
486
+ .positive()
487
+ .default(DEFAULT_LEAST_COST_EXPECTED_OUTPUT_TOKENS),
488
+ budget_output_safety_factor: z
489
+ .number()
490
+ .positive()
491
+ .default(DEFAULT_LEAST_COST_BUDGET_SAFETY_FACTOR),
492
+ priors_scope: z.enum(LEAST_COST_PRIORS_SCOPES).default("global"),
493
+ allow_unpriced: z.boolean().default(false),
494
+ max_reroutes: z.number().int().nonnegative().default(DEFAULT_LEAST_COST_MAX_REROUTES),
495
+ prefer_catalog_price: z.boolean().default(true),
496
+ preference_order: z.array(z.string().min(1)).default([]),
497
+ tiers: z.record(z.string(), QualityTierSchema).default({}),
498
+ candidates: LeastCostCandidatesSchema,
499
+ })
500
+ .strict();
501
+ export function defaultLeastCostConfig(sourcePath = null) {
502
+ return {
503
+ enabled: false,
504
+ minTier: "standard",
505
+ maxCostUsd: DEFAULT_LEAST_COST_MAX_COST_USD,
506
+ defaultExpectedOutputTokens: DEFAULT_LEAST_COST_EXPECTED_OUTPUT_TOKENS,
507
+ budgetOutputSafetyFactor: DEFAULT_LEAST_COST_BUDGET_SAFETY_FACTOR,
508
+ priorsScope: "global",
509
+ allowUnpriced: false,
510
+ maxReroutes: DEFAULT_LEAST_COST_MAX_REROUTES,
511
+ preferCatalogPrice: true,
512
+ preferenceOrder: [],
513
+ tiers: { ...DEFAULT_LEAST_COST_TIERS },
514
+ candidates: { allow: [], deny: [] },
515
+ sources: { configFile: sourcePath, envOverrides: [] },
516
+ };
517
+ }
518
+ function readLeastCostFile(configPath, logger) {
519
+ if (!existsSync(configPath)) {
520
+ return { raw: undefined, sourcePath: null };
521
+ }
522
+ try {
523
+ const require = createRequire(import.meta.url);
524
+ const TOML = require("smol-toml");
525
+ const text = readFileSync(configPath, "utf-8");
526
+ const parsed = TOML.parse(text);
527
+ return { raw: parsed?.least_cost, sourcePath: configPath };
528
+ }
529
+ catch (err) {
530
+ logger.error(`Failed to parse gateway config at ${configPath}; using least_cost defaults`, err);
531
+ return { raw: undefined, sourcePath: null };
532
+ }
533
+ }
534
+ export function loadLeastCostConfig(logger = noopLogger) {
535
+ const configPath = defaultGatewayConfigPath();
536
+ const { raw, sourcePath } = readLeastCostFile(configPath, logger);
537
+ const envOverrides = [];
538
+ const envSentinel = process.env.LLM_GATEWAY_LEAST_COST;
539
+ if (envSentinel !== undefined && envSentinel.length > 0) {
540
+ envOverrides.push("LLM_GATEWAY_LEAST_COST");
541
+ logWarn(logger, "LLM_GATEWAY_LEAST_COST is not supported and is ignored; configure [least_cost] in " +
542
+ "~/.llm-cli-gateway/config.toml (routing cannot be enabled via an environment variable).");
543
+ }
544
+ if (raw === undefined) {
545
+ const cfg = defaultLeastCostConfig(sourcePath);
546
+ cfg.sources.envOverrides = envOverrides;
547
+ return cfg;
548
+ }
549
+ let parsed;
550
+ try {
551
+ parsed = LeastCostSchema.parse(raw);
552
+ }
553
+ catch (err) {
554
+ throw new Error(`Invalid [least_cost] config: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
555
+ }
556
+ const tiers = { ...DEFAULT_LEAST_COST_TIERS, ...parsed.tiers };
557
+ return {
558
+ enabled: parsed.enabled,
559
+ minTier: parsed.min_tier,
560
+ maxCostUsd: parsed.max_cost_usd,
561
+ defaultExpectedOutputTokens: parsed.default_expected_output_tokens,
562
+ budgetOutputSafetyFactor: parsed.budget_output_safety_factor,
563
+ priorsScope: parsed.priors_scope,
564
+ allowUnpriced: parsed.allow_unpriced,
565
+ maxReroutes: parsed.max_reroutes,
566
+ preferCatalogPrice: parsed.prefer_catalog_price,
567
+ preferenceOrder: [...parsed.preference_order],
568
+ tiers,
569
+ candidates: {
570
+ allow: [...parsed.candidates.allow],
571
+ deny: [...parsed.candidates.deny],
572
+ },
573
+ sources: { configFile: sourcePath, envOverrides },
574
+ };
575
+ }
441
576
  export function minStableTokensForModel(config, modelName) {
442
577
  const lower = modelName.toLowerCase();
443
578
  const table = config.minStableTokensForCacheControl;
package/dist/db.js CHANGED
@@ -28,7 +28,7 @@ export class DatabaseConnection {
28
28
  }
29
29
  catch (error) {
30
30
  this.logger.error("Failed to connect to PostgreSQL", { error });
31
- throw new Error(`Failed to connect to PostgreSQL: ${error instanceof Error ? error.message : String(error)}`);
31
+ throw new Error(`Failed to connect to PostgreSQL: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
32
32
  }
33
33
  }
34
34
  async disconnect() {
@@ -40,7 +40,7 @@ export class DatabaseConnection {
40
40
  this.pool = null;
41
41
  }
42
42
  catch (error) {
43
- errors.push(new Error(`PostgreSQL disconnect error: ${error instanceof Error ? error.message : String(error)}`));
43
+ errors.push(new Error(`PostgreSQL disconnect error: ${error instanceof Error ? error.message : String(error)}`, { cause: error }));
44
44
  }
45
45
  }
46
46
  if (errors.length > 0) {
@@ -60,7 +60,7 @@ export class DatabaseConnection {
60
60
  result.postgres.connected = true;
61
61
  result.postgres.latency = Date.now() - pgStart;
62
62
  }
63
- catch (error) {
63
+ catch {
64
64
  result.postgres.connected = false;
65
65
  }
66
66
  finally {
@@ -87,7 +87,7 @@ async function importOptionalPg() {
87
87
  }
88
88
  catch (error) {
89
89
  if (error?.code === "ERR_MODULE_NOT_FOUND" || error?.code === "MODULE_NOT_FOUND") {
90
- throw new Error("PostgreSQL sessions require optional peer dependency 'pg'. Install it alongside llm-cli-gateway to use DATABASE_URL-backed sessions.");
90
+ throw new Error("PostgreSQL sessions require optional peer dependency 'pg'. Install it alongside llm-cli-gateway to use DATABASE_URL-backed sessions.", { cause: error });
91
91
  }
92
92
  throw error;
93
93
  }
package/dist/doctor.d.ts CHANGED
@@ -2,7 +2,9 @@ import { type EndpointExposureReport } from "./endpoint-exposure.js";
2
2
  import { type ProviderLoginStatus } from "./provider-status.js";
3
3
  import { type ApiProviderLoginGuidance } from "./provider-login-guidance.js";
4
4
  import type { FlightRecorderQuery } from "./flight-recorder.js";
5
- import { type CacheAwarenessConfig, type ProvidersConfig, type RemoteOAuthConfigDiagnostics } from "./config.js";
5
+ import { type CacheAwarenessConfig, type LeastCostConfig, type ProvidersConfig, type RemoteOAuthConfigDiagnostics } from "./config.js";
6
+ import { type TelemetryTier } from "./lcr-telemetry.js";
7
+ import type { Confidence, PriceSource, QualityTier } from "./least-cost-types.js";
6
8
  import type { AuthConfig } from "./auth.js";
7
9
  import { type RemoteSafeWorkspaceSummary } from "./workspace-registry.js";
8
10
  import { type ProviderCapabilityId, type ProviderKind } from "./provider-tool-capabilities.js";
@@ -79,6 +81,35 @@ export interface GeminiConfigStatus {
79
81
  }
80
82
  export declare function checkVibeSessionLogging(home?: string): VibeSessionLoggingStatus;
81
83
  export declare function checkGeminiConfig(cwd?: string, home?: string, whitelist?: readonly string[]): GeminiConfigStatus;
84
+ export interface LeastCostCandidateReport {
85
+ model: string;
86
+ family: string;
87
+ priced: boolean;
88
+ priceSource: PriceSource;
89
+ tier: QualityTier | null;
90
+ }
91
+ export interface LeastCostProviderReport {
92
+ provider: CliType;
93
+ telemetryTier: TelemetryTier;
94
+ candidates: LeastCostCandidateReport[];
95
+ }
96
+ export interface LeastCostCalibrationBucketReport {
97
+ bucket: string;
98
+ k: number;
99
+ samples: number;
100
+ confidence: Confidence;
101
+ }
102
+ export interface LeastCostReport {
103
+ enabled: boolean;
104
+ pricing: {
105
+ tableAsOf: string;
106
+ apiCatalogAsOf: string;
107
+ staleDays: number;
108
+ };
109
+ providers: LeastCostProviderReport[];
110
+ untieredModels: string[];
111
+ calibrationQuality: LeastCostCalibrationBucketReport[];
112
+ }
82
113
  export interface DoctorReport {
83
114
  schema_version: "1.0";
84
115
  ok: boolean;
@@ -158,6 +189,7 @@ export interface DoctorReport {
158
189
  };
159
190
  cache_awareness: CacheAwarenessReport;
160
191
  provider_capabilities: ProviderCapabilitySummaryReport;
192
+ least_cost: LeastCostReport;
161
193
  api_providers?: ApiProviderHealthReport;
162
194
  upstream: {
163
195
  note: string;
@@ -215,6 +247,7 @@ export interface CreateDoctorReportOptions {
215
247
  reachable: boolean;
216
248
  error?: string;
217
249
  }>;
250
+ leastCost?: LeastCostConfig;
218
251
  }
219
252
  export declare function createDoctorReport(envOrOptions?: NodeJS.ProcessEnv | CreateDoctorReportOptions): DoctorReport;
220
253
  export declare function printDoctorJson(opts?: {