@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/README.md +12 -2
- package/dist/{chunk-ZFWSLSE7.js → chunk-SWV5YIEE.js} +27 -2
- package/dist/chunk-SWV5YIEE.js.map +1 -0
- package/dist/index.d.ts +97 -9
- package/dist/index.js +221 -68
- package/dist/index.js.map +1 -1
- package/dist/registry.d.ts +9 -0
- package/dist/registry.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-ZFWSLSE7.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
resolveModel,
|
|
9
9
|
resolveTier,
|
|
10
10
|
setAvailability
|
|
11
|
-
} from "./chunk-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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.
|
|
3383
|
-
var SDK_TAG = "@broberg/ai-sdk@0.
|
|
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
|
|
3393
|
-
const
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
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
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
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();
|