ai-lcr 0.5.6 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,50 @@ All notable changes to `ai-lcr` are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/), and the project adheres to
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.6.0] — 2026-06-10
8
+
9
+ Media billing contract v2: **rank by the reference, bill by actual usage.**
10
+ The 0.5 media router used one number for both jobs — the price normalized to a
11
+ reference output (1080p image / 5-second clip) ranked routes *and* estimated
12
+ costs, multiplied by an untyped `units` count. That mispriced off-reference
13
+ outputs (an 8s clip billed as 5s) and made the baseline duration-blind, and the
14
+ bare `units` invited a seconds-as-count 8× overcharge. 0.6 separates the two.
15
+
16
+ ### Added
17
+
18
+ - **Typed usage (`MediaUsage`).** Adapter results (`MediaGenerateResult`,
19
+ `MediaStatusResult`) carry `usage: { seconds?, outputs?, megapixels? }` —
20
+ explicitly named dimensions that cannot be confused. The bundled adapters
21
+ report it (Kunavo video now safely reports the real `duration_seconds`).
22
+ The legacy bare `units` field is still honored as an output count.
23
+ - **Settle-time billing.** Cost estimates price the route's actual unit on
24
+ actual usage: per-second SKUs bill `usage.seconds` → `input.duration`
25
+ (numbers or `"8s"`-style strings) → the reference (last resort); per-image /
26
+ per-call SKUs bill output count; per-megapixel SKUs bill measured megapixels.
27
+ New public helpers: `billableUnits`, `priceCents`, `durationFromInput`.
28
+ - **Usage-aware savings baseline.** `baselineUsd` is now priced at settle time
29
+ against the same usage as the cost — an 8-second clip is baselined at 8
30
+ seconds of the official rate, not the 5-second reference. Off-reference calls
31
+ can no longer produce negative or understated savings.
32
+ - **`CallRecord` provenance fields** (all optional, backward compatible):
33
+ `modality` ("image" | "video"), `usage`, `baselineKind`
34
+ ("official" | "priciest-route" | "last-leg" — the text router now stamps
35
+ "last-leg"), `officialUsd` (the official price for this call's usage), and
36
+ `estCostUsd` (the price-table prediction; `costUsd − estCostUsd` on
37
+ provider-reported rows is price-table drift).
38
+ - **Cost-outlier guard.** A provider-reported cost ≥25× off the table
39
+ prediction (the classic USD-vs-cents slip is exactly 100×) raises `onError`
40
+ with both numbers; the reported bill still stands.
41
+ - `MediaRunResult` and the terminal `MediaPollResult` expose the `usage` that
42
+ backed the bill.
43
+
44
+ ### Changed
45
+
46
+ - `MediaJobHandle` now carries the serving route's `pricing` and the resolved
47
+ savings `baseline` so settle-time billing works across processes. Handles
48
+ serialized by 0.5.x still poll fine: they settle with the legacy
49
+ reference-price estimate and the submit-time baseline.
50
+
7
51
  ## [0.5.6] — 2026-06-07
8
52
 
9
53
  All additions are optional and backward compatible. The sync `createMediaLCR`
package/README.md CHANGED
@@ -293,7 +293,9 @@ USD per second, as of 2026-05 — verify current rates. Video billing differs by
293
293
 
294
294
  ## Image & video routing (`createMediaLCR`)
295
295
 
296
- Image and video are a separate, self-contained side of `ai-lcr` (file outputs, mixed pricing units, async jobs) — see [`src/media.ts`](src/media.ts). You give it a registry (each model's provider routes + per-unit price) and a set of adapters; it routes cheapest-first, fails over, and reports real/normalized cost through the same `onCall` sink as text.
296
+ Image and video are a separate, self-contained side of `ai-lcr` (file outputs, mixed pricing units, async jobs) — see [`src/media.ts`](src/media.ts). You give it a registry (each model's provider routes + per-unit price) and a set of adapters; it routes cheapest-first, fails over, and reports real cost through the same `onCall` sink as text.
297
+
298
+ Two prices, two jobs: routes are **ranked** by their price normalized to one reference output (a 1080p image / a 5-second clip) so mixed units are comparable, but each settled call is **billed** on its actual usage — an 8-second clip on a per-second SKU costs 8 × the per-second rate, and its savings baseline is the official price for those same 8 seconds. Adapters report typed usage (`usage: { seconds, outputs, megapixels }`); when a provider returns its own bill, that wins, and a bill wildly off the price table (the classic USD-vs-cents slip is exactly 100×) raises `onError` so the table gets fixed.
297
299
 
298
300
  ```ts
299
301
  import { createMediaLCR, createKunavoMediaAdapter, createFalMediaAdapter } from 'ai-lcr'
package/dist/index.cjs CHANGED
@@ -23,6 +23,7 @@ __export(index_exports, {
23
23
  DEFAULT_REFERENCE: () => DEFAULT_REFERENCE,
24
24
  MEDIA_PRICING: () => MEDIA_PRICING,
25
25
  OFFICIAL_PRICES: () => OFFICIAL_PRICES,
26
+ billableUnits: () => billableUnits,
26
27
  cheapestRoute: () => cheapestRoute,
27
28
  classifyError: () => classifyError,
28
29
  classifyErrorKind: () => classifyErrorKind,
@@ -33,11 +34,13 @@ __export(index_exports, {
33
34
  createLCR: () => createLCR,
34
35
  createMediaLCR: () => createMediaLCR,
35
36
  createRunwareMediaAdapter: () => createRunwareMediaAdapter,
37
+ durationFromInput: () => durationFromInput,
36
38
  formatCallRecord: () => formatCallRecord,
37
39
  isAbortError: () => isAbortError,
38
40
  isNetworkError: () => isNetworkError,
39
41
  isRetryableError: () => isRetryableError,
40
42
  normalizedCents: () => normalizedCents,
43
+ priceCents: () => priceCents,
41
44
  rankRoutes: () => rankRoutes,
42
45
  referenceMegapixels: () => referenceMegapixels,
43
46
  shouldFailover: () => shouldFailover
@@ -364,6 +367,7 @@ var LcrFallbackModel = class {
364
367
  const cachedSavingUsd = provider.cost ? cacheSavingForUsage(provider.cost, inputTokens, cacheReadTokens) : 0;
365
368
  const usageMissing = inputTokens === 0 && outputTokens === 0;
366
369
  const emptyCompletion = inputTokens > 0 && outputTokens === 0;
370
+ const baselineUsd = this.baselineUsd(inputTokens, outputTokens, cacheReadTokens);
367
371
  this.emitCost({
368
372
  model: this.opts.modelName,
369
373
  provider: provider.label,
@@ -384,7 +388,7 @@ var LcrFallbackModel = class {
384
388
  outputTokens,
385
389
  ...cacheReadTokens > 0 ? { cachedInputTokens: cacheReadTokens } : {},
386
390
  costUsd,
387
- baselineUsd: this.baselineUsd(inputTokens, outputTokens, cacheReadTokens),
391
+ ...baselineUsd !== void 0 ? { baselineUsd, baselineKind: "last-leg" } : {},
388
392
  ...cachedSavingUsd > 0 ? { cachedSavingUsd } : {},
389
393
  ...ctx.requestId ? { requestId: ctx.requestId } : {},
390
394
  ...usageMissing ? { usageMissing: true } : {},
@@ -715,6 +719,40 @@ function normalizedCents(pricing, ref = DEFAULT_REFERENCE) {
715
719
  return pricing.cents * ref.videoSeconds;
716
720
  }
717
721
  }
722
+ function durationFromInput(input) {
723
+ const raw = input?.["duration"];
724
+ if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) return raw;
725
+ if (typeof raw === "string") {
726
+ const m = /^(\d+(?:\.\d+)?)\s*s?$/i.exec(raw.trim());
727
+ if (m) {
728
+ const n = Number(m[1]);
729
+ if (Number.isFinite(n) && n > 0) return n;
730
+ }
731
+ }
732
+ return void 0;
733
+ }
734
+ function billableUnits(unit, ctx) {
735
+ const ref = ctx.reference ?? DEFAULT_REFERENCE;
736
+ const positive = (n) => typeof n === "number" && Number.isFinite(n) && n > 0 ? n : void 0;
737
+ switch (unit) {
738
+ case "second": {
739
+ const measured = positive(ctx.usage?.seconds) ?? durationFromInput(ctx.input);
740
+ return measured !== void 0 ? { value: measured, assumed: false } : { value: ref.videoSeconds, assumed: true };
741
+ }
742
+ case "megapixel": {
743
+ const measured = positive(ctx.usage?.megapixels);
744
+ return measured !== void 0 ? { value: measured, assumed: false } : { value: referenceMegapixels(ref), assumed: true };
745
+ }
746
+ case "image":
747
+ case "call": {
748
+ const measured = positive(ctx.usage?.outputs) ?? positive(ctx.outputCount) ?? positive(ctx.units);
749
+ return measured !== void 0 ? { value: measured, assumed: false } : { value: 1, assumed: true };
750
+ }
751
+ }
752
+ }
753
+ function priceCents(pricing, ctx) {
754
+ return pricing.cents * billableUnits(pricing.unit, ctx).value;
755
+ }
718
756
  function rankRoutes(def, ref = DEFAULT_REFERENCE) {
719
757
  return def.routes.map((r) => ({ ...r, refCents: normalizedCents(r.pricing, ref) })).sort((a, b) => a.refCents - b.refCents);
720
758
  }
@@ -780,38 +818,91 @@ function createMediaLCR(config) {
780
818
  }
781
819
  const ranked = rankRoutes(def, reference);
782
820
  const official = def.official ?? officialPrices[modelId];
783
- const baselineUsd = official !== void 0 ? normalizedCents(official, reference) / 100 : ranked.length > 0 ? Math.max(...ranked.map((r) => r.refCents)) / 100 : 0;
784
- return { ranked, baselineUsd };
821
+ const baseline = official !== void 0 ? { pricing: official, kind: "official" } : ranked.length > 0 ? { pricing: ranked[ranked.length - 1].pricing, kind: "priciest-route" } : void 0;
822
+ return { def, ranked, baseline };
785
823
  }
786
- const costFor = (refCents, result) => result.costCents === void 0 ? refCents * (result.units ?? 1) : result.costCents;
787
- const emitFail = (modelId, attempts, baselineUsd, startedAt) => safeCall({
788
- id: newMediaCallId(),
789
- model: modelId,
790
- attempts,
791
- winner: void 0,
792
- ok: false,
793
- failedOver: attempts.length > 1,
794
- latencyMs: Date.now() - startedAt,
795
- inputTokens: 0,
796
- outputTokens: 0,
797
- costUsd: 0,
798
- baselineUsd
799
- });
800
- const emitOk = (modelId, provider, attempts, costCents, baselineUsd, startedAt) => safeCall({
801
- id: newMediaCallId(),
802
- model: modelId,
803
- attempts,
804
- winner: provider,
805
- ok: true,
806
- failedOver: attempts.length > 1,
807
- latencyMs: Date.now() - startedAt,
808
- inputTokens: 0,
809
- outputTokens: 0,
810
- costUsd: costCents / 100,
811
- baselineUsd
812
- });
824
+ const refBaselineUsd = (baseline) => baseline ? normalizedCents(baseline.pricing, reference) / 100 : 0;
825
+ function settle(pricing, refCents, result, input) {
826
+ const ctx = {
827
+ ...result.usage ? { usage: result.usage } : {},
828
+ ...result.units !== void 0 ? { units: result.units } : {},
829
+ ...result.outputs ? { outputCount: result.outputs.length } : {},
830
+ ...input ? { input } : {},
831
+ reference
832
+ };
833
+ const estCents = pricing ? priceCents(pricing, ctx) : refCents * billableUnits("call", ctx).value;
834
+ const estimated = result.costCents === void 0;
835
+ const costCents = result.costCents ?? estCents;
836
+ const usage = { ...result.usage ?? {} };
837
+ if (usage.outputs === void 0 && (result.outputs?.length ?? 0) > 0) {
838
+ usage.outputs = result.outputs.length;
839
+ }
840
+ if (usage.seconds === void 0 && pricing?.unit === "second") {
841
+ const s = billableUnits("second", ctx);
842
+ if (!s.assumed) usage.seconds = s.value;
843
+ }
844
+ return {
845
+ costCents,
846
+ estCents,
847
+ estimated,
848
+ ...Object.keys(usage).length > 0 ? { usage } : {},
849
+ ctx
850
+ };
851
+ }
852
+ const DRIFT_RATIO = 25;
853
+ const warnDrift = (modelId, provider, s) => {
854
+ if (s.estimated || s.estCents <= 0 || s.costCents <= 0) return;
855
+ const ratio = s.costCents / s.estCents;
856
+ if (ratio >= DRIFT_RATIO || ratio <= 1 / DRIFT_RATIO) {
857
+ safeError(
858
+ new Error(
859
+ `ai-lcr: ${provider} reported ${s.costCents}\xA2 for "${modelId}" but the price table predicts ${s.estCents}\xA2 (${ratio.toFixed(1)}\xD7) \u2014 check the route's pricing units (USD vs cents) or refresh the registry price`
860
+ ),
861
+ provider
862
+ );
863
+ }
864
+ };
865
+ const emitFail = (modelId, attempts, baselineUsd, startedAt) => {
866
+ const modality = registry[modelId]?.modality;
867
+ safeCall({
868
+ id: newMediaCallId(),
869
+ model: modelId,
870
+ attempts,
871
+ winner: void 0,
872
+ ok: false,
873
+ failedOver: attempts.length > 1,
874
+ latencyMs: Date.now() - startedAt,
875
+ inputTokens: 0,
876
+ outputTokens: 0,
877
+ costUsd: 0,
878
+ baselineUsd,
879
+ ...modality ? { modality } : {}
880
+ });
881
+ };
882
+ const emitOk = (args) => {
883
+ const { settled, baseline } = args;
884
+ const baselineUsd = baseline ? priceCents(baseline.pricing, settled.ctx) / 100 : args.legacyBaselineUsd ?? 0;
885
+ safeCall({
886
+ id: newMediaCallId(),
887
+ model: args.modelId,
888
+ attempts: args.attempts,
889
+ winner: args.provider,
890
+ ok: true,
891
+ failedOver: args.attempts.length > 1,
892
+ latencyMs: Date.now() - args.startedAt,
893
+ inputTokens: 0,
894
+ outputTokens: 0,
895
+ costUsd: settled.costCents / 100,
896
+ baselineUsd,
897
+ ...baseline ? { baselineKind: baseline.kind } : {},
898
+ ...baseline?.kind === "official" ? { officialUsd: baselineUsd } : {},
899
+ modality: args.modality,
900
+ ...settled.usage ? { usage: settled.usage } : {},
901
+ estCostUsd: settled.estCents / 100
902
+ });
903
+ };
813
904
  const generate = async function generate2(modelId, input) {
814
- const { ranked, baselineUsd } = resolve(modelId);
905
+ const { def, ranked, baseline } = resolve(modelId);
815
906
  const startedAt = Date.now();
816
907
  const attempts = [];
817
908
  let lastErr;
@@ -821,12 +912,31 @@ function createMediaLCR(config) {
821
912
  const attemptStart = Date.now();
822
913
  try {
823
914
  const result = await adapter.run({ externalId: route.externalId, input });
824
- const estimated = result.costCents === void 0;
825
- const costCents = costFor(route.refCents, result);
915
+ const settled = settle(route.pricing, route.refCents, result, input);
826
916
  attempts.push({ provider: route.provider, ok: true, latencyMs: Date.now() - attemptStart });
827
- safeCost({ modelId, provider: route.provider, costCents, estimated });
828
- emitOk(modelId, route.provider, attempts, costCents, baselineUsd, startedAt);
829
- return { outputs: result.outputs, provider: route.provider, costCents, estimated };
917
+ warnDrift(modelId, route.provider, settled);
918
+ safeCost({
919
+ modelId,
920
+ provider: route.provider,
921
+ costCents: settled.costCents,
922
+ estimated: settled.estimated
923
+ });
924
+ emitOk({
925
+ modelId,
926
+ modality: def.modality,
927
+ provider: route.provider,
928
+ attempts,
929
+ settled,
930
+ baseline,
931
+ startedAt
932
+ });
933
+ return {
934
+ outputs: result.outputs,
935
+ provider: route.provider,
936
+ costCents: settled.costCents,
937
+ estimated: settled.estimated,
938
+ ...settled.usage ? { usage: settled.usage } : {}
939
+ };
830
940
  } catch (err) {
831
941
  lastErr = err;
832
942
  attempts.push({
@@ -837,19 +947,19 @@ function createMediaLCR(config) {
837
947
  });
838
948
  safeError(err, route.provider);
839
949
  if (!isRetryableError(err)) {
840
- emitFail(modelId, attempts, baselineUsd, startedAt);
950
+ emitFail(modelId, attempts, refBaselineUsd(baseline), startedAt);
841
951
  throw err;
842
952
  }
843
953
  }
844
954
  }
845
- emitFail(modelId, attempts, baselineUsd, startedAt);
955
+ emitFail(modelId, attempts, refBaselineUsd(baseline), startedAt);
846
956
  throw lastErr instanceof Error ? lastErr : new Error(`ai-lcr: no provider could serve media model "${modelId}"`);
847
957
  };
848
958
  const asyncRanked = (modelId) => {
849
959
  const { ranked } = resolve(modelId);
850
960
  return ranked.filter((r) => typeof adapters[r.provider]?.submit === "function");
851
961
  };
852
- async function submitFrom(modelId, routes, input, metadata, baselineUsd, startedAt, attempts) {
962
+ async function submitFrom(modelId, routes, input, metadata, baseline, startedAt, attempts) {
853
963
  let lastErr;
854
964
  for (let i = 0; i < routes.length; i++) {
855
965
  const route = routes[i];
@@ -864,10 +974,12 @@ function createMediaLCR(config) {
864
974
  externalId: route.externalId,
865
975
  requestId,
866
976
  refCents: route.refCents,
977
+ pricing: route.pricing,
978
+ ...baseline ? { baseline } : {},
867
979
  fallbacks: routes.slice(i + 1).map((r) => ({ provider: r.provider, externalId: r.externalId, refCents: r.refCents })),
868
980
  input,
869
981
  ...metadata ? { metadata } : {},
870
- baselineUsd,
982
+ baselineUsd: refBaselineUsd(baseline),
871
983
  startedAt,
872
984
  attemptStart,
873
985
  attempts
@@ -884,18 +996,18 @@ function createMediaLCR(config) {
884
996
  if (!isRetryableError(err)) break;
885
997
  }
886
998
  }
887
- emitFail(modelId, attempts, baselineUsd, startedAt);
999
+ emitFail(modelId, attempts, refBaselineUsd(baseline), startedAt);
888
1000
  throw lastErr instanceof Error ? lastErr : new Error(`ai-lcr: no async provider could submit media model "${modelId}"`);
889
1001
  }
890
1002
  generate.submit = async function submit(modelId, input, options) {
891
- const { ranked, baselineUsd } = resolve(modelId);
1003
+ const { ranked, baseline } = resolve(modelId);
892
1004
  const usable = ranked.filter((r) => typeof adapters[r.provider]?.submit === "function");
893
1005
  if (usable.length === 0) {
894
1006
  throw new Error(
895
1007
  `ai-lcr: no provider for media model "${modelId}" supports async submit (need an adapter with submit/checkStatus)`
896
1008
  );
897
1009
  }
898
- return submitFrom(modelId, usable, input, options?.metadata, baselineUsd, Date.now(), []);
1010
+ return submitFrom(modelId, usable, input, options?.metadata, baseline, Date.now(), []);
899
1011
  };
900
1012
  generate.poll = async function poll(handle) {
901
1013
  const adapter = adapters[handle.provider];
@@ -905,6 +1017,7 @@ function createMediaLCR(config) {
905
1017
  );
906
1018
  }
907
1019
  const failover = async (attempts) => {
1020
+ const { baseline } = resolve(handle.modelId);
908
1021
  const next = asyncRanked(handle.modelId).filter(
909
1022
  (r) => handle.fallbacks.some((f) => f.provider === r.provider && f.externalId === r.externalId)
910
1023
  );
@@ -913,7 +1026,7 @@ function createMediaLCR(config) {
913
1026
  next,
914
1027
  handle.input,
915
1028
  handle.metadata,
916
- handle.baselineUsd,
1029
+ handle.baseline ?? baseline,
917
1030
  handle.startedAt,
918
1031
  attempts
919
1032
  );
@@ -953,15 +1066,37 @@ function createMediaLCR(config) {
953
1066
  true
954
1067
  );
955
1068
  }
956
- const estimated = status.costCents === void 0;
957
- const costCents = costFor(handle.refCents, status);
1069
+ const settled = settle(handle.pricing, handle.refCents, { ...status, outputs }, handle.input);
958
1070
  const attempts = [
959
1071
  ...handle.attempts,
960
1072
  { provider: handle.provider, ok: true, latencyMs: Date.now() - handle.attemptStart }
961
1073
  ];
962
- safeCost({ modelId: handle.modelId, provider: handle.provider, costCents, estimated });
963
- emitOk(handle.modelId, handle.provider, attempts, costCents, handle.baselineUsd, handle.startedAt);
964
- return { done: true, status: "done", outputs, provider: handle.provider, costCents, estimated };
1074
+ warnDrift(handle.modelId, handle.provider, settled);
1075
+ safeCost({
1076
+ modelId: handle.modelId,
1077
+ provider: handle.provider,
1078
+ costCents: settled.costCents,
1079
+ estimated: settled.estimated
1080
+ });
1081
+ emitOk({
1082
+ modelId: handle.modelId,
1083
+ modality: registry[handle.modelId]?.modality ?? outputs[0].type,
1084
+ provider: handle.provider,
1085
+ attempts,
1086
+ settled,
1087
+ baseline: handle.baseline,
1088
+ legacyBaselineUsd: handle.baselineUsd,
1089
+ startedAt: handle.startedAt
1090
+ });
1091
+ return {
1092
+ done: true,
1093
+ status: "done",
1094
+ outputs,
1095
+ provider: handle.provider,
1096
+ costCents: settled.costCents,
1097
+ estimated: settled.estimated,
1098
+ ...settled.usage ? { usage: settled.usage } : {}
1099
+ };
965
1100
  }
966
1101
  return onLegFailure(new Error(status.error ?? `ai-lcr: ${handle.provider} job failed`), true);
967
1102
  };
@@ -1152,9 +1287,12 @@ function createKunavoMediaAdapter(config) {
1152
1287
  if (urls.length === 0) {
1153
1288
  return { status: "error", error: `Kunavo video job ${req.requestId} completed with no URL` };
1154
1289
  }
1290
+ const durationSeconds = body.output?.duration_seconds;
1291
+ const seconds = typeof durationSeconds === "number" && Number.isFinite(durationSeconds) && durationSeconds > 0 ? durationSeconds : void 0;
1155
1292
  return {
1156
1293
  status: "done",
1157
- outputs: urls.map((url) => ({ url, type: "video" }))
1294
+ outputs: urls.map((url) => ({ url, type: "video" })),
1295
+ usage: { outputs: urls.length, ...seconds !== void 0 ? { seconds } : {} }
1158
1296
  };
1159
1297
  }
1160
1298
  if (status === "failed" || status === "error") {
@@ -1312,6 +1450,7 @@ function createRunwareMediaAdapter(config) {
1312
1450
  return {
1313
1451
  outputs,
1314
1452
  units: images.length,
1453
+ usage: { outputs: images.length },
1315
1454
  ...cents !== void 0 ? { costCents: cents } : {}
1316
1455
  };
1317
1456
  },
@@ -1342,6 +1481,7 @@ function createRunwareMediaAdapter(config) {
1342
1481
  return {
1343
1482
  status: "done",
1344
1483
  outputs: [{ url, type: "video" }],
1484
+ usage: { outputs: 1 },
1345
1485
  ...cents !== void 0 ? { costCents: cents } : {}
1346
1486
  };
1347
1487
  }
@@ -1433,7 +1573,7 @@ function createFalMediaAdapter(config) {
1433
1573
  if (outputs.length === 0) {
1434
1574
  return { status: "error", error: `fal job ${req.requestId} completed with no media URL` };
1435
1575
  }
1436
- return { status: "done", outputs, units: outputs.length };
1576
+ return { status: "done", outputs, units: outputs.length, usage: { outputs: outputs.length } };
1437
1577
  }
1438
1578
  return {
1439
1579
  provider: "fal",
@@ -1485,7 +1625,7 @@ function createFalMediaAdapter(config) {
1485
1625
  if (outputs.length === 0) {
1486
1626
  throw new Error(`ai-lcr: fal returned no media URL for "${req.externalId}"`);
1487
1627
  }
1488
- return { outputs, units: outputs.length };
1628
+ return { outputs, units: outputs.length, usage: { outputs: outputs.length } };
1489
1629
  }
1490
1630
  };
1491
1631
  }
@@ -1571,6 +1711,7 @@ function createLCR(config) {
1571
1711
  DEFAULT_REFERENCE,
1572
1712
  MEDIA_PRICING,
1573
1713
  OFFICIAL_PRICES,
1714
+ billableUnits,
1574
1715
  cheapestRoute,
1575
1716
  classifyError,
1576
1717
  classifyErrorKind,
@@ -1581,11 +1722,13 @@ function createLCR(config) {
1581
1722
  createLCR,
1582
1723
  createMediaLCR,
1583
1724
  createRunwareMediaAdapter,
1725
+ durationFromInput,
1584
1726
  formatCallRecord,
1585
1727
  isAbortError,
1586
1728
  isNetworkError,
1587
1729
  isRetryableError,
1588
1730
  normalizedCents,
1731
+ priceCents,
1589
1732
  rankRoutes,
1590
1733
  referenceMegapixels,
1591
1734
  shouldFailover
package/dist/index.d.cts CHANGED
@@ -127,6 +127,47 @@ interface CallRecord {
127
127
  * when no provider was priced.
128
128
  */
129
129
  baselineUsd?: number;
130
+ /**
131
+ * How `baselineUsd` was derived, so a dashboard can qualify the savings
132
+ * number instead of treating every baseline as equally authoritative:
133
+ * - "last-leg": text router — the always-on fallback leg's list price.
134
+ * - "official": media router — the model maker's first-party price.
135
+ * - "priciest-route": media router with no official price — the most
136
+ * expensive configured route (self-referential; honest
137
+ * about cross-provider spread, but not a market price).
138
+ * Undefined when `baselineUsd` is undefined.
139
+ */
140
+ baselineKind?: "last-leg" | "official" | "priciest-route";
141
+ /**
142
+ * Media only: "image" | "video". Lets the dashboard split media traffic from
143
+ * token-billed text (whose records leave this unset) without inferring from
144
+ * zero token counts.
145
+ */
146
+ modality?: "image" | "video";
147
+ /**
148
+ * Media only: the actual billable quantity behind `costUsd` — seconds of
149
+ * video, output count, or megapixels — so per-unit economics ($/second,
150
+ * $/image) are derivable downstream. Absent when nothing was measured.
151
+ */
152
+ usage?: {
153
+ seconds?: number;
154
+ outputs?: number;
155
+ megapixels?: number;
156
+ };
157
+ /**
158
+ * Media only: the model maker's official first-party price for THIS call's
159
+ * usage (USD). Present only when an official price is known; equals
160
+ * `baselineUsd` when `baselineKind` is "official".
161
+ */
162
+ officialUsd?: number;
163
+ /**
164
+ * What the configured price table PREDICTED this call would cost (USD), on
165
+ * the same usage. When the provider reports an actual cost, `costUsd −
166
+ * estCostUsd` is the price-table drift — the signal that a registry price is
167
+ * stale or mis-entered. When no provider cost is reported the two are equal
168
+ * (the estimate IS the cost), so drift is only meaningful on reported rows.
169
+ */
170
+ estCostUsd?: number;
130
171
  /**
131
172
  * The slice of `costUsd` that prompt-cache reads saved versus paying the full
132
173
  * input rate for those same tokens (`cachedTokens × (input − cacheRead)`).
@@ -366,6 +407,41 @@ declare function referenceMegapixels(ref: ReferenceSpec): number;
366
407
  * This is the single normalization that makes providers comparable.
367
408
  */
368
409
  declare function normalizedCents(pricing: MediaPricing, ref?: ReferenceSpec): number;
410
+ /**
411
+ * Parse a duration in seconds out of a canonical media input. Accepts a number
412
+ * or a numeric-ish string (Veo-style `"8s"`, `"8"`). Returns undefined when the
413
+ * input carries no usable duration.
414
+ */
415
+ declare function durationFromInput(input: Record<string, unknown> | undefined): number | undefined;
416
+ /** What `billableUnits` can draw on to resolve the actual billed quantity. */
417
+ interface BillableContext {
418
+ /** Typed usage the adapter measured on the settled result. Highest priority. */
419
+ usage?: MediaUsage;
420
+ /** Legacy bare count from older adapters — OUTPUT COUNT only. */
421
+ units?: number;
422
+ /** Settled output count (`outputs.length`), when available. */
423
+ outputCount?: number;
424
+ /** The canonical request input — `duration` is read for per-second pricing. */
425
+ input?: Record<string, unknown>;
426
+ /** Reference spec, the last-resort assumption when nothing was measured. */
427
+ reference?: ReferenceSpec;
428
+ }
429
+ /**
430
+ * Resolve the actual billable quantity for a pricing unit, with provenance:
431
+ * `assumed: true` means nothing was measured and the reference spec filled in
432
+ * (so the resulting cost is a guess of last resort, not a measurement).
433
+ *
434
+ * Resolution order per unit:
435
+ * - "second": usage.seconds → input.duration → reference.videoSeconds (assumed)
436
+ * - "megapixel": usage.megapixels → reference image MP (assumed)
437
+ * - "image"/"call": usage.outputs → outputCount → legacy units → 1 (assumed)
438
+ */
439
+ declare function billableUnits(unit: MediaUnit, ctx: BillableContext): {
440
+ value: number;
441
+ assumed: boolean;
442
+ };
443
+ /** Price a settled generation on `pricing`, from its actual usage (cents). */
444
+ declare function priceCents(pricing: MediaPricing, ctx: BillableContext): number;
369
445
  interface RankedRoute extends MediaRoute {
370
446
  /** Normalized cost (cents per reference output) used for ordering. */
371
447
  refCents: number;
@@ -398,11 +474,31 @@ interface MediaOutput {
398
474
  url: string;
399
475
  type: MediaModality;
400
476
  }
477
+ /**
478
+ * Actual measured usage for one settled generation — the typed billing
479
+ * quantities. Adapters report whichever dimensions they can observe:
480
+ * - `seconds`: video length actually produced (per-second pricing bills this)
481
+ * - `outputs`: output count — images or clips (per-image / per-call pricing)
482
+ * - `megapixels`: total output megapixels (per-megapixel pricing)
483
+ * Dimensions are EXPLICITLY named so a seconds value can never be mistaken for
484
+ * an output count (the bug class the old bare `units` number invited: a flat
485
+ * per-call price × 8 "units" of an 8-second clip = an 8× overcharge).
486
+ */
487
+ interface MediaUsage {
488
+ seconds?: number;
489
+ outputs?: number;
490
+ megapixels?: number;
491
+ }
401
492
  interface MediaGenerateResult {
402
493
  outputs: MediaOutput[];
403
494
  /** Provider-reported actual cost in cents, when the API returns it. */
404
495
  costCents?: number;
405
- /** Units actually billed (images, or seconds of video) — for cost fallback. */
496
+ /** Typed actual usage (seconds / outputs / megapixels) — drives cost estimation. */
497
+ usage?: MediaUsage;
498
+ /**
499
+ * @deprecated Legacy bare count, meaning OUTPUT COUNT only (never seconds).
500
+ * Prefer `usage: { outputs }`. Still honored for backward compatibility.
501
+ */
406
502
  units?: number;
407
503
  }
408
504
  interface MediaSubmitRequest {
@@ -429,7 +525,12 @@ interface MediaStatusResult {
429
525
  outputs?: MediaOutput[];
430
526
  /** Provider-reported actual cost in cents, when the API returns it (`done`). */
431
527
  costCents?: number;
432
- /** Units billed (e.g. seconds of video) — cost fallback when `costCents` is absent. */
528
+ /** Typed actual usage (seconds / outputs / megapixels) — drives cost estimation. */
529
+ usage?: MediaUsage;
530
+ /**
531
+ * @deprecated Legacy bare count, meaning OUTPUT COUNT only (never seconds).
532
+ * Prefer `usage: { outputs }` (or `usage: { seconds }` for per-second SKUs).
533
+ */
433
534
  units?: number;
434
535
  /** Human-readable reason on `error`. */
435
536
  error?: string;
@@ -486,6 +587,8 @@ interface MediaRunResult {
486
587
  provider: string;
487
588
  costCents: number;
488
589
  estimated: boolean;
590
+ /** Actual usage behind `costCents` (seconds / outputs / megapixels), when known. */
591
+ usage?: MediaUsage;
489
592
  }
490
593
  interface MediaSubmitOptions {
491
594
  /** Opaque caller metadata forwarded to the provider's `submit`. */
@@ -514,8 +617,24 @@ interface MediaJobHandle {
514
617
  externalId: string;
515
618
  /** Provider-native job id from `submit`. */
516
619
  requestId: string;
517
- /** Normalized cost (cents/ref output) of the serving route — used to estimate cost. */
620
+ /** Normalized cost (cents/ref output) of the serving route — ranking metadata. */
518
621
  refCents: number;
622
+ /**
623
+ * The serving route's pricing — settle-time billing prices ACTUAL usage on
624
+ * this (`priceCents`), not the reference estimate. Optional only so handles
625
+ * serialized by pre-0.6 versions still poll; those fall back to `refCents`.
626
+ */
627
+ pricing?: MediaPricing;
628
+ /**
629
+ * Savings baseline carried across processes: the official first-party price
630
+ * when known, else the priciest configured route. Priced against the SAME
631
+ * actual usage as the cost at settle time. Absent on pre-0.6 handles (those
632
+ * settle with the submit-time `baselineUsd` estimate below).
633
+ */
634
+ baseline?: {
635
+ pricing: MediaPricing;
636
+ kind: "official" | "priciest-route";
637
+ };
519
638
  /** Not-yet-tried routes, cheapest-first, for poll-time re-submit failover. */
520
639
  fallbacks: {
521
640
  provider: string;
@@ -551,6 +670,8 @@ type MediaPollResult = {
551
670
  provider: string;
552
671
  costCents: number;
553
672
  estimated: boolean;
673
+ /** Actual usage behind `costCents` (seconds / outputs / megapixels), when known. */
674
+ usage?: MediaUsage;
554
675
  };
555
676
  /**
556
677
  * The router returned by {@link createMediaLCR}: a callable (the sync `run`
@@ -791,4 +912,4 @@ type LCRRouter = (modelName: string) => LanguageModelV3;
791
912
  */
792
913
  declare function createLCR(config: LCRConfig): LCRRouter;
793
914
 
794
- export { type CallRecord, type CostEvent, DEFAULT_REFERENCE, type ErrorKind, type FormatOptions, type HttpSinkOptions, type LCRConfig, type LCRRouter, MEDIA_PRICING, type MediaAdapter, type MediaCostEvent, type MediaGenerateRequest, type MediaGenerateResult, type MediaJobHandle, type MediaJobStatus, type MediaLCR, type MediaLCRConfig, type MediaModality, type MediaModelDef, type MediaOutput, type MediaPollResult, type MediaPricing, type MediaRegistry, type MediaRoute, type MediaRunResult, type MediaStatusRequest, type MediaStatusResult, type MediaSubmitOptions, type MediaSubmitRequest, type MediaSubmitResult, type MediaUnit, OFFICIAL_PRICES, type PriceComparisonRow, type ProviderCost, type ProviderEntry, type RankedRoute, type ReferenceSpec, type RouteAttempt, cheapestRoute, classifyError, classifyErrorKind, comparePrices, createFalMediaAdapter, createHttpSink, createKunavoMediaAdapter, createLCR, createMediaLCR, createRunwareMediaAdapter, formatCallRecord, isAbortError, isNetworkError, isRetryableError, normalizedCents, rankRoutes, referenceMegapixels, shouldFailover };
915
+ export { type BillableContext, type CallRecord, type CostEvent, DEFAULT_REFERENCE, type ErrorKind, type FormatOptions, type HttpSinkOptions, type LCRConfig, type LCRRouter, MEDIA_PRICING, type MediaAdapter, type MediaCostEvent, type MediaGenerateRequest, type MediaGenerateResult, type MediaJobHandle, type MediaJobStatus, type MediaLCR, type MediaLCRConfig, type MediaModality, type MediaModelDef, type MediaOutput, type MediaPollResult, type MediaPricing, type MediaRegistry, type MediaRoute, type MediaRunResult, type MediaStatusRequest, type MediaStatusResult, type MediaSubmitOptions, type MediaSubmitRequest, type MediaSubmitResult, type MediaUnit, type MediaUsage, OFFICIAL_PRICES, type PriceComparisonRow, type ProviderCost, type ProviderEntry, type RankedRoute, type ReferenceSpec, type RouteAttempt, billableUnits, cheapestRoute, classifyError, classifyErrorKind, comparePrices, createFalMediaAdapter, createHttpSink, createKunavoMediaAdapter, createLCR, createMediaLCR, createRunwareMediaAdapter, durationFromInput, formatCallRecord, isAbortError, isNetworkError, isRetryableError, normalizedCents, priceCents, rankRoutes, referenceMegapixels, shouldFailover };
package/dist/index.d.ts CHANGED
@@ -127,6 +127,47 @@ interface CallRecord {
127
127
  * when no provider was priced.
128
128
  */
129
129
  baselineUsd?: number;
130
+ /**
131
+ * How `baselineUsd` was derived, so a dashboard can qualify the savings
132
+ * number instead of treating every baseline as equally authoritative:
133
+ * - "last-leg": text router — the always-on fallback leg's list price.
134
+ * - "official": media router — the model maker's first-party price.
135
+ * - "priciest-route": media router with no official price — the most
136
+ * expensive configured route (self-referential; honest
137
+ * about cross-provider spread, but not a market price).
138
+ * Undefined when `baselineUsd` is undefined.
139
+ */
140
+ baselineKind?: "last-leg" | "official" | "priciest-route";
141
+ /**
142
+ * Media only: "image" | "video". Lets the dashboard split media traffic from
143
+ * token-billed text (whose records leave this unset) without inferring from
144
+ * zero token counts.
145
+ */
146
+ modality?: "image" | "video";
147
+ /**
148
+ * Media only: the actual billable quantity behind `costUsd` — seconds of
149
+ * video, output count, or megapixels — so per-unit economics ($/second,
150
+ * $/image) are derivable downstream. Absent when nothing was measured.
151
+ */
152
+ usage?: {
153
+ seconds?: number;
154
+ outputs?: number;
155
+ megapixels?: number;
156
+ };
157
+ /**
158
+ * Media only: the model maker's official first-party price for THIS call's
159
+ * usage (USD). Present only when an official price is known; equals
160
+ * `baselineUsd` when `baselineKind` is "official".
161
+ */
162
+ officialUsd?: number;
163
+ /**
164
+ * What the configured price table PREDICTED this call would cost (USD), on
165
+ * the same usage. When the provider reports an actual cost, `costUsd −
166
+ * estCostUsd` is the price-table drift — the signal that a registry price is
167
+ * stale or mis-entered. When no provider cost is reported the two are equal
168
+ * (the estimate IS the cost), so drift is only meaningful on reported rows.
169
+ */
170
+ estCostUsd?: number;
130
171
  /**
131
172
  * The slice of `costUsd` that prompt-cache reads saved versus paying the full
132
173
  * input rate for those same tokens (`cachedTokens × (input − cacheRead)`).
@@ -366,6 +407,41 @@ declare function referenceMegapixels(ref: ReferenceSpec): number;
366
407
  * This is the single normalization that makes providers comparable.
367
408
  */
368
409
  declare function normalizedCents(pricing: MediaPricing, ref?: ReferenceSpec): number;
410
+ /**
411
+ * Parse a duration in seconds out of a canonical media input. Accepts a number
412
+ * or a numeric-ish string (Veo-style `"8s"`, `"8"`). Returns undefined when the
413
+ * input carries no usable duration.
414
+ */
415
+ declare function durationFromInput(input: Record<string, unknown> | undefined): number | undefined;
416
+ /** What `billableUnits` can draw on to resolve the actual billed quantity. */
417
+ interface BillableContext {
418
+ /** Typed usage the adapter measured on the settled result. Highest priority. */
419
+ usage?: MediaUsage;
420
+ /** Legacy bare count from older adapters — OUTPUT COUNT only. */
421
+ units?: number;
422
+ /** Settled output count (`outputs.length`), when available. */
423
+ outputCount?: number;
424
+ /** The canonical request input — `duration` is read for per-second pricing. */
425
+ input?: Record<string, unknown>;
426
+ /** Reference spec, the last-resort assumption when nothing was measured. */
427
+ reference?: ReferenceSpec;
428
+ }
429
+ /**
430
+ * Resolve the actual billable quantity for a pricing unit, with provenance:
431
+ * `assumed: true` means nothing was measured and the reference spec filled in
432
+ * (so the resulting cost is a guess of last resort, not a measurement).
433
+ *
434
+ * Resolution order per unit:
435
+ * - "second": usage.seconds → input.duration → reference.videoSeconds (assumed)
436
+ * - "megapixel": usage.megapixels → reference image MP (assumed)
437
+ * - "image"/"call": usage.outputs → outputCount → legacy units → 1 (assumed)
438
+ */
439
+ declare function billableUnits(unit: MediaUnit, ctx: BillableContext): {
440
+ value: number;
441
+ assumed: boolean;
442
+ };
443
+ /** Price a settled generation on `pricing`, from its actual usage (cents). */
444
+ declare function priceCents(pricing: MediaPricing, ctx: BillableContext): number;
369
445
  interface RankedRoute extends MediaRoute {
370
446
  /** Normalized cost (cents per reference output) used for ordering. */
371
447
  refCents: number;
@@ -398,11 +474,31 @@ interface MediaOutput {
398
474
  url: string;
399
475
  type: MediaModality;
400
476
  }
477
+ /**
478
+ * Actual measured usage for one settled generation — the typed billing
479
+ * quantities. Adapters report whichever dimensions they can observe:
480
+ * - `seconds`: video length actually produced (per-second pricing bills this)
481
+ * - `outputs`: output count — images or clips (per-image / per-call pricing)
482
+ * - `megapixels`: total output megapixels (per-megapixel pricing)
483
+ * Dimensions are EXPLICITLY named so a seconds value can never be mistaken for
484
+ * an output count (the bug class the old bare `units` number invited: a flat
485
+ * per-call price × 8 "units" of an 8-second clip = an 8× overcharge).
486
+ */
487
+ interface MediaUsage {
488
+ seconds?: number;
489
+ outputs?: number;
490
+ megapixels?: number;
491
+ }
401
492
  interface MediaGenerateResult {
402
493
  outputs: MediaOutput[];
403
494
  /** Provider-reported actual cost in cents, when the API returns it. */
404
495
  costCents?: number;
405
- /** Units actually billed (images, or seconds of video) — for cost fallback. */
496
+ /** Typed actual usage (seconds / outputs / megapixels) — drives cost estimation. */
497
+ usage?: MediaUsage;
498
+ /**
499
+ * @deprecated Legacy bare count, meaning OUTPUT COUNT only (never seconds).
500
+ * Prefer `usage: { outputs }`. Still honored for backward compatibility.
501
+ */
406
502
  units?: number;
407
503
  }
408
504
  interface MediaSubmitRequest {
@@ -429,7 +525,12 @@ interface MediaStatusResult {
429
525
  outputs?: MediaOutput[];
430
526
  /** Provider-reported actual cost in cents, when the API returns it (`done`). */
431
527
  costCents?: number;
432
- /** Units billed (e.g. seconds of video) — cost fallback when `costCents` is absent. */
528
+ /** Typed actual usage (seconds / outputs / megapixels) — drives cost estimation. */
529
+ usage?: MediaUsage;
530
+ /**
531
+ * @deprecated Legacy bare count, meaning OUTPUT COUNT only (never seconds).
532
+ * Prefer `usage: { outputs }` (or `usage: { seconds }` for per-second SKUs).
533
+ */
433
534
  units?: number;
434
535
  /** Human-readable reason on `error`. */
435
536
  error?: string;
@@ -486,6 +587,8 @@ interface MediaRunResult {
486
587
  provider: string;
487
588
  costCents: number;
488
589
  estimated: boolean;
590
+ /** Actual usage behind `costCents` (seconds / outputs / megapixels), when known. */
591
+ usage?: MediaUsage;
489
592
  }
490
593
  interface MediaSubmitOptions {
491
594
  /** Opaque caller metadata forwarded to the provider's `submit`. */
@@ -514,8 +617,24 @@ interface MediaJobHandle {
514
617
  externalId: string;
515
618
  /** Provider-native job id from `submit`. */
516
619
  requestId: string;
517
- /** Normalized cost (cents/ref output) of the serving route — used to estimate cost. */
620
+ /** Normalized cost (cents/ref output) of the serving route — ranking metadata. */
518
621
  refCents: number;
622
+ /**
623
+ * The serving route's pricing — settle-time billing prices ACTUAL usage on
624
+ * this (`priceCents`), not the reference estimate. Optional only so handles
625
+ * serialized by pre-0.6 versions still poll; those fall back to `refCents`.
626
+ */
627
+ pricing?: MediaPricing;
628
+ /**
629
+ * Savings baseline carried across processes: the official first-party price
630
+ * when known, else the priciest configured route. Priced against the SAME
631
+ * actual usage as the cost at settle time. Absent on pre-0.6 handles (those
632
+ * settle with the submit-time `baselineUsd` estimate below).
633
+ */
634
+ baseline?: {
635
+ pricing: MediaPricing;
636
+ kind: "official" | "priciest-route";
637
+ };
519
638
  /** Not-yet-tried routes, cheapest-first, for poll-time re-submit failover. */
520
639
  fallbacks: {
521
640
  provider: string;
@@ -551,6 +670,8 @@ type MediaPollResult = {
551
670
  provider: string;
552
671
  costCents: number;
553
672
  estimated: boolean;
673
+ /** Actual usage behind `costCents` (seconds / outputs / megapixels), when known. */
674
+ usage?: MediaUsage;
554
675
  };
555
676
  /**
556
677
  * The router returned by {@link createMediaLCR}: a callable (the sync `run`
@@ -791,4 +912,4 @@ type LCRRouter = (modelName: string) => LanguageModelV3;
791
912
  */
792
913
  declare function createLCR(config: LCRConfig): LCRRouter;
793
914
 
794
- export { type CallRecord, type CostEvent, DEFAULT_REFERENCE, type ErrorKind, type FormatOptions, type HttpSinkOptions, type LCRConfig, type LCRRouter, MEDIA_PRICING, type MediaAdapter, type MediaCostEvent, type MediaGenerateRequest, type MediaGenerateResult, type MediaJobHandle, type MediaJobStatus, type MediaLCR, type MediaLCRConfig, type MediaModality, type MediaModelDef, type MediaOutput, type MediaPollResult, type MediaPricing, type MediaRegistry, type MediaRoute, type MediaRunResult, type MediaStatusRequest, type MediaStatusResult, type MediaSubmitOptions, type MediaSubmitRequest, type MediaSubmitResult, type MediaUnit, OFFICIAL_PRICES, type PriceComparisonRow, type ProviderCost, type ProviderEntry, type RankedRoute, type ReferenceSpec, type RouteAttempt, cheapestRoute, classifyError, classifyErrorKind, comparePrices, createFalMediaAdapter, createHttpSink, createKunavoMediaAdapter, createLCR, createMediaLCR, createRunwareMediaAdapter, formatCallRecord, isAbortError, isNetworkError, isRetryableError, normalizedCents, rankRoutes, referenceMegapixels, shouldFailover };
915
+ export { type BillableContext, type CallRecord, type CostEvent, DEFAULT_REFERENCE, type ErrorKind, type FormatOptions, type HttpSinkOptions, type LCRConfig, type LCRRouter, MEDIA_PRICING, type MediaAdapter, type MediaCostEvent, type MediaGenerateRequest, type MediaGenerateResult, type MediaJobHandle, type MediaJobStatus, type MediaLCR, type MediaLCRConfig, type MediaModality, type MediaModelDef, type MediaOutput, type MediaPollResult, type MediaPricing, type MediaRegistry, type MediaRoute, type MediaRunResult, type MediaStatusRequest, type MediaStatusResult, type MediaSubmitOptions, type MediaSubmitRequest, type MediaSubmitResult, type MediaUnit, type MediaUsage, OFFICIAL_PRICES, type PriceComparisonRow, type ProviderCost, type ProviderEntry, type RankedRoute, type ReferenceSpec, type RouteAttempt, billableUnits, cheapestRoute, classifyError, classifyErrorKind, comparePrices, createFalMediaAdapter, createHttpSink, createKunavoMediaAdapter, createLCR, createMediaLCR, createRunwareMediaAdapter, durationFromInput, formatCallRecord, isAbortError, isNetworkError, isRetryableError, normalizedCents, priceCents, rankRoutes, referenceMegapixels, shouldFailover };
package/dist/index.js CHANGED
@@ -318,6 +318,7 @@ var LcrFallbackModel = class {
318
318
  const cachedSavingUsd = provider.cost ? cacheSavingForUsage(provider.cost, inputTokens, cacheReadTokens) : 0;
319
319
  const usageMissing = inputTokens === 0 && outputTokens === 0;
320
320
  const emptyCompletion = inputTokens > 0 && outputTokens === 0;
321
+ const baselineUsd = this.baselineUsd(inputTokens, outputTokens, cacheReadTokens);
321
322
  this.emitCost({
322
323
  model: this.opts.modelName,
323
324
  provider: provider.label,
@@ -338,7 +339,7 @@ var LcrFallbackModel = class {
338
339
  outputTokens,
339
340
  ...cacheReadTokens > 0 ? { cachedInputTokens: cacheReadTokens } : {},
340
341
  costUsd,
341
- baselineUsd: this.baselineUsd(inputTokens, outputTokens, cacheReadTokens),
342
+ ...baselineUsd !== void 0 ? { baselineUsd, baselineKind: "last-leg" } : {},
342
343
  ...cachedSavingUsd > 0 ? { cachedSavingUsd } : {},
343
344
  ...ctx.requestId ? { requestId: ctx.requestId } : {},
344
345
  ...usageMissing ? { usageMissing: true } : {},
@@ -669,6 +670,40 @@ function normalizedCents(pricing, ref = DEFAULT_REFERENCE) {
669
670
  return pricing.cents * ref.videoSeconds;
670
671
  }
671
672
  }
673
+ function durationFromInput(input) {
674
+ const raw = input?.["duration"];
675
+ if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) return raw;
676
+ if (typeof raw === "string") {
677
+ const m = /^(\d+(?:\.\d+)?)\s*s?$/i.exec(raw.trim());
678
+ if (m) {
679
+ const n = Number(m[1]);
680
+ if (Number.isFinite(n) && n > 0) return n;
681
+ }
682
+ }
683
+ return void 0;
684
+ }
685
+ function billableUnits(unit, ctx) {
686
+ const ref = ctx.reference ?? DEFAULT_REFERENCE;
687
+ const positive = (n) => typeof n === "number" && Number.isFinite(n) && n > 0 ? n : void 0;
688
+ switch (unit) {
689
+ case "second": {
690
+ const measured = positive(ctx.usage?.seconds) ?? durationFromInput(ctx.input);
691
+ return measured !== void 0 ? { value: measured, assumed: false } : { value: ref.videoSeconds, assumed: true };
692
+ }
693
+ case "megapixel": {
694
+ const measured = positive(ctx.usage?.megapixels);
695
+ return measured !== void 0 ? { value: measured, assumed: false } : { value: referenceMegapixels(ref), assumed: true };
696
+ }
697
+ case "image":
698
+ case "call": {
699
+ const measured = positive(ctx.usage?.outputs) ?? positive(ctx.outputCount) ?? positive(ctx.units);
700
+ return measured !== void 0 ? { value: measured, assumed: false } : { value: 1, assumed: true };
701
+ }
702
+ }
703
+ }
704
+ function priceCents(pricing, ctx) {
705
+ return pricing.cents * billableUnits(pricing.unit, ctx).value;
706
+ }
672
707
  function rankRoutes(def, ref = DEFAULT_REFERENCE) {
673
708
  return def.routes.map((r) => ({ ...r, refCents: normalizedCents(r.pricing, ref) })).sort((a, b) => a.refCents - b.refCents);
674
709
  }
@@ -734,38 +769,91 @@ function createMediaLCR(config) {
734
769
  }
735
770
  const ranked = rankRoutes(def, reference);
736
771
  const official = def.official ?? officialPrices[modelId];
737
- const baselineUsd = official !== void 0 ? normalizedCents(official, reference) / 100 : ranked.length > 0 ? Math.max(...ranked.map((r) => r.refCents)) / 100 : 0;
738
- return { ranked, baselineUsd };
772
+ const baseline = official !== void 0 ? { pricing: official, kind: "official" } : ranked.length > 0 ? { pricing: ranked[ranked.length - 1].pricing, kind: "priciest-route" } : void 0;
773
+ return { def, ranked, baseline };
739
774
  }
740
- const costFor = (refCents, result) => result.costCents === void 0 ? refCents * (result.units ?? 1) : result.costCents;
741
- const emitFail = (modelId, attempts, baselineUsd, startedAt) => safeCall({
742
- id: newMediaCallId(),
743
- model: modelId,
744
- attempts,
745
- winner: void 0,
746
- ok: false,
747
- failedOver: attempts.length > 1,
748
- latencyMs: Date.now() - startedAt,
749
- inputTokens: 0,
750
- outputTokens: 0,
751
- costUsd: 0,
752
- baselineUsd
753
- });
754
- const emitOk = (modelId, provider, attempts, costCents, baselineUsd, startedAt) => safeCall({
755
- id: newMediaCallId(),
756
- model: modelId,
757
- attempts,
758
- winner: provider,
759
- ok: true,
760
- failedOver: attempts.length > 1,
761
- latencyMs: Date.now() - startedAt,
762
- inputTokens: 0,
763
- outputTokens: 0,
764
- costUsd: costCents / 100,
765
- baselineUsd
766
- });
775
+ const refBaselineUsd = (baseline) => baseline ? normalizedCents(baseline.pricing, reference) / 100 : 0;
776
+ function settle(pricing, refCents, result, input) {
777
+ const ctx = {
778
+ ...result.usage ? { usage: result.usage } : {},
779
+ ...result.units !== void 0 ? { units: result.units } : {},
780
+ ...result.outputs ? { outputCount: result.outputs.length } : {},
781
+ ...input ? { input } : {},
782
+ reference
783
+ };
784
+ const estCents = pricing ? priceCents(pricing, ctx) : refCents * billableUnits("call", ctx).value;
785
+ const estimated = result.costCents === void 0;
786
+ const costCents = result.costCents ?? estCents;
787
+ const usage = { ...result.usage ?? {} };
788
+ if (usage.outputs === void 0 && (result.outputs?.length ?? 0) > 0) {
789
+ usage.outputs = result.outputs.length;
790
+ }
791
+ if (usage.seconds === void 0 && pricing?.unit === "second") {
792
+ const s = billableUnits("second", ctx);
793
+ if (!s.assumed) usage.seconds = s.value;
794
+ }
795
+ return {
796
+ costCents,
797
+ estCents,
798
+ estimated,
799
+ ...Object.keys(usage).length > 0 ? { usage } : {},
800
+ ctx
801
+ };
802
+ }
803
+ const DRIFT_RATIO = 25;
804
+ const warnDrift = (modelId, provider, s) => {
805
+ if (s.estimated || s.estCents <= 0 || s.costCents <= 0) return;
806
+ const ratio = s.costCents / s.estCents;
807
+ if (ratio >= DRIFT_RATIO || ratio <= 1 / DRIFT_RATIO) {
808
+ safeError(
809
+ new Error(
810
+ `ai-lcr: ${provider} reported ${s.costCents}\xA2 for "${modelId}" but the price table predicts ${s.estCents}\xA2 (${ratio.toFixed(1)}\xD7) \u2014 check the route's pricing units (USD vs cents) or refresh the registry price`
811
+ ),
812
+ provider
813
+ );
814
+ }
815
+ };
816
+ const emitFail = (modelId, attempts, baselineUsd, startedAt) => {
817
+ const modality = registry[modelId]?.modality;
818
+ safeCall({
819
+ id: newMediaCallId(),
820
+ model: modelId,
821
+ attempts,
822
+ winner: void 0,
823
+ ok: false,
824
+ failedOver: attempts.length > 1,
825
+ latencyMs: Date.now() - startedAt,
826
+ inputTokens: 0,
827
+ outputTokens: 0,
828
+ costUsd: 0,
829
+ baselineUsd,
830
+ ...modality ? { modality } : {}
831
+ });
832
+ };
833
+ const emitOk = (args) => {
834
+ const { settled, baseline } = args;
835
+ const baselineUsd = baseline ? priceCents(baseline.pricing, settled.ctx) / 100 : args.legacyBaselineUsd ?? 0;
836
+ safeCall({
837
+ id: newMediaCallId(),
838
+ model: args.modelId,
839
+ attempts: args.attempts,
840
+ winner: args.provider,
841
+ ok: true,
842
+ failedOver: args.attempts.length > 1,
843
+ latencyMs: Date.now() - args.startedAt,
844
+ inputTokens: 0,
845
+ outputTokens: 0,
846
+ costUsd: settled.costCents / 100,
847
+ baselineUsd,
848
+ ...baseline ? { baselineKind: baseline.kind } : {},
849
+ ...baseline?.kind === "official" ? { officialUsd: baselineUsd } : {},
850
+ modality: args.modality,
851
+ ...settled.usage ? { usage: settled.usage } : {},
852
+ estCostUsd: settled.estCents / 100
853
+ });
854
+ };
767
855
  const generate = async function generate2(modelId, input) {
768
- const { ranked, baselineUsd } = resolve(modelId);
856
+ const { def, ranked, baseline } = resolve(modelId);
769
857
  const startedAt = Date.now();
770
858
  const attempts = [];
771
859
  let lastErr;
@@ -775,12 +863,31 @@ function createMediaLCR(config) {
775
863
  const attemptStart = Date.now();
776
864
  try {
777
865
  const result = await adapter.run({ externalId: route.externalId, input });
778
- const estimated = result.costCents === void 0;
779
- const costCents = costFor(route.refCents, result);
866
+ const settled = settle(route.pricing, route.refCents, result, input);
780
867
  attempts.push({ provider: route.provider, ok: true, latencyMs: Date.now() - attemptStart });
781
- safeCost({ modelId, provider: route.provider, costCents, estimated });
782
- emitOk(modelId, route.provider, attempts, costCents, baselineUsd, startedAt);
783
- return { outputs: result.outputs, provider: route.provider, costCents, estimated };
868
+ warnDrift(modelId, route.provider, settled);
869
+ safeCost({
870
+ modelId,
871
+ provider: route.provider,
872
+ costCents: settled.costCents,
873
+ estimated: settled.estimated
874
+ });
875
+ emitOk({
876
+ modelId,
877
+ modality: def.modality,
878
+ provider: route.provider,
879
+ attempts,
880
+ settled,
881
+ baseline,
882
+ startedAt
883
+ });
884
+ return {
885
+ outputs: result.outputs,
886
+ provider: route.provider,
887
+ costCents: settled.costCents,
888
+ estimated: settled.estimated,
889
+ ...settled.usage ? { usage: settled.usage } : {}
890
+ };
784
891
  } catch (err) {
785
892
  lastErr = err;
786
893
  attempts.push({
@@ -791,19 +898,19 @@ function createMediaLCR(config) {
791
898
  });
792
899
  safeError(err, route.provider);
793
900
  if (!isRetryableError(err)) {
794
- emitFail(modelId, attempts, baselineUsd, startedAt);
901
+ emitFail(modelId, attempts, refBaselineUsd(baseline), startedAt);
795
902
  throw err;
796
903
  }
797
904
  }
798
905
  }
799
- emitFail(modelId, attempts, baselineUsd, startedAt);
906
+ emitFail(modelId, attempts, refBaselineUsd(baseline), startedAt);
800
907
  throw lastErr instanceof Error ? lastErr : new Error(`ai-lcr: no provider could serve media model "${modelId}"`);
801
908
  };
802
909
  const asyncRanked = (modelId) => {
803
910
  const { ranked } = resolve(modelId);
804
911
  return ranked.filter((r) => typeof adapters[r.provider]?.submit === "function");
805
912
  };
806
- async function submitFrom(modelId, routes, input, metadata, baselineUsd, startedAt, attempts) {
913
+ async function submitFrom(modelId, routes, input, metadata, baseline, startedAt, attempts) {
807
914
  let lastErr;
808
915
  for (let i = 0; i < routes.length; i++) {
809
916
  const route = routes[i];
@@ -818,10 +925,12 @@ function createMediaLCR(config) {
818
925
  externalId: route.externalId,
819
926
  requestId,
820
927
  refCents: route.refCents,
928
+ pricing: route.pricing,
929
+ ...baseline ? { baseline } : {},
821
930
  fallbacks: routes.slice(i + 1).map((r) => ({ provider: r.provider, externalId: r.externalId, refCents: r.refCents })),
822
931
  input,
823
932
  ...metadata ? { metadata } : {},
824
- baselineUsd,
933
+ baselineUsd: refBaselineUsd(baseline),
825
934
  startedAt,
826
935
  attemptStart,
827
936
  attempts
@@ -838,18 +947,18 @@ function createMediaLCR(config) {
838
947
  if (!isRetryableError(err)) break;
839
948
  }
840
949
  }
841
- emitFail(modelId, attempts, baselineUsd, startedAt);
950
+ emitFail(modelId, attempts, refBaselineUsd(baseline), startedAt);
842
951
  throw lastErr instanceof Error ? lastErr : new Error(`ai-lcr: no async provider could submit media model "${modelId}"`);
843
952
  }
844
953
  generate.submit = async function submit(modelId, input, options) {
845
- const { ranked, baselineUsd } = resolve(modelId);
954
+ const { ranked, baseline } = resolve(modelId);
846
955
  const usable = ranked.filter((r) => typeof adapters[r.provider]?.submit === "function");
847
956
  if (usable.length === 0) {
848
957
  throw new Error(
849
958
  `ai-lcr: no provider for media model "${modelId}" supports async submit (need an adapter with submit/checkStatus)`
850
959
  );
851
960
  }
852
- return submitFrom(modelId, usable, input, options?.metadata, baselineUsd, Date.now(), []);
961
+ return submitFrom(modelId, usable, input, options?.metadata, baseline, Date.now(), []);
853
962
  };
854
963
  generate.poll = async function poll(handle) {
855
964
  const adapter = adapters[handle.provider];
@@ -859,6 +968,7 @@ function createMediaLCR(config) {
859
968
  );
860
969
  }
861
970
  const failover = async (attempts) => {
971
+ const { baseline } = resolve(handle.modelId);
862
972
  const next = asyncRanked(handle.modelId).filter(
863
973
  (r) => handle.fallbacks.some((f) => f.provider === r.provider && f.externalId === r.externalId)
864
974
  );
@@ -867,7 +977,7 @@ function createMediaLCR(config) {
867
977
  next,
868
978
  handle.input,
869
979
  handle.metadata,
870
- handle.baselineUsd,
980
+ handle.baseline ?? baseline,
871
981
  handle.startedAt,
872
982
  attempts
873
983
  );
@@ -907,15 +1017,37 @@ function createMediaLCR(config) {
907
1017
  true
908
1018
  );
909
1019
  }
910
- const estimated = status.costCents === void 0;
911
- const costCents = costFor(handle.refCents, status);
1020
+ const settled = settle(handle.pricing, handle.refCents, { ...status, outputs }, handle.input);
912
1021
  const attempts = [
913
1022
  ...handle.attempts,
914
1023
  { provider: handle.provider, ok: true, latencyMs: Date.now() - handle.attemptStart }
915
1024
  ];
916
- safeCost({ modelId: handle.modelId, provider: handle.provider, costCents, estimated });
917
- emitOk(handle.modelId, handle.provider, attempts, costCents, handle.baselineUsd, handle.startedAt);
918
- return { done: true, status: "done", outputs, provider: handle.provider, costCents, estimated };
1025
+ warnDrift(handle.modelId, handle.provider, settled);
1026
+ safeCost({
1027
+ modelId: handle.modelId,
1028
+ provider: handle.provider,
1029
+ costCents: settled.costCents,
1030
+ estimated: settled.estimated
1031
+ });
1032
+ emitOk({
1033
+ modelId: handle.modelId,
1034
+ modality: registry[handle.modelId]?.modality ?? outputs[0].type,
1035
+ provider: handle.provider,
1036
+ attempts,
1037
+ settled,
1038
+ baseline: handle.baseline,
1039
+ legacyBaselineUsd: handle.baselineUsd,
1040
+ startedAt: handle.startedAt
1041
+ });
1042
+ return {
1043
+ done: true,
1044
+ status: "done",
1045
+ outputs,
1046
+ provider: handle.provider,
1047
+ costCents: settled.costCents,
1048
+ estimated: settled.estimated,
1049
+ ...settled.usage ? { usage: settled.usage } : {}
1050
+ };
919
1051
  }
920
1052
  return onLegFailure(new Error(status.error ?? `ai-lcr: ${handle.provider} job failed`), true);
921
1053
  };
@@ -1106,9 +1238,12 @@ function createKunavoMediaAdapter(config) {
1106
1238
  if (urls.length === 0) {
1107
1239
  return { status: "error", error: `Kunavo video job ${req.requestId} completed with no URL` };
1108
1240
  }
1241
+ const durationSeconds = body.output?.duration_seconds;
1242
+ const seconds = typeof durationSeconds === "number" && Number.isFinite(durationSeconds) && durationSeconds > 0 ? durationSeconds : void 0;
1109
1243
  return {
1110
1244
  status: "done",
1111
- outputs: urls.map((url) => ({ url, type: "video" }))
1245
+ outputs: urls.map((url) => ({ url, type: "video" })),
1246
+ usage: { outputs: urls.length, ...seconds !== void 0 ? { seconds } : {} }
1112
1247
  };
1113
1248
  }
1114
1249
  if (status === "failed" || status === "error") {
@@ -1266,6 +1401,7 @@ function createRunwareMediaAdapter(config) {
1266
1401
  return {
1267
1402
  outputs,
1268
1403
  units: images.length,
1404
+ usage: { outputs: images.length },
1269
1405
  ...cents !== void 0 ? { costCents: cents } : {}
1270
1406
  };
1271
1407
  },
@@ -1296,6 +1432,7 @@ function createRunwareMediaAdapter(config) {
1296
1432
  return {
1297
1433
  status: "done",
1298
1434
  outputs: [{ url, type: "video" }],
1435
+ usage: { outputs: 1 },
1299
1436
  ...cents !== void 0 ? { costCents: cents } : {}
1300
1437
  };
1301
1438
  }
@@ -1387,7 +1524,7 @@ function createFalMediaAdapter(config) {
1387
1524
  if (outputs.length === 0) {
1388
1525
  return { status: "error", error: `fal job ${req.requestId} completed with no media URL` };
1389
1526
  }
1390
- return { status: "done", outputs, units: outputs.length };
1527
+ return { status: "done", outputs, units: outputs.length, usage: { outputs: outputs.length } };
1391
1528
  }
1392
1529
  return {
1393
1530
  provider: "fal",
@@ -1439,7 +1576,7 @@ function createFalMediaAdapter(config) {
1439
1576
  if (outputs.length === 0) {
1440
1577
  throw new Error(`ai-lcr: fal returned no media URL for "${req.externalId}"`);
1441
1578
  }
1442
- return { outputs, units: outputs.length };
1579
+ return { outputs, units: outputs.length, usage: { outputs: outputs.length } };
1443
1580
  }
1444
1581
  };
1445
1582
  }
@@ -1524,6 +1661,7 @@ export {
1524
1661
  DEFAULT_REFERENCE,
1525
1662
  MEDIA_PRICING,
1526
1663
  OFFICIAL_PRICES,
1664
+ billableUnits,
1527
1665
  cheapestRoute,
1528
1666
  classifyError,
1529
1667
  classifyErrorKind,
@@ -1534,11 +1672,13 @@ export {
1534
1672
  createLCR,
1535
1673
  createMediaLCR,
1536
1674
  createRunwareMediaAdapter,
1675
+ durationFromInput,
1537
1676
  formatCallRecord,
1538
1677
  isAbortError,
1539
1678
  isNetworkError,
1540
1679
  isRetryableError,
1541
1680
  normalizedCents,
1681
+ priceCents,
1542
1682
  rankRoutes,
1543
1683
  referenceMegapixels,
1544
1684
  shouldFailover
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lcr",
3
- "version": "0.5.6",
3
+ "version": "0.6.0",
4
4
  "description": "Least Cost Routing for LLMs — route every model call to the cheapest available provider, fall back automatically, and track real cost. Built for the Vercel AI SDK.",
5
5
  "keywords": [
6
6
  "ai",
@@ -19,11 +19,11 @@
19
19
  "author": "Victor",
20
20
  "repository": {
21
21
  "type": "git",
22
- "url": "git+https://github.com/victorzhrn/ai-lcr.git"
22
+ "url": "git+https://github.com/ai-lcr/ai-lcr.git"
23
23
  },
24
- "homepage": "https://github.com/victorzhrn/ai-lcr#readme",
24
+ "homepage": "https://github.com/ai-lcr/ai-lcr#readme",
25
25
  "bugs": {
26
- "url": "https://github.com/victorzhrn/ai-lcr/issues"
26
+ "url": "https://github.com/ai-lcr/ai-lcr/issues"
27
27
  },
28
28
  "type": "module",
29
29
  "main": "./dist/index.cjs",