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 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 a retryable error — rate limit, 5xx, timeout, or a **billing cap** (402 / out-of-credit / quota) — it advances to the next provider, streaming-safe. A caller's own bad request (e.g. 400, 422) passes through immediately.
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 (live, via its async queue API); Kunavo's Veo poll path is implemented but unverified
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** (async queue API)
353
- - [ ] Normalized cross-provider video price comparison + verified Kunavo/Runware video adapters
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 (and *not* failing over on a 400), exhausting the whole chain, and a real broken-provider → Kunavo recovery. Live tests run only when `KUNAVO_API_KEY` is set in the environment; otherwise they're skipped.
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. **失败时向下穿透。** 遇到可重试的错误(限流、5xx、超时)时,前进到下一个 provider,且对流式安全。硬错误(400、401、403、422)会直接透传,不做重试。
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
- 测试套件覆盖了:最便宜优先路由、可重试错误时的 failover(以及遇到 400 时*不*做 failover)、穷尽整条链路,以及一次真实的「provider 故障 → Kunavo 恢复」。真实测试仅在环境变量 `KUNAVO_API_KEY` 设置时运行,否则跳过。
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 ?? isRetryableError)(error);
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
- // ⚠️ Version/SKU mismatch across providers: Kunavo bills "veo-3" per CALL
899
- // (flat fee per clip; Veo 3 generates ~8s, audio/res tier unconfirmed); fal
900
- // bills "veo3.1" per SECOND. Normalized to a 5s clip the per-call price wins
901
- // by a wide margin verify the clip's duration/resolution/audio before
902
- // trusting the gap. See note fields.
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 (SKU unverified)" },
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 = 3e5,
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 res = await fetchImpl(`${baseUrl}/v1/images/generations`, {
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 body = await res.json();
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 runVideo(req) {
965
- const submit = await fetchImpl(`${baseUrl}/v1/video/generations`, {
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 inlineUrls = extractImageUrls(submitBody);
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
- submitBody
983
- ).join(", ")})`
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 sleep(pollIntervalMs);
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 pollBody = await poll.json();
996
- const status = String(pollBody.status ?? "").toLowerCase();
997
- if (status === "succeeded" || status === "completed" || status === "success") {
998
- const urls = extractImageUrls(pollBody);
999
- const direct = pollBody.url;
1000
- const all = urls.length > 0 ? urls : direct ? [direct] : [];
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: all.map((url) => ({ url, type: "video" })) };
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(pollBody)}`
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 Error(`ai-lcr: Kunavo video job ${jobId} timed out after ${pollTimeoutMs}ms`);
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
- return isVideo ? runVideo(req) : runImage(req);
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 { models, autoSort = false, resetIntervalMs, onError, onCost, onCall, defaultCacheReadRatio } = config;
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 → returns a files.kunavo.com URL.
452
- * Synchronous (~11s for nano-banana). VERIFIED end-to-end.
453
- * - Video: POST /v1/video/generations (singular "video"; /videos/ 405).
454
- * Long-running. The submit→poll path here is IMPLEMENTED FROM THE
455
- * DOCS SHAPE BUT NOT YET RUN against a real job (veo-3 generation
456
- * was skipped to save cost). Treat the poll loop as unverified:
457
- * the field names (`id`/`status`/`url`) may differ from what the
458
- * live API returns. Verify before relying on video in production.
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, so cost is
461
- * left to the router's normalized estimate (MediaGenerateResult.costCents
462
- * stays undefined; `units` defaults to 1 — one image / one clip per call).
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
- /** Video poll cadence (ms). Default 5000. */
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 a video job before giving up (ms). Default 300000 (5m). */
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 → returns a files.kunavo.com URL.
452
- * Synchronous (~11s for nano-banana). VERIFIED end-to-end.
453
- * - Video: POST /v1/video/generations (singular "video"; /videos/ 405).
454
- * Long-running. The submit→poll path here is IMPLEMENTED FROM THE
455
- * DOCS SHAPE BUT NOT YET RUN against a real job (veo-3 generation
456
- * was skipped to save cost). Treat the poll loop as unverified:
457
- * the field names (`id`/`status`/`url`) may differ from what the
458
- * live API returns. Verify before relying on video in production.
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, so cost is
461
- * left to the router's normalized estimate (MediaGenerateResult.costCents
462
- * stays undefined; `units` defaults to 1 — one image / one clip per call).
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
- /** Video poll cadence (ms). Default 5000. */
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 a video job before giving up (ms). Default 300000 (5m). */
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 ?? isRetryableError)(error);
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
- // ⚠️ Version/SKU mismatch across providers: Kunavo bills "veo-3" per CALL
857
- // (flat fee per clip; Veo 3 generates ~8s, audio/res tier unconfirmed); fal
858
- // bills "veo3.1" per SECOND. Normalized to a 5s clip the per-call price wins
859
- // by a wide margin verify the clip's duration/resolution/audio before
860
- // trusting the gap. See note fields.
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 (SKU unverified)" },
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 = 3e5,
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 res = await fetchImpl(`${baseUrl}/v1/images/generations`, {
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 body = await res.json();
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 runVideo(req) {
923
- const submit = await fetchImpl(`${baseUrl}/v1/video/generations`, {
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 inlineUrls = extractImageUrls(submitBody);
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
- submitBody
941
- ).join(", ")})`
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 sleep(pollIntervalMs);
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 pollBody = await poll.json();
954
- const status = String(pollBody.status ?? "").toLowerCase();
955
- if (status === "succeeded" || status === "completed" || status === "success") {
956
- const urls = extractImageUrls(pollBody);
957
- const direct = pollBody.url;
958
- const all = urls.length > 0 ? urls : direct ? [direct] : [];
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: all.map((url) => ({ url, type: "video" })) };
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(pollBody)}`
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 Error(`ai-lcr: Kunavo video job ${jobId} timed out after ${pollTimeoutMs}ms`);
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
- return isVideo ? runVideo(req) : runImage(req);
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 { models, autoSort = false, resetIntervalMs, onError, onCost, onCall, defaultCacheReadRatio } = config;
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",
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",