@broberg/ai-sdk 0.2.0 → 0.3.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
@@ -81,6 +81,51 @@ async function subprocessTransport(req) {
81
81
  return parseClaudeCliJson(stdout);
82
82
  }
83
83
 
84
+ // src/transport/stream.ts
85
+ var StreamHttpError = class extends Error {
86
+ status;
87
+ constructor(message, status) {
88
+ super(message);
89
+ this.name = "StreamHttpError";
90
+ this.status = status;
91
+ }
92
+ };
93
+ async function* streamTransport(req) {
94
+ if (!req.http) throw new Error("streamTransport: req.http is required for http transport");
95
+ const { url, method = "POST", headers, body } = req.http;
96
+ const fetchImpl = req.fetch ?? fetch;
97
+ const res = await fetchImpl(url, {
98
+ method,
99
+ headers,
100
+ body: body === void 0 ? void 0 : typeof body === "string" ? body : JSON.stringify(body)
101
+ });
102
+ if (!res.ok || !res.body) {
103
+ const text = await res.text().catch(() => "");
104
+ throw new StreamHttpError(`stream ${res.status}: ${text.slice(0, 300)}`, res.status);
105
+ }
106
+ const reader = res.body.getReader();
107
+ const decoder = new TextDecoder();
108
+ let buffer = "";
109
+ try {
110
+ for (; ; ) {
111
+ const { done, value } = await reader.read();
112
+ if (done) break;
113
+ buffer += decoder.decode(value, { stream: true });
114
+ let nl;
115
+ while ((nl = buffer.indexOf("\n")) >= 0) {
116
+ const line = buffer.slice(0, nl).replace(/\r$/, "");
117
+ buffer = buffer.slice(nl + 1);
118
+ if (!line.startsWith("data:")) continue;
119
+ const data = line.slice(5).trim();
120
+ if (data === "[DONE]") return;
121
+ if (data) yield data;
122
+ }
123
+ }
124
+ } finally {
125
+ reader.releaseLock();
126
+ }
127
+ }
128
+
84
129
  // src/providers/tools.ts
85
130
  function family(provider) {
86
131
  if (provider === "gemini" || provider === "google") return "gemini";
@@ -274,9 +319,7 @@ function flattenForSubprocess(messages) {
274
319
  function anthropicAdapter(config = {}) {
275
320
  const baseUrl = config.baseUrl ?? "https://api.anthropic.com";
276
321
  const version = config.anthropicVersion ?? "2023-06-01";
277
- async function chatHttp(req) {
278
- const apiKey = config.apiKey ?? process.env.ANTHROPIC_API_KEY;
279
- if (!apiKey) throw new Error("anthropic adapter: API key not set (env ANTHROPIC_API_KEY)");
322
+ function buildBody(req) {
280
323
  const system = [];
281
324
  const messages = [];
282
325
  for (const m of req.messages) {
@@ -295,6 +338,16 @@ function anthropicAdapter(config = {}) {
295
338
  if (system.length > 0) body.system = system.join("\n");
296
339
  if (req.tools) body.tools = toProviderTools(req.tools, "anthropic");
297
340
  if (req.temperature !== void 0) body.temperature = req.temperature;
341
+ return body;
342
+ }
343
+ function apiKeyOrThrow() {
344
+ const apiKey = config.apiKey ?? process.env.ANTHROPIC_API_KEY;
345
+ if (!apiKey) throw new Error("anthropic adapter: API key not set (env ANTHROPIC_API_KEY)");
346
+ return apiKey;
347
+ }
348
+ async function chatHttp(req) {
349
+ const apiKey = apiKeyOrThrow();
350
+ const body = buildBody(req);
298
351
  const res = await httpTransport({
299
352
  spec: req.spec,
300
353
  http: {
@@ -345,7 +398,110 @@ function anthropicAdapter(config = {}) {
345
398
  async function chat(req) {
346
399
  return req.spec.transport === "subprocess" ? chatSubprocess(req) : chatHttp(req);
347
400
  }
348
- return { name: "anthropic", chat, vision: chat };
401
+ async function* chatStream(req) {
402
+ if (req.spec.transport === "subprocess") {
403
+ throw new Error("anthropic adapter: streaming is not supported over the subprocess transport");
404
+ }
405
+ const apiKey = apiKeyOrThrow();
406
+ const body = { ...buildBody(req), stream: true };
407
+ const stream = streamTransport({
408
+ spec: req.spec,
409
+ fetch: config.fetch,
410
+ http: {
411
+ url: `${baseUrl}/v1/messages`,
412
+ headers: {
413
+ "content-type": "application/json",
414
+ "x-api-key": apiKey,
415
+ "anthropic-version": version
416
+ },
417
+ body
418
+ }
419
+ });
420
+ let inputTokens = 0;
421
+ let outputTokens = 0;
422
+ let cacheReadTokens = 0;
423
+ let cacheCreationTokens = 0;
424
+ let stopReason = null;
425
+ const toolBlocks = /* @__PURE__ */ new Map();
426
+ for await (const data of stream) {
427
+ let ev;
428
+ try {
429
+ ev = JSON.parse(data);
430
+ } catch {
431
+ continue;
432
+ }
433
+ switch (ev.type) {
434
+ case "message_start": {
435
+ const u = ev.message?.usage;
436
+ inputTokens = u?.input_tokens ?? 0;
437
+ cacheReadTokens = u?.cache_read_input_tokens ?? 0;
438
+ cacheCreationTokens = u?.cache_creation_input_tokens ?? 0;
439
+ break;
440
+ }
441
+ case "content_block_start": {
442
+ if (ev.content_block?.type === "tool_use" && ev.index !== void 0) {
443
+ toolBlocks.set(ev.index, {
444
+ id: ev.content_block.id ?? "",
445
+ name: ev.content_block.name ?? "",
446
+ json: ""
447
+ });
448
+ }
449
+ break;
450
+ }
451
+ case "content_block_delta": {
452
+ const d = ev.delta;
453
+ if (d?.type === "text_delta" && d.text) {
454
+ yield { type: "text", delta: d.text };
455
+ } else if (d?.type === "input_json_delta" && d.partial_json && ev.index !== void 0) {
456
+ const b = toolBlocks.get(ev.index);
457
+ if (b) b.json += d.partial_json;
458
+ }
459
+ break;
460
+ }
461
+ case "message_delta": {
462
+ if (ev.delta?.stop_reason) stopReason = ev.delta.stop_reason;
463
+ if (ev.usage?.output_tokens !== void 0) outputTokens = ev.usage.output_tokens;
464
+ break;
465
+ }
466
+ default:
467
+ break;
468
+ }
469
+ }
470
+ for (const [, b] of [...toolBlocks.entries()].sort((a, c) => a[0] - c[0])) {
471
+ let args = {};
472
+ try {
473
+ args = b.json ? JSON.parse(b.json) : {};
474
+ } catch {
475
+ args = {};
476
+ }
477
+ yield { type: "tool_call", id: b.id, name: b.name, args };
478
+ }
479
+ const usage = freshUsage({
480
+ provider: "anthropic",
481
+ model: req.spec.model,
482
+ transport: "http",
483
+ capability: "chat",
484
+ inputTokens,
485
+ outputTokens,
486
+ cacheReadTokens,
487
+ cacheCreationTokens
488
+ });
489
+ yield { type: "usage", costUsd: usage.costUsd, model: usage.model, usage };
490
+ yield { type: "finish", reason: mapAnthropicStop(stopReason) };
491
+ }
492
+ return { name: "anthropic", chat, chatStream, vision: chat };
493
+ }
494
+ function mapAnthropicStop(reason) {
495
+ switch (reason) {
496
+ case "tool_use":
497
+ return "tool_calls";
498
+ case "max_tokens":
499
+ return "length";
500
+ case "stop_sequence":
501
+ return "stop";
502
+ default:
503
+ return "end_turn";
504
+ }
349
505
  }
350
506
 
351
507
  // src/providers/openai-compatible.ts
@@ -353,6 +509,13 @@ function toOpenAIMessage(m) {
353
509
  if (typeof m.content === "string") {
354
510
  const base = { role: m.role, content: m.content };
355
511
  if (m.toolCallId) base.tool_call_id = m.toolCallId;
512
+ if (m.toolCalls && m.toolCalls.length > 0) {
513
+ base.tool_calls = m.toolCalls.map((tc) => ({
514
+ id: tc.id,
515
+ type: "function",
516
+ function: { name: tc.name, arguments: JSON.stringify(tc.arguments) }
517
+ }));
518
+ }
356
519
  return base;
357
520
  }
358
521
  const content = m.content.map((p) => {
@@ -375,6 +538,8 @@ function makeOpenAICompatibleAdapter(config) {
375
538
  if (req.tools) body.tools = toProviderTools(req.tools, "openai");
376
539
  if (req.maxTokens !== void 0) body.max_tokens = req.maxTokens;
377
540
  if (req.temperature !== void 0) body.temperature = req.temperature;
541
+ if (req.responseFormat === "json") body.response_format = { type: "json_object" };
542
+ if (config.costFromResponseField) body.usage = { include: true };
378
543
  const res = await httpTransport({
379
544
  spec: req.spec,
380
545
  http: {
@@ -404,17 +569,113 @@ function makeOpenAICompatibleAdapter(config) {
404
569
  inputTokens: data.usage?.prompt_tokens ?? 0,
405
570
  outputTokens: data.usage?.completion_tokens ?? 0
406
571
  });
572
+ if (config.costFromResponseField && typeof data.usage?.cost === "number") {
573
+ usage.costUsd = data.usage.cost;
574
+ }
407
575
  const result = { text, usage };
408
576
  if (toolCalls && toolCalls.length > 0) result.toolCalls = toolCalls;
409
577
  return result;
410
578
  }
579
+ async function* chatStream(req) {
580
+ const apiKey = config.apiKey ?? process.env[`${config.name.toUpperCase()}_API_KEY`];
581
+ if (!apiKey) {
582
+ throw new Error(`${config.name} adapter: API key not set (env ${config.name.toUpperCase()}_API_KEY)`);
583
+ }
584
+ const body = {
585
+ model: req.spec.model,
586
+ messages: req.messages.map(toOpenAIMessage),
587
+ stream: true,
588
+ stream_options: { include_usage: true }
589
+ };
590
+ if (req.tools) body.tools = toProviderTools(req.tools, "openai");
591
+ if (req.maxTokens !== void 0) body.max_tokens = req.maxTokens;
592
+ if (req.temperature !== void 0) body.temperature = req.temperature;
593
+ if (req.responseFormat === "json") body.response_format = { type: "json_object" };
594
+ if (config.costFromResponseField) body.usage = { include: true };
595
+ const stream = streamTransport({
596
+ spec: req.spec,
597
+ fetch: config.fetch,
598
+ http: {
599
+ url: `${config.baseUrl}/chat/completions`,
600
+ headers: {
601
+ "content-type": "application/json",
602
+ Authorization: `Bearer ${apiKey}`,
603
+ ...config.extraHeaders
604
+ },
605
+ body
606
+ }
607
+ });
608
+ const toolAcc = /* @__PURE__ */ new Map();
609
+ let finishReason = null;
610
+ for await (const data of stream) {
611
+ let chunk;
612
+ try {
613
+ chunk = JSON.parse(data);
614
+ } catch {
615
+ continue;
616
+ }
617
+ const choice = chunk.choices?.[0];
618
+ if (choice) {
619
+ const delta = choice.delta ?? {};
620
+ if (typeof delta.content === "string" && delta.content.length > 0) {
621
+ yield { type: "text", delta: delta.content };
622
+ }
623
+ for (const tc of delta.tool_calls ?? []) {
624
+ const idx = tc.index ?? 0;
625
+ const cur = toolAcc.get(idx) ?? { id: "", name: "", args: "" };
626
+ if (tc.id) cur.id = tc.id;
627
+ if (tc.function?.name) cur.name = tc.function.name;
628
+ if (tc.function?.arguments) cur.args += tc.function.arguments;
629
+ toolAcc.set(idx, cur);
630
+ }
631
+ if (choice.finish_reason) finishReason = choice.finish_reason;
632
+ }
633
+ if (chunk.usage) {
634
+ const usage = freshUsage({
635
+ provider: config.name,
636
+ model: req.spec.model,
637
+ transport: "http",
638
+ capability: "chat",
639
+ inputTokens: chunk.usage.prompt_tokens ?? 0,
640
+ outputTokens: chunk.usage.completion_tokens ?? 0
641
+ });
642
+ if (config.costFromResponseField && typeof chunk.usage.cost === "number") {
643
+ usage.costUsd = chunk.usage.cost;
644
+ }
645
+ yield { type: "usage", costUsd: usage.costUsd, model: usage.model, usage };
646
+ }
647
+ }
648
+ for (const [, t] of [...toolAcc.entries()].sort((a, b) => a[0] - b[0])) {
649
+ let args = {};
650
+ try {
651
+ args = t.args ? JSON.parse(t.args) : {};
652
+ } catch {
653
+ args = {};
654
+ }
655
+ yield { type: "tool_call", id: t.id, name: t.name, args };
656
+ }
657
+ yield { type: "finish", reason: mapFinishReason(finishReason) };
658
+ }
411
659
  return {
412
660
  name: config.name,
413
661
  chat,
662
+ chatStream,
414
663
  // gpt-4o-class models are multimodal — vision shares the chat path.
415
664
  vision: chat
416
665
  };
417
666
  }
667
+ function mapFinishReason(reason) {
668
+ switch (reason) {
669
+ case "tool_calls":
670
+ return "tool_calls";
671
+ case "length":
672
+ return "length";
673
+ case "stop":
674
+ return "stop";
675
+ default:
676
+ return "end_turn";
677
+ }
678
+ }
418
679
 
419
680
  // src/providers/openai.ts
420
681
  var WHISPER_PRICE_PER_MIN = {
@@ -495,11 +756,12 @@ function partsFrom(content) {
495
756
  }
496
757
  function geminiAdapter(config = {}) {
497
758
  const baseUrl = config.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta";
498
- async function chat(req) {
759
+ function resolveKey() {
499
760
  const apiKey = config.apiKey ?? process.env.GOOGLE_API_KEY ?? process.env.GEMINI_API_KEY;
500
- if (!apiKey) {
501
- throw new Error("gemini adapter: API key not set (env GOOGLE_API_KEY)");
502
- }
761
+ if (!apiKey) throw new Error("gemini adapter: API key not set (env GOOGLE_API_KEY)");
762
+ return apiKey;
763
+ }
764
+ function buildBody(req) {
503
765
  const systemParts = [];
504
766
  const contents = [];
505
767
  for (const m of req.messages) {
@@ -519,6 +781,11 @@ function geminiAdapter(config = {}) {
519
781
  if (req.maxTokens !== void 0) genConfig.maxOutputTokens = req.maxTokens;
520
782
  if (req.temperature !== void 0) genConfig.temperature = req.temperature;
521
783
  if (Object.keys(genConfig).length > 0) body.generationConfig = genConfig;
784
+ return body;
785
+ }
786
+ async function chat(req) {
787
+ const apiKey = resolveKey();
788
+ const body = buildBody(req);
522
789
  const res = await httpTransport({
523
790
  spec: req.spec,
524
791
  http: {
@@ -546,7 +813,71 @@ function geminiAdapter(config = {}) {
546
813
  if (toolCalls.length > 0) result.toolCalls = toolCalls;
547
814
  return result;
548
815
  }
549
- return { name: "gemini", chat, vision: chat };
816
+ async function* chatStream(req) {
817
+ const apiKey = resolveKey();
818
+ const body = buildBody(req);
819
+ const stream = streamTransport({
820
+ spec: req.spec,
821
+ fetch: config.fetch,
822
+ http: {
823
+ url: `${baseUrl}/models/${req.spec.model}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`,
824
+ headers: { "content-type": "application/json" },
825
+ body
826
+ }
827
+ });
828
+ const toolCalls = [];
829
+ let inputTokens = 0;
830
+ let outputTokens = 0;
831
+ let finishReason = null;
832
+ for await (const data of stream) {
833
+ let chunk;
834
+ try {
835
+ chunk = JSON.parse(data);
836
+ } catch {
837
+ continue;
838
+ }
839
+ const candidate = chunk.candidates?.[0];
840
+ for (const p of candidate?.content?.parts ?? []) {
841
+ if (typeof p.text === "string" && p.text.length > 0) {
842
+ yield { type: "text", delta: p.text };
843
+ } else if (p.functionCall) {
844
+ toolCalls.push(fromProviderToolCall({ functionCall: p.functionCall }, "gemini"));
845
+ }
846
+ }
847
+ if (candidate?.finishReason) finishReason = candidate.finishReason;
848
+ if (chunk.usageMetadata) {
849
+ inputTokens = chunk.usageMetadata.promptTokenCount ?? inputTokens;
850
+ outputTokens = chunk.usageMetadata.candidatesTokenCount ?? outputTokens;
851
+ }
852
+ }
853
+ for (const tc of toolCalls) {
854
+ yield { type: "tool_call", id: tc.id, name: tc.name, args: tc.arguments };
855
+ }
856
+ const usage = freshUsage({
857
+ provider: "gemini",
858
+ model: req.spec.model,
859
+ transport: "http",
860
+ capability: "chat",
861
+ inputTokens,
862
+ outputTokens
863
+ });
864
+ yield { type: "usage", costUsd: usage.costUsd, model: usage.model, usage };
865
+ yield {
866
+ type: "finish",
867
+ reason: toolCalls.length > 0 ? "tool_calls" : mapGeminiFinish(finishReason)
868
+ };
869
+ }
870
+ return { name: "gemini", chat, chatStream, vision: chat };
871
+ }
872
+ function mapGeminiFinish(reason) {
873
+ switch (reason) {
874
+ case "MAX_TOKENS":
875
+ return "length";
876
+ case "STOP":
877
+ return "end_turn";
878
+ default:
879
+ return reason ? "stop" : "end_turn";
880
+ }
550
881
  }
551
882
 
552
883
  // src/providers/deepinfra.ts
@@ -567,7 +898,10 @@ function openrouterAdapter(config = {}) {
567
898
  extraHeaders: {
568
899
  "HTTP-Referer": config.referer ?? "https://broberg.ai",
569
900
  "X-Title": config.title ?? "@broberg/ai-sdk"
570
- }
901
+ },
902
+ // OpenRouter returns ground-truth usage.cost (USD) when usage:{include:true}
903
+ // is set — use it over the local pricing-table estimate (F010).
904
+ costFromResponseField: true
571
905
  });
572
906
  }
573
907
 
@@ -911,6 +1245,8 @@ var chatInputSchema = z.object({
911
1245
  tools: z.array(toolSchema).optional(),
912
1246
  maxTokens: z.number().int().positive().optional(),
913
1247
  temperature: z.number().min(0).max(2).optional(),
1248
+ /** "json" requests JSON-object output (OpenAI-compatible response_format). */
1249
+ responseFormat: z.enum(["json", "text"]).optional(),
914
1250
  ...callOptions
915
1251
  });
916
1252
  var visionInputSchema = z.object({
@@ -1029,6 +1365,73 @@ function createAI(config = {}) {
1029
1365
  }
1030
1366
  throw lastErr;
1031
1367
  }
1368
+ function eligibleForFallback(e) {
1369
+ const status = e?.status;
1370
+ if (status === void 0) return true;
1371
+ return status === 429 || status >= 500;
1372
+ }
1373
+ function errorEvent(e) {
1374
+ const ev = {
1375
+ type: "error",
1376
+ message: e instanceof Error ? e.message : String(e)
1377
+ };
1378
+ const status = e?.status;
1379
+ if (status !== void 0) ev.status = status;
1380
+ return ev;
1381
+ }
1382
+ async function* chatStreamImpl(input) {
1383
+ input = chatInputSchema.parse(input);
1384
+ const tier = input.tier ?? "smart";
1385
+ const messages = toMessages(input);
1386
+ const estIn = messages.reduce(
1387
+ (n, m) => n + estTokens(typeof m.content === "string" ? m.content : JSON.stringify(m.content)),
1388
+ 0
1389
+ );
1390
+ const estOut = input.maxTokens ?? 512;
1391
+ const routes = [
1392
+ resolveTier(tier, input.override, cfg.defaults),
1393
+ ...(input.fallback ?? []).map(
1394
+ (f) => typeof f === "string" ? resolveTier(f, void 0, cfg.defaults) : f
1395
+ )
1396
+ ];
1397
+ let lastErr;
1398
+ for (let i = 0; i < routes.length; i++) {
1399
+ const spec = routes[i];
1400
+ await preflight(spec, estIn, estOut);
1401
+ const adapter = pickProvider(spec.provider);
1402
+ if (!adapter.chatStream) {
1403
+ throw new Error(`createAI: provider "${spec.provider}" does not support streaming`);
1404
+ }
1405
+ const t0 = performance.now();
1406
+ let emitted = false;
1407
+ try {
1408
+ for await (const ev of adapter.chatStream({
1409
+ messages,
1410
+ spec,
1411
+ tools: input.tools,
1412
+ maxTokens: input.maxTokens,
1413
+ temperature: input.temperature,
1414
+ responseFormat: input.responseFormat
1415
+ })) {
1416
+ if (ev.type === "text" || ev.type === "tool_call") emitted = true;
1417
+ if (ev.type === "usage") {
1418
+ enrich(ev.usage, "chat", i === 0 ? tier : void 0, input.purpose, performance.now() - t0);
1419
+ await settle(ev.usage);
1420
+ await report(ev.usage);
1421
+ }
1422
+ yield ev;
1423
+ }
1424
+ return;
1425
+ } catch (e) {
1426
+ lastErr = e;
1427
+ if (emitted || !eligibleForFallback(e)) {
1428
+ yield errorEvent(e);
1429
+ return;
1430
+ }
1431
+ }
1432
+ }
1433
+ yield errorEvent(lastErr);
1434
+ }
1032
1435
  const client = {
1033
1436
  async chat(input) {
1034
1437
  input = chatInputSchema.parse(input);
@@ -1049,10 +1452,11 @@ function createAI(config = {}) {
1049
1452
  invoke: async (spec) => {
1050
1453
  const adapter = pickProvider(spec.provider);
1051
1454
  if (!adapter.chat) throw new Error(`createAI: provider "${spec.provider}" does not support chat`);
1052
- return adapter.chat({ messages, spec, tools: input.tools, maxTokens: input.maxTokens, temperature: input.temperature });
1455
+ return adapter.chat({ messages, spec, tools: input.tools, maxTokens: input.maxTokens, temperature: input.temperature, responseFormat: input.responseFormat });
1053
1456
  }
1054
1457
  });
1055
1458
  },
1459
+ chatStream: chatStreamImpl,
1056
1460
  async vision(input) {
1057
1461
  input = visionInputSchema.parse(input);
1058
1462
  const tier = input.tier ?? VISION_DEFAULT_TIER;
@@ -1235,8 +1639,8 @@ var stubProviders = {
1235
1639
  };
1236
1640
 
1237
1641
  // src/version.ts
1238
- var VERSION = "0.2.0";
1239
- var SDK_TAG = "@broberg/ai-sdk@0.2.0";
1642
+ var VERSION = "0.3.0";
1643
+ var SDK_TAG = "@broberg/ai-sdk@0.3.0";
1240
1644
 
1241
1645
  // src/cost/budget-store.ts
1242
1646
  function sqliteBudgetStore(config) {
@@ -1465,6 +1869,7 @@ export {
1465
1869
  BudgetGuard,
1466
1870
  DEFAULT_TIER_MAP,
1467
1871
  SDK_TAG,
1872
+ StreamHttpError,
1468
1873
  VERSION,
1469
1874
  aiConfigSchema,
1470
1875
  anthropicAdapter,
@@ -1499,6 +1904,7 @@ export {
1499
1904
  resolveTier,
1500
1905
  sqliteBudgetStore,
1501
1906
  sqliteSink,
1907
+ streamTransport,
1502
1908
  stubProviders,
1503
1909
  subprocessTransport,
1504
1910
  tierSpecSchema,