@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.
Files changed (108) hide show
  1. package/README.md +1411 -2
  2. package/dist/bindings/client/core/registry.d.ts +12 -0
  3. package/dist/bindings/client/core/registry.js +22 -0
  4. package/dist/bindings/client/core/types.d.ts +126 -0
  5. package/dist/bindings/client/core/types.js +13 -0
  6. package/dist/bindings/client/core/wrap-client.d.ts +6 -0
  7. package/dist/bindings/client/core/wrap-client.js +261 -0
  8. package/dist/bindings/client/gateways/bedrock/bedrock-recipe.d.ts +17 -0
  9. package/dist/bindings/client/gateways/bedrock/bedrock-recipe.js +134 -0
  10. package/dist/bindings/client/gateways/bedrock/types.d.ts +13 -0
  11. package/dist/bindings/client/gateways/bedrock/types.js +2 -0
  12. package/dist/bindings/client/gateways/cloudflare/cloudflare-recipe.d.ts +10 -0
  13. package/dist/bindings/client/gateways/cloudflare/cloudflare-recipe.js +254 -0
  14. package/dist/bindings/client/gateways/cloudflare/types.d.ts +35 -0
  15. package/dist/bindings/client/gateways/cloudflare/types.js +2 -0
  16. package/dist/bindings/client/gateways/databricks/databricks-recipe.d.ts +32 -0
  17. package/dist/bindings/client/gateways/databricks/databricks-recipe.js +324 -0
  18. package/dist/bindings/client/gateways/databricks/types.d.ts +30 -0
  19. package/dist/bindings/client/gateways/databricks/types.js +2 -0
  20. package/dist/bindings/client/gateways/litellm/litellm-recipe.d.ts +17 -0
  21. package/dist/bindings/client/gateways/litellm/litellm-recipe.js +158 -0
  22. package/dist/bindings/client/gateways/litellm/types.d.ts +30 -0
  23. package/dist/bindings/client/gateways/litellm/types.js +2 -0
  24. package/dist/bindings/client/gateways/openrouter/openrouter-recipe.d.ts +20 -0
  25. package/dist/bindings/client/gateways/openrouter/openrouter-recipe.js +233 -0
  26. package/dist/bindings/client/gateways/openrouter/sdk-usage.d.ts +37 -0
  27. package/dist/bindings/client/gateways/openrouter/sdk-usage.js +63 -0
  28. package/dist/bindings/client/gateways/openrouter/types.d.ts +78 -0
  29. package/dist/bindings/client/gateways/openrouter/types.js +2 -0
  30. package/dist/bindings/client/providers/anthropic/anthropic-recipe.d.ts +34 -0
  31. package/dist/bindings/client/providers/anthropic/anthropic-recipe.js +169 -0
  32. package/dist/bindings/client/providers/gemini/gemini-recipe.d.ts +54 -0
  33. package/dist/bindings/client/providers/gemini/gemini-recipe.js +243 -0
  34. package/dist/bindings/client/providers/groq/groq-recipe.d.ts +20 -0
  35. package/dist/bindings/client/providers/groq/groq-recipe.js +84 -0
  36. package/dist/bindings/client/providers/mistral/mistral-recipe.d.ts +22 -0
  37. package/dist/bindings/client/providers/mistral/mistral-recipe.js +114 -0
  38. package/dist/bindings/client/providers/mistral/types.d.ts +38 -0
  39. package/dist/bindings/client/providers/mistral/types.js +2 -0
  40. package/dist/bindings/client/providers/ollama/ollama-recipe.d.ts +31 -0
  41. package/dist/bindings/client/providers/ollama/ollama-recipe.js +107 -0
  42. package/dist/bindings/client/providers/ollama/types.d.ts +24 -0
  43. package/dist/bindings/client/providers/ollama/types.js +2 -0
  44. package/dist/bindings/client/providers/openai/openai-recipe.d.ts +71 -0
  45. package/dist/bindings/client/providers/openai/openai-recipe.js +122 -0
  46. package/dist/bindings/client/providers/typesafe/typesafe-recipe.d.ts +44 -0
  47. package/dist/bindings/client/providers/typesafe/typesafe-recipe.js +83 -0
  48. package/dist/bindings/proxy/headers.d.ts +6 -0
  49. package/dist/bindings/proxy/headers.js +12 -0
  50. package/dist/bindings/proxy/index.d.ts +21 -0
  51. package/dist/bindings/proxy/index.js +18 -0
  52. package/dist/bindings/proxy/types.d.ts +8 -0
  53. package/dist/bindings/proxy/types.js +2 -0
  54. package/dist/bindings/shared/base-url.d.ts +44 -0
  55. package/dist/bindings/shared/base-url.js +90 -0
  56. package/dist/bindings/shared/estimate.d.ts +27 -0
  57. package/dist/bindings/shared/estimate.js +38 -0
  58. package/dist/bindings/shared/meter-stream.d.ts +18 -0
  59. package/dist/bindings/shared/meter-stream.js +151 -0
  60. package/dist/bindings/shared/token-usage.d.ts +345 -0
  61. package/dist/bindings/shared/token-usage.js +735 -0
  62. package/dist/bindings/shared/types.d.ts +170 -0
  63. package/dist/bindings/shared/types.js +2 -0
  64. package/dist/bindings/shared/weighted-metrics.d.ts +37 -0
  65. package/dist/bindings/shared/weighted-metrics.js +47 -0
  66. package/dist/config.d.ts +145 -0
  67. package/dist/config.js +99 -0
  68. package/dist/core/context.d.ts +160 -0
  69. package/dist/core/context.js +221 -0
  70. package/dist/core/dimensions.d.ts +22 -0
  71. package/dist/core/dimensions.js +47 -0
  72. package/dist/core/hooks.d.ts +21 -0
  73. package/dist/core/hooks.js +47 -0
  74. package/dist/core/session.d.ts +190 -0
  75. package/dist/core/session.js +448 -0
  76. package/dist/decision-text.d.ts +15 -0
  77. package/dist/decision-text.js +54 -0
  78. package/dist/engine/composite-engine.d.ts +72 -0
  79. package/dist/engine/composite-engine.js +135 -0
  80. package/dist/engine/create-engine.d.ts +16 -0
  81. package/dist/engine/create-engine.js +93 -0
  82. package/dist/engine/decision.d.ts +42 -0
  83. package/dist/engine/decision.js +135 -0
  84. package/dist/engine/engine.d.ts +166 -0
  85. package/dist/engine/engine.js +11 -0
  86. package/dist/engine/node-sdk-engine.d.ts +125 -0
  87. package/dist/engine/node-sdk-engine.js +262 -0
  88. package/dist/engine/report.d.ts +106 -0
  89. package/dist/engine/report.js +188 -0
  90. package/dist/engine/rest-governance-engine.d.ts +47 -0
  91. package/dist/engine/rest-governance-engine.js +220 -0
  92. package/dist/errors.d.ts +86 -0
  93. package/dist/errors.js +161 -0
  94. package/dist/headers.d.ts +40 -0
  95. package/dist/headers.js +82 -0
  96. package/dist/index.d.ts +63 -0
  97. package/dist/index.js +124 -0
  98. package/dist/internal.d.ts +33 -0
  99. package/dist/internal.js +91 -0
  100. package/dist/logger.d.ts +15 -0
  101. package/dist/logger.js +20 -0
  102. package/dist/sdk-identity.d.ts +36 -0
  103. package/dist/sdk-identity.js +43 -0
  104. package/dist/stigg-agent-sdk.d.ts +300 -0
  105. package/dist/stigg-agent-sdk.js +669 -0
  106. package/dist/types.d.ts +676 -0
  107. package/dist/types.js +147 -0
  108. 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,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -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
+ }