ai-lcr 0.5.3 → 0.5.5
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 +67 -0
- package/README.md +9 -5
- package/README.zh-CN.md +2 -2
- package/dist/index.cjs +113 -44
- package/dist/index.d.cts +74 -16
- package/dist/index.d.ts +74 -16
- package/dist/index.js +108 -43
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,73 @@ 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.5.5] — 2026-06-06
|
|
8
|
+
|
|
9
|
+
Kunavo media (image + video) verified live and properly wired. The Kunavo
|
|
10
|
+
adapter previously had a working image path but an unverified, broken video
|
|
11
|
+
path; this release fixes the video path against the real API and adds the
|
|
12
|
+
reference-image edit endpoint. Backward compatible — `videoMode` defaults to
|
|
13
|
+
the new async path, and existing image routes are unchanged.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **Kunavo video hit the wrong endpoints.** `createKunavoMediaAdapter`'s
|
|
18
|
+
`runVideo` POSTed to the sync `POST /v1/video/generations` but then polled a
|
|
19
|
+
non-existent `GET /v1/video/generations/{id}` — unreachable dead code that
|
|
20
|
+
only ever worked through an inline early-return. Replaced with Kunavo's real,
|
|
21
|
+
live-verified endpoints (see Added). Long video SKUs no longer risk a hung,
|
|
22
|
+
timeout-less `fetch`: both video paths are now bounded.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **Kunavo async video (default).** Verified live 2026-06-06: `veo-3-lite`
|
|
27
|
+
renders a real 720p mp4 via `POST /v1/videos` → poll `GET /v1/videos/{id}`
|
|
28
|
+
(~80s). This is the adapter's default and mirrors the fal submit→poll shape.
|
|
29
|
+
A poll timeout surfaces as a retryable `504` so the media router fails over.
|
|
30
|
+
- **Kunavo sync video fallback.** New `KunavoMediaConfig.videoMode: "sync"`
|
|
31
|
+
uses the blocking `POST /v1/video/generations` (~108s for veo-3-lite),
|
|
32
|
+
hard-capped by `syncVideoTimeoutMs` (default 10m, remapped to a retryable
|
|
33
|
+
`504` on timeout). `pollIntervalMs` / `pollTimeoutMs` now actually drive the
|
|
34
|
+
async path.
|
|
35
|
+
- **Kunavo image edit (reference image).** `*-edit` slugs
|
|
36
|
+
(`nano-banana-edit`, `gpt-image-2-edit`) route to `POST /v1/images/edits`
|
|
37
|
+
with the caller's `image` / `image_urls[]` — the character-reference path.
|
|
38
|
+
- **`scripts/check-kunavo-media.sh`** — a `bash` + `curl` + `jq` live media
|
|
39
|
+
integrity probe (image gen, edit, async + sync video) mirroring the text
|
|
40
|
+
`check-provider.sh`.
|
|
41
|
+
- **Test coverage for the Kunavo media adapter**, which previously shipped with
|
|
42
|
+
none (fal and Runware had tests; Kunavo did not).
|
|
43
|
+
|
|
44
|
+
## [0.5.4] — 2026-06-03
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- **A provider 400 now fails over instead of being passed through.** Previously
|
|
49
|
+
any client error (400/422/…) was treated as the caller's fault and thrown
|
|
50
|
+
immediately, killing the request even when another provider would have served
|
|
51
|
+
it. But across OpenAI-compatible aggregators a 400 is most often
|
|
52
|
+
*provider-specific* — an unsupported parameter, a model the provider hasn't
|
|
53
|
+
listed, a stricter JSON schema — not a universally-broken request. The default
|
|
54
|
+
failover gate (`shouldFailover`) now advances to the next provider on **any**
|
|
55
|
+
failure except a deliberate caller cancellation (`AbortSignal`), which is the
|
|
56
|
+
one thing we must never re-issue elsewhere. When every provider rejects the
|
|
57
|
+
request it still throws — now surfacing the **first** (original) error rather
|
|
58
|
+
than the last fallback's, so a genuine caller bug stays debuggable. Failed
|
|
59
|
+
attempts keep their precise `ErrorKind` (`"client"` for a 400) in the
|
|
60
|
+
`CallRecord`, so a real bug is still visible.
|
|
61
|
+
|
|
62
|
+
To restore the old "client errors fail fast" behavior, pass
|
|
63
|
+
`shouldRetry: isRetryableError` to `createLCR`.
|
|
64
|
+
|
|
65
|
+
### Added
|
|
66
|
+
|
|
67
|
+
- **`createLCR({ shouldRetry })`.** The failover predicate is now configurable
|
|
68
|
+
from the top-level API (it previously existed only on the internal engine), so
|
|
69
|
+
callers can tune or fully override the policy above.
|
|
70
|
+
- **Exported error predicates** `isRetryableError`, `isNetworkError`,
|
|
71
|
+
`isAbortError`, and `shouldFailover` — building blocks for a custom
|
|
72
|
+
`shouldRetry`.
|
|
73
|
+
|
|
7
74
|
## [0.5.3] — 2026-06-03
|
|
8
75
|
|
|
9
76
|
All additions are optional and backward compatible.
|
package/README.md
CHANGED
|
@@ -141,7 +141,7 @@ DeepInfra carries open weights only — no first-party Claude / GPT / Gemini. Fo
|
|
|
141
141
|
## How it routes
|
|
142
142
|
|
|
143
143
|
1. **Cheapest first.** Providers are tried in order — list them cheapest-first, or set `autoSort: true` to order them by `cost` automatically.
|
|
144
|
-
2. **Fall through on failure.** On
|
|
144
|
+
2. **Fall through on failure.** On any provider failure — rate limit, 5xx, timeout, a **billing cap** (402 / out-of-credit / quota), *and* a client error like a **400** — it advances to the next provider, streaming-safe. A 400 fails over on purpose: across OpenAI-compatible aggregators a 400 is usually "*this* provider won't take this request" (an unsupported param, a model it hasn't listed, a stricter schema), not a universally-broken request — so the next provider may well serve it. If every provider rejects the request it still fails, surfacing the **original** error so a genuine caller bug stays debuggable. The one failure that never fails over is a deliberate caller cancellation (`AbortSignal`). Pass `shouldRetry: isRetryableError` to `createLCR` to restore the stricter "client errors fail fast" behavior.
|
|
145
145
|
3. **Recover.** After an idle window (`resetIntervalMs`, default 60s) it snaps back to the cheapest provider.
|
|
146
146
|
|
|
147
147
|
## See what happened (`onCall`)
|
|
@@ -232,7 +232,7 @@ Any OpenAI-compatible endpoint works — and so does any AI SDK provider package
|
|
|
232
232
|
|
|
233
233
|
- **Model vendors' own APIs (native):** route straight to [DeepSeek](https://platform.deepseek.com), [OpenAI](https://openai.com), [Anthropic](https://anthropic.com), [Google](https://ai.google.dev), [xAI](https://x.ai), etc. via their AI SDK provider packages — no markup, full native features. See [Route to a model vendor's own API](#route-to-a-model-vendors-own-api-native-providers).
|
|
234
234
|
- **Text aggregators:** [OpenRouter](https://openrouter.ai) (widest coverage, list pricing) · [Kunavo](https://kunavo.com/?ref=victorimf) (**20% off** every model) · [TokenMart](https://thetokenmart.ai) (15–65% off, varies by model)
|
|
235
|
-
- **Image / video:** [Kunavo](https://kunavo.com/?ref=victorimf) (**20% off**) · [TokenMart](https://thetokenmart.ai) · [fal.ai](https://fal.ai) · [Runware](https://runware.ai) — routing via `createMediaLCR`. Image: Kunavo + Runware + fal. Video: fal (
|
|
235
|
+
- **Image / video:** [Kunavo](https://kunavo.com/?ref=victorimf) (**20% off**) · [TokenMart](https://thetokenmart.ai) · [fal.ai](https://fal.ai) · [Runware](https://runware.ai) — routing via `createMediaLCR`. Image: Kunavo (generations + `*-edit` reference-image endpoints) + Runware + fal. Video: fal (async queue) and Kunavo (async `POST /v1/videos` + poll, sync fallback) — both verified live
|
|
236
236
|
|
|
237
237
|
## Text model pricing
|
|
238
238
|
|
|
@@ -277,6 +277,8 @@ USD per image, as of 2026-05 (provider list / retail; verify current rates). Kun
|
|
|
277
277
|
|
|
278
278
|
USD per second, as of 2026-05 — verify current rates. Video billing differs by provider, so a clean cross-provider table isn't apples-to-apples: fal.ai and Runware charge per second, while Kunavo's Veo is per clip (Fast ~$0.28 / Lite ~$0.168 / Quality ~$1.34). Below are fal.ai's per-second rates (the video workhorse in testing); a normalized fal / Runware / Kunavo comparison is a TODO.
|
|
279
279
|
|
|
280
|
+
> **Kunavo video — verified live 2026-06-06.** `veo-3-lite` renders a real 720p mp4 via Kunavo's async API (`POST /v1/videos` → poll `GET /v1/videos/{id}`, ~80s) and its sync fallback (`POST /v1/video/generations`, ~108s). The `createMediaLCR` Kunavo adapter defaults to async (non-blocking, fal-isomorphic). Two caveats: per-clip prices are hand-entered (`GET /v1/models` returns no pricing), and the async queue can occasionally sit much longer than 80s — the adapter's `pollTimeoutMs` bounds it so the router can fail over.
|
|
281
|
+
|
|
280
282
|
| Model | fal.ai ($/s) |
|
|
281
283
|
|---|---|
|
|
282
284
|
| Seedance Lite | $0.036 |
|
|
@@ -293,6 +295,8 @@ USD per second, as of 2026-05 — verify current rates. Video billing differs by
|
|
|
293
295
|
|
|
294
296
|
A discount is worthless if the provider quietly breaks the wire protocol. `ai-lcr` ships a zero-dependency check (`scripts/check-provider.sh`, just `bash` + `curl` + `python3`) that vets the things that actually cost you money or corrupt output, **per model**:
|
|
295
297
|
|
|
298
|
+
> **Media providers** have their own probe: `scripts/check-kunavo-media.sh` (`bash` + `curl` + `jq`) live-tests Kunavo's image generation, `*-edit` reference endpoint, and async + sync video — the same checks used to verify the routes above. Run it before trusting a media route in production.
|
|
299
|
+
|
|
296
300
|
- **tool calling** — single call and a multi-step round-trip with `content: null` (the shape every agent loop sends)
|
|
297
301
|
- **`max_tokens` honored** — caps must bound output
|
|
298
302
|
- **hidden-prompt injection** — sends a neutral message; flags the provider if the model starts reacting to a system prompt it was never given
|
|
@@ -349,8 +353,8 @@ Two OpenAI-compatible providers, same probe, same day. Cells cover both families
|
|
|
349
353
|
- [ ] Bundled price table for zero-config pricing (drop the manual `cost` numbers)
|
|
350
354
|
- [ ] Provider-quirk middleware (transparently patch known per-provider request quirks, e.g. Kunavo's ignored `max_tokens`)
|
|
351
355
|
- [ ] Feed probe results into routing automatically (auto-exclude a model from a provider that fails its probe)
|
|
352
|
-
- [x] Image & video model routing (`createMediaLCR`) — image via Kunavo + Runware + fal; **video live via fal** (
|
|
353
|
-
- [ ] Normalized cross-provider video price comparison + verified
|
|
356
|
+
- [x] Image & video model routing (`createMediaLCR`) — image via Kunavo (incl. `*-edit`) + Runware + fal; **video live via fal and Kunavo** (both verified)
|
|
357
|
+
- [ ] Normalized cross-provider video price comparison + verified Runware video adapter
|
|
354
358
|
|
|
355
359
|
## Affiliate disclosure
|
|
356
360
|
|
|
@@ -364,7 +368,7 @@ npm run typecheck
|
|
|
364
368
|
npm test # mocked routing/failover tests + live Kunavo tests
|
|
365
369
|
```
|
|
366
370
|
|
|
367
|
-
The suite covers cheapest-first routing, failover on retryable errors
|
|
371
|
+
The suite covers cheapest-first routing, failover on retryable errors *and* on a provider 400 (but *not* on a caller cancellation), surfacing the original error when the whole chain is exhausted, and a real broken-provider → Kunavo recovery. Live tests run only when `KUNAVO_API_KEY` is set in the environment; otherwise they're skipped.
|
|
368
372
|
|
|
369
373
|
## Credits
|
|
370
374
|
|
package/README.zh-CN.md
CHANGED
|
@@ -141,7 +141,7 @@ DeepInfra 只承载开源权重——没有第一方 Claude / GPT / Gemini。那
|
|
|
141
141
|
## 它如何路由
|
|
142
142
|
|
|
143
143
|
1. **最便宜优先。** provider 按顺序依次尝试——把它们排成最便宜优先,或设置 `autoSort: true` 让它按 `cost` 自动排序。
|
|
144
|
-
2. **失败时向下穿透。**
|
|
144
|
+
2. **失败时向下穿透。** 遇到任何 provider 失败——限流、5xx、超时、**额度耗尽**(402 / 欠费 / 余额不足),以及 **400** 这类 client 错误——都会前进到下一个 provider,且对流式安全。400 会 failover 是有意为之:在 OpenAI 兼容聚合层里,400 往往是"*这家* provider 不吃这个请求"(不支持的参数、它没上架这个 model、更严格的 schema),而非请求本身坏了——换一家很可能就能服务。若所有 provider 都拒绝,请求仍会失败,并抛出**第一个**(原始)错误,让真正的调用方 bug 保持可调试。唯一永远不 failover 的是调用方主动取消(`AbortSignal`)。想恢复旧的"client 错误立即失败"行为,给 `createLCR` 传 `shouldRetry: isRetryableError`。
|
|
145
145
|
3. **恢复。** 在一段空闲窗口(`resetIntervalMs`,默认 60s)之后,自动回到最便宜的 provider。
|
|
146
146
|
|
|
147
147
|
## 支持的 provider
|
|
@@ -280,7 +280,7 @@ npm run typecheck
|
|
|
280
280
|
npm test # mock 的路由 / failover 测试 + 真实 Kunavo 测试
|
|
281
281
|
```
|
|
282
282
|
|
|
283
|
-
|
|
283
|
+
测试套件覆盖了:最便宜优先路由、可重试错误以及 provider 400 时的 failover(但调用方主动取消时*不*做 failover)、穷尽整条链路时抛出原始错误,以及一次真实的「provider 故障 → Kunavo 恢复」。真实测试仅在环境变量 `KUNAVO_API_KEY` 设置时运行,否则跳过。
|
|
284
284
|
|
|
285
285
|
## 致谢
|
|
286
286
|
|
package/dist/index.cjs
CHANGED
|
@@ -34,9 +34,13 @@ __export(index_exports, {
|
|
|
34
34
|
createMediaLCR: () => createMediaLCR,
|
|
35
35
|
createRunwareMediaAdapter: () => createRunwareMediaAdapter,
|
|
36
36
|
formatCallRecord: () => formatCallRecord,
|
|
37
|
+
isAbortError: () => isAbortError,
|
|
38
|
+
isNetworkError: () => isNetworkError,
|
|
39
|
+
isRetryableError: () => isRetryableError,
|
|
37
40
|
normalizedCents: () => normalizedCents,
|
|
38
41
|
rankRoutes: () => rankRoutes,
|
|
39
|
-
referenceMegapixels: () => referenceMegapixels
|
|
42
|
+
referenceMegapixels: () => referenceMegapixels,
|
|
43
|
+
shouldFailover: () => shouldFailover
|
|
40
44
|
});
|
|
41
45
|
module.exports = __toCommonJS(index_exports);
|
|
42
46
|
|
|
@@ -158,6 +162,15 @@ function isRetryableError(error) {
|
|
|
158
162
|
const { text } = errorSignals(error);
|
|
159
163
|
return RETRYABLE_PATTERNS.some((p) => text.includes(p));
|
|
160
164
|
}
|
|
165
|
+
function isAbortError(error) {
|
|
166
|
+
const e = error;
|
|
167
|
+
if (typeof e?.name === "string" && e.name === "AbortError") return true;
|
|
168
|
+
const { text } = errorSignals(error);
|
|
169
|
+
return text.includes("operation was aborted") || text.includes("operation was canceled");
|
|
170
|
+
}
|
|
171
|
+
function shouldFailover(error) {
|
|
172
|
+
return !isAbortError(error);
|
|
173
|
+
}
|
|
161
174
|
function classifyError(error) {
|
|
162
175
|
if (error instanceof EmptyCompletionError) return "empty_completion";
|
|
163
176
|
const e = error;
|
|
@@ -281,7 +294,7 @@ var LcrFallbackModel = class {
|
|
|
281
294
|
this.lastFailoverAt = Date.now();
|
|
282
295
|
}
|
|
283
296
|
shouldRetry(error) {
|
|
284
|
-
return (this.opts.shouldRetry ??
|
|
297
|
+
return (this.opts.shouldRetry ?? shouldFailover)(error);
|
|
285
298
|
}
|
|
286
299
|
// Observer callbacks are caller-supplied logging hooks: a throw from one of
|
|
287
300
|
// them must NEVER turn a successful (or already-failed) request into a
|
|
@@ -314,6 +327,7 @@ var LcrFallbackModel = class {
|
|
|
314
327
|
}
|
|
315
328
|
/** Record a failed attempt onto the call's chain (no event yet). */
|
|
316
329
|
recordFail(ctx, provider, attemptStart, error) {
|
|
330
|
+
if (ctx.firstError === void 0) ctx.firstError = error;
|
|
317
331
|
ctx.attempts.push({
|
|
318
332
|
provider: provider.label,
|
|
319
333
|
ok: false,
|
|
@@ -429,7 +443,7 @@ var LcrFallbackModel = class {
|
|
|
429
443
|
}
|
|
430
444
|
}
|
|
431
445
|
this.finalizeFail(ctx);
|
|
432
|
-
throw lastError;
|
|
446
|
+
throw ctx.firstError ?? lastError;
|
|
433
447
|
}
|
|
434
448
|
async doStream(options) {
|
|
435
449
|
return this.doStreamWithCtx(options, this.startCall(options), this.startIndex(), 0);
|
|
@@ -465,7 +479,7 @@ var LcrFallbackModel = class {
|
|
|
465
479
|
tried++;
|
|
466
480
|
if (tried >= n) {
|
|
467
481
|
this.finalizeFail(ctx);
|
|
468
|
-
throw error;
|
|
482
|
+
throw ctx.firstError ?? error;
|
|
469
483
|
}
|
|
470
484
|
idx = (idx + 1) % n;
|
|
471
485
|
}
|
|
@@ -513,7 +527,7 @@ var LcrFallbackModel = class {
|
|
|
513
527
|
const nextTried = triedBeforeServing + 1;
|
|
514
528
|
if (nextTried >= n) {
|
|
515
529
|
self.finalizeFail(ctx);
|
|
516
|
-
controller.error(error);
|
|
530
|
+
controller.error(ctx.firstError ?? error);
|
|
517
531
|
return;
|
|
518
532
|
}
|
|
519
533
|
try {
|
|
@@ -895,11 +909,15 @@ var MEDIA_PRICING = {
|
|
|
895
909
|
]
|
|
896
910
|
},
|
|
897
911
|
// ── Google video (Veo) ──────────────────────────────────────
|
|
898
|
-
//
|
|
899
|
-
// (
|
|
900
|
-
//
|
|
901
|
-
//
|
|
902
|
-
//
|
|
912
|
+
// Kunavo video VERIFIED live 2026-06-06: veo-3-lite renders via both the async
|
|
913
|
+
// path (POST /v1/videos + poll, ~80s) and the sync path (POST /v1/video/
|
|
914
|
+
// generations, ~108s), real 720p mp4 out. The adapter defaults to async.
|
|
915
|
+
// ⚠️ Two caveats remain on the PRICE gap, not the capability: (1) Version/SKU
|
|
916
|
+
// mismatch — Kunavo bills "veo-3" per CALL (flat per clip, ~8s 720p) while fal
|
|
917
|
+
// bills "veo3.1" per SECOND, so normalized to a 5s clip the per-call price wins
|
|
918
|
+
// by a wide margin; (2) /v1/models exposes NO pricing, so the per-call cents
|
|
919
|
+
// below are hand-entered — verify clip duration/resolution/audio before
|
|
920
|
+
// trusting the gap. veo-3 / veo-3-quality capability not individually rendered.
|
|
903
921
|
"google/veo-3": {
|
|
904
922
|
id: "google/veo-3",
|
|
905
923
|
modality: "video",
|
|
@@ -912,7 +930,7 @@ var MEDIA_PRICING = {
|
|
|
912
930
|
id: "google/veo-3-lite",
|
|
913
931
|
modality: "video",
|
|
914
932
|
routes: [
|
|
915
|
-
{ provider: "kunavo", externalId: "veo-3-lite", pricing: { unit: "call", cents: 16 }, note: "flat per clip (
|
|
933
|
+
{ provider: "kunavo", externalId: "veo-3-lite", pricing: { unit: "call", cents: 16 }, note: "flat per clip; rendering verified 2026-06-06 (720p, async+sync); price hand-entered" },
|
|
916
934
|
{ provider: "fal", externalId: "fal-ai/veo3.1/lite", pricing: { unit: "second", cents: 8 }, note: "veo3.1 lite, 1080p audio-on" }
|
|
917
935
|
]
|
|
918
936
|
},
|
|
@@ -932,12 +950,26 @@ function extractImageUrls(body) {
|
|
|
932
950
|
if (!Array.isArray(data)) return [];
|
|
933
951
|
return data.map((d) => d?.url).filter((u) => typeof u === "string" && u.length > 0);
|
|
934
952
|
}
|
|
953
|
+
function extractVideoUrls(body) {
|
|
954
|
+
const output = body.output;
|
|
955
|
+
if (output) {
|
|
956
|
+
if (Array.isArray(output.urls)) {
|
|
957
|
+
const urls = output.urls.filter((u) => typeof u === "string" && u.length > 0);
|
|
958
|
+
if (urls.length > 0) return urls;
|
|
959
|
+
}
|
|
960
|
+
if (typeof output.url === "string" && output.url.length > 0) return [output.url];
|
|
961
|
+
}
|
|
962
|
+
if (typeof body.url === "string" && body.url.length > 0) return [body.url];
|
|
963
|
+
return extractImageUrls(body);
|
|
964
|
+
}
|
|
935
965
|
function createKunavoMediaAdapter(config) {
|
|
936
966
|
const {
|
|
937
967
|
apiKey,
|
|
938
968
|
baseUrl = DEFAULT_BASE,
|
|
969
|
+
videoMode = "async",
|
|
939
970
|
pollIntervalMs = 5e3,
|
|
940
|
-
pollTimeoutMs =
|
|
971
|
+
pollTimeoutMs = 6e5,
|
|
972
|
+
syncVideoTimeoutMs = 6e5,
|
|
941
973
|
fetchImpl = fetch
|
|
942
974
|
} = config;
|
|
943
975
|
const headers = {
|
|
@@ -945,7 +977,8 @@ function createKunavoMediaAdapter(config) {
|
|
|
945
977
|
authorization: `Bearer ${apiKey}`
|
|
946
978
|
};
|
|
947
979
|
async function runImage(req) {
|
|
948
|
-
const
|
|
980
|
+
const path = /-edit$/i.test(req.externalId) ? "/v1/images/edits" : "/v1/images/generations";
|
|
981
|
+
const res = await fetchImpl(`${baseUrl}${path}`, {
|
|
949
982
|
method: "POST",
|
|
950
983
|
headers,
|
|
951
984
|
body: JSON.stringify({ model: req.externalId, ...req.input })
|
|
@@ -953,16 +986,15 @@ function createKunavoMediaAdapter(config) {
|
|
|
953
986
|
if (!res.ok) {
|
|
954
987
|
throw new KunavoMediaError(res.status, await safeText(res));
|
|
955
988
|
}
|
|
956
|
-
const
|
|
957
|
-
const urls = extractImageUrls(body);
|
|
989
|
+
const urls = extractImageUrls(await res.json());
|
|
958
990
|
if (urls.length === 0) {
|
|
959
991
|
throw new Error(`ai-lcr: Kunavo returned no image URL for "${req.externalId}"`);
|
|
960
992
|
}
|
|
961
993
|
const outputs = urls.map((url) => ({ url, type: "image" }));
|
|
962
994
|
return { outputs };
|
|
963
995
|
}
|
|
964
|
-
async function
|
|
965
|
-
const submit = await fetchImpl(`${baseUrl}/v1/
|
|
996
|
+
async function runVideoAsync(req) {
|
|
997
|
+
const submit = await fetchImpl(`${baseUrl}/v1/videos`, {
|
|
966
998
|
method: "POST",
|
|
967
999
|
headers,
|
|
968
1000
|
body: JSON.stringify({ model: req.externalId, ...req.input })
|
|
@@ -971,51 +1003,75 @@ function createKunavoMediaAdapter(config) {
|
|
|
971
1003
|
throw new KunavoMediaError(submit.status, await safeText(submit));
|
|
972
1004
|
}
|
|
973
1005
|
const submitBody = await submit.json();
|
|
974
|
-
const
|
|
975
|
-
if (inlineUrls.length > 0) {
|
|
976
|
-
return { outputs: inlineUrls.map((url) => ({ url, type: "video" })) };
|
|
977
|
-
}
|
|
978
|
-
const jobId = submitBody.id ?? submitBody.task_id ?? submitBody.request_id;
|
|
1006
|
+
const jobId = submitBody.id;
|
|
979
1007
|
if (!jobId) {
|
|
980
1008
|
throw new Error(
|
|
981
|
-
`ai-lcr: Kunavo video submit returned no job id (got keys: ${Object.keys(
|
|
982
|
-
|
|
983
|
-
)
|
|
1009
|
+
`ai-lcr: Kunavo video submit returned no job id (got keys: ${Object.keys(submitBody).join(
|
|
1010
|
+
", "
|
|
1011
|
+
)})`
|
|
984
1012
|
);
|
|
985
1013
|
}
|
|
986
1014
|
const deadline = Date.now() + pollTimeoutMs;
|
|
987
1015
|
while (Date.now() < deadline) {
|
|
988
|
-
await
|
|
989
|
-
const poll = await fetchImpl(`${baseUrl}/v1/video/generations/${jobId}`, {
|
|
990
|
-
headers
|
|
991
|
-
});
|
|
1016
|
+
const poll = await fetchImpl(`${baseUrl}/v1/videos/${jobId}`, { headers });
|
|
992
1017
|
if (!poll.ok) {
|
|
993
1018
|
throw new KunavoMediaError(poll.status, await safeText(poll));
|
|
994
1019
|
}
|
|
995
|
-
const
|
|
996
|
-
const status = String(
|
|
997
|
-
if (status === "
|
|
998
|
-
const urls =
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
if (all.length === 0) {
|
|
1002
|
-
throw new Error(`ai-lcr: Kunavo video job ${jobId} finished with no URL`);
|
|
1020
|
+
const body = await poll.json();
|
|
1021
|
+
const status = String(body.status ?? "").toLowerCase();
|
|
1022
|
+
if (status === "completed" || status === "succeeded" || status === "success") {
|
|
1023
|
+
const urls = extractVideoUrls(body);
|
|
1024
|
+
if (urls.length === 0) {
|
|
1025
|
+
throw new Error(`ai-lcr: Kunavo video job ${jobId} completed with no URL`);
|
|
1003
1026
|
}
|
|
1004
|
-
return { outputs:
|
|
1027
|
+
return { outputs: urls.map((url) => ({ url, type: "video" })) };
|
|
1005
1028
|
}
|
|
1006
1029
|
if (status === "failed" || status === "error") {
|
|
1030
|
+
const err = body.error;
|
|
1007
1031
|
throw new Error(
|
|
1008
|
-
`ai-lcr: Kunavo video job ${jobId} failed: ${JSON.stringify(
|
|
1032
|
+
`ai-lcr: Kunavo video job ${jobId} failed: ${err?.message ?? JSON.stringify(body)}`
|
|
1009
1033
|
);
|
|
1010
1034
|
}
|
|
1035
|
+
await sleep(pollIntervalMs);
|
|
1011
1036
|
}
|
|
1012
|
-
throw new
|
|
1037
|
+
throw new KunavoMediaError(
|
|
1038
|
+
504,
|
|
1039
|
+
`Kunavo video job ${jobId} timed out after ${pollTimeoutMs}ms`
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
async function runVideoSync(req) {
|
|
1043
|
+
let res;
|
|
1044
|
+
try {
|
|
1045
|
+
res = await fetchImpl(`${baseUrl}/v1/video/generations`, {
|
|
1046
|
+
method: "POST",
|
|
1047
|
+
headers,
|
|
1048
|
+
body: JSON.stringify({ model: req.externalId, ...req.input }),
|
|
1049
|
+
signal: AbortSignal.timeout(syncVideoTimeoutMs)
|
|
1050
|
+
});
|
|
1051
|
+
} catch (err) {
|
|
1052
|
+
if (err?.name === "TimeoutError" || err?.name === "AbortError") {
|
|
1053
|
+
throw new KunavoMediaError(
|
|
1054
|
+
504,
|
|
1055
|
+
`Kunavo sync video timed out after ${syncVideoTimeoutMs}ms`
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
throw err;
|
|
1059
|
+
}
|
|
1060
|
+
if (!res.ok) {
|
|
1061
|
+
throw new KunavoMediaError(res.status, await safeText(res));
|
|
1062
|
+
}
|
|
1063
|
+
const urls = extractImageUrls(await res.json());
|
|
1064
|
+
if (urls.length === 0) {
|
|
1065
|
+
throw new Error(`ai-lcr: Kunavo sync video returned no URL for "${req.externalId}"`);
|
|
1066
|
+
}
|
|
1067
|
+
return { outputs: urls.map((url) => ({ url, type: "video" })) };
|
|
1013
1068
|
}
|
|
1014
1069
|
return {
|
|
1015
1070
|
provider: "kunavo",
|
|
1016
1071
|
async run(req) {
|
|
1017
1072
|
const isVideo = /(^|\/)veo/i.test(req.externalId);
|
|
1018
|
-
|
|
1073
|
+
if (!isVideo) return runImage(req);
|
|
1074
|
+
return videoMode === "sync" ? runVideoSync(req) : runVideoAsync(req);
|
|
1019
1075
|
}
|
|
1020
1076
|
};
|
|
1021
1077
|
}
|
|
@@ -1229,7 +1285,16 @@ function withDefaultCacheRead(p, ratio) {
|
|
|
1229
1285
|
return { ...p, cost: { ...p.cost, cacheRead: p.cost.input * ratio } };
|
|
1230
1286
|
}
|
|
1231
1287
|
function createLCR(config) {
|
|
1232
|
-
const {
|
|
1288
|
+
const {
|
|
1289
|
+
models,
|
|
1290
|
+
autoSort = false,
|
|
1291
|
+
resetIntervalMs,
|
|
1292
|
+
onError,
|
|
1293
|
+
onCost,
|
|
1294
|
+
onCall,
|
|
1295
|
+
shouldRetry,
|
|
1296
|
+
defaultCacheReadRatio
|
|
1297
|
+
} = config;
|
|
1233
1298
|
if (defaultCacheReadRatio !== void 0 && (defaultCacheReadRatio < 0 || defaultCacheReadRatio > 1)) {
|
|
1234
1299
|
throw new Error(
|
|
1235
1300
|
`ai-lcr: defaultCacheReadRatio must be in [0, 1], got ${defaultCacheReadRatio}`
|
|
@@ -1243,7 +1308,7 @@ function createLCR(config) {
|
|
|
1243
1308
|
}
|
|
1244
1309
|
routed.set(
|
|
1245
1310
|
name,
|
|
1246
|
-
new LcrFallbackModel({ modelName: name, providers, resetIntervalMs, onError, onCost, onCall })
|
|
1311
|
+
new LcrFallbackModel({ modelName: name, providers, resetIntervalMs, onError, onCost, onCall, shouldRetry })
|
|
1247
1312
|
);
|
|
1248
1313
|
}
|
|
1249
1314
|
return (modelName) => {
|
|
@@ -1272,7 +1337,11 @@ function createLCR(config) {
|
|
|
1272
1337
|
createMediaLCR,
|
|
1273
1338
|
createRunwareMediaAdapter,
|
|
1274
1339
|
formatCallRecord,
|
|
1340
|
+
isAbortError,
|
|
1341
|
+
isNetworkError,
|
|
1342
|
+
isRetryableError,
|
|
1275
1343
|
normalizedCents,
|
|
1276
1344
|
rankRoutes,
|
|
1277
|
-
referenceMegapixels
|
|
1345
|
+
referenceMegapixels,
|
|
1346
|
+
shouldFailover
|
|
1278
1347
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -165,6 +165,40 @@ interface CallRecord {
|
|
|
165
165
|
*/
|
|
166
166
|
emptyCompletion?: boolean;
|
|
167
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* A transport-level failure (provider unreachable / socket dropped / DNS /
|
|
170
|
+
* connect timeout). These carry no HTTP status, so they must be detected
|
|
171
|
+
* structurally — by Node `code` or message — or they read as non-retryable.
|
|
172
|
+
* Note: a deliberate caller cancellation (AbortError without a network code) is
|
|
173
|
+
* intentionally NOT treated as network here, so we don't "fail over" a request
|
|
174
|
+
* the caller chose to abort.
|
|
175
|
+
*/
|
|
176
|
+
declare function isNetworkError(error: unknown): boolean;
|
|
177
|
+
/** Default switch criterion: provider down / rate-limited / overloaded / unreachable. */
|
|
178
|
+
declare function isRetryableError(error: unknown): boolean;
|
|
179
|
+
/**
|
|
180
|
+
* A deliberate caller cancellation (an `AbortSignal` fired by the app). This is
|
|
181
|
+
* the one failure we must NEVER fail over: re-issuing an aborted request to the
|
|
182
|
+
* next provider is the opposite of what the caller asked for. Detected by name
|
|
183
|
+
* (`fetch`/AI SDK emit an `AbortError`) and by the canonical abort message.
|
|
184
|
+
*/
|
|
185
|
+
declare function isAbortError(error: unknown): boolean;
|
|
186
|
+
/**
|
|
187
|
+
* Default failover criterion — broader than {@link isRetryableError} on purpose.
|
|
188
|
+
* It fails over on *anything* except a deliberate caller cancellation, including
|
|
189
|
+
* a client error such as a 400. In the OpenAI-compatible aggregator world a 400
|
|
190
|
+
* is most often "THIS provider won't take this request" (an unsupported param, a
|
|
191
|
+
* model it hasn't listed, a stricter schema) rather than a universally-broken
|
|
192
|
+
* request — and the next provider may well serve it, which is the whole point of
|
|
193
|
+
* the router. When every provider rejects the request, the engine still throws
|
|
194
|
+
* (surfacing the original error), so a genuinely-bad request stays debuggable.
|
|
195
|
+
* The failed attempts keep their precise {@link ErrorKind} (`"client"` for a
|
|
196
|
+
* 400) so a real caller bug is still visible in the {@link CallRecord}.
|
|
197
|
+
*
|
|
198
|
+
* Pass a custom `shouldRetry` to opt out (e.g. `isRetryableError` to restore the
|
|
199
|
+
* stricter "client errors fail fast" behavior).
|
|
200
|
+
*/
|
|
201
|
+
declare function shouldFailover(error: unknown): boolean;
|
|
168
202
|
/**
|
|
169
203
|
* Normalize an error into a short, log-friendly class for {@link CallRecord}.
|
|
170
204
|
* An HTTP status wins (e.g. "502", "429"); otherwise the first matching
|
|
@@ -442,34 +476,50 @@ declare const MEDIA_PRICING: MediaRegistry;
|
|
|
442
476
|
declare const OFFICIAL_PRICES: Record<string, MediaPricing>;
|
|
443
477
|
|
|
444
478
|
/**
|
|
445
|
-
* Kunavo media adapter — image (sync) + video (async poll).
|
|
479
|
+
* Kunavo media adapter — image (sync) + video (async poll, sync fallback).
|
|
446
480
|
*
|
|
447
481
|
* Kunavo is NOT an AI-SDK chat provider for media: image/video generation uses
|
|
448
482
|
* its own REST endpoints, not `/v1/chat/completions`. So this is a hand-rolled
|
|
449
|
-
* `MediaAdapter`, not a `createOpenAICompatible` wrapper.
|
|
483
|
+
* `MediaAdapter`, not a `createOpenAICompatible` wrapper. All paths VERIFIED
|
|
484
|
+
* live against the real API (image 2026-05-31, edit + async video 2026-06-06).
|
|
450
485
|
*
|
|
451
|
-
* - Image: POST /v1/images/generations →
|
|
452
|
-
*
|
|
453
|
-
* -
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
486
|
+
* - Image gen: POST /v1/images/generations → { created, data:[{url}] }.
|
|
487
|
+
* Synchronous (~11s nano-banana, ~42s nano-banana-2).
|
|
488
|
+
* - Image edit: POST /v1/images/edits → same shape. Triggered for
|
|
489
|
+
* `*-edit` slugs (nano-banana-edit, gpt-image-2-edit); the
|
|
490
|
+
* caller supplies `image` (url/data-uri) or `image_urls[]`.
|
|
491
|
+
* - Video: Kunavo has TWO video endpoints; this adapter defaults to the
|
|
492
|
+
* ASYNC one (Kunavo's own "recommended for production"):
|
|
493
|
+
* submit POST /v1/videos → { id:"vid_…", status }
|
|
494
|
+
* poll GET /v1/videos/{id} → status queued→in_progress
|
|
495
|
+
* →completed, output:{url,urls}
|
|
496
|
+
* Set `videoMode:"sync"` to use the blocking single-call path
|
|
497
|
+
* POST /v1/video/generations instead (returns { data:[{url}] }
|
|
498
|
+
* inline, ~108s for veo-3-lite; longer SKUs need a long timeout).
|
|
459
499
|
*
|
|
460
|
-
* Kunavo does NOT return a per-call cost in the generation response,
|
|
461
|
-
* left to the router's
|
|
462
|
-
* stays undefined; `units`
|
|
500
|
+
* Kunavo does NOT return a per-call cost in the generation response, and
|
|
501
|
+
* `GET /v1/models` carries no pricing — so cost is left to the router's
|
|
502
|
+
* normalized estimate (MediaGenerateResult.costCents stays undefined; `units`
|
|
503
|
+
* defaults to 1 — one image / one clip per call).
|
|
463
504
|
*/
|
|
464
505
|
|
|
465
506
|
interface KunavoMediaConfig {
|
|
466
507
|
apiKey: string;
|
|
467
508
|
/** Override for testing. Defaults to https://api.kunavo.com. */
|
|
468
509
|
baseUrl?: string;
|
|
469
|
-
/**
|
|
510
|
+
/**
|
|
511
|
+
* Video execution path. "async" (default) submits to POST /v1/videos and
|
|
512
|
+
* polls GET /v1/videos/{id} — non-blocking, survives proxy/LB connection
|
|
513
|
+
* limits, and is Kunavo's recommended production path. "sync" uses the
|
|
514
|
+
* blocking POST /v1/video/generations single call.
|
|
515
|
+
*/
|
|
516
|
+
videoMode?: "async" | "sync";
|
|
517
|
+
/** Async-video poll cadence (ms). Default 5000. */
|
|
470
518
|
pollIntervalMs?: number;
|
|
471
|
-
/** Max time to wait for
|
|
519
|
+
/** Max time to wait for an async video job before giving up (ms). Default 600000 (10m). */
|
|
472
520
|
pollTimeoutMs?: number;
|
|
521
|
+
/** Hard cap for the blocking sync-video HTTP call (ms). Default 600000 (10m). */
|
|
522
|
+
syncVideoTimeoutMs?: number;
|
|
473
523
|
/** Injected for testing; defaults to global fetch. */
|
|
474
524
|
fetchImpl?: typeof fetch;
|
|
475
525
|
}
|
|
@@ -589,6 +639,14 @@ interface LCRConfig {
|
|
|
589
639
|
* you. Pair with `formatCallRecord` for a one-line log. See {@link CallRecord}.
|
|
590
640
|
*/
|
|
591
641
|
onCall?: (record: CallRecord) => void;
|
|
642
|
+
/**
|
|
643
|
+
* Decide whether a failed attempt should fail over to the next provider.
|
|
644
|
+
* Defaults to {@link shouldFailover} — fail over on everything except a
|
|
645
|
+
* deliberate caller cancellation, so a provider-specific 400 still survives by
|
|
646
|
+
* trying the next provider. Pass {@link isRetryableError} to restore the
|
|
647
|
+
* stricter behavior where a client error (e.g. 400) fails fast.
|
|
648
|
+
*/
|
|
649
|
+
shouldRetry?: (error: unknown) => boolean;
|
|
592
650
|
/**
|
|
593
651
|
* Fallback prompt-cache read rate, as a fraction of each leg's `input` price,
|
|
594
652
|
* applied ONLY to legs whose `cost` omits an explicit `cacheRead`. So a leg
|
|
@@ -614,4 +672,4 @@ type LCRRouter = (modelName: string) => LanguageModelV3;
|
|
|
614
672
|
*/
|
|
615
673
|
declare function createLCR(config: LCRConfig): LCRRouter;
|
|
616
674
|
|
|
617
|
-
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 MediaLCRConfig, type MediaModality, type MediaModelDef, type MediaOutput, type MediaPricing, type MediaRegistry, type MediaRoute, type MediaRunResult, 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, normalizedCents, rankRoutes, referenceMegapixels };
|
|
675
|
+
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 MediaLCRConfig, type MediaModality, type MediaModelDef, type MediaOutput, type MediaPricing, type MediaRegistry, type MediaRoute, type MediaRunResult, 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 };
|
package/dist/index.d.ts
CHANGED
|
@@ -165,6 +165,40 @@ interface CallRecord {
|
|
|
165
165
|
*/
|
|
166
166
|
emptyCompletion?: boolean;
|
|
167
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* A transport-level failure (provider unreachable / socket dropped / DNS /
|
|
170
|
+
* connect timeout). These carry no HTTP status, so they must be detected
|
|
171
|
+
* structurally — by Node `code` or message — or they read as non-retryable.
|
|
172
|
+
* Note: a deliberate caller cancellation (AbortError without a network code) is
|
|
173
|
+
* intentionally NOT treated as network here, so we don't "fail over" a request
|
|
174
|
+
* the caller chose to abort.
|
|
175
|
+
*/
|
|
176
|
+
declare function isNetworkError(error: unknown): boolean;
|
|
177
|
+
/** Default switch criterion: provider down / rate-limited / overloaded / unreachable. */
|
|
178
|
+
declare function isRetryableError(error: unknown): boolean;
|
|
179
|
+
/**
|
|
180
|
+
* A deliberate caller cancellation (an `AbortSignal` fired by the app). This is
|
|
181
|
+
* the one failure we must NEVER fail over: re-issuing an aborted request to the
|
|
182
|
+
* next provider is the opposite of what the caller asked for. Detected by name
|
|
183
|
+
* (`fetch`/AI SDK emit an `AbortError`) and by the canonical abort message.
|
|
184
|
+
*/
|
|
185
|
+
declare function isAbortError(error: unknown): boolean;
|
|
186
|
+
/**
|
|
187
|
+
* Default failover criterion — broader than {@link isRetryableError} on purpose.
|
|
188
|
+
* It fails over on *anything* except a deliberate caller cancellation, including
|
|
189
|
+
* a client error such as a 400. In the OpenAI-compatible aggregator world a 400
|
|
190
|
+
* is most often "THIS provider won't take this request" (an unsupported param, a
|
|
191
|
+
* model it hasn't listed, a stricter schema) rather than a universally-broken
|
|
192
|
+
* request — and the next provider may well serve it, which is the whole point of
|
|
193
|
+
* the router. When every provider rejects the request, the engine still throws
|
|
194
|
+
* (surfacing the original error), so a genuinely-bad request stays debuggable.
|
|
195
|
+
* The failed attempts keep their precise {@link ErrorKind} (`"client"` for a
|
|
196
|
+
* 400) so a real caller bug is still visible in the {@link CallRecord}.
|
|
197
|
+
*
|
|
198
|
+
* Pass a custom `shouldRetry` to opt out (e.g. `isRetryableError` to restore the
|
|
199
|
+
* stricter "client errors fail fast" behavior).
|
|
200
|
+
*/
|
|
201
|
+
declare function shouldFailover(error: unknown): boolean;
|
|
168
202
|
/**
|
|
169
203
|
* Normalize an error into a short, log-friendly class for {@link CallRecord}.
|
|
170
204
|
* An HTTP status wins (e.g. "502", "429"); otherwise the first matching
|
|
@@ -442,34 +476,50 @@ declare const MEDIA_PRICING: MediaRegistry;
|
|
|
442
476
|
declare const OFFICIAL_PRICES: Record<string, MediaPricing>;
|
|
443
477
|
|
|
444
478
|
/**
|
|
445
|
-
* Kunavo media adapter — image (sync) + video (async poll).
|
|
479
|
+
* Kunavo media adapter — image (sync) + video (async poll, sync fallback).
|
|
446
480
|
*
|
|
447
481
|
* Kunavo is NOT an AI-SDK chat provider for media: image/video generation uses
|
|
448
482
|
* its own REST endpoints, not `/v1/chat/completions`. So this is a hand-rolled
|
|
449
|
-
* `MediaAdapter`, not a `createOpenAICompatible` wrapper.
|
|
483
|
+
* `MediaAdapter`, not a `createOpenAICompatible` wrapper. All paths VERIFIED
|
|
484
|
+
* live against the real API (image 2026-05-31, edit + async video 2026-06-06).
|
|
450
485
|
*
|
|
451
|
-
* - Image: POST /v1/images/generations →
|
|
452
|
-
*
|
|
453
|
-
* -
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
486
|
+
* - Image gen: POST /v1/images/generations → { created, data:[{url}] }.
|
|
487
|
+
* Synchronous (~11s nano-banana, ~42s nano-banana-2).
|
|
488
|
+
* - Image edit: POST /v1/images/edits → same shape. Triggered for
|
|
489
|
+
* `*-edit` slugs (nano-banana-edit, gpt-image-2-edit); the
|
|
490
|
+
* caller supplies `image` (url/data-uri) or `image_urls[]`.
|
|
491
|
+
* - Video: Kunavo has TWO video endpoints; this adapter defaults to the
|
|
492
|
+
* ASYNC one (Kunavo's own "recommended for production"):
|
|
493
|
+
* submit POST /v1/videos → { id:"vid_…", status }
|
|
494
|
+
* poll GET /v1/videos/{id} → status queued→in_progress
|
|
495
|
+
* →completed, output:{url,urls}
|
|
496
|
+
* Set `videoMode:"sync"` to use the blocking single-call path
|
|
497
|
+
* POST /v1/video/generations instead (returns { data:[{url}] }
|
|
498
|
+
* inline, ~108s for veo-3-lite; longer SKUs need a long timeout).
|
|
459
499
|
*
|
|
460
|
-
* Kunavo does NOT return a per-call cost in the generation response,
|
|
461
|
-
* left to the router's
|
|
462
|
-
* stays undefined; `units`
|
|
500
|
+
* Kunavo does NOT return a per-call cost in the generation response, and
|
|
501
|
+
* `GET /v1/models` carries no pricing — so cost is left to the router's
|
|
502
|
+
* normalized estimate (MediaGenerateResult.costCents stays undefined; `units`
|
|
503
|
+
* defaults to 1 — one image / one clip per call).
|
|
463
504
|
*/
|
|
464
505
|
|
|
465
506
|
interface KunavoMediaConfig {
|
|
466
507
|
apiKey: string;
|
|
467
508
|
/** Override for testing. Defaults to https://api.kunavo.com. */
|
|
468
509
|
baseUrl?: string;
|
|
469
|
-
/**
|
|
510
|
+
/**
|
|
511
|
+
* Video execution path. "async" (default) submits to POST /v1/videos and
|
|
512
|
+
* polls GET /v1/videos/{id} — non-blocking, survives proxy/LB connection
|
|
513
|
+
* limits, and is Kunavo's recommended production path. "sync" uses the
|
|
514
|
+
* blocking POST /v1/video/generations single call.
|
|
515
|
+
*/
|
|
516
|
+
videoMode?: "async" | "sync";
|
|
517
|
+
/** Async-video poll cadence (ms). Default 5000. */
|
|
470
518
|
pollIntervalMs?: number;
|
|
471
|
-
/** Max time to wait for
|
|
519
|
+
/** Max time to wait for an async video job before giving up (ms). Default 600000 (10m). */
|
|
472
520
|
pollTimeoutMs?: number;
|
|
521
|
+
/** Hard cap for the blocking sync-video HTTP call (ms). Default 600000 (10m). */
|
|
522
|
+
syncVideoTimeoutMs?: number;
|
|
473
523
|
/** Injected for testing; defaults to global fetch. */
|
|
474
524
|
fetchImpl?: typeof fetch;
|
|
475
525
|
}
|
|
@@ -589,6 +639,14 @@ interface LCRConfig {
|
|
|
589
639
|
* you. Pair with `formatCallRecord` for a one-line log. See {@link CallRecord}.
|
|
590
640
|
*/
|
|
591
641
|
onCall?: (record: CallRecord) => void;
|
|
642
|
+
/**
|
|
643
|
+
* Decide whether a failed attempt should fail over to the next provider.
|
|
644
|
+
* Defaults to {@link shouldFailover} — fail over on everything except a
|
|
645
|
+
* deliberate caller cancellation, so a provider-specific 400 still survives by
|
|
646
|
+
* trying the next provider. Pass {@link isRetryableError} to restore the
|
|
647
|
+
* stricter behavior where a client error (e.g. 400) fails fast.
|
|
648
|
+
*/
|
|
649
|
+
shouldRetry?: (error: unknown) => boolean;
|
|
592
650
|
/**
|
|
593
651
|
* Fallback prompt-cache read rate, as a fraction of each leg's `input` price,
|
|
594
652
|
* applied ONLY to legs whose `cost` omits an explicit `cacheRead`. So a leg
|
|
@@ -614,4 +672,4 @@ type LCRRouter = (modelName: string) => LanguageModelV3;
|
|
|
614
672
|
*/
|
|
615
673
|
declare function createLCR(config: LCRConfig): LCRRouter;
|
|
616
674
|
|
|
617
|
-
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 MediaLCRConfig, type MediaModality, type MediaModelDef, type MediaOutput, type MediaPricing, type MediaRegistry, type MediaRoute, type MediaRunResult, 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, normalizedCents, rankRoutes, referenceMegapixels };
|
|
675
|
+
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 MediaLCRConfig, type MediaModality, type MediaModelDef, type MediaOutput, type MediaPricing, type MediaRegistry, type MediaRoute, type MediaRunResult, 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 };
|
package/dist/index.js
CHANGED
|
@@ -116,6 +116,15 @@ function isRetryableError(error) {
|
|
|
116
116
|
const { text } = errorSignals(error);
|
|
117
117
|
return RETRYABLE_PATTERNS.some((p) => text.includes(p));
|
|
118
118
|
}
|
|
119
|
+
function isAbortError(error) {
|
|
120
|
+
const e = error;
|
|
121
|
+
if (typeof e?.name === "string" && e.name === "AbortError") return true;
|
|
122
|
+
const { text } = errorSignals(error);
|
|
123
|
+
return text.includes("operation was aborted") || text.includes("operation was canceled");
|
|
124
|
+
}
|
|
125
|
+
function shouldFailover(error) {
|
|
126
|
+
return !isAbortError(error);
|
|
127
|
+
}
|
|
119
128
|
function classifyError(error) {
|
|
120
129
|
if (error instanceof EmptyCompletionError) return "empty_completion";
|
|
121
130
|
const e = error;
|
|
@@ -239,7 +248,7 @@ var LcrFallbackModel = class {
|
|
|
239
248
|
this.lastFailoverAt = Date.now();
|
|
240
249
|
}
|
|
241
250
|
shouldRetry(error) {
|
|
242
|
-
return (this.opts.shouldRetry ??
|
|
251
|
+
return (this.opts.shouldRetry ?? shouldFailover)(error);
|
|
243
252
|
}
|
|
244
253
|
// Observer callbacks are caller-supplied logging hooks: a throw from one of
|
|
245
254
|
// them must NEVER turn a successful (or already-failed) request into a
|
|
@@ -272,6 +281,7 @@ var LcrFallbackModel = class {
|
|
|
272
281
|
}
|
|
273
282
|
/** Record a failed attempt onto the call's chain (no event yet). */
|
|
274
283
|
recordFail(ctx, provider, attemptStart, error) {
|
|
284
|
+
if (ctx.firstError === void 0) ctx.firstError = error;
|
|
275
285
|
ctx.attempts.push({
|
|
276
286
|
provider: provider.label,
|
|
277
287
|
ok: false,
|
|
@@ -387,7 +397,7 @@ var LcrFallbackModel = class {
|
|
|
387
397
|
}
|
|
388
398
|
}
|
|
389
399
|
this.finalizeFail(ctx);
|
|
390
|
-
throw lastError;
|
|
400
|
+
throw ctx.firstError ?? lastError;
|
|
391
401
|
}
|
|
392
402
|
async doStream(options) {
|
|
393
403
|
return this.doStreamWithCtx(options, this.startCall(options), this.startIndex(), 0);
|
|
@@ -423,7 +433,7 @@ var LcrFallbackModel = class {
|
|
|
423
433
|
tried++;
|
|
424
434
|
if (tried >= n) {
|
|
425
435
|
this.finalizeFail(ctx);
|
|
426
|
-
throw error;
|
|
436
|
+
throw ctx.firstError ?? error;
|
|
427
437
|
}
|
|
428
438
|
idx = (idx + 1) % n;
|
|
429
439
|
}
|
|
@@ -471,7 +481,7 @@ var LcrFallbackModel = class {
|
|
|
471
481
|
const nextTried = triedBeforeServing + 1;
|
|
472
482
|
if (nextTried >= n) {
|
|
473
483
|
self.finalizeFail(ctx);
|
|
474
|
-
controller.error(error);
|
|
484
|
+
controller.error(ctx.firstError ?? error);
|
|
475
485
|
return;
|
|
476
486
|
}
|
|
477
487
|
try {
|
|
@@ -853,11 +863,15 @@ var MEDIA_PRICING = {
|
|
|
853
863
|
]
|
|
854
864
|
},
|
|
855
865
|
// ── Google video (Veo) ──────────────────────────────────────
|
|
856
|
-
//
|
|
857
|
-
// (
|
|
858
|
-
//
|
|
859
|
-
//
|
|
860
|
-
//
|
|
866
|
+
// Kunavo video VERIFIED live 2026-06-06: veo-3-lite renders via both the async
|
|
867
|
+
// path (POST /v1/videos + poll, ~80s) and the sync path (POST /v1/video/
|
|
868
|
+
// generations, ~108s), real 720p mp4 out. The adapter defaults to async.
|
|
869
|
+
// ⚠️ Two caveats remain on the PRICE gap, not the capability: (1) Version/SKU
|
|
870
|
+
// mismatch — Kunavo bills "veo-3" per CALL (flat per clip, ~8s 720p) while fal
|
|
871
|
+
// bills "veo3.1" per SECOND, so normalized to a 5s clip the per-call price wins
|
|
872
|
+
// by a wide margin; (2) /v1/models exposes NO pricing, so the per-call cents
|
|
873
|
+
// below are hand-entered — verify clip duration/resolution/audio before
|
|
874
|
+
// trusting the gap. veo-3 / veo-3-quality capability not individually rendered.
|
|
861
875
|
"google/veo-3": {
|
|
862
876
|
id: "google/veo-3",
|
|
863
877
|
modality: "video",
|
|
@@ -870,7 +884,7 @@ var MEDIA_PRICING = {
|
|
|
870
884
|
id: "google/veo-3-lite",
|
|
871
885
|
modality: "video",
|
|
872
886
|
routes: [
|
|
873
|
-
{ provider: "kunavo", externalId: "veo-3-lite", pricing: { unit: "call", cents: 16 }, note: "flat per clip (
|
|
887
|
+
{ provider: "kunavo", externalId: "veo-3-lite", pricing: { unit: "call", cents: 16 }, note: "flat per clip; rendering verified 2026-06-06 (720p, async+sync); price hand-entered" },
|
|
874
888
|
{ provider: "fal", externalId: "fal-ai/veo3.1/lite", pricing: { unit: "second", cents: 8 }, note: "veo3.1 lite, 1080p audio-on" }
|
|
875
889
|
]
|
|
876
890
|
},
|
|
@@ -890,12 +904,26 @@ function extractImageUrls(body) {
|
|
|
890
904
|
if (!Array.isArray(data)) return [];
|
|
891
905
|
return data.map((d) => d?.url).filter((u) => typeof u === "string" && u.length > 0);
|
|
892
906
|
}
|
|
907
|
+
function extractVideoUrls(body) {
|
|
908
|
+
const output = body.output;
|
|
909
|
+
if (output) {
|
|
910
|
+
if (Array.isArray(output.urls)) {
|
|
911
|
+
const urls = output.urls.filter((u) => typeof u === "string" && u.length > 0);
|
|
912
|
+
if (urls.length > 0) return urls;
|
|
913
|
+
}
|
|
914
|
+
if (typeof output.url === "string" && output.url.length > 0) return [output.url];
|
|
915
|
+
}
|
|
916
|
+
if (typeof body.url === "string" && body.url.length > 0) return [body.url];
|
|
917
|
+
return extractImageUrls(body);
|
|
918
|
+
}
|
|
893
919
|
function createKunavoMediaAdapter(config) {
|
|
894
920
|
const {
|
|
895
921
|
apiKey,
|
|
896
922
|
baseUrl = DEFAULT_BASE,
|
|
923
|
+
videoMode = "async",
|
|
897
924
|
pollIntervalMs = 5e3,
|
|
898
|
-
pollTimeoutMs =
|
|
925
|
+
pollTimeoutMs = 6e5,
|
|
926
|
+
syncVideoTimeoutMs = 6e5,
|
|
899
927
|
fetchImpl = fetch
|
|
900
928
|
} = config;
|
|
901
929
|
const headers = {
|
|
@@ -903,7 +931,8 @@ function createKunavoMediaAdapter(config) {
|
|
|
903
931
|
authorization: `Bearer ${apiKey}`
|
|
904
932
|
};
|
|
905
933
|
async function runImage(req) {
|
|
906
|
-
const
|
|
934
|
+
const path = /-edit$/i.test(req.externalId) ? "/v1/images/edits" : "/v1/images/generations";
|
|
935
|
+
const res = await fetchImpl(`${baseUrl}${path}`, {
|
|
907
936
|
method: "POST",
|
|
908
937
|
headers,
|
|
909
938
|
body: JSON.stringify({ model: req.externalId, ...req.input })
|
|
@@ -911,16 +940,15 @@ function createKunavoMediaAdapter(config) {
|
|
|
911
940
|
if (!res.ok) {
|
|
912
941
|
throw new KunavoMediaError(res.status, await safeText(res));
|
|
913
942
|
}
|
|
914
|
-
const
|
|
915
|
-
const urls = extractImageUrls(body);
|
|
943
|
+
const urls = extractImageUrls(await res.json());
|
|
916
944
|
if (urls.length === 0) {
|
|
917
945
|
throw new Error(`ai-lcr: Kunavo returned no image URL for "${req.externalId}"`);
|
|
918
946
|
}
|
|
919
947
|
const outputs = urls.map((url) => ({ url, type: "image" }));
|
|
920
948
|
return { outputs };
|
|
921
949
|
}
|
|
922
|
-
async function
|
|
923
|
-
const submit = await fetchImpl(`${baseUrl}/v1/
|
|
950
|
+
async function runVideoAsync(req) {
|
|
951
|
+
const submit = await fetchImpl(`${baseUrl}/v1/videos`, {
|
|
924
952
|
method: "POST",
|
|
925
953
|
headers,
|
|
926
954
|
body: JSON.stringify({ model: req.externalId, ...req.input })
|
|
@@ -929,51 +957,75 @@ function createKunavoMediaAdapter(config) {
|
|
|
929
957
|
throw new KunavoMediaError(submit.status, await safeText(submit));
|
|
930
958
|
}
|
|
931
959
|
const submitBody = await submit.json();
|
|
932
|
-
const
|
|
933
|
-
if (inlineUrls.length > 0) {
|
|
934
|
-
return { outputs: inlineUrls.map((url) => ({ url, type: "video" })) };
|
|
935
|
-
}
|
|
936
|
-
const jobId = submitBody.id ?? submitBody.task_id ?? submitBody.request_id;
|
|
960
|
+
const jobId = submitBody.id;
|
|
937
961
|
if (!jobId) {
|
|
938
962
|
throw new Error(
|
|
939
|
-
`ai-lcr: Kunavo video submit returned no job id (got keys: ${Object.keys(
|
|
940
|
-
|
|
941
|
-
)
|
|
963
|
+
`ai-lcr: Kunavo video submit returned no job id (got keys: ${Object.keys(submitBody).join(
|
|
964
|
+
", "
|
|
965
|
+
)})`
|
|
942
966
|
);
|
|
943
967
|
}
|
|
944
968
|
const deadline = Date.now() + pollTimeoutMs;
|
|
945
969
|
while (Date.now() < deadline) {
|
|
946
|
-
await
|
|
947
|
-
const poll = await fetchImpl(`${baseUrl}/v1/video/generations/${jobId}`, {
|
|
948
|
-
headers
|
|
949
|
-
});
|
|
970
|
+
const poll = await fetchImpl(`${baseUrl}/v1/videos/${jobId}`, { headers });
|
|
950
971
|
if (!poll.ok) {
|
|
951
972
|
throw new KunavoMediaError(poll.status, await safeText(poll));
|
|
952
973
|
}
|
|
953
|
-
const
|
|
954
|
-
const status = String(
|
|
955
|
-
if (status === "
|
|
956
|
-
const urls =
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
if (all.length === 0) {
|
|
960
|
-
throw new Error(`ai-lcr: Kunavo video job ${jobId} finished with no URL`);
|
|
974
|
+
const body = await poll.json();
|
|
975
|
+
const status = String(body.status ?? "").toLowerCase();
|
|
976
|
+
if (status === "completed" || status === "succeeded" || status === "success") {
|
|
977
|
+
const urls = extractVideoUrls(body);
|
|
978
|
+
if (urls.length === 0) {
|
|
979
|
+
throw new Error(`ai-lcr: Kunavo video job ${jobId} completed with no URL`);
|
|
961
980
|
}
|
|
962
|
-
return { outputs:
|
|
981
|
+
return { outputs: urls.map((url) => ({ url, type: "video" })) };
|
|
963
982
|
}
|
|
964
983
|
if (status === "failed" || status === "error") {
|
|
984
|
+
const err = body.error;
|
|
965
985
|
throw new Error(
|
|
966
|
-
`ai-lcr: Kunavo video job ${jobId} failed: ${JSON.stringify(
|
|
986
|
+
`ai-lcr: Kunavo video job ${jobId} failed: ${err?.message ?? JSON.stringify(body)}`
|
|
967
987
|
);
|
|
968
988
|
}
|
|
989
|
+
await sleep(pollIntervalMs);
|
|
969
990
|
}
|
|
970
|
-
throw new
|
|
991
|
+
throw new KunavoMediaError(
|
|
992
|
+
504,
|
|
993
|
+
`Kunavo video job ${jobId} timed out after ${pollTimeoutMs}ms`
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
async function runVideoSync(req) {
|
|
997
|
+
let res;
|
|
998
|
+
try {
|
|
999
|
+
res = await fetchImpl(`${baseUrl}/v1/video/generations`, {
|
|
1000
|
+
method: "POST",
|
|
1001
|
+
headers,
|
|
1002
|
+
body: JSON.stringify({ model: req.externalId, ...req.input }),
|
|
1003
|
+
signal: AbortSignal.timeout(syncVideoTimeoutMs)
|
|
1004
|
+
});
|
|
1005
|
+
} catch (err) {
|
|
1006
|
+
if (err?.name === "TimeoutError" || err?.name === "AbortError") {
|
|
1007
|
+
throw new KunavoMediaError(
|
|
1008
|
+
504,
|
|
1009
|
+
`Kunavo sync video timed out after ${syncVideoTimeoutMs}ms`
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
throw err;
|
|
1013
|
+
}
|
|
1014
|
+
if (!res.ok) {
|
|
1015
|
+
throw new KunavoMediaError(res.status, await safeText(res));
|
|
1016
|
+
}
|
|
1017
|
+
const urls = extractImageUrls(await res.json());
|
|
1018
|
+
if (urls.length === 0) {
|
|
1019
|
+
throw new Error(`ai-lcr: Kunavo sync video returned no URL for "${req.externalId}"`);
|
|
1020
|
+
}
|
|
1021
|
+
return { outputs: urls.map((url) => ({ url, type: "video" })) };
|
|
971
1022
|
}
|
|
972
1023
|
return {
|
|
973
1024
|
provider: "kunavo",
|
|
974
1025
|
async run(req) {
|
|
975
1026
|
const isVideo = /(^|\/)veo/i.test(req.externalId);
|
|
976
|
-
|
|
1027
|
+
if (!isVideo) return runImage(req);
|
|
1028
|
+
return videoMode === "sync" ? runVideoSync(req) : runVideoAsync(req);
|
|
977
1029
|
}
|
|
978
1030
|
};
|
|
979
1031
|
}
|
|
@@ -1187,7 +1239,16 @@ function withDefaultCacheRead(p, ratio) {
|
|
|
1187
1239
|
return { ...p, cost: { ...p.cost, cacheRead: p.cost.input * ratio } };
|
|
1188
1240
|
}
|
|
1189
1241
|
function createLCR(config) {
|
|
1190
|
-
const {
|
|
1242
|
+
const {
|
|
1243
|
+
models,
|
|
1244
|
+
autoSort = false,
|
|
1245
|
+
resetIntervalMs,
|
|
1246
|
+
onError,
|
|
1247
|
+
onCost,
|
|
1248
|
+
onCall,
|
|
1249
|
+
shouldRetry,
|
|
1250
|
+
defaultCacheReadRatio
|
|
1251
|
+
} = config;
|
|
1191
1252
|
if (defaultCacheReadRatio !== void 0 && (defaultCacheReadRatio < 0 || defaultCacheReadRatio > 1)) {
|
|
1192
1253
|
throw new Error(
|
|
1193
1254
|
`ai-lcr: defaultCacheReadRatio must be in [0, 1], got ${defaultCacheReadRatio}`
|
|
@@ -1201,7 +1262,7 @@ function createLCR(config) {
|
|
|
1201
1262
|
}
|
|
1202
1263
|
routed.set(
|
|
1203
1264
|
name,
|
|
1204
|
-
new LcrFallbackModel({ modelName: name, providers, resetIntervalMs, onError, onCost, onCall })
|
|
1265
|
+
new LcrFallbackModel({ modelName: name, providers, resetIntervalMs, onError, onCost, onCall, shouldRetry })
|
|
1205
1266
|
);
|
|
1206
1267
|
}
|
|
1207
1268
|
return (modelName) => {
|
|
@@ -1229,7 +1290,11 @@ export {
|
|
|
1229
1290
|
createMediaLCR,
|
|
1230
1291
|
createRunwareMediaAdapter,
|
|
1231
1292
|
formatCallRecord,
|
|
1293
|
+
isAbortError,
|
|
1294
|
+
isNetworkError,
|
|
1295
|
+
isRetryableError,
|
|
1232
1296
|
normalizedCents,
|
|
1233
1297
|
rankRoutes,
|
|
1234
|
-
referenceMegapixels
|
|
1298
|
+
referenceMegapixels,
|
|
1299
|
+
shouldFailover
|
|
1235
1300
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-lcr",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
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",
|