@stigg/agent-sdk-js 0.1.0 → 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 +118 -3
- package/dist/bindings/client/core/types.d.ts +9 -0
- package/dist/bindings/client/core/wrap-client.js +5 -2
- 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.js +6 -19
- package/dist/bindings/client/gateways/openrouter/openrouter-recipe.js +3 -14
- package/dist/bindings/client/providers/groq/groq-recipe.js +3 -20
- package/dist/bindings/shared/base-url.d.ts +44 -0
- package/dist/bindings/shared/base-url.js +90 -0
- package/dist/bindings/shared/token-usage.d.ts +106 -1
- package/dist/bindings/shared/token-usage.js +162 -1
- package/dist/bindings/shared/types.d.ts +41 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +19 -1
- package/dist/internal.d.ts +1 -1
- package/dist/internal.js +5 -1
- package/dist/stigg-agent-sdk.d.ts +1 -1
- package/dist/stigg-agent-sdk.js +13 -0
- package/dist/types.d.ts +35 -1
- package/package.json +7 -2
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.databricksExternalRecipe = exports.databricksRecipe = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* The Databricks recipes - one platform, two payers, and no price on either.
|
|
6
|
+
*
|
|
7
|
+
* Databricks speaks the OpenAI wire, so `openAIRecipe` fits both clients by shape. What it cannot
|
|
8
|
+
* express is identity: on `/serving-endpoints` the `model` argument is a serving *endpoint* name
|
|
9
|
+
* the customer invented, so `"prod-chat"` may front DBRX, a hosted Llama, or somebody's OpenAI
|
|
10
|
+
* account - and metered generically it reports as `openai.*` with no dimensions at all. The
|
|
11
|
+
* response's `model` is what recovers that, and putting it on the ingest is most of why these
|
|
12
|
+
* recipes exist.
|
|
13
|
+
*
|
|
14
|
+
* ## Two recipes, because two parties are invoiced
|
|
15
|
+
*
|
|
16
|
+
* Foundation Model APIs are billed by Databricks in DBUs (`databricksRecipe`). An endpoint
|
|
17
|
+
* configured with your own OpenAI or Anthropic key is billed by that upstream
|
|
18
|
+
* (`databricksExternalRecipe`).
|
|
19
|
+
*
|
|
20
|
+
* **Nothing in the request or response tells them apart** - same host, same path, same wire. The
|
|
21
|
+
* difference lives in server-side endpoint config, so the split is not detected, it is *asserted*
|
|
22
|
+
* by naming the second recipe at `wrap`. Assert wrong and nothing throws: DBU traffic lands on
|
|
23
|
+
* your upstream meter, or upstream traffic on your Databricks one. The event name is the only
|
|
24
|
+
* durable record of which you asserted, which is why these are two vendors and not one vendor
|
|
25
|
+
* with a dimension - a meter filter can drop a dimension.
|
|
26
|
+
*
|
|
27
|
+
* Only `databricksRecipe` is registered by default; an assertion nobody made is not a default.
|
|
28
|
+
*
|
|
29
|
+
* ## No cost, on either route
|
|
30
|
+
*
|
|
31
|
+
* Databricks states no price on any response, body or header. DBUs reconcile into
|
|
32
|
+
* `system.billing.usage` and external spend into `system.ai_gateway.external_model_spend`, both
|
|
33
|
+
* asynchronously. So `metrics` has no `cost` rather than a zero a mapper would multiply.
|
|
34
|
+
*
|
|
35
|
+
* `extractUsage` never sees request parameters, so the endpoint *asked for* cannot be recorded -
|
|
36
|
+
* only the model that answered. Add it yourself where it matters:
|
|
37
|
+
* `stiggContext: { dimensions: { endpoint: 'prod-chat' } }`.
|
|
38
|
+
*
|
|
39
|
+
* ## Two clients, one recipe
|
|
40
|
+
*
|
|
41
|
+
* An `OpenAI` client at the workspace (`chat.completions.create`, `responses.create`, matched on
|
|
42
|
+
* the URL) and `@databricks/sdk-modelservingquery`, whose entire surface is `client.query(...)`
|
|
43
|
+
* (`databricks.query`, matched on client shape - see {@link isDatabricksQueryClient} for why, and why its
|
|
44
|
+
* failure mode is a loud throw).
|
|
45
|
+
*
|
|
46
|
+
* One Databricks account is billed whichever the application imported, so they share a vendor and
|
|
47
|
+
* differ only in event name. The native client is generated and hands the caller `promptTokens`
|
|
48
|
+
* where the wire said `prompt_tokens`, so it reads through `DATABRICKS_QUERY_METRIC_NAME_MAP` and
|
|
49
|
+
* reports the wire's names - otherwise one workspace would produce two feature ids from a choice
|
|
50
|
+
* that has nothing to do with billing. It reports three counters and no cache pair, absent rather
|
|
51
|
+
* than zero, and does not stream: `query` resolves to a parsed body whose schema rejects SSE.
|
|
52
|
+
*/
|
|
53
|
+
const meter_stream_1 = require("../../../shared/meter-stream");
|
|
54
|
+
const openai_recipe_1 = require("../../providers/openai/openai-recipe");
|
|
55
|
+
const token_usage_1 = require("../../../shared/token-usage");
|
|
56
|
+
const base_url_1 = require("../../../shared/base-url");
|
|
57
|
+
const registry_1 = require("../../core/registry");
|
|
58
|
+
/**
|
|
59
|
+
* The three public workspace families, one per cloud.
|
|
60
|
+
*
|
|
61
|
+
* None is more canonical than the others, which is the difference from a single-host gateway like
|
|
62
|
+
* OpenRouter: the matcher needs the set. A PrivateLink or custom-DNS workspace is in none of them
|
|
63
|
+
* and supplies `baseUrl` instead.
|
|
64
|
+
*/
|
|
65
|
+
const WORKSPACE_HOSTS = ['cloud.databricks.com', 'azuredatabricks.net', 'gcp.databricks.com'];
|
|
66
|
+
/**
|
|
67
|
+
* The two paths a serving endpoint is reached on.
|
|
68
|
+
*
|
|
69
|
+
* `/serving-endpoints` is the classic route; `/ai-gateway/mlflow/v1` is the newer Unity Gateway
|
|
70
|
+
* one. Checked as well as the host because the host families above are shared with every other
|
|
71
|
+
* Databricks API - a workspace URL alone says "Databricks", not "inference".
|
|
72
|
+
*/
|
|
73
|
+
const SERVING_PATHS = ['/serving-endpoints', '/ai-gateway/mlflow/v1'];
|
|
74
|
+
/**
|
|
75
|
+
* Which served model answered, from wherever this deployment put it.
|
|
76
|
+
*
|
|
77
|
+
* `served-model-name` is documented as a response header and *also* appears in the body - the
|
|
78
|
+
* official JS client parses it off the payload, the REST reference calls it a header - so both are
|
|
79
|
+
* read rather than betting on one. It names the served entity behind a traffic-split endpoint,
|
|
80
|
+
* which is a finer thing than `model`, so it rides as its own dimension when present instead of
|
|
81
|
+
* overwriting the model identity.
|
|
82
|
+
*/
|
|
83
|
+
function servedEntity(result, response) {
|
|
84
|
+
const fromBody = result?.['served-model-name'] ?? result?.served_model_name ?? result?.servedModelName;
|
|
85
|
+
if (typeof fromBody === 'string' && fromBody !== '')
|
|
86
|
+
return fromBody;
|
|
87
|
+
const fromHeader = response?.headers?.get('served-model-name');
|
|
88
|
+
return fromHeader == null || fromHeader === '' ? undefined : fromHeader;
|
|
89
|
+
}
|
|
90
|
+
function measure(usage, model, id, served, modelDimension, keys = token_usage_1.DATABRICKS_PATHS) {
|
|
91
|
+
if (!usage)
|
|
92
|
+
return undefined;
|
|
93
|
+
// Databricks' own six names, verbatim. The first three are always there; the cache pair and
|
|
94
|
+
// `reasoning_tokens` are Foundation Model API extensions, and `pickMetrics` drops what a given
|
|
95
|
+
// endpoint did not report rather than reporting it as a zero.
|
|
96
|
+
const metrics = (0, token_usage_1.pickMetrics)(usage, keys);
|
|
97
|
+
const dimensions = {};
|
|
98
|
+
if (model)
|
|
99
|
+
dimensions[modelDimension] = model;
|
|
100
|
+
// A finer fact than the model, and only some endpoints have one: which served entity behind a
|
|
101
|
+
// traffic split answered. Absent for every endpoint that is not split, which is most of them.
|
|
102
|
+
if (served && served !== model)
|
|
103
|
+
dimensions.served_model_name = served;
|
|
104
|
+
return {
|
|
105
|
+
metrics,
|
|
106
|
+
usage: (0, token_usage_1.fromDatabricksUsage)(usage),
|
|
107
|
+
raw: usage,
|
|
108
|
+
dimensions: Object.keys(dimensions).length > 0 ? dimensions : undefined,
|
|
109
|
+
// The completion id. Databricks publishes no proxy-level request id on the response the way
|
|
110
|
+
// LiteLLM does, so this is the best stable key available - it is what a replayed response
|
|
111
|
+
// would carry again.
|
|
112
|
+
idempotencyKey: id,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The first-party client's one surface. One segment, which is why it is kept out of `GatedPath` -
|
|
117
|
+
* see `DatabricksGatedPath` in `src/types.ts`.
|
|
118
|
+
*/
|
|
119
|
+
const QUERY_PATH = ['query'];
|
|
120
|
+
/**
|
|
121
|
+
* The marker that makes `query` safe to claim: the user agent this SDK builds at construction.
|
|
122
|
+
*
|
|
123
|
+
* `'sdk-js-' + pkgJson.name.replace(/^@[^/]+\/sdk-/, '')`, so the string names the package.
|
|
124
|
+
*/
|
|
125
|
+
const QUERY_CLIENT_MARKER = 'sdk-js-modelservingquery';
|
|
126
|
+
/**
|
|
127
|
+
* Whether this is `@databricks/sdk-modelservingquery`'s client.
|
|
128
|
+
*
|
|
129
|
+
* Both halves are required. `query` alone is a database client - every Postgres, MySQL, Snowflake
|
|
130
|
+
* and BigQuery client has one, and claiming them would meter a database call as `databricks.query`.
|
|
131
|
+
* The marker alone is not callable.
|
|
132
|
+
*
|
|
133
|
+
* The awkward part is that this client publishes nothing else: its `.d.ts` declares exactly one
|
|
134
|
+
* public member, and `options`, `logger`, `userAgent` and `config` are all `private`. So any second
|
|
135
|
+
* marker is a field the SDK does not publish. `userAgent` is the one chosen because the constructor
|
|
136
|
+
* sets it unconditionally; `config` was rejected because it is memoised lazily and is `undefined`
|
|
137
|
+
* until the first request, so a fresh client would not match.
|
|
138
|
+
*
|
|
139
|
+
* A future version renaming that field breaks the match, and that cost is acceptable only because
|
|
140
|
+
* of how it fails: no recipe matches, and `wrap()` throws naming what it supports - loudly, before
|
|
141
|
+
* one call is metered. Nothing else here claims a bare `query` client, so there is no recipe to
|
|
142
|
+
* fall through to and no silent mismeter. A caller who hits it names the recipe instead.
|
|
143
|
+
*
|
|
144
|
+
* The false-positive direction is the silent one, and it is closed: the marker names the package,
|
|
145
|
+
* so a wrong claim needs an object exposing `query` *and* advertising this exact SDK.
|
|
146
|
+
*
|
|
147
|
+
* Note the two routes are matched on different evidence, deliberately. The OpenAI route is a base
|
|
148
|
+
* URL, because its client shape says nothing; the native route is shape, because its host sits in
|
|
149
|
+
* a private field that is often unset anyway - Databricks resolves it from `~/.databrickscfg` or
|
|
150
|
+
* `DATABRICKS_HOST`. So an `options.baseUrl` given for the OpenAI route does not constrain this
|
|
151
|
+
* one; there would be nothing to compare against.
|
|
152
|
+
*/
|
|
153
|
+
function isDatabricksQueryClient(client) {
|
|
154
|
+
if (!(0, registry_1.hasMethod)(client, QUERY_PATH))
|
|
155
|
+
return false;
|
|
156
|
+
const agent = client.userAgent;
|
|
157
|
+
return typeof agent === 'string' && agent.includes(QUERY_CLIENT_MARKER);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* What a `query` response is worth, projected rather than passed through.
|
|
161
|
+
*
|
|
162
|
+
* `raw` is built from the renamed counters and **never** from the response, for a harder reason
|
|
163
|
+
* than Ollama's. `created` is a `bigint`, and `JSON.stringify` throws outright on one - "Do not
|
|
164
|
+
* know how to serialize a BigInt" - so letting the response reach `raw` or a dimension would not
|
|
165
|
+
* flatten a reply into event dimensions the way Ollama's would, it would fail the ingest. The
|
|
166
|
+
* counters are projected explicitly and nothing else crosses.
|
|
167
|
+
*/
|
|
168
|
+
function measureQuery(result, modelDimension) {
|
|
169
|
+
const usage = result?.usage;
|
|
170
|
+
if (!usage)
|
|
171
|
+
return undefined;
|
|
172
|
+
// Read camelCase, report the wire's snake_case - so this surface and the OpenAI one above
|
|
173
|
+
// produce the same feature ids for the same workspace. The whole point of the map.
|
|
174
|
+
const metrics = (0, token_usage_1.pickRenamedMetrics)(usage, token_usage_1.DATABRICKS_QUERY_METRIC_NAME_MAP);
|
|
175
|
+
if (Object.keys(metrics).length === 0)
|
|
176
|
+
return undefined;
|
|
177
|
+
const dimensions = {};
|
|
178
|
+
const model = typeof result?.model === 'string' ? result.model : undefined;
|
|
179
|
+
if (model)
|
|
180
|
+
dimensions[modelDimension] = model;
|
|
181
|
+
// The same served entity the OpenAI route reads off `served-model-name`; this client exposes it
|
|
182
|
+
// camelCased, which `servedEntity` already looks for.
|
|
183
|
+
const served = servedEntity(result, undefined);
|
|
184
|
+
if (served && served !== model)
|
|
185
|
+
dimensions.served_model_name = served;
|
|
186
|
+
// Which of the four workloads behind this one method ran. See the file header.
|
|
187
|
+
if (typeof result?.object === 'string' && result.object !== '')
|
|
188
|
+
dimensions.response_object = result.object;
|
|
189
|
+
return {
|
|
190
|
+
metrics,
|
|
191
|
+
usage: (0, token_usage_1.fromDatabricksQueryUsage)(usage),
|
|
192
|
+
raw: { ...metrics },
|
|
193
|
+
dimensions: Object.keys(dimensions).length > 0 ? dimensions : undefined,
|
|
194
|
+
// The completion id, as on the OpenAI route. A string here; `created` beside it is the bigint
|
|
195
|
+
// and is deliberately never read.
|
|
196
|
+
idempotencyKey: typeof result?.id === 'string' ? result.id : undefined,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* One factory, two vendors.
|
|
201
|
+
*
|
|
202
|
+
* The recipes are identical in everything but the name they report under, because the traffic
|
|
203
|
+
* genuinely is identical - see the header. Sharing the body is the point: a divergence between
|
|
204
|
+
* them would be a claim about a difference the wire does not contain.
|
|
205
|
+
*/
|
|
206
|
+
function databricksRecipeFor(vendor, options) {
|
|
207
|
+
const estimate = options.estimate ?? (() => 1);
|
|
208
|
+
const modelDimension = options.modelDimension ?? 'model';
|
|
209
|
+
return {
|
|
210
|
+
vendor,
|
|
211
|
+
metrics: token_usage_1.DATABRICKS_METRICS,
|
|
212
|
+
// The OpenAI shape it speaks, so the OpenAI name for the total - and Databricks states one,
|
|
213
|
+
// so it is read rather than computed. Which matters more than usual here: the cache counters
|
|
214
|
+
// may or may not sit inside `prompt_tokens`, so a computed total could double-count.
|
|
215
|
+
totalTokensMetric: token_usage_1.TOTAL_TOKENS,
|
|
216
|
+
// Two clients, two kinds of evidence - see the file header. The first-party client is known by
|
|
217
|
+
// its own shape; an OpenAI client's shape says nothing, so for that route the base URL is the
|
|
218
|
+
// discriminator. Neither can separate the two Databricks *routes* from each other - that is
|
|
219
|
+
// the caller's assertion, not a detection.
|
|
220
|
+
matches: (client) => isDatabricksQueryClient(client) || (openai_recipe_1.openAIRecipe.matches(client) && isDatabricks(client, options.baseUrl)),
|
|
221
|
+
// Built from the OpenAI recipe's own surfaces, for the same reason as the OpenRouter and
|
|
222
|
+
// LiteLLM ones: this recipe is matched ahead of it, so a surface it failed to declare would go
|
|
223
|
+
// ungated rather than being metered by something else.
|
|
224
|
+
methods: [
|
|
225
|
+
// The first-party client. No `attachStream`: `query` has no streamed form - see the header.
|
|
226
|
+
{
|
|
227
|
+
path: QUERY_PATH,
|
|
228
|
+
estimate,
|
|
229
|
+
extractUsage: (result) => measureQuery(result, modelDimension),
|
|
230
|
+
},
|
|
231
|
+
// The OpenAI-wire surfaces, built from the OpenAI recipe's own list.
|
|
232
|
+
...openai_recipe_1.openAIRecipe.methods.map((base) => {
|
|
233
|
+
const { streamed } = (0, openai_recipe_1.openAISurface)(base.path);
|
|
234
|
+
// Chat Completions and Responses spell the same counters differently, so each surface
|
|
235
|
+
// reads its own list. Sharing one would leave a Responses call reporting `total_tokens`
|
|
236
|
+
// alone, with no breakdown under it.
|
|
237
|
+
const keys = base.path.join('.') === 'responses.create' ? token_usage_1.DATABRICKS_RESPONSES_PATHS : token_usage_1.DATABRICKS_PATHS;
|
|
238
|
+
return {
|
|
239
|
+
path: base.path,
|
|
240
|
+
estimate,
|
|
241
|
+
extractUsage: (result, response) => measure(result?.usage, result?.model, result?.id, servedEntity(result, response), modelDimension, keys),
|
|
242
|
+
// Usage arrives once, at the end, and only when the caller asked for it: Databricks honours
|
|
243
|
+
// `stream_options: {include_usage: true}` and defaults it off. A caller who does not ask
|
|
244
|
+
// gets a stream that states nothing, and the metrics are then absent rather than zero.
|
|
245
|
+
attachStream: (stream, ingest, response) => {
|
|
246
|
+
let usage;
|
|
247
|
+
let model;
|
|
248
|
+
let id;
|
|
249
|
+
(0, meter_stream_1.meterStream)(stream, (chunk) => {
|
|
250
|
+
// Last write wins. Databricks' own docs disagree with themselves about whether usage
|
|
251
|
+
// rides every chunk or only the final one, so this reads it wherever it appears and
|
|
252
|
+
// keeps the last - which is correct under either reading.
|
|
253
|
+
const seen = streamed(chunk);
|
|
254
|
+
if (seen)
|
|
255
|
+
usage = seen;
|
|
256
|
+
// The Responses API nests its completion event - `{ response: { id, model, usage } }`
|
|
257
|
+
// - where Chat Completions puts all three at the chunk root. `streamed()` already
|
|
258
|
+
// reaches through for usage; the model and the id are read off the same envelope so
|
|
259
|
+
// a streamed Responses turn keeps its attribution and its idempotency key instead
|
|
260
|
+
// of falling back to a generated one.
|
|
261
|
+
const payload = chunk?.response ?? chunk;
|
|
262
|
+
if (payload?.model)
|
|
263
|
+
model = payload.model;
|
|
264
|
+
if (payload?.id)
|
|
265
|
+
id = payload.id;
|
|
266
|
+
}, () => {
|
|
267
|
+
const measurement = measure(usage, model, id, servedEntity({}, response), modelDimension, keys);
|
|
268
|
+
if (measurement)
|
|
269
|
+
ingest(measurement);
|
|
270
|
+
});
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
}),
|
|
274
|
+
],
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Databricks-hosted inference - Foundation Model APIs, billed to your Databricks account in DBUs.
|
|
279
|
+
*
|
|
280
|
+
* Registered by default and matched on the workspace URL, because reading a richer response is not
|
|
281
|
+
* a pricing decision anyone needs to opt into. What it adds over `openAIRecipe` is a vendor name
|
|
282
|
+
* and the model that answered, which on this platform is the only thing that says what an endpoint
|
|
283
|
+
* name meant.
|
|
284
|
+
*/
|
|
285
|
+
const databricksRecipe = (options = {}) => databricksRecipeFor('databricks', options);
|
|
286
|
+
exports.databricksRecipe = databricksRecipe;
|
|
287
|
+
/**
|
|
288
|
+
* The external-model route - Databricks proxies, your upstream provider invoices.
|
|
289
|
+
*
|
|
290
|
+
* Not registered by default: which route an endpoint serves is server-side configuration, so this
|
|
291
|
+
* is the caller asserting something about their own workspace rather than the SDK detecting it.
|
|
292
|
+
* Name it at `wrap(client, { recipe: databricksExternalRecipe() })`, and see the file header for
|
|
293
|
+
* what a wrong assertion costs.
|
|
294
|
+
*
|
|
295
|
+
* `databricks-external` rather than something further from `databricks`: the two sort together in
|
|
296
|
+
* a meter list, which is what you want when you are reading one account's traffic split across
|
|
297
|
+
* them, and `external` is Databricks' own word - an endpoint of this kind is configured under an
|
|
298
|
+
* `external_model` block.
|
|
299
|
+
*/
|
|
300
|
+
const databricksExternalRecipe = (options = {}) => databricksRecipeFor('databricks-external', options);
|
|
301
|
+
exports.databricksExternalRecipe = databricksExternalRecipe;
|
|
302
|
+
/**
|
|
303
|
+
* Whether this client points at a Databricks serving endpoint.
|
|
304
|
+
*
|
|
305
|
+
* Given a `baseUrl`, that endpoint and anything beneath it - nothing weaker, because a prefix
|
|
306
|
+
* match on the raw string lets `.../v1` claim `.../v10`.
|
|
307
|
+
*
|
|
308
|
+
* Without one, the guess is narrow on purpose and needs *both* halves: one of the three public
|
|
309
|
+
* workspace host families, and one of the two serving paths. The host alone is every Databricks
|
|
310
|
+
* API there is, most of which are not inference and must not be claimed; the path alone would let
|
|
311
|
+
* any unrelated service that happens to expose `/serving-endpoints` be metered as Databricks. A
|
|
312
|
+
* PrivateLink workspace satisfies neither half and takes `baseUrl`.
|
|
313
|
+
*/
|
|
314
|
+
function isDatabricks(client, baseUrl) {
|
|
315
|
+
const clientBase = (0, base_url_1.baseUrlOf)(client);
|
|
316
|
+
if (clientBase === undefined)
|
|
317
|
+
return false;
|
|
318
|
+
if (baseUrl)
|
|
319
|
+
return (0, base_url_1.withinBaseUrl)(baseUrl, clientBase);
|
|
320
|
+
if (!(0, base_url_1.matchesAnyHost)(clientBase, WORKSPACE_HOSTS))
|
|
321
|
+
return false;
|
|
322
|
+
const path = (0, base_url_1.pathOf)(clientBase);
|
|
323
|
+
return SERVING_PATHS.some((serving) => path === serving || path.startsWith(`${serving}/`));
|
|
324
|
+
}
|
|
@@ -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
|
+
};
|
|
@@ -23,6 +23,7 @@ exports.litellmRecipe = litellmRecipe;
|
|
|
23
23
|
* self-hosted proxy, to give it the base URL its matcher cannot guess.
|
|
24
24
|
*/
|
|
25
25
|
const meter_stream_1 = require("../../../shared/meter-stream");
|
|
26
|
+
const base_url_1 = require("../../../shared/base-url");
|
|
26
27
|
const openai_recipe_1 = require("../../providers/openai/openai-recipe");
|
|
27
28
|
const token_usage_1 = require("../../../shared/token-usage");
|
|
28
29
|
/** The cost LiteLLM charged for this call, in USD. Non-streaming responses only. */
|
|
@@ -130,30 +131,16 @@ function litellmRecipe(options = {}) {
|
|
|
130
131
|
* via `wrap(client, run, { recipe })`.
|
|
131
132
|
*/
|
|
132
133
|
function isLiteLLM(client, baseUrl) {
|
|
133
|
-
const clientBase = client
|
|
134
|
-
if (
|
|
134
|
+
const clientBase = (0, base_url_1.baseUrlOf)(client);
|
|
135
|
+
if (clientBase === undefined)
|
|
135
136
|
return false;
|
|
136
137
|
// A prefix match on the raw string lets `.../v1` claim `.../v10`, which is a different
|
|
137
|
-
// deployment with a different price list.
|
|
138
|
+
// deployment with a different price list. `withinBaseUrl` compares the endpoint, one path
|
|
139
|
+
// segment at a time.
|
|
138
140
|
if (baseUrl)
|
|
139
|
-
return
|
|
141
|
+
return (0, base_url_1.withinBaseUrl)(baseUrl, clientBase);
|
|
140
142
|
return clientBase.toLowerCase().includes('litellm');
|
|
141
143
|
}
|
|
142
|
-
/** Whether `candidate` is the configured endpoint, or something under it. */
|
|
143
|
-
function within(configured, candidate) {
|
|
144
|
-
try {
|
|
145
|
-
const want = new URL(configured);
|
|
146
|
-
const got = new URL(candidate);
|
|
147
|
-
if (want.origin !== got.origin)
|
|
148
|
-
return false;
|
|
149
|
-
const base = want.pathname.replace(/\/+$/, '');
|
|
150
|
-
const path = got.pathname.replace(/\/+$/, '');
|
|
151
|
-
return path === base || path.startsWith(`${base}/`);
|
|
152
|
-
}
|
|
153
|
-
catch {
|
|
154
|
-
return false;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
144
|
/**
|
|
158
145
|
* The denomination, refused when it cannot divide a price.
|
|
159
146
|
*
|
|
@@ -24,6 +24,7 @@ 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
26
|
const registry_1 = require("../../core/registry");
|
|
27
|
+
const base_url_1 = require("../../../shared/base-url");
|
|
27
28
|
const sdk_usage_1 = require("./sdk-usage");
|
|
28
29
|
/** Float noise: 0.00108 * 1e6 is 1080.0000000000002, and ceil would bill the extra unit. */
|
|
29
30
|
const toUnits = (cost, unitsPerCredit) => Math.max(1, Math.ceil(Number((cost * unitsPerCredit).toPrecision(12))));
|
|
@@ -189,13 +190,10 @@ function openrouterRecipe(options = {}) {
|
|
|
189
190
|
* endpoint on another host takes the recipe explicitly, via `wrap(client, run, { recipe })`.
|
|
190
191
|
*/
|
|
191
192
|
function isOpenRouter(client) {
|
|
192
|
-
const baseUrl = client
|
|
193
|
-
if (typeof baseUrl !== 'string')
|
|
194
|
-
return false;
|
|
193
|
+
const baseUrl = (0, base_url_1.baseUrlOf)(client);
|
|
195
194
|
// The host, not a substring of the URL: `openrouter.ai.example.com` is somebody else's domain,
|
|
196
195
|
// and claiming it would meter an unrelated client as a gateway.
|
|
197
|
-
|
|
198
|
-
return host === 'openrouter.ai' || host.endsWith('.openrouter.ai');
|
|
196
|
+
return baseUrl !== undefined && (0, base_url_1.matchesHost)(baseUrl, 'openrouter.ai');
|
|
199
197
|
}
|
|
200
198
|
/**
|
|
201
199
|
* Whether this is OpenRouter's own SDK client, which carries no base URL to recognise it by.
|
|
@@ -218,15 +216,6 @@ function isOpenRouterSdk(client) {
|
|
|
218
216
|
return false;
|
|
219
217
|
return 'credits' in client && 'generations' in client;
|
|
220
218
|
}
|
|
221
|
-
/** The hostname, lowercased, or '' when the URL is not one this can reason about. */
|
|
222
|
-
function hostOf(url) {
|
|
223
|
-
try {
|
|
224
|
-
return new URL(url).hostname.toLowerCase();
|
|
225
|
-
}
|
|
226
|
-
catch {
|
|
227
|
-
return '';
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
219
|
/**
|
|
231
220
|
* The denomination, refused when it cannot divide a price.
|
|
232
221
|
*
|
|
@@ -51,23 +51,9 @@ exports.groqRecipe = void 0;
|
|
|
51
51
|
*/
|
|
52
52
|
const token_usage_1 = require("../../../shared/token-usage");
|
|
53
53
|
const openai_recipe_1 = require("../openai/openai-recipe");
|
|
54
|
+
const base_url_1 = require("../../../shared/base-url");
|
|
54
55
|
/** The one surface Groq serves, as the OpenAI recipe addresses it. */
|
|
55
56
|
const CHAT_PATH = ['chat', 'completions', 'create'];
|
|
56
|
-
/**
|
|
57
|
-
* The hostname, lowercased, or '' when the URL is not one this can reason about.
|
|
58
|
-
*
|
|
59
|
-
* Duplicated from the gateway recipes rather than shared, as they duplicate it from each other -
|
|
60
|
-
* three lines, and hoisting it would put a URL helper in the shared vocabulary file, which is
|
|
61
|
-
* about counters.
|
|
62
|
-
*/
|
|
63
|
-
function hostOf(url) {
|
|
64
|
-
try {
|
|
65
|
-
return new URL(url).hostname.toLowerCase();
|
|
66
|
-
}
|
|
67
|
-
catch {
|
|
68
|
-
return '';
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
57
|
/**
|
|
72
58
|
* `baseURL` is a public field on both clients that reach Groq, and the only thing that says which
|
|
73
59
|
* vendor is on the other end.
|
|
@@ -80,11 +66,8 @@ function hostOf(url) {
|
|
|
80
66
|
* `wrap(client, { recipe: groqRecipe })` to attribute such a client to Groq explicitly.
|
|
81
67
|
*/
|
|
82
68
|
function isGroq(client) {
|
|
83
|
-
const baseUrl = client
|
|
84
|
-
|
|
85
|
-
return false;
|
|
86
|
-
const host = hostOf(baseUrl);
|
|
87
|
-
return host === 'api.groq.com' || host.endsWith('.api.groq.com');
|
|
69
|
+
const baseUrl = (0, base_url_1.baseUrlOf)(client);
|
|
70
|
+
return baseUrl !== undefined && (0, base_url_1.matchesHost)(baseUrl, 'api.groq.com');
|
|
88
71
|
}
|
|
89
72
|
exports.groqRecipe = {
|
|
90
73
|
vendor: 'groq',
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a client's `baseURL`, which is the only thing that says which vendor is on the far end.
|
|
3
|
+
*
|
|
4
|
+
* Shape cannot answer that question: every OpenAI-compatible endpoint has the same methods, so
|
|
5
|
+
* `matches` falls back to the URL, and a wrong answer there does not throw - it silently meters
|
|
6
|
+
* one vendor's traffic under another vendor's name and bills the wrong party. That is what makes
|
|
7
|
+
* these few lines the most consequential ones in a binding, and why they live in one place rather
|
|
8
|
+
* than being written out per recipe.
|
|
9
|
+
*
|
|
10
|
+
* The subdomain rule below is the part worth getting exactly right, and the part a hand-written
|
|
11
|
+
* copy tends to get wrong. Five recipes match on a host - Groq, OpenRouter, LiteLLM, Databricks
|
|
12
|
+
* and Cloudflare - and two of them need a *set* of host suffixes rather than one, with Databricks
|
|
13
|
+
* needing the path as well, because its host families cover every Databricks API and most of them
|
|
14
|
+
* are not inference.
|
|
15
|
+
*
|
|
16
|
+
* This is deliberately not in `token-usage.ts`. That file is the vocabulary of what a response
|
|
17
|
+
* counts, and a hostname is not a counter.
|
|
18
|
+
*/
|
|
19
|
+
/** The hostname, lowercased, or '' when the URL is not one this can reason about. */
|
|
20
|
+
export declare function hostOf(url: string): string;
|
|
21
|
+
/** The path, lowercased and without trailing slashes, or '' when the URL will not parse. */
|
|
22
|
+
export declare function pathOf(url: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* Whether a URL is served by `expected` or a subdomain of it.
|
|
25
|
+
*
|
|
26
|
+
* The dot is the whole point. A substring test lets `api.vendor.com.attacker.net` through, and an
|
|
27
|
+
* `endsWith` without it lets `notvendor.com` match `vendor.com` - both are somebody else's domain,
|
|
28
|
+
* and claiming either attributes their traffic to this vendor.
|
|
29
|
+
*/
|
|
30
|
+
export declare function matchesHost(url: string, expected: string): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* The same test against several hosts, for a platform that serves one product from more than one
|
|
33
|
+
* domain - Databricks has a hostname per cloud, and none of them is more canonical than the rest.
|
|
34
|
+
*/
|
|
35
|
+
export declare function matchesAnyHost(url: string, expected: readonly string[]): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Whether `candidate` is the configured endpoint, or something underneath it.
|
|
38
|
+
*
|
|
39
|
+
* A prefix match on the raw string lets `.../v1` claim `.../v10`, which is a different deployment
|
|
40
|
+
* with a different price list. Compare the origin, then the path one whole segment at a time.
|
|
41
|
+
*/
|
|
42
|
+
export declare function withinBaseUrl(configured: string, candidate: string): boolean;
|
|
43
|
+
/** A client's configured base URL, when it has one this can read. */
|
|
44
|
+
export declare function baseUrlOf(client: unknown): string | undefined;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Reading a client's `baseURL`, which is the only thing that says which vendor is on the far end.
|
|
4
|
+
*
|
|
5
|
+
* Shape cannot answer that question: every OpenAI-compatible endpoint has the same methods, so
|
|
6
|
+
* `matches` falls back to the URL, and a wrong answer there does not throw - it silently meters
|
|
7
|
+
* one vendor's traffic under another vendor's name and bills the wrong party. That is what makes
|
|
8
|
+
* these few lines the most consequential ones in a binding, and why they live in one place rather
|
|
9
|
+
* than being written out per recipe.
|
|
10
|
+
*
|
|
11
|
+
* The subdomain rule below is the part worth getting exactly right, and the part a hand-written
|
|
12
|
+
* copy tends to get wrong. Five recipes match on a host - Groq, OpenRouter, LiteLLM, Databricks
|
|
13
|
+
* and Cloudflare - and two of them need a *set* of host suffixes rather than one, with Databricks
|
|
14
|
+
* needing the path as well, because its host families cover every Databricks API and most of them
|
|
15
|
+
* are not inference.
|
|
16
|
+
*
|
|
17
|
+
* This is deliberately not in `token-usage.ts`. That file is the vocabulary of what a response
|
|
18
|
+
* counts, and a hostname is not a counter.
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.hostOf = hostOf;
|
|
22
|
+
exports.pathOf = pathOf;
|
|
23
|
+
exports.matchesHost = matchesHost;
|
|
24
|
+
exports.matchesAnyHost = matchesAnyHost;
|
|
25
|
+
exports.withinBaseUrl = withinBaseUrl;
|
|
26
|
+
exports.baseUrlOf = baseUrlOf;
|
|
27
|
+
/** The hostname, lowercased, or '' when the URL is not one this can reason about. */
|
|
28
|
+
function hostOf(url) {
|
|
29
|
+
try {
|
|
30
|
+
return new URL(url).hostname.toLowerCase();
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return '';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** The path, lowercased and without trailing slashes, or '' when the URL will not parse. */
|
|
37
|
+
function pathOf(url) {
|
|
38
|
+
try {
|
|
39
|
+
return new URL(url).pathname.toLowerCase().replace(/\/+$/, '');
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return '';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Whether a URL is served by `expected` or a subdomain of it.
|
|
47
|
+
*
|
|
48
|
+
* The dot is the whole point. A substring test lets `api.vendor.com.attacker.net` through, and an
|
|
49
|
+
* `endsWith` without it lets `notvendor.com` match `vendor.com` - both are somebody else's domain,
|
|
50
|
+
* and claiming either attributes their traffic to this vendor.
|
|
51
|
+
*/
|
|
52
|
+
function matchesHost(url, expected) {
|
|
53
|
+
const host = hostOf(url);
|
|
54
|
+
const want = expected.toLowerCase();
|
|
55
|
+
if (!host || !want)
|
|
56
|
+
return false;
|
|
57
|
+
return host === want || host.endsWith(`.${want}`);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The same test against several hosts, for a platform that serves one product from more than one
|
|
61
|
+
* domain - Databricks has a hostname per cloud, and none of them is more canonical than the rest.
|
|
62
|
+
*/
|
|
63
|
+
function matchesAnyHost(url, expected) {
|
|
64
|
+
return expected.some((host) => matchesHost(url, host));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Whether `candidate` is the configured endpoint, or something underneath it.
|
|
68
|
+
*
|
|
69
|
+
* A prefix match on the raw string lets `.../v1` claim `.../v10`, which is a different deployment
|
|
70
|
+
* with a different price list. Compare the origin, then the path one whole segment at a time.
|
|
71
|
+
*/
|
|
72
|
+
function withinBaseUrl(configured, candidate) {
|
|
73
|
+
try {
|
|
74
|
+
const want = new URL(configured);
|
|
75
|
+
const got = new URL(candidate);
|
|
76
|
+
if (want.origin !== got.origin)
|
|
77
|
+
return false;
|
|
78
|
+
const base = want.pathname.replace(/\/+$/, '');
|
|
79
|
+
const path = got.pathname.replace(/\/+$/, '');
|
|
80
|
+
return path === base || path.startsWith(`${base}/`);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** A client's configured base URL, when it has one this can read. */
|
|
87
|
+
function baseUrlOf(client) {
|
|
88
|
+
const configured = client?.baseURL;
|
|
89
|
+
return typeof configured === 'string' ? configured : undefined;
|
|
90
|
+
}
|