@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.
- package/README.md +135 -43
- package/dist/bindings/client/gateways/bedrock/bedrock-recipe.d.ts +17 -0
- package/dist/bindings/client/gateways/bedrock/bedrock-recipe.js +134 -0
- package/dist/bindings/client/gateways/bedrock/types.d.ts +13 -0
- package/dist/bindings/client/gateways/bedrock/types.js +2 -0
- package/dist/bindings/client/gateways/openrouter/openrouter-recipe.d.ts +5 -0
- package/dist/bindings/client/gateways/openrouter/openrouter-recipe.js +137 -34
- package/dist/bindings/client/gateways/openrouter/sdk-usage.d.ts +37 -0
- package/dist/bindings/client/gateways/openrouter/sdk-usage.js +63 -0
- package/dist/bindings/client/gateways/openrouter/types.d.ts +49 -0
- package/dist/bindings/client/providers/anthropic/anthropic-recipe.d.ts +5 -1
- package/dist/bindings/client/providers/anthropic/anthropic-recipe.js +103 -13
- package/dist/bindings/shared/token-usage.d.ts +30 -1
- package/dist/bindings/shared/token-usage.js +58 -3
- package/dist/bindings/shared/types.d.ts +16 -0
- package/dist/bindings/shared/weighted-metrics.js +5 -2
- package/dist/config.d.ts +26 -17
- package/dist/config.js +29 -27
- package/dist/core/context.d.ts +21 -12
- package/dist/core/context.js +76 -9
- package/dist/core/session.d.ts +38 -9
- package/dist/core/session.js +57 -12
- package/dist/decision-text.d.ts +3 -3
- package/dist/engine/decision.d.ts +11 -6
- package/dist/engine/decision.js +9 -2
- package/dist/engine/report.js +4 -1
- package/dist/errors.d.ts +14 -6
- package/dist/errors.js +13 -3
- package/dist/headers.d.ts +13 -0
- package/dist/headers.js +20 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.js +8 -2
- package/dist/stigg-agent-sdk.d.ts +29 -13
- package/dist/stigg-agent-sdk.js +103 -56
- package/dist/types.d.ts +124 -35
- package/dist/types.js +66 -16
- 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.
|
|
33
|
-
*
|
|
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
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
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:
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
23
|
-
|
|
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
|
-
|
|
28
|
-
if (event?.type === 'message_delta')
|
|
29
|
-
|
|
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.
|
|
35
|
-
//
|
|
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)(
|
|
38
|
-
usage: (0, token_usage_1.fromFlatTokenUsage)(
|
|
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.
|