llm-cli-gateway 2.16.0 → 2.17.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.
Files changed (61) hide show
  1. package/.agents/skills/least-cost-routing/SKILL.md +123 -0
  2. package/CHANGELOG.md +28 -0
  3. package/dist/acp/client.js +5 -0
  4. package/dist/acp/flight-redaction.d.ts +2 -0
  5. package/dist/acp/flight-redaction.js +2 -0
  6. package/dist/acp/runtime.d.ts +1 -0
  7. package/dist/acp/runtime.js +20 -0
  8. package/dist/acp/types.d.ts +52 -0
  9. package/dist/acp/types.js +8 -0
  10. package/dist/api-provider.d.ts +1 -0
  11. package/dist/api-provider.js +1 -0
  12. package/dist/async-job-manager.d.ts +16 -0
  13. package/dist/async-job-manager.js +70 -22
  14. package/dist/claude-mcp-config.js +1 -1
  15. package/dist/compressor/transforms/ansi.js +12 -2
  16. package/dist/config.d.ts +31 -0
  17. package/dist/config.js +142 -7
  18. package/dist/db.js +4 -4
  19. package/dist/doctor.d.ts +34 -1
  20. package/dist/doctor.js +85 -3
  21. package/dist/executor.d.ts +5 -0
  22. package/dist/executor.js +11 -1
  23. package/dist/flight-recorder.d.ts +10 -0
  24. package/dist/flight-recorder.js +68 -2
  25. package/dist/http-transport.js +19 -21
  26. package/dist/index.d.ts +41 -1
  27. package/dist/index.js +658 -30
  28. package/dist/job-store.d.ts +10 -2
  29. package/dist/job-store.js +154 -43
  30. package/dist/lcr-priors.d.ts +60 -0
  31. package/dist/lcr-priors.js +190 -0
  32. package/dist/lcr-router-env.d.ts +20 -0
  33. package/dist/lcr-router-env.js +133 -0
  34. package/dist/lcr-telemetry.d.ts +2 -0
  35. package/dist/lcr-telemetry.js +17 -0
  36. package/dist/least-cost-router.d.ts +86 -0
  37. package/dist/least-cost-router.js +296 -0
  38. package/dist/least-cost-types.d.ts +34 -0
  39. package/dist/least-cost-types.js +1 -0
  40. package/dist/migrate-sessions.js +1 -1
  41. package/dist/migrate.js +1 -1
  42. package/dist/model-registry.js +1 -1
  43. package/dist/postgres-job-store-worker.js +56 -13
  44. package/dist/pricing.d.ts +17 -0
  45. package/dist/pricing.js +167 -0
  46. package/dist/provider-definitions.d.ts +4 -0
  47. package/dist/provider-definitions.js +28 -0
  48. package/dist/request-helpers.d.ts +2 -2
  49. package/dist/resources.d.ts +37 -2
  50. package/dist/resources.js +96 -1
  51. package/dist/retry.d.ts +1 -0
  52. package/dist/retry.js +1 -1
  53. package/dist/token-estimator.d.ts +6 -0
  54. package/dist/token-estimator.js +59 -0
  55. package/dist/upstream-contracts.js +1 -1
  56. package/dist/validation-receipt.js +1 -1
  57. package/dist/validation-tools.d.ts +6 -0
  58. package/dist/validation-tools.js +174 -54
  59. package/npm-shrinkwrap.json +2 -2
  60. package/package.json +9 -4
  61. package/setup/status.schema.json +68 -0
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?: {
package/dist/doctor.js CHANGED
@@ -7,7 +7,11 @@ import { createEndpointExposureReport, redactDiagnosticUrl, } from "./endpoint-e
7
7
  import { getApiProviderStatus, listProviderRuntimeStatuses, } from "./provider-status.js";
8
8
  import { getApiProviderLoginGuidance, } from "./provider-login-guidance.js";
9
9
  import { CLAUDE_MCP_SERVER_NAMES } from "./claude-mcp-config.js";
10
- import { diagnoseRemoteOAuthConfig, enabledApiProviders, loadCacheAwarenessConfig, loadProvidersConfig, } from "./config.js";
10
+ import { defaultLeastCostConfig, diagnoseRemoteOAuthConfig, enabledApiProviders, loadCacheAwarenessConfig, loadLeastCostConfig, loadProvidersConfig, } from "./config.js";
11
+ import { telemetryTierFor } from "./lcr-telemetry.js";
12
+ import { API_CATALOG_AS_OF, PRICING_AS_OF, getModelCost, modelIdToFamily } from "./pricing.js";
13
+ import { computeLcrPriorsFromDb } from "./lcr-priors.js";
14
+ import { getCliInfo } from "./model-registry.js";
11
15
  import { loadWorkspaceRegistry, remoteSafeWorkspaceSummary, } from "./workspace-registry.js";
12
16
  import { buildRemoteConnectorUrls, resolveConfiguredRemoteOrigin } from "./remote-url.js";
13
17
  import { computeGlobalCacheStats } from "./cache-stats.js";
@@ -58,7 +62,7 @@ export function checkVibeSessionLogging(home = homedir()) {
58
62
  function parseVibeSessionLoggingEnabled(text) {
59
63
  const lines = text.split(/\r?\n/);
60
64
  let inSection = false;
61
- for (let raw of lines) {
65
+ for (const raw of lines) {
62
66
  const line = raw.replace(/#.*$/, "").trim();
63
67
  if (!line)
64
68
  continue;
@@ -426,6 +430,70 @@ function buildCacheAwarenessReport(opts) {
426
430
  per_cli: perCli,
427
431
  };
428
432
  }
433
+ const MS_PER_DAY = 86_400_000;
434
+ function computePricingStaleDays(runIso) {
435
+ const run = Date.parse(runIso);
436
+ const table = Date.parse(PRICING_AS_OF);
437
+ const catalog = Date.parse(API_CATALOG_AS_OF);
438
+ const newer = Math.max(table, catalog);
439
+ if (!Number.isFinite(run) || !Number.isFinite(newer))
440
+ return 0;
441
+ const days = Math.floor((run - newer) / MS_PER_DAY);
442
+ return days > 0 ? days : 0;
443
+ }
444
+ function buildLeastCostReport(cfg, generatedAt, flightRecorder) {
445
+ const pricing = {
446
+ tableAsOf: PRICING_AS_OF,
447
+ apiCatalogAsOf: API_CATALOG_AS_OF,
448
+ staleDays: computePricingStaleDays(generatedAt),
449
+ };
450
+ if (!cfg.enabled) {
451
+ return { enabled: false, pricing, providers: [], untieredModels: [], calibrationQuality: [] };
452
+ }
453
+ const tierMap = new Map(Object.entries(cfg.tiers));
454
+ const cliInfo = getCliInfo();
455
+ const providers = [];
456
+ const untieredModels = [];
457
+ for (const provider of CLI_TYPES) {
458
+ const candidates = Object.keys(cliInfo[provider].models).map(model => {
459
+ const family = modelIdToFamily(model);
460
+ const cost = getModelCost(provider, model, { preferCatalog: cfg.preferCatalogPrice });
461
+ const tier = tierMap.get(`${provider}:${family}`) ?? null;
462
+ if (tier === null) {
463
+ untieredModels.push(`${provider}:${model}`);
464
+ }
465
+ return {
466
+ model,
467
+ family,
468
+ priced: cost.source !== "unknown",
469
+ priceSource: cost.source,
470
+ tier,
471
+ };
472
+ });
473
+ providers.push({
474
+ provider,
475
+ telemetryTier: telemetryTierFor(provider),
476
+ candidates,
477
+ });
478
+ }
479
+ const calibrationQuality = [];
480
+ if (flightRecorder && cfg.priorsScope !== "off") {
481
+ try {
482
+ const priors = computeLcrPriorsFromDb(flightRecorder, { priorsScope: cfg.priorsScope });
483
+ for (const [bucket, entry] of priors.calibration) {
484
+ calibrationQuality.push({
485
+ bucket,
486
+ k: entry.k,
487
+ samples: entry.samples,
488
+ confidence: entry.confidence,
489
+ });
490
+ }
491
+ }
492
+ catch {
493
+ }
494
+ }
495
+ return { enabled: true, pricing, providers, untieredModels, calibrationQuality };
496
+ }
429
497
  function buildProviderCapabilitySummary(providerStatuses) {
430
498
  const capabilities = getProviderToolCapabilities({
431
499
  includeSkills: true,
@@ -502,10 +570,12 @@ export function createDoctorReport(envOrOptions = process.env) {
502
570
  if (probeReport) {
503
571
  upstream.probe_report = probeReport;
504
572
  }
573
+ const generatedAt = new Date().toISOString();
574
+ const leastCostConfig = opts.leastCost ?? defaultLeastCostConfig();
505
575
  const report = {
506
576
  schema_version: "1.0",
507
577
  ok: true,
508
- generated_at: new Date().toISOString(),
578
+ generated_at: generatedAt,
509
579
  system: {
510
580
  os: platform(),
511
581
  arch: arch(),
@@ -564,6 +634,7 @@ export function createDoctorReport(envOrOptions = process.env) {
564
634
  client_config: clientConfigStatus(),
565
635
  cache_awareness: buildCacheAwarenessReport(opts),
566
636
  provider_capabilities: buildProviderCapabilitySummary(providerStatuses),
637
+ least_cost: buildLeastCostReport(leastCostConfig, generatedAt, opts.flightRecorder),
567
638
  upstream,
568
639
  next_actions: [],
569
640
  };
@@ -613,11 +684,17 @@ export async function printDoctorJson(opts = {}) {
613
684
  let cacheAwareness;
614
685
  let flightRecorder;
615
686
  let providersConfig;
687
+ let leastCost;
616
688
  try {
617
689
  cacheAwareness = loadCacheAwarenessConfig();
618
690
  }
619
691
  catch {
620
692
  }
693
+ try {
694
+ leastCost = loadLeastCostConfig();
695
+ }
696
+ catch {
697
+ }
621
698
  try {
622
699
  const dbPath = resolveFlightRecorderDbPath();
623
700
  if (dbPath)
@@ -644,6 +721,7 @@ export async function printDoctorJson(opts = {}) {
644
721
  probeUpstream: opts.probeUpstream,
645
722
  providersConfig,
646
723
  apiReachability,
724
+ leastCost,
647
725
  });
648
726
  process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
649
727
  if (flightRecorder) {
@@ -685,6 +763,10 @@ function isCreateDoctorReportOptions(value) {
685
763
  const candidate = value.flightRecorder;
686
764
  return candidate === undefined || typeof candidate === "object";
687
765
  }
766
+ if (Object.prototype.hasOwnProperty.call(value, "leastCost")) {
767
+ const candidate = value.leastCost;
768
+ return candidate === undefined || typeof candidate === "object";
769
+ }
688
770
  if (Object.prototype.hasOwnProperty.call(value, "env")) {
689
771
  const candidate = value.env;
690
772
  return candidate === undefined || typeof candidate === "object";
@@ -1,5 +1,7 @@
1
1
  import { ChildProcess, type SpawnOptions } from "child_process";
2
+ import { CircuitBreakerState } from "./retry.js";
2
3
  import type { Logger } from "./logger.js";
4
+ import type { CliType } from "./provider-types.js";
3
5
  export interface ExecuteOptions {
4
6
  timeout?: number;
5
7
  idleTimeout?: number;
@@ -14,6 +16,9 @@ export interface ExecuteResult {
14
16
  code: number;
15
17
  }
16
18
  export declare function providerCommandName(command: string): string;
19
+ export declare function cliBreakerState(cli: CliType): CircuitBreakerState;
20
+ export declare function resetCliBreakersForTest(): void;
21
+ export declare function primeCliBreakerStateForTest(command: string, state: CircuitBreakerState): void;
17
22
  export declare function buildExtendedPath(env?: NodeJS.ProcessEnv, home?: string, nodePath?: string, platform?: NodeJS.Platform): string;
18
23
  export declare function getExtendedPath(): string;
19
24
  export declare function envWithExtendedPath(baseEnv?: NodeJS.ProcessEnv, extendedPath?: string, platform?: NodeJS.Platform): NodeJS.ProcessEnv;
package/dist/executor.js CHANGED
@@ -2,7 +2,7 @@ import { spawn, spawnSync } from "child_process";
2
2
  import { homedir } from "os";
3
3
  import { delimiter, join, dirname, extname, win32 } from "path";
4
4
  import { readdirSync, existsSync } from "fs";
5
- import { createCircuitBreaker, withRetry } from "./retry.js";
5
+ import { createCircuitBreaker, withRetry, CircuitBreakerState } from "./retry.js";
6
6
  import { applySpawnEnvIsolation } from "./spawn-env-isolation.js";
7
7
  export function providerCommandName(command) {
8
8
  if (command === "gemini")
@@ -25,6 +25,16 @@ function getCircuitBreaker(command) {
25
25
  circuitBreakers.set(command, circuitBreaker);
26
26
  return circuitBreaker;
27
27
  }
28
+ export function cliBreakerState(cli) {
29
+ const command = providerCommandName(cli);
30
+ return circuitBreakers.get(command)?.state ?? CircuitBreakerState.CLOSED;
31
+ }
32
+ export function resetCliBreakersForTest() {
33
+ circuitBreakers.clear();
34
+ }
35
+ export function primeCliBreakerStateForTest(command, state) {
36
+ getCircuitBreaker(command).state = state;
37
+ }
28
38
  function getNvmPath() {
29
39
  if (cachedNvmPath !== undefined) {
30
40
  return cachedNvmPath;
@@ -23,6 +23,7 @@ export interface FlightLogResult {
23
23
  retryCount: number;
24
24
  circuitBreakerState: string;
25
25
  costUsd?: number;
26
+ costBasis?: string;
26
27
  approvalDecision?: string;
27
28
  optimizationApplied: boolean;
28
29
  thinkingBlocks?: string[];
@@ -44,6 +45,13 @@ export interface CompressionTelemetry {
44
45
  compressedChars: number;
45
46
  estimatedTokensSaved: number;
46
47
  }
48
+ export interface RoutingRecord {
49
+ estCostUsd?: number | null;
50
+ estConfidence?: string | null;
51
+ reason?: string | null;
52
+ considered?: number | null;
53
+ reroutes?: number | null;
54
+ }
47
55
  export declare function resolveFlightRecorderDbPath(): string | null;
48
56
  export declare class FlightRecorder {
49
57
  private db;
@@ -59,6 +67,7 @@ export declare class FlightRecorder {
59
67
  logStart(entry: FlightLogStart): void;
60
68
  logComplete(correlationId: string, result: FlightLogResult): void;
61
69
  recordCompressionTelemetry(correlationId: string, telemetry: CompressionTelemetry): void;
70
+ recordRouting(correlationId: string, routing: RoutingRecord): void;
62
71
  private redactStart;
63
72
  private redactResult;
64
73
  queryRequests<T = Record<string, unknown>>(sql: string, ...params: unknown[]): T[];
@@ -69,6 +78,7 @@ export declare class NoopFlightRecorder {
69
78
  logStart(_entry: FlightLogStart): void;
70
79
  logComplete(_correlationId: string, _result: FlightLogResult): void;
71
80
  recordCompressionTelemetry(_correlationId: string, _telemetry: CompressionTelemetry): void;
81
+ recordRouting(_correlationId: string, _routing: RoutingRecord): void;
72
82
  queryRequests<T = Record<string, unknown>>(_sql: string, ..._params: unknown[]): T[];
73
83
  flush(): void;
74
84
  close(): void;