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 +17 -0
- package/README.md +3 -2
- package/README.zh-CN.md +3 -2
- package/dist/index.cjs +76 -31
- package/dist/index.d.cts +26 -19
- package/dist/index.d.ts +26 -19
- package/dist/index.js +76 -31
- package/package.json +1 -1
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) —
|
|
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
|
-
- [
|
|
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
|
-
- [
|
|
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
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
const
|
|
911
|
-
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
if (
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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
|
-
|
|
921
|
+
pushUrl(data.video?.url, "video");
|
|
922
|
+
return out;
|
|
920
923
|
}
|
|
921
924
|
function createFalMediaAdapter(config) {
|
|
922
|
-
const {
|
|
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
|
|
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
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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
|
-
|
|
942
|
-
|
|
976
|
+
const resultRes = await fetchImpl(responseUrl, { headers });
|
|
977
|
+
if (!resultRes.ok) {
|
|
978
|
+
throw new FalMediaError(resultRes.status, await safeText2(resultRes));
|
|
943
979
|
}
|
|
944
|
-
const
|
|
945
|
-
if (
|
|
946
|
-
throw new Error(`ai-lcr: fal returned no
|
|
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
|
|
362
|
+
* fal media adapter — image (queue) + video (queue, async poll).
|
|
363
363
|
*
|
|
364
|
-
* fal
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
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
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
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
|
-
*
|
|
384
|
-
*
|
|
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
|
|
362
|
+
* fal media adapter — image (queue) + video (queue, async poll).
|
|
363
363
|
*
|
|
364
|
-
* fal
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
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
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
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
|
-
*
|
|
384
|
-
*
|
|
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
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
const
|
|
871
|
-
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
if (
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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
|
-
|
|
881
|
+
pushUrl(data.video?.url, "video");
|
|
882
|
+
return out;
|
|
880
883
|
}
|
|
881
884
|
function createFalMediaAdapter(config) {
|
|
882
|
-
const {
|
|
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
|
|
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
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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
|
-
|
|
902
|
-
|
|
936
|
+
const resultRes = await fetchImpl(responseUrl, { headers });
|
|
937
|
+
if (!resultRes.ok) {
|
|
938
|
+
throw new FalMediaError(resultRes.status, await safeText2(resultRes));
|
|
903
939
|
}
|
|
904
|
-
const
|
|
905
|
-
if (
|
|
906
|
-
throw new Error(`ai-lcr: fal returned no
|
|
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.
|
|
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",
|