llm-cli-gateway 2.15.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 (80) hide show
  1. package/.agents/skills/least-cost-routing/SKILL.md +123 -0
  2. package/CHANGELOG.md +78 -1
  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/process-manager.js +2 -1
  7. package/dist/acp/provider-registry.js +8 -8
  8. package/dist/acp/runtime.d.ts +1 -0
  9. package/dist/acp/runtime.js +20 -0
  10. package/dist/acp/types.d.ts +52 -0
  11. package/dist/acp/types.js +8 -0
  12. package/dist/api-provider.d.ts +1 -0
  13. package/dist/api-provider.js +1 -0
  14. package/dist/async-job-manager.d.ts +20 -1
  15. package/dist/async-job-manager.js +85 -28
  16. package/dist/claude-mcp-config.js +1 -1
  17. package/dist/compressor/estimate.d.ts +5 -0
  18. package/dist/compressor/estimate.js +21 -0
  19. package/dist/compressor/index.d.ts +23 -0
  20. package/dist/compressor/index.js +77 -0
  21. package/dist/compressor/router.d.ts +2 -0
  22. package/dist/compressor/router.js +57 -0
  23. package/dist/compressor/transforms/ansi.d.ts +3 -0
  24. package/dist/compressor/transforms/ansi.js +99 -0
  25. package/dist/compressor/transforms/json.d.ts +1 -0
  26. package/dist/compressor/transforms/json.js +156 -0
  27. package/dist/compressor/transforms/log.d.ts +12 -0
  28. package/dist/compressor/transforms/log.js +55 -0
  29. package/dist/compressor/transforms/whitespace.d.ts +8 -0
  30. package/dist/compressor/transforms/whitespace.js +79 -0
  31. package/dist/config.d.ts +39 -0
  32. package/dist/config.js +166 -6
  33. package/dist/db.js +4 -4
  34. package/dist/doctor.d.ts +34 -1
  35. package/dist/doctor.js +85 -3
  36. package/dist/executor.d.ts +6 -0
  37. package/dist/executor.js +16 -3
  38. package/dist/flight-recorder.d.ts +19 -0
  39. package/dist/flight-recorder.js +110 -2
  40. package/dist/http-transport.js +19 -21
  41. package/dist/index.d.ts +58 -2
  42. package/dist/index.js +929 -94
  43. package/dist/job-store.d.ts +14 -2
  44. package/dist/job-store.js +170 -43
  45. package/dist/lcr-priors.d.ts +60 -0
  46. package/dist/lcr-priors.js +190 -0
  47. package/dist/lcr-router-env.d.ts +20 -0
  48. package/dist/lcr-router-env.js +133 -0
  49. package/dist/lcr-telemetry.d.ts +2 -0
  50. package/dist/lcr-telemetry.js +17 -0
  51. package/dist/least-cost-router.d.ts +86 -0
  52. package/dist/least-cost-router.js +296 -0
  53. package/dist/least-cost-types.d.ts +34 -0
  54. package/dist/least-cost-types.js +1 -0
  55. package/dist/migrate-sessions.js +1 -1
  56. package/dist/migrate.js +1 -1
  57. package/dist/model-registry.js +1 -1
  58. package/dist/postgres-job-store-worker.js +56 -13
  59. package/dist/pricing.d.ts +17 -0
  60. package/dist/pricing.js +167 -0
  61. package/dist/provider-definitions.d.ts +5 -0
  62. package/dist/provider-definitions.js +56 -10
  63. package/dist/provider-tool-capabilities.js +3 -3
  64. package/dist/request-helpers.d.ts +2 -2
  65. package/dist/request-helpers.js +1 -1
  66. package/dist/resources.d.ts +37 -2
  67. package/dist/resources.js +96 -1
  68. package/dist/retry.d.ts +1 -0
  69. package/dist/retry.js +1 -1
  70. package/dist/spawn-env-isolation.d.ts +10 -0
  71. package/dist/spawn-env-isolation.js +55 -0
  72. package/dist/token-estimator.d.ts +6 -0
  73. package/dist/token-estimator.js +59 -0
  74. package/dist/upstream-contracts.js +48 -28
  75. package/dist/validation-receipt.js +1 -1
  76. package/dist/validation-tools.d.ts +6 -0
  77. package/dist/validation-tools.js +174 -54
  78. package/npm-shrinkwrap.json +2 -2
  79. package/package.json +15 -6
  80. package/setup/status.schema.json +68 -0
@@ -10,6 +10,7 @@ export interface JobRecord {
10
10
  cli: string;
11
11
  argsJson: string;
12
12
  outputFormat?: string | null;
13
+ compressResponse?: boolean | null;
13
14
  status: JobStoreStatus;
14
15
  exitCode: number | null;
15
16
  stdout: string;
@@ -52,6 +53,7 @@ export interface JobStore {
52
53
  cli: string;
53
54
  args: string[];
54
55
  outputFormat?: string;
56
+ compressResponse?: boolean;
55
57
  startedAt: string;
56
58
  pid: number | null;
57
59
  ownerPrincipal?: string | null;
@@ -173,6 +175,7 @@ export declare class SqliteJobStore implements JobStore, ValidationRunStore {
173
175
  cli: string;
174
176
  args: string[];
175
177
  outputFormat?: string;
178
+ compressResponse?: boolean;
176
179
  startedAt: string;
177
180
  pid: number | null;
178
181
  ownerPrincipal?: string | null;
@@ -236,6 +239,7 @@ export declare class MemoryJobStore implements JobStore {
236
239
  cli: string;
237
240
  args: string[];
238
241
  outputFormat?: string;
242
+ compressResponse?: boolean;
239
243
  startedAt: string;
240
244
  pid: number | null;
241
245
  ownerPrincipal?: string | null;
@@ -276,15 +280,23 @@ export declare class MemoryJobStore implements JobStore {
276
280
  export declare class PostgresJobStore implements JobStore, ValidationRunStore {
277
281
  private logger;
278
282
  private worker;
279
- private tmpDir;
280
- private nextRequestId;
283
+ private retiringWorker;
284
+ private readonly intentionallyRetiredWorkers;
285
+ private workerTerminationPending;
281
286
  private closed;
287
+ private readonly workerData;
282
288
  constructor(dsn: string, logger?: Logger, options?: {
283
289
  retentionMs?: number;
284
290
  dedupWindowMs?: number;
285
291
  leaseTtlMs?: number;
286
292
  });
293
+ private createWorker;
287
294
  private syncCall;
295
+ private ensureWorker;
296
+ private callWorker;
297
+ private retireAfterBridgeFailure;
298
+ private retireWorker;
299
+ private markWorkerRetired;
288
300
  recordStart(input: {
289
301
  id: string;
290
302
  correlationId: string;
package/dist/job-store.js CHANGED
@@ -1,8 +1,8 @@
1
- import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync } from "fs";
1
+ import { chmodSync, existsSync } from "fs";
2
2
  import os from "os";
3
3
  import path from "path";
4
4
  import { createHash } from "crypto";
5
- import { Worker } from "worker_threads";
5
+ import { MessageChannel, receiveMessageOnPort, Worker } from "worker_threads";
6
6
  import { fileURLToPath } from "url";
7
7
  import { openDatabase } from "./sqlite-driver.js";
8
8
  import { noopLogger } from "./logger.js";
@@ -30,6 +30,8 @@ export function resolveJobRetentionMs() {
30
30
  return days * 24 * 60 * 60 * 1000;
31
31
  }
32
32
  const DEFAULT_DEDUP_WINDOW_MS = 60 * 60 * 1000;
33
+ const POSTGRES_WORKER_OPERATION_TIMEOUT_MS = 35_000;
34
+ const POSTGRES_WORKER_INITIALIZATION_TIMEOUT_MS = 45_000;
33
35
  export function resolveDedupWindowMs() {
34
36
  const raw = process.env.LLM_GATEWAY_DEDUP_WINDOW_MS;
35
37
  if (raw === undefined)
@@ -51,6 +53,9 @@ function rowToRecord(row) {
51
53
  cli: row.cli,
52
54
  argsJson: row.args_json,
53
55
  outputFormat: row.output_format ?? null,
56
+ compressResponse: row.compress_response === null || row.compress_response === undefined
57
+ ? null
58
+ : Boolean(row.compress_response),
54
59
  status: row.status,
55
60
  exitCode: row.exit_code,
56
61
  stdout: row.stdout ?? "",
@@ -99,6 +104,13 @@ function ensureJobsLeaseColumns(db) {
99
104
  db.exec("ALTER TABLE jobs ADD COLUMN lease_deadline INTEGER");
100
105
  }
101
106
  }
107
+ function ensureJobsCompressResponseColumn(db) {
108
+ const cols = db.prepare("PRAGMA table_info(jobs)").all();
109
+ const names = new Set(cols.map(col => col?.name));
110
+ if (!names.has("compress_response")) {
111
+ db.exec("ALTER TABLE jobs ADD COLUMN compress_response INTEGER");
112
+ }
113
+ }
102
114
  export function isValidationRunStore(store) {
103
115
  return (typeof store === "object" &&
104
116
  store !== null &&
@@ -143,6 +155,7 @@ export class SqliteJobStore {
143
155
  cli TEXT NOT NULL,
144
156
  args_json TEXT NOT NULL,
145
157
  output_format TEXT,
158
+ compress_response INTEGER,
146
159
  status TEXT NOT NULL,
147
160
  exit_code INTEGER,
148
161
  stdout TEXT,
@@ -216,6 +229,7 @@ export class SqliteJobStore {
216
229
  ensureJobsTransportColumns(this.db);
217
230
  ensureJobsLeaseColumns(this.db);
218
231
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_jobs_owner_status ON jobs(owner_instance, status)");
232
+ ensureJobsCompressResponseColumn(this.db);
219
233
  if (process.platform !== "win32") {
220
234
  try {
221
235
  chmodSync(dbPath, 0o600);
@@ -228,10 +242,12 @@ export class SqliteJobStore {
228
242
  this.leaseTtlMs = options.leaseTtlMs ?? DEFAULT_INSTANCE_LEASE_TTL_MS;
229
243
  this.insertStmt = this.db.prepare(`
230
244
  INSERT INTO jobs (id, correlation_id, request_key, cli, args_json, output_format,
245
+ compress_response,
231
246
  status, exit_code, stdout, stderr, output_truncated, error,
232
247
  started_at, finished_at, pid, expires_at, owner_principal,
233
248
  transport, http_status, payload_json, owner_instance, lease_deadline)
234
249
  VALUES (@id, @correlation_id, @request_key, @cli, @args_json, @output_format,
250
+ @compress_response,
235
251
  'queued', @exit_code, @stdout, @stderr, @output_truncated, @error,
236
252
  @started_at, @finished_at, @pid, @expires_at, @owner_principal,
237
253
  @transport, @http_status, @payload_json, @owner_instance,
@@ -332,6 +348,7 @@ export class SqliteJobStore {
332
348
  cli: input.cli,
333
349
  args_json: JSON.stringify(input.args),
334
350
  output_format: input.outputFormat ?? null,
351
+ compress_response: input.compressResponse === undefined ? null : input.compressResponse ? 1 : 0,
335
352
  exit_code: null,
336
353
  stdout: "",
337
354
  stderr: "",
@@ -624,6 +641,7 @@ export class MemoryJobStore {
624
641
  cli: input.cli,
625
642
  argsJson: JSON.stringify(input.args),
626
643
  outputFormat: input.outputFormat ?? null,
644
+ compressResponse: input.compressResponse ?? null,
627
645
  status: "queued",
628
646
  exitCode: null,
629
647
  stdout: "",
@@ -741,66 +759,153 @@ export class MemoryJobStore {
741
759
  }
742
760
  export class PostgresJobStore {
743
761
  logger;
744
- worker;
745
- tmpDir;
746
- nextRequestId = 0;
762
+ worker = null;
763
+ retiringWorker = null;
764
+ intentionallyRetiredWorkers = new WeakSet();
765
+ workerTerminationPending = false;
747
766
  closed = false;
767
+ workerData;
748
768
  constructor(dsn, logger = noopLogger, options = {}) {
749
769
  this.logger = logger;
750
770
  if (!dsn) {
751
771
  throw new Error("PostgresJobStore requires a non-empty DSN");
752
772
  }
753
- this.tmpDir = mkdtempSync(path.join(os.tmpdir(), "llm-gateway-pg-job-store-"));
754
- this.worker = new Worker(resolvePostgresWorkerUrl(), {
755
- execArgv: [],
756
- workerData: {
757
- dsn,
758
- retentionMs: options.retentionMs ?? resolveJobRetentionMs(),
759
- dedupWindowMs: options.dedupWindowMs ?? resolveDedupWindowMs(),
760
- leaseTtlMs: options.leaseTtlMs ?? DEFAULT_INSTANCE_LEASE_TTL_MS,
761
- farFutureIso: FAR_FUTURE_ISO,
762
- connectionTimeoutMillis: 5000,
763
- },
764
- });
773
+ this.workerData = {
774
+ dsn,
775
+ retentionMs: options.retentionMs ?? resolveJobRetentionMs(),
776
+ dedupWindowMs: options.dedupWindowMs ?? resolveDedupWindowMs(),
777
+ leaseTtlMs: options.leaseTtlMs ?? DEFAULT_INSTANCE_LEASE_TTL_MS,
778
+ farFutureIso: FAR_FUTURE_ISO,
779
+ connectionTimeoutMillis: 5000,
780
+ };
765
781
  try {
766
- this.syncCall("init");
782
+ this.ensureWorker();
767
783
  }
768
784
  catch (err) {
769
785
  this.closed = true;
770
- void this.worker.terminate();
771
- rmSync(this.tmpDir, { recursive: true, force: true });
786
+ this.retireWorker(this.worker);
772
787
  throw err;
773
788
  }
774
789
  }
790
+ createWorker() {
791
+ const worker = new Worker(resolvePostgresWorkerUrl(), {
792
+ execArgv: [],
793
+ workerData: this.workerData,
794
+ });
795
+ worker.on("message", message => {
796
+ if (!isPostgresWorkerDiagnostic(message))
797
+ return;
798
+ const error = new Error(message.message);
799
+ if (message.stack)
800
+ error.stack = message.stack;
801
+ this.logger.error(`PostgresJobStore worker ${message.kind}`, error);
802
+ });
803
+ worker.on("error", error => {
804
+ this.logger.error("PostgresJobStore worker crashed", error);
805
+ if (!this.closed && this.worker === worker)
806
+ this.retireWorker(worker);
807
+ });
808
+ worker.on("exit", code => {
809
+ const wasRetiring = this.retiringWorker === worker || this.intentionallyRetiredWorkers.has(worker);
810
+ if (this.worker === worker)
811
+ this.worker = null;
812
+ if (wasRetiring)
813
+ this.markWorkerRetired(worker);
814
+ if (this.closed || wasRetiring || code === 0)
815
+ return;
816
+ this.logger.error("PostgresJobStore worker exited unexpectedly", new Error(`PostgresJobStore worker exited with code ${code}`));
817
+ });
818
+ return worker;
819
+ }
775
820
  syncCall(method, ...args) {
776
- if (this.closed && method !== "close") {
821
+ if (this.closed) {
777
822
  throw new Error("PostgresJobStore is closed");
778
823
  }
779
- const shared = new SharedArrayBuffer(4);
780
- const state = new Int32Array(shared);
781
- const resultPath = path.join(this.tmpDir, `result-${process.pid}-${++this.nextRequestId}.json`);
782
- this.worker.postMessage({ method, args, resultPath, shared });
783
- const wait = Atomics.wait(state, 0, 0, method === "init" ? 10_000 : 30_000);
784
- if (wait !== "ok" && wait !== "not-equal") {
785
- throw new Error(`PostgresJobStore ${method} wait failed: ${wait}`);
824
+ const worker = this.ensureWorker();
825
+ return this.callWorker(worker, method, args);
826
+ }
827
+ ensureWorker() {
828
+ if (this.closed)
829
+ throw new Error("PostgresJobStore is closed");
830
+ if (this.worker)
831
+ return this.worker;
832
+ if (this.workerTerminationPending) {
833
+ throw new Error("PostgresJobStore is waiting for a failed worker to terminate before it can recover");
834
+ }
835
+ const worker = this.createWorker();
836
+ this.worker = worker;
837
+ try {
838
+ this.callWorker(worker, "init", []);
839
+ return worker;
786
840
  }
787
- if (Atomics.load(state, 0) === 2) {
788
- throw new Error(`PostgresJobStore ${method} worker could not write its result`);
841
+ catch (err) {
842
+ this.retireWorker(worker);
843
+ throw err;
789
844
  }
790
- let payload;
845
+ }
846
+ callWorker(worker, method, args) {
847
+ const shared = new SharedArrayBuffer(4);
848
+ const state = new Int32Array(shared);
849
+ const { port1: workerPort, port2: responsePort } = new MessageChannel();
791
850
  try {
792
- payload = JSON.parse(readFileSync(resultPath, "utf8"));
851
+ try {
852
+ worker.postMessage({ method, args, shared, responsePort: workerPort }, [workerPort]);
853
+ }
854
+ catch (err) {
855
+ throw this.retireAfterBridgeFailure(worker, `PostgresJobStore ${method} could not dispatch work to its worker`, err);
856
+ }
857
+ const timeoutMs = method === "init"
858
+ ? POSTGRES_WORKER_INITIALIZATION_TIMEOUT_MS
859
+ : POSTGRES_WORKER_OPERATION_TIMEOUT_MS;
860
+ const wait = Atomics.wait(state, 0, 0, timeoutMs);
861
+ if (wait === "timed-out") {
862
+ throw this.retireAfterBridgeFailure(worker, `PostgresJobStore ${method} timed out after ${timeoutMs}ms; the operation outcome is unknown`);
863
+ }
864
+ if (wait !== "ok" && wait !== "not-equal") {
865
+ throw this.retireAfterBridgeFailure(worker, `PostgresJobStore ${method} wait failed: ${wait}`);
866
+ }
867
+ if (Atomics.load(state, 0) !== 1) {
868
+ throw this.retireAfterBridgeFailure(worker, `PostgresJobStore ${method} worker response transport failed`);
869
+ }
870
+ const received = receiveMessageOnPort(responsePort);
871
+ if (!received || !isPostgresWorkerPayload(received.message)) {
872
+ throw this.retireAfterBridgeFailure(worker, `PostgresJobStore ${method} worker signalled without a valid response`);
873
+ }
874
+ const payload = received.message;
875
+ if (!payload.ok) {
876
+ const err = new Error(payload.error.message);
877
+ if (payload.error.stack)
878
+ err.stack = payload.error.stack;
879
+ throw err;
880
+ }
881
+ return payload.value;
793
882
  }
794
883
  finally {
795
- rmSync(resultPath, { force: true });
884
+ responsePort.close();
796
885
  }
797
- if (!payload.ok) {
798
- const err = new Error(payload.error.message);
799
- if (payload.error.stack)
800
- err.stack = payload.error.stack;
801
- throw err;
802
- }
803
- return payload.value;
886
+ }
887
+ retireAfterBridgeFailure(worker, message, cause) {
888
+ this.retireWorker(worker);
889
+ return cause === undefined ? new Error(message) : new Error(message, { cause });
890
+ }
891
+ retireWorker(worker) {
892
+ if (!worker || this.retiringWorker === worker)
893
+ return;
894
+ if (this.worker === worker)
895
+ this.worker = null;
896
+ this.retiringWorker = worker;
897
+ this.intentionallyRetiredWorkers.add(worker);
898
+ this.workerTerminationPending = true;
899
+ void worker.terminate().then(() => this.markWorkerRetired(worker), error => {
900
+ this.logger.error("PostgresJobStore worker termination failed", error);
901
+ this.markWorkerRetired(worker);
902
+ });
903
+ }
904
+ markWorkerRetired(worker) {
905
+ if (this.retiringWorker !== worker)
906
+ return;
907
+ this.retiringWorker = null;
908
+ this.workerTerminationPending = false;
804
909
  }
805
910
  recordStart(input) {
806
911
  this.syncCall("recordStart", input);
@@ -883,19 +988,41 @@ export class PostgresJobStore {
883
988
  close() {
884
989
  if (this.closed)
885
990
  return;
991
+ const worker = this.worker;
886
992
  try {
887
- this.syncCall("close");
993
+ if (worker)
994
+ this.callWorker(worker, "close", []);
888
995
  }
889
996
  catch (err) {
890
997
  this.logger.error("PostgresJobStore close failed", err);
891
998
  }
892
999
  finally {
893
1000
  this.closed = true;
894
- void this.worker.terminate();
895
- rmSync(this.tmpDir, { recursive: true, force: true });
1001
+ this.retireWorker(worker);
896
1002
  }
897
1003
  }
898
1004
  }
1005
+ function isPostgresWorkerPayload(value) {
1006
+ if (!value || typeof value !== "object")
1007
+ return false;
1008
+ const candidate = value;
1009
+ if (candidate.ok === true)
1010
+ return "value" in candidate;
1011
+ if (!candidate.error || typeof candidate.error !== "object" || candidate.ok !== false)
1012
+ return false;
1013
+ const error = candidate.error;
1014
+ return (typeof error.message === "string" &&
1015
+ (error.stack === undefined || typeof error.stack === "string"));
1016
+ }
1017
+ function isPostgresWorkerDiagnostic(value) {
1018
+ if (!value || typeof value !== "object")
1019
+ return false;
1020
+ const candidate = value;
1021
+ return (candidate.type === "postgres-job-store-diagnostic" &&
1022
+ typeof candidate.kind === "string" &&
1023
+ typeof candidate.message === "string" &&
1024
+ (candidate.stack === undefined || typeof candidate.stack === "string"));
1025
+ }
899
1026
  function resolvePostgresWorkerUrl() {
900
1027
  const sibling = new URL("./postgres-job-store-worker.js", import.meta.url);
901
1028
  if (existsSync(fileURLToPath(sibling)))
@@ -0,0 +1,60 @@
1
+ import type { FlightRecorderQuery } from "./flight-recorder.js";
2
+ import type { Confidence } from "./least-cost-types.js";
3
+ import type { ContentType } from "./token-estimator.js";
4
+ export type PriorsScope = "global" | "principal" | "off";
5
+ export declare const CALIBRATION_K_MIN_SAMPLES = 5;
6
+ export declare const CONFIDENCE_HIGH_MIN_SAMPLES = 30;
7
+ export declare const CONFIDENCE_HIGH_MAX_SPREAD = 1.5;
8
+ export declare const CONFIDENCE_MEDIUM_MIN_SAMPLES = 10;
9
+ export declare const CONFIDENCE_MEDIUM_MAX_SPREAD = 3;
10
+ export interface LcrPriorRow {
11
+ provider: string;
12
+ model: string;
13
+ prompt: string;
14
+ inputTokens: number | null;
15
+ outputTokens: number | null;
16
+ cacheReadTokens: number | null;
17
+ cacheCreationTokens: number | null;
18
+ costUsd: number | null;
19
+ costBasis: string | null;
20
+ routeEstCostUsd: number | null;
21
+ ownerPrincipal: string | null;
22
+ sessionContinued: boolean;
23
+ }
24
+ export interface OutputPrior {
25
+ median: number;
26
+ p90: number;
27
+ samples: number;
28
+ }
29
+ export interface CalibrationBucket {
30
+ k: number;
31
+ samples: number;
32
+ p10: number;
33
+ p50: number;
34
+ p90: number;
35
+ confidence: Confidence;
36
+ }
37
+ export interface AccuracyBucket {
38
+ provider: string;
39
+ model: string;
40
+ costBasis: string;
41
+ medianAccuracy: number;
42
+ samples: number;
43
+ p10: number;
44
+ p50: number;
45
+ p90: number;
46
+ }
47
+ export interface LcrPriors {
48
+ outputPriors: Map<string, OutputPrior>;
49
+ calibration: Map<string, CalibrationBucket>;
50
+ accuracyByBasis: Map<string, AccuracyBucket>;
51
+ }
52
+ export interface ComputeLcrPriorsOptions {
53
+ priorsScope: PriorsScope;
54
+ ownerPrincipal?: string;
55
+ }
56
+ export declare function confidenceFromQuality(samples: number, p10: number, p90: number): Confidence;
57
+ export declare function lookupCalibrationK(priors: LcrPriors, contentType: ContentType, family: string): number;
58
+ export declare function computeLcrPriors(rows: LcrPriorRow[], opts: ComputeLcrPriorsOptions): LcrPriors;
59
+ export declare function loadLcrPriorRows(db: FlightRecorderQuery): LcrPriorRow[];
60
+ export declare function computeLcrPriorsFromDb(db: FlightRecorderQuery, opts: ComputeLcrPriorsOptions): LcrPriors;
@@ -0,0 +1,190 @@
1
+ import { estimateInputTokens, classifyContent } from "./token-estimator.js";
2
+ import { modelIdToFamily } from "./pricing.js";
3
+ export const CALIBRATION_K_MIN_SAMPLES = 5;
4
+ export const CONFIDENCE_HIGH_MIN_SAMPLES = 30;
5
+ export const CONFIDENCE_HIGH_MAX_SPREAD = 1.5;
6
+ export const CONFIDENCE_MEDIUM_MIN_SAMPLES = 10;
7
+ export const CONFIDENCE_MEDIUM_MAX_SPREAD = 3.0;
8
+ function isFiniteNumber(n) {
9
+ return typeof n === "number" && Number.isFinite(n);
10
+ }
11
+ function num(n) {
12
+ return isFiniteNumber(n) ? n : 0;
13
+ }
14
+ function percentile(sortedAsc, q) {
15
+ const n = sortedAsc.length;
16
+ if (n === 0)
17
+ return 0;
18
+ if (n === 1)
19
+ return sortedAsc[0];
20
+ const idx = (n - 1) * q;
21
+ const lo = Math.floor(idx);
22
+ const hi = Math.ceil(idx);
23
+ const loVal = sortedAsc.at(lo) ?? 0;
24
+ const hiVal = sortedAsc.at(hi) ?? 0;
25
+ if (lo === hi)
26
+ return loVal;
27
+ return loVal + (hiVal - loVal) * (idx - lo);
28
+ }
29
+ const outputKey = (provider, model) => `${provider}:${model}`;
30
+ const calibrationKey = (contentType, family) => `${contentType}:${family}`;
31
+ const accuracyKey = (provider, model, costBasis) => `${provider}:${model}::${costBasis}`;
32
+ function reconstructActualInput(row, family) {
33
+ const input = num(row.inputTokens);
34
+ if (family.startsWith("claude-")) {
35
+ return input + num(row.cacheReadTokens) + num(row.cacheCreationTokens);
36
+ }
37
+ return input;
38
+ }
39
+ export function confidenceFromQuality(samples, p10, p90) {
40
+ if (p10 <= 0)
41
+ return "low";
42
+ const spread = p90 / p10;
43
+ if (samples >= CONFIDENCE_HIGH_MIN_SAMPLES && spread <= CONFIDENCE_HIGH_MAX_SPREAD) {
44
+ return "high";
45
+ }
46
+ if (samples >= CONFIDENCE_MEDIUM_MIN_SAMPLES && spread <= CONFIDENCE_MEDIUM_MAX_SPREAD) {
47
+ return "medium";
48
+ }
49
+ return "low";
50
+ }
51
+ export function lookupCalibrationK(priors, contentType, family) {
52
+ const bucket = priors.calibration.get(calibrationKey(contentType, family));
53
+ if (!bucket)
54
+ return 1;
55
+ if (bucket.samples < CALIBRATION_K_MIN_SAMPLES)
56
+ return 1;
57
+ return bucket.k;
58
+ }
59
+ export function computeLcrPriors(rows, opts) {
60
+ const empty = {
61
+ outputPriors: new Map(),
62
+ calibration: new Map(),
63
+ accuracyByBasis: new Map(),
64
+ };
65
+ if (opts.priorsScope === "off")
66
+ return empty;
67
+ let scoped = rows;
68
+ if (opts.priorsScope === "principal") {
69
+ const owner = opts.ownerPrincipal;
70
+ if (owner === undefined)
71
+ return empty;
72
+ scoped = rows.filter(r => r.ownerPrincipal === owner);
73
+ }
74
+ const outputSamples = new Map();
75
+ const calibrationRatios = new Map();
76
+ const accuracyRatios = new Map();
77
+ for (const row of scoped) {
78
+ if (isFiniteNumber(row.outputTokens) && row.outputTokens >= 0) {
79
+ const key = outputKey(row.provider, row.model);
80
+ const arr = outputSamples.get(key) ?? [];
81
+ arr.push(row.outputTokens);
82
+ outputSamples.set(key, arr);
83
+ }
84
+ const family = modelIdToFamily(row.model);
85
+ if (family !== "unknown" && !row.sessionContinued && isFiniteNumber(row.inputTokens)) {
86
+ const base = estimateInputTokens(row.prompt, { family });
87
+ if (base > 0) {
88
+ const actual = reconstructActualInput(row, family);
89
+ const contentType = classifyContent(row.prompt);
90
+ const key = calibrationKey(contentType, family);
91
+ const arr = calibrationRatios.get(key) ?? [];
92
+ arr.push(actual / base);
93
+ calibrationRatios.set(key, arr);
94
+ }
95
+ }
96
+ if (isFiniteNumber(row.routeEstCostUsd) &&
97
+ isFiniteNumber(row.costUsd) &&
98
+ row.costUsd > 0 &&
99
+ row.costBasis) {
100
+ const key = accuracyKey(row.provider, row.model, row.costBasis);
101
+ const arr = accuracyRatios.get(key) ?? [];
102
+ arr.push(row.routeEstCostUsd / row.costUsd);
103
+ accuracyRatios.set(key, arr);
104
+ }
105
+ }
106
+ const outputPriors = new Map();
107
+ for (const [key, values] of outputSamples) {
108
+ values.sort((a, b) => a - b);
109
+ outputPriors.set(key, {
110
+ median: percentile(values, 0.5),
111
+ p90: percentile(values, 0.9),
112
+ samples: values.length,
113
+ });
114
+ }
115
+ const calibration = new Map();
116
+ for (const [key, values] of calibrationRatios) {
117
+ values.sort((a, b) => a - b);
118
+ const p10 = percentile(values, 0.1);
119
+ const p50 = percentile(values, 0.5);
120
+ const p90 = percentile(values, 0.9);
121
+ calibration.set(key, {
122
+ k: p50,
123
+ samples: values.length,
124
+ p10,
125
+ p50,
126
+ p90,
127
+ confidence: confidenceFromQuality(values.length, p10, p90),
128
+ });
129
+ }
130
+ const accuracyByBasis = new Map();
131
+ for (const [key, values] of accuracyRatios) {
132
+ values.sort((a, b) => a - b);
133
+ const sep = key.lastIndexOf("::");
134
+ const pm = key.slice(0, sep);
135
+ const costBasis = key.slice(sep + 2);
136
+ const colon = pm.indexOf(":");
137
+ const provider = pm.slice(0, colon);
138
+ const model = pm.slice(colon + 1);
139
+ accuracyByBasis.set(key, {
140
+ provider,
141
+ model,
142
+ costBasis,
143
+ medianAccuracy: percentile(values, 0.5),
144
+ samples: values.length,
145
+ p10: percentile(values, 0.1),
146
+ p50: percentile(values, 0.5),
147
+ p90: percentile(values, 0.9),
148
+ });
149
+ }
150
+ return { outputPriors, calibration, accuracyByBasis };
151
+ }
152
+ export function loadLcrPriorRows(db) {
153
+ const raw = db.queryRequests(`SELECT r.cli, r.model, r.prompt,
154
+ r.input_tokens, r.output_tokens,
155
+ r.cache_read_tokens, r.cache_creation_tokens,
156
+ r.cost_basis, r.owner_principal, r.session_id, r.datetime_utc,
157
+ m.cost_usd, m.route_est_cost_usd
158
+ FROM requests r
159
+ LEFT JOIN gateway_metadata m ON m.request_id = r.id
160
+ ORDER BY r.datetime_utc ASC`);
161
+ const seenMistralSessions = new Set();
162
+ return raw.map(row => {
163
+ let sessionContinued = false;
164
+ if (row.cli === "mistral" && row.session_id) {
165
+ if (seenMistralSessions.has(row.session_id)) {
166
+ sessionContinued = true;
167
+ }
168
+ else {
169
+ seenMistralSessions.add(row.session_id);
170
+ }
171
+ }
172
+ return {
173
+ provider: row.cli,
174
+ model: row.model,
175
+ prompt: row.prompt ?? "",
176
+ inputTokens: row.input_tokens,
177
+ outputTokens: row.output_tokens,
178
+ cacheReadTokens: row.cache_read_tokens,
179
+ cacheCreationTokens: row.cache_creation_tokens,
180
+ costUsd: row.cost_usd,
181
+ costBasis: row.cost_basis,
182
+ routeEstCostUsd: row.route_est_cost_usd,
183
+ ownerPrincipal: row.owner_principal,
184
+ sessionContinued,
185
+ };
186
+ });
187
+ }
188
+ export function computeLcrPriorsFromDb(db, opts) {
189
+ return computeLcrPriors(loadLcrPriorRows(db), opts);
190
+ }
@@ -0,0 +1,20 @@
1
+ import { type JobLimiterSnapshot } from "./async-job-manager.js";
2
+ import { type LcrPriors, type PriorsScope } from "./lcr-priors.js";
3
+ import type { FlightRecorderQuery } from "./flight-recorder.js";
4
+ import type { PerformanceMetrics } from "./metrics.js";
5
+ import type { ApiProviderRuntime, LeastCostConfig } from "./config.js";
6
+ import type { RouterEnv, RouterConfig } from "./least-cost-router.js";
7
+ export interface RouterEnvDeps {
8
+ performanceMetrics: PerformanceMetrics;
9
+ limiterSnapshot: JobLimiterSnapshot;
10
+ apiProviders: readonly ApiProviderRuntime[];
11
+ isAuthed?: (provider: string) => boolean;
12
+ preferCatalogPrice?: boolean;
13
+ flightRecorder?: FlightRecorderQuery;
14
+ priorsScope?: PriorsScope;
15
+ ownerPrincipal?: string;
16
+ priors?: LcrPriors;
17
+ }
18
+ export declare function resetLcrPriorsCacheForTest(): void;
19
+ export declare function buildRouterEnv(deps: RouterEnvDeps): RouterEnv;
20
+ export declare function toRouterConfig(cfg: LeastCostConfig): RouterConfig;