@broberg/ai-sdk 0.47.1 → 0.49.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
@@ -8,7 +8,7 @@ import {
8
8
  resolveModel,
9
9
  resolveTier,
10
10
  setAvailability
11
- } from "./chunk-ZFWSLSE7.js";
11
+ } from "./chunk-SWV5YIEE.js";
12
12
  import {
13
13
  DEFAULT_CLIP_SEC,
14
14
  MEDIA_PRICING_CHECKED_AT,
@@ -2866,6 +2866,8 @@ async function resolveAudio(audio, fetchImpl = fetch) {
2866
2866
  }
2867
2867
 
2868
2868
  // src/capabilities/contracts/index.ts
2869
+ var RAW_SNIPPET_CHARS = 200;
2870
+ var CLASSIFY_ABSTAIN_SENTENCE = 'If none of the labels fit, return {"label": null}.';
2869
2871
  function parseJsonLoose(text) {
2870
2872
  const fenced = text.replace(/```(?:json)?/gi, "").trim();
2871
2873
  const start = fenced.search(/[[{]/);
@@ -2946,7 +2948,19 @@ Your previous output was not valid JSON. Return ONLY parseable JSON.` : base,
2946
2948
  },
2947
2949
  async classify(input) {
2948
2950
  const res = await client.chat({
2949
- system: 'You are a zero-shot classifier. Choose exactly one label from the provided list. Return ONLY JSON: {"label": "<one of the labels>", "confidence": <0..1>}.',
2951
+ // F060 — the model must be ALLOWED to say "none of these". Without that sentence,
2952
+ // measured by trail on 444 golden examples (mistral-small-latest, temp 0): of 38
2953
+ // inputs the model refused when invited to, this prompt turned 34 into a
2954
+ // confident WRONG label inside the menu and let none through as a refusal. That
2955
+ // undid F052 from the outside — the code stopped guessing labels[0], and the
2956
+ // prompt made the model guess instead.
2957
+ //
2958
+ // It is exactly ONE sentence, the one trail measured. They isolated it: removing
2959
+ // only this sentence reproduces the failure (35 wrong vs our 34), so it is the
2960
+ // invitation to refuse that matters, not the confidence field or the wording
2961
+ // around it. Do not "tidy" it away, and do not change the words — an unmeasured
2962
+ // edit on top of a measured one makes the measurement say nothing.
2963
+ system: "You are a zero-shot classifier. Choose exactly one label from the provided list. " + CLASSIFY_ABSTAIN_SENTENCE + ' Return ONLY JSON: {"label": "<one of the labels>", "confidence": <0..1>}.',
2950
2964
  prompt: `Labels: ${JSON.stringify(input.labels)}
2951
2965
 
2952
2966
  Text:
@@ -2954,13 +2968,26 @@ ${input.text}`,
2954
2968
  tier: input.tier ?? "cheap",
2955
2969
  purpose: input.purpose ?? "contract:classify"
2956
2970
  });
2957
- const parsed = parseJsonLoose(res.text);
2971
+ let parsed;
2972
+ try {
2973
+ parsed = parseJsonLoose(res.text);
2974
+ } catch (err) {
2975
+ if (input.onUnparseable !== "value") throw err;
2976
+ return {
2977
+ label: null,
2978
+ rawLabel: res.text.slice(0, RAW_SNIPPET_CHARS),
2979
+ confidence: null,
2980
+ outcome: "unparseable",
2981
+ usage: res.usage
2982
+ };
2983
+ }
2958
2984
  const matched = matchLabel(parsed.label, input.labels);
2959
2985
  return {
2960
2986
  label: matched,
2961
- ...matched !== null ? {} : { rawLabel: typeof parsed.label === "string" ? parsed.label : res.text.slice(0, 200) },
2987
+ ...matched !== null ? {} : { rawLabel: typeof parsed.label === "string" ? parsed.label : res.text.slice(0, RAW_SNIPPET_CHARS) },
2962
2988
  // 0 is a real confidence; "no confidence reported" is not 0.
2963
2989
  confidence: typeof parsed.confidence === "number" ? parsed.confidence : null,
2990
+ outcome: matched !== null ? "answered" : "out-of-set",
2964
2991
  usage: res.usage
2965
2992
  };
2966
2993
  },
@@ -2977,7 +3004,7 @@ ${JSON.stringify(input.items)}`,
2977
3004
  const raw = parseJsonLoose(res.text);
2978
3005
  if (!Array.isArray(raw)) {
2979
3006
  throw new Error(
2980
- `ai.contracts.rerank: the model did not return a JSON array. Got: ${res.text.slice(0, 200)}${res.text.length > 200 ? "\u2026" : ""}`
3007
+ `ai.contracts.rerank: the model did not return a JSON array. Got: ${res.text.slice(0, RAW_SNIPPET_CHARS)}${res.text.length > RAW_SNIPPET_CHARS ? "\u2026" : ""}`
2981
3008
  );
2982
3009
  }
2983
3010
  const scored = /* @__PURE__ */ new Map();
@@ -3378,83 +3405,201 @@ var aiConfigSchema = z.object({
3378
3405
  availability: availabilitySchema.optional()
3379
3406
  });
3380
3407
 
3408
+ // src/cost/sinks/upmetrics.ts
3409
+ import { randomUUID } from "crypto";
3410
+
3381
3411
  // src/version.ts
3382
- var VERSION = "0.47.1";
3383
- var SDK_TAG = "@broberg/ai-sdk@0.47.1";
3412
+ var VERSION = "0.49.0";
3413
+ var SDK_TAG = "@broberg/ai-sdk@0.49.0";
3384
3414
 
3385
3415
  // src/cost/sinks/upmetrics.ts
3416
+ function isRetryableStatus(status) {
3417
+ return status === 408 || status === 429 || status >= 500;
3418
+ }
3419
+ var MAX_QUEUE = 30;
3420
+ var MAX_ATTEMPTS = 5;
3421
+ var MAX_BACKOFF_MS = 3e4;
3386
3422
  function upmetricsSink(config) {
3387
3423
  const doFetch = config.fetch ?? fetch;
3388
3424
  const url = `${config.baseUrl.replace(/\/$/, "")}/api/agent`;
3425
+ const retry = config.retry ?? true;
3426
+ const baseMs = config.retryBaseMs ?? 1e3;
3427
+ const queue = [];
3428
+ const counts = { sent: 0, retried: 0, dropped: 0, rejected: 0 };
3429
+ let inFlight = 0;
3430
+ let timer = null;
3431
+ function buildBody(usage) {
3432
+ const startedAt = usage.ts || (/* @__PURE__ */ new Date()).toISOString();
3433
+ const endedAt = new Date(
3434
+ new Date(startedAt).getTime() + (usage.latencyMs || 0)
3435
+ ).toISOString();
3436
+ const agentKind = config.agentKind ?? (usage.capability === "embedding" ? "embedding" : "chatbot");
3437
+ const body = {
3438
+ mode: "record",
3439
+ agent_kind: agentKind,
3440
+ agent_name: config.agentName,
3441
+ provider: usage.provider,
3442
+ model: usage.model,
3443
+ status: "success",
3444
+ input_tokens: usage.inputTokens,
3445
+ output_tokens: usage.outputTokens,
3446
+ cache_read_tokens: usage.cacheReadTokens,
3447
+ cache_creation_tokens: usage.cacheCreationTokens,
3448
+ cost_usd: usage.costUsd,
3449
+ duration_ms: usage.latencyMs,
3450
+ started_at: startedAt,
3451
+ ended_at: endedAt,
3452
+ tags: {
3453
+ // Consumer attribution labels (e.g. tenantId) ride in tags so no new
3454
+ // top-level field risks the strict-shape ingest schema (F011). The
3455
+ // SDK-owned keys win — a label can never clobber capability/transport/sdk.
3456
+ ...usage.labels,
3457
+ capability: usage.capability,
3458
+ transport: usage.transport,
3459
+ // F042: data residency of the route that answered. Rides in tags like
3460
+ // capability/transport — no ingest-schema change, and without it the one
3461
+ // field built for auditability existed only in memory.
3462
+ region: usage.region,
3463
+ // F050: HOW cost_usd was arrived at. upmetrics already distinguishes
3464
+ // reported / computed / unpriced — we were sending an assumed number in
3465
+ // the same field as a measured one, so their labels could not be right
3466
+ // about our rows however carefully they were applied.
3467
+ cost_basis: usage.costBasis ?? "computed",
3468
+ sdk: SDK_TAG,
3469
+ // F061: the receiver's dedupe key. IDENTICAL across every retry of this record
3470
+ // — the body is built once and the same string is resent — and different
3471
+ // between records. Sent on the FIRST attempt too: a first attempt can reach the
3472
+ // server and lose its answer, and the retry must then dedupe against it. Placed
3473
+ // after the labels spread, so a consumer label cannot overwrite it.
3474
+ idempotencyKey: randomUUID()
3475
+ }
3476
+ };
3477
+ if (usage.tier !== void 0) body.tier = usage.tier;
3478
+ if (usage.purpose !== void 0) body.purpose = usage.purpose;
3479
+ if (usage.toolCalls) {
3480
+ body.tool_calls = usage.toolCalls.map((t) => ({
3481
+ name: t.name,
3482
+ count: t.count,
3483
+ error_count: t.errorCount ?? 0
3484
+ }));
3485
+ }
3486
+ void config.complianceMode;
3487
+ return JSON.stringify(body);
3488
+ }
3489
+ async function send(body) {
3490
+ try {
3491
+ const res = await doFetch(url, {
3492
+ method: "POST",
3493
+ headers: {
3494
+ "content-type": "application/json",
3495
+ "X-Upmetrics-Key": config.apiKey
3496
+ },
3497
+ body
3498
+ });
3499
+ if (res.ok) return { kind: "ok" };
3500
+ const text = await res.text().catch(() => "");
3501
+ const err = new Error(`upmetricsSink: ingest returned ${res.status}: ${text.slice(0, 200)}`);
3502
+ return { kind: isRetryableStatus(res.status) ? "retry" : "reject", err };
3503
+ } catch (err) {
3504
+ return { kind: "retry", err };
3505
+ }
3506
+ }
3507
+ function lose(err) {
3508
+ counts.dropped += 1;
3509
+ config.onError?.(err);
3510
+ }
3511
+ function enforceCap() {
3512
+ while (queue.length > MAX_QUEUE) {
3513
+ queue.shift();
3514
+ lose(new Error(`upmetricsSink: retry queue full (${MAX_QUEUE}) \u2014 dropped the oldest pending record`));
3515
+ }
3516
+ }
3517
+ function settle(p, r) {
3518
+ if (r.kind === "ok") {
3519
+ counts.sent += 1;
3520
+ return false;
3521
+ }
3522
+ if (r.kind === "reject") {
3523
+ counts.rejected += 1;
3524
+ config.onError?.(r.err);
3525
+ return false;
3526
+ }
3527
+ if (p.attempts >= MAX_ATTEMPTS) {
3528
+ lose(r.err);
3529
+ return false;
3530
+ }
3531
+ return true;
3532
+ }
3533
+ function schedule() {
3534
+ if (timer || queue.length === 0) return;
3535
+ const head = queue[0];
3536
+ const delay = Math.min(baseMs * 2 ** (head.attempts - 1), MAX_BACKOFF_MS);
3537
+ timer = setTimeout(() => {
3538
+ timer = null;
3539
+ void drainOne();
3540
+ }, delay);
3541
+ timer.unref?.();
3542
+ }
3543
+ async function drainOne() {
3544
+ const p = queue.shift();
3545
+ if (!p) return;
3546
+ inFlight += 1;
3547
+ p.attempts += 1;
3548
+ counts.retried += 1;
3549
+ const r = await send(p.body);
3550
+ inFlight -= 1;
3551
+ if (settle(p, r)) {
3552
+ queue.unshift(p);
3553
+ enforceCap();
3554
+ }
3555
+ schedule();
3556
+ }
3389
3557
  return {
3390
3558
  async record(usage) {
3391
3559
  try {
3392
- const startedAt = usage.ts || (/* @__PURE__ */ new Date()).toISOString();
3393
- const endedAt = new Date(
3394
- new Date(startedAt).getTime() + (usage.latencyMs || 0)
3395
- ).toISOString();
3396
- const agentKind = config.agentKind ?? (usage.capability === "embedding" ? "embedding" : "chatbot");
3397
- const body = {
3398
- mode: "record",
3399
- agent_kind: agentKind,
3400
- agent_name: config.agentName,
3401
- provider: usage.provider,
3402
- model: usage.model,
3403
- status: "success",
3404
- input_tokens: usage.inputTokens,
3405
- output_tokens: usage.outputTokens,
3406
- cache_read_tokens: usage.cacheReadTokens,
3407
- cache_creation_tokens: usage.cacheCreationTokens,
3408
- cost_usd: usage.costUsd,
3409
- duration_ms: usage.latencyMs,
3410
- started_at: startedAt,
3411
- ended_at: endedAt,
3412
- tags: {
3413
- // Consumer attribution labels (e.g. tenantId) ride in tags so no new
3414
- // top-level field risks the strict-shape ingest schema (F011). The
3415
- // SDK-owned keys win — a label can never clobber capability/transport/sdk.
3416
- ...usage.labels,
3417
- capability: usage.capability,
3418
- transport: usage.transport,
3419
- // F042: data residency of the route that answered. Rides in tags like
3420
- // capability/transport — no ingest-schema change, and without it the one
3421
- // field built for auditability existed only in memory.
3422
- region: usage.region,
3423
- // F050: HOW cost_usd was arrived at. upmetrics already distinguishes
3424
- // reported / computed / unpriced — we were sending an assumed number in
3425
- // the same field as a measured one, so their labels could not be right
3426
- // about our rows however carefully they were applied.
3427
- cost_basis: usage.costBasis ?? "computed",
3428
- sdk: SDK_TAG
3429
- }
3430
- };
3431
- if (usage.tier !== void 0) body.tier = usage.tier;
3432
- if (usage.purpose !== void 0) body.purpose = usage.purpose;
3433
- if (usage.toolCalls) {
3434
- body.tool_calls = usage.toolCalls.map((t) => ({
3435
- name: t.name,
3436
- count: t.count,
3437
- error_count: t.errorCount ?? 0
3438
- }));
3560
+ const body = buildBody(usage);
3561
+ const r = await send(body);
3562
+ if (r.kind === "ok") {
3563
+ counts.sent += 1;
3564
+ return;
3439
3565
  }
3440
- void config.complianceMode;
3441
- const res = await doFetch(url, {
3442
- method: "POST",
3443
- headers: {
3444
- "content-type": "application/json",
3445
- "X-Upmetrics-Key": config.apiKey
3446
- },
3447
- body: JSON.stringify(body)
3448
- });
3449
- if (!res.ok) {
3450
- const text = await res.text().catch(() => "");
3451
- config.onError?.(
3452
- new Error(`upmetricsSink: ingest returned ${res.status}: ${text.slice(0, 200)}`)
3453
- );
3566
+ if (r.kind === "reject") {
3567
+ counts.rejected += 1;
3568
+ config.onError?.(r.err);
3569
+ return;
3454
3570
  }
3571
+ if (!retry) {
3572
+ lose(r.err);
3573
+ return;
3574
+ }
3575
+ queue.push({ body, attempts: 1 });
3576
+ enforceCap();
3577
+ schedule();
3455
3578
  } catch (err) {
3456
3579
  config.onError?.(err);
3457
3580
  }
3581
+ },
3582
+ async flush() {
3583
+ if (timer) {
3584
+ clearTimeout(timer);
3585
+ timer = null;
3586
+ }
3587
+ const batch = queue.splice(0);
3588
+ const stillPending = [];
3589
+ for (const p of batch) {
3590
+ inFlight += 1;
3591
+ p.attempts += 1;
3592
+ counts.retried += 1;
3593
+ const r = await send(p.body);
3594
+ inFlight -= 1;
3595
+ if (settle(p, r)) stillPending.push(p);
3596
+ }
3597
+ queue.unshift(...stillPending);
3598
+ enforceCap();
3599
+ schedule();
3600
+ },
3601
+ stats() {
3602
+ return { ...counts, queued: queue.length + inFlight };
3458
3603
  }
3459
3604
  };
3460
3605
  }
@@ -4256,11 +4401,18 @@ CREATE TABLE IF NOT EXISTS ai_usage (
4256
4401
  latency_ms INTEGER NOT NULL,
4257
4402
  subprocess INTEGER NOT NULL DEFAULT 0
4258
4403
  )`;
4404
+ function requireBun(what) {
4405
+ if (typeof globalThis.Bun !== "undefined") return;
4406
+ throw new Error(
4407
+ `${what} requires the Bun runtime: it is backed by bun:sqlite, which Node cannot import (ERR_UNSUPPORTED_ESM_URL_SCHEME). Use upmetricsSink() on Node \u2014 it is the canonical sink. This throws at setup on purpose: the client swallows per-call sink errors, so a sink that can never write would otherwise look exactly like one recording zero spend.`
4408
+ );
4409
+ }
4259
4410
  async function openDb(dbPath, readonly = false) {
4260
4411
  const { Database } = await import("bun:sqlite");
4261
4412
  return new Database(dbPath, readonly ? { readonly: true } : void 0);
4262
4413
  }
4263
4414
  function sqliteSink(config) {
4415
+ requireBun("sqliteSink");
4264
4416
  let ready = null;
4265
4417
  const init = async () => {
4266
4418
  const db = await openDb(config.dbPath);
@@ -4307,6 +4459,7 @@ function sqliteSink(config) {
4307
4459
  };
4308
4460
  }
4309
4461
  async function getCostSummary(dbPath) {
4462
+ requireBun("getCostSummary");
4310
4463
  const db = await openDb(dbPath);
4311
4464
  db.run(CREATE_TABLE);
4312
4465
  const total = db.query(`SELECT SUM(cost_usd) AS total FROM ai_usage`).get();