@broberg/ai-sdk 0.57.0 → 0.58.0

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
@@ -26,6 +26,238 @@ import {
26
26
  getPrice
27
27
  } from "./chunk-QVMGFHCF.js";
28
28
 
29
+ // src/search/brave.ts
30
+ async function braveSearch(req, apiKey, f) {
31
+ const params = new URLSearchParams({ q: req.query, count: String(Math.min(req.limit ?? 10, 20)) });
32
+ if (req.lang) params.set("search_lang", req.lang);
33
+ if (req.country) params.set("country", req.country);
34
+ const res = await f(`https://api.search.brave.com/res/v1/web/search?${params}`, {
35
+ headers: { accept: "application/json", "X-Subscription-Token": apiKey }
36
+ });
37
+ if (!res.ok) throw Object.assign(new Error(`brave search: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`), { status: res.status });
38
+ const body = await res.json();
39
+ const items = (body.web?.results ?? []).map((r) => ({
40
+ title: r.title ?? "",
41
+ url: r.url ?? "",
42
+ description: r.description ?? "",
43
+ provider: "brave",
44
+ ...r.language ? { lang: r.language } : {},
45
+ ...r.page_age ? { publishedAt: new Date(r.page_age).toISOString() } : {}
46
+ }));
47
+ return { items, requestId: res.headers.get("x-request-id") ?? void 0 };
48
+ }
49
+
50
+ // src/search/cloudflare.ts
51
+ var CLOUDFLARE_MAX_QUERY = 1024;
52
+ var CLOUDFLARE_MAX_LIMIT = 10;
53
+ async function cloudflareSearch(req, creds, f) {
54
+ if (req.query.length < 1 || req.query.length > CLOUDFLARE_MAX_QUERY) {
55
+ throw new Error(`search: Cloudflare takes a query of 1\u2013${CLOUDFLARE_MAX_QUERY} characters, got ${req.query.length}`);
56
+ }
57
+ const provider = req.provider.split(":")[1];
58
+ const res = await f(`https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(creds.accountId)}/ai/websearch/`, {
59
+ method: "POST",
60
+ headers: { authorization: `Bearer ${creds.apiToken}`, "content-type": "application/json" },
61
+ body: JSON.stringify({ query: req.query, provider, limit: Math.min(req.limit ?? CLOUDFLARE_MAX_LIMIT, CLOUDFLARE_MAX_LIMIT) })
62
+ });
63
+ if (!res.ok) throw Object.assign(new Error(`cloudflare search (${provider}): HTTP ${res.status} ${(await res.text()).slice(0, 200)}`), { status: res.status });
64
+ const body = await res.json();
65
+ const inner = body.result ?? body;
66
+ const items = (inner.items ?? []).map((r) => ({
67
+ title: r.title ?? "",
68
+ url: r.url ?? "",
69
+ description: r.description ?? "",
70
+ provider: req.provider
71
+ }));
72
+ return { items, requestId: inner.metadata?.requestId };
73
+ }
74
+
75
+ // src/search/prices.ts
76
+ var SEARCH_PRICE_USD = {
77
+ "cloudflare:ceramic": 25e-5,
78
+ "cloudflare:linkup": 5e-3,
79
+ "cloudflare:exa": 7e-3,
80
+ brave: 5e-3
81
+ };
82
+
83
+ // src/search/router.ts
84
+ var DEFAULT_SEARCH_ROUTES = [
85
+ { name: "fresh-or-country", when: { fresh: true }, chain: ["brave", "cloudflare:linkup"] },
86
+ { name: "english-agent", when: { english: true, purpose: ["agent", "grounding"] }, chain: ["cloudflare:ceramic", "cloudflare:linkup", "brave"] },
87
+ { name: "english-other", when: { english: true }, chain: ["cloudflare:linkup", "cloudflare:ceramic", "brave"] },
88
+ { name: "non-english", when: {}, chain: ["cloudflare:linkup", "brave"] }
89
+ ];
90
+ var ZDR_PROVIDERS = /* @__PURE__ */ new Set(["cloudflare:ceramic", "cloudflare:linkup"]);
91
+ var isEnglish = (lang) => !lang || /^en\b/i.test(lang);
92
+ function routeSearch(req, routes = DEFAULT_SEARCH_ROUTES) {
93
+ if (req.provider) return [req.provider];
94
+ const fresh = Boolean(req.fresh || req.country);
95
+ const english = isEnglish(req.lang);
96
+ const route = routes.find(
97
+ (r) => (r.when.fresh === void 0 || r.when.fresh === fresh) && (r.when.english === void 0 || r.when.english === english) && (r.when.purpose === void 0 || r.when.purpose.includes(req.purpose ?? "agent"))
98
+ ) ?? routes[routes.length - 1];
99
+ let chain = route.chain.filter((p) => english || p !== "cloudflare:ceramic");
100
+ chain = chain.filter((p) => p !== "cloudflare:exa");
101
+ if (req.zdr) chain = chain.filter((p) => ZDR_PROVIDERS.has(p));
102
+ return chain;
103
+ }
104
+
105
+ // src/search/cache.ts
106
+ var SEARCH_CACHE_TTL_MS = {
107
+ monitor: 5 * 6e4,
108
+ agent: 60 * 6e4,
109
+ grounding: 60 * 6e4,
110
+ discovery: 7 * 24 * 60 * 6e4
111
+ };
112
+ function searchCacheKey(provider, req) {
113
+ const q = req.query.normalize("NFKC").trim().toLowerCase().replace(/\s+/g, " ");
114
+ return JSON.stringify([provider, req.lang ?? "", req.country ?? "", req.limit ?? 10, q]);
115
+ }
116
+ function memorySearchCache(max = 1e3) {
117
+ const m = /* @__PURE__ */ new Map();
118
+ return {
119
+ get(k) {
120
+ const e = m.get(k);
121
+ if (!e) return void 0;
122
+ if (Date.now() > e.until) {
123
+ m.delete(k);
124
+ return void 0;
125
+ }
126
+ return e.v;
127
+ },
128
+ set(k, v, ttlMs) {
129
+ if (m.size >= max) m.delete(m.keys().next().value);
130
+ m.set(k, { v, until: Date.now() + ttlMs });
131
+ }
132
+ };
133
+ }
134
+
135
+ // src/cost/failure.ts
136
+ function classifyFailure(err) {
137
+ const e = err;
138
+ const name = e?.name ?? "";
139
+ const message = String(e?.message ?? "");
140
+ if (name === "AbortError" || name === "TimeoutError" || /timed? ?out/i.test(message.split("\n")[0] ?? "")) {
141
+ return { errorCode: "timeout", errorKind: "timeout" };
142
+ }
143
+ let status = typeof e?.status === "number" ? e.status : void 0;
144
+ if (status === void 0) {
145
+ const m = /(?:^|\s)([45]\d{2})(?=[:\s]|$)/.exec(message.split("\n")[0] ?? "");
146
+ if (m) status = Number(m[1]);
147
+ }
148
+ if (status !== void 0) {
149
+ const kind = status === 401 || status === 403 ? "auth" : status === 404 ? "not_found" : status === 429 ? "rate_limit" : status >= 500 ? "server" : "other";
150
+ return { errorCode: String(status), errorKind: kind };
151
+ }
152
+ if (name === "TypeError" && /fetch failed|network|ECONN|ENOTFOUND/i.test(message + (e?.cause?.code ?? ""))) {
153
+ return { errorCode: "network", errorKind: "network" };
154
+ }
155
+ return { errorCode: "unknown", errorKind: "other" };
156
+ }
157
+
158
+ // src/search/types.ts
159
+ var SearchKeyMissingError = class extends Error {
160
+ constructor(provider, envVar) {
161
+ super(`search: ${provider} needs ${envVar} (env or options.credentials)`);
162
+ this.provider = provider;
163
+ this.envVar = envVar;
164
+ this.name = "SearchKeyMissingError";
165
+ }
166
+ provider;
167
+ envVar;
168
+ };
169
+
170
+ // src/search/index.ts
171
+ var sharedCache = memorySearchCache();
172
+ async function callProvider(provider, req, opts) {
173
+ const f = opts.fetch ?? fetch;
174
+ const c = opts.credentials ?? {};
175
+ if (provider === "brave") {
176
+ const key = c.braveApiKey ?? process.env.BRAVE_API_KEY;
177
+ if (!key) throw new SearchKeyMissingError(provider, "BRAVE_API_KEY");
178
+ return braveSearch({ ...req, provider }, key, f);
179
+ }
180
+ if (provider.startsWith("cloudflare:")) {
181
+ const accountId = c.cloudflareAccountId ?? process.env.CLOUDFLARE_ACCOUNT_ID;
182
+ const apiToken = c.cloudflareApiToken ?? process.env.CLOUDFLARE_API_TOKEN;
183
+ if (!accountId) throw new SearchKeyMissingError(provider, "CLOUDFLARE_ACCOUNT_ID");
184
+ if (!apiToken) throw new SearchKeyMissingError(provider, "CLOUDFLARE_API_TOKEN");
185
+ return cloudflareSearch({ ...req, provider }, { accountId, apiToken }, f);
186
+ }
187
+ throw new Error(`search: unknown provider "${String(provider)}"`);
188
+ }
189
+ function usageRow(provider, req, costUsd, latencyMs) {
190
+ return {
191
+ provider,
192
+ model: "web-search",
193
+ region: "unknown",
194
+ // Brave and Cloudflare's gateway do not let us say where a query is served
195
+ transport: "http",
196
+ inputTokens: 0,
197
+ outputTokens: 0,
198
+ cacheReadTokens: 0,
199
+ cacheCreationTokens: 0,
200
+ costUsd,
201
+ costBasis: "computed",
202
+ latencyMs,
203
+ capability: "search",
204
+ ...req.purpose ? { purpose: req.purpose } : {},
205
+ ...req.labels ? { labels: req.labels } : {},
206
+ ts: (/* @__PURE__ */ new Date()).toISOString()
207
+ };
208
+ }
209
+ async function search(req, opts = {}) {
210
+ const chain = routeSearch(req);
211
+ if (chain.length === 0) throw new Error("search: no provider fits this request (zdr excludes every candidate)");
212
+ const cache = opts.cache === false ? void 0 : opts.cache ?? sharedCache;
213
+ const ttl = SEARCH_CACHE_TTL_MS[req.purpose ?? "agent"];
214
+ const sink = opts.costSink;
215
+ let lastErr;
216
+ for (const provider of chain) {
217
+ const key = searchCacheKey(provider, req);
218
+ const hit = cache?.get(key);
219
+ if (hit) return { items: hit.items, meta: { ...hit.meta, cached: true, costUsd: 0, latencyMs: 0 } };
220
+ const t0 = performance.now();
221
+ try {
222
+ const out = await callProvider(provider, req, opts);
223
+ const latencyMs = Math.round(performance.now() - t0);
224
+ const result = {
225
+ items: dedupe(out.items),
226
+ meta: { provider, latencyMs, cached: false, costUsd: SEARCH_PRICE_USD[provider], ...out.requestId ? { requestId: out.requestId } : {} }
227
+ };
228
+ cache?.set(key, result, ttl);
229
+ try {
230
+ await sink?.record(usageRow(provider, req, result.meta.costUsd, latencyMs));
231
+ } catch {
232
+ }
233
+ return result;
234
+ } catch (e) {
235
+ lastErr = e;
236
+ if (sink?.recordFailure) {
237
+ try {
238
+ await sink.recordFailure({
239
+ provider,
240
+ model: "web-search",
241
+ transport: "http",
242
+ capability: "search",
243
+ ...req.purpose ? { purpose: req.purpose } : {},
244
+ ...req.labels ? { labels: req.labels } : {},
245
+ latencyMs: Math.round(performance.now() - t0),
246
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
247
+ ...classifyFailure(e)
248
+ });
249
+ } catch {
250
+ }
251
+ }
252
+ }
253
+ }
254
+ throw lastErr;
255
+ }
256
+ function dedupe(items) {
257
+ const seen = /* @__PURE__ */ new Set();
258
+ return items.filter((i) => seen.has(i.url) ? false : (seen.add(i.url), true));
259
+ }
260
+
29
261
  // src/cost/default-hosts.ts
30
262
  var DEFAULT_BASE_URLS = {
31
263
  anthropic: "https://api.anthropic.com",
@@ -3548,8 +3780,8 @@ var aiConfigSchema = z.object({
3548
3780
  import { randomUUID } from "crypto";
3549
3781
 
3550
3782
  // src/version.ts
3551
- var VERSION = "0.57.0";
3552
- var SDK_TAG = "@broberg/ai-sdk@0.57.0";
3783
+ var VERSION = "0.58.0";
3784
+ var SDK_TAG = "@broberg/ai-sdk@0.58.0";
3553
3785
 
3554
3786
  // src/cost/sinks/upmetrics.ts
3555
3787
  function isRetryableStatus(status) {
@@ -3830,29 +4062,6 @@ function assertByokProviders(providers) {
3830
4062
  }
3831
4063
  }
3832
4064
 
3833
- // src/cost/failure.ts
3834
- function classifyFailure(err) {
3835
- const e = err;
3836
- const name = e?.name ?? "";
3837
- const message = String(e?.message ?? "");
3838
- if (name === "AbortError" || name === "TimeoutError" || /timed? ?out/i.test(message.split("\n")[0] ?? "")) {
3839
- return { errorCode: "timeout", errorKind: "timeout" };
3840
- }
3841
- let status = typeof e?.status === "number" ? e.status : void 0;
3842
- if (status === void 0) {
3843
- const m = /(?:^|\s)([45]\d{2})(?=[:\s]|$)/.exec(message.split("\n")[0] ?? "");
3844
- if (m) status = Number(m[1]);
3845
- }
3846
- if (status !== void 0) {
3847
- const kind = status === 401 || status === 403 ? "auth" : status === 404 ? "not_found" : status === 429 ? "rate_limit" : status >= 500 ? "server" : "other";
3848
- return { errorCode: String(status), errorKind: kind };
3849
- }
3850
- if (name === "TypeError" && /fetch failed|network|ECONN|ENOTFOUND/i.test(message + (e?.cause?.code ?? ""))) {
3851
- return { errorCode: "network", errorKind: "network" };
3852
- }
3853
- return { errorCode: "unknown", errorKind: "other" };
3854
- }
3855
-
3856
4065
  // src/client.ts
3857
4066
  var DEFAULT_IMAGE_SPEC = {
3858
4067
  provider: "fal",
@@ -4364,6 +4573,10 @@ function createAI(config = {}) {
4364
4573
  }
4365
4574
  });
4366
4575
  },
4576
+ async search(req, opts) {
4577
+ const labels = { ...cfg.labels, ...req.labels };
4578
+ return search({ ...req, ...Object.keys(labels).length ? { labels } : {} }, { ...opts, ...costSink ? { costSink } : {} });
4579
+ },
4367
4580
  async judge(input) {
4368
4581
  input = judgeInputSchema.parse(input);
4369
4582
  const stateText = typeof input.state === "string" ? input.state : JSON.stringify(input.state);
@@ -5094,103 +5307,6 @@ function wouldRouteTo(tier) {
5094
5307
  if (!spec) return { kind: "unknown-provider", provider: String(tier), onlyIf: [...ASSUMPTIONS] };
5095
5308
  return wouldProviderRouteTo(spec.provider);
5096
5309
  }
5097
-
5098
- // src/search/brave.ts
5099
- async function braveSearch(req, apiKey, f) {
5100
- const params = new URLSearchParams({ q: req.query, count: String(Math.min(req.limit ?? 10, 20)) });
5101
- if (req.lang) params.set("search_lang", req.lang);
5102
- if (req.country) params.set("country", req.country);
5103
- const res = await f(`https://api.search.brave.com/res/v1/web/search?${params}`, {
5104
- headers: { accept: "application/json", "X-Subscription-Token": apiKey }
5105
- });
5106
- if (!res.ok) throw Object.assign(new Error(`brave search: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`), { status: res.status });
5107
- const body = await res.json();
5108
- const items = (body.web?.results ?? []).map((r) => ({
5109
- title: r.title ?? "",
5110
- url: r.url ?? "",
5111
- description: r.description ?? "",
5112
- provider: "brave",
5113
- ...r.language ? { lang: r.language } : {},
5114
- ...r.page_age ? { publishedAt: new Date(r.page_age).toISOString() } : {}
5115
- }));
5116
- return { items, requestId: res.headers.get("x-request-id") ?? void 0 };
5117
- }
5118
-
5119
- // src/search/cloudflare.ts
5120
- var CLOUDFLARE_MAX_QUERY = 1024;
5121
- var CLOUDFLARE_MAX_LIMIT = 10;
5122
- async function cloudflareSearch(req, creds, f) {
5123
- if (req.query.length < 1 || req.query.length > CLOUDFLARE_MAX_QUERY) {
5124
- throw new Error(`search: Cloudflare takes a query of 1\u2013${CLOUDFLARE_MAX_QUERY} characters, got ${req.query.length}`);
5125
- }
5126
- const provider = req.provider.split(":")[1];
5127
- const res = await f(`https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(creds.accountId)}/ai/websearch/`, {
5128
- method: "POST",
5129
- headers: { authorization: `Bearer ${creds.apiToken}`, "content-type": "application/json" },
5130
- body: JSON.stringify({ query: req.query, provider, limit: Math.min(req.limit ?? CLOUDFLARE_MAX_LIMIT, CLOUDFLARE_MAX_LIMIT) })
5131
- });
5132
- if (!res.ok) throw Object.assign(new Error(`cloudflare search (${provider}): HTTP ${res.status} ${(await res.text()).slice(0, 200)}`), { status: res.status });
5133
- const body = await res.json();
5134
- const inner = body.result ?? body;
5135
- const items = (inner.items ?? []).map((r) => ({
5136
- title: r.title ?? "",
5137
- url: r.url ?? "",
5138
- description: r.description ?? "",
5139
- provider: req.provider
5140
- }));
5141
- return { items, requestId: inner.metadata?.requestId };
5142
- }
5143
-
5144
- // src/search/prices.ts
5145
- var SEARCH_PRICE_USD = {
5146
- "cloudflare:ceramic": 25e-5,
5147
- "cloudflare:linkup": 5e-3,
5148
- "cloudflare:exa": 7e-3,
5149
- brave: 5e-3
5150
- };
5151
-
5152
- // src/search/types.ts
5153
- var SearchKeyMissingError = class extends Error {
5154
- constructor(provider, envVar) {
5155
- super(`search: ${provider} needs ${envVar} (env or options.credentials)`);
5156
- this.provider = provider;
5157
- this.envVar = envVar;
5158
- this.name = "SearchKeyMissingError";
5159
- }
5160
- provider;
5161
- envVar;
5162
- };
5163
-
5164
- // src/search/index.ts
5165
- async function search(req, opts = {}) {
5166
- const f = opts.fetch ?? fetch;
5167
- const c = opts.credentials ?? {};
5168
- const t0 = performance.now();
5169
- let out;
5170
- if (req.provider === "brave") {
5171
- const key = c.braveApiKey ?? process.env.BRAVE_API_KEY;
5172
- if (!key) throw new SearchKeyMissingError(req.provider, "BRAVE_API_KEY");
5173
- out = await braveSearch(req, key, f);
5174
- } else if (req.provider.startsWith("cloudflare:")) {
5175
- const accountId = c.cloudflareAccountId ?? process.env.CLOUDFLARE_ACCOUNT_ID;
5176
- const apiToken = c.cloudflareApiToken ?? process.env.CLOUDFLARE_API_TOKEN;
5177
- if (!accountId) throw new SearchKeyMissingError(req.provider, "CLOUDFLARE_ACCOUNT_ID");
5178
- if (!apiToken) throw new SearchKeyMissingError(req.provider, "CLOUDFLARE_API_TOKEN");
5179
- out = await cloudflareSearch(req, { accountId, apiToken }, f);
5180
- } else {
5181
- throw new Error(`search: unknown provider "${String(req.provider)}"`);
5182
- }
5183
- return {
5184
- items: out.items,
5185
- meta: {
5186
- provider: req.provider,
5187
- latencyMs: Math.round(performance.now() - t0),
5188
- cached: false,
5189
- costUsd: SEARCH_PRICE_USD[req.provider],
5190
- ...out.requestId ? { requestId: out.requestId } : {}
5191
- }
5192
- };
5193
- }
5194
5310
  export {
5195
5311
  AZURE_DANISH_VOICES,
5196
5312
  AZURE_DANISH_VOICE_LIST,
@@ -5199,6 +5315,7 @@ export {
5199
5315
  CONFIG_DERIVED_HOSTS,
5200
5316
  DEFAULT_BASE_URLS,
5201
5317
  DEFAULT_CLIP_SEC,
5318
+ DEFAULT_SEARCH_ROUTES,
5202
5319
  DEFAULT_TIER_MAP,
5203
5320
  ELEVENLABS_DANISH_VOICES,
5204
5321
  LOCALLY_PINNED_HOSTS,
@@ -5206,12 +5323,14 @@ export {
5206
5323
  ModelUnavailableError,
5207
5324
  PRICING_STALE_AFTER_DAYS,
5208
5325
  SDK_TAG,
5326
+ SEARCH_CACHE_TTL_MS,
5209
5327
  SEARCH_PRICE_USD,
5210
5328
  SearchKeyMissingError,
5211
5329
  StreamHttpError,
5212
5330
  UpmetricsCostError,
5213
5331
  VERSION,
5214
5332
  VoiceUnavailableError,
5333
+ ZDR_PROVIDERS,
5215
5334
  aiConfigSchema,
5216
5335
  alignWordTimings,
5217
5336
  anthropicAdapter,
@@ -5254,6 +5373,7 @@ export {
5254
5373
  makeContracts,
5255
5374
  makeOpenAICompatibleAdapter,
5256
5375
  matchLabel,
5376
+ memorySearchCache,
5257
5377
  messageSchema,
5258
5378
  mistralAdapter,
5259
5379
  mistralStubAdapter,
@@ -5276,6 +5396,7 @@ export {
5276
5396
  resolveModel,
5277
5397
  resolveTier,
5278
5398
  resolveVoice,
5399
+ routeSearch,
5279
5400
  search,
5280
5401
  sqliteBudgetStore,
5281
5402
  sqliteSink,