@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,254 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.cloudflareRecipe = cloudflareRecipe;
|
|
4
|
+
/**
|
|
5
|
+
* The Cloudflare recipe - one vendor over two products.
|
|
6
|
+
*
|
|
7
|
+
* **Workers AI** runs open-weight models on Cloudflare's GPUs, reached as `client.ai.run(...)` or
|
|
8
|
+
* as an `OpenAI` client at `api.cloudflare.com`. **AI Gateway** fronts somebody else's provider at
|
|
9
|
+
* `gateway.ai.cloudflare.com/v1/<account>/<gateway>/<provider>` and forwards that provider's body
|
|
10
|
+
* back untouched. One recipe, because one payer: Cloudflare invoices either way, and the vendor is
|
|
11
|
+
* whoever bills. Two vendor names would split one account across two meters.
|
|
12
|
+
*
|
|
13
|
+
* Filed under gateways although Workers AI alone looks provider-shaped - the same shape
|
|
14
|
+
* `groqRecipe` is argued from. That is deliberate: the category follows how a platform is sold,
|
|
15
|
+
* not how it is built. See the positioning section of the `govern-new-platform` skill.
|
|
16
|
+
*
|
|
17
|
+
* Two things follow from the gateway forwarding bodies verbatim, and both surprise people:
|
|
18
|
+
*
|
|
19
|
+
* - **Two counter vocabularies under one vendor.** `/openai` reports `prompt_tokens` and
|
|
20
|
+
* `/anthropic` reports `input_tokens`, so a meter watching one misses the other. Nothing is
|
|
21
|
+
* renamed - a counter has to reconcile against its own upstream's dashboard - so price both,
|
|
22
|
+
* or use `normalizedMetrics`. `metrics` is the union, which is what `metrics` is defined to be.
|
|
23
|
+
* - **A cache hit is a free call carrying a full `usage` block.** See {@link isCacheHit}: metered
|
|
24
|
+
* generically, every hit bills as real usage, and caching is this gateway's headline feature.
|
|
25
|
+
*
|
|
26
|
+
* Neither product states a price anywhere, so no `cost` metric is declared and `cf-aig-log-id`
|
|
27
|
+
* rides as a dimension for out-of-band reconciliation.
|
|
28
|
+
*/
|
|
29
|
+
const meter_stream_1 = require("../../../shared/meter-stream");
|
|
30
|
+
const token_usage_1 = require("../../../shared/token-usage");
|
|
31
|
+
const base_url_1 = require("../../../shared/base-url");
|
|
32
|
+
const registry_1 = require("../../core/registry");
|
|
33
|
+
const openai_recipe_1 = require("../../providers/openai/openai-recipe");
|
|
34
|
+
const anthropic_recipe_1 = require("../../providers/anthropic/anthropic-recipe");
|
|
35
|
+
const types_1 = require("../../../../types");
|
|
36
|
+
/** Workers AI, both of its routes. */
|
|
37
|
+
const CLOUDFLARE_API_HOST = 'api.cloudflare.com';
|
|
38
|
+
/** AI Gateway's public endpoint. A custom domain needs `options.baseUrl` instead. */
|
|
39
|
+
const GATEWAY_HOST = 'gateway.ai.cloudflare.com';
|
|
40
|
+
/** `HIT` means the upstream was never called, so nobody was billed for this. */
|
|
41
|
+
const CACHE_STATUS_HEADER = 'cf-aig-cache-status';
|
|
42
|
+
/** The gateway's own log record, where its (estimated) cost figure can be read afterwards. */
|
|
43
|
+
const LOG_ID_HEADER = 'cf-aig-log-id';
|
|
44
|
+
/** Auto Router's choice, which can differ from the model the request named. */
|
|
45
|
+
const ROUTED_MODEL_HEADER = 'cf-aig-routed-model';
|
|
46
|
+
/** Which fallback step served: `0` is the first provider, anything higher means one failed over. */
|
|
47
|
+
const STEP_HEADER = 'cf-aig-step';
|
|
48
|
+
/** The gateway's own request id, stable across its retries - unlike a local step counter. */
|
|
49
|
+
const REQUEST_ID_HEADER = 'cf-aig-request-id';
|
|
50
|
+
const headerOf = (response, name) => {
|
|
51
|
+
const raw = response?.headers?.get(name);
|
|
52
|
+
return raw == null || raw === '' ? undefined : raw;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Whether the gateway served this from its cache.
|
|
56
|
+
*
|
|
57
|
+
* Readable on a streamed response as well as a buffered one, unlike a header-borne *cost*: whether
|
|
58
|
+
* the cache will answer is known before any token is produced, so this header is on the wire in
|
|
59
|
+
* time either way. That is the difference between this and `x-litellm-response-cost`, and it is why
|
|
60
|
+
* the streaming branch below checks it rather than giving up.
|
|
61
|
+
*
|
|
62
|
+
* Absent on the Workers AI routes, which have no cache in front of them - so this reads `false`
|
|
63
|
+
* there and the surfaces behave as ordinary metered calls.
|
|
64
|
+
*/
|
|
65
|
+
const isCacheHit = (response) => headerOf(response, CACHE_STATUS_HEADER)?.toUpperCase() === 'HIT';
|
|
66
|
+
/** What only the gateway knows, folded onto whatever the upstream's own reader produced. */
|
|
67
|
+
function withGatewayFacts(measured, result, response, modelDimension) {
|
|
68
|
+
const dimensions = { ...measured.dimensions };
|
|
69
|
+
// The model that actually served: the router's own word for it, falling back to whatever the
|
|
70
|
+
// forwarded body said. Anthropic's Messages body carries `model` too, so this works on both wires.
|
|
71
|
+
const served = headerOf(response, ROUTED_MODEL_HEADER) ?? (typeof result?.model === 'string' ? result.model : undefined);
|
|
72
|
+
if (served)
|
|
73
|
+
dimensions[modelDimension] = served;
|
|
74
|
+
// The substitute for a cost metric: the key to the gateway's own record of what this call cost.
|
|
75
|
+
const logId = headerOf(response, LOG_ID_HEADER);
|
|
76
|
+
if (logId)
|
|
77
|
+
dimensions.cf_aig_log_id = logId;
|
|
78
|
+
// Only when a fallback actually happened. Step `0` is the ordinary case and tagging every event
|
|
79
|
+
// with it would add a dimension to all of them for no information.
|
|
80
|
+
const step = headerOf(response, STEP_HEADER);
|
|
81
|
+
if (step !== undefined && step !== '0')
|
|
82
|
+
dimensions.cf_aig_step = step;
|
|
83
|
+
return {
|
|
84
|
+
...measured,
|
|
85
|
+
dimensions: Object.keys(dimensions).length > 0 ? dimensions : undefined,
|
|
86
|
+
idempotencyKey: headerOf(response, REQUEST_ID_HEADER) ?? measured.idempotencyKey ?? result?.id,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** The Workers AI reader: the three counters it states, under its own (OpenAI's) names. */
|
|
90
|
+
function measure(usage, model, id, modelDimension, keys = token_usage_1.WORKERS_AI_PATHS) {
|
|
91
|
+
if (!usage)
|
|
92
|
+
return undefined;
|
|
93
|
+
return {
|
|
94
|
+
// Anything absent is absent: a model that reported no counters meters nothing rather than a
|
|
95
|
+
// row of zeroes.
|
|
96
|
+
metrics: (0, token_usage_1.pickMetrics)(usage, keys),
|
|
97
|
+
// The OpenAI reader, because these are the OpenAI counters - and it finds no nested cache
|
|
98
|
+
// detail here, which is correct, because Workers AI sends none.
|
|
99
|
+
usage: (0, token_usage_1.fromOpenAIUsage)(usage),
|
|
100
|
+
raw: usage,
|
|
101
|
+
dimensions: model ? { [modelDimension]: model } : undefined,
|
|
102
|
+
idempotencyKey: id,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function cloudflareRecipe(options = {}) {
|
|
106
|
+
const estimate = options.estimate ?? (() => 1);
|
|
107
|
+
const modelDimension = options.modelDimension ?? 'model';
|
|
108
|
+
/**
|
|
109
|
+
* One upstream surface, wrapped.
|
|
110
|
+
*
|
|
111
|
+
* The upstream recipes' own extractors are reused rather than reimplemented, which is the point:
|
|
112
|
+
* AI Gateway forwards the provider's body untouched, so the provider's reader is by definition
|
|
113
|
+
* the right one - and Anthropic's streamed usage in particular accumulates across two event
|
|
114
|
+
* types, which is not logic worth having a second copy of. Off the gateway the wrapper is inert:
|
|
115
|
+
* no `cf-aig-*` header is present, so no cache hit is seen and no gateway dimension is added.
|
|
116
|
+
*/
|
|
117
|
+
const surfaceOf = (base) => ({
|
|
118
|
+
// Spread first so anything the base declares and this wrapper does not know about - like
|
|
119
|
+
// `contextArg`, which says where `stiggContext` rides - survives being wrapped. Rebuilding
|
|
120
|
+
// the object field by field silently dropped it.
|
|
121
|
+
...base,
|
|
122
|
+
path: base.path,
|
|
123
|
+
estimate,
|
|
124
|
+
extractUsage: (result, response) => {
|
|
125
|
+
// Nobody was invoiced for a cache hit, so nothing is reported for one. Checked before the
|
|
126
|
+
// body is read at all, because the body of a hit looks exactly like the body of a real call.
|
|
127
|
+
if (isCacheHit(response))
|
|
128
|
+
return undefined;
|
|
129
|
+
const measured = base.extractUsage?.(result, response);
|
|
130
|
+
return measured === undefined
|
|
131
|
+
? undefined
|
|
132
|
+
: withGatewayFacts((0, types_1.toMeasurement)(measured), result, response, modelDimension);
|
|
133
|
+
},
|
|
134
|
+
attachStream: base.attachStream
|
|
135
|
+
? (stream, ingest, response) => {
|
|
136
|
+
// A cached reply can be streamed back too, and the cache header arrives in time to say so.
|
|
137
|
+
// Nothing is attached at all in that case: the consumer's stream is left untouched and no
|
|
138
|
+
// measurement is produced, which is what a free call should report.
|
|
139
|
+
if (isCacheHit(response))
|
|
140
|
+
return;
|
|
141
|
+
// A second tap, purely for attribution. The upstream recipes' stream readers carry out
|
|
142
|
+
// counters and nothing else, so without this the streamed path had no `result` to fall
|
|
143
|
+
// back on and a turn with no `cf-aig-routed-model` header lost its model entirely -
|
|
144
|
+
// where the buffered path reads it straight off the body. Each wire states it in its own
|
|
145
|
+
// envelope: Anthropic on `message_start.message`, the Responses API on `response`, Chat
|
|
146
|
+
// Completions at the chunk root. `meterStream` captures what it patches before patching,
|
|
147
|
+
// so stacking two observers is safe and each sees every chunk exactly once.
|
|
148
|
+
let seen = {};
|
|
149
|
+
(0, meter_stream_1.meterStream)(stream, (chunk) => {
|
|
150
|
+
const envelope = chunk?.message ?? chunk?.response ?? chunk;
|
|
151
|
+
if (envelope?.model)
|
|
152
|
+
seen = { ...seen, model: envelope.model };
|
|
153
|
+
if (envelope?.id)
|
|
154
|
+
seen = { ...seen, id: envelope.id };
|
|
155
|
+
}, () => { });
|
|
156
|
+
base.attachStream(stream, (measurement) => ingest(withGatewayFacts((0, types_1.toMeasurement)(measurement), seen, response, modelDimension)), response);
|
|
157
|
+
}
|
|
158
|
+
: undefined,
|
|
159
|
+
});
|
|
160
|
+
/**
|
|
161
|
+
* Chat Completions, which both products answer on - Workers AI's OpenAI-compatible route and the
|
|
162
|
+
* gateway's `/openai` one.
|
|
163
|
+
*
|
|
164
|
+
* Its own reader rather than `openAIRecipe`'s, for one reason: the model has to be tracked
|
|
165
|
+
* across the chunks of a *streamed* turn. The generic reader does not carry it, and off the
|
|
166
|
+
* gateway there is no `cf-aig-routed-model` header to recover it from - so reusing that reader
|
|
167
|
+
* would silently drop model attribution on every streamed Workers AI call. The key list is the
|
|
168
|
+
* full OpenAI one so a gateway-forwarded `cached_tokens` is not dropped either; Workers AI sends
|
|
169
|
+
* none of those, and absent stays absent.
|
|
170
|
+
*/
|
|
171
|
+
const chatSurface = {
|
|
172
|
+
path: ['chat', 'completions', 'create'],
|
|
173
|
+
extractUsage: (result) => measure(result?.usage, result?.model, result?.id, modelDimension, token_usage_1.METRIC_PATHS.openaiChat),
|
|
174
|
+
attachStream: (stream, ingest) => {
|
|
175
|
+
let usage;
|
|
176
|
+
let model;
|
|
177
|
+
let id;
|
|
178
|
+
// The OpenAI recipe's own chunk reader, not a copy of it. AI Gateway forwards whatever the
|
|
179
|
+
// upstream sent, and an upstream can hide its usage somewhere other than the chunk root -
|
|
180
|
+
// Groq puts it on `x_groq` and sends no top-level `usage` at all - so reading `chunk.usage`
|
|
181
|
+
// here would meter a streamed Groq call through this gateway at nothing. Borrowing the
|
|
182
|
+
// reader keeps every upstream that recipe learns about working through this one too.
|
|
183
|
+
const readStreamedUsage = (0, openai_recipe_1.openAISurface)(['chat', 'completions', 'create']).streamed;
|
|
184
|
+
(0, meter_stream_1.meterStream)(stream, (chunk) => {
|
|
185
|
+
// Stated once and whole on the final chunk rather than as deltas, so the last wins.
|
|
186
|
+
const streamedUsage = readStreamedUsage(chunk);
|
|
187
|
+
if (streamedUsage)
|
|
188
|
+
usage = streamedUsage;
|
|
189
|
+
if (chunk?.model)
|
|
190
|
+
model = chunk.model;
|
|
191
|
+
if (chunk?.id)
|
|
192
|
+
id = chunk.id;
|
|
193
|
+
}, () => {
|
|
194
|
+
const measurement = measure(usage, model, id, modelDimension, token_usage_1.METRIC_PATHS.openaiChat);
|
|
195
|
+
if (measurement)
|
|
196
|
+
ingest(measurement);
|
|
197
|
+
});
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
/**
|
|
201
|
+
* The `cloudflare` package's own method.
|
|
202
|
+
*
|
|
203
|
+
* Two limits live here rather than in the file header, because they are facts about this method.
|
|
204
|
+
*
|
|
205
|
+
* It resolves to a **bare promise with no response object**, so no `cf-aig-*` header is reachable
|
|
206
|
+
* on this route - cache status included. A Workers AI call made this way through a gateway is
|
|
207
|
+
* metered as an ordinary call even when the gateway served it free. The `OpenAI`-client route
|
|
208
|
+
* has the headers and does not have this gap.
|
|
209
|
+
*
|
|
210
|
+
* And no `attachStream`, deliberately. A streamed `ai.run` resolves to raw SSE bytes
|
|
211
|
+
* rather than parsed chunks, and a reader attached to those would collect nothing and report it
|
|
212
|
+
* as a measurement of zero, which is the one failure shape nothing downstream can notice. Gated
|
|
213
|
+
* and unmetered is the honest outcome, and it is documented rather than discovered.
|
|
214
|
+
*/
|
|
215
|
+
const aiRun = {
|
|
216
|
+
path: ['ai', 'run'],
|
|
217
|
+
// `ai.run(model, inputs)` - the model is argument 0, so the context rides in argument 1. Left
|
|
218
|
+
// at the default this surface would forward a caller's `stiggContext` to Cloudflare as an
|
|
219
|
+
// unknown input and gate on the wrap-time context instead, which is the quiet kind of wrong.
|
|
220
|
+
contextArg: 1,
|
|
221
|
+
// No model: it was the first argument, and an extractor is handed only the result. No id
|
|
222
|
+
// either - this surface returns neither, so idempotency falls back to the caller's.
|
|
223
|
+
extractUsage: (result) => measure(result?.usage, undefined, undefined, modelDimension),
|
|
224
|
+
};
|
|
225
|
+
return {
|
|
226
|
+
vendor: 'cloudflare',
|
|
227
|
+
metrics: token_usage_1.CLOUDFLARE_METRICS,
|
|
228
|
+
// Every wire here spells its total `total_tokens` - Anthropic's is computed from its disjoint
|
|
229
|
+
// counters by the reader this reuses, OpenAI's and Workers AI's are stated.
|
|
230
|
+
totalTokensMetric: token_usage_1.TOTAL_TOKENS,
|
|
231
|
+
// The host decides, because shape cannot: an `OpenAI` client and an `Anthropic` client pointed
|
|
232
|
+
// at either product look exactly like direct clients. `ai.run` is checked as well as the two
|
|
233
|
+
// shapes because the `cloudflare` package's client has no `chat.completions`.
|
|
234
|
+
matches: (client) => {
|
|
235
|
+
const baseUrl = (0, base_url_1.baseUrlOf)(client);
|
|
236
|
+
if (baseUrl === undefined)
|
|
237
|
+
return false;
|
|
238
|
+
const onCloudflare = options.baseUrl
|
|
239
|
+
? (0, base_url_1.withinBaseUrl)(options.baseUrl, baseUrl)
|
|
240
|
+
: (0, base_url_1.matchesHost)(baseUrl, CLOUDFLARE_API_HOST) || (0, base_url_1.matchesHost)(baseUrl, GATEWAY_HOST);
|
|
241
|
+
if (!onCloudflare)
|
|
242
|
+
return false;
|
|
243
|
+
return (0, registry_1.hasMethod)(client, ['ai', 'run']) || openai_recipe_1.openAIRecipe.matches(client) || anthropic_recipe_1.anthropicRecipe.matches(client);
|
|
244
|
+
},
|
|
245
|
+
// Every wire it serves. A surface left undeclared here is not metered by something else - it is
|
|
246
|
+
// ungated, because this recipe is matched ahead of both of the ones that would have claimed it.
|
|
247
|
+
methods: [
|
|
248
|
+
surfaceOf(chatSurface),
|
|
249
|
+
...openai_recipe_1.openAIRecipe.methods.filter((m) => m.path.join('.') !== 'chat.completions.create').map(surfaceOf),
|
|
250
|
+
...anthropic_recipe_1.anthropicRecipe.methods.map(surfaceOf),
|
|
251
|
+
surfaceOf(aiRun),
|
|
252
|
+
],
|
|
253
|
+
};
|
|
254
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type CloudflareRecipeOptions = {
|
|
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. No `unitsPer…` beside it, because there is nothing fractional to
|
|
7
|
+
* denominate: Cloudflare publishes no price on any response, on either route, so a run here
|
|
8
|
+
* bills token counters, which arrive whole.
|
|
9
|
+
*
|
|
10
|
+
* Worth knowing that a cache hit passes the gate and then meters nothing. That is the right way
|
|
11
|
+
* round - whether a call will hit the gateway's cache cannot be known before it is made, and a
|
|
12
|
+
* free call should not be billed afterwards.
|
|
13
|
+
*/
|
|
14
|
+
estimate?: (params: any) => number;
|
|
15
|
+
/**
|
|
16
|
+
* Dimension the model that actually served is attributed to.
|
|
17
|
+
*
|
|
18
|
+
* Which model that is depends on the route. Workers AI does not route, so what was asked for is
|
|
19
|
+
* what ran - and `ai.run` takes the model as its first *argument* and echoes nothing back, so a
|
|
20
|
+
* turn made that way carries no model dimension at all. Through AI Gateway it really can differ:
|
|
21
|
+
* Auto Router picks from a candidate pool and names its choice in `cf-aig-routed-model`, and a
|
|
22
|
+
* fallback chain can serve from a different provider entirely.
|
|
23
|
+
*/
|
|
24
|
+
modelDimension?: string;
|
|
25
|
+
/**
|
|
26
|
+
* The Cloudflare endpoint, when it is not one of the two public ones.
|
|
27
|
+
*
|
|
28
|
+
* Workers AI is served from `api.cloudflare.com` and AI Gateway from
|
|
29
|
+
* `gateway.ai.cloudflare.com/v1/<account>/<gateway>/<provider>`; the matcher recognises both
|
|
30
|
+
* without being told. The case that needs this is a **custom domain**: a gateway served from
|
|
31
|
+
* `ai.example.com` has the account and gateway ids stripped out of the path, so nothing in the
|
|
32
|
+
* URL identifies Cloudflare at all.
|
|
33
|
+
*/
|
|
34
|
+
baseUrl?: string;
|
|
35
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { DATABRICKS_METRICS } from '../../../shared/token-usage';
|
|
2
|
+
import type { DatabricksRecipeOptions } from './types';
|
|
3
|
+
import type { ClientRecipe, MetricsFor, MetricsOf } from '../../core/types';
|
|
4
|
+
type DatabricksMetric = (typeof DATABRICKS_METRICS)[number];
|
|
5
|
+
/**
|
|
6
|
+
* Databricks-hosted inference - Foundation Model APIs, billed to your Databricks account in DBUs.
|
|
7
|
+
*
|
|
8
|
+
* Registered by default and matched on the workspace URL, because reading a richer response is not
|
|
9
|
+
* a pricing decision anyone needs to opt into. What it adds over `openAIRecipe` is a vendor name
|
|
10
|
+
* and the model that answered, which on this platform is the only thing that says what an endpoint
|
|
11
|
+
* name meant.
|
|
12
|
+
*/
|
|
13
|
+
export declare const databricksRecipe: (options?: DatabricksRecipeOptions) => ClientRecipe<DatabricksMetric>;
|
|
14
|
+
/**
|
|
15
|
+
* The external-model route - Databricks proxies, your upstream provider invoices.
|
|
16
|
+
*
|
|
17
|
+
* Not registered by default: which route an endpoint serves is server-side configuration, so this
|
|
18
|
+
* is the caller asserting something about their own workspace rather than the SDK detecting it.
|
|
19
|
+
* Name it at `wrap(client, { recipe: databricksExternalRecipe() })`, and see the file header for
|
|
20
|
+
* what a wrong assertion costs.
|
|
21
|
+
*
|
|
22
|
+
* `databricks-external` rather than something further from `databricks`: the two sort together in
|
|
23
|
+
* a meter list, which is what you want when you are reading one account's traffic split across
|
|
24
|
+
* them, and `external` is Databricks' own word - an endpoint of this kind is configured under an
|
|
25
|
+
* `external_model` block.
|
|
26
|
+
*/
|
|
27
|
+
export declare const databricksExternalRecipe: (options?: DatabricksRecipeOptions) => ClientRecipe<DatabricksMetric>;
|
|
28
|
+
/** One of the metric names Databricks reports. */
|
|
29
|
+
export type DatabricksMetricName = MetricsOf<ReturnType<typeof databricksRecipe>>;
|
|
30
|
+
/** What a mapper reads off a Databricks call: those names, each possibly absent. */
|
|
31
|
+
export type DatabricksMetrics = MetricsFor<ReturnType<typeof databricksRecipe>>;
|
|
32
|
+
export {};
|
|
@@ -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
|
+
}
|