ai-lcr 0.2.2 → 0.2.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 +108 -0
- package/README.md +2 -3
- package/README.zh-CN.md +2 -3
- package/dist/index.cjs +256 -132
- package/dist/index.d.cts +48 -38
- package/dist/index.d.ts +48 -38
- package/dist/index.js +255 -131
- package/package.json +5 -3
package/dist/index.js
CHANGED
|
@@ -18,35 +18,128 @@ var RETRYABLE_PATTERNS = [
|
|
|
18
18
|
"504",
|
|
19
19
|
"429",
|
|
20
20
|
// Billing caps — a capped provider should fall over, not kill the request.
|
|
21
|
+
// Include non-English wording: Chinese providers (e.g. Kunavo) report a failed
|
|
22
|
+
// charge as "余额不足"/"账户欠费"/"扣费失败" with a 200/400 body, which no
|
|
23
|
+
// English keyword and no HTTP status would catch — so without these a billing
|
|
24
|
+
// failure would die instead of failing over, the exact opposite of what we want.
|
|
21
25
|
"insufficient",
|
|
22
26
|
"credit",
|
|
23
27
|
"quota",
|
|
24
28
|
"billing",
|
|
25
|
-
"payment required"
|
|
29
|
+
"payment required",
|
|
30
|
+
"balance",
|
|
31
|
+
"\u4F59\u989D",
|
|
32
|
+
"\u6B20\u8D39",
|
|
33
|
+
"\u6263\u8D39",
|
|
34
|
+
"\u6263\u6B3E"
|
|
26
35
|
];
|
|
36
|
+
var NETWORK_CODES = /* @__PURE__ */ new Set([
|
|
37
|
+
"ECONNREFUSED",
|
|
38
|
+
"ECONNRESET",
|
|
39
|
+
"ECONNABORTED",
|
|
40
|
+
"ENOTFOUND",
|
|
41
|
+
"EAI_AGAIN",
|
|
42
|
+
"ETIMEDOUT",
|
|
43
|
+
"EPIPE",
|
|
44
|
+
"EHOSTUNREACH",
|
|
45
|
+
"ENETUNREACH",
|
|
46
|
+
"EPROTO",
|
|
47
|
+
"UND_ERR_SOCKET",
|
|
48
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
49
|
+
"UND_ERR_HEADERS_TIMEOUT",
|
|
50
|
+
"UND_ERR_BODY_TIMEOUT"
|
|
51
|
+
]);
|
|
52
|
+
var NETWORK_PATTERNS = [
|
|
53
|
+
"fetch failed",
|
|
54
|
+
"failed to fetch",
|
|
55
|
+
"socket hang up",
|
|
56
|
+
"socket disconnected",
|
|
57
|
+
"econnrefused",
|
|
58
|
+
"econnreset",
|
|
59
|
+
"enotfound",
|
|
60
|
+
"etimedout",
|
|
61
|
+
"ehostunreach",
|
|
62
|
+
"enetunreach",
|
|
63
|
+
"eai_again",
|
|
64
|
+
"getaddrinfo",
|
|
65
|
+
"connect timeout",
|
|
66
|
+
"connection refused",
|
|
67
|
+
"connection reset",
|
|
68
|
+
"connection error",
|
|
69
|
+
"network error",
|
|
70
|
+
"dns"
|
|
71
|
+
];
|
|
72
|
+
function safeStringify(value) {
|
|
73
|
+
try {
|
|
74
|
+
return JSON.stringify(value) ?? "";
|
|
75
|
+
} catch {
|
|
76
|
+
return String(value);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function errorSignals(error) {
|
|
80
|
+
const parts = [];
|
|
81
|
+
const codes = [];
|
|
82
|
+
const seen = /* @__PURE__ */ new Set();
|
|
83
|
+
let cur = error;
|
|
84
|
+
for (let depth = 0; depth < 6 && cur && typeof cur === "object" && !seen.has(cur); depth++) {
|
|
85
|
+
seen.add(cur);
|
|
86
|
+
const e = cur;
|
|
87
|
+
if (typeof e.message === "string") parts.push(e.message);
|
|
88
|
+
if (typeof e.name === "string") parts.push(e.name);
|
|
89
|
+
if (typeof e.code === "string") {
|
|
90
|
+
parts.push(e.code);
|
|
91
|
+
codes.push(e.code);
|
|
92
|
+
}
|
|
93
|
+
cur = e.cause;
|
|
94
|
+
}
|
|
95
|
+
if (parts.length === 0) parts.push(safeStringify(error));
|
|
96
|
+
return { text: parts.join(" ").toLowerCase(), codes };
|
|
97
|
+
}
|
|
98
|
+
function isNetworkError(error) {
|
|
99
|
+
const { text, codes } = errorSignals(error);
|
|
100
|
+
if (codes.some((c) => NETWORK_CODES.has(c))) return true;
|
|
101
|
+
return NETWORK_PATTERNS.some((p) => text.includes(p));
|
|
102
|
+
}
|
|
27
103
|
function isRetryableError(error) {
|
|
28
104
|
const e = error;
|
|
29
105
|
const status = e?.statusCode ?? e?.status;
|
|
30
106
|
if (typeof status === "number" && (RETRYABLE_STATUS.has(status) || status > 500)) {
|
|
31
107
|
return true;
|
|
32
108
|
}
|
|
33
|
-
|
|
109
|
+
if (isNetworkError(error)) return true;
|
|
110
|
+
const { text } = errorSignals(error);
|
|
34
111
|
return RETRYABLE_PATTERNS.some((p) => text.includes(p));
|
|
35
112
|
}
|
|
36
|
-
function safeStringify(value) {
|
|
37
|
-
try {
|
|
38
|
-
return JSON.stringify(value) ?? "";
|
|
39
|
-
} catch {
|
|
40
|
-
return String(value);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
113
|
function classifyError(error) {
|
|
44
114
|
const e = error;
|
|
45
115
|
const status = e?.statusCode ?? e?.status;
|
|
46
116
|
if (typeof status === "number") return String(status);
|
|
47
|
-
|
|
117
|
+
if (isNetworkError(error)) return "network";
|
|
118
|
+
const { text } = errorSignals(error);
|
|
48
119
|
return RETRYABLE_PATTERNS.find((p) => text.includes(p)) ?? "error";
|
|
49
120
|
}
|
|
121
|
+
var AUTH_STATUS = /* @__PURE__ */ new Set([401, 403]);
|
|
122
|
+
var BILLING_PATTERNS = [
|
|
123
|
+
"insufficient",
|
|
124
|
+
"credit",
|
|
125
|
+
"quota",
|
|
126
|
+
"billing",
|
|
127
|
+
"payment required",
|
|
128
|
+
"balance",
|
|
129
|
+
"exhausted",
|
|
130
|
+
"\u4F59\u989D",
|
|
131
|
+
"\u6B20\u8D39",
|
|
132
|
+
"\u6263\u8D39",
|
|
133
|
+
"\u6263\u6B3E"
|
|
134
|
+
];
|
|
135
|
+
function classifyErrorKind(error) {
|
|
136
|
+
const e = error;
|
|
137
|
+
const status = e?.statusCode ?? e?.status;
|
|
138
|
+
const { text } = errorSignals(error);
|
|
139
|
+
if (status === 402 || BILLING_PATTERNS.some((p) => text.includes(p))) return "billing";
|
|
140
|
+
if (typeof status === "number" && AUTH_STATUS.has(status)) return "auth";
|
|
141
|
+
return isRetryableError(error) ? "transient" : "client";
|
|
142
|
+
}
|
|
50
143
|
var callSeq = 0;
|
|
51
144
|
function newCallId() {
|
|
52
145
|
const c = globalThis.crypto;
|
|
@@ -63,11 +156,20 @@ var LcrFallbackModel = class {
|
|
|
63
156
|
}
|
|
64
157
|
opts;
|
|
65
158
|
specificationVersion = "v3";
|
|
66
|
-
|
|
67
|
-
|
|
159
|
+
// Cross-request *hint* for where the next request starts: after a failover we
|
|
160
|
+
// remember the provider that worked so we don't re-probe a dead cheap one on
|
|
161
|
+
// every call. This is the ONLY shared mutable state — and crucially it is read
|
|
162
|
+
// once per request (snapshotted into a local cursor) and written once on
|
|
163
|
+
// settle, never used as a per-request loop bound. The within-request iteration
|
|
164
|
+
// is fully local, so concurrent requests can't corrupt each other's routing.
|
|
165
|
+
sticky = 0;
|
|
166
|
+
// When `sticky` was last advanced (a failover). The re-probe timer measures
|
|
167
|
+
// from THIS, not from the last call — so it fires under sustained traffic too,
|
|
168
|
+
// instead of being pushed forward forever by a busy stream of requests.
|
|
169
|
+
lastFailoverAt = Date.now();
|
|
68
170
|
resetIntervalMs;
|
|
69
171
|
get current() {
|
|
70
|
-
return this.opts.providers[this.
|
|
172
|
+
return this.opts.providers[this.sticky];
|
|
71
173
|
}
|
|
72
174
|
get modelId() {
|
|
73
175
|
return this.current.model.modelId;
|
|
@@ -78,18 +180,53 @@ var LcrFallbackModel = class {
|
|
|
78
180
|
get supportedUrls() {
|
|
79
181
|
return this.current.model.supportedUrls;
|
|
80
182
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
183
|
+
/**
|
|
184
|
+
* Index a new request should start at. If we're parked on a non-cheapest
|
|
185
|
+
* provider and it's been `resetIntervalMs` since the failover, snap back to
|
|
186
|
+
* the cheapest and re-probe it — this is what lets routing recover to the
|
|
187
|
+
* cheap source even during continuous traffic.
|
|
188
|
+
*/
|
|
189
|
+
startIndex() {
|
|
190
|
+
if (this.sticky !== 0 && Date.now() - this.lastFailoverAt >= this.resetIntervalMs) {
|
|
191
|
+
this.sticky = 0;
|
|
84
192
|
}
|
|
85
|
-
this.
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
193
|
+
return this.sticky;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* A request settled on `winIndex`. Park there so the next request skips the
|
|
197
|
+
* providers we just learned are down. Stamp the failover time only when the
|
|
198
|
+
* parked provider actually CHANGES — so a steady stream of successful calls
|
|
199
|
+
* on the same fallback doesn't keep pushing the re-probe timer forward.
|
|
200
|
+
*/
|
|
201
|
+
settleSticky(winIndex) {
|
|
202
|
+
if (winIndex === this.sticky) return;
|
|
203
|
+
this.sticky = winIndex;
|
|
204
|
+
this.lastFailoverAt = Date.now();
|
|
89
205
|
}
|
|
90
206
|
shouldRetry(error) {
|
|
91
207
|
return (this.opts.shouldRetry ?? isRetryableError)(error);
|
|
92
208
|
}
|
|
209
|
+
// Observer callbacks are caller-supplied logging hooks: a throw from one of
|
|
210
|
+
// them must NEVER turn a successful (or already-failed) request into a
|
|
211
|
+
// different outcome. Swallow anything they throw — they are fire-and-forget.
|
|
212
|
+
emitError(error, provider) {
|
|
213
|
+
try {
|
|
214
|
+
this.opts.onError?.(error, provider);
|
|
215
|
+
} catch {
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
emitCost(event) {
|
|
219
|
+
try {
|
|
220
|
+
this.opts.onCost?.(event);
|
|
221
|
+
} catch {
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
emitCall(record) {
|
|
225
|
+
try {
|
|
226
|
+
this.opts.onCall?.(record);
|
|
227
|
+
} catch {
|
|
228
|
+
}
|
|
229
|
+
}
|
|
93
230
|
startCall() {
|
|
94
231
|
return { id: newCallId(), attempts: [], startedAt: Date.now() };
|
|
95
232
|
}
|
|
@@ -99,7 +236,8 @@ var LcrFallbackModel = class {
|
|
|
99
236
|
provider: provider.label,
|
|
100
237
|
ok: false,
|
|
101
238
|
latencyMs: Date.now() - attemptStart,
|
|
102
|
-
errorClass: classifyError(error)
|
|
239
|
+
errorClass: classifyError(error),
|
|
240
|
+
kind: classifyErrorKind(error)
|
|
103
241
|
});
|
|
104
242
|
}
|
|
105
243
|
/** Winner settled: record the attempt, fire `onCost` (compat) + `onCall`. */
|
|
@@ -108,14 +246,14 @@ var LcrFallbackModel = class {
|
|
|
108
246
|
const inputTokens = usage?.inputTokens?.total ?? 0;
|
|
109
247
|
const outputTokens = usage?.outputTokens?.total ?? 0;
|
|
110
248
|
const costUsd = provider.cost ? inputTokens / 1e6 * provider.cost.input + outputTokens / 1e6 * provider.cost.output : 0;
|
|
111
|
-
this.
|
|
249
|
+
this.emitCost({
|
|
112
250
|
model: this.opts.modelName,
|
|
113
251
|
provider: provider.label,
|
|
114
252
|
inputTokens,
|
|
115
253
|
outputTokens,
|
|
116
254
|
costUsd
|
|
117
255
|
});
|
|
118
|
-
this.
|
|
256
|
+
this.emitCall({
|
|
119
257
|
id: ctx.id,
|
|
120
258
|
model: this.opts.modelName,
|
|
121
259
|
attempts: ctx.attempts,
|
|
@@ -130,7 +268,7 @@ var LcrFallbackModel = class {
|
|
|
130
268
|
}
|
|
131
269
|
/** Every provider failed: fire `onCall` with no winner. */
|
|
132
270
|
finalizeFail(ctx) {
|
|
133
|
-
this.
|
|
271
|
+
this.emitCall({
|
|
134
272
|
id: ctx.id,
|
|
135
273
|
model: this.opts.modelName,
|
|
136
274
|
attempts: ctx.attempts,
|
|
@@ -144,15 +282,18 @@ var LcrFallbackModel = class {
|
|
|
144
282
|
});
|
|
145
283
|
}
|
|
146
284
|
async doGenerate(options) {
|
|
147
|
-
this.checkReset();
|
|
148
285
|
const ctx = this.startCall();
|
|
149
|
-
const
|
|
286
|
+
const providers = this.opts.providers;
|
|
287
|
+
const n = providers.length;
|
|
288
|
+
const start = this.startIndex();
|
|
150
289
|
let lastError;
|
|
151
|
-
for (; ; ) {
|
|
152
|
-
const
|
|
290
|
+
for (let tried = 0; tried < n; tried++) {
|
|
291
|
+
const idx = (start + tried) % n;
|
|
292
|
+
const provider = providers[idx];
|
|
153
293
|
const attemptStart = Date.now();
|
|
154
294
|
try {
|
|
155
295
|
const result = await provider.model.doGenerate(options);
|
|
296
|
+
this.settleSticky(idx);
|
|
156
297
|
this.finalizeOk(ctx, provider, attemptStart, result.usage);
|
|
157
298
|
return result;
|
|
158
299
|
} catch (error) {
|
|
@@ -162,31 +303,32 @@ var LcrFallbackModel = class {
|
|
|
162
303
|
this.finalizeFail(ctx);
|
|
163
304
|
throw error;
|
|
164
305
|
}
|
|
165
|
-
this.
|
|
306
|
+
this.emitError(error, provider.label);
|
|
166
307
|
this.recordFail(ctx, provider, attemptStart, error);
|
|
167
|
-
this.switchNext();
|
|
168
|
-
if (this.index === start) {
|
|
169
|
-
this.finalizeFail(ctx);
|
|
170
|
-
throw lastError;
|
|
171
|
-
}
|
|
172
308
|
}
|
|
173
309
|
}
|
|
310
|
+
this.finalizeFail(ctx);
|
|
311
|
+
throw lastError;
|
|
174
312
|
}
|
|
175
313
|
async doStream(options) {
|
|
176
|
-
this.
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
314
|
+
return this.doStreamWithCtx(options, this.startCall(), this.startIndex(), 0);
|
|
315
|
+
}
|
|
316
|
+
// The stream's failover recursion re-enters here with the SAME `ctx` and a
|
|
317
|
+
// threaded-through local cursor (`idx`/`tried`), so a mid-stream switch keeps
|
|
318
|
+
// appending to one CallRecord and bounds itself on the local `tried` count —
|
|
319
|
+
// never on shared instance state. `finalizeOk`/`finalizeFail` fire exactly
|
|
320
|
+
// once per outer request.
|
|
321
|
+
async doStreamWithCtx(options, ctx, startIdx, alreadyTried) {
|
|
183
322
|
const self = this;
|
|
184
|
-
const
|
|
323
|
+
const providers = this.opts.providers;
|
|
324
|
+
const n = providers.length;
|
|
185
325
|
let result;
|
|
186
326
|
let serving;
|
|
187
327
|
let servingStart;
|
|
328
|
+
let idx = startIdx;
|
|
329
|
+
let tried = alreadyTried;
|
|
188
330
|
for (; ; ) {
|
|
189
|
-
serving =
|
|
331
|
+
serving = providers[idx];
|
|
190
332
|
servingStart = Date.now();
|
|
191
333
|
try {
|
|
192
334
|
result = await serving.model.doStream(options);
|
|
@@ -197,17 +339,20 @@ var LcrFallbackModel = class {
|
|
|
197
339
|
this.finalizeFail(ctx);
|
|
198
340
|
throw error;
|
|
199
341
|
}
|
|
200
|
-
this.
|
|
342
|
+
this.emitError(error, serving.label);
|
|
201
343
|
this.recordFail(ctx, serving, servingStart, error);
|
|
202
|
-
|
|
203
|
-
if (
|
|
344
|
+
tried++;
|
|
345
|
+
if (tried >= n) {
|
|
204
346
|
this.finalizeFail(ctx);
|
|
205
347
|
throw error;
|
|
206
348
|
}
|
|
349
|
+
idx = (idx + 1) % n;
|
|
207
350
|
}
|
|
208
351
|
}
|
|
209
352
|
const servingProvider = serving;
|
|
210
353
|
const servingAttemptStart = servingStart;
|
|
354
|
+
const servingIdx = idx;
|
|
355
|
+
const triedBeforeServing = tried;
|
|
211
356
|
let usage;
|
|
212
357
|
let streamedAny = false;
|
|
213
358
|
const stream = new ReadableStream({
|
|
@@ -226,20 +371,26 @@ var LcrFallbackModel = class {
|
|
|
226
371
|
controller.enqueue(value);
|
|
227
372
|
if (value.type !== "stream-start") streamedAny = true;
|
|
228
373
|
}
|
|
374
|
+
self.settleSticky(servingIdx);
|
|
229
375
|
self.finalizeOk(ctx, servingProvider, servingAttemptStart, usage);
|
|
230
376
|
controller.close();
|
|
231
377
|
} catch (error) {
|
|
232
|
-
self.
|
|
378
|
+
self.emitError(error, servingProvider.label);
|
|
233
379
|
self.recordFail(ctx, servingProvider, servingAttemptStart, error);
|
|
234
380
|
if (!streamedAny) {
|
|
235
|
-
|
|
236
|
-
if (
|
|
381
|
+
const nextTried = triedBeforeServing + 1;
|
|
382
|
+
if (nextTried >= n) {
|
|
237
383
|
self.finalizeFail(ctx);
|
|
238
384
|
controller.error(error);
|
|
239
385
|
return;
|
|
240
386
|
}
|
|
241
387
|
try {
|
|
242
|
-
const next = await self.doStreamWithCtx(
|
|
388
|
+
const next = await self.doStreamWithCtx(
|
|
389
|
+
options,
|
|
390
|
+
ctx,
|
|
391
|
+
(servingIdx + 1) % n,
|
|
392
|
+
nextTried
|
|
393
|
+
);
|
|
243
394
|
const nextReader = next.stream.getReader();
|
|
244
395
|
try {
|
|
245
396
|
for (; ; ) {
|
|
@@ -348,6 +499,24 @@ function newMediaCallId() {
|
|
|
348
499
|
}
|
|
349
500
|
function createMediaLCR(config) {
|
|
350
501
|
const { registry, adapters, reference = DEFAULT_REFERENCE, onError, onCost, onCall } = config;
|
|
502
|
+
const safeError = (error, provider) => {
|
|
503
|
+
try {
|
|
504
|
+
onError?.(error, provider);
|
|
505
|
+
} catch {
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
const safeCost = (event) => {
|
|
509
|
+
try {
|
|
510
|
+
onCost?.(event);
|
|
511
|
+
} catch {
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
const safeCall = (record) => {
|
|
515
|
+
try {
|
|
516
|
+
onCall?.(record);
|
|
517
|
+
} catch {
|
|
518
|
+
}
|
|
519
|
+
};
|
|
351
520
|
return async function generate(modelId, input) {
|
|
352
521
|
const def = registry[modelId];
|
|
353
522
|
if (!def) {
|
|
@@ -358,7 +527,7 @@ function createMediaLCR(config) {
|
|
|
358
527
|
const startedAt = Date.now();
|
|
359
528
|
const attempts = [];
|
|
360
529
|
let lastErr;
|
|
361
|
-
const emitFail = () =>
|
|
530
|
+
const emitFail = () => safeCall({
|
|
362
531
|
id: newMediaCallId(),
|
|
363
532
|
model: modelId,
|
|
364
533
|
attempts,
|
|
@@ -380,8 +549,8 @@ function createMediaLCR(config) {
|
|
|
380
549
|
const estimated = result.costCents === void 0;
|
|
381
550
|
const costCents = estimated ? route.refCents * (result.units ?? 1) : result.costCents;
|
|
382
551
|
attempts.push({ provider: route.provider, ok: true, latencyMs: Date.now() - attemptStart });
|
|
383
|
-
|
|
384
|
-
|
|
552
|
+
safeCost({ modelId, provider: route.provider, costCents, estimated });
|
|
553
|
+
safeCall({
|
|
385
554
|
id: newMediaCallId(),
|
|
386
555
|
model: modelId,
|
|
387
556
|
attempts,
|
|
@@ -403,7 +572,7 @@ function createMediaLCR(config) {
|
|
|
403
572
|
latencyMs: Date.now() - attemptStart,
|
|
404
573
|
errorClass: classifyError(err)
|
|
405
574
|
});
|
|
406
|
-
|
|
575
|
+
safeError(err, route.provider);
|
|
407
576
|
if (!isRetryableError(err)) {
|
|
408
577
|
emitFail();
|
|
409
578
|
throw err;
|
|
@@ -694,84 +863,49 @@ var RunwareMediaError = class extends Error {
|
|
|
694
863
|
};
|
|
695
864
|
|
|
696
865
|
// src/adapters/fal-media.ts
|
|
697
|
-
var DEFAULT_BASE3 = "https://
|
|
698
|
-
function
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
const
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
if (
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
if (Array.isArray(data.videos)) {
|
|
710
|
-
for (const v of data.videos) pushUrl(v?.url, "video");
|
|
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("; ");
|
|
711
878
|
}
|
|
712
|
-
|
|
713
|
-
return out;
|
|
879
|
+
return body.error || body.message || "unknown";
|
|
714
880
|
}
|
|
715
881
|
function createFalMediaAdapter(config) {
|
|
716
|
-
const {
|
|
717
|
-
apiKey,
|
|
718
|
-
baseUrl = DEFAULT_BASE3,
|
|
719
|
-
pollIntervalMs = 3e3,
|
|
720
|
-
pollTimeoutMs = 3e5,
|
|
721
|
-
fetchImpl = fetch
|
|
722
|
-
} = config;
|
|
723
|
-
const headers = {
|
|
724
|
-
"content-type": "application/json",
|
|
725
|
-
authorization: `Key ${apiKey}`
|
|
726
|
-
};
|
|
882
|
+
const { apiKey, baseUrl = DEFAULT_BASE3, fetchImpl = fetch } = config;
|
|
727
883
|
return {
|
|
728
884
|
provider: "fal",
|
|
729
885
|
async run(req) {
|
|
730
|
-
const
|
|
886
|
+
const res = await fetchImpl(`${baseUrl}/${req.externalId}`, {
|
|
731
887
|
method: "POST",
|
|
732
|
-
headers
|
|
888
|
+
headers: {
|
|
889
|
+
"content-type": "application/json",
|
|
890
|
+
authorization: `Key ${apiKey}`,
|
|
891
|
+
accept: "application/json"
|
|
892
|
+
},
|
|
733
893
|
body: JSON.stringify(req.input)
|
|
734
894
|
});
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
const responseUrl = submit.response_url;
|
|
741
|
-
if (!statusUrl || !responseUrl) {
|
|
742
|
-
throw new Error(
|
|
743
|
-
`ai-lcr: fal submit for "${req.externalId}" returned no status/response URL (keys: ${Object.keys(
|
|
744
|
-
submit
|
|
745
|
-
).join(", ")})`
|
|
746
|
-
);
|
|
747
|
-
}
|
|
748
|
-
const deadline = Date.now() + pollTimeoutMs;
|
|
749
|
-
let completed = false;
|
|
750
|
-
while (Date.now() < deadline) {
|
|
751
|
-
const statusRes = await fetchImpl(statusUrl, { headers });
|
|
752
|
-
if (!statusRes.ok) {
|
|
753
|
-
throw new FalMediaError(statusRes.status, await safeText2(statusRes));
|
|
754
|
-
}
|
|
755
|
-
const status = String((await statusRes.json()).status ?? "");
|
|
756
|
-
if (status === "COMPLETED") {
|
|
757
|
-
completed = true;
|
|
758
|
-
break;
|
|
759
|
-
}
|
|
760
|
-
await sleep2(pollIntervalMs);
|
|
761
|
-
}
|
|
762
|
-
if (!completed) {
|
|
763
|
-
throw new Error(
|
|
764
|
-
`ai-lcr: fal job for "${req.externalId}" timed out after ${pollTimeoutMs}ms`
|
|
765
|
-
);
|
|
895
|
+
let body;
|
|
896
|
+
try {
|
|
897
|
+
body = await res.json();
|
|
898
|
+
} catch {
|
|
899
|
+
body = {};
|
|
766
900
|
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
throw new FalMediaError(resultRes.status, await safeText2(resultRes));
|
|
901
|
+
if (!res.ok) {
|
|
902
|
+
throw new FalMediaError(res.status, errorMessage2(body));
|
|
770
903
|
}
|
|
771
|
-
const
|
|
772
|
-
if (
|
|
773
|
-
throw new Error(`ai-lcr: fal returned no
|
|
904
|
+
const urls = extractImageUrls2(body);
|
|
905
|
+
if (urls.length === 0) {
|
|
906
|
+
throw new Error(`ai-lcr: fal returned no image URL for "${req.externalId}"`);
|
|
774
907
|
}
|
|
908
|
+
const outputs = urls.map((url) => ({ url, type: "image" }));
|
|
775
909
|
return { outputs, units: outputs.length };
|
|
776
910
|
}
|
|
777
911
|
};
|
|
@@ -784,16 +918,6 @@ var FalMediaError = class extends Error {
|
|
|
784
918
|
}
|
|
785
919
|
status;
|
|
786
920
|
};
|
|
787
|
-
function sleep2(ms) {
|
|
788
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
789
|
-
}
|
|
790
|
-
async function safeText2(res) {
|
|
791
|
-
try {
|
|
792
|
-
return await res.text();
|
|
793
|
-
} catch {
|
|
794
|
-
return "<no body>";
|
|
795
|
-
}
|
|
796
|
-
}
|
|
797
921
|
|
|
798
922
|
// src/index.ts
|
|
799
923
|
function isLanguageModel(entry) {
|
|
@@ -837,10 +961,10 @@ function createLCR(config) {
|
|
|
837
961
|
}
|
|
838
962
|
export {
|
|
839
963
|
DEFAULT_REFERENCE,
|
|
840
|
-
FalMediaError,
|
|
841
964
|
MEDIA_PRICING,
|
|
842
965
|
cheapestRoute,
|
|
843
966
|
classifyError,
|
|
967
|
+
classifyErrorKind,
|
|
844
968
|
comparePrices,
|
|
845
969
|
createFalMediaAdapter,
|
|
846
970
|
createKunavoMediaAdapter,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-lcr",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.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",
|
|
@@ -39,13 +39,15 @@
|
|
|
39
39
|
"files": [
|
|
40
40
|
"dist",
|
|
41
41
|
"README.md",
|
|
42
|
-
"LICENSE"
|
|
42
|
+
"LICENSE",
|
|
43
|
+
"CHANGELOG.md"
|
|
43
44
|
],
|
|
44
45
|
"scripts": {
|
|
45
46
|
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
|
46
47
|
"typecheck": "tsc --noEmit",
|
|
47
48
|
"test": "vitest run",
|
|
48
|
-
"test:watch": "vitest"
|
|
49
|
+
"test:watch": "vitest",
|
|
50
|
+
"prepublishOnly": "npm run build && npm run typecheck && npm test"
|
|
49
51
|
},
|
|
50
52
|
"peerDependencies": {
|
|
51
53
|
"ai": "^6.0.0"
|