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 +37 -0
- package/README.md +7 -3
- package/dist/index.cjs +78 -36
- package/dist/index.d.cts +31 -15
- package/dist/index.d.ts +31 -15
- package/dist/index.js +78 -36
- package/package.json +1 -1
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 (
|
|
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
|
|
package/dist/index.cjs
CHANGED
|
@@ -909,11 +909,15 @@ var MEDIA_PRICING = {
|
|
|
909
909
|
]
|
|
910
910
|
},
|
|
911
911
|
// ── Google video (Veo) ──────────────────────────────────────
|
|
912
|
-
//
|
|
913
|
-
// (
|
|
914
|
-
//
|
|
915
|
-
//
|
|
916
|
-
//
|
|
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 (
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
979
|
-
const submit = await fetchImpl(`${baseUrl}/v1/
|
|
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
|
|
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
|
-
|
|
997
|
-
)
|
|
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
|
|
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
|
|
1010
|
-
const status = String(
|
|
1011
|
-
if (status === "
|
|
1012
|
-
const urls =
|
|
1013
|
-
|
|
1014
|
-
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 →
|
|
486
|
-
*
|
|
487
|
-
* -
|
|
488
|
-
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
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,
|
|
495
|
-
* left to the router's
|
|
496
|
-
* 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).
|
|
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
|
-
/**
|
|
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
|
|
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 →
|
|
486
|
-
*
|
|
487
|
-
* -
|
|
488
|
-
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
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,
|
|
495
|
-
* left to the router's
|
|
496
|
-
* 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).
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
//
|
|
867
|
-
// (
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
//
|
|
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 (
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
933
|
-
const submit = await fetchImpl(`${baseUrl}/v1/
|
|
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
|
|
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
|
-
|
|
951
|
-
)
|
|
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
|
|
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
|
|
964
|
-
const status = String(
|
|
965
|
-
if (status === "
|
|
966
|
-
const urls =
|
|
967
|
-
|
|
968
|
-
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|