ai-lcr 0.7.1 → 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 +24 -0
- package/dist/index.cjs +37 -15
- package/dist/index.d.cts +44 -1
- package/dist/index.d.ts +44 -1
- package/dist/index.js +36 -15
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,30 @@ 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
|
+
|
|
7
31
|
## [0.7.1] — 2026-06-20
|
|
8
32
|
|
|
9
33
|
Async media adapters now forward a caller-supplied webhook URL to the 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:
|
|
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:
|
|
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 =
|
|
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 =
|
|
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:
|
|
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:
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
1693
|
+
{ provider: handle.provider, ok: true, latencyMs: now() - handle.attemptStart }
|
|
1673
1694
|
];
|
|
1674
1695
|
warnDrift(handle.modelId, handle.provider, settled);
|
|
1675
1696
|
safeCost({
|
|
@@ -2364,6 +2385,7 @@ function createLCR(config) {
|
|
|
2364
2385
|
0 && (module.exports = {
|
|
2365
2386
|
DEFAULT_PROVIDERS,
|
|
2366
2387
|
DEFAULT_REFERENCE,
|
|
2388
|
+
DEFAULT_VIDEO_DEADLINE_MS,
|
|
2367
2389
|
MEDIA_PRICING,
|
|
2368
2390
|
MODEL_PRICES,
|
|
2369
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:
|
|
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:
|
|
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 =
|
|
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 =
|
|
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:
|
|
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:
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
1638
|
+
{ provider: handle.provider, ok: true, latencyMs: now() - handle.attemptStart }
|
|
1619
1639
|
];
|
|
1620
1640
|
warnDrift(handle.modelId, handle.provider, settled);
|
|
1621
1641
|
safeCost({
|
|
@@ -2309,6 +2329,7 @@ function createLCR(config) {
|
|
|
2309
2329
|
export {
|
|
2310
2330
|
DEFAULT_PROVIDERS,
|
|
2311
2331
|
DEFAULT_REFERENCE,
|
|
2332
|
+
DEFAULT_VIDEO_DEADLINE_MS,
|
|
2312
2333
|
MEDIA_PRICING,
|
|
2313
2334
|
MODEL_PRICES,
|
|
2314
2335
|
OFFICIAL_PRICES,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-lcr",
|
|
3
|
-
"version": "0.7.
|
|
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",
|