@broberg/ai-sdk 0.48.0 → 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.d.ts CHANGED
@@ -713,6 +713,19 @@ interface ClassifyInput {
713
713
  labels: string[];
714
714
  tier?: Tier;
715
715
  purpose?: string;
716
+ /** What to do when the model's reply contains no JSON at all (F059).
717
+ *
718
+ * `"throw"` (the DEFAULT, and unchanged) is right in product use: a refusal or an
719
+ * outage is not a classification, and a throw is the loudest honest answer.
720
+ *
721
+ * `"value"` is for MEASURING. Requested by trail with the measurement behind it:
722
+ * over 444 golden examples in one batch, a throw at example 212 is not informative,
723
+ * it is destructive — the 232 that were never measured afterwards look like they
724
+ * did not exist. Their alternative was to wrap every call in try/catch and count
725
+ * the throws, which is this field built by hand, worse.
726
+ *
727
+ * Opt-in on purpose: the caller who sets it is the caller who is reading for it. */
728
+ onUnparseable?: "throw" | "value";
716
729
  }
717
730
  interface ClassifyResult {
718
731
  /** The chosen label, or `null` when the model named a label that is not in `labels`
@@ -728,15 +741,55 @@ interface ClassifyResult {
728
741
  * an autonomy level off this field — an unclassifiable ticket therefore landed on the
729
742
  * tenant's first intent, chosen by the order of a config array, with no error and no
730
743
  * log trace. `null` is the whole fix: there is no field to hardcode, because the
731
- * absence IS the signal. */
744
+ * absence IS the signal.
745
+ *
746
+ * **Expect `null` routinely since F060 (23 September 2026)** — the prompt now
747
+ * explicitly lets the model say none of the labels fit. Before that it could not,
748
+ * and trail measured the cost: 34 of 38 honest refusals came back as confident
749
+ * WRONG labels. How OFTEN `null` now occurs under this exact prompt is not yet
750
+ * measured (F060.2) — expect it to be common, not rare. If you route anything
751
+ * automatic off this field, send `null` to a human. */
732
752
  label: string | null;
733
753
  /** The model's own answer when it did not match, so a caller can log or route what
734
- * actually came back instead of only knowing that something did not. */
754
+ * actually came back instead of only knowing that something did not.
755
+ *
756
+ * **It can contain RAW model output** — on the `unparseable` path it always does
757
+ * (the first 200 characters of the reply), and on `out-of-set` it does whenever the
758
+ * reply had no usable `label` string. A model that echoes part of your prompt can
759
+ * therefore put part of YOUR INPUT here. On a path carrying personal or health data,
760
+ * treat this field with the same care as the input before you log it. */
735
761
  rawLabel?: string;
736
762
  /** `null` when the model reported no confidence. `0` is a REAL confidence and stays
737
763
  * `0` — the two used to be the same number, which made the field unusable as a
738
764
  * signal even for a caller who wanted to check it. */
739
765
  confidence: number | null;
766
+ /** WHICH of the three things happened, as a value you must read rather than a shape
767
+ * you might infer (F059).
768
+ *
769
+ * `"answered"` the model named a label from `labels`; `label` is it.
770
+ * `"out-of-set"` it named something else; `label` is null, `rawLabel` is its answer.
771
+ * `"unparseable"` we could not read the reply. Only reachable with
772
+ * `onUnparseable: "value"` — the default still throws. It covers
773
+ * BOTH ways parsing fails: no JSON in the reply at all, AND JSON
774
+ * that is present but malformed or truncated. Said explicitly
775
+ * because you are going to COUNT this, and a count that quietly
776
+ * includes a category the docs deny is a wrong number that reads
777
+ * as a right one.
778
+ *
779
+ * It is NOT decoration. Once the throw is optional, `out-of-set` and `unparseable`
780
+ * both yield `label: null` with `rawLabel` set, so without this field they cannot be
781
+ * told apart — and telling them apart ("got it wrong" vs "did not answer" vs "could
782
+ * not be read") is the whole reason the flag was asked for.
783
+ *
784
+ * The form is components' argument, not ours: a boolean like `fallbackUsed` can be
785
+ * destructured away as easily as a `confidence` field can be ignored, while a value
786
+ * you must read to proceed cannot. `answered` ⟺ `label !== null`.
787
+ *
788
+ * REQUIRED, not optional — additive for anyone READING a result, a compile fix for
789
+ * anyone CONSTRUCTING one (a test stub or mock of `classify`). Saying it rather than
790
+ * calling the change "purely additive": this package has shipped that exact
791
+ * over-claim before, about `toolCall.arguments`, and it was wrong then. */
792
+ outcome: "answered" | "out-of-set" | "unparseable";
740
793
  usage: Usage;
741
794
  }
742
795
  interface RerankInput {
@@ -2268,7 +2321,6 @@ interface AiClient {
2268
2321
 
2269
2322
  declare function createAI(config?: AiConfig): AiClient;
2270
2323
 
2271
- /** Pull the first JSON value out of a model reply (tolerates ```json fences + prose). */
2272
2324
  declare function parseJsonLoose(text: string): unknown;
2273
2325
  type ChatVision = Pick<AiClient, "chat" | "vision">;
2274
2326
  /** F052.2 — resolve the model's answer to one of the CALLER's labels, or to null.
@@ -2552,8 +2604,8 @@ declare const falStubAdapter: ProviderAdapter;
2552
2604
  * wires the live adapters. */
2553
2605
  declare const stubProviders: Record<string, ProviderAdapter>;
2554
2606
 
2555
- declare const VERSION: "0.48.0";
2556
- declare const SDK_TAG: "@broberg/ai-sdk@0.48.0";
2607
+ declare const VERSION: "0.49.0";
2608
+ declare const SDK_TAG: "@broberg/ai-sdk@0.49.0";
2557
2609
 
2558
2610
  /** Built-in defaults. Every entry is overridable via AiConfig.defaults or a
2559
2611
  * per-call override.
@@ -2758,10 +2810,46 @@ interface UpmetricsSinkConfig {
2758
2810
  complianceMode?: boolean;
2759
2811
  /** Injectable fetch for testing; defaults to global fetch. */
2760
2812
  fetch?: typeof fetch;
2761
- /** Optional error hook (errors are otherwise swallowed silently). */
2813
+ /** Called when a record is actually LOST (dropped) or REFUSED (rejected) — not on a
2814
+ * transient failure that is still being retried. See F061 below. */
2762
2815
  onError?: (err: unknown) => void;
2763
- }
2764
- declare function upmetricsSink(config: UpmetricsSinkConfig): CostSink;
2816
+ /** Retry transient failures in the background (F061). Default `true`.
2817
+ *
2818
+ * SAFE ONLY IF THIS SINK'S RECEIVER DEDUPLICATES on `tags.idempotencyKey`. That is
2819
+ * a property of your configuration, not of this package: upmetrics does (live since
2820
+ * 2026-09-08, measured by them — two deliveries of one payload → one row). If you
2821
+ * point `baseUrl` at something that does not, a retry trades a loss for a double
2822
+ * count there — set `retry: false`. We do not guess from the hostname: a sink that
2823
+ * takes a baseUrl cannot have its behaviour decided by a name. */
2824
+ retry?: boolean;
2825
+ /** Base backoff in ms (doubles per attempt, capped at 30 s). For tests. */
2826
+ retryBaseMs?: number;
2827
+ }
2828
+ /** What the sink has done with what it was given — "we lost nothing" and "we do not
2829
+ * know whether we lost anything" are different statements, and only a count can make
2830
+ * the first one. In-process: it resets on deploy, so a zero is a claim about uptime,
2831
+ * not about history (trail's caveat, and it is right). */
2832
+ interface UpmetricsSinkStats {
2833
+ /** Accepted by the receiver. */
2834
+ sent: number;
2835
+ /** Retry ATTEMPTS made (not records). */
2836
+ retried: number;
2837
+ /** Given up on: attempts exhausted, or pushed out of a full queue. LOST. */
2838
+ dropped: number;
2839
+ /** Permanently refused by the receiver (4xx other than 408/429). Not retried. */
2840
+ rejected: number;
2841
+ /** Waiting for a retry right now, including one in flight. */
2842
+ queued: number;
2843
+ }
2844
+ interface UpmetricsSink extends CostSink {
2845
+ /** Try everything waiting, once, now. Call before exit in a SHORT-LIVED process
2846
+ * (a script, a serverless function): the retry timer is unref'd so it never holds
2847
+ * a process open, which also means it will not finish on its own before exit.
2848
+ * Whatever still fails stays queued — check `stats().queued` afterwards. */
2849
+ flush(): Promise<void>;
2850
+ stats(): UpmetricsSinkStats;
2851
+ }
2852
+ declare function upmetricsSink(config: UpmetricsSinkConfig): UpmetricsSink;
2765
2853
 
2766
2854
  interface DiscordSinkConfig {
2767
2855
  webhookUrl: string;
@@ -3027,4 +3115,4 @@ interface StreamTransportRequest extends TransportRequest {
3027
3115
  */
3028
3116
  declare function streamTransport(req: StreamTransportRequest): AsyncIterable<string>;
3029
3117
 
3030
- export { AZURE_DANISH_VOICES, AZURE_DANISH_VOICE_LIST, type AiClient, type AiConfig, type AlignedWordTimings, type AzureVoiceInfo, type AzureWordBoundary, type BatchJob, type BatchRequestItem, type BatchResultItem, type BflAdapterConfig, type BflCredits, type BudgetConfig, BudgetExceededError, BudgetGuard, type BudgetStore, CONFIG_DERIVED_HOSTS, type CallOptions, type Capability, type ChatInput, type ChatRequest, type ChatResult, type ChatStreamEvent, type CheckVoiceOptions, type ClassifyInput, type ClassifyResult, type ContentPart, type Contracts, type CostQuery, type CostSink, type CostSummary, type CostSummaryQuery, type CostTimeseriesQuery, DEFAULT_BASE_URLS, DEFAULT_TIER_MAP, type DesignInput, type DesignResult, type DialogueRequest, type DialogueTurn, type DiscordSinkConfig, ELEVENLABS_DANISH_VOICES, type EmbeddingInput, type EmbeddingRequest, type EmbeddingResult, type ExtractInput, type ExtractResult, type FalAdapterConfig, type FixedHostProvider, type HttpResponse, type ImageInput, type ImageRequest, type ImageResult, LOCALLY_PINNED_HOSTS, type LoraWeight, type Message, type MockupInput, type MockupResult, type ModerationInput, type ModerationItem, type ModerationRequest, type ModerationResult, type OcrInput, type OcrPage, type OcrRequest, type OcrResult, type OpenAICompatibleConfig, type PodcastInput, type PodcastResult, type PricingEntry, type ProviderAdapter, type RefreshOptions, type RefreshResult, type Region, type RerankInput, type RerankResult, type Role, type RouteForecast, SDK_TAG, type SqliteBudgetStoreConfig, type SqliteSinkConfig, StreamHttpError, type SubprocessResponse, type Tier, type TierSpec, type Tool, type ToolCall, type TrainStyleInput, type TrainStyleRequest, type TrainStyleResult, type TranscribeInput, type TranscribeRequest, type TranscribeResult, type TranslateInput, type TranslateResult, type Transport, type TransportRequest, type TransportResponse, type TtsInput, type TtsRequest, type UpmetricsCostClientConfig, UpmetricsCostError, type UpmetricsCostRow, type UpmetricsCostSummary, type UpmetricsCostTimeseries, type UpmetricsSinkConfig, type Usage, VERSION, type VideoInput, type VisionInput, type VoiceInfo, type VoiceProvider, type VoiceResolveResult, type VoiceStatus, VoiceUnavailableError, type WordTiming, aiConfigSchema, alignWordTimings, anthropicAdapter, anthropicApiAdapter, anthropicSubprocessAdapter, azureAdapter, bflAdapter, bflCredits, chatInputSchema, checkVoice, classifyRegionName, computeCost, createAI, deepinfraAdapter, deeplAdapter, deepseekAdapter, defaultBaseUrl, defaultProviders, discordSink, elevenlabsAdapter, embeddingInputSchema, falAdapter, falStubAdapter, freshUsage, fromProviderToolCall, geminiAdapter, getCostSummary, getPrice, httpTransport, imageInputSchema, listAzureDanishVoices, listVoices, makeContracts, makeOpenAICompatibleAdapter, matchLabel, messageSchema, mistralAdapter, mistralStubAdapter, multiSink, noopSink, openaiAdapter, openaiStubAdapter, openrouterAdapter, parseClaudeCliJson, parseJsonLoose, refreshAvailability, regionOfHost, regionOfProvider, requestyAdapter, resetRefreshState, resetRegistry, resolveAzureVoice, resolveTier, resolveVoice, sqliteBudgetStore, sqliteSink, streamTransport, stubProviders, subprocessTransport, tierSpecSchema, toProviderTools, toolSchema, translateInputSchema, upmetricsCostClient, upmetricsSink, usdFromMicro, vertexAdapter, visionInputSchema, wouldProviderRouteTo, wouldRouteTo };
3118
+ export { AZURE_DANISH_VOICES, AZURE_DANISH_VOICE_LIST, type AiClient, type AiConfig, type AlignedWordTimings, type AzureVoiceInfo, type AzureWordBoundary, type BatchJob, type BatchRequestItem, type BatchResultItem, type BflAdapterConfig, type BflCredits, type BudgetConfig, BudgetExceededError, BudgetGuard, type BudgetStore, CONFIG_DERIVED_HOSTS, type CallOptions, type Capability, type ChatInput, type ChatRequest, type ChatResult, type ChatStreamEvent, type CheckVoiceOptions, type ClassifyInput, type ClassifyResult, type ContentPart, type Contracts, type CostQuery, type CostSink, type CostSummary, type CostSummaryQuery, type CostTimeseriesQuery, DEFAULT_BASE_URLS, DEFAULT_TIER_MAP, type DesignInput, type DesignResult, type DialogueRequest, type DialogueTurn, type DiscordSinkConfig, ELEVENLABS_DANISH_VOICES, type EmbeddingInput, type EmbeddingRequest, type EmbeddingResult, type ExtractInput, type ExtractResult, type FalAdapterConfig, type FixedHostProvider, type HttpResponse, type ImageInput, type ImageRequest, type ImageResult, LOCALLY_PINNED_HOSTS, type LoraWeight, type Message, type MockupInput, type MockupResult, type ModerationInput, type ModerationItem, type ModerationRequest, type ModerationResult, type OcrInput, type OcrPage, type OcrRequest, type OcrResult, type OpenAICompatibleConfig, type PodcastInput, type PodcastResult, type PricingEntry, type ProviderAdapter, type RefreshOptions, type RefreshResult, type Region, type RerankInput, type RerankResult, type Role, type RouteForecast, SDK_TAG, type SqliteBudgetStoreConfig, type SqliteSinkConfig, StreamHttpError, type SubprocessResponse, type Tier, type TierSpec, type Tool, type ToolCall, type TrainStyleInput, type TrainStyleRequest, type TrainStyleResult, type TranscribeInput, type TranscribeRequest, type TranscribeResult, type TranslateInput, type TranslateResult, type Transport, type TransportRequest, type TransportResponse, type TtsInput, type TtsRequest, type UpmetricsCostClientConfig, UpmetricsCostError, type UpmetricsCostRow, type UpmetricsCostSummary, type UpmetricsCostTimeseries, type UpmetricsSink, type UpmetricsSinkConfig, type UpmetricsSinkStats, type Usage, VERSION, type VideoInput, type VisionInput, type VoiceInfo, type VoiceProvider, type VoiceResolveResult, type VoiceStatus, VoiceUnavailableError, type WordTiming, aiConfigSchema, alignWordTimings, anthropicAdapter, anthropicApiAdapter, anthropicSubprocessAdapter, azureAdapter, bflAdapter, bflCredits, chatInputSchema, checkVoice, classifyRegionName, computeCost, createAI, deepinfraAdapter, deeplAdapter, deepseekAdapter, defaultBaseUrl, defaultProviders, discordSink, elevenlabsAdapter, embeddingInputSchema, falAdapter, falStubAdapter, freshUsage, fromProviderToolCall, geminiAdapter, getCostSummary, getPrice, httpTransport, imageInputSchema, listAzureDanishVoices, listVoices, makeContracts, makeOpenAICompatibleAdapter, matchLabel, messageSchema, mistralAdapter, mistralStubAdapter, multiSink, noopSink, openaiAdapter, openaiStubAdapter, openrouterAdapter, parseClaudeCliJson, parseJsonLoose, refreshAvailability, regionOfHost, regionOfProvider, requestyAdapter, resetRefreshState, resetRegistry, resolveAzureVoice, resolveTier, resolveVoice, sqliteBudgetStore, sqliteSink, streamTransport, stubProviders, subprocessTransport, tierSpecSchema, toProviderTools, toolSchema, translateInputSchema, upmetricsCostClient, upmetricsSink, usdFromMicro, vertexAdapter, visionInputSchema, wouldProviderRouteTo, wouldRouteTo };
package/dist/index.js CHANGED
@@ -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.48.0";
3383
- var SDK_TAG = "@broberg/ai-sdk@0.48.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 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
  }