@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 +97 -9
- package/dist/index.js +212 -67
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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.
|
|
2556
|
-
declare const SDK_TAG: "@broberg/ai-sdk@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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|