ai-lcr 0.5.4 → 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,43 @@ 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
+
7
44
  ## [0.5.4] — 2026-06-03
8
45
 
9
46
  ### Changed
package/README.md CHANGED
@@ -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
 
package/dist/index.cjs CHANGED
@@ -909,11 +909,15 @@ var MEDIA_PRICING = {
909
909
  ]
910
910
  },
911
911
  // ── Google video (Veo) ──────────────────────────────────────
912
- // ⚠️ Version/SKU mismatch across providers: Kunavo bills "veo-3" per CALL
913
- // (flat fee per clip; Veo 3 generates ~8s, audio/res tier unconfirmed); fal
914
- // bills "veo3.1" per SECOND. Normalized to a 5s clip the per-call price wins
915
- // by a wide margin verify the clip's duration/resolution/audio before
916
- // 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.
917
921
  "google/veo-3": {
918
922
  id: "google/veo-3",
919
923
  modality: "video",
@@ -926,7 +930,7 @@ var MEDIA_PRICING = {
926
930
  id: "google/veo-3-lite",
927
931
  modality: "video",
928
932
  routes: [
929
- { 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" },
930
934
  { provider: "fal", externalId: "fal-ai/veo3.1/lite", pricing: { unit: "second", cents: 8 }, note: "veo3.1 lite, 1080p audio-on" }
931
935
  ]
932
936
  },
@@ -946,12 +950,26 @@ function extractImageUrls(body) {
946
950
  if (!Array.isArray(data)) return [];
947
951
  return data.map((d) => d?.url).filter((u) => typeof u === "string" && u.length > 0);
948
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
+ }
949
965
  function createKunavoMediaAdapter(config) {
950
966
  const {
951
967
  apiKey,
952
968
  baseUrl = DEFAULT_BASE,
969
+ videoMode = "async",
953
970
  pollIntervalMs = 5e3,
954
- pollTimeoutMs = 3e5,
971
+ pollTimeoutMs = 6e5,
972
+ syncVideoTimeoutMs = 6e5,
955
973
  fetchImpl = fetch
956
974
  } = config;
957
975
  const headers = {
@@ -959,7 +977,8 @@ function createKunavoMediaAdapter(config) {
959
977
  authorization: `Bearer ${apiKey}`
960
978
  };
961
979
  async function runImage(req) {
962
- 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}`, {
963
982
  method: "POST",
964
983
  headers,
965
984
  body: JSON.stringify({ model: req.externalId, ...req.input })
@@ -967,16 +986,15 @@ function createKunavoMediaAdapter(config) {
967
986
  if (!res.ok) {
968
987
  throw new KunavoMediaError(res.status, await safeText(res));
969
988
  }
970
- const body = await res.json();
971
- const urls = extractImageUrls(body);
989
+ const urls = extractImageUrls(await res.json());
972
990
  if (urls.length === 0) {
973
991
  throw new Error(`ai-lcr: Kunavo returned no image URL for "${req.externalId}"`);
974
992
  }
975
993
  const outputs = urls.map((url) => ({ url, type: "image" }));
976
994
  return { outputs };
977
995
  }
978
- async function runVideo(req) {
979
- const submit = await fetchImpl(`${baseUrl}/v1/video/generations`, {
996
+ async function runVideoAsync(req) {
997
+ const submit = await fetchImpl(`${baseUrl}/v1/videos`, {
980
998
  method: "POST",
981
999
  headers,
982
1000
  body: JSON.stringify({ model: req.externalId, ...req.input })
@@ -985,51 +1003,75 @@ function createKunavoMediaAdapter(config) {
985
1003
  throw new KunavoMediaError(submit.status, await safeText(submit));
986
1004
  }
987
1005
  const submitBody = await submit.json();
988
- const inlineUrls = extractImageUrls(submitBody);
989
- if (inlineUrls.length > 0) {
990
- return { outputs: inlineUrls.map((url) => ({ url, type: "video" })) };
991
- }
992
- const jobId = submitBody.id ?? submitBody.task_id ?? submitBody.request_id;
1006
+ const jobId = submitBody.id;
993
1007
  if (!jobId) {
994
1008
  throw new Error(
995
- `ai-lcr: Kunavo video submit returned no job id (got keys: ${Object.keys(
996
- submitBody
997
- ).join(", ")})`
1009
+ `ai-lcr: Kunavo video submit returned no job id (got keys: ${Object.keys(submitBody).join(
1010
+ ", "
1011
+ )})`
998
1012
  );
999
1013
  }
1000
1014
  const deadline = Date.now() + pollTimeoutMs;
1001
1015
  while (Date.now() < deadline) {
1002
- await sleep(pollIntervalMs);
1003
- const poll = await fetchImpl(`${baseUrl}/v1/video/generations/${jobId}`, {
1004
- headers
1005
- });
1016
+ const poll = await fetchImpl(`${baseUrl}/v1/videos/${jobId}`, { headers });
1006
1017
  if (!poll.ok) {
1007
1018
  throw new KunavoMediaError(poll.status, await safeText(poll));
1008
1019
  }
1009
- const pollBody = await poll.json();
1010
- const status = String(pollBody.status ?? "").toLowerCase();
1011
- if (status === "succeeded" || status === "completed" || status === "success") {
1012
- const urls = extractImageUrls(pollBody);
1013
- const direct = pollBody.url;
1014
- const all = urls.length > 0 ? urls : direct ? [direct] : [];
1015
- if (all.length === 0) {
1016
- 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`);
1017
1026
  }
1018
- return { outputs: all.map((url) => ({ url, type: "video" })) };
1027
+ return { outputs: urls.map((url) => ({ url, type: "video" })) };
1019
1028
  }
1020
1029
  if (status === "failed" || status === "error") {
1030
+ const err = body.error;
1021
1031
  throw new Error(
1022
- `ai-lcr: Kunavo video job ${jobId} failed: ${JSON.stringify(pollBody)}`
1032
+ `ai-lcr: Kunavo video job ${jobId} failed: ${err?.message ?? JSON.stringify(body)}`
1033
+ );
1034
+ }
1035
+ await sleep(pollIntervalMs);
1036
+ }
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`
1023
1056
  );
1024
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}"`);
1025
1066
  }
1026
- throw new Error(`ai-lcr: Kunavo video job ${jobId} timed out after ${pollTimeoutMs}ms`);
1067
+ return { outputs: urls.map((url) => ({ url, type: "video" })) };
1027
1068
  }
1028
1069
  return {
1029
1070
  provider: "kunavo",
1030
1071
  async run(req) {
1031
1072
  const isVideo = /(^|\/)veo/i.test(req.externalId);
1032
- return isVideo ? runVideo(req) : runImage(req);
1073
+ if (!isVideo) return runImage(req);
1074
+ return videoMode === "sync" ? runVideoSync(req) : runVideoAsync(req);
1033
1075
  }
1034
1076
  };
1035
1077
  }
package/dist/index.d.cts CHANGED
@@ -476,34 +476,50 @@ declare const MEDIA_PRICING: MediaRegistry;
476
476
  declare const OFFICIAL_PRICES: Record<string, MediaPricing>;
477
477
 
478
478
  /**
479
- * Kunavo media adapter — image (sync) + video (async poll).
479
+ * Kunavo media adapter — image (sync) + video (async poll, sync fallback).
480
480
  *
481
481
  * Kunavo is NOT an AI-SDK chat provider for media: image/video generation uses
482
482
  * its own REST endpoints, not `/v1/chat/completions`. So this is a hand-rolled
483
- * `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).
484
485
  *
485
- * - Image: POST /v1/images/generations → returns a files.kunavo.com URL.
486
- * Synchronous (~11s for nano-banana). VERIFIED end-to-end.
487
- * - Video: POST /v1/video/generations (singular "video"; /videos/ 405).
488
- * Long-running. The submit→poll path here is IMPLEMENTED FROM THE
489
- * DOCS SHAPE BUT NOT YET RUN against a real job (veo-3 generation
490
- * was skipped to save cost). Treat the poll loop as unverified:
491
- * the field names (`id`/`status`/`url`) may differ from what the
492
- * 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).
493
499
  *
494
- * Kunavo does NOT return a per-call cost in the generation response, so cost is
495
- * left to the router's normalized estimate (MediaGenerateResult.costCents
496
- * 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).
497
504
  */
498
505
 
499
506
  interface KunavoMediaConfig {
500
507
  apiKey: string;
501
508
  /** Override for testing. Defaults to https://api.kunavo.com. */
502
509
  baseUrl?: string;
503
- /** 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. */
504
518
  pollIntervalMs?: number;
505
- /** 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). */
506
520
  pollTimeoutMs?: number;
521
+ /** Hard cap for the blocking sync-video HTTP call (ms). Default 600000 (10m). */
522
+ syncVideoTimeoutMs?: number;
507
523
  /** Injected for testing; defaults to global fetch. */
508
524
  fetchImpl?: typeof fetch;
509
525
  }
package/dist/index.d.ts CHANGED
@@ -476,34 +476,50 @@ declare const MEDIA_PRICING: MediaRegistry;
476
476
  declare const OFFICIAL_PRICES: Record<string, MediaPricing>;
477
477
 
478
478
  /**
479
- * Kunavo media adapter — image (sync) + video (async poll).
479
+ * Kunavo media adapter — image (sync) + video (async poll, sync fallback).
480
480
  *
481
481
  * Kunavo is NOT an AI-SDK chat provider for media: image/video generation uses
482
482
  * its own REST endpoints, not `/v1/chat/completions`. So this is a hand-rolled
483
- * `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).
484
485
  *
485
- * - Image: POST /v1/images/generations → returns a files.kunavo.com URL.
486
- * Synchronous (~11s for nano-banana). VERIFIED end-to-end.
487
- * - Video: POST /v1/video/generations (singular "video"; /videos/ 405).
488
- * Long-running. The submit→poll path here is IMPLEMENTED FROM THE
489
- * DOCS SHAPE BUT NOT YET RUN against a real job (veo-3 generation
490
- * was skipped to save cost). Treat the poll loop as unverified:
491
- * the field names (`id`/`status`/`url`) may differ from what the
492
- * 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).
493
499
  *
494
- * Kunavo does NOT return a per-call cost in the generation response, so cost is
495
- * left to the router's normalized estimate (MediaGenerateResult.costCents
496
- * 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).
497
504
  */
498
505
 
499
506
  interface KunavoMediaConfig {
500
507
  apiKey: string;
501
508
  /** Override for testing. Defaults to https://api.kunavo.com. */
502
509
  baseUrl?: string;
503
- /** 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. */
504
518
  pollIntervalMs?: number;
505
- /** 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). */
506
520
  pollTimeoutMs?: number;
521
+ /** Hard cap for the blocking sync-video HTTP call (ms). Default 600000 (10m). */
522
+ syncVideoTimeoutMs?: number;
507
523
  /** Injected for testing; defaults to global fetch. */
508
524
  fetchImpl?: typeof fetch;
509
525
  }
package/dist/index.js CHANGED
@@ -863,11 +863,15 @@ var MEDIA_PRICING = {
863
863
  ]
864
864
  },
865
865
  // ── Google video (Veo) ──────────────────────────────────────
866
- // ⚠️ Version/SKU mismatch across providers: Kunavo bills "veo-3" per CALL
867
- // (flat fee per clip; Veo 3 generates ~8s, audio/res tier unconfirmed); fal
868
- // bills "veo3.1" per SECOND. Normalized to a 5s clip the per-call price wins
869
- // by a wide margin verify the clip's duration/resolution/audio before
870
- // 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.
871
875
  "google/veo-3": {
872
876
  id: "google/veo-3",
873
877
  modality: "video",
@@ -880,7 +884,7 @@ var MEDIA_PRICING = {
880
884
  id: "google/veo-3-lite",
881
885
  modality: "video",
882
886
  routes: [
883
- { 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" },
884
888
  { provider: "fal", externalId: "fal-ai/veo3.1/lite", pricing: { unit: "second", cents: 8 }, note: "veo3.1 lite, 1080p audio-on" }
885
889
  ]
886
890
  },
@@ -900,12 +904,26 @@ function extractImageUrls(body) {
900
904
  if (!Array.isArray(data)) return [];
901
905
  return data.map((d) => d?.url).filter((u) => typeof u === "string" && u.length > 0);
902
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
+ }
903
919
  function createKunavoMediaAdapter(config) {
904
920
  const {
905
921
  apiKey,
906
922
  baseUrl = DEFAULT_BASE,
923
+ videoMode = "async",
907
924
  pollIntervalMs = 5e3,
908
- pollTimeoutMs = 3e5,
925
+ pollTimeoutMs = 6e5,
926
+ syncVideoTimeoutMs = 6e5,
909
927
  fetchImpl = fetch
910
928
  } = config;
911
929
  const headers = {
@@ -913,7 +931,8 @@ function createKunavoMediaAdapter(config) {
913
931
  authorization: `Bearer ${apiKey}`
914
932
  };
915
933
  async function runImage(req) {
916
- 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}`, {
917
936
  method: "POST",
918
937
  headers,
919
938
  body: JSON.stringify({ model: req.externalId, ...req.input })
@@ -921,16 +940,15 @@ function createKunavoMediaAdapter(config) {
921
940
  if (!res.ok) {
922
941
  throw new KunavoMediaError(res.status, await safeText(res));
923
942
  }
924
- const body = await res.json();
925
- const urls = extractImageUrls(body);
943
+ const urls = extractImageUrls(await res.json());
926
944
  if (urls.length === 0) {
927
945
  throw new Error(`ai-lcr: Kunavo returned no image URL for "${req.externalId}"`);
928
946
  }
929
947
  const outputs = urls.map((url) => ({ url, type: "image" }));
930
948
  return { outputs };
931
949
  }
932
- async function runVideo(req) {
933
- const submit = await fetchImpl(`${baseUrl}/v1/video/generations`, {
950
+ async function runVideoAsync(req) {
951
+ const submit = await fetchImpl(`${baseUrl}/v1/videos`, {
934
952
  method: "POST",
935
953
  headers,
936
954
  body: JSON.stringify({ model: req.externalId, ...req.input })
@@ -939,51 +957,75 @@ function createKunavoMediaAdapter(config) {
939
957
  throw new KunavoMediaError(submit.status, await safeText(submit));
940
958
  }
941
959
  const submitBody = await submit.json();
942
- const inlineUrls = extractImageUrls(submitBody);
943
- if (inlineUrls.length > 0) {
944
- return { outputs: inlineUrls.map((url) => ({ url, type: "video" })) };
945
- }
946
- const jobId = submitBody.id ?? submitBody.task_id ?? submitBody.request_id;
960
+ const jobId = submitBody.id;
947
961
  if (!jobId) {
948
962
  throw new Error(
949
- `ai-lcr: Kunavo video submit returned no job id (got keys: ${Object.keys(
950
- submitBody
951
- ).join(", ")})`
963
+ `ai-lcr: Kunavo video submit returned no job id (got keys: ${Object.keys(submitBody).join(
964
+ ", "
965
+ )})`
952
966
  );
953
967
  }
954
968
  const deadline = Date.now() + pollTimeoutMs;
955
969
  while (Date.now() < deadline) {
956
- await sleep(pollIntervalMs);
957
- const poll = await fetchImpl(`${baseUrl}/v1/video/generations/${jobId}`, {
958
- headers
959
- });
970
+ const poll = await fetchImpl(`${baseUrl}/v1/videos/${jobId}`, { headers });
960
971
  if (!poll.ok) {
961
972
  throw new KunavoMediaError(poll.status, await safeText(poll));
962
973
  }
963
- const pollBody = await poll.json();
964
- const status = String(pollBody.status ?? "").toLowerCase();
965
- if (status === "succeeded" || status === "completed" || status === "success") {
966
- const urls = extractImageUrls(pollBody);
967
- const direct = pollBody.url;
968
- const all = urls.length > 0 ? urls : direct ? [direct] : [];
969
- if (all.length === 0) {
970
- 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`);
971
980
  }
972
- return { outputs: all.map((url) => ({ url, type: "video" })) };
981
+ return { outputs: urls.map((url) => ({ url, type: "video" })) };
973
982
  }
974
983
  if (status === "failed" || status === "error") {
984
+ const err = body.error;
975
985
  throw new Error(
976
- `ai-lcr: Kunavo video job ${jobId} failed: ${JSON.stringify(pollBody)}`
986
+ `ai-lcr: Kunavo video job ${jobId} failed: ${err?.message ?? JSON.stringify(body)}`
987
+ );
988
+ }
989
+ await sleep(pollIntervalMs);
990
+ }
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`
977
1010
  );
978
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}"`);
979
1020
  }
980
- throw new Error(`ai-lcr: Kunavo video job ${jobId} timed out after ${pollTimeoutMs}ms`);
1021
+ return { outputs: urls.map((url) => ({ url, type: "video" })) };
981
1022
  }
982
1023
  return {
983
1024
  provider: "kunavo",
984
1025
  async run(req) {
985
1026
  const isVideo = /(^|\/)veo/i.test(req.externalId);
986
- return isVideo ? runVideo(req) : runImage(req);
1027
+ if (!isVideo) return runImage(req);
1028
+ return videoMode === "sync" ? runVideoSync(req) : runVideoAsync(req);
987
1029
  }
988
1030
  };
989
1031
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lcr",
3
- "version": "0.5.4",
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",