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/dist/index.cjs CHANGED
@@ -21,10 +21,10 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  DEFAULT_REFERENCE: () => DEFAULT_REFERENCE,
24
- FalMediaError: () => FalMediaError,
25
24
  MEDIA_PRICING: () => MEDIA_PRICING,
26
25
  cheapestRoute: () => cheapestRoute,
27
26
  classifyError: () => classifyError,
27
+ classifyErrorKind: () => classifyErrorKind,
28
28
  comparePrices: () => comparePrices,
29
29
  createFalMediaAdapter: () => createFalMediaAdapter,
30
30
  createKunavoMediaAdapter: () => createKunavoMediaAdapter,
@@ -58,35 +58,128 @@ var RETRYABLE_PATTERNS = [
58
58
  "504",
59
59
  "429",
60
60
  // Billing caps — a capped provider should fall over, not kill the request.
61
+ // Include non-English wording: Chinese providers (e.g. Kunavo) report a failed
62
+ // charge as "余额不足"/"账户欠费"/"扣费失败" with a 200/400 body, which no
63
+ // English keyword and no HTTP status would catch — so without these a billing
64
+ // failure would die instead of failing over, the exact opposite of what we want.
61
65
  "insufficient",
62
66
  "credit",
63
67
  "quota",
64
68
  "billing",
65
- "payment required"
69
+ "payment required",
70
+ "balance",
71
+ "\u4F59\u989D",
72
+ "\u6B20\u8D39",
73
+ "\u6263\u8D39",
74
+ "\u6263\u6B3E"
66
75
  ];
76
+ var NETWORK_CODES = /* @__PURE__ */ new Set([
77
+ "ECONNREFUSED",
78
+ "ECONNRESET",
79
+ "ECONNABORTED",
80
+ "ENOTFOUND",
81
+ "EAI_AGAIN",
82
+ "ETIMEDOUT",
83
+ "EPIPE",
84
+ "EHOSTUNREACH",
85
+ "ENETUNREACH",
86
+ "EPROTO",
87
+ "UND_ERR_SOCKET",
88
+ "UND_ERR_CONNECT_TIMEOUT",
89
+ "UND_ERR_HEADERS_TIMEOUT",
90
+ "UND_ERR_BODY_TIMEOUT"
91
+ ]);
92
+ var NETWORK_PATTERNS = [
93
+ "fetch failed",
94
+ "failed to fetch",
95
+ "socket hang up",
96
+ "socket disconnected",
97
+ "econnrefused",
98
+ "econnreset",
99
+ "enotfound",
100
+ "etimedout",
101
+ "ehostunreach",
102
+ "enetunreach",
103
+ "eai_again",
104
+ "getaddrinfo",
105
+ "connect timeout",
106
+ "connection refused",
107
+ "connection reset",
108
+ "connection error",
109
+ "network error",
110
+ "dns"
111
+ ];
112
+ function safeStringify(value) {
113
+ try {
114
+ return JSON.stringify(value) ?? "";
115
+ } catch {
116
+ return String(value);
117
+ }
118
+ }
119
+ function errorSignals(error) {
120
+ const parts = [];
121
+ const codes = [];
122
+ const seen = /* @__PURE__ */ new Set();
123
+ let cur = error;
124
+ for (let depth = 0; depth < 6 && cur && typeof cur === "object" && !seen.has(cur); depth++) {
125
+ seen.add(cur);
126
+ const e = cur;
127
+ if (typeof e.message === "string") parts.push(e.message);
128
+ if (typeof e.name === "string") parts.push(e.name);
129
+ if (typeof e.code === "string") {
130
+ parts.push(e.code);
131
+ codes.push(e.code);
132
+ }
133
+ cur = e.cause;
134
+ }
135
+ if (parts.length === 0) parts.push(safeStringify(error));
136
+ return { text: parts.join(" ").toLowerCase(), codes };
137
+ }
138
+ function isNetworkError(error) {
139
+ const { text, codes } = errorSignals(error);
140
+ if (codes.some((c) => NETWORK_CODES.has(c))) return true;
141
+ return NETWORK_PATTERNS.some((p) => text.includes(p));
142
+ }
67
143
  function isRetryableError(error) {
68
144
  const e = error;
69
145
  const status = e?.statusCode ?? e?.status;
70
146
  if (typeof status === "number" && (RETRYABLE_STATUS.has(status) || status > 500)) {
71
147
  return true;
72
148
  }
73
- const text = (e?.message ? String(e.message) : safeStringify(error)).toLowerCase();
149
+ if (isNetworkError(error)) return true;
150
+ const { text } = errorSignals(error);
74
151
  return RETRYABLE_PATTERNS.some((p) => text.includes(p));
75
152
  }
76
- function safeStringify(value) {
77
- try {
78
- return JSON.stringify(value) ?? "";
79
- } catch {
80
- return String(value);
81
- }
82
- }
83
153
  function classifyError(error) {
84
154
  const e = error;
85
155
  const status = e?.statusCode ?? e?.status;
86
156
  if (typeof status === "number") return String(status);
87
- const text = (e?.message ? String(e.message) : safeStringify(error)).toLowerCase();
157
+ if (isNetworkError(error)) return "network";
158
+ const { text } = errorSignals(error);
88
159
  return RETRYABLE_PATTERNS.find((p) => text.includes(p)) ?? "error";
89
160
  }
161
+ var AUTH_STATUS = /* @__PURE__ */ new Set([401, 403]);
162
+ var BILLING_PATTERNS = [
163
+ "insufficient",
164
+ "credit",
165
+ "quota",
166
+ "billing",
167
+ "payment required",
168
+ "balance",
169
+ "exhausted",
170
+ "\u4F59\u989D",
171
+ "\u6B20\u8D39",
172
+ "\u6263\u8D39",
173
+ "\u6263\u6B3E"
174
+ ];
175
+ function classifyErrorKind(error) {
176
+ const e = error;
177
+ const status = e?.statusCode ?? e?.status;
178
+ const { text } = errorSignals(error);
179
+ if (status === 402 || BILLING_PATTERNS.some((p) => text.includes(p))) return "billing";
180
+ if (typeof status === "number" && AUTH_STATUS.has(status)) return "auth";
181
+ return isRetryableError(error) ? "transient" : "client";
182
+ }
90
183
  var callSeq = 0;
91
184
  function newCallId() {
92
185
  const c = globalThis.crypto;
@@ -103,11 +196,20 @@ var LcrFallbackModel = class {
103
196
  }
104
197
  opts;
105
198
  specificationVersion = "v3";
106
- index = 0;
107
- lastReset = Date.now();
199
+ // Cross-request *hint* for where the next request starts: after a failover we
200
+ // remember the provider that worked so we don't re-probe a dead cheap one on
201
+ // every call. This is the ONLY shared mutable state — and crucially it is read
202
+ // once per request (snapshotted into a local cursor) and written once on
203
+ // settle, never used as a per-request loop bound. The within-request iteration
204
+ // is fully local, so concurrent requests can't corrupt each other's routing.
205
+ sticky = 0;
206
+ // When `sticky` was last advanced (a failover). The re-probe timer measures
207
+ // from THIS, not from the last call — so it fires under sustained traffic too,
208
+ // instead of being pushed forward forever by a busy stream of requests.
209
+ lastFailoverAt = Date.now();
108
210
  resetIntervalMs;
109
211
  get current() {
110
- return this.opts.providers[this.index];
212
+ return this.opts.providers[this.sticky];
111
213
  }
112
214
  get modelId() {
113
215
  return this.current.model.modelId;
@@ -118,18 +220,53 @@ var LcrFallbackModel = class {
118
220
  get supportedUrls() {
119
221
  return this.current.model.supportedUrls;
120
222
  }
121
- checkReset() {
122
- if (this.index !== 0 && Date.now() - this.lastReset >= this.resetIntervalMs) {
123
- this.index = 0;
223
+ /**
224
+ * Index a new request should start at. If we're parked on a non-cheapest
225
+ * provider and it's been `resetIntervalMs` since the failover, snap back to
226
+ * the cheapest and re-probe it — this is what lets routing recover to the
227
+ * cheap source even during continuous traffic.
228
+ */
229
+ startIndex() {
230
+ if (this.sticky !== 0 && Date.now() - this.lastFailoverAt >= this.resetIntervalMs) {
231
+ this.sticky = 0;
124
232
  }
125
- this.lastReset = Date.now();
126
- }
127
- switchNext() {
128
- this.index = (this.index + 1) % this.opts.providers.length;
233
+ return this.sticky;
234
+ }
235
+ /**
236
+ * A request settled on `winIndex`. Park there so the next request skips the
237
+ * providers we just learned are down. Stamp the failover time only when the
238
+ * parked provider actually CHANGES — so a steady stream of successful calls
239
+ * on the same fallback doesn't keep pushing the re-probe timer forward.
240
+ */
241
+ settleSticky(winIndex) {
242
+ if (winIndex === this.sticky) return;
243
+ this.sticky = winIndex;
244
+ this.lastFailoverAt = Date.now();
129
245
  }
130
246
  shouldRetry(error) {
131
247
  return (this.opts.shouldRetry ?? isRetryableError)(error);
132
248
  }
249
+ // Observer callbacks are caller-supplied logging hooks: a throw from one of
250
+ // them must NEVER turn a successful (or already-failed) request into a
251
+ // different outcome. Swallow anything they throw — they are fire-and-forget.
252
+ emitError(error, provider) {
253
+ try {
254
+ this.opts.onError?.(error, provider);
255
+ } catch {
256
+ }
257
+ }
258
+ emitCost(event) {
259
+ try {
260
+ this.opts.onCost?.(event);
261
+ } catch {
262
+ }
263
+ }
264
+ emitCall(record) {
265
+ try {
266
+ this.opts.onCall?.(record);
267
+ } catch {
268
+ }
269
+ }
133
270
  startCall() {
134
271
  return { id: newCallId(), attempts: [], startedAt: Date.now() };
135
272
  }
@@ -139,7 +276,8 @@ var LcrFallbackModel = class {
139
276
  provider: provider.label,
140
277
  ok: false,
141
278
  latencyMs: Date.now() - attemptStart,
142
- errorClass: classifyError(error)
279
+ errorClass: classifyError(error),
280
+ kind: classifyErrorKind(error)
143
281
  });
144
282
  }
145
283
  /** Winner settled: record the attempt, fire `onCost` (compat) + `onCall`. */
@@ -148,14 +286,14 @@ var LcrFallbackModel = class {
148
286
  const inputTokens = usage?.inputTokens?.total ?? 0;
149
287
  const outputTokens = usage?.outputTokens?.total ?? 0;
150
288
  const costUsd = provider.cost ? inputTokens / 1e6 * provider.cost.input + outputTokens / 1e6 * provider.cost.output : 0;
151
- this.opts.onCost?.({
289
+ this.emitCost({
152
290
  model: this.opts.modelName,
153
291
  provider: provider.label,
154
292
  inputTokens,
155
293
  outputTokens,
156
294
  costUsd
157
295
  });
158
- this.opts.onCall?.({
296
+ this.emitCall({
159
297
  id: ctx.id,
160
298
  model: this.opts.modelName,
161
299
  attempts: ctx.attempts,
@@ -170,7 +308,7 @@ var LcrFallbackModel = class {
170
308
  }
171
309
  /** Every provider failed: fire `onCall` with no winner. */
172
310
  finalizeFail(ctx) {
173
- this.opts.onCall?.({
311
+ this.emitCall({
174
312
  id: ctx.id,
175
313
  model: this.opts.modelName,
176
314
  attempts: ctx.attempts,
@@ -184,15 +322,18 @@ var LcrFallbackModel = class {
184
322
  });
185
323
  }
186
324
  async doGenerate(options) {
187
- this.checkReset();
188
325
  const ctx = this.startCall();
189
- const start = this.index;
326
+ const providers = this.opts.providers;
327
+ const n = providers.length;
328
+ const start = this.startIndex();
190
329
  let lastError;
191
- for (; ; ) {
192
- const provider = this.current;
330
+ for (let tried = 0; tried < n; tried++) {
331
+ const idx = (start + tried) % n;
332
+ const provider = providers[idx];
193
333
  const attemptStart = Date.now();
194
334
  try {
195
335
  const result = await provider.model.doGenerate(options);
336
+ this.settleSticky(idx);
196
337
  this.finalizeOk(ctx, provider, attemptStart, result.usage);
197
338
  return result;
198
339
  } catch (error) {
@@ -202,31 +343,32 @@ var LcrFallbackModel = class {
202
343
  this.finalizeFail(ctx);
203
344
  throw error;
204
345
  }
205
- this.opts.onError?.(error, provider.label);
346
+ this.emitError(error, provider.label);
206
347
  this.recordFail(ctx, provider, attemptStart, error);
207
- this.switchNext();
208
- if (this.index === start) {
209
- this.finalizeFail(ctx);
210
- throw lastError;
211
- }
212
348
  }
213
349
  }
350
+ this.finalizeFail(ctx);
351
+ throw lastError;
214
352
  }
215
353
  async doStream(options) {
216
- this.checkReset();
217
- return this.doStreamWithCtx(options, this.startCall());
218
- }
219
- // The stream's failover recursion re-enters here with the SAME `ctx`, so a
220
- // mid-stream switch keeps appending to one CallRecord instead of starting a
221
- // fresh one. `finalizeOk`/`finalizeFail` fire exactly once per outer request.
222
- async doStreamWithCtx(options, ctx) {
354
+ return this.doStreamWithCtx(options, this.startCall(), this.startIndex(), 0);
355
+ }
356
+ // The stream's failover recursion re-enters here with the SAME `ctx` and a
357
+ // threaded-through local cursor (`idx`/`tried`), so a mid-stream switch keeps
358
+ // appending to one CallRecord and bounds itself on the local `tried` count —
359
+ // never on shared instance state. `finalizeOk`/`finalizeFail` fire exactly
360
+ // once per outer request.
361
+ async doStreamWithCtx(options, ctx, startIdx, alreadyTried) {
223
362
  const self = this;
224
- const start = this.index;
363
+ const providers = this.opts.providers;
364
+ const n = providers.length;
225
365
  let result;
226
366
  let serving;
227
367
  let servingStart;
368
+ let idx = startIdx;
369
+ let tried = alreadyTried;
228
370
  for (; ; ) {
229
- serving = this.current;
371
+ serving = providers[idx];
230
372
  servingStart = Date.now();
231
373
  try {
232
374
  result = await serving.model.doStream(options);
@@ -237,17 +379,20 @@ var LcrFallbackModel = class {
237
379
  this.finalizeFail(ctx);
238
380
  throw error;
239
381
  }
240
- this.opts.onError?.(error, serving.label);
382
+ this.emitError(error, serving.label);
241
383
  this.recordFail(ctx, serving, servingStart, error);
242
- this.switchNext();
243
- if (this.index === start) {
384
+ tried++;
385
+ if (tried >= n) {
244
386
  this.finalizeFail(ctx);
245
387
  throw error;
246
388
  }
389
+ idx = (idx + 1) % n;
247
390
  }
248
391
  }
249
392
  const servingProvider = serving;
250
393
  const servingAttemptStart = servingStart;
394
+ const servingIdx = idx;
395
+ const triedBeforeServing = tried;
251
396
  let usage;
252
397
  let streamedAny = false;
253
398
  const stream = new ReadableStream({
@@ -266,20 +411,26 @@ var LcrFallbackModel = class {
266
411
  controller.enqueue(value);
267
412
  if (value.type !== "stream-start") streamedAny = true;
268
413
  }
414
+ self.settleSticky(servingIdx);
269
415
  self.finalizeOk(ctx, servingProvider, servingAttemptStart, usage);
270
416
  controller.close();
271
417
  } catch (error) {
272
- self.opts.onError?.(error, servingProvider.label);
418
+ self.emitError(error, servingProvider.label);
273
419
  self.recordFail(ctx, servingProvider, servingAttemptStart, error);
274
420
  if (!streamedAny) {
275
- self.switchNext();
276
- if (self.index === start) {
421
+ const nextTried = triedBeforeServing + 1;
422
+ if (nextTried >= n) {
277
423
  self.finalizeFail(ctx);
278
424
  controller.error(error);
279
425
  return;
280
426
  }
281
427
  try {
282
- const next = await self.doStreamWithCtx(options, ctx);
428
+ const next = await self.doStreamWithCtx(
429
+ options,
430
+ ctx,
431
+ (servingIdx + 1) % n,
432
+ nextTried
433
+ );
283
434
  const nextReader = next.stream.getReader();
284
435
  try {
285
436
  for (; ; ) {
@@ -388,6 +539,24 @@ function newMediaCallId() {
388
539
  }
389
540
  function createMediaLCR(config) {
390
541
  const { registry, adapters, reference = DEFAULT_REFERENCE, onError, onCost, onCall } = config;
542
+ const safeError = (error, provider) => {
543
+ try {
544
+ onError?.(error, provider);
545
+ } catch {
546
+ }
547
+ };
548
+ const safeCost = (event) => {
549
+ try {
550
+ onCost?.(event);
551
+ } catch {
552
+ }
553
+ };
554
+ const safeCall = (record) => {
555
+ try {
556
+ onCall?.(record);
557
+ } catch {
558
+ }
559
+ };
391
560
  return async function generate(modelId, input) {
392
561
  const def = registry[modelId];
393
562
  if (!def) {
@@ -398,7 +567,7 @@ function createMediaLCR(config) {
398
567
  const startedAt = Date.now();
399
568
  const attempts = [];
400
569
  let lastErr;
401
- const emitFail = () => onCall?.({
570
+ const emitFail = () => safeCall({
402
571
  id: newMediaCallId(),
403
572
  model: modelId,
404
573
  attempts,
@@ -420,8 +589,8 @@ function createMediaLCR(config) {
420
589
  const estimated = result.costCents === void 0;
421
590
  const costCents = estimated ? route.refCents * (result.units ?? 1) : result.costCents;
422
591
  attempts.push({ provider: route.provider, ok: true, latencyMs: Date.now() - attemptStart });
423
- onCost?.({ modelId, provider: route.provider, costCents, estimated });
424
- onCall?.({
592
+ safeCost({ modelId, provider: route.provider, costCents, estimated });
593
+ safeCall({
425
594
  id: newMediaCallId(),
426
595
  model: modelId,
427
596
  attempts,
@@ -443,7 +612,7 @@ function createMediaLCR(config) {
443
612
  latencyMs: Date.now() - attemptStart,
444
613
  errorClass: classifyError(err)
445
614
  });
446
- onError?.(err, route.provider);
615
+ safeError(err, route.provider);
447
616
  if (!isRetryableError(err)) {
448
617
  emitFail();
449
618
  throw err;
@@ -734,84 +903,49 @@ var RunwareMediaError = class extends Error {
734
903
  };
735
904
 
736
905
  // src/adapters/fal-media.ts
737
- var DEFAULT_BASE3 = "https://queue.fal.run";
738
- function extractOutputs(raw) {
739
- if (!raw || typeof raw !== "object") return [];
740
- const data = raw;
741
- const out = [];
742
- const pushUrl = (url, type) => {
743
- if (typeof url === "string" && url.length > 0) out.push({ url, type });
744
- };
745
- if (Array.isArray(data.images)) {
746
- for (const img of data.images) pushUrl(img?.url, "image");
747
- }
748
- pushUrl(data.image?.url, "image");
749
- if (Array.isArray(data.videos)) {
750
- for (const v of data.videos) pushUrl(v?.url, "video");
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("; ");
751
918
  }
752
- pushUrl(data.video?.url, "video");
753
- return out;
919
+ return body.error || body.message || "unknown";
754
920
  }
755
921
  function createFalMediaAdapter(config) {
756
- const {
757
- apiKey,
758
- baseUrl = DEFAULT_BASE3,
759
- pollIntervalMs = 3e3,
760
- pollTimeoutMs = 3e5,
761
- fetchImpl = fetch
762
- } = config;
763
- const headers = {
764
- "content-type": "application/json",
765
- authorization: `Key ${apiKey}`
766
- };
922
+ const { apiKey, baseUrl = DEFAULT_BASE3, fetchImpl = fetch } = config;
767
923
  return {
768
924
  provider: "fal",
769
925
  async run(req) {
770
- const submitRes = await fetchImpl(`${baseUrl}/${req.externalId}`, {
926
+ const res = await fetchImpl(`${baseUrl}/${req.externalId}`, {
771
927
  method: "POST",
772
- headers,
928
+ headers: {
929
+ "content-type": "application/json",
930
+ authorization: `Key ${apiKey}`,
931
+ accept: "application/json"
932
+ },
773
933
  body: JSON.stringify(req.input)
774
934
  });
775
- if (!submitRes.ok) {
776
- throw new FalMediaError(submitRes.status, await safeText2(submitRes));
777
- }
778
- const submit = await submitRes.json();
779
- const statusUrl = submit.status_url;
780
- const responseUrl = submit.response_url;
781
- if (!statusUrl || !responseUrl) {
782
- throw new Error(
783
- `ai-lcr: fal submit for "${req.externalId}" returned no status/response URL (keys: ${Object.keys(
784
- submit
785
- ).join(", ")})`
786
- );
787
- }
788
- const deadline = Date.now() + pollTimeoutMs;
789
- let completed = false;
790
- while (Date.now() < deadline) {
791
- const statusRes = await fetchImpl(statusUrl, { headers });
792
- if (!statusRes.ok) {
793
- throw new FalMediaError(statusRes.status, await safeText2(statusRes));
794
- }
795
- const status = String((await statusRes.json()).status ?? "");
796
- if (status === "COMPLETED") {
797
- completed = true;
798
- break;
799
- }
800
- await sleep2(pollIntervalMs);
801
- }
802
- if (!completed) {
803
- throw new Error(
804
- `ai-lcr: fal job for "${req.externalId}" timed out after ${pollTimeoutMs}ms`
805
- );
935
+ let body;
936
+ try {
937
+ body = await res.json();
938
+ } catch {
939
+ body = {};
806
940
  }
807
- const resultRes = await fetchImpl(responseUrl, { headers });
808
- if (!resultRes.ok) {
809
- throw new FalMediaError(resultRes.status, await safeText2(resultRes));
941
+ if (!res.ok) {
942
+ throw new FalMediaError(res.status, errorMessage2(body));
810
943
  }
811
- const outputs = extractOutputs(await resultRes.json());
812
- if (outputs.length === 0) {
813
- throw new Error(`ai-lcr: fal returned no media URL for "${req.externalId}"`);
944
+ const urls = extractImageUrls2(body);
945
+ if (urls.length === 0) {
946
+ throw new Error(`ai-lcr: fal returned no image URL for "${req.externalId}"`);
814
947
  }
948
+ const outputs = urls.map((url) => ({ url, type: "image" }));
815
949
  return { outputs, units: outputs.length };
816
950
  }
817
951
  };
@@ -824,16 +958,6 @@ var FalMediaError = class extends Error {
824
958
  }
825
959
  status;
826
960
  };
827
- function sleep2(ms) {
828
- return new Promise((r) => setTimeout(r, ms));
829
- }
830
- async function safeText2(res) {
831
- try {
832
- return await res.text();
833
- } catch {
834
- return "<no body>";
835
- }
836
- }
837
961
 
838
962
  // src/index.ts
839
963
  function isLanguageModel(entry) {
@@ -878,10 +1002,10 @@ function createLCR(config) {
878
1002
  // Annotate the CommonJS export names for ESM import in node:
879
1003
  0 && (module.exports = {
880
1004
  DEFAULT_REFERENCE,
881
- FalMediaError,
882
1005
  MEDIA_PRICING,
883
1006
  cheapestRoute,
884
1007
  classifyError,
1008
+ classifyErrorKind,
885
1009
  comparePrices,
886
1010
  createFalMediaAdapter,
887
1011
  createKunavoMediaAdapter,