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.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
- const text = (e?.message ? String(e.message) : safeStringify(error)).toLowerCase();
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
- const text = (e?.message ? String(e.message) : safeStringify(error)).toLowerCase();
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
- index = 0;
67
- lastReset = Date.now();
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.index];
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
- checkReset() {
82
- if (this.index !== 0 && Date.now() - this.lastReset >= this.resetIntervalMs) {
83
- this.index = 0;
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.lastReset = Date.now();
86
- }
87
- switchNext() {
88
- this.index = (this.index + 1) % this.opts.providers.length;
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.opts.onCost?.({
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.opts.onCall?.({
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.opts.onCall?.({
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 start = this.index;
286
+ const providers = this.opts.providers;
287
+ const n = providers.length;
288
+ const start = this.startIndex();
150
289
  let lastError;
151
- for (; ; ) {
152
- const provider = this.current;
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.opts.onError?.(error, provider.label);
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.checkReset();
177
- return this.doStreamWithCtx(options, this.startCall());
178
- }
179
- // The stream's failover recursion re-enters here with the SAME `ctx`, so a
180
- // mid-stream switch keeps appending to one CallRecord instead of starting a
181
- // fresh one. `finalizeOk`/`finalizeFail` fire exactly once per outer request.
182
- async doStreamWithCtx(options, ctx) {
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 start = this.index;
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 = this.current;
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.opts.onError?.(error, serving.label);
342
+ this.emitError(error, serving.label);
201
343
  this.recordFail(ctx, serving, servingStart, error);
202
- this.switchNext();
203
- if (this.index === start) {
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.opts.onError?.(error, servingProvider.label);
378
+ self.emitError(error, servingProvider.label);
233
379
  self.recordFail(ctx, servingProvider, servingAttemptStart, error);
234
380
  if (!streamedAny) {
235
- self.switchNext();
236
- if (self.index === start) {
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(options, ctx);
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 = () => onCall?.({
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
- onCost?.({ modelId, provider: route.provider, costCents, estimated });
384
- onCall?.({
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
- onError?.(err, route.provider);
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://queue.fal.run";
698
- function extractOutputs(raw) {
699
- if (!raw || typeof raw !== "object") return [];
700
- const data = raw;
701
- const out = [];
702
- const pushUrl = (url, type) => {
703
- if (typeof url === "string" && url.length > 0) out.push({ url, type });
704
- };
705
- if (Array.isArray(data.images)) {
706
- for (const img of data.images) pushUrl(img?.url, "image");
707
- }
708
- pushUrl(data.image?.url, "image");
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
- pushUrl(data.video?.url, "video");
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 submitRes = await fetchImpl(`${baseUrl}/${req.externalId}`, {
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
- if (!submitRes.ok) {
736
- throw new FalMediaError(submitRes.status, await safeText2(submitRes));
737
- }
738
- const submit = await submitRes.json();
739
- const statusUrl = submit.status_url;
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
- const resultRes = await fetchImpl(responseUrl, { headers });
768
- if (!resultRes.ok) {
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 outputs = extractOutputs(await resultRes.json());
772
- if (outputs.length === 0) {
773
- throw new Error(`ai-lcr: fal returned no media URL for "${req.externalId}"`);
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.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"