@stigg/agent-sdk-js 0.7.7 → 0.7.9

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 CHANGED
@@ -155,6 +155,7 @@ closed, and conflating them is how an outage takes an application down.
155
155
  | <img src="https://raw.githubusercontent.com/stiggio/stigg-agent-sdk-ts/main/docs/logos/bedrock.svg" width="18" alt=""> | Amazon Bedrock | <img src="https://raw.githubusercontent.com/stiggio/stigg-agent-sdk-ts/main/docs/logos/litellm.png" width="18" alt=""> | LiteLLM |
156
156
  | <img src="https://raw.githubusercontent.com/stiggio/stigg-agent-sdk-ts/main/docs/logos/cloudflare.svg" width="18" alt=""> | Cloudflare Workers AI | <img src="https://raw.githubusercontent.com/stiggio/stigg-agent-sdk-ts/main/docs/logos/openrouter.svg" width="18" alt=""> | OpenRouter |
157
157
  | <img src="https://raw.githubusercontent.com/stiggio/stigg-agent-sdk-ts/main/docs/logos/databricks.svg" width="18" alt=""> | Databricks | | agentgateway (incl. Solo.io Enterprise) |
158
+ | | TrueFoundry AI Gateway | | |
158
159
 
159
160
  A gateway called with plain HTTP rather than through an SDK is governed the same way:
160
161
  `stigg.wrapFetch({ baseUrl, recipe })` hands back a `fetch` that is checked and metered per
@@ -165,7 +166,7 @@ request.
165
166
  | | Orchestrator | | Orchestrator |
166
167
  | --- | --- | --- | --- |
167
168
  | <img src="https://raw.githubusercontent.com/stiggio/stigg-agent-sdk-ts/main/docs/logos/langchain.svg" width="18" alt=""> | [LangChain](docs/langchain.md) | <img src="https://raw.githubusercontent.com/stiggio/stigg-agent-sdk-ts/main/docs/logos/langgraph.svg" width="18" alt=""> | [LangGraph](docs/langchain.md#a-hand-built-langgraph) |
168
- | <img src="https://raw.githubusercontent.com/stiggio/stigg-agent-sdk-ts/main/docs/logos/google-adk.png" width="18" alt=""> | [Google ADK](docs/guide.md#google-adk-one-plugin-per-runner) | <img src="https://raw.githubusercontent.com/stiggio/stigg-agent-sdk-ts/main/docs/logos/vercel.svg" width="18" alt=""> | [Vercel AI SDK](docs/vercel-ai.md) (model calls, for now) |
169
+ | <img src="https://raw.githubusercontent.com/stiggio/stigg-agent-sdk-ts/main/docs/logos/google-adk.png" width="18" alt=""> | [Google ADK](docs/guide.md#google-adk-one-plugin-per-runner) | <img src="https://raw.githubusercontent.com/stiggio/stigg-agent-sdk-ts/main/docs/logos/vercel.svg" width="18" alt=""> | [Vercel AI SDK](docs/vercel-ai.md) |
169
170
  | | Your own harness, through `stigg.middleware()` | | |
170
171
 
171
172
  Each binding is written against the *shape a vendor returns*, not against its SDK — so installing
@@ -187,7 +188,7 @@ rather than handing back a client that quietly meters nothing.
187
188
  | Meter a gateway you operate | `client.stigg.headers()` out, `stigg.ingestFromHeaders(...)` back |
188
189
  | Govern a LangChain / LangGraph agent graph ([guide](docs/langchain.md)) | `new StiggLangChainCallbackHandler({ stigg, agents, tools })` from `@stigg/agent-sdk-js/langchain` |
189
190
  | Govern a Google ADK agent graph ([guide](docs/guide.md#google-adk-one-plugin-per-runner)) | `new StiggAdkPlugin({ stigg, agents, tools, stepContext })` from `@stigg/agent-sdk-js/adk`, in the Runner's `plugins` |
190
- | Govern a Vercel AI SDK model or agent ([guide](docs/vercel-ai.md)) | `wrapLanguageModel({ model, middleware: stiggVercelMiddleware({ stigg, stiggContext }) })`, a call's own in `providerOptions.stigg` |
191
+ | Govern a Vercel AI SDK model or agent ([guide](docs/vercel-ai.md)) | `wrapLanguageModel({ model, middleware: stiggVercelMiddleware({ stigg, stiggContext, tools }) })`, a call's own in `providerOptions.stigg`; paid tools through `middleware.tools(...)` |
191
192
  | Govern a graph from your own harness | `stigg.middleware({ stiggContext, agents })` |
192
193
 
193
194
  One wrapped client serves every customer and every agent: the context is an argument, given once at
@@ -0,0 +1,29 @@
1
+ import type { MethodRecipe, MeteredResponse } from './types';
2
+ /** What the response body says about the call, buffered or streamed. */
3
+ export type BodyFacts = {
4
+ model?: string;
5
+ id?: string;
6
+ };
7
+ /** What only the gateway knows, folded onto the wire reader's measurement. */
8
+ export type GatewayFacts = {
9
+ /** The model that actually served, reported under the recipe's model dimension. */
10
+ model?: string;
11
+ /** The upstream's completion id: the idempotency key when the wire reader stated none. */
12
+ id?: string;
13
+ /** The gateway's own request id: the idempotency key ahead of anything the wire reader stated. */
14
+ requestId?: string;
15
+ /** Anything else the gateway states, as dimensions. */
16
+ dimensions?: Record<string, string>;
17
+ };
18
+ export type GatewaySurfaceOptions = {
19
+ estimate: NonNullable<MethodRecipe['estimate']>;
20
+ modelDimension: string;
21
+ /** The gateway's facts, from the body and the response. Defaults to the body's own. */
22
+ facts?: (body: BodyFacts, response: MeteredResponse | undefined) => GatewayFacts;
23
+ /** A call nobody was charged for (a cache hit), which reports nothing at all. */
24
+ skip?: (response: MeteredResponse | undefined) => boolean;
25
+ };
26
+ /** A response header, with an empty value read as absent. */
27
+ export declare function headerOf(response: MeteredResponse | undefined, name: string): string | undefined;
28
+ /** One wire's surface, answered through a gateway. */
29
+ export declare function gatewaySurface(base: MethodRecipe, options: GatewaySurfaceOptions): MethodRecipe;
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.headerOf = headerOf;
4
+ exports.gatewaySurface = gatewaySurface;
5
+ /**
6
+ * A provider's wire, answered by a gateway - the wrapper agentgateway, TrueFoundry and Cloudflare
7
+ * AI Gateway share.
8
+ *
9
+ * Each of those gateways answers in the shape of the wire it was called on, so that wire's recipe
10
+ * already has the right reader - and Anthropic's streamed usage in particular accumulates across
11
+ * two event types, which is not logic worth having a second copy of. What the gateway adds is what
12
+ * only it knows: the model that actually served and the id a replay repeats, plus whatever a
13
+ * particular gateway states in its own headers. This file folds those onto the wire reader's
14
+ * measurement; each recipe supplies the facts.
15
+ */
16
+ const meter_stream_1 = require("../../shared/meter-stream");
17
+ const types_1 = require("../../../types");
18
+ /** A response header, with an empty value read as absent. */
19
+ function headerOf(response, name) {
20
+ const raw = response?.headers?.get(name);
21
+ return raw == null || raw === '' ? undefined : raw;
22
+ }
23
+ function withGatewayFacts(measured, facts, modelDimension) {
24
+ const dimensions = {
25
+ ...measured.dimensions,
26
+ ...facts.dimensions,
27
+ };
28
+ if (facts.model)
29
+ dimensions[modelDimension] = facts.model;
30
+ return {
31
+ ...measured,
32
+ dimensions: Object.keys(dimensions).length > 0 ? dimensions : undefined,
33
+ idempotencyKey: facts.requestId ?? measured.idempotencyKey ?? facts.id,
34
+ };
35
+ }
36
+ /** A non-empty string, or absent - an empty id would key every such call as one, the stream tap's rule too. */
37
+ const stringOr = (value) => (typeof value === 'string' && value ? value : undefined);
38
+ /** One wire's surface, answered through a gateway. */
39
+ function gatewaySurface(base, options) {
40
+ const { estimate, modelDimension, skip } = options;
41
+ const facts = options.facts ?? ((body) => body);
42
+ return {
43
+ // Spread first so anything the base declares and this wrapper does not know about - like
44
+ // `contextArg` - survives being wrapped.
45
+ ...base,
46
+ estimate,
47
+ extractUsage: (result, response) => {
48
+ // Checked before the body is read, because the body of a cache hit looks like a real call.
49
+ if (skip?.(response))
50
+ return undefined;
51
+ const measured = base.extractUsage?.(result, response);
52
+ if (measured === undefined)
53
+ return undefined;
54
+ const body = { model: stringOr(result?.model), id: stringOr(result?.id) };
55
+ return withGatewayFacts((0, types_1.toMeasurement)(measured), facts(body, response), modelDimension);
56
+ },
57
+ attachStream: base.attachStream
58
+ ? (stream, ingest, response) => {
59
+ // Nothing attached at all: the consumer's stream is left untouched and nothing reported.
60
+ if (skip?.(response))
61
+ return;
62
+ // A second tap for the body's model and id - the wire readers carry out counters and
63
+ // nothing else. Each wire states them in its own envelope: Anthropic on
64
+ // `message_start.message`, the Responses API on `response`, Chat Completions at the
65
+ // chunk root. `meterStream` captures what it patches before patching, so two observers
66
+ // each see every chunk exactly once.
67
+ const seen = {};
68
+ (0, meter_stream_1.meterStream)(stream, (chunk) => {
69
+ const envelope = chunk?.message ?? chunk?.response ?? chunk;
70
+ if (typeof envelope?.model === 'string' && envelope.model)
71
+ seen.model = envelope.model;
72
+ if (typeof envelope?.id === 'string' && envelope.id)
73
+ seen.id = envelope.id;
74
+ }, () => { });
75
+ base.attachStream(stream, (measurement) => ingest(withGatewayFacts((0, types_1.toMeasurement)(measurement), facts({ ...seen }, response), modelDimension)), response);
76
+ }
77
+ : undefined,
78
+ };
79
+ }
@@ -1,3 +1,36 @@
1
+ /**
2
+ * The agentgateway recipe - the open-source AI gateway (and the builds on it: Solo.io's enterprise
3
+ * edition, Gloo AI Gateway), a gateway you call that states no price.
4
+ *
5
+ * Its OpenAI routes fit `openAIRecipe` by shape and its Anthropic route fits `anthropicRecipe`, so
6
+ * the recipe has to justify itself, and it does on two counts:
7
+ *
8
+ * - **A vendor name of its own.** Without this, traffic through the gateway meters as `openai.*`
9
+ * or `anthropic.*` - indistinguishable from a direct call, so no meter and no per-provider
10
+ * budget can tell the gateway's traffic apart. Worse on the Anthropic wire: `anthropicRecipe`
11
+ * has no base-URL guard, so an `Anthropic` client pointed here would be claimed by it and
12
+ * reported as direct Anthropic traffic.
13
+ * - **The model that actually served.** The gateway can rewrite the model a request named - an
14
+ * alias, a provider prefix, a failover priority group - and the response body names the one
15
+ * that answered. The bare recipes drop it; this one attributes it.
16
+ *
17
+ * One vendor across every wire, because the gateway is one deployment whichever client reached it,
18
+ * and the wire is already in the event name (`agentgateway.chat.completions.create` against
19
+ * `agentgateway.messages.create`). The gateway translates where it needs to, so each route reports
20
+ * in the vocabulary of the wire it answered on - OpenAI's names on the OpenAI routes, Anthropic's
21
+ * on Messages - and `metrics` is the union, which is what `metrics` is defined to be.
22
+ *
23
+ * No `cost`, and that is the defining fact rather than an oversight. The gateway is self-hosted and
24
+ * states no price on any response; whatever the upstream charged is invoiced by the upstream, out
25
+ * of band. Never declare a metric the platform cannot report.
26
+ *
27
+ * Self-hosted, so there is no public hostname to recognise. The base URL handed to
28
+ * `agentgatewayRecipe({ baseUrl })` is the reliable discriminator; without one the matcher falls
29
+ * back to a guess on the hostname - see {@link isAgentgateway} for what it claims.
30
+ *
31
+ * The same surfaces govern raw HTTP too: `stigg.wrapFetch` routes a request by its path to the
32
+ * method here that names it, so a `fetch` to the gateway is metered by exactly these readers.
33
+ */
1
34
  import { AGENTGATEWAY_METRICS } from '../../../shared/token-usage';
2
35
  import type { AgentgatewayRecipeOptions } from './types';
3
36
  import type { ClientRecipe, MetricsFor, MetricsOf } from '../../core/types';
@@ -34,12 +34,11 @@ exports.agentgatewayRecipe = agentgatewayRecipe;
34
34
  * The same surfaces govern raw HTTP too: `stigg.wrapFetch` routes a request by its path to the
35
35
  * method here that names it, so a `fetch` to the gateway is metered by exactly these readers.
36
36
  */
37
- const meter_stream_1 = require("../../../shared/meter-stream");
38
37
  const token_usage_1 = require("../../../shared/token-usage");
39
38
  const base_url_1 = require("../../../shared/base-url");
40
39
  const openai_recipe_1 = require("../../providers/openai/openai-recipe");
41
40
  const anthropic_recipe_1 = require("../../providers/anthropic/anthropic-recipe");
42
- const types_1 = require("../../../../types");
41
+ const wire_surface_1 = require("../../core/wire-surface");
43
42
  /**
44
43
  * The name that identifies the gateway in an ordinary deployment: the Kubernetes service or
45
44
  * namespace (`agentgateway-proxy.agentgateway-system.svc`) or a hostname an operator chose to say
@@ -54,58 +53,11 @@ const types_1 = require("../../../../types");
54
53
  * and out of the meters counting them. Solo's and Gloo's builds name the gateway by `baseUrl`.
55
54
  */
56
55
  const HOST_LABEL = 'agentgateway';
57
- /** What only the gateway knows, folded onto whatever the wire's own reader produced. */
58
- function withGatewayFacts(measured, result, modelDimension) {
59
- const served = typeof result?.model === 'string' ? result.model : undefined;
60
- return {
61
- ...measured,
62
- dimensions: served
63
- ? { ...measured.dimensions, [modelDimension]: served }
64
- : measured.dimensions,
65
- // The upstream's completion id, carried back through the gateway. It is what a replayed
66
- // response repeats, which a local step counter cannot be.
67
- idempotencyKey: measured.idempotencyKey ?? (typeof result?.id === 'string' ? result.id : undefined),
68
- };
69
- }
70
56
  function agentgatewayRecipe(options = {}) {
71
57
  const estimate = options.estimate ?? (() => 1);
72
58
  const modelDimension = options.modelDimension ?? 'model';
73
- /**
74
- * One wire's surface, wrapped.
75
- *
76
- * The wire recipes' own extractors are reused rather than reimplemented: the gateway answers in
77
- * that wire's shape, so that wire's reader is by definition the right one - and Anthropic's
78
- * streamed usage in particular accumulates across two event types, which is not logic worth
79
- * having a second copy of.
80
- */
81
- const surfaceOf = (base) => ({
82
- // Spread first so anything the base declares and this wrapper does not know about - like
83
- // `contextArg` - survives being wrapped.
84
- ...base,
85
- estimate,
86
- extractUsage: (result, response) => {
87
- const measured = base.extractUsage?.(result, response);
88
- return measured === undefined ? undefined : withGatewayFacts((0, types_1.toMeasurement)(measured), result, modelDimension);
89
- },
90
- attachStream: base.attachStream
91
- ? (stream, ingest, response) => {
92
- // A second tap, purely for attribution: the wire readers carry out counters and nothing
93
- // else. Each wire states the model in its own envelope - Anthropic on
94
- // `message_start.message`, the Responses API on `response`, Chat Completions at the chunk
95
- // root. `meterStream` captures what it patches before patching, so two observers each
96
- // see every chunk exactly once.
97
- let seen = {};
98
- (0, meter_stream_1.meterStream)(stream, (chunk) => {
99
- const envelope = chunk?.message ?? chunk?.response ?? chunk;
100
- if (envelope?.model)
101
- seen = { ...seen, model: envelope.model };
102
- if (envelope?.id)
103
- seen = { ...seen, id: envelope.id };
104
- }, () => { });
105
- base.attachStream(stream, (measurement) => ingest(withGatewayFacts((0, types_1.toMeasurement)(measurement), seen, modelDimension)), response);
106
- }
107
- : undefined,
108
- });
59
+ // The gateway answers in each wire's shape, and the body's model and id are its facts.
60
+ const surfaceOf = (base) => (0, wire_surface_1.gatewaySurface)(base, { estimate, modelDimension });
109
61
  return {
110
62
  vendor: 'agentgateway',
111
63
  metrics: token_usage_1.AGENTGATEWAY_METRICS,
@@ -32,7 +32,7 @@ const base_url_1 = require("../../../shared/base-url");
32
32
  const registry_1 = require("../../core/registry");
33
33
  const openai_recipe_1 = require("../../providers/openai/openai-recipe");
34
34
  const anthropic_recipe_1 = require("../../providers/anthropic/anthropic-recipe");
35
- const types_1 = require("../../../../types");
35
+ const wire_surface_1 = require("../../core/wire-surface");
36
36
  /** Workers AI, both of its routes. */
37
37
  const CLOUDFLARE_API_HOST = 'api.cloudflare.com';
38
38
  /** AI Gateway's public endpoint. A custom domain needs `options.baseUrl` instead. */
@@ -47,10 +47,6 @@ const ROUTED_MODEL_HEADER = 'cf-aig-routed-model';
47
47
  const STEP_HEADER = 'cf-aig-step';
48
48
  /** The gateway's own request id, stable across its retries - unlike a local step counter. */
49
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
50
  /**
55
51
  * Whether the gateway served this from its cache.
56
52
  *
@@ -62,28 +58,26 @@ const headerOf = (response, name) => {
62
58
  * Absent on the Workers AI routes, which have no cache in front of them - so this reads `false`
63
59
  * there and the surfaces behave as ordinary metered calls.
64
60
  */
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;
61
+ const isCacheHit = (response) => (0, wire_surface_1.headerOf)(response, CACHE_STATUS_HEADER)?.toUpperCase() === 'HIT';
62
+ /** What only the gateway knows, from its headers, with the forwarded body as the fallback. */
63
+ function gatewayFacts(body, response) {
64
+ const dimensions = {};
74
65
  // 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);
66
+ const logId = (0, wire_surface_1.headerOf)(response, LOG_ID_HEADER);
76
67
  if (logId)
77
68
  dimensions.cf_aig_log_id = logId;
78
69
  // Only when a fallback actually happened. Step `0` is the ordinary case and tagging every event
79
70
  // with it would add a dimension to all of them for no information.
80
- const step = headerOf(response, STEP_HEADER);
71
+ const step = (0, wire_surface_1.headerOf)(response, STEP_HEADER);
81
72
  if (step !== undefined && step !== '0')
82
73
  dimensions.cf_aig_step = step;
83
74
  return {
84
- ...measured,
85
- dimensions: Object.keys(dimensions).length > 0 ? dimensions : undefined,
86
- idempotencyKey: headerOf(response, REQUEST_ID_HEADER) ?? measured.idempotencyKey ?? result?.id,
75
+ // The model that actually served: the router's own word for it, falling back to whatever the
76
+ // forwarded body said. Anthropic's Messages body carries `model` too, so this works on both wires.
77
+ model: (0, wire_surface_1.headerOf)(response, ROUTED_MODEL_HEADER) ?? body.model,
78
+ id: body.id,
79
+ requestId: (0, wire_surface_1.headerOf)(response, REQUEST_ID_HEADER),
80
+ dimensions,
87
81
  };
88
82
  }
89
83
  /** The Workers AI reader: the three counters it states, under its own (OpenAI's) names. */
@@ -106,57 +100,13 @@ function cloudflareRecipe(options = {}) {
106
100
  const estimate = options.estimate ?? (() => 1);
107
101
  const modelDimension = options.modelDimension ?? 'model';
108
102
  /**
109
- * One upstream surface, wrapped.
103
+ * One upstream surface, wrapped. Off the gateway the wrapper is inert: no `cf-aig-*` header is
104
+ * present, so no cache hit is seen and no gateway dimension is added.
110
105
  *
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.
106
+ * A cache hit reports nothing, buffered or streamed: nobody was invoiced for it, and the cache
107
+ * header arrives before any token does.
116
108
  */
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
- });
109
+ const surfaceOf = (base) => (0, wire_surface_1.gatewaySurface)(base, { estimate, modelDimension, facts: gatewayFacts, skip: isCacheHit });
160
110
  /**
161
111
  * Chat Completions, which both products answer on - Workers AI's OpenAI-compatible route and the
162
112
  * gateway's `/openai` one.
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The TrueFoundry recipe - TrueFoundry's AI Gateway, hosted at `gateway.truefoundry.ai` or
3
+ * self-hosted in your cluster, a gateway you call that states no price.
4
+ *
5
+ * Apps reach it by pointing an existing `OpenAI` or `Anthropic` client at the gateway's base URL
6
+ * with a TrueFoundry key, and naming models as `provider_account/model` (`openai-main/gpt-5-mini`).
7
+ * Both wires fit `openAIRecipe` and `anthropicRecipe` by shape, so the recipe earns its place the
8
+ * way agentgateway's does:
9
+ *
10
+ * - **A vendor name of its own.** Without this, gateway traffic meters as `openai.*` or
11
+ * `anthropic.*`, indistinguishable from a direct call. `anthropicRecipe` has no base-URL guard,
12
+ * so an `Anthropic` client pointed here would be claimed as direct Anthropic traffic.
13
+ * - **The model that actually served.** TrueFoundry load-balances and falls back between provider
14
+ * accounts, and names the one that answered in the `x-tfy-resolved-model` response header.
15
+ * Headers go on the wire before the body, so a streamed call has it too. The body's `model` is
16
+ * the fallback when the header is absent.
17
+ *
18
+ * No `cost`. TrueFoundry tracks spend in its own dashboard and puts no price on any response; the
19
+ * provider accounts behind it are the caller's own keys, so the upstream invoices the tokens.
20
+ *
21
+ * Not covered: TrueFoundry's Gemini (`/gemini/<account>/proxy`), Bedrock (`/bedrock/proxy`) and
22
+ * Azure OpenAI (`/azure-openai`) passthroughs. Each speaks its own provider's SDK and shape.
23
+ *
24
+ * `stigg.wrapFetch` governs raw HTTP to the gateway through these same methods.
25
+ */
26
+ import { TRUEFOUNDRY_METRICS } from '../../../shared/token-usage';
27
+ import type { TrueFoundryRecipeOptions } from './types';
28
+ import type { ClientRecipe, MetricsFor, MetricsOf } from '../../core/types';
29
+ type TrueFoundryMetric = (typeof TRUEFOUNDRY_METRICS)[number];
30
+ export declare function truefoundryRecipe(options?: TrueFoundryRecipeOptions): ClientRecipe<TrueFoundryMetric>;
31
+ /** One of the metric names TrueFoundry can report, across every wire it serves. */
32
+ export type TrueFoundryMetricName = MetricsOf<ReturnType<typeof truefoundryRecipe>>;
33
+ /** What a mapper reads off a call through TrueFoundry: those names, each possibly absent. */
34
+ export type TrueFoundryMetrics = MetricsFor<ReturnType<typeof truefoundryRecipe>>;
35
+ export {};
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.truefoundryRecipe = truefoundryRecipe;
4
+ /**
5
+ * The TrueFoundry recipe - TrueFoundry's AI Gateway, hosted at `gateway.truefoundry.ai` or
6
+ * self-hosted in your cluster, a gateway you call that states no price.
7
+ *
8
+ * Apps reach it by pointing an existing `OpenAI` or `Anthropic` client at the gateway's base URL
9
+ * with a TrueFoundry key, and naming models as `provider_account/model` (`openai-main/gpt-5-mini`).
10
+ * Both wires fit `openAIRecipe` and `anthropicRecipe` by shape, so the recipe earns its place the
11
+ * way agentgateway's does:
12
+ *
13
+ * - **A vendor name of its own.** Without this, gateway traffic meters as `openai.*` or
14
+ * `anthropic.*`, indistinguishable from a direct call. `anthropicRecipe` has no base-URL guard,
15
+ * so an `Anthropic` client pointed here would be claimed as direct Anthropic traffic.
16
+ * - **The model that actually served.** TrueFoundry load-balances and falls back between provider
17
+ * accounts, and names the one that answered in the `x-tfy-resolved-model` response header.
18
+ * Headers go on the wire before the body, so a streamed call has it too. The body's `model` is
19
+ * the fallback when the header is absent.
20
+ *
21
+ * No `cost`. TrueFoundry tracks spend in its own dashboard and puts no price on any response; the
22
+ * provider accounts behind it are the caller's own keys, so the upstream invoices the tokens.
23
+ *
24
+ * Not covered: TrueFoundry's Gemini (`/gemini/<account>/proxy`), Bedrock (`/bedrock/proxy`) and
25
+ * Azure OpenAI (`/azure-openai`) passthroughs. Each speaks its own provider's SDK and shape.
26
+ *
27
+ * `stigg.wrapFetch` governs raw HTTP to the gateway through these same methods.
28
+ */
29
+ const token_usage_1 = require("../../../shared/token-usage");
30
+ const base_url_1 = require("../../../shared/base-url");
31
+ const openai_recipe_1 = require("../../providers/openai/openai-recipe");
32
+ const anthropic_recipe_1 = require("../../providers/anthropic/anthropic-recipe");
33
+ const wire_surface_1 = require("../../core/wire-surface");
34
+ /** TrueFoundry's own domains: the hosted gateway, and the tenant control planes that serve one. */
35
+ const TRUEFOUNDRY_HOSTS = ['truefoundry.ai', 'truefoundry.cloud', 'truefoundry.com'];
36
+ /** Which model answered, after TrueFoundry's load balancing or fallback. */
37
+ const RESOLVED_MODEL_HEADER = 'x-tfy-resolved-model';
38
+ function truefoundryRecipe(options = {}) {
39
+ const estimate = options.estimate ?? (() => 1);
40
+ const modelDimension = options.modelDimension ?? 'model';
41
+ // The gateway answers in each wire's shape. The header names the model that served, ahead of
42
+ // the body's, and is on the wire before a streamed body is.
43
+ const surfaceOf = (base) => (0, wire_surface_1.gatewaySurface)(base, {
44
+ estimate,
45
+ modelDimension,
46
+ facts: (body, response) => ({ ...body, model: (0, wire_surface_1.headerOf)(response, RESOLVED_MODEL_HEADER) ?? body.model }),
47
+ });
48
+ return {
49
+ vendor: 'truefoundry',
50
+ metrics: token_usage_1.TRUEFOUNDRY_METRICS,
51
+ totalTokensMetric: token_usage_1.TOTAL_TOKENS,
52
+ matches: (client) => isTrueFoundry(client, options.baseUrl) && (openai_recipe_1.openAIRecipe.matches(client) || anthropic_recipe_1.anthropicRecipe.matches(client)),
53
+ // Every wire it serves. This recipe is matched ahead of both bare ones, so a surface left out
54
+ // here would be ungated rather than metered by them.
55
+ methods: [...openai_recipe_1.openAIRecipe.methods.map(surfaceOf), ...anthropic_recipe_1.anthropicRecipe.methods.map(surfaceOf)],
56
+ };
57
+ }
58
+ /**
59
+ * Given a `baseUrl`, the endpoint is compared one path segment at a time, so `.../v1` cannot claim
60
+ * `.../v10`, and TrueFoundry's own domains are no longer claimed. Without one, only TrueFoundry's own
61
+ * domains and their subdomains are claimed - a self-hosted install under your hostname needs the
62
+ * `baseUrl`.
63
+ *
64
+ * That claims every OpenAI- or Anthropic-shaped client on those domains, not only the gateway - a
65
+ * model TrueFoundry serves there directly reports under `truefoundry.*` too. Name
66
+ * `recipe: openAIRecipe` (or `anthropicRecipe`) at `wrap` to settle it.
67
+ */
68
+ function isTrueFoundry(client, baseUrl) {
69
+ const clientBase = (0, base_url_1.baseUrlOf)(client);
70
+ if (clientBase === undefined)
71
+ return false;
72
+ if (baseUrl)
73
+ return (0, base_url_1.withinBaseUrl)(baseUrl, clientBase);
74
+ return (0, base_url_1.matchesAnyHost)(clientBase, TRUEFOUNDRY_HOSTS);
75
+ }
@@ -0,0 +1,22 @@
1
+ export type TrueFoundryRecipeOptions = {
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: TrueFoundry puts no price on any response, so a
7
+ * run here bills token counters, which arrive whole.
8
+ */
9
+ estimate?: (params: any) => number;
10
+ /**
11
+ * Dimension the model that actually served is attributed to, so routing drift is visible in
12
+ * Stigg. TrueFoundry load-balances and falls back between provider accounts, and says which one
13
+ * answered in the `x-tfy-resolved-model` response header.
14
+ */
15
+ modelDimension?: string;
16
+ /**
17
+ * The gateway this recipe is for, when it is not TrueFoundry's hosted one. A self-hosted install
18
+ * runs under your own hostname, so give it the same base URL the client was built with and
19
+ * `matches` compares against exactly that. Without it only TrueFoundry's own domains are claimed.
20
+ */
21
+ baseUrl?: string;
22
+ };
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -9,16 +9,26 @@
9
9
  * bindings. A call's own `stiggContext` rides `providerOptions.stigg`, and is taken out before the
10
10
  * provider sees the call. Nothing here imports `ai`: a middleware is a plain object. See
11
11
  * docs/vercel-ai.md.
12
+ *
13
+ * A model middleware never sees a tool run, so the paid tools are governed by `tools()`, which wraps
14
+ * each one's `execute`. A tool call is governed as the model step that asked for it.
12
15
  */
13
16
  import type { StiggAgentSDK } from '../../../stigg-agent-sdk';
14
17
  import type { SessionHooks } from '../../../core/session';
15
- import type { ClientContext, GovernedAgentMiddleware, Measurement } from '../../../types';
18
+ import type { ClientContext, GovernedAgentMiddleware, Measurement, MiddlewareTools } from '../../../types';
19
+ import { type ModelUsage } from './usage';
16
20
  export { VERCEL_AI_METRICS, measurementOf } from './usage';
17
21
  export type { VercelAIMetric, VercelAIMetrics } from './usage';
18
22
  export type StiggVercelMiddlewareOptions = {
19
23
  stigg: StiggAgentSDK;
20
24
  /** Every call's context - typically the agent's `agentId` and what it gates on. A call's `providerOptions.stigg` is laid over it. */
21
25
  stiggContext?: ClientContext;
26
+ /**
27
+ * The paid tools, by tool name - `'*'` for every tool not named, `false` to exempt one. Governed
28
+ * once wrapped by `tools()`. A sub-agent run inside a tool is metered by its own model calls, so
29
+ * give it `false` under `'*'`.
30
+ */
31
+ tools?: MiddlewareTools;
22
32
  hooks?: SessionHooks;
23
33
  };
24
34
  /**
@@ -40,13 +50,30 @@ export type StiggVercelMiddleware = {
40
50
  params: any;
41
51
  model?: any;
42
52
  }): Promise<any>;
53
+ /**
54
+ * The same tools, each paid one's `execute` wrapped: checked before it runs, and reported after it
55
+ * succeeds. A refused tool does not run; it fails with a message that names the tool and why, and
56
+ * `stigg.denial(error)` returns the decision. Tools with no `execute` are returned as they are.
57
+ */
58
+ tools<T extends Record<string, any>>(tools: T): T;
43
59
  /** What this middleware did, and what is still in flight. */
44
60
  readonly stigg: GovernedAgentMiddleware['stigg'];
45
61
  };
46
- export declare function stiggVercelMiddleware({ stigg, stiggContext, hooks, }: StiggVercelMiddlewareOptions): StiggVercelMiddleware;
62
+ export declare function stiggVercelMiddleware({ stigg, stiggContext, tools: toolConfig, hooks, }: StiggVercelMiddlewareOptions): StiggVercelMiddleware;
63
+ type StreamPart = {
64
+ type?: string;
65
+ id?: string;
66
+ modelId?: unknown;
67
+ delta?: unknown;
68
+ usage?: ModelUsage;
69
+ };
47
70
  /**
48
71
  * Calls `done` once, when the stream ends: read to the end, failed, cancelled, or stopped by the
49
72
  * call's `abortSignal` - which is how the AI SDK stops a stream, without cancelling it.
50
73
  * ponytail: a reader that stops with no `abortSignal` is not reported; nothing tells this layer.
51
74
  */
52
- export declare function observeStream(result: any, signal: AbortSignal | undefined, done: (measurement: Measurement | undefined, modelId?: string) => void): void;
75
+ export declare function observeStream(result: any, signal: AbortSignal | undefined, done: (measurement: Measurement | undefined, modelId?: string) => void,
76
+ /** Each part, before the AI SDK reads it - so a tool call is known before its tool can start. */
77
+ onPart?: (part: StreamPart & {
78
+ toolCallId?: unknown;
79
+ }) => void): void;
@@ -3,17 +3,22 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.measurementOf = exports.VERCEL_AI_METRICS = void 0;
4
4
  exports.stiggVercelMiddleware = stiggVercelMiddleware;
5
5
  exports.observeStream = observeStream;
6
+ const errors_1 = require("../../../errors");
7
+ const hooks_1 = require("../../../core/hooks");
8
+ const agent_middleware_1 = require("../core/agent-middleware");
6
9
  const usage_1 = require("./usage");
7
10
  var usage_2 = require("./usage");
8
11
  Object.defineProperty(exports, "VERCEL_AI_METRICS", { enumerable: true, get: function () { return usage_2.VERCEL_AI_METRICS; } });
9
12
  Object.defineProperty(exports, "measurementOf", { enumerable: true, get: function () { return usage_2.measurementOf; } });
10
13
  const MISSING_CUSTOMER = 'Name it on every call as `providerOptions: { stigg: { customerId } }`, ' +
11
14
  'or give the middleware a default in its `stiggContext: { customerId }`.';
12
- function stiggVercelMiddleware({ stigg, stiggContext, hooks, }) {
15
+ /** How many asked-for tool calls are remembered. One that never runs - a client-side tool - is dropped past it. */
16
+ const ISSUED_CALLS_LIMIT = 1_000;
17
+ function stiggVercelMiddleware({ stigg, stiggContext, tools: toolConfig, hooks, }) {
13
18
  if (!stigg || typeof stigg.middleware !== 'function') {
14
19
  throw new Error('stiggVercelMiddleware needs `stigg`: the StiggAgentSDK instance to govern through.');
15
20
  }
16
- const governance = stigg.middleware({ stiggContext, hooks, missingCustomer: MISSING_CUSTOMER });
21
+ const governance = stigg.middleware({ stiggContext, tools: toolConfig, hooks, missingCustomer: MISSING_CUSTOMER });
17
22
  // `doGenerate()` takes no arguments: the model is called with what `transformParams` returned.
18
23
  // So the context comes out there, and is found again by that params object.
19
24
  const contexts = new WeakMap();
@@ -27,11 +32,128 @@ function stiggVercelMiddleware({ stigg, stiggContext, hooks, }) {
27
32
  // of the same customer included - never queue behind it. A call with no context of its own falls
28
33
  // back to its customer and agent. `endModel` never rejects.
29
34
  const reporting = new Map();
30
- const callOf = (params) => contexts.get(params) ?? JSON.stringify([stiggContext?.customerId, stiggContext?.agentId]);
35
+ const defaultCall = JSON.stringify([stiggContext?.customerId, stiggContext?.agentId]);
36
+ const callOf = (params) => contexts.get(params) ?? defaultCall;
37
+ /** Waits for a call's stream reports in flight, so its next check sees their spend. */
38
+ const settle = (call) => Promise.all(reporting.get(call) ?? []);
39
+ // A tool's `execute` is handed only the call id, so each tool call a model step asks for is kept
40
+ // by its id - before the AI SDK can start the tool - and taken when it runs.
41
+ // Only for tools `tools()` wrapped: another's call - client-side, run by the provider - never runs
42
+ // here, and would only fill the map.
43
+ // ponytail: keyed by the provider's id alone. First-party providers make each id unique; a
44
+ // backend that reuses ids (`call_0`) across concurrent calls would let one call's entry replace
45
+ // another's - scope the key by call if one shows up. A call `experimental_repairToolCall` renames
46
+ // is not kept either, and is governed as one no step asked for.
47
+ const issued = new Map();
48
+ const wrapped = new Set();
49
+ const ask = (params, part) => {
50
+ if (part?.type !== 'tool-call' || typeof part.toolCallId !== 'string' || part.providerExecuted)
51
+ return;
52
+ if (typeof part.toolName !== 'string' || !wrapped.has(part.toolName))
53
+ return;
54
+ issued.delete(part.toolCallId);
55
+ issued.set(part.toolCallId, { context: contexts.get(params), call: callOf(params) });
56
+ if (issued.size > ISSUED_CALLS_LIMIT)
57
+ issued.delete(issued.keys().next().value);
58
+ };
59
+ /**
60
+ * Said once per tool: a call no model step of this middleware asked for - approved after a pause
61
+ * (`needsApproval` runs it at the start of the next call, before any model step), answered on
62
+ * another instance, or asked by another middleware's model - and whose own context names no
63
+ * `stigg`, is governed by this middleware's own `stiggContext`, and reported for its customer.
64
+ */
65
+ const warned = new Set();
66
+ const warnOnce = (name) => {
67
+ if (warned.has(name))
68
+ return;
69
+ warned.add(name);
70
+ // A logger that throws must not fail the tool it only reports on.
71
+ (0, hooks_1.safely)(() => stigg.config?.logger?.warn(`[stigg] tool "${name}" ran without a model step this middleware saw asking for it, and its ` +
72
+ "context names no `stigg`, so it is governed by the middleware's own stiggContext. An approved " +
73
+ `tool does this: name its customer in \`toolsContext: { ${name}: { stigg: { customerId } } }\`.`), () => undefined);
74
+ };
75
+ /** Checks a tool call as the step that asked for it. Refused: an error the model may read. */
76
+ const startTool = async (name, input, options) => {
77
+ const callId = typeof options?.toolCallId === 'string' ? options.toolCallId : undefined;
78
+ const issuedCall = callId === undefined ? undefined : issued.get(callId);
79
+ if (callId !== undefined)
80
+ issued.delete(callId);
81
+ // No model step asked for it here - an approved tool runs at the start of the next call - so
82
+ // the tool's own context may name who it is for: `toolsContext` (ai 7), `experimental_context` (ai 6).
83
+ const stated = issuedCall ? undefined : toolContextOf(options);
84
+ const asked = issuedCall ?? (stated ? { context: stated, call: stated } : undefined);
85
+ if (!asked)
86
+ warnOnce(name);
87
+ // A stream reports when it ends, and the AI SDK runs its tools before that: the tool's check
88
+ // waits, so it sees the spend of the step that asked for it.
89
+ await settle(asked?.call ?? defaultCall);
90
+ const runId = `${prefix}:tool:${++steps}`;
91
+ try {
92
+ await governance.startTool(runId, { name, callId, input }, { stiggContexts: asked?.context ? [asked.context] : [] });
93
+ }
94
+ catch (err) {
95
+ const decision = (0, errors_1.isAccessDenied)(err);
96
+ if (!decision)
97
+ throw err;
98
+ // The AI SDK hands a failed tool's message to the model, so it says why, not who refused or
99
+ // what is left. The decision rides along as the cause, for `stigg.denial(error)`.
100
+ throw new Error(`${name} is not available: ${decision.reason ?? 'access denied'}.`, { cause: err });
101
+ }
102
+ return runId;
103
+ };
104
+ /**
105
+ * A tool whose `execute` is checked and reported. An `async function*` streams its results, and is
106
+ * reported by its last one, as the AI SDK takes it.
107
+ * ponytail: any other function returns a promise here, since the check is awaited first - so an
108
+ * async iterable it returns is run to its end and answered by its last value, without the
109
+ * preliminary results. Write it as `async function*` to stream them.
110
+ */
111
+ const governTool = (name, tool) => {
112
+ const execute = tool.execute;
113
+ if (Object.prototype.toString.call(execute) === '[object AsyncGeneratorFunction]') {
114
+ return async function* (input, options) {
115
+ const runId = await startTool(name, input, options);
116
+ let output;
117
+ let finished = false;
118
+ try {
119
+ for await (const value of execute.call(tool, input, options)) {
120
+ output = value;
121
+ yield value;
122
+ }
123
+ finished = true;
124
+ }
125
+ finally {
126
+ if (!finished)
127
+ governance.abandon(runId);
128
+ }
129
+ await governance.endTool(runId, output, name);
130
+ };
131
+ }
132
+ return async (input, options) => {
133
+ const runId = await startTool(name, input, options);
134
+ let output;
135
+ try {
136
+ output = await execute.call(tool, input, options);
137
+ if (isAsyncIterable(output)) {
138
+ let last;
139
+ for await (const value of output)
140
+ last = value;
141
+ output = last;
142
+ }
143
+ }
144
+ catch (err) {
145
+ governance.abandon(runId);
146
+ throw err;
147
+ }
148
+ // Awaited, and the AI SDK waits for every tool before the next step: that step's check sees it.
149
+ await governance.endTool(runId, output, name);
150
+ return output;
151
+ };
152
+ };
31
153
  /** Checks the step, as the agent and customer this call names. Refused: `AccessDeniedError`. */
32
154
  const start = async (params, model, event) => {
33
155
  // Once: a call's steps run one after another, so nothing of its own lands while this waits.
34
- await Promise.all(reporting.get(callOf(params)) ?? []);
156
+ await settle(callOf(params));
35
157
  const runId = `${prefix}:${++steps}`;
36
158
  const context = contexts.get(params);
37
159
  // Kept by the middleware until the step ends, so the model the reply names can still land on it.
@@ -42,6 +164,23 @@ function stiggVercelMiddleware({ stigg, stiggContext, hooks, }) {
42
164
  return {
43
165
  specificationVersion: 'v3',
44
166
  stigg: governance.stigg,
167
+ tools(tools) {
168
+ if (!toolConfig || Object.keys(toolConfig).length === 0) {
169
+ throw new Error("stiggVercelMiddleware's tools() needs a `tools` option: the paid tools by name, '*' for the rest.");
170
+ }
171
+ // Only a tool the config governs - by name, else through `'*'` - as the core looks it up. An
172
+ // exempt or unpriced one is left as it is: nothing would check it, so nothing should wait.
173
+ const governed = (name) => {
174
+ const entry = Object.hasOwn(toolConfig, name) ? toolConfig[name] : toolConfig[agent_middleware_1.ALL_TOOLS];
175
+ return entry !== undefined && entry !== false;
176
+ };
177
+ return Object.fromEntries(Object.entries(tools).map(([name, tool]) => {
178
+ if (typeof tool?.execute !== 'function' || !governed(name))
179
+ return [name, tool];
180
+ wrapped.add(name);
181
+ return [name, { ...tool, execute: governTool(name, tool) }];
182
+ }));
183
+ },
45
184
  async transformParams({ params }) {
46
185
  const options = params?.providerOptions;
47
186
  if (!options || typeof options !== 'object' || !('stigg' in options))
@@ -71,6 +210,8 @@ function stiggVercelMiddleware({ stigg, stiggContext, hooks, }) {
71
210
  const responseId = result?.response?.id;
72
211
  const chars = (0, usage_1.contentChars)(result?.content);
73
212
  served(trace, result?.response?.modelId);
213
+ for (const part of Array.isArray(result?.content) ? result.content : [])
214
+ ask(params, part);
74
215
  await governance.endModel(runId, (0, usage_1.measurementOf)(result?.usage, responseId) ?? (chars ? (0, usage_1.estimated)(chars, usage_1.NO_USAGE, responseId) : undefined));
75
216
  return result;
76
217
  },
@@ -98,7 +239,7 @@ function stiggVercelMiddleware({ stigg, stiggContext, hooks, }) {
98
239
  if (pending.size === 0 && reporting.get(call) === pending)
99
240
  reporting.delete(call);
100
241
  });
101
- });
242
+ }, (part) => ask(params, part));
102
243
  return result;
103
244
  },
104
245
  };
@@ -180,7 +321,9 @@ function streamTally() {
180
321
  * call's `abortSignal` - which is how the AI SDK stops a stream, without cancelling it.
181
322
  * ponytail: a reader that stops with no `abortSignal` is not reported; nothing tells this layer.
182
323
  */
183
- function observeStream(result, signal, done) {
324
+ function observeStream(result, signal, done,
325
+ /** Each part, before the AI SDK reads it - so a tool call is known before its tool can start. */
326
+ onPart) {
184
327
  const source = result?.stream;
185
328
  if (typeof source?.getReader !== 'function') {
186
329
  done(undefined);
@@ -214,6 +357,11 @@ function observeStream(result, signal, done) {
214
357
  return;
215
358
  }
216
359
  tally.record(next.value);
360
+ onPart?.(next.value);
361
+ // The AI SDK starts a step's tools on its `finish` part, before the stream closes - which, from
362
+ // an HTTP provider, is a later read. So the step reports here, and its tools' checks see it.
363
+ if (next.value?.type === 'finish')
364
+ report('ended');
217
365
  controller.enqueue(next.value);
218
366
  },
219
367
  async cancel(reason) {
@@ -222,3 +370,17 @@ function observeStream(result, signal, done) {
222
370
  },
223
371
  });
224
372
  }
373
+ const isAsyncIterable = (value) => typeof value?.[Symbol.asyncIterator] === 'function';
374
+ /** The `stigg` a tool's own context states - its `toolsContext` entry on ai 7, `experimental_context` on ai 6. */
375
+ function toolContextOf(options) {
376
+ const context = options?.context?.stigg ?? options?.experimental_context?.stigg;
377
+ if (context === undefined || context === null)
378
+ return undefined;
379
+ if (typeof context !== 'object' || Array.isArray(context)) {
380
+ throw new TypeError(`A tool's context \`stigg\` must be a stiggContext object - got ${typeof context}.`);
381
+ }
382
+ // On ai 6 the context is the whole app's, and `stigg` there may be the SDK instance itself: only
383
+ // a plain object is a stiggContext. Anything else names no one, and the SDK says so.
384
+ const proto = Object.getPrototypeOf(context);
385
+ return proto === Object.prototype || proto === null ? context : undefined;
386
+ }
@@ -141,28 +141,24 @@ export declare const OPENAI_METRICS: readonly ["prompt_tokens", "completion_toke
141
141
  /** A gateway speaks the OpenAI shape and adds the price it charged, under its own field name. */
142
142
  export declare const GATEWAY_METRICS: readonly ["prompt_tokens", "completion_tokens", "input_tokens", "output_tokens", "cached_tokens", "total_tokens", "cost"];
143
143
  /**
144
- * A gateway that forwards the upstream body untouched, and so reports in two vocabularies.
145
- *
146
- * Unlike LiteLLM, which normalises every upstream to the OpenAI wire, Cloudflare AI Gateway's
147
- * provider-native routes pass the provider's own body straight through - so `/openai` returns
148
- * OpenAI's counter names and `/anthropic` returns Anthropic's. One recipe serves both wires, so
149
- * this is the union of what its surfaces can report, which is what `metrics` is defined to be.
150
- *
151
- * No `cost`, and that is the defining fact rather than an oversight. Cloudflare states no price on
152
- * any response: its figure lives in the gateway's own log, is explicitly an estimate, and is zero
153
- * on a cache hit. So the recipe carries the log id as a dimension and lets spend be reconciled out
154
- * of band, exactly as the gateway reference prescribes for a price that is only available
155
- * asynchronously.
144
+ * A gateway that answers on the OpenAI routes and on Anthropic Messages, each in its own wire's
145
+ * vocabulary - so the union of the two, which is what `metrics` is defined to be.
146
+ *
147
+ * No `cost` on any of the gateways that use it, and that is the defining fact rather than an
148
+ * oversight: none of them states a price on the response, so spend is reconciled out of band.
156
149
  */
157
- export declare const CLOUDFLARE_METRICS: readonly ["prompt_tokens", "completion_tokens", "input_tokens", "output_tokens", "cached_tokens", "total_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"];
150
+ export declare const OPENAI_ANTHROPIC_METRICS: readonly ["prompt_tokens", "completion_tokens", "input_tokens", "output_tokens", "cached_tokens", "total_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"];
158
151
  /**
159
- * agentgateway, which answers on the OpenAI routes and on Anthropic Messages - each in its
160
- * own wire's vocabulary, so the union of the two, like Cloudflare's.
161
- *
162
- * No `cost`: the gateway is self-hosted and states no price on any response.
163
- * Whatever the upstream charged is invoiced by the upstream, out of band.
152
+ * Cloudflare AI Gateway's provider-native routes pass the provider's body straight through, unlike
153
+ * LiteLLM, which normalises every upstream to the OpenAI wire. Its price lives in the gateway's own
154
+ * log, is explicitly an estimate, and is zero on a cache hit - so the recipe carries the log id as
155
+ * a dimension instead.
164
156
  */
157
+ export declare const CLOUDFLARE_METRICS: readonly ["prompt_tokens", "completion_tokens", "input_tokens", "output_tokens", "cached_tokens", "total_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"];
158
+ /** agentgateway is self-hosted and states no price; the upstream invoices out of band. */
165
159
  export declare const AGENTGATEWAY_METRICS: readonly ["prompt_tokens", "completion_tokens", "input_tokens", "output_tokens", "cached_tokens", "total_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"];
160
+ /** TrueFoundry tracks spend in its own dashboard; the provider accounts behind it are the caller's. */
161
+ export declare const TRUEFOUNDRY_METRICS: readonly ["prompt_tokens", "completion_tokens", "input_tokens", "output_tokens", "cached_tokens", "total_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"];
166
162
  /**
167
163
  * Bedrock's five, under its own names and with no price among them.
168
164
  *
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.normalizedMetrics = exports.addMetrics = exports.METRIC_PATHS = exports.metricNamesMatchPaths = exports.COMPUTED_TOTALS = exports.metricName = exports.WORKERS_AI_METRICS = exports.WORKERS_AI_PATHS = exports.DATABRICKS_QUERY_METRIC_NAME_MAP = exports.DATABRICKS_METRICS = exports.DATABRICKS_RESPONSES_PATHS = exports.DATABRICKS_PATHS = exports.BEDROCK_METRICS = exports.BEDROCK_PATHS = exports.AGENTGATEWAY_METRICS = exports.CLOUDFLARE_METRICS = exports.GATEWAY_METRICS = exports.OPENAI_METRICS = exports.OLLAMA_METRICS = exports.OLLAMA_COMPONENT_METRICS = exports.TYPESAFE_METRICS = exports.TYPESAFE_COMPONENT_METRICS = exports.MISTRAL_METRICS = exports.MISTRAL_METRIC_NAME_MAP = exports.OPENAI_RESPONSES_METRICS = exports.OPENAI_CHAT_METRICS = exports.GEMINI_COMPONENT_METRICS = exports.GEMINI_METRICS = exports.ANTHROPIC_METRICS = exports.ANTHROPIC_COMPONENT_METRICS = exports.TOTAL_TOKENS = exports.addTokenUsage = void 0;
3
+ exports.normalizedMetrics = exports.addMetrics = exports.METRIC_PATHS = exports.metricNamesMatchPaths = exports.COMPUTED_TOTALS = exports.metricName = exports.WORKERS_AI_METRICS = exports.WORKERS_AI_PATHS = exports.DATABRICKS_QUERY_METRIC_NAME_MAP = exports.DATABRICKS_METRICS = exports.DATABRICKS_RESPONSES_PATHS = exports.DATABRICKS_PATHS = exports.BEDROCK_METRICS = exports.BEDROCK_PATHS = exports.TRUEFOUNDRY_METRICS = exports.AGENTGATEWAY_METRICS = exports.CLOUDFLARE_METRICS = exports.OPENAI_ANTHROPIC_METRICS = exports.GATEWAY_METRICS = exports.OPENAI_METRICS = exports.OLLAMA_METRICS = exports.OLLAMA_COMPONENT_METRICS = exports.TYPESAFE_METRICS = exports.TYPESAFE_COMPONENT_METRICS = exports.MISTRAL_METRICS = exports.MISTRAL_METRIC_NAME_MAP = exports.OPENAI_RESPONSES_METRICS = exports.OPENAI_CHAT_METRICS = exports.GEMINI_COMPONENT_METRICS = exports.GEMINI_METRICS = exports.ANTHROPIC_METRICS = exports.ANTHROPIC_COMPONENT_METRICS = exports.TOTAL_TOKENS = exports.addTokenUsage = void 0;
4
4
  exports.fromFlatTokenUsage = fromFlatTokenUsage;
5
5
  exports.fromOpenAIUsage = fromOpenAIUsage;
6
6
  exports.fromResponsesUsage = fromResponsesUsage;
@@ -407,36 +407,28 @@ exports.OPENAI_METRICS = [
407
407
  /** A gateway speaks the OpenAI shape and adds the price it charged, under its own field name. */
408
408
  exports.GATEWAY_METRICS = [...exports.OPENAI_METRICS, 'cost'];
409
409
  /**
410
- * A gateway that forwards the upstream body untouched, and so reports in two vocabularies.
411
- *
412
- * Unlike LiteLLM, which normalises every upstream to the OpenAI wire, Cloudflare AI Gateway's
413
- * provider-native routes pass the provider's own body straight through - so `/openai` returns
414
- * OpenAI's counter names and `/anthropic` returns Anthropic's. One recipe serves both wires, so
415
- * this is the union of what its surfaces can report, which is what `metrics` is defined to be.
416
- *
417
- * No `cost`, and that is the defining fact rather than an oversight. Cloudflare states no price on
418
- * any response: its figure lives in the gateway's own log, is explicitly an estimate, and is zero
419
- * on a cache hit. So the recipe carries the log id as a dimension and lets spend be reconciled out
420
- * of band, exactly as the gateway reference prescribes for a price that is only available
421
- * asynchronously.
410
+ * A gateway that answers on the OpenAI routes and on Anthropic Messages, each in its own wire's
411
+ * vocabulary - so the union of the two, which is what `metrics` is defined to be.
412
+ *
413
+ * No `cost` on any of the gateways that use it, and that is the defining fact rather than an
414
+ * oversight: none of them states a price on the response, so spend is reconciled out of band.
422
415
  */
423
- exports.CLOUDFLARE_METRICS = [
416
+ exports.OPENAI_ANTHROPIC_METRICS = [
424
417
  ...exports.OPENAI_METRICS,
425
418
  'cache_read_input_tokens',
426
419
  'cache_creation_input_tokens',
427
420
  ];
428
421
  /**
429
- * agentgateway, which answers on the OpenAI routes and on Anthropic Messages - each in its
430
- * own wire's vocabulary, so the union of the two, like Cloudflare's.
431
- *
432
- * No `cost`: the gateway is self-hosted and states no price on any response.
433
- * Whatever the upstream charged is invoiced by the upstream, out of band.
422
+ * Cloudflare AI Gateway's provider-native routes pass the provider's body straight through, unlike
423
+ * LiteLLM, which normalises every upstream to the OpenAI wire. Its price lives in the gateway's own
424
+ * log, is explicitly an estimate, and is zero on a cache hit - so the recipe carries the log id as
425
+ * a dimension instead.
434
426
  */
435
- exports.AGENTGATEWAY_METRICS = [
436
- ...exports.OPENAI_METRICS,
437
- 'cache_read_input_tokens',
438
- 'cache_creation_input_tokens',
439
- ];
427
+ exports.CLOUDFLARE_METRICS = exports.OPENAI_ANTHROPIC_METRICS;
428
+ /** agentgateway is self-hosted and states no price; the upstream invoices out of band. */
429
+ exports.AGENTGATEWAY_METRICS = exports.OPENAI_ANTHROPIC_METRICS;
430
+ /** TrueFoundry tracks spend in its own dashboard; the provider accounts behind it are the caller's. */
431
+ exports.TRUEFOUNDRY_METRICS = exports.OPENAI_ANTHROPIC_METRICS;
440
432
  /**
441
433
  * Bedrock's five, under its own names and with no price among them.
442
434
  *
package/dist/index.d.ts CHANGED
@@ -60,6 +60,9 @@ export type { CloudflareRecipeOptions } from './bindings/client/gateways/cloudfl
60
60
  export { agentgatewayRecipe } from './bindings/client/gateways/agentgateway/agentgateway-recipe';
61
61
  export type { AgentgatewayMetricName, AgentgatewayMetrics, } from './bindings/client/gateways/agentgateway/agentgateway-recipe';
62
62
  export type { AgentgatewayRecipeOptions } from './bindings/client/gateways/agentgateway/types';
63
+ export { truefoundryRecipe } from './bindings/client/gateways/truefoundry/truefoundry-recipe';
64
+ export type { TrueFoundryMetricName, TrueFoundryMetrics, } from './bindings/client/gateways/truefoundry/truefoundry-recipe';
65
+ export type { TrueFoundryRecipeOptions } from './bindings/client/gateways/truefoundry/types';
63
66
  export type { FetchLike, FetchOptions, GovernedFetch, GovernedRequestInit } from './bindings/fetch/types';
64
67
  export { typesafeRecipe, estimateFromState } from './bindings/client/providers/typesafe/typesafe-recipe';
65
68
  export type { TypeSafeMetric, TypeSafeMetrics } from './bindings/client/providers/typesafe/typesafe-recipe';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.normalizeUsage = exports.normalizedMetrics = exports.stiggVercelMiddleware = exports.weightedMetrics = exports.estimateFromState = exports.typesafeRecipe = exports.agentgatewayRecipe = exports.cloudflareRecipe = exports.bedrockRecipe = exports.databricksExternalRecipe = exports.databricksRecipe = exports.ollamaRecipe = exports.mistralRecipe = exports.groqRecipe = exports.litellmRecipe = exports.openrouterRecipe = exports.estimateFromMaxOutputTokens = exports.geminiChatRecipe = exports.geminiRecipe = exports.openAIRecipe = exports.estimateFromMaxTokens = exports.anthropicRecipe = exports.hasMethod = exports.createClientRegistry = exports.wrapClient = exports.toMeasurement = exports.entitlementRef = exports.entitlementKind = exports.entitlementId = exports.emptyTokenUsage = exports.vendorMetrics = exports.unitsPerTokens = exports.looksLikeGovernanceNotEnabled = exports.isGovernanceNotEnabled = exports.isAccessDenied = exports.isEngineError = exports.GovernanceNotEnabledError = exports.AccessDeniedError = exports.EngineError = exports.STIGG_TRACER_NAME = exports.noopLogger = exports.consoleLogger = exports.parseGovernanceHeaders = exports.GOVERNANCE_HEADERS = exports.remedyMessage = exports.describeDecision = exports.flattenRaw = exports.RAW_PREFIX = exports.StiggAgentSDK = void 0;
3
+ exports.normalizeUsage = exports.normalizedMetrics = exports.stiggVercelMiddleware = exports.weightedMetrics = exports.estimateFromState = exports.typesafeRecipe = exports.truefoundryRecipe = exports.agentgatewayRecipe = exports.cloudflareRecipe = exports.bedrockRecipe = exports.databricksExternalRecipe = exports.databricksRecipe = exports.ollamaRecipe = exports.mistralRecipe = exports.groqRecipe = exports.litellmRecipe = exports.openrouterRecipe = exports.estimateFromMaxOutputTokens = exports.geminiChatRecipe = exports.geminiRecipe = exports.openAIRecipe = exports.estimateFromMaxTokens = exports.anthropicRecipe = exports.hasMethod = exports.createClientRegistry = exports.wrapClient = exports.toMeasurement = exports.entitlementRef = exports.entitlementKind = exports.entitlementId = exports.emptyTokenUsage = exports.vendorMetrics = exports.unitsPerTokens = exports.looksLikeGovernanceNotEnabled = exports.isGovernanceNotEnabled = exports.isAccessDenied = exports.isEngineError = exports.GovernanceNotEnabledError = exports.AccessDeniedError = exports.EngineError = exports.STIGG_TRACER_NAME = exports.noopLogger = exports.consoleLogger = exports.parseGovernanceHeaders = exports.GOVERNANCE_HEADERS = exports.remedyMessage = exports.describeDecision = exports.flattenRaw = exports.RAW_PREFIX = exports.StiggAgentSDK = void 0;
4
4
  /**
5
5
  * Public surface.
6
6
  *
@@ -126,6 +126,12 @@ Object.defineProperty(exports, "cloudflareRecipe", { enumerable: true, get: func
126
126
  // and no `cost`, because the gateway states no price on any response.
127
127
  var agentgateway_recipe_1 = require("./bindings/client/gateways/agentgateway/agentgateway-recipe");
128
128
  Object.defineProperty(exports, "agentgatewayRecipe", { enumerable: true, get: function () { return agentgateway_recipe_1.agentgatewayRecipe; } });
129
+ // TrueFoundry's AI Gateway, hosted at `gateway.truefoundry.ai` or self-hosted. A default, ahead of both
130
+ // wires it serves, so its traffic reports as `truefoundry.*`. Self-hosted installs pass their base URL at
131
+ // `wrap(client, { recipe: truefoundryRecipe({ baseUrl }) })`. Tokens and the model that served
132
+ // (`x-tfy-resolved-model`), no `cost`.
133
+ var truefoundry_recipe_1 = require("./bindings/client/gateways/truefoundry/truefoundry-recipe");
134
+ Object.defineProperty(exports, "truefoundryRecipe", { enumerable: true, get: function () { return truefoundry_recipe_1.truefoundryRecipe; } });
129
135
  var typesafe_recipe_1 = require("./bindings/client/providers/typesafe/typesafe-recipe");
130
136
  Object.defineProperty(exports, "typesafeRecipe", { enumerable: true, get: function () { return typesafe_recipe_1.typesafeRecipe; } });
131
137
  Object.defineProperty(exports, "estimateFromState", { enumerable: true, get: function () { return typesafe_recipe_1.estimateFromState; } });
@@ -28,6 +28,7 @@ const litellm_recipe_1 = require("./bindings/client/gateways/litellm/litellm-rec
28
28
  const databricks_recipe_1 = require("./bindings/client/gateways/databricks/databricks-recipe");
29
29
  const cloudflare_recipe_1 = require("./bindings/client/gateways/cloudflare/cloudflare-recipe");
30
30
  const agentgateway_recipe_1 = require("./bindings/client/gateways/agentgateway/agentgateway-recipe");
31
+ const truefoundry_recipe_1 = require("./bindings/client/gateways/truefoundry/truefoundry-recipe");
31
32
  const groq_recipe_1 = require("./bindings/client/providers/groq/groq-recipe");
32
33
  const mistral_recipe_1 = require("./bindings/client/providers/mistral/mistral-recipe");
33
34
  const ollama_recipe_1 = require("./bindings/client/providers/ollama/ollama-recipe");
@@ -71,6 +72,9 @@ exports.DEFAULT_RECIPES = [
71
72
  // Anthropic Messages wire too, and only the base URL tells its `Anthropic` client from a direct
72
73
  // one. Without a configured base URL it matches on a hostname label - see the recipe.
73
74
  (0, agentgateway_recipe_1.agentgatewayRecipe)(),
75
+ // Ahead of `anthropicRecipe` for the same reason again: TrueFoundry answers on both wires, and
76
+ // only its hostname (or a configured base URL) tells its clients from direct ones.
77
+ (0, truefoundry_recipe_1.truefoundryRecipe)(),
74
78
  anthropic_recipe_1.anthropicRecipe,
75
79
  (0, openrouter_recipe_1.openrouterRecipe)(),
76
80
  (0, litellm_recipe_1.litellmRecipe)(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stigg/agent-sdk-js",
3
- "version": "0.7.7",
3
+ "version": "0.7.9",
4
4
  "description": "Stigg agent SDK (JS) - run-lifecycle governance for agentic stacks, with examples per layer",
5
5
  "main": "dist/index.js",
6
6
  "typings": "dist/index.d.ts",
@@ -64,6 +64,7 @@
64
64
  "example-gateway-openrouter": "tsx examples/gateway/openrouter.ts",
65
65
  "example-gateway-litellm": "tsx examples/gateway/litellm.ts",
66
66
  "example-gateway-agentgateway": "tsx examples/gateway/agentgateway.ts",
67
+ "example-gateway-truefoundry": "tsx examples/gateway/truefoundry.ts",
67
68
  "example-gateway-cloudflare": "tsx examples/gateway/cloudflare.ts",
68
69
  "example-gateway-bedrock": "tsx examples/gateway/bedrock.ts",
69
70
  "example-gateway-databricks": "tsx examples/gateway/databricks.ts"