ai-lcr 0.2.5 → 0.2.6

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,22 @@ 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.2.6] — 2026-06-01
8
+
9
+ ### Changed
10
+
11
+ - **fal media adapter now covers image *and* video** via fal's async queue API
12
+ (submit → poll `status_url` → fetch `response_url`), replacing the synchronous
13
+ image-only `fal.run` adapter shipped in 0.2.5. This is ai-lcr's first working
14
+ **video** execution path: the registry already priced/routed the Veo family
15
+ but no adapter could run it. Same house style — raw `fetch`, injectable
16
+ `fetchImpl`, no provider SDK; `Authorization: Key` (not Bearer); cost left to
17
+ the router's normalized estimate (the queue result carries no per-call price).
18
+ Following the submit response's `status_url`/`response_url` sidesteps fal's
19
+ sub-path quirk (`fal-ai/flux/schnell` submits to the full path, but status and
20
+ result live under the `fal-ai/flux` base). `createFalMediaAdapter`'s public
21
+ name is unchanged; image callers are unaffected.
22
+
7
23
  ## [0.2.5] — 2026-06-01
8
24
 
9
25
  Pre-launch failover-robustness + media-provider pass — closing cases where a
@@ -104,5 +120,6 @@ Release-quality and engine-correctness pass.
104
120
  - Dual ESM/CJS build. Media (image/video) least-cost routing with the Runware
105
121
  and Kunavo adapters; cap-aware failover for the text router.
106
122
 
123
+ [0.2.6]: https://github.com/victorzhrn/ai-lcr/releases/tag/v0.2.6
107
124
  [0.2.5]: https://github.com/victorzhrn/ai-lcr/releases/tag/v0.2.5
108
125
  [0.2.3]: https://github.com/victorzhrn/ai-lcr/releases/tag/v0.2.3
package/README.md CHANGED
@@ -156,7 +156,7 @@ Any OpenAI-compatible endpoint works — and so does any AI SDK provider package
156
156
 
157
157
  - **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).
158
158
  - **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)
159
- - **Image / video:** [Kunavo](https://kunavo.com/?ref=victorimf) (**20% off**) · [TokenMart](https://thetokenmart.ai) · [fal.ai](https://fal.ai) · [Runware](https://runware.ai) — image routing available via `createMediaLCR` (Kunavo + Runware + fal adapters); video on the roadmap
159
+ - **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
160
160
 
161
161
  ## Text model pricing
162
162
 
@@ -273,7 +273,8 @@ Two OpenAI-compatible providers, same probe, same day. Cells cover both families
273
273
  - [ ] Bundled price table for zero-config pricing (drop the manual `cost` numbers)
274
274
  - [ ] Provider-quirk middleware (transparently patch known per-provider request quirks, e.g. Kunavo's ignored `max_tokens`)
275
275
  - [ ] Feed probe results into routing automatically (auto-exclude a model from a provider that fails its probe)
276
- - [ ] Image & video model routing (fal.ai / Runware / Kunavo)
276
+ - [x] Image & video model routing (`createMediaLCR`) image via Kunavo + Runware + fal; **video live via fal** (async queue API)
277
+ - [ ] Normalized cross-provider video price comparison + verified Kunavo/Runware video adapters
277
278
 
278
279
  ## Affiliate disclosure
279
280
 
package/README.zh-CN.md CHANGED
@@ -114,7 +114,7 @@ const lcr = createLCR({
114
114
 
115
115
  - **模型厂商官方 API(原生):** 通过各自的 AI SDK provider 包直连 [DeepSeek](https://platform.deepseek.com)、[OpenAI](https://openai.com)、[Anthropic](https://anthropic.com)、[Google](https://ai.google.dev)、[xAI](https://x.ai) 等——无加价,原生特性齐全。见上方「直连模型厂商官方 API(原生 provider)」一节。
116
116
  - **文本聚合器:** [OpenRouter](https://openrouter.ai)(覆盖最广,列表定价)· [Kunavo](https://kunavo.com/?ref=victorimf)(**全模型 8 折**)· [TokenMart](https://thetokenmart.ai)(按模型 85 折–35 折不等)
117
- - **图像 / 视频:** [Kunavo](https://kunavo.com/?ref=victorimf)(**8 折**)· [TokenMart](https://thetokenmart.ai) · [fal.ai](https://fal.ai) · [Runware](https://runware.ai) —— 路由功能在路线图中
117
+ - **图像 / 视频:** [Kunavo](https://kunavo.com/?ref=victorimf)(**8 折**)· [TokenMart](https://thetokenmart.ai) · [fal.ai](https://fal.ai) · [Runware](https://runware.ai) —— 通过 `createMediaLCR` 路由。图像:Kunavo + Runware + fal。视频:fal(已可用,走其异步队列 API);Kunavo 的 Veo 轮询路径已实现但未验证
118
118
 
119
119
  ## 文本模型价格
120
120
 
@@ -229,7 +229,8 @@ API_KEY=$TOKENMART_API_KEY BASE=https://api.tokenmart.ai \
229
229
  - [ ] 内置价格表,实现零配置定价(省去手填 `cost` 数字)
230
230
  - [ ] provider 怪癖中间件(透明地修补已知怪癖,如 Kunavo 被忽略的 `max_tokens`)
231
231
  - [ ] 把 probe 结果自动接入路由(探测失败的 provider×model 自动从列表剔除)
232
- - [ ] 图像与视频模型路由(fal.ai / Runware / Kunavo
232
+ - [x] 图像与视频模型路由(`createMediaLCR`)—— 图像走 Kunavo + Runware + fal;**视频已可用,走 fal**(异步队列 API
233
+ - [ ] 归一化的跨 provider 视频价格对比 + 验证 Kunavo/Runware 视频适配器
233
234
 
234
235
  ## 联盟(Affiliate)披露
235
236
 
package/dist/index.cjs CHANGED
@@ -903,49 +903,84 @@ var RunwareMediaError = class extends Error {
903
903
  };
904
904
 
905
905
  // src/adapters/fal-media.ts
906
- var DEFAULT_BASE3 = "https://fal.run";
907
- function extractImageUrls2(body) {
908
- const fromArray = (body.images ?? []).map((im) => im?.url).filter((u) => typeof u === "string" && u.length > 0);
909
- if (fromArray.length > 0) return fromArray;
910
- const single = body.image?.url;
911
- return typeof single === "string" && single.length > 0 ? [single] : [];
912
- }
913
- function errorMessage2(body) {
914
- if (typeof body.detail === "string") return body.detail;
915
- if (Array.isArray(body.detail)) {
916
- const msgs = body.detail.map((d) => d?.msg).filter(Boolean);
917
- if (msgs.length > 0) return msgs.join("; ");
906
+ var DEFAULT_BASE3 = "https://queue.fal.run";
907
+ function extractOutputs(raw) {
908
+ if (!raw || typeof raw !== "object") return [];
909
+ const data = raw;
910
+ const out = [];
911
+ const pushUrl = (url, type) => {
912
+ if (typeof url === "string" && url.length > 0) out.push({ url, type });
913
+ };
914
+ if (Array.isArray(data.images)) {
915
+ for (const img of data.images) pushUrl(img?.url, "image");
916
+ }
917
+ pushUrl(data.image?.url, "image");
918
+ if (Array.isArray(data.videos)) {
919
+ for (const v of data.videos) pushUrl(v?.url, "video");
918
920
  }
919
- return body.error || body.message || "unknown";
921
+ pushUrl(data.video?.url, "video");
922
+ return out;
920
923
  }
921
924
  function createFalMediaAdapter(config) {
922
- const { apiKey, baseUrl = DEFAULT_BASE3, fetchImpl = fetch } = config;
925
+ const {
926
+ apiKey,
927
+ baseUrl = DEFAULT_BASE3,
928
+ pollIntervalMs = 3e3,
929
+ pollTimeoutMs = 3e5,
930
+ fetchImpl = fetch
931
+ } = config;
932
+ const headers = {
933
+ "content-type": "application/json",
934
+ authorization: `Key ${apiKey}`
935
+ };
923
936
  return {
924
937
  provider: "fal",
925
938
  async run(req) {
926
- const res = await fetchImpl(`${baseUrl}/${req.externalId}`, {
939
+ const submitRes = await fetchImpl(`${baseUrl}/${req.externalId}`, {
927
940
  method: "POST",
928
- headers: {
929
- "content-type": "application/json",
930
- authorization: `Key ${apiKey}`,
931
- accept: "application/json"
932
- },
941
+ headers,
933
942
  body: JSON.stringify(req.input)
934
943
  });
935
- let body;
936
- try {
937
- body = await res.json();
938
- } catch {
939
- body = {};
944
+ if (!submitRes.ok) {
945
+ throw new FalMediaError(submitRes.status, await safeText2(submitRes));
946
+ }
947
+ const submit = await submitRes.json();
948
+ const statusUrl = submit.status_url;
949
+ const responseUrl = submit.response_url;
950
+ if (!statusUrl || !responseUrl) {
951
+ throw new Error(
952
+ `ai-lcr: fal submit for "${req.externalId}" returned no status/response URL (keys: ${Object.keys(
953
+ submit
954
+ ).join(", ")})`
955
+ );
956
+ }
957
+ const deadline = Date.now() + pollTimeoutMs;
958
+ let completed = false;
959
+ while (Date.now() < deadline) {
960
+ const statusRes = await fetchImpl(statusUrl, { headers });
961
+ if (!statusRes.ok) {
962
+ throw new FalMediaError(statusRes.status, await safeText2(statusRes));
963
+ }
964
+ const status = String((await statusRes.json()).status ?? "");
965
+ if (status === "COMPLETED") {
966
+ completed = true;
967
+ break;
968
+ }
969
+ await sleep2(pollIntervalMs);
970
+ }
971
+ if (!completed) {
972
+ throw new Error(
973
+ `ai-lcr: fal job for "${req.externalId}" timed out after ${pollTimeoutMs}ms`
974
+ );
940
975
  }
941
- if (!res.ok) {
942
- throw new FalMediaError(res.status, errorMessage2(body));
976
+ const resultRes = await fetchImpl(responseUrl, { headers });
977
+ if (!resultRes.ok) {
978
+ throw new FalMediaError(resultRes.status, await safeText2(resultRes));
943
979
  }
944
- const urls = extractImageUrls2(body);
945
- if (urls.length === 0) {
946
- throw new Error(`ai-lcr: fal returned no image URL for "${req.externalId}"`);
980
+ const outputs = extractOutputs(await resultRes.json());
981
+ if (outputs.length === 0) {
982
+ throw new Error(`ai-lcr: fal returned no media URL for "${req.externalId}"`);
947
983
  }
948
- const outputs = urls.map((url) => ({ url, type: "image" }));
949
984
  return { outputs, units: outputs.length };
950
985
  }
951
986
  };
@@ -958,6 +993,16 @@ var FalMediaError = class extends Error {
958
993
  }
959
994
  status;
960
995
  };
996
+ function sleep2(ms) {
997
+ return new Promise((r) => setTimeout(r, ms));
998
+ }
999
+ async function safeText2(res) {
1000
+ try {
1001
+ return await res.text();
1002
+ } catch {
1003
+ return "<no body>";
1004
+ }
1005
+ }
961
1006
 
962
1007
  // src/index.ts
963
1008
  function isLanguageModel(entry) {
package/dist/index.d.cts CHANGED
@@ -359,35 +359,42 @@ interface RunwareMediaConfig {
359
359
  declare function createRunwareMediaAdapter(config: RunwareMediaConfig): MediaAdapter;
360
360
 
361
361
  /**
362
- * fal.ai media adapter — image generation (synchronous).
362
+ * fal media adapter — image (queue) + video (queue, async poll).
363
363
  *
364
- * fal exposes every model at `https://fal.run/<model-id>` (the synchronous API):
365
- * POST the model's inputs as a flat JSON body, get the result back in the same
366
- * response. This adapter passes the caller's `input` straight through, so any
367
- * fal image model and any of its parameters (prompt, image_size, num_images,
368
- * image_url for i2i/edit, …) work without this adapter knowing about them — it
369
- * stays generic, not tied to one model family.
364
+ * fal serves every model through one async queue API, so a single submit→poll→
365
+ * fetch-result path covers both image and video. That is the whole reason this
366
+ * adapter exists: it is ai-lcr's first VIDEO-capable execution path. (The
367
+ * Runware adapter is image-only; the Kunavo one's video poll loop is unverified.)
370
368
  *
371
- * Auth: fal uses `Authorization: Key <FAL_KEY>` (NOT a Bearer token).
369
+ * Implementation note: ai-art's fal adapter uses the `@fal-ai/client` SDK, but
370
+ * ai-lcr deliberately keeps zero provider SDKs — every adapter is raw `fetch`
371
+ * with an injectable `fetchImpl` for testing (see runware-media, kunavo-media).
372
+ * So this re-implements the three queue calls against fal's REST endpoints:
372
373
  *
373
- * Errors: fal returns a proper HTTP status — 401 (bad key), 403 (insufficient
374
- * balance / no permission), 422 (bad input), 429 (rate limit), 5xx. We surface
375
- * the status on the thrown error so the router's `isRetryableError` can decide
376
- * whether to fail over. A 403 "exhausted balance" is retryable (fall over to the
377
- * next provider); a 422 bad-input is not (don't waste the fallbacks).
374
+ * 1. submit POST https://queue.fal.run/{model} → { request_id, status_url, response_url }
375
+ * 2. status GET {status_url} → { status: IN_QUEUE | IN_PROGRESS | COMPLETED }
376
+ * 3. result GET {response_url} → { images:[…] } | { video:{url} } |
378
377
  *
379
- * Cost: the synchronous response does NOT carry a per-call price (fal billing is
380
- * a separate account-level API), so `costCents` stays undefined and the router
381
- * falls back to its normalized estimate same contract as the Kunavo adapter.
378
+ * We follow the `status_url` / `response_url` returned by submit rather than
379
+ * rebuilding them, which sidesteps fal's sub-path quirk (a model like
380
+ * `fal-ai/flux/schnell` submits to the full path but its status/result live
381
+ * under the `fal-ai/flux` base).
382
382
  *
383
- * Video: fal video (e.g. veo3.1) is a long-running queue job, a different code
384
- * path — out of scope here, like the Runware adapter. Image inference only.
383
+ * Auth: fal uses `Authorization: Key {FAL_KEY}` (NOT Bearer).
384
+ *
385
+ * Cost: fal's queue result does not carry a per-call price, so cost is left to
386
+ * the router's normalized estimate (costCents stays undefined; `units` is the
387
+ * output count — one image, or one clip).
385
388
  */
386
389
 
387
390
  interface FalMediaConfig {
388
391
  apiKey: string;
389
- /** Override for testing. Defaults to https://fal.run. */
392
+ /** Override for testing. Defaults to https://queue.fal.run. */
390
393
  baseUrl?: string;
394
+ /** Video/job poll cadence (ms). Default 3000. */
395
+ pollIntervalMs?: number;
396
+ /** Max time to wait for a job before giving up (ms). Default 300000 (5m). */
397
+ pollTimeoutMs?: number;
391
398
  /** Injected for testing; defaults to global fetch. */
392
399
  fetchImpl?: typeof fetch;
393
400
  }
package/dist/index.d.ts CHANGED
@@ -359,35 +359,42 @@ interface RunwareMediaConfig {
359
359
  declare function createRunwareMediaAdapter(config: RunwareMediaConfig): MediaAdapter;
360
360
 
361
361
  /**
362
- * fal.ai media adapter — image generation (synchronous).
362
+ * fal media adapter — image (queue) + video (queue, async poll).
363
363
  *
364
- * fal exposes every model at `https://fal.run/<model-id>` (the synchronous API):
365
- * POST the model's inputs as a flat JSON body, get the result back in the same
366
- * response. This adapter passes the caller's `input` straight through, so any
367
- * fal image model and any of its parameters (prompt, image_size, num_images,
368
- * image_url for i2i/edit, …) work without this adapter knowing about them — it
369
- * stays generic, not tied to one model family.
364
+ * fal serves every model through one async queue API, so a single submit→poll→
365
+ * fetch-result path covers both image and video. That is the whole reason this
366
+ * adapter exists: it is ai-lcr's first VIDEO-capable execution path. (The
367
+ * Runware adapter is image-only; the Kunavo one's video poll loop is unverified.)
370
368
  *
371
- * Auth: fal uses `Authorization: Key <FAL_KEY>` (NOT a Bearer token).
369
+ * Implementation note: ai-art's fal adapter uses the `@fal-ai/client` SDK, but
370
+ * ai-lcr deliberately keeps zero provider SDKs — every adapter is raw `fetch`
371
+ * with an injectable `fetchImpl` for testing (see runware-media, kunavo-media).
372
+ * So this re-implements the three queue calls against fal's REST endpoints:
372
373
  *
373
- * Errors: fal returns a proper HTTP status — 401 (bad key), 403 (insufficient
374
- * balance / no permission), 422 (bad input), 429 (rate limit), 5xx. We surface
375
- * the status on the thrown error so the router's `isRetryableError` can decide
376
- * whether to fail over. A 403 "exhausted balance" is retryable (fall over to the
377
- * next provider); a 422 bad-input is not (don't waste the fallbacks).
374
+ * 1. submit POST https://queue.fal.run/{model} → { request_id, status_url, response_url }
375
+ * 2. status GET {status_url} → { status: IN_QUEUE | IN_PROGRESS | COMPLETED }
376
+ * 3. result GET {response_url} → { images:[…] } | { video:{url} } |
378
377
  *
379
- * Cost: the synchronous response does NOT carry a per-call price (fal billing is
380
- * a separate account-level API), so `costCents` stays undefined and the router
381
- * falls back to its normalized estimate same contract as the Kunavo adapter.
378
+ * We follow the `status_url` / `response_url` returned by submit rather than
379
+ * rebuilding them, which sidesteps fal's sub-path quirk (a model like
380
+ * `fal-ai/flux/schnell` submits to the full path but its status/result live
381
+ * under the `fal-ai/flux` base).
382
382
  *
383
- * Video: fal video (e.g. veo3.1) is a long-running queue job, a different code
384
- * path — out of scope here, like the Runware adapter. Image inference only.
383
+ * Auth: fal uses `Authorization: Key {FAL_KEY}` (NOT Bearer).
384
+ *
385
+ * Cost: fal's queue result does not carry a per-call price, so cost is left to
386
+ * the router's normalized estimate (costCents stays undefined; `units` is the
387
+ * output count — one image, or one clip).
385
388
  */
386
389
 
387
390
  interface FalMediaConfig {
388
391
  apiKey: string;
389
- /** Override for testing. Defaults to https://fal.run. */
392
+ /** Override for testing. Defaults to https://queue.fal.run. */
390
393
  baseUrl?: string;
394
+ /** Video/job poll cadence (ms). Default 3000. */
395
+ pollIntervalMs?: number;
396
+ /** Max time to wait for a job before giving up (ms). Default 300000 (5m). */
397
+ pollTimeoutMs?: number;
391
398
  /** Injected for testing; defaults to global fetch. */
392
399
  fetchImpl?: typeof fetch;
393
400
  }
package/dist/index.js CHANGED
@@ -863,49 +863,84 @@ var RunwareMediaError = class extends Error {
863
863
  };
864
864
 
865
865
  // src/adapters/fal-media.ts
866
- var DEFAULT_BASE3 = "https://fal.run";
867
- function extractImageUrls2(body) {
868
- const fromArray = (body.images ?? []).map((im) => im?.url).filter((u) => typeof u === "string" && u.length > 0);
869
- if (fromArray.length > 0) return fromArray;
870
- const single = body.image?.url;
871
- return typeof single === "string" && single.length > 0 ? [single] : [];
872
- }
873
- function errorMessage2(body) {
874
- if (typeof body.detail === "string") return body.detail;
875
- if (Array.isArray(body.detail)) {
876
- const msgs = body.detail.map((d) => d?.msg).filter(Boolean);
877
- if (msgs.length > 0) return msgs.join("; ");
866
+ var DEFAULT_BASE3 = "https://queue.fal.run";
867
+ function extractOutputs(raw) {
868
+ if (!raw || typeof raw !== "object") return [];
869
+ const data = raw;
870
+ const out = [];
871
+ const pushUrl = (url, type) => {
872
+ if (typeof url === "string" && url.length > 0) out.push({ url, type });
873
+ };
874
+ if (Array.isArray(data.images)) {
875
+ for (const img of data.images) pushUrl(img?.url, "image");
876
+ }
877
+ pushUrl(data.image?.url, "image");
878
+ if (Array.isArray(data.videos)) {
879
+ for (const v of data.videos) pushUrl(v?.url, "video");
878
880
  }
879
- return body.error || body.message || "unknown";
881
+ pushUrl(data.video?.url, "video");
882
+ return out;
880
883
  }
881
884
  function createFalMediaAdapter(config) {
882
- const { apiKey, baseUrl = DEFAULT_BASE3, fetchImpl = fetch } = config;
885
+ const {
886
+ apiKey,
887
+ baseUrl = DEFAULT_BASE3,
888
+ pollIntervalMs = 3e3,
889
+ pollTimeoutMs = 3e5,
890
+ fetchImpl = fetch
891
+ } = config;
892
+ const headers = {
893
+ "content-type": "application/json",
894
+ authorization: `Key ${apiKey}`
895
+ };
883
896
  return {
884
897
  provider: "fal",
885
898
  async run(req) {
886
- const res = await fetchImpl(`${baseUrl}/${req.externalId}`, {
899
+ const submitRes = await fetchImpl(`${baseUrl}/${req.externalId}`, {
887
900
  method: "POST",
888
- headers: {
889
- "content-type": "application/json",
890
- authorization: `Key ${apiKey}`,
891
- accept: "application/json"
892
- },
901
+ headers,
893
902
  body: JSON.stringify(req.input)
894
903
  });
895
- let body;
896
- try {
897
- body = await res.json();
898
- } catch {
899
- body = {};
904
+ if (!submitRes.ok) {
905
+ throw new FalMediaError(submitRes.status, await safeText2(submitRes));
906
+ }
907
+ const submit = await submitRes.json();
908
+ const statusUrl = submit.status_url;
909
+ const responseUrl = submit.response_url;
910
+ if (!statusUrl || !responseUrl) {
911
+ throw new Error(
912
+ `ai-lcr: fal submit for "${req.externalId}" returned no status/response URL (keys: ${Object.keys(
913
+ submit
914
+ ).join(", ")})`
915
+ );
916
+ }
917
+ const deadline = Date.now() + pollTimeoutMs;
918
+ let completed = false;
919
+ while (Date.now() < deadline) {
920
+ const statusRes = await fetchImpl(statusUrl, { headers });
921
+ if (!statusRes.ok) {
922
+ throw new FalMediaError(statusRes.status, await safeText2(statusRes));
923
+ }
924
+ const status = String((await statusRes.json()).status ?? "");
925
+ if (status === "COMPLETED") {
926
+ completed = true;
927
+ break;
928
+ }
929
+ await sleep2(pollIntervalMs);
930
+ }
931
+ if (!completed) {
932
+ throw new Error(
933
+ `ai-lcr: fal job for "${req.externalId}" timed out after ${pollTimeoutMs}ms`
934
+ );
900
935
  }
901
- if (!res.ok) {
902
- throw new FalMediaError(res.status, errorMessage2(body));
936
+ const resultRes = await fetchImpl(responseUrl, { headers });
937
+ if (!resultRes.ok) {
938
+ throw new FalMediaError(resultRes.status, await safeText2(resultRes));
903
939
  }
904
- const urls = extractImageUrls2(body);
905
- if (urls.length === 0) {
906
- throw new Error(`ai-lcr: fal returned no image URL for "${req.externalId}"`);
940
+ const outputs = extractOutputs(await resultRes.json());
941
+ if (outputs.length === 0) {
942
+ throw new Error(`ai-lcr: fal returned no media URL for "${req.externalId}"`);
907
943
  }
908
- const outputs = urls.map((url) => ({ url, type: "image" }));
909
944
  return { outputs, units: outputs.length };
910
945
  }
911
946
  };
@@ -918,6 +953,16 @@ var FalMediaError = class extends Error {
918
953
  }
919
954
  status;
920
955
  };
956
+ function sleep2(ms) {
957
+ return new Promise((r) => setTimeout(r, ms));
958
+ }
959
+ async function safeText2(res) {
960
+ try {
961
+ return await res.text();
962
+ } catch {
963
+ return "<no body>";
964
+ }
965
+ }
921
966
 
922
967
  // src/index.ts
923
968
  function isLanguageModel(entry) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lcr",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
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",