@stigg/agent-sdk-js 1.0.1 → 2.0.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.
Files changed (37) hide show
  1. package/README.md +135 -43
  2. package/dist/bindings/client/gateways/bedrock/bedrock-recipe.d.ts +17 -0
  3. package/dist/bindings/client/gateways/bedrock/bedrock-recipe.js +134 -0
  4. package/dist/bindings/client/gateways/bedrock/types.d.ts +13 -0
  5. package/dist/bindings/client/gateways/bedrock/types.js +2 -0
  6. package/dist/bindings/client/gateways/openrouter/openrouter-recipe.d.ts +5 -0
  7. package/dist/bindings/client/gateways/openrouter/openrouter-recipe.js +137 -34
  8. package/dist/bindings/client/gateways/openrouter/sdk-usage.d.ts +37 -0
  9. package/dist/bindings/client/gateways/openrouter/sdk-usage.js +63 -0
  10. package/dist/bindings/client/gateways/openrouter/types.d.ts +49 -0
  11. package/dist/bindings/client/providers/anthropic/anthropic-recipe.d.ts +5 -1
  12. package/dist/bindings/client/providers/anthropic/anthropic-recipe.js +103 -13
  13. package/dist/bindings/shared/token-usage.d.ts +30 -1
  14. package/dist/bindings/shared/token-usage.js +58 -3
  15. package/dist/bindings/shared/types.d.ts +16 -0
  16. package/dist/bindings/shared/weighted-metrics.js +5 -2
  17. package/dist/config.d.ts +26 -17
  18. package/dist/config.js +29 -27
  19. package/dist/core/context.d.ts +21 -12
  20. package/dist/core/context.js +76 -9
  21. package/dist/core/session.d.ts +38 -9
  22. package/dist/core/session.js +57 -12
  23. package/dist/decision-text.d.ts +3 -3
  24. package/dist/engine/decision.d.ts +11 -6
  25. package/dist/engine/decision.js +9 -2
  26. package/dist/engine/report.js +4 -1
  27. package/dist/errors.d.ts +14 -6
  28. package/dist/errors.js +13 -3
  29. package/dist/headers.d.ts +13 -0
  30. package/dist/headers.js +20 -1
  31. package/dist/index.d.ts +8 -5
  32. package/dist/index.js +8 -2
  33. package/dist/stigg-agent-sdk.d.ts +29 -13
  34. package/dist/stigg-agent-sdk.js +103 -56
  35. package/dist/types.d.ts +124 -35
  36. package/dist/types.js +66 -16
  37. package/package.json +6 -3
@@ -23,16 +23,33 @@ exports.openrouterRecipe = openrouterRecipe;
23
23
  const meter_stream_1 = require("../../../shared/meter-stream");
24
24
  const openai_recipe_1 = require("../../providers/openai/openai-recipe");
25
25
  const token_usage_1 = require("../../../shared/token-usage");
26
+ const registry_1 = require("../../core/registry");
27
+ const sdk_usage_1 = require("./sdk-usage");
26
28
  /** Float noise: 0.00108 * 1e6 is 1080.0000000000002, and ceil would bill the extra unit. */
27
29
  const toUnits = (cost, unitsPerCredit) => Math.max(1, Math.ceil(Number((cost * unitsPerCredit).toPrecision(12))));
28
30
  /**
29
31
  * Tokens and cost, from one response.
30
32
  *
31
33
  * A gateway states both, they are different features, and reporting both is what lets one meter
32
- * the tokens and the other the money. A streamed call simply carries no `cost` - the header went
33
- * out before the first token existed - so the key is absent rather than substituted.
34
+ * the tokens and the other the money.
35
+ *
36
+ * OpenRouter writes the money in the body, which is why a streamed call can still be billed on it:
37
+ * the usage object arrives on the last chunk, cost included, provided the request asked for usage
38
+ * (`stream_options.include_usage`, or `streamOptions.includeUsage` through the first-party SDK).
39
+ * A caller who does not ask gets a stream that states nothing, and the key is then absent rather
40
+ * than substituted - billing a guess is the one thing a cost-truth adapter must not do. LiteLLM is
41
+ * the gateway that genuinely cannot state it, because its cost rides a header that went out before
42
+ * the first token existed.
34
43
  */
35
- function measure(usage, parse, keys, model, id, options) {
44
+ function measure(usage, parse, keys, model, id, options,
45
+ /**
46
+ * What the vendor itself stated, when that is not the object being read.
47
+ *
48
+ * The first-party SDK hands back camelCased counters, which are translated to the wire's own
49
+ * spelling before anything meters them - but an event should carry what OpenRouter said, not
50
+ * what this file rewrote it to, so the original rides along unchanged.
51
+ */
52
+ stated) {
36
53
  if (!usage)
37
54
  return undefined;
38
55
  const cost = usage.cost;
@@ -45,15 +62,76 @@ function measure(usage, parse, keys, model, id, options) {
45
62
  usage: parse(usage),
46
63
  // What the gateway stated, before `cost` was scaled into whole units - an event can carry the
47
64
  // fraction as it was, which is the honest number and the one that reconciles with the bill.
48
- raw: usage,
65
+ raw: stated ?? usage,
49
66
  // The gateway's own request id: stable across its retries, which a local step counter is not.
50
67
  dimensions: model ? { [options.modelDimension]: model } : undefined,
51
68
  idempotencyKey: id,
52
69
  };
53
70
  }
71
+ /**
72
+ * The first-party SDK's two surfaces, built the same way the OpenAI-shaped ones are.
73
+ *
74
+ * `@openrouter/sdk` is generated from the OpenAPI spec, so it shares nothing with `openai-node`
75
+ * but the service behind it: the method is `chat.send` rather than `chat.completions.create`, the
76
+ * request nests under `chatRequest`, and the counters come back camelCased. Only the last of
77
+ * those reaches a meter, and `sdk-usage.ts` undoes it so both clients report the same metric
78
+ * names for the same call.
79
+ *
80
+ * `responses.send` is here for the reason the comment on `methods` gives: a surface this recipe
81
+ * declines to declare is not metered by something else, it is simply ungated - and a client this
82
+ * recipe claims to govern must not have a billable method left open on it.
83
+ */
84
+ function sdkSurfaces(resolved) {
85
+ const surfaces = [
86
+ { path: ['chat', 'send'], read: sdk_usage_1.fromSdkChatUsage, parse: token_usage_1.fromOpenAIUsage, keys: token_usage_1.METRIC_PATHS.openaiChat },
87
+ {
88
+ path: ['responses', 'send'],
89
+ read: sdk_usage_1.fromSdkResponsesUsage,
90
+ parse: token_usage_1.fromResponsesUsage,
91
+ keys: token_usage_1.METRIC_PATHS.openaiResponses,
92
+ },
93
+ ];
94
+ return surfaces.map(({ path, read, parse, keys }) => {
95
+ /** The vendor's own object translated, with the original kept for the event to carry. */
96
+ const measureSdk = (usage, model, id) => measure(read(usage), parse, keys, model, id, resolved, usage);
97
+ return {
98
+ path: [...path],
99
+ estimate: resolved.estimate,
100
+ extractUsage: (result) => measureSdk(result?.usage, result?.model, result?.id),
101
+ // A streamed call resolves to an `EventStream`, which is a `ReadableStream` subclass: async
102
+ // iterable, and with no `next`/`return` of its own for `meterStream` to patch. So the
103
+ // metered path is `for await`, which is how the SDK documents consuming one. A caller who
104
+ // takes `getReader()` instead bypasses the wrapper - the same hole every streaming binding
105
+ // has, and the reason the non-streamed surface is the one the example leans on.
106
+ attachStream: (stream, ingest) => {
107
+ let usage;
108
+ let model;
109
+ let id;
110
+ (0, meter_stream_1.meterStream)(stream, (chunk) => {
111
+ // Usage arrives once, on the last chunk; `model` and `id` ride every one of them.
112
+ if (chunk?.usage)
113
+ usage = chunk.usage;
114
+ if (chunk?.model)
115
+ model = chunk.model;
116
+ if (chunk?.id)
117
+ id = chunk.id;
118
+ }, () => {
119
+ const measurement = measureSdk(usage, model, id);
120
+ if (measurement)
121
+ ingest(measurement);
122
+ });
123
+ },
124
+ };
125
+ });
126
+ }
54
127
  /**
55
128
  * One recipe covers every upstream. OpenRouter normalizes Anthropic, OpenAI and the rest to the
56
129
  * same wire shape, so the response never changes form - `model` is just a field saying who served.
130
+ *
131
+ * It also covers both ways of reaching OpenRouter from Node: the OpenAI SDK pointed at the
132
+ * gateway's base URL, and OpenRouter's own `@openrouter/sdk`. Those are different objects with
133
+ * different method names reporting the same call, and a recipe that governed only the first would
134
+ * meter nothing at all for an application that took OpenRouter's own documented advice.
57
135
  */
58
136
  function openrouterRecipe(options = {}) {
59
137
  const resolved = {
@@ -66,39 +144,43 @@ function openrouterRecipe(options = {}) {
66
144
  // The OpenAI shape it speaks, so the OpenAI name for the total.
67
145
  totalTokensMetric: token_usage_1.TOTAL_TOKENS,
68
146
  vendor: 'openrouter',
69
- // Shape alone cannot separate this from any other OpenAI-compatible client, so the base URL
70
- // is the discriminator - it is the one thing that says which gateway is on the other end.
71
- matches: (client) => openai_recipe_1.openAIRecipe.matches(client) && isOpenRouter(client),
147
+ // Two clients, recognised two different ways - see each guard for why they cannot share one.
148
+ // The OpenAI-shaped client is OpenAI-shaped, so only its base URL says which gateway it talks
149
+ // to; OpenRouter's own SDK is unmistakable by shape and may legitimately point anywhere.
150
+ matches: (client) => (openai_recipe_1.openAIRecipe.matches(client) && isOpenRouter(client)) || isOpenRouterSdk(client),
72
151
  // Built from the OpenAI recipe's own surfaces. A gateway speaks that API, so the methods worth
73
152
  // governing are the same ones - and because this recipe is matched ahead of it, any surface it
74
153
  // failed to declare would be left ungated rather than falling through.
75
- methods: openai_recipe_1.openAIRecipe.methods.map((base) => {
76
- const { parse, keys, streamed } = (0, openai_recipe_1.openAISurface)(base.path);
77
- return {
78
- path: base.path,
79
- estimate: resolved.estimate,
80
- extractUsage: (result) => measure(result?.usage, parse, keys, result?.model, result?.id, resolved),
81
- // Usage arrives once, at the end; `model` and `id` ride every chunk.
82
- attachStream: (stream, ingest) => {
83
- let usage;
84
- let model;
85
- let id;
86
- (0, meter_stream_1.meterStream)(stream, (chunk) => {
87
- const seen = streamed(chunk);
88
- if (seen)
89
- usage = seen;
90
- if (chunk?.model)
91
- model = chunk.model;
92
- if (chunk?.id)
93
- id = chunk.id;
94
- }, () => {
95
- const measurement = measure(usage, parse, keys, model, id, resolved);
96
- if (measurement)
97
- ingest(measurement);
98
- });
99
- },
100
- };
101
- }),
154
+ methods: [
155
+ ...openai_recipe_1.openAIRecipe.methods.map((base) => {
156
+ const { parse, keys, streamed } = (0, openai_recipe_1.openAISurface)(base.path);
157
+ return {
158
+ path: base.path,
159
+ estimate: resolved.estimate,
160
+ extractUsage: (result) => measure(result?.usage, parse, keys, result?.model, result?.id, resolved),
161
+ // Usage arrives once, at the end; `model` and `id` ride every chunk.
162
+ attachStream: (stream, ingest) => {
163
+ let usage;
164
+ let model;
165
+ let id;
166
+ (0, meter_stream_1.meterStream)(stream, (chunk) => {
167
+ const seen = streamed(chunk);
168
+ if (seen)
169
+ usage = seen;
170
+ if (chunk?.model)
171
+ model = chunk.model;
172
+ if (chunk?.id)
173
+ id = chunk.id;
174
+ }, () => {
175
+ const measurement = measure(usage, parse, keys, model, id, resolved);
176
+ if (measurement)
177
+ ingest(measurement);
178
+ });
179
+ },
180
+ };
181
+ }),
182
+ ...sdkSurfaces(resolved),
183
+ ],
102
184
  };
103
185
  }
104
186
  /**
@@ -115,6 +197,27 @@ function isOpenRouter(client) {
115
197
  const host = hostOf(baseUrl);
116
198
  return host === 'openrouter.ai' || host.endsWith('.openrouter.ai');
117
199
  }
200
+ /**
201
+ * Whether this is OpenRouter's own SDK client, which carries no base URL to recognise it by.
202
+ *
203
+ * The OpenAI-shaped client is identified by where it points, because its shape says only
204
+ * "OpenAI-compatible" and half the industry is. This one is the opposite: `chat.send` is not a
205
+ * shape anyone else has, and the namespaces beside it - `credits`, `generations`, the gateway's
206
+ * own billing and routing APIs - are OpenRouter's product rather than an API style someone could
207
+ * implement by accident. So shape is the whole discriminator here, and there is no URL to check:
208
+ * an application may point the SDK at a proxy, which is still OpenRouter's SDK and still speaks
209
+ * its format.
210
+ *
211
+ * `in` rather than a property read, because every namespace on that client is a lazy getter that
212
+ * constructs a sub-client - and merely asking whether a recipe fits must not build three of them.
213
+ */
214
+ function isOpenRouterSdk(client) {
215
+ if (!client || typeof client !== 'object')
216
+ return false;
217
+ if (!(0, registry_1.hasMethod)(client, ['chat', 'send']) && !(0, registry_1.hasMethod)(client, ['responses', 'send']))
218
+ return false;
219
+ return 'credits' in client && 'generations' in client;
220
+ }
118
221
  /** The hostname, lowercased, or '' when the URL is not one this can reason about. */
119
222
  function hostOf(url) {
120
223
  try {
@@ -0,0 +1,37 @@
1
+ /**
2
+ * OpenRouter's first-party SDK counts the same tokens under different names.
3
+ *
4
+ * `@openrouter/sdk` is generated from the OpenAPI spec and camelCases every field on the way in,
5
+ * so a response that arrived as `prompt_tokens` reaches the caller as `promptTokens`. Nothing
6
+ * about the call changed - same gateway, same wire format, same bill - only the spelling the
7
+ * parse handed back.
8
+ *
9
+ * That matters more than it looks. A metric name is a **feature id**: it is what the mapper
10
+ * prices and what Stigg meters. If the same gateway reported `prompt_tokens` through one client
11
+ * and `promptTokens` through another, one customer's calls would land on two different features
12
+ * depending on which SDK the application happened to import - and the second of them exists in
13
+ * nobody's catalog, so those calls would report to a feature that cannot be billed.
14
+ *
15
+ * So the spelling is undone here rather than carried through: each reader below returns the
16
+ * OpenAI-shaped object the wire actually carried, and everything downstream - `pickMetrics`,
17
+ * `fromOpenAIUsage`, the metric names in `GATEWAY_METRICS` - is the code that already serves the
18
+ * OpenAI-shaped client, reading the names it already knows. The vendor's own object is still what
19
+ * rides along as `raw`, so an event carries what OpenRouter said, spelled the way OpenRouter
20
+ * spelled it.
21
+ */
22
+ import type { OpenRouterSdkChatUsage, OpenRouterSdkResponsesUsage, OpenRouterUsage } from './types';
23
+ /**
24
+ * `chat.send` - the Chat Completions shape, camelCased.
25
+ *
26
+ * `cost` keeps its name across both spellings, because it is OpenRouter's own field rather than
27
+ * one inherited from OpenAI, and the generator had no snake_case original to convert.
28
+ */
29
+ export declare function fromSdkChatUsage(usage: OpenRouterSdkChatUsage | null | undefined): OpenRouterUsage | undefined;
30
+ /**
31
+ * `responses.send` - the Responses API shape, camelCased.
32
+ *
33
+ * The same tokens again under the third set of names this SDK can hand back: `inputTokens` rather
34
+ * than `promptTokens`, with the cache detail one level along. Read with the chat reader it would
35
+ * find none of them and meter the call at zero, which is why the two surfaces get a reader each.
36
+ */
37
+ export declare function fromSdkResponsesUsage(usage: OpenRouterSdkResponsesUsage | null | undefined): OpenRouterUsage | undefined;
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.fromSdkChatUsage = fromSdkChatUsage;
4
+ exports.fromSdkResponsesUsage = fromSdkResponsesUsage;
5
+ /** Drops what the SDK models as nullable, so an absent counter stays absent rather than null. */
6
+ const num = (value) => (typeof value === 'number' ? value : undefined);
7
+ /**
8
+ * `{ key: value }` when there is a value, and nothing at all when there is not.
9
+ *
10
+ * Absent and zero are different: `pickMetrics` skips a counter that is not a positive number, and
11
+ * a nested detail object that exists with nothing in it reads as a vendor that reported a cache
12
+ * and found none - which is not the same as a vendor that reported no cache at all.
13
+ */
14
+ const maybe = (key, value) => value === undefined ? {} : { [key]: value };
15
+ /** Nothing, rather than an empty object, when a whole detail group was absent. */
16
+ const group = (fields) => Object.keys(fields).length > 0 ? fields : undefined;
17
+ /**
18
+ * `chat.send` - the Chat Completions shape, camelCased.
19
+ *
20
+ * `cost` keeps its name across both spellings, because it is OpenRouter's own field rather than
21
+ * one inherited from OpenAI, and the generator had no snake_case original to convert.
22
+ */
23
+ function fromSdkChatUsage(usage) {
24
+ if (!usage)
25
+ return undefined;
26
+ const promptDetails = group({
27
+ ...maybe('cached_tokens', num(usage.promptTokensDetails?.cachedTokens)),
28
+ ...maybe('cache_write_tokens', num(usage.promptTokensDetails?.cacheWriteTokens)),
29
+ });
30
+ const completionDetails = group({
31
+ ...maybe('reasoning_tokens', num(usage.completionTokensDetails?.reasoningTokens)),
32
+ });
33
+ return {
34
+ ...maybe('prompt_tokens', num(usage.promptTokens)),
35
+ ...maybe('completion_tokens', num(usage.completionTokens)),
36
+ ...maybe('total_tokens', num(usage.totalTokens)),
37
+ ...maybe('cost', num(usage.cost)),
38
+ ...maybe('prompt_tokens_details', promptDetails),
39
+ ...maybe('completion_tokens_details', completionDetails),
40
+ };
41
+ }
42
+ /**
43
+ * `responses.send` - the Responses API shape, camelCased.
44
+ *
45
+ * The same tokens again under the third set of names this SDK can hand back: `inputTokens` rather
46
+ * than `promptTokens`, with the cache detail one level along. Read with the chat reader it would
47
+ * find none of them and meter the call at zero, which is why the two surfaces get a reader each.
48
+ */
49
+ function fromSdkResponsesUsage(usage) {
50
+ if (!usage)
51
+ return undefined;
52
+ const inputDetails = group({
53
+ ...maybe('cached_tokens', num(usage.inputTokensDetails?.cachedTokens)),
54
+ ...maybe('cache_write_tokens', num(usage.inputTokensDetails?.cacheWriteTokens)),
55
+ });
56
+ return {
57
+ ...maybe('input_tokens', num(usage.inputTokens)),
58
+ ...maybe('output_tokens', num(usage.outputTokens)),
59
+ ...maybe('total_tokens', num(usage.totalTokens)),
60
+ ...maybe('cost', num(usage.cost)),
61
+ ...maybe('input_tokens_details', inputDetails),
62
+ };
63
+ }
@@ -2,6 +2,55 @@ import type { OpenAILikeUsage } from '../../../shared/types';
2
2
  /** OpenRouter extends the OpenAI usage object with the credits it actually charged. */
3
3
  export type OpenRouterUsage = OpenAILikeUsage & {
4
4
  cost?: number | null;
5
+ /**
6
+ * The Responses-API counters, for the one client that can report either shape.
7
+ *
8
+ * `@openrouter/sdk` reaches both of OpenRouter's surfaces, and they count the same tokens under
9
+ * different names - so the object a reader hands back may carry one set or the other. Optional
10
+ * on both sides rather than a union, because the reader that produced it already knows which
11
+ * surface it read, and the metric list it is picked with is the surface's own.
12
+ */
13
+ input_tokens?: number | null;
14
+ output_tokens?: number | null;
15
+ input_tokens_details?: {
16
+ cached_tokens?: number | null;
17
+ cache_write_tokens?: number | null;
18
+ } | null;
19
+ };
20
+ /**
21
+ * What `@openrouter/sdk` hands back for `chat.send`, in the SDK's own spelling.
22
+ *
23
+ * Declared structurally rather than imported from `@openrouter/sdk`: the package is a devDependency
24
+ * of the examples, and a recipe that imported its types would make it a dependency of the SDK
25
+ * itself - every consumer installing a client they may not use. Only the counters are named here,
26
+ * which is all a meter reads; see `sdk-usage.ts` for why the spelling is undone rather than kept.
27
+ */
28
+ export type OpenRouterSdkChatUsage = {
29
+ promptTokens?: number | null;
30
+ completionTokens?: number | null;
31
+ totalTokens?: number | null;
32
+ cost?: number | null;
33
+ promptTokensDetails?: {
34
+ cachedTokens?: number | null;
35
+ cacheWriteTokens?: number | null;
36
+ } | null;
37
+ completionTokensDetails?: {
38
+ reasoningTokens?: number | null;
39
+ } | null;
40
+ };
41
+ /** The same, for `responses.send` - the Responses API's names, camelCased the same way. */
42
+ export type OpenRouterSdkResponsesUsage = {
43
+ inputTokens?: number | null;
44
+ outputTokens?: number | null;
45
+ totalTokens?: number | null;
46
+ cost?: number | null;
47
+ inputTokensDetails?: {
48
+ cachedTokens?: number | null;
49
+ cacheWriteTokens?: number | null;
50
+ } | null;
51
+ outputTokensDetails?: {
52
+ reasoningTokens?: number | null;
53
+ } | null;
5
54
  };
6
55
  export type OpenRouterRecipeOptions = {
7
56
  /**
@@ -1,6 +1,10 @@
1
1
  import type { Measurement } from '../../../../types';
2
2
  import type { MetricsFor, MetricsOf } from '../../core/types';
3
- /** `message_delta` restates the whole message's running totals, input included, so its counters replace `message_start`'s rather than add to them. */
3
+ /**
4
+ * Accumulates usage across raw stream events: Anthropic sends the input counts on
5
+ * `message_start` and restates them, with the output count, on `message_delta` - so one completed
6
+ * stream produces one measurement rather than one per event.
7
+ */
4
8
  declare function observeStream(stream: any, ingest: (measurement: Measurement) => void): void;
5
9
  export declare const anthropicRecipe: {
6
10
  vendor: string;
@@ -13,36 +13,126 @@ exports.estimateFromMaxTokens = exports.anthropicRecipe = void 0;
13
13
  const token_usage_1 = require("../../../shared/token-usage");
14
14
  const estimate_1 = require("../../../shared/estimate");
15
15
  const registry_1 = require("../../core/registry");
16
- /** `message_delta` restates the whole message's running totals, input included, so its counters replace `message_start`'s rather than add to them. */
16
+ /**
17
+ * Characters per output token, for the one case where the vendor states no count.
18
+ *
19
+ * Approximate on purpose and deliberately not tuned: it is only ever reached for a stream that
20
+ * was abandoned, where the alternative is reporting nothing for text the customer read and the
21
+ * vendor billed. Roughly four characters to a token holds well enough across English prose for
22
+ * the estimate to be the closer of the two answers. Anything reported from it is marked
23
+ * `estimated` so it is never mistaken for a counter Anthropic stated.
24
+ */
25
+ const CHARS_PER_OUTPUT_TOKEN = 4;
26
+ /** The characters a delta contributed, or 0 for the events that generate nothing. */
27
+ function generatedChars(delta) {
28
+ if (!delta)
29
+ return 0;
30
+ const produced = delta.type === 'text_delta'
31
+ ? delta.text
32
+ : delta.type === 'thinking_delta'
33
+ ? delta.thinking
34
+ : delta.type === 'input_json_delta'
35
+ ? delta.partial_json
36
+ : undefined;
37
+ return typeof produced === 'string' ? produced.length : 0;
38
+ }
39
+ /**
40
+ * Whether a wire value is a count at all.
41
+ *
42
+ * Whole and non-negative, because a counter that *replaces* another has to be checked before it
43
+ * is trusted: a negative or fractional one is not a token count, and letting it overwrite a real
44
+ * count reads back as zero.
45
+ */
46
+ const isCount = (value) => typeof value === 'number' && Number.isInteger(value) && value >= 0;
47
+ /**
48
+ * The counters a stream has most recently stated - each event replacing what it names, rather
49
+ * than adding to it.
50
+ *
51
+ * Anthropic restates cumulatively. `message_delta.usage.input_tokens` is "the *cumulative* number
52
+ * of input tokens which were used", in the vendor's own words, and the same holds for both cache
53
+ * counters and for `output_tokens`. None of them is a delta despite the event's name, so summing
54
+ * the two events bills every input counter twice.
55
+ *
56
+ * Per counter rather than by replacing the block wholesale, because the two events do not carry
57
+ * the same fields: on older API versions `message_start` is the only one that states the input
58
+ * counts, and a wholesale replace would make the opposite billing error.
59
+ *
60
+ * The key list is {@link ANTHROPIC_COMPONENT_METRICS} rather than one of its own: these are the
61
+ * same four counters under the same four names, and a second list would be free to disagree with
62
+ * the one that decides what gets reported.
63
+ */
64
+ function restate(into, stated) {
65
+ if (!stated)
66
+ return into;
67
+ const merged = { ...into };
68
+ for (const key of token_usage_1.ANTHROPIC_COMPONENT_METRICS) {
69
+ const value = stated[key];
70
+ // Only a whole, non-negative number counts as a statement. `null` is how this wire says
71
+ // "not this event", and a malformed or negative one is not a count - letting either through
72
+ // would erase a count an earlier event did state.
73
+ if (isCount(value))
74
+ merged[key] = value;
75
+ }
76
+ return merged;
77
+ }
78
+ /**
79
+ * Accumulates usage across raw stream events: Anthropic sends the input counts on
80
+ * `message_start` and restates them, with the output count, on `message_delta` - so one completed
81
+ * stream produces one measurement rather than one per event.
82
+ */
17
83
  function observeStream(stream, ingest) {
18
84
  if (!stream || typeof stream[Symbol.asyncIterator] !== 'function')
19
85
  return;
20
86
  const iterate = stream[Symbol.asyncIterator].bind(stream);
21
87
  stream[Symbol.asyncIterator] = async function* gatedIterator() {
22
- // A counter an event leaves out keeps its last value.
23
- let usage = {};
88
+ // What the stream has said so far, in the vendor's own shape. Kept flat rather than converted
89
+ // per event: both views of the call - the vendor's names and the normalized counters - are
90
+ // derived from it once at the end, so neither can be accumulated out of step with the other.
91
+ let stated = {};
92
+ // Whether the stream ever stated a final output count. `message_start` carries a placeholder
93
+ // `output_tokens: 1`, so the presence of the counter says nothing - only `message_delta` does.
94
+ let hasStatedOutputTokens = false;
95
+ // What the stream actually produced, for the case where it never gets to say.
96
+ let chars = 0;
24
97
  try {
25
98
  for await (const event of iterate()) {
26
99
  if (event?.type === 'message_start')
27
- usage = { ...usage, ...stated(event.message?.usage) };
28
- if (event?.type === 'message_delta')
29
- usage = { ...usage, ...stated(event.usage) };
100
+ stated = restate(stated, event.message?.usage);
101
+ if (event?.type === 'message_delta') {
102
+ stated = restate(stated, event.usage);
103
+ hasStatedOutputTokens ||= typeof event.usage?.output_tokens === 'number';
104
+ }
105
+ if (event?.type === 'content_block_delta')
106
+ chars += generatedChars(event.delta);
30
107
  yield event;
31
108
  }
32
109
  }
33
110
  finally {
34
- // Meter what was actually produced, even if the consumer broke out of the loop early. No `raw`,
35
- // because the usage is assembled from two events rather than sent as one block.
111
+ // Meter what was actually produced, even if the consumer broke out of the loop early.
112
+ //
113
+ // No `raw` here, deliberately, and this is the one vendor that cannot have one: the counters
114
+ // arrive split across `message_start` and `message_delta`, so there is no single usage block
115
+ // on the wire to carry. The Gemini and OpenAI streams each report one whole block and do
116
+ // pass it on; `stated` is this SDK's reconstruction across two events, not an object the
117
+ // stream ever actually sent, so passing it would be a claim about the wire that is not true.
118
+ //
119
+ // A stream the consumer walked away from never reaches `message_delta`, the only event
120
+ // that states a final output count - so without this the text it did produce meters at the
121
+ // placeholder `output_tokens: 1` from `message_start`. The customer read that text and
122
+ // Anthropic billed for generating it, so one token is a hole rather than caution. What it
123
+ // produced is estimated instead, and the measurement says so.
124
+ const estimated = !hasStatedOutputTokens && chars > 0;
125
+ const final = estimated
126
+ ? restate(stated, { output_tokens: Math.max(1, Math.ceil(chars / CHARS_PER_OUTPUT_TOKEN)) })
127
+ : stated;
36
128
  ingest({
37
- metrics: (0, token_usage_1.withComputedTotal)((0, token_usage_1.pickMetrics)(usage, token_usage_1.ANTHROPIC_COMPONENT_METRICS), token_usage_1.COMPUTED_TOTALS.anthropic),
38
- usage: (0, token_usage_1.fromFlatTokenUsage)(usage),
129
+ metrics: (0, token_usage_1.withComputedTotal)((0, token_usage_1.pickMetrics)(final, token_usage_1.ANTHROPIC_COMPONENT_METRICS), token_usage_1.COMPUTED_TOTALS.anthropic),
130
+ usage: (0, token_usage_1.fromFlatTokenUsage)(final),
131
+ ...(estimated ? { estimated: true } : {}),
39
132
  });
40
133
  }
41
134
  };
42
135
  }
43
- /** Keeps only counts, so a missing, null or malformed counter can't erase one. */
44
- const stated = (usage) => Object.fromEntries(Object.entries(usage ?? {}).filter(([, value]) => isCount(value)));
45
- const isCount = (value) => typeof value === 'number' && Number.isInteger(value) && value >= 0;
46
136
  exports.anthropicRecipe = {
47
137
  vendor: 'anthropic',
48
138
  metrics: token_usage_1.ANTHROPIC_METRICS,
@@ -7,7 +7,7 @@
7
7
  * `fromFlatTokenUsage` is read by every vendor that counts that way, not by one of them.
8
8
  */
9
9
  import { type Measurement, type Metrics, type MetricsMapper, type TokenUsage } from '../../types';
10
- import type { FlatTokenUsage, GeminiLikeUsage, LangChainLikeUsage, OpenAILikeUsage, ResponsesLikeUsage } from './types';
10
+ import type { BedrockLikeUsage, FlatTokenUsage, GeminiLikeUsage, LangChainLikeUsage, OpenAILikeUsage, ResponsesLikeUsage } from './types';
11
11
  export declare function fromFlatTokenUsage(usage: FlatTokenUsage | null | undefined): TokenUsage;
12
12
  export declare function fromOpenAIUsage(usage: OpenAILikeUsage | null | undefined): TokenUsage;
13
13
  /**
@@ -16,6 +16,20 @@ export declare function fromOpenAIUsage(usage: OpenAILikeUsage | null | undefine
16
16
  * Completions extractor finds none of them and meters the call at zero.
17
17
  */
18
18
  export declare function fromResponsesUsage(usage: ResponsesLikeUsage | null | undefined): TokenUsage;
19
+ /**
20
+ * Bedrock's Converse usage, whose four counters are disjoint.
21
+ *
22
+ * Nothing is subtracted, unlike the OpenAI and Gemini readers: AWS states that `inputTokens`
23
+ * already excludes whatever was read from or written to the cache, so taking the cache back out
24
+ * would charge the call for less than it used.
25
+ *
26
+ * The total is read, not recomputed, like every other vendor that publishes one - but it is worth
27
+ * knowing what it covers. AWS documents `totalTokens` as the input and output of the call, and
28
+ * documents `inputTokens` as cache-exclusive, so on a cached call the stated total can be smaller
29
+ * than the four counters add up to. Both are reported: price the breakdown if the cache matters to
30
+ * you, the total if it does not, and see the README's overlap table before pricing both.
31
+ */
32
+ export declare function fromBedrockUsage(usage: BedrockLikeUsage | null | undefined): TokenUsage;
19
33
  export declare function fromLangChainUsage(usage: LangChainLikeUsage | null | undefined): TokenUsage;
20
34
  /**
21
35
  * Gemini's `usageMetadata`, whose two overlaps run in opposite directions - see `GeminiLikeUsage`
@@ -74,6 +88,20 @@ export declare const TYPESAFE_METRICS: readonly ["input_tokens", "output_tokens"
74
88
  export declare const OPENAI_METRICS: readonly ["prompt_tokens", "completion_tokens", "input_tokens", "output_tokens", "cached_tokens", "total_tokens"];
75
89
  /** A gateway speaks the OpenAI shape and adds the price it charged, under its own field name. */
76
90
  export declare const GATEWAY_METRICS: readonly ["prompt_tokens", "completion_tokens", "input_tokens", "output_tokens", "cached_tokens", "total_tokens", "cost"];
91
+ /**
92
+ * Bedrock's five, under its own names and with no price among them.
93
+ *
94
+ * Reported verbatim rather than translated into the OpenAI spelling, which is the rule everywhere
95
+ * except where one service is reachable through two different clients - see `sdk-usage.ts` for
96
+ * that exception. Bedrock is reached one way, so its own names are the honest ones, and a reader
97
+ * comparing a Stigg meter against an AWS bill is comparing like with like.
98
+ *
99
+ * No `cost`: Bedrock states no price on the response, which is the thing that most separates it
100
+ * from the other two gateways here. See the recipe for what that costs a run.
101
+ */
102
+ export declare const BEDROCK_PATHS: readonly ["inputTokens", "outputTokens", "cacheReadInputTokens", "cacheWriteInputTokens", "totalTokens"];
103
+ /** Those five. Flat, so the paths are the names. */
104
+ export declare const BEDROCK_METRICS: readonly ["inputTokens", "outputTokens", "cacheReadInputTokens", "cacheWriteInputTokens", "totalTokens"];
77
105
  /** The leaf a dotted path reports under - `prompt_tokens_details.cached_tokens` -> `cached_tokens`. */
78
106
  export declare const metricName: (path: string) => string;
79
107
  /**
@@ -107,6 +135,7 @@ export declare const METRIC_PATHS: {
107
135
  readonly openaiResponses: readonly ["input_tokens", "output_tokens", "input_tokens_details.cached_tokens", "total_tokens"];
108
136
  readonly typesafe: readonly ["input_tokens", "output_tokens"];
109
137
  readonly gemini: readonly ["promptTokenCount", "candidatesTokenCount", "cachedContentTokenCount", "thoughtsTokenCount", "toolUsePromptTokenCount", "totalTokenCount"];
138
+ readonly bedrock: readonly ["inputTokens", "outputTokens", "cacheReadInputTokens", "cacheWriteInputTokens", "totalTokens"];
110
139
  };
111
140
  /**
112
141
  * Reads the named keys off a vendor's usage object, dotted paths included.