@stigg/agent-sdk-js 0.1.0 → 0.2.0

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