@stigg/agent-sdk-js 0.0.0-stage → 0.2.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 +1411 -2
- package/dist/bindings/client/core/registry.d.ts +12 -0
- package/dist/bindings/client/core/registry.js +22 -0
- package/dist/bindings/client/core/types.d.ts +126 -0
- package/dist/bindings/client/core/types.js +13 -0
- package/dist/bindings/client/core/wrap-client.d.ts +6 -0
- package/dist/bindings/client/core/wrap-client.js +261 -0
- 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/cloudflare/cloudflare-recipe.d.ts +10 -0
- package/dist/bindings/client/gateways/cloudflare/cloudflare-recipe.js +254 -0
- package/dist/bindings/client/gateways/cloudflare/types.d.ts +35 -0
- package/dist/bindings/client/gateways/cloudflare/types.js +2 -0
- package/dist/bindings/client/gateways/databricks/databricks-recipe.d.ts +32 -0
- package/dist/bindings/client/gateways/databricks/databricks-recipe.js +324 -0
- package/dist/bindings/client/gateways/databricks/types.d.ts +30 -0
- package/dist/bindings/client/gateways/databricks/types.js +2 -0
- package/dist/bindings/client/gateways/litellm/litellm-recipe.d.ts +17 -0
- package/dist/bindings/client/gateways/litellm/litellm-recipe.js +158 -0
- package/dist/bindings/client/gateways/litellm/types.d.ts +30 -0
- package/dist/bindings/client/gateways/litellm/types.js +2 -0
- package/dist/bindings/client/gateways/openrouter/openrouter-recipe.d.ts +20 -0
- package/dist/bindings/client/gateways/openrouter/openrouter-recipe.js +233 -0
- 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 +78 -0
- package/dist/bindings/client/gateways/openrouter/types.js +2 -0
- package/dist/bindings/client/providers/anthropic/anthropic-recipe.d.ts +34 -0
- package/dist/bindings/client/providers/anthropic/anthropic-recipe.js +169 -0
- package/dist/bindings/client/providers/gemini/gemini-recipe.d.ts +54 -0
- package/dist/bindings/client/providers/gemini/gemini-recipe.js +243 -0
- package/dist/bindings/client/providers/groq/groq-recipe.d.ts +20 -0
- package/dist/bindings/client/providers/groq/groq-recipe.js +84 -0
- package/dist/bindings/client/providers/mistral/mistral-recipe.d.ts +22 -0
- package/dist/bindings/client/providers/mistral/mistral-recipe.js +114 -0
- package/dist/bindings/client/providers/mistral/types.d.ts +38 -0
- package/dist/bindings/client/providers/mistral/types.js +2 -0
- package/dist/bindings/client/providers/ollama/ollama-recipe.d.ts +31 -0
- package/dist/bindings/client/providers/ollama/ollama-recipe.js +107 -0
- package/dist/bindings/client/providers/ollama/types.d.ts +24 -0
- package/dist/bindings/client/providers/ollama/types.js +2 -0
- package/dist/bindings/client/providers/openai/openai-recipe.d.ts +71 -0
- package/dist/bindings/client/providers/openai/openai-recipe.js +122 -0
- package/dist/bindings/client/providers/typesafe/typesafe-recipe.d.ts +44 -0
- package/dist/bindings/client/providers/typesafe/typesafe-recipe.js +83 -0
- package/dist/bindings/proxy/headers.d.ts +6 -0
- package/dist/bindings/proxy/headers.js +12 -0
- package/dist/bindings/proxy/index.d.ts +21 -0
- package/dist/bindings/proxy/index.js +18 -0
- package/dist/bindings/proxy/types.d.ts +8 -0
- package/dist/bindings/proxy/types.js +2 -0
- package/dist/bindings/shared/base-url.d.ts +44 -0
- package/dist/bindings/shared/base-url.js +90 -0
- package/dist/bindings/shared/estimate.d.ts +27 -0
- package/dist/bindings/shared/estimate.js +38 -0
- package/dist/bindings/shared/meter-stream.d.ts +18 -0
- package/dist/bindings/shared/meter-stream.js +151 -0
- package/dist/bindings/shared/token-usage.d.ts +345 -0
- package/dist/bindings/shared/token-usage.js +735 -0
- package/dist/bindings/shared/types.d.ts +170 -0
- package/dist/bindings/shared/types.js +2 -0
- package/dist/bindings/shared/weighted-metrics.d.ts +37 -0
- package/dist/bindings/shared/weighted-metrics.js +47 -0
- package/dist/config.d.ts +145 -0
- package/dist/config.js +99 -0
- package/dist/core/context.d.ts +160 -0
- package/dist/core/context.js +221 -0
- package/dist/core/dimensions.d.ts +22 -0
- package/dist/core/dimensions.js +47 -0
- package/dist/core/hooks.d.ts +21 -0
- package/dist/core/hooks.js +47 -0
- package/dist/core/session.d.ts +190 -0
- package/dist/core/session.js +448 -0
- package/dist/decision-text.d.ts +15 -0
- package/dist/decision-text.js +54 -0
- package/dist/engine/composite-engine.d.ts +72 -0
- package/dist/engine/composite-engine.js +135 -0
- package/dist/engine/create-engine.d.ts +16 -0
- package/dist/engine/create-engine.js +93 -0
- package/dist/engine/decision.d.ts +42 -0
- package/dist/engine/decision.js +135 -0
- package/dist/engine/engine.d.ts +166 -0
- package/dist/engine/engine.js +11 -0
- package/dist/engine/node-sdk-engine.d.ts +125 -0
- package/dist/engine/node-sdk-engine.js +262 -0
- package/dist/engine/report.d.ts +106 -0
- package/dist/engine/report.js +188 -0
- package/dist/engine/rest-governance-engine.d.ts +47 -0
- package/dist/engine/rest-governance-engine.js +220 -0
- package/dist/errors.d.ts +86 -0
- package/dist/errors.js +161 -0
- package/dist/headers.d.ts +40 -0
- package/dist/headers.js +82 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +124 -0
- package/dist/internal.d.ts +33 -0
- package/dist/internal.js +91 -0
- package/dist/logger.d.ts +15 -0
- package/dist/logger.js +20 -0
- package/dist/sdk-identity.d.ts +36 -0
- package/dist/sdk-identity.js +43 -0
- package/dist/stigg-agent-sdk.d.ts +300 -0
- package/dist/stigg-agent-sdk.js +669 -0
- package/dist/types.d.ts +676 -0
- package/dist/types.js +147 -0
- package/package.json +94 -3
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type DatabricksRecipeOptions = {
|
|
2
|
+
/**
|
|
3
|
+
* How much to ask permission for before the call, in whatever unit `meter` bills.
|
|
4
|
+
*
|
|
5
|
+
* The default asks for 1 - "is there any budget left at all?" - and lets the ingest afterwards
|
|
6
|
+
* settle the balance. There is no `unitsPer…` beside it because there is nothing fractional to
|
|
7
|
+
* denominate: Databricks states no price on any response, so a run here bills token counters,
|
|
8
|
+
* which arrive as whole numbers already.
|
|
9
|
+
*/
|
|
10
|
+
estimate?: (params: any) => number;
|
|
11
|
+
/**
|
|
12
|
+
* Dimension the model that actually served is attributed to.
|
|
13
|
+
*
|
|
14
|
+
* Worth more here than at most gateways. The request names a serving *endpoint*, which on the
|
|
15
|
+
* classic route is a string the customer chose and which need not resemble any model id - so
|
|
16
|
+
* the response's `model` field is frequently the only thing in the whole exchange that says
|
|
17
|
+
* what answered.
|
|
18
|
+
*/
|
|
19
|
+
modelDimension?: string;
|
|
20
|
+
/**
|
|
21
|
+
* The workspace this recipe is for, as a base URL.
|
|
22
|
+
*
|
|
23
|
+
* A Databricks workspace hostname is customer-specific - `<workspace>.cloud.databricks.com`,
|
|
24
|
+
* `adb-<id>.<n>.azuredatabricks.net`, `<id>.<n>.gcp.databricks.com`, or whatever a PrivateLink
|
|
25
|
+
* or custom-DNS deployment resolves to. The matcher recognises the three public families and
|
|
26
|
+
* the two serving paths without being told; a deployment on a private hostname is invisible to
|
|
27
|
+
* that guess and needs this. Give it the same base URL the client was built with.
|
|
28
|
+
*/
|
|
29
|
+
baseUrl?: string;
|
|
30
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { GATEWAY_METRICS } from '../../../shared/token-usage';
|
|
2
|
+
import type { LiteLLMRecipeOptions } from './types';
|
|
3
|
+
import type { ClientRecipe, MetricsFor, MetricsOf } from '../../core/types';
|
|
4
|
+
/** Both OpenAI surfaces plus the price the gateway itself charged. */
|
|
5
|
+
type GatewayMetric = (typeof GATEWAY_METRICS)[number];
|
|
6
|
+
/**
|
|
7
|
+
* One recipe covers every upstream. LiteLLM normalizes Anthropic, OpenAI, Bedrock and the rest to
|
|
8
|
+
* the same wire shape, so the response never changes form - `model` is just a field saying which
|
|
9
|
+
* deployment served, which is what makes it worth putting on the ingest as a dimension: the client
|
|
10
|
+
* asked for a model group, and the group is free to resolve to something else.
|
|
11
|
+
*/
|
|
12
|
+
export declare function litellmRecipe(options?: LiteLLMRecipeOptions): ClientRecipe<GatewayMetric>;
|
|
13
|
+
/** One of the metric names LiteLLM reports. */
|
|
14
|
+
export type LiteLLMMetric = MetricsOf<ReturnType<typeof litellmRecipe>>;
|
|
15
|
+
/** What a mapper reads off a LiteLLM call: those names, each possibly absent. */
|
|
16
|
+
export type LiteLLMMetrics = MetricsFor<ReturnType<typeof litellmRecipe>>;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.litellmRecipe = litellmRecipe;
|
|
4
|
+
/**
|
|
5
|
+
* The LiteLLM recipe - a gateway you call, whose cost arrives beside the body rather than in it.
|
|
6
|
+
*
|
|
7
|
+
* LiteLLM Proxy speaks the OpenAI wire format, so `openAIRecipe` already fits it by shape. What
|
|
8
|
+
* that recipe cannot express is the same pair `openrouterRecipe` exists for: the money actually
|
|
9
|
+
* spent, and which deployment actually served after routing. LiteLLM knows both - it prices every
|
|
10
|
+
* call itself - but it reports the cost in a response header, `x-litellm-response-cost`, and never
|
|
11
|
+
* in the body. A body-only extractor cannot see it, which is why `extractUsage` takes the response.
|
|
12
|
+
*
|
|
13
|
+
* That placement has a consequence worth stating plainly, because it is the protocol and not a bug:
|
|
14
|
+
* headers go on the wire before the model has generated anything, so a STREAMED call can never
|
|
15
|
+
* carry a cost. Tokens survive it fine - LiteLLM honours `stream_options: {include_usage: true}`
|
|
16
|
+
* and puts `usage` on the final chunk - so a stream falls back to the token path via
|
|
17
|
+
* `onMissingCost`. Non-streaming bills real spend; streaming bills tokens. The streaming branch
|
|
18
|
+
* below still checks for the header, so the day LiteLLM can send one, this recipe already reads it.
|
|
19
|
+
*
|
|
20
|
+
* All of that applies to a run that bills `cost` - `weightedMetrics(litellmRecipe({ baseUrl }),
|
|
21
|
+
* 'credits', { cost: 1 })`. Bill the token counters instead and the split above stops mattering:
|
|
22
|
+
* one denomination whether or not the call streamed. Either way the recipe is worth naming for a
|
|
23
|
+
* self-hosted proxy, to give it the base URL its matcher cannot guess.
|
|
24
|
+
*/
|
|
25
|
+
const meter_stream_1 = require("../../../shared/meter-stream");
|
|
26
|
+
const base_url_1 = require("../../../shared/base-url");
|
|
27
|
+
const openai_recipe_1 = require("../../providers/openai/openai-recipe");
|
|
28
|
+
const token_usage_1 = require("../../../shared/token-usage");
|
|
29
|
+
/** The cost LiteLLM charged for this call, in USD. Non-streaming responses only. */
|
|
30
|
+
const COST_HEADER = 'x-litellm-response-cost';
|
|
31
|
+
/** The proxy's own id for the call: stable across its retries, which a local step counter is not. */
|
|
32
|
+
const CALL_ID_HEADER = 'x-litellm-call-id';
|
|
33
|
+
/** Float noise: 0.00108 * 1e6 is 1080.0000000000002, and ceil would bill the extra unit. */
|
|
34
|
+
const toUnits = (cost, unitsPerDollar) => Math.max(1, Math.ceil(Number((cost * unitsPerDollar).toPrecision(12))));
|
|
35
|
+
/** The header is a string on the wire, and absent on every streamed response. */
|
|
36
|
+
function costOf(response) {
|
|
37
|
+
const raw = response?.headers?.get(COST_HEADER);
|
|
38
|
+
if (raw == null || raw === '')
|
|
39
|
+
return undefined;
|
|
40
|
+
const cost = Number(raw);
|
|
41
|
+
return Number.isFinite(cost) ? cost : undefined;
|
|
42
|
+
}
|
|
43
|
+
function measure(usage, parse, keys, model, id, response, options) {
|
|
44
|
+
if (!usage)
|
|
45
|
+
return undefined;
|
|
46
|
+
// LiteLLM normalizes every upstream (Anthropic, Bedrock, whatever) to the OpenAI wire format,
|
|
47
|
+
// so the token names are OpenAI's. The cost is the proxy's own, and it rides beside them rather
|
|
48
|
+
// than instead of them - a streamed call simply has no cost to report, because the header goes
|
|
49
|
+
// out before the first token exists.
|
|
50
|
+
const cost = costOf(response);
|
|
51
|
+
const metrics = (0, token_usage_1.pickMetrics)(usage, keys);
|
|
52
|
+
if (cost !== undefined && cost > 0)
|
|
53
|
+
metrics.cost = toUnits(cost, options.unitsPerDollar);
|
|
54
|
+
return {
|
|
55
|
+
metrics,
|
|
56
|
+
usage: parse(usage),
|
|
57
|
+
// What the proxy stated, before `cost` was scaled into whole units - an event can carry the
|
|
58
|
+
// fraction as it was, which is the honest number and the one that reconciles with the bill.
|
|
59
|
+
//
|
|
60
|
+
// The cost is folded in rather than assumed present: LiteLLM reports it in a header, not in
|
|
61
|
+
// the body, so unlike OpenRouter the response alone carries no trace of it. Taking the body
|
|
62
|
+
// as-is left `metrics.cost` rounded up to whole units as the only record of a price the
|
|
63
|
+
// proxy had stated exactly.
|
|
64
|
+
raw: cost === undefined ? usage : { ...usage, cost },
|
|
65
|
+
dimensions: model ? { [options.modelDimension]: model } : undefined,
|
|
66
|
+
// The proxy's own call id wins over the completion id: it is what its spend logs are keyed on,
|
|
67
|
+
// so a replay lines up on both sides of the boundary.
|
|
68
|
+
idempotencyKey: response?.headers?.get(CALL_ID_HEADER) ?? id,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* One recipe covers every upstream. LiteLLM normalizes Anthropic, OpenAI, Bedrock and the rest to
|
|
73
|
+
* the same wire shape, so the response never changes form - `model` is just a field saying which
|
|
74
|
+
* deployment served, which is what makes it worth putting on the ingest as a dimension: the client
|
|
75
|
+
* asked for a model group, and the group is free to resolve to something else.
|
|
76
|
+
*/
|
|
77
|
+
function litellmRecipe(options = {}) {
|
|
78
|
+
const resolved = {
|
|
79
|
+
estimate: options.estimate ?? (() => 1),
|
|
80
|
+
unitsPerDollar: positiveDenomination(options.unitsPerDollar, 'unitsPerDollar'),
|
|
81
|
+
modelDimension: options.modelDimension ?? 'model',
|
|
82
|
+
};
|
|
83
|
+
return {
|
|
84
|
+
metrics: token_usage_1.GATEWAY_METRICS,
|
|
85
|
+
// The OpenAI shape it speaks, so the OpenAI name for the total.
|
|
86
|
+
totalTokensMetric: token_usage_1.TOTAL_TOKENS,
|
|
87
|
+
vendor: 'litellm',
|
|
88
|
+
// Shape alone cannot separate this from any other OpenAI-compatible client, so the base URL
|
|
89
|
+
// is the discriminator - it is the one thing that says which gateway is on the other end.
|
|
90
|
+
matches: (client) => openai_recipe_1.openAIRecipe.matches(client) && isLiteLLM(client, options.baseUrl),
|
|
91
|
+
// Built from the OpenAI recipe's own surfaces, for the same reason as the OpenRouter one: this
|
|
92
|
+
// recipe is matched ahead of it, so a surface it failed to declare would go ungated.
|
|
93
|
+
methods: openai_recipe_1.openAIRecipe.methods.map((base) => {
|
|
94
|
+
const { parse, keys, streamed } = (0, openai_recipe_1.openAISurface)(base.path);
|
|
95
|
+
return {
|
|
96
|
+
path: base.path,
|
|
97
|
+
estimate: resolved.estimate,
|
|
98
|
+
extractUsage: (result, response) => measure(result?.usage, parse, keys, result?.model, result?.id, response, resolved),
|
|
99
|
+
// Usage arrives once, at the end; `model` and `id` ride every chunk. The response is
|
|
100
|
+
// threaded through even though a real stream's headers carry no cost today - the branch
|
|
101
|
+
// costs nothing and is the whole difference between this being wrong later and right.
|
|
102
|
+
attachStream: (stream, ingest, response) => {
|
|
103
|
+
let usage;
|
|
104
|
+
let model;
|
|
105
|
+
let id;
|
|
106
|
+
(0, meter_stream_1.meterStream)(stream, (chunk) => {
|
|
107
|
+
const seen = streamed(chunk);
|
|
108
|
+
if (seen)
|
|
109
|
+
usage = seen;
|
|
110
|
+
if (chunk?.model)
|
|
111
|
+
model = chunk.model;
|
|
112
|
+
if (chunk?.id)
|
|
113
|
+
id = chunk.id;
|
|
114
|
+
}, () => {
|
|
115
|
+
const measurement = measure(usage, parse, keys, model, id, response, resolved);
|
|
116
|
+
if (measurement)
|
|
117
|
+
ingest(measurement);
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* `baseURL` is a public field on the OpenAI client. Shape cannot answer this and a wrong guess
|
|
126
|
+
* silently changes the unit of account, so the check is narrow on purpose.
|
|
127
|
+
*
|
|
128
|
+
* A LiteLLM proxy is self-hosted, so there is no `openrouter.ai` to look for: the configured
|
|
129
|
+
* `baseUrl` is the only reliable answer, and the hostname guess below is a convenience for the
|
|
130
|
+
* deployments that do say so. A proxy on a host that mentions neither takes the recipe explicitly,
|
|
131
|
+
* via `wrap(client, run, { recipe })`.
|
|
132
|
+
*/
|
|
133
|
+
function isLiteLLM(client, baseUrl) {
|
|
134
|
+
const clientBase = (0, base_url_1.baseUrlOf)(client);
|
|
135
|
+
if (clientBase === undefined)
|
|
136
|
+
return false;
|
|
137
|
+
// A prefix match on the raw string lets `.../v1` claim `.../v10`, which is a different
|
|
138
|
+
// deployment with a different price list. `withinBaseUrl` compares the endpoint, one path
|
|
139
|
+
// segment at a time.
|
|
140
|
+
if (baseUrl)
|
|
141
|
+
return (0, base_url_1.withinBaseUrl)(baseUrl, clientBase);
|
|
142
|
+
return clientBase.toLowerCase().includes('litellm');
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The denomination, refused when it cannot divide a price.
|
|
146
|
+
*
|
|
147
|
+
* `NaN` and `Infinity` make `toUnits` produce a value the ingest rejects a call later, and zero or
|
|
148
|
+
* a negative clamps every cost to the floor of 1 - a bill that looks plausible and is not. Both
|
|
149
|
+
* are configuration mistakes worth naming where they were made.
|
|
150
|
+
*/
|
|
151
|
+
function positiveDenomination(value, name) {
|
|
152
|
+
if (value === undefined)
|
|
153
|
+
return 1_000_000;
|
|
154
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
155
|
+
throw new Error(`${name} must be a finite positive number - got ${value}, which prices every call wrongly.`);
|
|
156
|
+
}
|
|
157
|
+
return value;
|
|
158
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type LiteLLMRecipeOptions = {
|
|
2
|
+
/**
|
|
3
|
+
* How much to ask permission for before the call, in whatever unit `meter` bills.
|
|
4
|
+
*
|
|
5
|
+
* The default asks for 1 - "is there any budget left at all?" - and lets the ingest afterwards
|
|
6
|
+
* settle the balance. Billing tokens, the gap that leaves is about one call's worth of units.
|
|
7
|
+
* Billing `cost` it is not: a call priced in micro-dollars is thousands of units, so a run with
|
|
8
|
+
* one unit left passes the gate and then spends far past it.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here can price a call the gateway has not priced yet, so closing that gap needs a
|
|
11
|
+
* ceiling only the caller knows - `max_tokens` against a rate they are willing to assume. Give
|
|
12
|
+
* one and the gate asks for it; leave it and the overshoot is bounded by a single call, which
|
|
13
|
+
* is the trade the default makes everywhere else.
|
|
14
|
+
*/
|
|
15
|
+
estimate?: (params: any) => number;
|
|
16
|
+
/**
|
|
17
|
+
* Units per dollar. LiteLLM reports fractional USD and Stigg ingests whole units, so the meter
|
|
18
|
+
* has to be denominated in something small enough not to round every call up to 1. The default
|
|
19
|
+
* is micro-dollars; set it to match however the Stigg currency is defined.
|
|
20
|
+
*/
|
|
21
|
+
unitsPerDollar?: number;
|
|
22
|
+
/** Dimension the served model is attributed to, so routing drift is visible in Stigg. */
|
|
23
|
+
modelDimension?: string;
|
|
24
|
+
/**
|
|
25
|
+
* The proxy this recipe is for. A LiteLLM deployment is self-hosted, so unlike a public gateway
|
|
26
|
+
* there is no fixed host to recognise - without this, `matches` can only fall back to a narrow
|
|
27
|
+
* guess at the hostname. Give it the same base URL the client was built with.
|
|
28
|
+
*/
|
|
29
|
+
baseUrl?: string;
|
|
30
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { GATEWAY_METRICS } from '../../../shared/token-usage';
|
|
2
|
+
import type { OpenRouterRecipeOptions } from './types';
|
|
3
|
+
import type { ClientRecipe, MetricsFor, MetricsOf } from '../../core/types';
|
|
4
|
+
/** Both OpenAI surfaces plus the price the gateway itself charged. */
|
|
5
|
+
type GatewayMetric = (typeof GATEWAY_METRICS)[number];
|
|
6
|
+
/**
|
|
7
|
+
* One recipe covers every upstream. OpenRouter normalizes Anthropic, OpenAI and the rest to the
|
|
8
|
+
* same wire shape, so the response never changes form - `model` is just a field saying who served.
|
|
9
|
+
*
|
|
10
|
+
* It also covers both ways of reaching OpenRouter from Node: the OpenAI SDK pointed at the
|
|
11
|
+
* gateway's base URL, and OpenRouter's own `@openrouter/sdk`. Those are different objects with
|
|
12
|
+
* different method names reporting the same call, and a recipe that governed only the first would
|
|
13
|
+
* meter nothing at all for an application that took OpenRouter's own documented advice.
|
|
14
|
+
*/
|
|
15
|
+
export declare function openrouterRecipe(options?: OpenRouterRecipeOptions): ClientRecipe<GatewayMetric>;
|
|
16
|
+
/** One of the metric names OpenRouter reports. */
|
|
17
|
+
export type OpenRouterMetric = MetricsOf<ReturnType<typeof openrouterRecipe>>;
|
|
18
|
+
/** What a mapper reads off an OpenRouter call: those names, each possibly absent. */
|
|
19
|
+
export type OpenRouterMetrics = MetricsFor<ReturnType<typeof openrouterRecipe>>;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.openrouterRecipe = openrouterRecipe;
|
|
4
|
+
/**
|
|
5
|
+
* The OpenRouter recipe - a gateway you call rather than one you operate.
|
|
6
|
+
*
|
|
7
|
+
* OpenRouter speaks the OpenAI wire format, so `openAIRecipe` already fits it by shape. What that
|
|
8
|
+
* recipe cannot express is the two things a gateway knows and a client-side wrapper cannot: the
|
|
9
|
+
* credits it actually charged (`usage.cost`) and which model actually served after fallback
|
|
10
|
+
* routing (`response.model`). Both are on the response, and both are dropped by a projection down
|
|
11
|
+
* to four token counters - so this recipe bills the real number and attributes it to the model
|
|
12
|
+
* that earned it.
|
|
13
|
+
*
|
|
14
|
+
* It is registered by default, because reading a response is not a pricing decision. It reports
|
|
15
|
+
* what the gateway stated - the same token counters `openAIRecipe` would, plus `cost` and the
|
|
16
|
+
* model that served - and what any of that is worth is the run's mapper to decide:
|
|
17
|
+
* `weightedMetrics(openrouterRecipe(), 'credits', { cost: 1 })` bills the money and ignores the
|
|
18
|
+
* tokens. The SDK must not infer that from a base URL, which is why it is not inferred at all.
|
|
19
|
+
*
|
|
20
|
+
* The other topology - a proxy you run yourself, fronting services that may not even be Node -
|
|
21
|
+
* has no client object to wrap and stays with the ingest-only gateway binding.
|
|
22
|
+
*/
|
|
23
|
+
const meter_stream_1 = require("../../../shared/meter-stream");
|
|
24
|
+
const openai_recipe_1 = require("../../providers/openai/openai-recipe");
|
|
25
|
+
const token_usage_1 = require("../../../shared/token-usage");
|
|
26
|
+
const registry_1 = require("../../core/registry");
|
|
27
|
+
const base_url_1 = require("../../../shared/base-url");
|
|
28
|
+
const sdk_usage_1 = require("./sdk-usage");
|
|
29
|
+
/** Float noise: 0.00108 * 1e6 is 1080.0000000000002, and ceil would bill the extra unit. */
|
|
30
|
+
const toUnits = (cost, unitsPerCredit) => Math.max(1, Math.ceil(Number((cost * unitsPerCredit).toPrecision(12))));
|
|
31
|
+
/**
|
|
32
|
+
* Tokens and cost, from one response.
|
|
33
|
+
*
|
|
34
|
+
* A gateway states both, they are different features, and reporting both is what lets one meter
|
|
35
|
+
* the tokens and the other the money.
|
|
36
|
+
*
|
|
37
|
+
* OpenRouter writes the money in the body, which is why a streamed call can still be billed on it:
|
|
38
|
+
* the usage object arrives on the last chunk, cost included, provided the request asked for usage
|
|
39
|
+
* (`stream_options.include_usage`, or `streamOptions.includeUsage` through the first-party SDK).
|
|
40
|
+
* A caller who does not ask gets a stream that states nothing, and the key is then absent rather
|
|
41
|
+
* than substituted - billing a guess is the one thing a cost-truth adapter must not do. LiteLLM is
|
|
42
|
+
* the gateway that genuinely cannot state it, because its cost rides a header that went out before
|
|
43
|
+
* the first token existed.
|
|
44
|
+
*/
|
|
45
|
+
function measure(usage, parse, keys, model, id, options,
|
|
46
|
+
/**
|
|
47
|
+
* What the vendor itself stated, when that is not the object being read.
|
|
48
|
+
*
|
|
49
|
+
* The first-party SDK hands back camelCased counters, which are translated to the wire's own
|
|
50
|
+
* spelling before anything meters them - but an event should carry what OpenRouter said, not
|
|
51
|
+
* what this file rewrote it to, so the original rides along unchanged.
|
|
52
|
+
*/
|
|
53
|
+
stated) {
|
|
54
|
+
if (!usage)
|
|
55
|
+
return undefined;
|
|
56
|
+
const cost = usage.cost;
|
|
57
|
+
const metrics = (0, token_usage_1.pickMetrics)(usage, keys);
|
|
58
|
+
// `cost` is the gateway's own field name, so it obeys the same rule as every other metric.
|
|
59
|
+
if (typeof cost === 'number' && cost > 0)
|
|
60
|
+
metrics.cost = toUnits(cost, options.unitsPerCredit);
|
|
61
|
+
return {
|
|
62
|
+
metrics,
|
|
63
|
+
usage: parse(usage),
|
|
64
|
+
// What the gateway stated, before `cost` was scaled into whole units - an event can carry the
|
|
65
|
+
// fraction as it was, which is the honest number and the one that reconciles with the bill.
|
|
66
|
+
raw: stated ?? usage,
|
|
67
|
+
// The gateway's own request id: stable across its retries, which a local step counter is not.
|
|
68
|
+
dimensions: model ? { [options.modelDimension]: model } : undefined,
|
|
69
|
+
idempotencyKey: id,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The first-party SDK's two surfaces, built the same way the OpenAI-shaped ones are.
|
|
74
|
+
*
|
|
75
|
+
* `@openrouter/sdk` is generated from the OpenAPI spec, so it shares nothing with `openai-node`
|
|
76
|
+
* but the service behind it: the method is `chat.send` rather than `chat.completions.create`, the
|
|
77
|
+
* request nests under `chatRequest`, and the counters come back camelCased. Only the last of
|
|
78
|
+
* those reaches a meter, and `sdk-usage.ts` undoes it so both clients report the same metric
|
|
79
|
+
* names for the same call.
|
|
80
|
+
*
|
|
81
|
+
* `responses.send` is here for the reason the comment on `methods` gives: a surface this recipe
|
|
82
|
+
* declines to declare is not metered by something else, it is simply ungated - and a client this
|
|
83
|
+
* recipe claims to govern must not have a billable method left open on it.
|
|
84
|
+
*/
|
|
85
|
+
function sdkSurfaces(resolved) {
|
|
86
|
+
const surfaces = [
|
|
87
|
+
{ path: ['chat', 'send'], read: sdk_usage_1.fromSdkChatUsage, parse: token_usage_1.fromOpenAIUsage, keys: token_usage_1.METRIC_PATHS.openaiChat },
|
|
88
|
+
{
|
|
89
|
+
path: ['responses', 'send'],
|
|
90
|
+
read: sdk_usage_1.fromSdkResponsesUsage,
|
|
91
|
+
parse: token_usage_1.fromResponsesUsage,
|
|
92
|
+
keys: token_usage_1.METRIC_PATHS.openaiResponses,
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
return surfaces.map(({ path, read, parse, keys }) => {
|
|
96
|
+
/** The vendor's own object translated, with the original kept for the event to carry. */
|
|
97
|
+
const measureSdk = (usage, model, id) => measure(read(usage), parse, keys, model, id, resolved, usage);
|
|
98
|
+
return {
|
|
99
|
+
path: [...path],
|
|
100
|
+
estimate: resolved.estimate,
|
|
101
|
+
extractUsage: (result) => measureSdk(result?.usage, result?.model, result?.id),
|
|
102
|
+
// A streamed call resolves to an `EventStream`, which is a `ReadableStream` subclass: async
|
|
103
|
+
// iterable, and with no `next`/`return` of its own for `meterStream` to patch. So the
|
|
104
|
+
// metered path is `for await`, which is how the SDK documents consuming one. A caller who
|
|
105
|
+
// takes `getReader()` instead bypasses the wrapper - the same hole every streaming binding
|
|
106
|
+
// has, and the reason the non-streamed surface is the one the example leans on.
|
|
107
|
+
attachStream: (stream, ingest) => {
|
|
108
|
+
let usage;
|
|
109
|
+
let model;
|
|
110
|
+
let id;
|
|
111
|
+
(0, meter_stream_1.meterStream)(stream, (chunk) => {
|
|
112
|
+
// Usage arrives once, on the last chunk; `model` and `id` ride every one of them.
|
|
113
|
+
if (chunk?.usage)
|
|
114
|
+
usage = chunk.usage;
|
|
115
|
+
if (chunk?.model)
|
|
116
|
+
model = chunk.model;
|
|
117
|
+
if (chunk?.id)
|
|
118
|
+
id = chunk.id;
|
|
119
|
+
}, () => {
|
|
120
|
+
const measurement = measureSdk(usage, model, id);
|
|
121
|
+
if (measurement)
|
|
122
|
+
ingest(measurement);
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* One recipe covers every upstream. OpenRouter normalizes Anthropic, OpenAI and the rest to the
|
|
130
|
+
* same wire shape, so the response never changes form - `model` is just a field saying who served.
|
|
131
|
+
*
|
|
132
|
+
* It also covers both ways of reaching OpenRouter from Node: the OpenAI SDK pointed at the
|
|
133
|
+
* gateway's base URL, and OpenRouter's own `@openrouter/sdk`. Those are different objects with
|
|
134
|
+
* different method names reporting the same call, and a recipe that governed only the first would
|
|
135
|
+
* meter nothing at all for an application that took OpenRouter's own documented advice.
|
|
136
|
+
*/
|
|
137
|
+
function openrouterRecipe(options = {}) {
|
|
138
|
+
const resolved = {
|
|
139
|
+
estimate: options.estimate ?? (() => 1),
|
|
140
|
+
unitsPerCredit: positiveDenomination(options.unitsPerCredit, 'unitsPerCredit'),
|
|
141
|
+
modelDimension: options.modelDimension ?? 'model',
|
|
142
|
+
};
|
|
143
|
+
return {
|
|
144
|
+
metrics: token_usage_1.GATEWAY_METRICS,
|
|
145
|
+
// The OpenAI shape it speaks, so the OpenAI name for the total.
|
|
146
|
+
totalTokensMetric: token_usage_1.TOTAL_TOKENS,
|
|
147
|
+
vendor: 'openrouter',
|
|
148
|
+
// Two clients, recognised two different ways - see each guard for why they cannot share one.
|
|
149
|
+
// The OpenAI-shaped client is OpenAI-shaped, so only its base URL says which gateway it talks
|
|
150
|
+
// to; OpenRouter's own SDK is unmistakable by shape and may legitimately point anywhere.
|
|
151
|
+
matches: (client) => (openai_recipe_1.openAIRecipe.matches(client) && isOpenRouter(client)) || isOpenRouterSdk(client),
|
|
152
|
+
// Built from the OpenAI recipe's own surfaces. A gateway speaks that API, so the methods worth
|
|
153
|
+
// governing are the same ones - and because this recipe is matched ahead of it, any surface it
|
|
154
|
+
// failed to declare would be left ungated rather than falling through.
|
|
155
|
+
methods: [
|
|
156
|
+
...openai_recipe_1.openAIRecipe.methods.map((base) => {
|
|
157
|
+
const { parse, keys, streamed } = (0, openai_recipe_1.openAISurface)(base.path);
|
|
158
|
+
return {
|
|
159
|
+
path: base.path,
|
|
160
|
+
estimate: resolved.estimate,
|
|
161
|
+
extractUsage: (result) => measure(result?.usage, parse, keys, result?.model, result?.id, resolved),
|
|
162
|
+
// Usage arrives once, at the end; `model` and `id` ride every chunk.
|
|
163
|
+
attachStream: (stream, ingest) => {
|
|
164
|
+
let usage;
|
|
165
|
+
let model;
|
|
166
|
+
let id;
|
|
167
|
+
(0, meter_stream_1.meterStream)(stream, (chunk) => {
|
|
168
|
+
const seen = streamed(chunk);
|
|
169
|
+
if (seen)
|
|
170
|
+
usage = seen;
|
|
171
|
+
if (chunk?.model)
|
|
172
|
+
model = chunk.model;
|
|
173
|
+
if (chunk?.id)
|
|
174
|
+
id = chunk.id;
|
|
175
|
+
}, () => {
|
|
176
|
+
const measurement = measure(usage, parse, keys, model, id, resolved);
|
|
177
|
+
if (measurement)
|
|
178
|
+
ingest(measurement);
|
|
179
|
+
});
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}),
|
|
183
|
+
...sdkSurfaces(resolved),
|
|
184
|
+
],
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* `baseURL` is a public field on the OpenAI client. Shape cannot answer this and a wrong guess
|
|
189
|
+
* silently changes the unit of account, so the check is narrow on purpose: an OpenRouter-compatible
|
|
190
|
+
* endpoint on another host takes the recipe explicitly, via `wrap(client, run, { recipe })`.
|
|
191
|
+
*/
|
|
192
|
+
function isOpenRouter(client) {
|
|
193
|
+
const baseUrl = (0, base_url_1.baseUrlOf)(client);
|
|
194
|
+
// The host, not a substring of the URL: `openrouter.ai.example.com` is somebody else's domain,
|
|
195
|
+
// and claiming it would meter an unrelated client as a gateway.
|
|
196
|
+
return baseUrl !== undefined && (0, base_url_1.matchesHost)(baseUrl, 'openrouter.ai');
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Whether this is OpenRouter's own SDK client, which carries no base URL to recognise it by.
|
|
200
|
+
*
|
|
201
|
+
* The OpenAI-shaped client is identified by where it points, because its shape says only
|
|
202
|
+
* "OpenAI-compatible" and half the industry is. This one is the opposite: `chat.send` is not a
|
|
203
|
+
* shape anyone else has, and the namespaces beside it - `credits`, `generations`, the gateway's
|
|
204
|
+
* own billing and routing APIs - are OpenRouter's product rather than an API style someone could
|
|
205
|
+
* implement by accident. So shape is the whole discriminator here, and there is no URL to check:
|
|
206
|
+
* an application may point the SDK at a proxy, which is still OpenRouter's SDK and still speaks
|
|
207
|
+
* its format.
|
|
208
|
+
*
|
|
209
|
+
* `in` rather than a property read, because every namespace on that client is a lazy getter that
|
|
210
|
+
* constructs a sub-client - and merely asking whether a recipe fits must not build three of them.
|
|
211
|
+
*/
|
|
212
|
+
function isOpenRouterSdk(client) {
|
|
213
|
+
if (!client || typeof client !== 'object')
|
|
214
|
+
return false;
|
|
215
|
+
if (!(0, registry_1.hasMethod)(client, ['chat', 'send']) && !(0, registry_1.hasMethod)(client, ['responses', 'send']))
|
|
216
|
+
return false;
|
|
217
|
+
return 'credits' in client && 'generations' in client;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* The denomination, refused when it cannot divide a price.
|
|
221
|
+
*
|
|
222
|
+
* `NaN` and `Infinity` make `toUnits` produce a value the ingest rejects a call later, and zero or
|
|
223
|
+
* a negative clamps every cost to the floor of 1 - a bill that looks plausible and is not. Both
|
|
224
|
+
* are configuration mistakes worth naming where they were made.
|
|
225
|
+
*/
|
|
226
|
+
function positiveDenomination(value, name) {
|
|
227
|
+
if (value === undefined)
|
|
228
|
+
return 1_000_000;
|
|
229
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
230
|
+
throw new Error(`${name} must be a finite positive number - got ${value}, which prices every call wrongly.`);
|
|
231
|
+
}
|
|
232
|
+
return value;
|
|
233
|
+
}
|
|
@@ -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
|
+
}
|