ai-lcr 0.7.0 → 0.7.2

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,45 @@ 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.7.2] — 2026-06-20
8
+
9
+ Async media jobs now have a **deadline/SLA**: a provider that accepts a job and
10
+ then hangs (`queued`/`running` forever) is failed over to the next provider and
11
+ recorded, instead of polling silently forever.
12
+
13
+ ### Added
14
+
15
+ - **`MediaSubmitOptions.deadlineMs`** — per-job SLA. When a `poll()` finds the
16
+ job still `queued`/`running` at/after its deadline, the leg is treated as a
17
+ provider failure and runs the same failover path a `status:"error"` triggers
18
+ (re-submit to the next provider, carrying the deadline forward unchanged so a
19
+ hung provider can't reset the request's clock). Exhausted → a fail CallRecord
20
+ is settled and the poll throws (message contains "timeout").
21
+ - **`MediaLCRConfig.defaultDeadlineMs`** (defaults to the new exported
22
+ **`DEFAULT_VIDEO_DEADLINE_MS` = 12 min**) and **`MediaLCRConfig.now`** (injectable
23
+ clock, defaults to `Date.now`, for deterministic tests).
24
+ - **`MediaJobHandle.deadlineAt`** (absolute epoch ms) — survives the JSON
25
+ round-trip to a cross-process poll worker. Pre-0.7.2 handles without it keep
26
+ the old never-time-out behavior.
27
+ - A CallRecord is now emitted on the **timeout** terminal outcome too (not just
28
+ success/error), with each hung leg carrying `errorClass: "timeout"` — so the
29
+ dashboard sees timeouts and timeout-driven failovers.
30
+
31
+ ## [0.7.1] — 2026-06-20
32
+
33
+ Async media adapters now forward a caller-supplied webhook URL to the provider,
34
+ so async video jobs can complete by **push** instead of poll-only.
35
+
36
+ ### Added
37
+
38
+ - **`metadata.webhookUrl` is forwarded to the provider on async `submit`.**
39
+ `runware-media` adds it as the `videoInference` task's `webhookURL`; `fal-media`
40
+ appends it as the `?fal_webhook=` query param on the submit POST. The webhook is
41
+ a push path — the caller still polls as a fallback. Fixed task fields stay
42
+ un-clobberable (placed after the input spread). `metadata` was previously
43
+ accepted but dropped, so the documented "webhook hint" never reached the
44
+ provider; now it does.
45
+
7
46
  ## [0.7.0] — 2026-06-20
8
47
 
9
48
  The text router now records the **provider-reported actual cost** when a provider
package/dist/index.cjs CHANGED
@@ -22,6 +22,7 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  DEFAULT_PROVIDERS: () => DEFAULT_PROVIDERS,
24
24
  DEFAULT_REFERENCE: () => DEFAULT_REFERENCE,
25
+ DEFAULT_VIDEO_DEADLINE_MS: () => DEFAULT_VIDEO_DEADLINE_MS,
25
26
  MEDIA_PRICING: () => MEDIA_PRICING,
26
27
  MODEL_PRICES: () => MODEL_PRICES,
27
28
  OFFICIAL_PRICES: () => OFFICIAL_PRICES,
@@ -1379,6 +1380,7 @@ function comparePrices(registry, ref = DEFAULT_REFERENCE) {
1379
1380
  };
1380
1381
  });
1381
1382
  }
1383
+ var DEFAULT_VIDEO_DEADLINE_MS = 12 * 60 * 1e3;
1382
1384
  function newMediaCallId() {
1383
1385
  const c = globalThis.crypto;
1384
1386
  return c?.randomUUID ? c.randomUUID() : `lcr_${Date.now().toString(36)}`;
@@ -1391,7 +1393,9 @@ function createMediaLCR(config) {
1391
1393
  officialPrices = OFFICIAL_PRICES,
1392
1394
  onError,
1393
1395
  onCost,
1394
- onCall
1396
+ onCall,
1397
+ defaultDeadlineMs = DEFAULT_VIDEO_DEADLINE_MS,
1398
+ now = Date.now
1395
1399
  } = config;
1396
1400
  const safeError = (error, provider) => {
1397
1401
  try {
@@ -1471,7 +1475,7 @@ function createMediaLCR(config) {
1471
1475
  winner: void 0,
1472
1476
  ok: false,
1473
1477
  failedOver: attempts.length > 1,
1474
- latencyMs: Date.now() - startedAt,
1478
+ latencyMs: now() - startedAt,
1475
1479
  inputTokens: 0,
1476
1480
  outputTokens: 0,
1477
1481
  costUsd: 0,
@@ -1489,7 +1493,7 @@ function createMediaLCR(config) {
1489
1493
  winner: args.provider,
1490
1494
  ok: true,
1491
1495
  failedOver: args.attempts.length > 1,
1492
- latencyMs: Date.now() - args.startedAt,
1496
+ latencyMs: now() - args.startedAt,
1493
1497
  inputTokens: 0,
1494
1498
  outputTokens: 0,
1495
1499
  costUsd: settled.costCents / 100,
@@ -1503,17 +1507,17 @@ function createMediaLCR(config) {
1503
1507
  };
1504
1508
  const generate = async function generate2(modelId, input) {
1505
1509
  const { def, ranked, baseline } = resolve(modelId);
1506
- const startedAt = Date.now();
1510
+ const startedAt = now();
1507
1511
  const attempts = [];
1508
1512
  let lastErr;
1509
1513
  for (const route of ranked) {
1510
1514
  const adapter = adapters[route.provider];
1511
1515
  if (!adapter) continue;
1512
- const attemptStart = Date.now();
1516
+ const attemptStart = now();
1513
1517
  try {
1514
1518
  const result = await adapter.run({ externalId: route.externalId, input });
1515
1519
  const settled = settle(route.pricing, route.refCents, result, input);
1516
- attempts.push({ provider: route.provider, ok: true, latencyMs: Date.now() - attemptStart });
1520
+ attempts.push({ provider: route.provider, ok: true, latencyMs: now() - attemptStart });
1517
1521
  warnDrift(modelId, route.provider, settled);
1518
1522
  safeCost({
1519
1523
  modelId,
@@ -1542,7 +1546,7 @@ function createMediaLCR(config) {
1542
1546
  attempts.push({
1543
1547
  provider: route.provider,
1544
1548
  ok: false,
1545
- latencyMs: Date.now() - attemptStart,
1549
+ latencyMs: now() - attemptStart,
1546
1550
  errorClass: classifyError(err)
1547
1551
  });
1548
1552
  safeError(err, route.provider);
@@ -1559,13 +1563,13 @@ function createMediaLCR(config) {
1559
1563
  const { ranked } = resolve(modelId);
1560
1564
  return ranked.filter((r) => typeof adapters[r.provider]?.submit === "function");
1561
1565
  };
1562
- async function submitFrom(modelId, routes, input, metadata, baseline, startedAt, attempts) {
1566
+ async function submitFrom(modelId, routes, input, metadata, baseline, startedAt, attempts, deadlineAt) {
1563
1567
  let lastErr;
1564
1568
  for (let i = 0; i < routes.length; i++) {
1565
1569
  const route = routes[i];
1566
1570
  const adapter = adapters[route.provider];
1567
1571
  if (!adapter?.submit) continue;
1568
- const attemptStart = Date.now();
1572
+ const attemptStart = now();
1569
1573
  try {
1570
1574
  const { requestId } = await adapter.submit({ externalId: route.externalId, input, metadata });
1571
1575
  return {
@@ -1582,14 +1586,17 @@ function createMediaLCR(config) {
1582
1586
  baselineUsd: refBaselineUsd(baseline),
1583
1587
  startedAt,
1584
1588
  attemptStart,
1585
- attempts
1589
+ attempts,
1590
+ // Carry the SLA forward unchanged across a re-submit, so a hung
1591
+ // provider can't reset the request's clock by failing over.
1592
+ ...deadlineAt !== void 0 ? { deadlineAt } : {}
1586
1593
  };
1587
1594
  } catch (err) {
1588
1595
  lastErr = err;
1589
1596
  attempts.push({
1590
1597
  provider: route.provider,
1591
1598
  ok: false,
1592
- latencyMs: Date.now() - attemptStart,
1599
+ latencyMs: now() - attemptStart,
1593
1600
  errorClass: classifyError(err)
1594
1601
  });
1595
1602
  safeError(err, route.provider);
@@ -1607,7 +1614,10 @@ function createMediaLCR(config) {
1607
1614
  `ai-lcr: no provider for media model "${modelId}" supports async submit (need an adapter with submit/checkStatus)`
1608
1615
  );
1609
1616
  }
1610
- return submitFrom(modelId, usable, input, options?.metadata, baseline, Date.now(), []);
1617
+ const startedAt = now();
1618
+ const deadlineMs = options?.deadlineMs ?? defaultDeadlineMs;
1619
+ const deadlineAt = typeof deadlineMs === "number" && deadlineMs > 0 ? startedAt + deadlineMs : void 0;
1620
+ return submitFrom(modelId, usable, input, options?.metadata, baseline, startedAt, [], deadlineAt);
1611
1621
  };
1612
1622
  generate.poll = async function poll(handle) {
1613
1623
  const adapter = adapters[handle.provider];
@@ -1628,7 +1638,9 @@ function createMediaLCR(config) {
1628
1638
  handle.metadata,
1629
1639
  handle.baseline ?? baseline,
1630
1640
  handle.startedAt,
1631
- attempts
1641
+ attempts,
1642
+ handle.deadlineAt
1643
+ // same SLA instant — the new leg inherits the clock
1632
1644
  );
1633
1645
  return { done: false, status: "queued", handle: newHandle, failedOver: true };
1634
1646
  };
@@ -1638,7 +1650,7 @@ function createMediaLCR(config) {
1638
1650
  {
1639
1651
  provider: handle.provider,
1640
1652
  ok: false,
1641
- latencyMs: Date.now() - handle.attemptStart,
1653
+ latencyMs: now() - handle.attemptStart,
1642
1654
  errorClass: classifyError(err)
1643
1655
  }
1644
1656
  ];
@@ -1656,6 +1668,15 @@ function createMediaLCR(config) {
1656
1668
  return onLegFailure(err, isRetryableError(err));
1657
1669
  }
1658
1670
  if (status.status === "queued" || status.status === "running") {
1671
+ if (handle.deadlineAt !== void 0 && now() >= handle.deadlineAt) {
1672
+ const elapsedMs = now() - handle.startedAt;
1673
+ return onLegFailure(
1674
+ new Error(
1675
+ `ai-lcr: ${handle.provider} job ${handle.requestId} hit its timeout \u2014 still "${status.status}" after ${elapsedMs}ms (deadline ${handle.deadlineAt - handle.startedAt}ms)`
1676
+ ),
1677
+ true
1678
+ );
1679
+ }
1659
1680
  return { done: false, status: status.status, handle };
1660
1681
  }
1661
1682
  if (status.status === "done") {
@@ -1669,7 +1690,7 @@ function createMediaLCR(config) {
1669
1690
  const settled = settle(handle.pricing, handle.refCents, { ...status, outputs }, handle.input);
1670
1691
  const attempts = [
1671
1692
  ...handle.attempts,
1672
- { provider: handle.provider, ok: true, latencyMs: Date.now() - handle.attemptStart }
1693
+ { provider: handle.provider, ok: true, latencyMs: now() - handle.attemptStart }
1673
1694
  ];
1674
1695
  warnDrift(handle.modelId, handle.provider, settled);
1675
1696
  safeCost({
@@ -2059,6 +2080,7 @@ function createRunwareMediaAdapter(config) {
2059
2080
  // (a `getResponse` poll). Image generation stays on the synchronous `run()`.
2060
2081
  async submit(req) {
2061
2082
  const taskUUID = crypto.randomUUID();
2083
+ const webhookUrl = typeof req.metadata?.["webhookUrl"] === "string" ? req.metadata["webhookUrl"] : void 0;
2062
2084
  await postTask({
2063
2085
  outputType: "URL",
2064
2086
  includeCost: true,
@@ -2066,7 +2088,8 @@ function createRunwareMediaAdapter(config) {
2066
2088
  taskType: "videoInference",
2067
2089
  taskUUID,
2068
2090
  model: req.externalId,
2069
- deliveryMethod: "async"
2091
+ deliveryMethod: "async",
2092
+ ...webhookUrl ? { webhookURL: webhookUrl } : {}
2070
2093
  });
2071
2094
  return { requestId: taskUUID };
2072
2095
  },
@@ -2134,7 +2157,9 @@ function createFalMediaAdapter(config) {
2134
2157
  };
2135
2158
  const queueBase = (externalId) => externalId.split("/").slice(0, 2).join("/");
2136
2159
  async function submit(req) {
2137
- const submitRes = await fetchImpl(`${baseUrl}/${req.externalId}`, {
2160
+ const webhookUrl = typeof req.metadata?.["webhookUrl"] === "string" ? req.metadata["webhookUrl"] : void 0;
2161
+ const submitUrl = webhookUrl ? `${baseUrl}/${req.externalId}?fal_webhook=${encodeURIComponent(webhookUrl)}` : `${baseUrl}/${req.externalId}`;
2162
+ const submitRes = await fetchImpl(submitUrl, {
2138
2163
  method: "POST",
2139
2164
  headers,
2140
2165
  body: JSON.stringify(req.input)
@@ -2360,6 +2385,7 @@ function createLCR(config) {
2360
2385
  0 && (module.exports = {
2361
2386
  DEFAULT_PROVIDERS,
2362
2387
  DEFAULT_REFERENCE,
2388
+ DEFAULT_VIDEO_DEADLINE_MS,
2363
2389
  MEDIA_PRICING,
2364
2390
  MODEL_PRICES,
2365
2391
  OFFICIAL_PRICES,
package/dist/index.d.cts CHANGED
@@ -787,7 +787,31 @@ interface MediaLCRConfig {
787
787
  * throws. Media records carry no token counts (inputTokens/outputTokens = 0).
788
788
  */
789
789
  onCall?: (record: CallRecord) => void;
790
+ /**
791
+ * Default SLA for an async job, in ms: how long a submitted job may stay
792
+ * `queued`/`running` before `poll` declares it timed out and fails over to the
793
+ * next provider (see {@link MediaSubmitOptions.deadlineMs} for a per-job
794
+ * override). Defaults to {@link DEFAULT_VIDEO_DEADLINE_MS} (12 min) — long
795
+ * enough for a slow video render, short enough that a hung provider is caught
796
+ * instead of polling forever. Set per consumer to match its own product SLA.
797
+ */
798
+ defaultDeadlineMs?: number;
799
+ /**
800
+ * Injectable clock (epoch ms), defaulting to `Date.now`. The deadline math and
801
+ * every latency stamp read THIS, so a test can drive a job past its deadline
802
+ * deterministically without real waits. Production never sets it.
803
+ */
804
+ now?: () => number;
790
805
  }
806
+ /**
807
+ * Default async-job SLA: 12 minutes. A submitted job that stays
808
+ * `queued`/`running` longer than this is treated by `poll` as a provider failure
809
+ * and fails over to the next provider. Exposed (not buried) so the deadline is
810
+ * an explicit product knob, overridable per consumer via
811
+ * {@link MediaLCRConfig.defaultDeadlineMs} and per job via
812
+ * {@link MediaSubmitOptions.deadlineMs}.
813
+ */
814
+ declare const DEFAULT_VIDEO_DEADLINE_MS: number;
791
815
  interface MediaRunResult {
792
816
  outputs: MediaOutput[];
793
817
  provider: string;
@@ -799,6 +823,16 @@ interface MediaRunResult {
799
823
  interface MediaSubmitOptions {
800
824
  /** Opaque caller metadata forwarded to the provider's `submit`. */
801
825
  metadata?: Record<string, unknown>;
826
+ /**
827
+ * Per-job SLA in ms: how long this job may stay `queued`/`running` before
828
+ * `poll` declares it timed out and fails over to the next provider. Overrides
829
+ * {@link MediaLCRConfig.defaultDeadlineMs} (which defaults to
830
+ * {@link DEFAULT_VIDEO_DEADLINE_MS}, 12 min) for this submit only. The deadline
831
+ * is captured at submit time as an absolute instant on the handle and carried
832
+ * forward unchanged across a failover, so the whole request — not each leg —
833
+ * is bounded by it.
834
+ */
835
+ deadlineMs?: number;
802
836
  }
803
837
  /**
804
838
  * A serializable receipt for an in-flight async job, returned by `submit` and
@@ -859,6 +893,15 @@ interface MediaJobHandle {
859
893
  attemptStart: number;
860
894
  /** Failed attempts so far, threaded across processes for the final CallRecord. */
861
895
  attempts: CallRecord["attempts"];
896
+ /**
897
+ * Epoch ms the WHOLE request must finish by — the SLA captured at the first
898
+ * submit ({@link MediaSubmitOptions.deadlineMs} or the config default). When a
899
+ * `poll` finds the job still `queued`/`running` at/after this instant, the leg
900
+ * is treated as a provider failure and fails over to the next provider (with
901
+ * this same deadline carried forward, so a hung provider can't reset the clock).
902
+ * Absent on pre-0.8 handles — those never time out (the old behavior).
903
+ */
904
+ deadlineAt?: number;
862
905
  }
863
906
  /** Outcome of one `poll` call. `done:false` ⇒ keep polling `handle`. */
864
907
  type MediaPollResult = {
@@ -1199,4 +1242,4 @@ type LCRRouter = (modelName: string) => LanguageModelV3;
1199
1242
  */
1200
1243
  declare function createLCR(config: LCRConfig): LCRRouter;
1201
1244
 
1202
- export { type AnyLanguageModel, type BillableContext, type CacheOptions, type CacheStore, type CachedCall, type CachedMeta, type CallRecord, type CooldownOptions, type CostEvent, DEFAULT_PROVIDERS, DEFAULT_REFERENCE, type DefaultProviderId, type ErrorKind, type FormatOptions, type HttpSinkOptions, type LCRConfig, type LCRRouter, MEDIA_PRICING, MODEL_PRICES, 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, type MemoryCacheOptions, OFFICIAL_PRICES, type PriceComparisonRow, type PromptCacheOptions, type ProviderConfig, type ProviderCost, type ProviderEntry, type RankedRoute, type ReferenceSpec, type RouteAttempt, billableUnits, cheapestRoute, classifyError, classifyErrorKind, comparePrices, createEnvSink, createFalMediaAdapter, createHttpSink, createKunavoMediaAdapter, createLCR, createMediaLCR, createMemoryCacheStore, createRunwareMediaAdapter, durationFromInput, formatCallRecord, getModelPrice, isAbortError, isNetworkError, isRetryableError, normalizedCents, priceCents, rankRoutes, referenceMegapixels, shouldFailover };
1245
+ export { type AnyLanguageModel, type BillableContext, type CacheOptions, type CacheStore, type CachedCall, type CachedMeta, type CallRecord, type CooldownOptions, type CostEvent, DEFAULT_PROVIDERS, DEFAULT_REFERENCE, DEFAULT_VIDEO_DEADLINE_MS, type DefaultProviderId, type ErrorKind, type FormatOptions, type HttpSinkOptions, type LCRConfig, type LCRRouter, MEDIA_PRICING, MODEL_PRICES, 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, type MemoryCacheOptions, OFFICIAL_PRICES, type PriceComparisonRow, type PromptCacheOptions, type ProviderConfig, type ProviderCost, type ProviderEntry, type RankedRoute, type ReferenceSpec, type RouteAttempt, billableUnits, cheapestRoute, classifyError, classifyErrorKind, comparePrices, createEnvSink, createFalMediaAdapter, createHttpSink, createKunavoMediaAdapter, createLCR, createMediaLCR, createMemoryCacheStore, createRunwareMediaAdapter, durationFromInput, formatCallRecord, getModelPrice, isAbortError, isNetworkError, isRetryableError, normalizedCents, priceCents, rankRoutes, referenceMegapixels, shouldFailover };
package/dist/index.d.ts CHANGED
@@ -787,7 +787,31 @@ interface MediaLCRConfig {
787
787
  * throws. Media records carry no token counts (inputTokens/outputTokens = 0).
788
788
  */
789
789
  onCall?: (record: CallRecord) => void;
790
+ /**
791
+ * Default SLA for an async job, in ms: how long a submitted job may stay
792
+ * `queued`/`running` before `poll` declares it timed out and fails over to the
793
+ * next provider (see {@link MediaSubmitOptions.deadlineMs} for a per-job
794
+ * override). Defaults to {@link DEFAULT_VIDEO_DEADLINE_MS} (12 min) — long
795
+ * enough for a slow video render, short enough that a hung provider is caught
796
+ * instead of polling forever. Set per consumer to match its own product SLA.
797
+ */
798
+ defaultDeadlineMs?: number;
799
+ /**
800
+ * Injectable clock (epoch ms), defaulting to `Date.now`. The deadline math and
801
+ * every latency stamp read THIS, so a test can drive a job past its deadline
802
+ * deterministically without real waits. Production never sets it.
803
+ */
804
+ now?: () => number;
790
805
  }
806
+ /**
807
+ * Default async-job SLA: 12 minutes. A submitted job that stays
808
+ * `queued`/`running` longer than this is treated by `poll` as a provider failure
809
+ * and fails over to the next provider. Exposed (not buried) so the deadline is
810
+ * an explicit product knob, overridable per consumer via
811
+ * {@link MediaLCRConfig.defaultDeadlineMs} and per job via
812
+ * {@link MediaSubmitOptions.deadlineMs}.
813
+ */
814
+ declare const DEFAULT_VIDEO_DEADLINE_MS: number;
791
815
  interface MediaRunResult {
792
816
  outputs: MediaOutput[];
793
817
  provider: string;
@@ -799,6 +823,16 @@ interface MediaRunResult {
799
823
  interface MediaSubmitOptions {
800
824
  /** Opaque caller metadata forwarded to the provider's `submit`. */
801
825
  metadata?: Record<string, unknown>;
826
+ /**
827
+ * Per-job SLA in ms: how long this job may stay `queued`/`running` before
828
+ * `poll` declares it timed out and fails over to the next provider. Overrides
829
+ * {@link MediaLCRConfig.defaultDeadlineMs} (which defaults to
830
+ * {@link DEFAULT_VIDEO_DEADLINE_MS}, 12 min) for this submit only. The deadline
831
+ * is captured at submit time as an absolute instant on the handle and carried
832
+ * forward unchanged across a failover, so the whole request — not each leg —
833
+ * is bounded by it.
834
+ */
835
+ deadlineMs?: number;
802
836
  }
803
837
  /**
804
838
  * A serializable receipt for an in-flight async job, returned by `submit` and
@@ -859,6 +893,15 @@ interface MediaJobHandle {
859
893
  attemptStart: number;
860
894
  /** Failed attempts so far, threaded across processes for the final CallRecord. */
861
895
  attempts: CallRecord["attempts"];
896
+ /**
897
+ * Epoch ms the WHOLE request must finish by — the SLA captured at the first
898
+ * submit ({@link MediaSubmitOptions.deadlineMs} or the config default). When a
899
+ * `poll` finds the job still `queued`/`running` at/after this instant, the leg
900
+ * is treated as a provider failure and fails over to the next provider (with
901
+ * this same deadline carried forward, so a hung provider can't reset the clock).
902
+ * Absent on pre-0.8 handles — those never time out (the old behavior).
903
+ */
904
+ deadlineAt?: number;
862
905
  }
863
906
  /** Outcome of one `poll` call. `done:false` ⇒ keep polling `handle`. */
864
907
  type MediaPollResult = {
@@ -1199,4 +1242,4 @@ type LCRRouter = (modelName: string) => LanguageModelV3;
1199
1242
  */
1200
1243
  declare function createLCR(config: LCRConfig): LCRRouter;
1201
1244
 
1202
- export { type AnyLanguageModel, type BillableContext, type CacheOptions, type CacheStore, type CachedCall, type CachedMeta, type CallRecord, type CooldownOptions, type CostEvent, DEFAULT_PROVIDERS, DEFAULT_REFERENCE, type DefaultProviderId, type ErrorKind, type FormatOptions, type HttpSinkOptions, type LCRConfig, type LCRRouter, MEDIA_PRICING, MODEL_PRICES, 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, type MemoryCacheOptions, OFFICIAL_PRICES, type PriceComparisonRow, type PromptCacheOptions, type ProviderConfig, type ProviderCost, type ProviderEntry, type RankedRoute, type ReferenceSpec, type RouteAttempt, billableUnits, cheapestRoute, classifyError, classifyErrorKind, comparePrices, createEnvSink, createFalMediaAdapter, createHttpSink, createKunavoMediaAdapter, createLCR, createMediaLCR, createMemoryCacheStore, createRunwareMediaAdapter, durationFromInput, formatCallRecord, getModelPrice, isAbortError, isNetworkError, isRetryableError, normalizedCents, priceCents, rankRoutes, referenceMegapixels, shouldFailover };
1245
+ export { type AnyLanguageModel, type BillableContext, type CacheOptions, type CacheStore, type CachedCall, type CachedMeta, type CallRecord, type CooldownOptions, type CostEvent, DEFAULT_PROVIDERS, DEFAULT_REFERENCE, DEFAULT_VIDEO_DEADLINE_MS, type DefaultProviderId, type ErrorKind, type FormatOptions, type HttpSinkOptions, type LCRConfig, type LCRRouter, MEDIA_PRICING, MODEL_PRICES, 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, type MemoryCacheOptions, OFFICIAL_PRICES, type PriceComparisonRow, type PromptCacheOptions, type ProviderConfig, type ProviderCost, type ProviderEntry, type RankedRoute, type ReferenceSpec, type RouteAttempt, billableUnits, cheapestRoute, classifyError, classifyErrorKind, comparePrices, createEnvSink, createFalMediaAdapter, createHttpSink, createKunavoMediaAdapter, createLCR, createMediaLCR, createMemoryCacheStore, createRunwareMediaAdapter, durationFromInput, formatCallRecord, getModelPrice, isAbortError, isNetworkError, isRetryableError, normalizedCents, priceCents, rankRoutes, referenceMegapixels, shouldFailover };
package/dist/index.js CHANGED
@@ -1325,6 +1325,7 @@ function comparePrices(registry, ref = DEFAULT_REFERENCE) {
1325
1325
  };
1326
1326
  });
1327
1327
  }
1328
+ var DEFAULT_VIDEO_DEADLINE_MS = 12 * 60 * 1e3;
1328
1329
  function newMediaCallId() {
1329
1330
  const c = globalThis.crypto;
1330
1331
  return c?.randomUUID ? c.randomUUID() : `lcr_${Date.now().toString(36)}`;
@@ -1337,7 +1338,9 @@ function createMediaLCR(config) {
1337
1338
  officialPrices = OFFICIAL_PRICES,
1338
1339
  onError,
1339
1340
  onCost,
1340
- onCall
1341
+ onCall,
1342
+ defaultDeadlineMs = DEFAULT_VIDEO_DEADLINE_MS,
1343
+ now = Date.now
1341
1344
  } = config;
1342
1345
  const safeError = (error, provider) => {
1343
1346
  try {
@@ -1417,7 +1420,7 @@ function createMediaLCR(config) {
1417
1420
  winner: void 0,
1418
1421
  ok: false,
1419
1422
  failedOver: attempts.length > 1,
1420
- latencyMs: Date.now() - startedAt,
1423
+ latencyMs: now() - startedAt,
1421
1424
  inputTokens: 0,
1422
1425
  outputTokens: 0,
1423
1426
  costUsd: 0,
@@ -1435,7 +1438,7 @@ function createMediaLCR(config) {
1435
1438
  winner: args.provider,
1436
1439
  ok: true,
1437
1440
  failedOver: args.attempts.length > 1,
1438
- latencyMs: Date.now() - args.startedAt,
1441
+ latencyMs: now() - args.startedAt,
1439
1442
  inputTokens: 0,
1440
1443
  outputTokens: 0,
1441
1444
  costUsd: settled.costCents / 100,
@@ -1449,17 +1452,17 @@ function createMediaLCR(config) {
1449
1452
  };
1450
1453
  const generate = async function generate2(modelId, input) {
1451
1454
  const { def, ranked, baseline } = resolve(modelId);
1452
- const startedAt = Date.now();
1455
+ const startedAt = now();
1453
1456
  const attempts = [];
1454
1457
  let lastErr;
1455
1458
  for (const route of ranked) {
1456
1459
  const adapter = adapters[route.provider];
1457
1460
  if (!adapter) continue;
1458
- const attemptStart = Date.now();
1461
+ const attemptStart = now();
1459
1462
  try {
1460
1463
  const result = await adapter.run({ externalId: route.externalId, input });
1461
1464
  const settled = settle(route.pricing, route.refCents, result, input);
1462
- attempts.push({ provider: route.provider, ok: true, latencyMs: Date.now() - attemptStart });
1465
+ attempts.push({ provider: route.provider, ok: true, latencyMs: now() - attemptStart });
1463
1466
  warnDrift(modelId, route.provider, settled);
1464
1467
  safeCost({
1465
1468
  modelId,
@@ -1488,7 +1491,7 @@ function createMediaLCR(config) {
1488
1491
  attempts.push({
1489
1492
  provider: route.provider,
1490
1493
  ok: false,
1491
- latencyMs: Date.now() - attemptStart,
1494
+ latencyMs: now() - attemptStart,
1492
1495
  errorClass: classifyError(err)
1493
1496
  });
1494
1497
  safeError(err, route.provider);
@@ -1505,13 +1508,13 @@ function createMediaLCR(config) {
1505
1508
  const { ranked } = resolve(modelId);
1506
1509
  return ranked.filter((r) => typeof adapters[r.provider]?.submit === "function");
1507
1510
  };
1508
- async function submitFrom(modelId, routes, input, metadata, baseline, startedAt, attempts) {
1511
+ async function submitFrom(modelId, routes, input, metadata, baseline, startedAt, attempts, deadlineAt) {
1509
1512
  let lastErr;
1510
1513
  for (let i = 0; i < routes.length; i++) {
1511
1514
  const route = routes[i];
1512
1515
  const adapter = adapters[route.provider];
1513
1516
  if (!adapter?.submit) continue;
1514
- const attemptStart = Date.now();
1517
+ const attemptStart = now();
1515
1518
  try {
1516
1519
  const { requestId } = await adapter.submit({ externalId: route.externalId, input, metadata });
1517
1520
  return {
@@ -1528,14 +1531,17 @@ function createMediaLCR(config) {
1528
1531
  baselineUsd: refBaselineUsd(baseline),
1529
1532
  startedAt,
1530
1533
  attemptStart,
1531
- attempts
1534
+ attempts,
1535
+ // Carry the SLA forward unchanged across a re-submit, so a hung
1536
+ // provider can't reset the request's clock by failing over.
1537
+ ...deadlineAt !== void 0 ? { deadlineAt } : {}
1532
1538
  };
1533
1539
  } catch (err) {
1534
1540
  lastErr = err;
1535
1541
  attempts.push({
1536
1542
  provider: route.provider,
1537
1543
  ok: false,
1538
- latencyMs: Date.now() - attemptStart,
1544
+ latencyMs: now() - attemptStart,
1539
1545
  errorClass: classifyError(err)
1540
1546
  });
1541
1547
  safeError(err, route.provider);
@@ -1553,7 +1559,10 @@ function createMediaLCR(config) {
1553
1559
  `ai-lcr: no provider for media model "${modelId}" supports async submit (need an adapter with submit/checkStatus)`
1554
1560
  );
1555
1561
  }
1556
- return submitFrom(modelId, usable, input, options?.metadata, baseline, Date.now(), []);
1562
+ const startedAt = now();
1563
+ const deadlineMs = options?.deadlineMs ?? defaultDeadlineMs;
1564
+ const deadlineAt = typeof deadlineMs === "number" && deadlineMs > 0 ? startedAt + deadlineMs : void 0;
1565
+ return submitFrom(modelId, usable, input, options?.metadata, baseline, startedAt, [], deadlineAt);
1557
1566
  };
1558
1567
  generate.poll = async function poll(handle) {
1559
1568
  const adapter = adapters[handle.provider];
@@ -1574,7 +1583,9 @@ function createMediaLCR(config) {
1574
1583
  handle.metadata,
1575
1584
  handle.baseline ?? baseline,
1576
1585
  handle.startedAt,
1577
- attempts
1586
+ attempts,
1587
+ handle.deadlineAt
1588
+ // same SLA instant — the new leg inherits the clock
1578
1589
  );
1579
1590
  return { done: false, status: "queued", handle: newHandle, failedOver: true };
1580
1591
  };
@@ -1584,7 +1595,7 @@ function createMediaLCR(config) {
1584
1595
  {
1585
1596
  provider: handle.provider,
1586
1597
  ok: false,
1587
- latencyMs: Date.now() - handle.attemptStart,
1598
+ latencyMs: now() - handle.attemptStart,
1588
1599
  errorClass: classifyError(err)
1589
1600
  }
1590
1601
  ];
@@ -1602,6 +1613,15 @@ function createMediaLCR(config) {
1602
1613
  return onLegFailure(err, isRetryableError(err));
1603
1614
  }
1604
1615
  if (status.status === "queued" || status.status === "running") {
1616
+ if (handle.deadlineAt !== void 0 && now() >= handle.deadlineAt) {
1617
+ const elapsedMs = now() - handle.startedAt;
1618
+ return onLegFailure(
1619
+ new Error(
1620
+ `ai-lcr: ${handle.provider} job ${handle.requestId} hit its timeout \u2014 still "${status.status}" after ${elapsedMs}ms (deadline ${handle.deadlineAt - handle.startedAt}ms)`
1621
+ ),
1622
+ true
1623
+ );
1624
+ }
1605
1625
  return { done: false, status: status.status, handle };
1606
1626
  }
1607
1627
  if (status.status === "done") {
@@ -1615,7 +1635,7 @@ function createMediaLCR(config) {
1615
1635
  const settled = settle(handle.pricing, handle.refCents, { ...status, outputs }, handle.input);
1616
1636
  const attempts = [
1617
1637
  ...handle.attempts,
1618
- { provider: handle.provider, ok: true, latencyMs: Date.now() - handle.attemptStart }
1638
+ { provider: handle.provider, ok: true, latencyMs: now() - handle.attemptStart }
1619
1639
  ];
1620
1640
  warnDrift(handle.modelId, handle.provider, settled);
1621
1641
  safeCost({
@@ -2005,6 +2025,7 @@ function createRunwareMediaAdapter(config) {
2005
2025
  // (a `getResponse` poll). Image generation stays on the synchronous `run()`.
2006
2026
  async submit(req) {
2007
2027
  const taskUUID = crypto.randomUUID();
2028
+ const webhookUrl = typeof req.metadata?.["webhookUrl"] === "string" ? req.metadata["webhookUrl"] : void 0;
2008
2029
  await postTask({
2009
2030
  outputType: "URL",
2010
2031
  includeCost: true,
@@ -2012,7 +2033,8 @@ function createRunwareMediaAdapter(config) {
2012
2033
  taskType: "videoInference",
2013
2034
  taskUUID,
2014
2035
  model: req.externalId,
2015
- deliveryMethod: "async"
2036
+ deliveryMethod: "async",
2037
+ ...webhookUrl ? { webhookURL: webhookUrl } : {}
2016
2038
  });
2017
2039
  return { requestId: taskUUID };
2018
2040
  },
@@ -2080,7 +2102,9 @@ function createFalMediaAdapter(config) {
2080
2102
  };
2081
2103
  const queueBase = (externalId) => externalId.split("/").slice(0, 2).join("/");
2082
2104
  async function submit(req) {
2083
- const submitRes = await fetchImpl(`${baseUrl}/${req.externalId}`, {
2105
+ const webhookUrl = typeof req.metadata?.["webhookUrl"] === "string" ? req.metadata["webhookUrl"] : void 0;
2106
+ const submitUrl = webhookUrl ? `${baseUrl}/${req.externalId}?fal_webhook=${encodeURIComponent(webhookUrl)}` : `${baseUrl}/${req.externalId}`;
2107
+ const submitRes = await fetchImpl(submitUrl, {
2084
2108
  method: "POST",
2085
2109
  headers,
2086
2110
  body: JSON.stringify(req.input)
@@ -2305,6 +2329,7 @@ function createLCR(config) {
2305
2329
  export {
2306
2330
  DEFAULT_PROVIDERS,
2307
2331
  DEFAULT_REFERENCE,
2332
+ DEFAULT_VIDEO_DEADLINE_MS,
2308
2333
  MEDIA_PRICING,
2309
2334
  MODEL_PRICES,
2310
2335
  OFFICIAL_PRICES,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lcr",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
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",