@stigg/agent-sdk-js 0.7.5 → 0.7.7
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 +6 -1
- package/dist/bindings/client/core/wrap-client.d.ts +10 -0
- package/dist/bindings/client/core/wrap-client.js +1 -0
- package/dist/bindings/client/gateways/agentgateway/agentgateway-recipe.d.ts +10 -0
- package/dist/bindings/client/gateways/agentgateway/agentgateway-recipe.js +143 -0
- package/dist/bindings/client/gateways/agentgateway/types.d.ts +24 -0
- package/dist/bindings/client/gateways/agentgateway/types.js +2 -0
- package/dist/bindings/fetch/types.d.ts +40 -0
- package/dist/bindings/fetch/types.js +2 -0
- package/dist/bindings/fetch/wrap-fetch.d.ts +5 -0
- package/dist/bindings/fetch/wrap-fetch.js +316 -0
- package/dist/bindings/shared/token-usage.d.ts +8 -0
- package/dist/bindings/shared/token-usage.js +13 -1
- package/dist/engine/engine.d.ts +7 -2
- package/dist/engine/rest-governance-engine.js +9 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.js +9 -1
- package/dist/stigg-agent-sdk.d.ts +23 -0
- package/dist/stigg-agent-sdk.js +55 -2
- package/dist/types.d.ts +12 -1
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -154,7 +154,11 @@ closed, and conflating them is how an outage takes an application down.
|
|
|
154
154
|
| --- | --- | --- | --- |
|
|
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
|
-
| <img src="https://raw.githubusercontent.com/stiggio/stigg-agent-sdk-ts/main/docs/logos/databricks.svg" width="18" alt=""> | Databricks | | |
|
|
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
|
+
|
|
159
|
+
A gateway called with plain HTTP rather than through an SDK is governed the same way:
|
|
160
|
+
`stigg.wrapFetch({ baseUrl, recipe })` hands back a `fetch` that is checked and metered per
|
|
161
|
+
request.
|
|
158
162
|
|
|
159
163
|
**Orchestrators** — every agent in a graph checked and metered as itself, its tools included:
|
|
160
164
|
|
|
@@ -175,6 +179,7 @@ rather than handing back a client that quietly meters nothing.
|
|
|
175
179
|
| You want to | Use |
|
|
176
180
|
| --- | --- |
|
|
177
181
|
| Govern a client | `stigg.wrap(client, { stiggContext })` |
|
|
182
|
+
| Govern plain HTTP calls to a gateway | `stigg.wrapFetch({ baseUrl, recipe, stiggContext })` |
|
|
178
183
|
| Gate without a client to wrap | `stigg.check(context)` |
|
|
179
184
|
| Meter something you measured yourself | `stigg.report(context, metrics)` |
|
|
180
185
|
| Give an agent its own budget | `stigg.register({ customerId, agentId, budget })` |
|
|
@@ -11,3 +11,13 @@ export declare function isGoverned(client: object): boolean;
|
|
|
11
11
|
* for Cloudflare's `ai.run(model, inputs)`.
|
|
12
12
|
*/
|
|
13
13
|
export declare function modelOf(...sources: unknown[]): string | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* How much to ask permission for, with the one answer a gate must never send.
|
|
16
|
+
*
|
|
17
|
+
* `estimate` is the application's own callback - a gateway recipe takes it as an option - and a
|
|
18
|
+
* negative one is worse than a wrong one: `remaining >= requestedUsage` is true at any balance,
|
|
19
|
+
* so an unaffordable call is granted by a budget that had nothing left. `NaN` and `Infinity` fail
|
|
20
|
+
* the same comparison in the other direction. Zero and fractions are left alone: a credit
|
|
21
|
+
* currency is spent in fractions, and asking for nothing is a legitimate "may I at all".
|
|
22
|
+
*/
|
|
23
|
+
export declare function requestedUnits(estimate: (params: any) => number, params: any): number;
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.wrapClient = wrapClient;
|
|
4
4
|
exports.isGoverned = isGoverned;
|
|
5
5
|
exports.modelOf = modelOf;
|
|
6
|
+
exports.requestedUnits = requestedUnits;
|
|
6
7
|
/**
|
|
7
8
|
* The client binding: a drop-in wrapper that gates before the call and meters after it.
|
|
8
9
|
*
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { AGENTGATEWAY_METRICS } from '../../../shared/token-usage';
|
|
2
|
+
import type { AgentgatewayRecipeOptions } from './types';
|
|
3
|
+
import type { ClientRecipe, MetricsFor, MetricsOf } from '../../core/types';
|
|
4
|
+
type AgentgatewayMetric = (typeof AGENTGATEWAY_METRICS)[number];
|
|
5
|
+
export declare function agentgatewayRecipe(options?: AgentgatewayRecipeOptions): ClientRecipe<AgentgatewayMetric>;
|
|
6
|
+
/** One of the metric names agentgateway can report, across every wire it serves. */
|
|
7
|
+
export type AgentgatewayMetricName = MetricsOf<ReturnType<typeof agentgatewayRecipe>>;
|
|
8
|
+
/** What a mapper reads off a call through agentgateway: those names, each possibly absent. */
|
|
9
|
+
export type AgentgatewayMetrics = MetricsFor<ReturnType<typeof agentgatewayRecipe>>;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.agentgatewayRecipe = agentgatewayRecipe;
|
|
4
|
+
/**
|
|
5
|
+
* The agentgateway recipe - the open-source AI gateway (and the builds on it: Solo.io's enterprise
|
|
6
|
+
* edition, Gloo AI Gateway), a gateway you call that states no price.
|
|
7
|
+
*
|
|
8
|
+
* Its OpenAI routes fit `openAIRecipe` by shape and its Anthropic route fits `anthropicRecipe`, so
|
|
9
|
+
* the recipe has to justify itself, and it does on two counts:
|
|
10
|
+
*
|
|
11
|
+
* - **A vendor name of its own.** Without this, traffic through the gateway meters as `openai.*`
|
|
12
|
+
* or `anthropic.*` - indistinguishable from a direct call, so no meter and no per-provider
|
|
13
|
+
* budget can tell the gateway's traffic apart. Worse on the Anthropic wire: `anthropicRecipe`
|
|
14
|
+
* has no base-URL guard, so an `Anthropic` client pointed here would be claimed by it and
|
|
15
|
+
* reported as direct Anthropic traffic.
|
|
16
|
+
* - **The model that actually served.** The gateway can rewrite the model a request named - an
|
|
17
|
+
* alias, a provider prefix, a failover priority group - and the response body names the one
|
|
18
|
+
* that answered. The bare recipes drop it; this one attributes it.
|
|
19
|
+
*
|
|
20
|
+
* One vendor across every wire, because the gateway is one deployment whichever client reached it,
|
|
21
|
+
* and the wire is already in the event name (`agentgateway.chat.completions.create` against
|
|
22
|
+
* `agentgateway.messages.create`). The gateway translates where it needs to, so each route reports
|
|
23
|
+
* in the vocabulary of the wire it answered on - OpenAI's names on the OpenAI routes, Anthropic's
|
|
24
|
+
* on Messages - and `metrics` is the union, which is what `metrics` is defined to be.
|
|
25
|
+
*
|
|
26
|
+
* No `cost`, and that is the defining fact rather than an oversight. The gateway is self-hosted and
|
|
27
|
+
* states no price on any response; whatever the upstream charged is invoiced by the upstream, out
|
|
28
|
+
* of band. Never declare a metric the platform cannot report.
|
|
29
|
+
*
|
|
30
|
+
* Self-hosted, so there is no public hostname to recognise. The base URL handed to
|
|
31
|
+
* `agentgatewayRecipe({ baseUrl })` is the reliable discriminator; without one the matcher falls
|
|
32
|
+
* back to a guess on the hostname - see {@link isAgentgateway} for what it claims.
|
|
33
|
+
*
|
|
34
|
+
* The same surfaces govern raw HTTP too: `stigg.wrapFetch` routes a request by its path to the
|
|
35
|
+
* method here that names it, so a `fetch` to the gateway is metered by exactly these readers.
|
|
36
|
+
*/
|
|
37
|
+
const meter_stream_1 = require("../../../shared/meter-stream");
|
|
38
|
+
const token_usage_1 = require("../../../shared/token-usage");
|
|
39
|
+
const base_url_1 = require("../../../shared/base-url");
|
|
40
|
+
const openai_recipe_1 = require("../../providers/openai/openai-recipe");
|
|
41
|
+
const anthropic_recipe_1 = require("../../providers/anthropic/anthropic-recipe");
|
|
42
|
+
const types_1 = require("../../../../types");
|
|
43
|
+
/**
|
|
44
|
+
* The name that identifies the gateway in an ordinary deployment: the Kubernetes service or
|
|
45
|
+
* namespace (`agentgateway-proxy.agentgateway-system.svc`) or a hostname an operator chose to say
|
|
46
|
+
* so with (`agentgateway.acme.internal`). A dot-separated label matches when it *is* this name or
|
|
47
|
+
* *starts with* it and a hyphen - which is how a default install names both its service and its
|
|
48
|
+
* namespace. Never a substring or a later segment: `myagentgateway` and `my-agentgateway` are
|
|
49
|
+
* somebody else's host.
|
|
50
|
+
*
|
|
51
|
+
* Only this one. `solo` and `gloo` are words a customer picks for their own resources
|
|
52
|
+
* (`solo-prod.openai.azure.com`, `litellm.gloo-system.svc`), and this recipe sits ahead of
|
|
53
|
+
* LiteLLM, Databricks and OpenAI - so guessing on them would move those calls to `agentgateway.*`
|
|
54
|
+
* and out of the meters counting them. Solo's and Gloo's builds name the gateway by `baseUrl`.
|
|
55
|
+
*/
|
|
56
|
+
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
|
+
function agentgatewayRecipe(options = {}) {
|
|
71
|
+
const estimate = options.estimate ?? (() => 1);
|
|
72
|
+
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
|
+
});
|
|
109
|
+
return {
|
|
110
|
+
vendor: 'agentgateway',
|
|
111
|
+
metrics: token_usage_1.AGENTGATEWAY_METRICS,
|
|
112
|
+
// Every wire here spells its total `total_tokens` - Anthropic's is computed from its disjoint
|
|
113
|
+
// counters by the reader this reuses, OpenAI's is stated.
|
|
114
|
+
totalTokensMetric: token_usage_1.TOTAL_TOKENS,
|
|
115
|
+
// The base URL decides, because shape cannot: an `OpenAI` client and an `Anthropic` client
|
|
116
|
+
// pointed at the gateway look exactly like direct clients.
|
|
117
|
+
matches: (client) => isAgentgateway(client, options.baseUrl) && (openai_recipe_1.openAIRecipe.matches(client) || anthropic_recipe_1.anthropicRecipe.matches(client)),
|
|
118
|
+
// Every wire it serves. A surface left undeclared here is not metered by something else - it
|
|
119
|
+
// is ungated, because this recipe is matched ahead of both of the ones that would have claimed
|
|
120
|
+
// it.
|
|
121
|
+
methods: [...openai_recipe_1.openAIRecipe.methods.map(surfaceOf), ...anthropic_recipe_1.anthropicRecipe.methods.map(surfaceOf)],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* `baseURL` is a public field on both clients. Shape cannot answer this and a wrong guess silently
|
|
126
|
+
* changes which vendor an event names, so the check is narrow on purpose.
|
|
127
|
+
*
|
|
128
|
+
* Given a `baseUrl`, the endpoint is compared one path segment at a time, so `.../v1` cannot claim
|
|
129
|
+
* `.../v10`. Without one, the hostname is split on dots and claimed only when a label is, or starts
|
|
130
|
+
* with, `agentgateway-` - the name a default install puts in its service and namespace.
|
|
131
|
+
* An unrelated endpoint whose hostname carries one of those labels is claimed too and reports
|
|
132
|
+
* under `agentgateway.*`; name `recipe: openAIRecipe` (or `anthropicRecipe`) at `wrap` to settle it.
|
|
133
|
+
*/
|
|
134
|
+
function isAgentgateway(client, baseUrl) {
|
|
135
|
+
const clientBase = (0, base_url_1.baseUrlOf)(client);
|
|
136
|
+
if (clientBase === undefined)
|
|
137
|
+
return false;
|
|
138
|
+
if (baseUrl)
|
|
139
|
+
return (0, base_url_1.withinBaseUrl)(baseUrl, clientBase);
|
|
140
|
+
return (0, base_url_1.hostOf)(clientBase)
|
|
141
|
+
.split('.')
|
|
142
|
+
.some((label) => label.split('-')[0] === HOST_LABEL);
|
|
143
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type AgentgatewayRecipeOptions = {
|
|
2
|
+
/**
|
|
3
|
+
* How much to ask permission for before the call, in whatever unit `meter` bills.
|
|
4
|
+
*
|
|
5
|
+
* The default asks for 1 - "is there any budget left at all?" - and lets the ingest afterwards
|
|
6
|
+
* settle the balance. No `unitsPer…` beside it, because there is nothing fractional to
|
|
7
|
+
* denominate: the gateway is self-hosted and puts no price on any response, so a run here bills
|
|
8
|
+
* token counters, which arrive whole.
|
|
9
|
+
*/
|
|
10
|
+
estimate?: (params: any) => number;
|
|
11
|
+
/**
|
|
12
|
+
* Dimension the model that actually served is attributed to, so routing drift is visible in
|
|
13
|
+
* Stigg. The gateway can rewrite the model a request named - an alias, a provider prefix, a
|
|
14
|
+
* failover priority group - and the response body names the one that answered.
|
|
15
|
+
*/
|
|
16
|
+
modelDimension?: string;
|
|
17
|
+
/**
|
|
18
|
+
* The gateway this recipe is for. agentgateway is self-hosted - a proxy in your own cluster - so
|
|
19
|
+
* unlike a public gateway there is no fixed host to recognise. Give it the same base URL the
|
|
20
|
+
* client was built with, and `matches` compares against exactly that. Without it the matcher
|
|
21
|
+
* falls back to a guess on the hostname; see the recipe for what that guess claims.
|
|
22
|
+
*/
|
|
23
|
+
baseUrl?: string;
|
|
24
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ClientRecipe } from '../client/core/types';
|
|
2
|
+
import type { StiggContext } from '../../core/context';
|
|
3
|
+
import type { SessionHooks } from '../../core/session';
|
|
4
|
+
import type { ClientContext, ClientGovernance } from '../../types';
|
|
5
|
+
/** Whatever `fetch` the application already uses. Defaults to the global one. */
|
|
6
|
+
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
7
|
+
/** `fetch`'s own second argument, plus the per-call context a wrapped client reads off its params. */
|
|
8
|
+
export type GovernedRequestInit = RequestInit & {
|
|
9
|
+
/** Overrides the context `wrapFetch` was given, for this request only. Never sent on the wire. */
|
|
10
|
+
stiggContext?: StiggContext;
|
|
11
|
+
};
|
|
12
|
+
export type FetchOptions = {
|
|
13
|
+
/**
|
|
14
|
+
* The endpoint whose calls are governed - the gateway's base URL. A request outside it, or to a
|
|
15
|
+
* path no recipe method answers, goes through untouched: this is a `fetch`, and an application
|
|
16
|
+
* reaches more than its gateway with one.
|
|
17
|
+
*/
|
|
18
|
+
baseUrl: string;
|
|
19
|
+
/**
|
|
20
|
+
* What the responses mean. Required, unlike at `wrap`: there is no client object to recognise a
|
|
21
|
+
* vendor by, and guessing from a URL is exactly the decision that bills the wrong party. Any
|
|
22
|
+
* recipe over the OpenAI or Anthropic wire works - `agentgatewayRecipe()`, `litellmRecipe()`,
|
|
23
|
+
* `openAIRecipe` - because the request path says which of its methods a call is.
|
|
24
|
+
*/
|
|
25
|
+
recipe: ClientRecipe;
|
|
26
|
+
/** The `fetch` to govern. Defaults to `globalThis.fetch`. */
|
|
27
|
+
fetch?: FetchLike;
|
|
28
|
+
/** Everything this fetch reports back while it works. See {@link SessionHooks}. */
|
|
29
|
+
hooks?: SessionHooks;
|
|
30
|
+
/** The default context for every governed request. Any request may override it. */
|
|
31
|
+
stiggContext?: ClientContext;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* A `fetch` bound to this environment: a request to a governed route is checked before it leaves
|
|
35
|
+
* and metered from its response, exactly as the same call through a wrapped SDK client would be.
|
|
36
|
+
*/
|
|
37
|
+
export type GovernedFetch = ((input: string | URL | Request, init?: GovernedRequestInit) => Promise<Response>) & {
|
|
38
|
+
/** What this fetch did, and what is still in flight. The same handle a wrapped client carries. */
|
|
39
|
+
readonly stigg: ClientGovernance;
|
|
40
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type ResolvedContext } from '../../core/context';
|
|
2
|
+
import type { GovernanceSession } from '../../core/session';
|
|
3
|
+
import { type ClientRecipe } from '../client/core/types';
|
|
4
|
+
import type { FetchLike, GovernedRequestInit } from './types';
|
|
5
|
+
export declare function wrapFetch(fetchFn: FetchLike, recipe: ClientRecipe, baseUrl: string, session: GovernanceSession, base: ResolvedContext): (input: string | URL | Request, init?: GovernedRequestInit) => Promise<Response>;
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.wrapFetch = wrapFetch;
|
|
4
|
+
/**
|
|
5
|
+
* The fetch binding: the client binding's gate and meter, for a call made with plain HTTP.
|
|
6
|
+
*
|
|
7
|
+
* A gateway is often called with no SDK at all - a service posts JSON to `/v1/chat/completions`
|
|
8
|
+
* and reads the reply - and `wrap` has nothing to hold on to there. The proxy binding does not
|
|
9
|
+
* help either: it ingests what a proxy *you* operate observed, and a third-party gateway never
|
|
10
|
+
* calls Stigg. So this wraps the one object such a caller does hold, its `fetch`.
|
|
11
|
+
*
|
|
12
|
+
* Nothing about reading a response is new here. A request is matched to a recipe method by its
|
|
13
|
+
* path - `/chat/completions` is `chat.completions.create` on every OpenAI-wire SDK, `/messages`
|
|
14
|
+
* is Anthropic's `messages.create` - and from then on it is that method's call: the same
|
|
15
|
+
* `estimate`, the same `extractUsage` on a JSON reply, the same `attachStream` on an SSE one, and
|
|
16
|
+
* the same event name. A recipe is written once and governs both ways of reaching its vendor.
|
|
17
|
+
*
|
|
18
|
+
* Three things differ from a wrapped client, all because a `fetch` is a lower-level door:
|
|
19
|
+
*
|
|
20
|
+
* - **Only routes the recipe declares are governed.** Anything else - another host, `GET
|
|
21
|
+
* /models`, embeddings - passes through untouched. A `fetch` reaches more than one endpoint,
|
|
22
|
+
* and refusing or metering a call nothing describes would be guessing.
|
|
23
|
+
* - **A stream is metered as the caller reads it.** The body is observed in place, not teed, so
|
|
24
|
+
* nothing is buffered on the caller's behalf - and, as with an SDK stream, a body nobody reads
|
|
25
|
+
* to the end meters what was read.
|
|
26
|
+
* - **A streamed reply comes back as a new `Response`** around the observed body, carrying the
|
|
27
|
+
* original status and headers. `url` and `redirected` are not carried, because a constructed
|
|
28
|
+
* `Response` cannot have them.
|
|
29
|
+
*/
|
|
30
|
+
const errors_1 = require("../../errors");
|
|
31
|
+
const context_1 = require("../../core/context");
|
|
32
|
+
const types_1 = require("../../types");
|
|
33
|
+
const base_url_1 = require("../shared/base-url");
|
|
34
|
+
const types_2 = require("../client/core/types");
|
|
35
|
+
const wrap_client_1 = require("../client/core/wrap-client");
|
|
36
|
+
/**
|
|
37
|
+
* Where each SDK method posts. The same on every vendor that speaks the wire - which is why a
|
|
38
|
+
* gateway can be OpenAI-compatible at all - so it lives here once rather than on every recipe.
|
|
39
|
+
*/
|
|
40
|
+
const ROUTES = {
|
|
41
|
+
'chat.completions.create': '/chat/completions',
|
|
42
|
+
'responses.create': '/responses',
|
|
43
|
+
'messages.create': '/messages',
|
|
44
|
+
};
|
|
45
|
+
function wrapFetch(fetchFn, recipe, baseUrl, session, base) {
|
|
46
|
+
const routes = recipe.methods.flatMap((method) => {
|
|
47
|
+
const suffix = ROUTES[method.path.join('.')];
|
|
48
|
+
return suffix ? [{ suffix, method, eventName: (0, types_2.eventNameFor)(recipe, method) }] : [];
|
|
49
|
+
});
|
|
50
|
+
// The same refusal `wrap` makes for a recipe that gates nothing on the client it was handed: a
|
|
51
|
+
// fetch that looks governed and meters nothing is worse than an error now.
|
|
52
|
+
if (routes.length === 0) {
|
|
53
|
+
throw new Error(`The ${recipe.vendor} recipe gates ${recipe.methods.map((m) => m.path.join('.')).join(', ')}, and none of ` +
|
|
54
|
+
`those has an HTTP route this binding knows (${Object.values(ROUTES).join(', ')}). Nothing would be ` +
|
|
55
|
+
'checked or metered, so this is refused rather than handed back looking governed.');
|
|
56
|
+
}
|
|
57
|
+
// A relative or malformed one matches nothing, so every request would pass through ungoverned.
|
|
58
|
+
if (!URL.canParse(baseUrl))
|
|
59
|
+
throw new Error(`wrapFetch needs an absolute base URL, got "${baseUrl}".`);
|
|
60
|
+
return async function governedFetch(input, init) {
|
|
61
|
+
// Taken off before anything else sees the request, governed or not - `fetch` ignores an
|
|
62
|
+
// unknown field today, but it is not this binding's to forward.
|
|
63
|
+
let requestInit = init;
|
|
64
|
+
let override;
|
|
65
|
+
// Presence, not value: an explicit `stiggContext: undefined` is still not the wire's.
|
|
66
|
+
if (init && 'stiggContext' in init) {
|
|
67
|
+
const { stiggContext, ...rest } = init;
|
|
68
|
+
requestInit = rest;
|
|
69
|
+
override = stiggContext;
|
|
70
|
+
}
|
|
71
|
+
const route = routeOf(input, requestInit, baseUrl, routes);
|
|
72
|
+
if (!route)
|
|
73
|
+
return fetchFn(input, requestInit);
|
|
74
|
+
const params = await paramsOf(input, requestInit);
|
|
75
|
+
if (route.method.passthrough?.(params, undefined))
|
|
76
|
+
return fetchFn(input, requestInit);
|
|
77
|
+
const defaults = base.eventName ? base : { ...base, eventName: { name: route.eventName } };
|
|
78
|
+
// Presence, as `wrap` tests it: a `null` override is validated and refused, not ignored.
|
|
79
|
+
const context = override !== undefined ? (0, context_1.overrideContext)(defaults, override) : defaults;
|
|
80
|
+
if (!context.customerId)
|
|
81
|
+
throw (0, context_1.noCustomer)();
|
|
82
|
+
if ((0, context_1.isEnforced)(context)) {
|
|
83
|
+
const decision = await session.check(context, (0, wrap_client_1.requestedUnits)(route.method.estimate ?? (() => 1), params));
|
|
84
|
+
if (!decision.hasAccess)
|
|
85
|
+
throw new errors_1.AccessDeniedError(decision);
|
|
86
|
+
}
|
|
87
|
+
const startedAt = Date.now();
|
|
88
|
+
const response = await fetchFn(input, requestInit);
|
|
89
|
+
// An error body carries no usage, and the gateway billed nothing for a refusal of its own.
|
|
90
|
+
if (!response.ok)
|
|
91
|
+
return response;
|
|
92
|
+
let data;
|
|
93
|
+
let served;
|
|
94
|
+
const ingest = (measured) => {
|
|
95
|
+
const measurement = (0, types_1.toMeasurement)(measured);
|
|
96
|
+
// What an AUDR record names and the measurement does not, as `wrap` adds it: the reply's
|
|
97
|
+
// model before the request's, since the gateway can serve another.
|
|
98
|
+
const traced = {
|
|
99
|
+
...context,
|
|
100
|
+
audr: { provider: recipe.vendor, model: (0, wrap_client_1.modelOf)(served, data, params), startedAt },
|
|
101
|
+
};
|
|
102
|
+
void session.ingestMeasurement(traced, measurement);
|
|
103
|
+
if (measurement.incomplete)
|
|
104
|
+
session.failedToMeasure(context, new Error(measurement.incomplete));
|
|
105
|
+
};
|
|
106
|
+
try {
|
|
107
|
+
if (isEventStream(response))
|
|
108
|
+
return meteredStream(response, route.method, ingest, (err) => session.failedToMeasure(context, err),
|
|
109
|
+
// The model that served, from the first event that names it - a Chat Completions chunk,
|
|
110
|
+
// Anthropic's `message_start`, a Responses event - so a stream's record matches a reply's.
|
|
111
|
+
(event) => (served ??= (0, wrap_client_1.modelOf)(event, event?.message, event?.response)));
|
|
112
|
+
// A copy, read here, so the caller gets a body nobody has touched. Awaited rather than left
|
|
113
|
+
// running, so the ingest is in flight - and `flush()` waits for it - by the time the caller
|
|
114
|
+
// has the response.
|
|
115
|
+
data = await response.clone().json();
|
|
116
|
+
const measured = route.method.extractUsage?.(data, response);
|
|
117
|
+
if (measured)
|
|
118
|
+
ingest(measured);
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
// The gateway already answered. A reply this cannot read is a metering failure, not the
|
|
122
|
+
// caller's - turning it into a rejection would fail a request that worked. A parse error
|
|
123
|
+
// quotes the body it choked on, so it is replaced rather than handed to the logger.
|
|
124
|
+
session.failedToMeasure(context, err instanceof SyntaxError
|
|
125
|
+
? new Error('The gateway’s reply was not JSON, so its usage could not be read.')
|
|
126
|
+
: err);
|
|
127
|
+
}
|
|
128
|
+
return response;
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/** The route a request takes, or undefined for one this binding leaves alone. */
|
|
132
|
+
function routeOf(input, init, baseUrl, routes) {
|
|
133
|
+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
134
|
+
if (!(0, base_url_1.withinBaseUrl)(baseUrl, url))
|
|
135
|
+
return undefined;
|
|
136
|
+
const method = (init?.method ?? (typeof input === 'object' && 'method' in input ? input.method : 'GET')).toUpperCase();
|
|
137
|
+
if (method !== 'POST')
|
|
138
|
+
return undefined;
|
|
139
|
+
// Read from the base URL down, so `/messages` is the gateway's own route and not the tail of
|
|
140
|
+
// `/v1/threads/{id}/messages` (OpenAI Assistants), and `/messages/count_tokens` - free, and not
|
|
141
|
+
// generation - is not one either. An optional `/v1`, for a base URL given without it.
|
|
142
|
+
const path = (0, base_url_1.pathOf)(url).slice((0, base_url_1.pathOf)(baseUrl).length);
|
|
143
|
+
return routes.find((route) => path === route.suffix || path === `/v1${route.suffix}`);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The request's JSON, which is what a recipe's `estimate` and `passthrough` read - the same object
|
|
147
|
+
* an SDK method would have been handed. An empty object for a body that is not JSON text (a stream,
|
|
148
|
+
* form data, text that does not parse), so an estimate like `(p) => p.max_tokens` falls back to
|
|
149
|
+
* its default instead of throwing - and failing a request before it is sent.
|
|
150
|
+
*/
|
|
151
|
+
async function paramsOf(input, init) {
|
|
152
|
+
let text;
|
|
153
|
+
const body = init?.body;
|
|
154
|
+
if (typeof body === 'string')
|
|
155
|
+
text = body;
|
|
156
|
+
else if (body instanceof ArrayBuffer || ArrayBuffer.isView(body))
|
|
157
|
+
text = new TextDecoder().decode(body);
|
|
158
|
+
// A Blob is immutable, so reading it here leaves the body `fetch` sends untouched.
|
|
159
|
+
else if (body instanceof Blob)
|
|
160
|
+
text = await body.text();
|
|
161
|
+
else if (body == null && input instanceof Request)
|
|
162
|
+
text = await input.clone().text();
|
|
163
|
+
if (!text)
|
|
164
|
+
return {};
|
|
165
|
+
try {
|
|
166
|
+
const parsed = JSON.parse(text);
|
|
167
|
+
return typeof parsed === 'object' && parsed !== null ? parsed : {};
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return {};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const isEventStream = (response) =>
|
|
174
|
+
// Media types are case-insensitive, and a JSON parse of a live stream would wait for it to end.
|
|
175
|
+
(response.headers.get('content-type') ?? '').toLowerCase().includes('text/event-stream') && response.body !== null;
|
|
176
|
+
/**
|
|
177
|
+
* The reply, with its body observed in place.
|
|
178
|
+
*
|
|
179
|
+
* The recipe's stream reader is written against an async iterable of parsed events - what an SDK
|
|
180
|
+
* stream yields - so this parses the SSE as the caller reads it and feeds a channel the reader is
|
|
181
|
+
* attached to. Every recipe's existing reader works unchanged: Anthropic accumulating across two
|
|
182
|
+
* event types, Chat Completions reading the last chunk, Responses its completion event.
|
|
183
|
+
*/
|
|
184
|
+
function meteredStream(response, method, ingest, failed, seen) {
|
|
185
|
+
const events = channel();
|
|
186
|
+
if (!method.attachStream)
|
|
187
|
+
return response;
|
|
188
|
+
method.attachStream(events, ingest, response);
|
|
189
|
+
// Drives the iterator the recipe patched, which is what runs its accounting. Started by the
|
|
190
|
+
// first event rather than up front, because the recipe reads a started stream that closes as a
|
|
191
|
+
// zero - and a body cancelled before anything was read measured nothing, not nothing used.
|
|
192
|
+
let consumed;
|
|
193
|
+
const consume = () => (consumed ??= (async () => {
|
|
194
|
+
try {
|
|
195
|
+
for await (const _event of events)
|
|
196
|
+
void _event;
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
failed(err);
|
|
200
|
+
}
|
|
201
|
+
})());
|
|
202
|
+
const body = observeSse(response.body, (event) => {
|
|
203
|
+
seen(event);
|
|
204
|
+
events.push(event);
|
|
205
|
+
void consume();
|
|
206
|
+
}, () => {
|
|
207
|
+
events.close();
|
|
208
|
+
return consumed;
|
|
209
|
+
});
|
|
210
|
+
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
211
|
+
}
|
|
212
|
+
/** A one-reader queue: pushed to as the body is read, drained by the recipe's stream reader. */
|
|
213
|
+
function channel() {
|
|
214
|
+
const queue = [];
|
|
215
|
+
let wake;
|
|
216
|
+
let closed = false;
|
|
217
|
+
return {
|
|
218
|
+
push: (event) => {
|
|
219
|
+
queue.push(event);
|
|
220
|
+
wake?.();
|
|
221
|
+
},
|
|
222
|
+
close: () => {
|
|
223
|
+
closed = true;
|
|
224
|
+
wake?.();
|
|
225
|
+
},
|
|
226
|
+
async *[Symbol.asyncIterator]() {
|
|
227
|
+
while (true) {
|
|
228
|
+
if (queue.length) {
|
|
229
|
+
yield queue.shift();
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (closed)
|
|
233
|
+
return;
|
|
234
|
+
await new Promise((resolve) => (wake = resolve));
|
|
235
|
+
wake = undefined;
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Passes the bytes through unchanged and hands each complete SSE event's `data` to `onEvent`,
|
|
242
|
+
* parsed. `[DONE]` and anything that is not JSON are skipped - they carry no usage. `onEnd` fires
|
|
243
|
+
* once, however the body ends: run out, errored, or cancelled by the caller - and what it returns
|
|
244
|
+
* is awaited before the caller sees that end, so the final ingest is pending, and `flush()` waits
|
|
245
|
+
* for it, by the time `response.text()` resolves or rejects, or `cancel()` returns.
|
|
246
|
+
*/
|
|
247
|
+
function observeSse(source, onEvent, onEnd) {
|
|
248
|
+
const decoder = new TextDecoder();
|
|
249
|
+
const reader = source.getReader();
|
|
250
|
+
let buffer = '';
|
|
251
|
+
let ended = false;
|
|
252
|
+
const end = () => {
|
|
253
|
+
if (ended)
|
|
254
|
+
return undefined;
|
|
255
|
+
ended = true;
|
|
256
|
+
return onEnd();
|
|
257
|
+
};
|
|
258
|
+
const emit = (block) => {
|
|
259
|
+
const data = block
|
|
260
|
+
.split('\n')
|
|
261
|
+
.filter((line) => line.startsWith('data:'))
|
|
262
|
+
.map((line) => line.slice(5).replace(/^ /, ''))
|
|
263
|
+
.join('\n');
|
|
264
|
+
if (!data || data === '[DONE]')
|
|
265
|
+
return;
|
|
266
|
+
try {
|
|
267
|
+
onEvent(JSON.parse(data));
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// Not an event this binding can read; the caller still gets the bytes.
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
const drain = (final) => {
|
|
274
|
+
// A `\r` at the very end may be the first half of a `\r\n` split across two reads, and
|
|
275
|
+
// normalising it early would turn that one line break into an event boundary.
|
|
276
|
+
buffer = buffer.replace(final ? /\r\n?/g : /\r\n|\r(?=[\s\S])/g, '\n');
|
|
277
|
+
let at;
|
|
278
|
+
while ((at = buffer.indexOf('\n\n')) !== -1) {
|
|
279
|
+
emit(buffer.slice(0, at));
|
|
280
|
+
buffer = buffer.slice(at + 2);
|
|
281
|
+
}
|
|
282
|
+
if (final && buffer.trim()) {
|
|
283
|
+
emit(buffer);
|
|
284
|
+
buffer = '';
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
// A high-water mark of zero, so the source is read only when the caller asks: nothing is metered
|
|
288
|
+
// ahead of what the caller has, and a cancel stops at what was handed over.
|
|
289
|
+
return new ReadableStream({
|
|
290
|
+
async pull(controller) {
|
|
291
|
+
try {
|
|
292
|
+
const { value, done } = await reader.read();
|
|
293
|
+
if (done) {
|
|
294
|
+
buffer += decoder.decode();
|
|
295
|
+
drain(true);
|
|
296
|
+
await end();
|
|
297
|
+
controller.close();
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
buffer += decoder.decode(value, { stream: true });
|
|
301
|
+
drain(false);
|
|
302
|
+
controller.enqueue(value);
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
// Awaited, as on a body that ran out, so what was read is pending before the caller hears.
|
|
306
|
+
await end();
|
|
307
|
+
controller.error(err);
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
async cancel(reason) {
|
|
311
|
+
const ended = end();
|
|
312
|
+
await reader.cancel(reason);
|
|
313
|
+
await ended;
|
|
314
|
+
},
|
|
315
|
+
}, { highWaterMark: 0 });
|
|
316
|
+
}
|
|
@@ -155,6 +155,14 @@ export declare const GATEWAY_METRICS: readonly ["prompt_tokens", "completion_tok
|
|
|
155
155
|
* asynchronously.
|
|
156
156
|
*/
|
|
157
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
|
+
/**
|
|
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.
|
|
164
|
+
*/
|
|
165
|
+
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"];
|
|
158
166
|
/**
|
|
159
167
|
* Bedrock's five, under its own names and with no price among them.
|
|
160
168
|
*
|
|
@@ -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.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.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;
|
|
4
4
|
exports.fromFlatTokenUsage = fromFlatTokenUsage;
|
|
5
5
|
exports.fromOpenAIUsage = fromOpenAIUsage;
|
|
6
6
|
exports.fromResponsesUsage = fromResponsesUsage;
|
|
@@ -425,6 +425,18 @@ exports.CLOUDFLARE_METRICS = [
|
|
|
425
425
|
'cache_read_input_tokens',
|
|
426
426
|
'cache_creation_input_tokens',
|
|
427
427
|
];
|
|
428
|
+
/**
|
|
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.
|
|
434
|
+
*/
|
|
435
|
+
exports.AGENTGATEWAY_METRICS = [
|
|
436
|
+
...exports.OPENAI_METRICS,
|
|
437
|
+
'cache_read_input_tokens',
|
|
438
|
+
'cache_creation_input_tokens',
|
|
439
|
+
];
|
|
428
440
|
/**
|
|
429
441
|
* Bedrock's five, under its own names and with no price among them.
|
|
430
442
|
*
|
package/dist/engine/engine.d.ts
CHANGED
|
@@ -122,9 +122,14 @@ export type EntityPlacement = {
|
|
|
122
122
|
/**
|
|
123
123
|
* Set the budget only when the assignment does not exist yet. An existing one keeps its limit
|
|
124
124
|
* and cadence, so a raise made in the dashboard survives the next run. Mutually exclusive with
|
|
125
|
-
* `usageLimit` - it costs one extra read, which is why
|
|
125
|
+
* `usageLimit` - it costs one extra read, which is why a number is never seeded unless asked for.
|
|
126
|
+
*
|
|
127
|
+
* `null` seeds an unlimited assignment - the metering-only one, which puts the agent in the tree
|
|
128
|
+
* and tracks its usage without its own assignment refusing it. It is what a budget with no limit
|
|
129
|
+
* sends, so that case pays the read on every call. Unlike a numeric seed it still applies the
|
|
130
|
+
* caller's `cadence` to an existing assignment, as a budget with no limit always has.
|
|
126
131
|
*/
|
|
127
|
-
usageLimitOnCreate?: number;
|
|
132
|
+
usageLimitOnCreate?: number | null;
|
|
128
133
|
cadence?: string;
|
|
129
134
|
/** The entities the budget is scoped to - its own assignment beside the agent's whole one. */
|
|
130
135
|
scopeEntityIds?: string[];
|
|
@@ -16,6 +16,8 @@ const errors_1 = require("../errors");
|
|
|
16
16
|
const types_1 = require("../types");
|
|
17
17
|
const sdk_identity_1 = require("../sdk-identity");
|
|
18
18
|
const report_1 = require("./report");
|
|
19
|
+
/** The window an unlimited assignment is created with when the caller named none. */
|
|
20
|
+
const TRACKING_CADENCE = 'P1M';
|
|
19
21
|
/** The governance check endpoint is `/api/v1-beta/` and flagged experimental. */
|
|
20
22
|
class RestGovernanceEngine {
|
|
21
23
|
/**
|
|
@@ -116,11 +118,16 @@ class RestGovernanceEngine {
|
|
|
116
118
|
const scope = scopeOf(placement.scopeEntityIds);
|
|
117
119
|
// Every page: the one with this scope can sit past the first, and missing it would overwrite
|
|
118
120
|
// an admin's limit with the seed.
|
|
121
|
+
const tracking = placement.usageLimitOnCreate === null;
|
|
119
122
|
for await (const row of this.client.v1Beta.customers.assignments.list(customerId, { entityId, ...capability })) {
|
|
120
|
-
// Already assigned: leave the limit and its window exactly as they are
|
|
123
|
+
// Already assigned: leave the limit and its window exactly as they are - except that a
|
|
124
|
+
// metering-only call still moves the window it named, as a budget with no limit always did.
|
|
121
125
|
if (scopeOf(row.scopeEntityIds) === scope)
|
|
122
|
-
return {};
|
|
126
|
+
return tracking ? cadence : {};
|
|
123
127
|
}
|
|
128
|
+
// Governance requires a window on create, and a metering-only caller has no reason to pick one.
|
|
129
|
+
if (tracking)
|
|
130
|
+
return { usageLimit: null, cadence: placement.cadence ?? TRACKING_CADENCE };
|
|
124
131
|
return { usageLimit: placement.usageLimitOnCreate, ...cadence };
|
|
125
132
|
}
|
|
126
133
|
async check(customerId, { entitlement, requestedUsage, dimensions }) {
|
package/dist/index.d.ts
CHANGED
|
@@ -57,6 +57,10 @@ export type { BedrockRecipeOptions } from './bindings/client/gateways/bedrock/ty
|
|
|
57
57
|
export { cloudflareRecipe } from './bindings/client/gateways/cloudflare/cloudflare-recipe';
|
|
58
58
|
export type { CloudflareMetricName, CloudflareMetrics } from './bindings/client/gateways/cloudflare/cloudflare-recipe';
|
|
59
59
|
export type { CloudflareRecipeOptions } from './bindings/client/gateways/cloudflare/types';
|
|
60
|
+
export { agentgatewayRecipe } from './bindings/client/gateways/agentgateway/agentgateway-recipe';
|
|
61
|
+
export type { AgentgatewayMetricName, AgentgatewayMetrics, } from './bindings/client/gateways/agentgateway/agentgateway-recipe';
|
|
62
|
+
export type { AgentgatewayRecipeOptions } from './bindings/client/gateways/agentgateway/types';
|
|
63
|
+
export type { FetchLike, FetchOptions, GovernedFetch, GovernedRequestInit } from './bindings/fetch/types';
|
|
60
64
|
export { typesafeRecipe, estimateFromState } from './bindings/client/providers/typesafe/typesafe-recipe';
|
|
61
65
|
export type { TypeSafeMetric, TypeSafeMetrics } from './bindings/client/providers/typesafe/typesafe-recipe';
|
|
62
66
|
export { weightedMetrics } from './bindings/shared/weighted-metrics';
|
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.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.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
|
*
|
|
@@ -118,6 +118,14 @@ Object.defineProperty(exports, "bedrockRecipe", { enumerable: true, get: functio
|
|
|
118
118
|
// limitation.
|
|
119
119
|
var cloudflare_recipe_1 = require("./bindings/client/gateways/cloudflare/cloudflare-recipe");
|
|
120
120
|
Object.defineProperty(exports, "cloudflareRecipe", { enumerable: true, get: function () { return cloudflare_recipe_1.cloudflareRecipe; } });
|
|
121
|
+
// agentgateway - the open-source AI gateway (Solo.io's enterprise build and Gloo AI Gateway sit on
|
|
122
|
+
// it). A default, ahead of
|
|
123
|
+
// both wires it serves - OpenAI chat and responses, and Anthropic Messages - so its traffic reports
|
|
124
|
+
// as `agentgateway.*` rather than as a direct call. Self-hosted, so give it your gateway's base URL at
|
|
125
|
+
// `wrap(client, { recipe: agentgatewayRecipe({ baseUrl }) })`; it reports tokens and the model that served,
|
|
126
|
+
// and no `cost`, because the gateway states no price on any response.
|
|
127
|
+
var agentgateway_recipe_1 = require("./bindings/client/gateways/agentgateway/agentgateway-recipe");
|
|
128
|
+
Object.defineProperty(exports, "agentgatewayRecipe", { enumerable: true, get: function () { return agentgateway_recipe_1.agentgatewayRecipe; } });
|
|
121
129
|
var typesafe_recipe_1 = require("./bindings/client/providers/typesafe/typesafe-recipe");
|
|
122
130
|
Object.defineProperty(exports, "typesafeRecipe", { enumerable: true, get: function () { return typesafe_recipe_1.typesafeRecipe; } });
|
|
123
131
|
Object.defineProperty(exports, "estimateFromState", { enumerable: true, get: function () { return typesafe_recipe_1.estimateFromState; } });
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ResolvedConfig, type StiggAgentSDKOptions } from './config';
|
|
2
2
|
import { type EngineResolution, type GovernanceEngine } from './engine/engine';
|
|
3
|
+
import type { FetchOptions, GovernedFetch } from './bindings/fetch/types';
|
|
3
4
|
import type { StiggContext } from './core/context';
|
|
4
5
|
import type { SessionSummary } from './core/session';
|
|
5
6
|
import type { Metrics, GovernedAgentMiddleware, MiddlewareOptions, ClientOptions, DeregisterInput, AccessDecision, WrappedClient, PathsOf, IngestContext, IngestResult, RegisterInput } from './types';
|
|
@@ -250,6 +251,28 @@ export declare class StiggAgentSDK {
|
|
|
250
251
|
* see {@link GovernanceSession.warmUp} for which cache that is and when there is one at all.
|
|
251
252
|
*/
|
|
252
253
|
wrap<T extends object, O extends ClientOptions>(client: T, options?: O): WrappedClient<T, O['stiggContext'], PathsOf<O['recipe'], T>>;
|
|
254
|
+
/**
|
|
255
|
+
* Governs plain HTTP calls to a gateway, for an application that posts JSON with `fetch` instead
|
|
256
|
+
* of holding an SDK client - the same gate and meter `wrap` gives a client, keyed on the request
|
|
257
|
+
* path rather than on a method.
|
|
258
|
+
*
|
|
259
|
+
* ```ts
|
|
260
|
+
* const gatewayFetch = stigg.wrapFetch({
|
|
261
|
+
* baseUrl: 'http://agentgateway.internal:8080',
|
|
262
|
+
* recipe: agentgatewayRecipe(),
|
|
263
|
+
* stiggContext: { customerId, entitlement: { currencyId: 'credits' } },
|
|
264
|
+
* });
|
|
265
|
+
* await gatewayFetch('http://agentgateway.internal:8080/v1/chat/completions', {
|
|
266
|
+
* method: 'POST',
|
|
267
|
+
* body: JSON.stringify({ model: 'gpt-5-mini', messages }),
|
|
268
|
+
* stiggContext: { agentId: 'support-bot' },
|
|
269
|
+
* });
|
|
270
|
+
* ```
|
|
271
|
+
*
|
|
272
|
+
* The recipe is named rather than detected, because a URL alone cannot say who bills. A request
|
|
273
|
+
* outside `baseUrl`, or to a route the recipe does not declare, goes through untouched.
|
|
274
|
+
*/
|
|
275
|
+
wrapFetch(options: FetchOptions): GovernedFetch;
|
|
253
276
|
/**
|
|
254
277
|
* An agent graph's governance middleware - the orchestration counterpart to `wrap`.
|
|
255
278
|
*
|
package/dist/stigg-agent-sdk.js
CHANGED
|
@@ -27,6 +27,7 @@ const gemini_recipe_1 = require("./bindings/client/providers/gemini/gemini-recip
|
|
|
27
27
|
const litellm_recipe_1 = require("./bindings/client/gateways/litellm/litellm-recipe");
|
|
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
|
+
const agentgateway_recipe_1 = require("./bindings/client/gateways/agentgateway/agentgateway-recipe");
|
|
30
31
|
const groq_recipe_1 = require("./bindings/client/providers/groq/groq-recipe");
|
|
31
32
|
const mistral_recipe_1 = require("./bindings/client/providers/mistral/mistral-recipe");
|
|
32
33
|
const ollama_recipe_1 = require("./bindings/client/providers/ollama/ollama-recipe");
|
|
@@ -37,6 +38,7 @@ const typesafe_recipe_1 = require("./bindings/client/providers/typesafe/typesafe
|
|
|
37
38
|
const registry_1 = require("./bindings/client/core/registry");
|
|
38
39
|
const wrap_client_1 = require("./bindings/client/core/wrap-client");
|
|
39
40
|
const agent_middleware_1 = require("./bindings/orchestrator/core/agent-middleware");
|
|
41
|
+
const wrap_fetch_1 = require("./bindings/fetch/wrap-fetch");
|
|
40
42
|
const token_usage_1 = require("./bindings/shared/token-usage");
|
|
41
43
|
const context_1 = require("./core/context");
|
|
42
44
|
const session_1 = require("./core/session");
|
|
@@ -65,6 +67,10 @@ exports.DEFAULT_RECIPES = [
|
|
|
65
67
|
// claim that client and meter gateway traffic as direct Anthropic traffic. The same argument puts
|
|
66
68
|
// it ahead of `openAIRecipe` for its OpenAI-shaped routes and for Workers AI's compatible one.
|
|
67
69
|
(0, cloudflare_recipe_1.cloudflareRecipe)(),
|
|
70
|
+
// Ahead of `anthropicRecipe` for the same reason as Cloudflare: agentgateway answers on the
|
|
71
|
+
// Anthropic Messages wire too, and only the base URL tells its `Anthropic` client from a direct
|
|
72
|
+
// one. Without a configured base URL it matches on a hostname label - see the recipe.
|
|
73
|
+
(0, agentgateway_recipe_1.agentgatewayRecipe)(),
|
|
68
74
|
anthropic_recipe_1.anthropicRecipe,
|
|
69
75
|
(0, openrouter_recipe_1.openrouterRecipe)(),
|
|
70
76
|
(0, litellm_recipe_1.litellmRecipe)(),
|
|
@@ -152,7 +158,8 @@ function toPlacement(input) {
|
|
|
152
158
|
if (onCreate && typeof input.budget.limit !== 'number') {
|
|
153
159
|
throw new Error(`Agent ${input.agentId} asked to seed a budget with no limit to seed. ` +
|
|
154
160
|
"`budget.apply: 'on-create'` writes a cap the first time and never again, so there is " +
|
|
155
|
-
'nothing to write without `budget.limit`.'
|
|
161
|
+
'nothing to write without `budget.limit`. To seed an unlimited, metering-only budget, ' +
|
|
162
|
+
'leave out both `limit` and `apply`.');
|
|
156
163
|
}
|
|
157
164
|
// Each entry names an entity, and a blank one names none: the budget would be scoped to an
|
|
158
165
|
// entity no request resolves, and apply to nothing.
|
|
@@ -160,12 +167,15 @@ function toPlacement(input) {
|
|
|
160
167
|
throw new Error(`Agent ${input.agentId} has a budget.scope that is not a list of entity ids: ${JSON.stringify(scope)}. ` +
|
|
161
168
|
'Leave it out, or pass [], for the whole budget.');
|
|
162
169
|
}
|
|
170
|
+
// No limit at all is metering only: an agent with no assignment is in nobody's tree and its usage
|
|
171
|
+
// is counted against nothing, so it gets an unlimited one - seeded, so a cap set later survives.
|
|
172
|
+
const tracking = !onCreate && input.budget.limit === undefined;
|
|
163
173
|
return {
|
|
164
174
|
parentId,
|
|
165
175
|
entitlement,
|
|
166
176
|
cadence,
|
|
167
177
|
usageLimit: onCreate ? undefined : input.budget.limit,
|
|
168
|
-
usageLimitOnCreate: onCreate ? (input.budget.limit ?? undefined) : undefined,
|
|
178
|
+
usageLimitOnCreate: tracking ? null : onCreate ? (input.budget.limit ?? undefined) : undefined,
|
|
169
179
|
scopeEntityIds: scope?.length ? scope : undefined,
|
|
170
180
|
};
|
|
171
181
|
}
|
|
@@ -426,6 +436,49 @@ class StiggAgentSDK {
|
|
|
426
436
|
void session.warmUp(context);
|
|
427
437
|
return governed;
|
|
428
438
|
}
|
|
439
|
+
/**
|
|
440
|
+
* Governs plain HTTP calls to a gateway, for an application that posts JSON with `fetch` instead
|
|
441
|
+
* of holding an SDK client - the same gate and meter `wrap` gives a client, keyed on the request
|
|
442
|
+
* path rather than on a method.
|
|
443
|
+
*
|
|
444
|
+
* ```ts
|
|
445
|
+
* const gatewayFetch = stigg.wrapFetch({
|
|
446
|
+
* baseUrl: 'http://agentgateway.internal:8080',
|
|
447
|
+
* recipe: agentgatewayRecipe(),
|
|
448
|
+
* stiggContext: { customerId, entitlement: { currencyId: 'credits' } },
|
|
449
|
+
* });
|
|
450
|
+
* await gatewayFetch('http://agentgateway.internal:8080/v1/chat/completions', {
|
|
451
|
+
* method: 'POST',
|
|
452
|
+
* body: JSON.stringify({ model: 'gpt-5-mini', messages }),
|
|
453
|
+
* stiggContext: { agentId: 'support-bot' },
|
|
454
|
+
* });
|
|
455
|
+
* ```
|
|
456
|
+
*
|
|
457
|
+
* The recipe is named rather than detected, because a URL alone cannot say who bills. A request
|
|
458
|
+
* outside `baseUrl`, or to a route the recipe does not declare, goes through untouched.
|
|
459
|
+
*/
|
|
460
|
+
wrapFetch(options) {
|
|
461
|
+
void this.ensureStarted().catch((err) => this.reportStartupFailure(err));
|
|
462
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
463
|
+
if (typeof fetchFn !== 'function') {
|
|
464
|
+
throw new Error('No `fetch` to govern: pass `fetch` in the options, or run on Node 22 or later.');
|
|
465
|
+
}
|
|
466
|
+
const session = this.newSession(options.hooks);
|
|
467
|
+
const context = (0, context_1.resolveContext)(options.stiggContext ?? {}, (0, node_crypto_1.randomUUID)(), {
|
|
468
|
+
requireCustomer: false,
|
|
469
|
+
defaults: this.contextDefaults,
|
|
470
|
+
});
|
|
471
|
+
if (context.customerId && !(0, context_1.isEnforced)(context)) {
|
|
472
|
+
this.config.logger.warn('[stigg] this fetch named no entitlement, so nothing is checked and no request can be ' +
|
|
473
|
+
"refused - it meters only. Pass `entitlement: { currencyId: 'credits' }` in " +
|
|
474
|
+
'`stiggContext`, at the wrap or on a request, to gate it.');
|
|
475
|
+
}
|
|
476
|
+
const governed = (0, wrap_fetch_1.wrapFetch)(fetchFn, options.recipe, options.baseUrl, session, context);
|
|
477
|
+
this.attachGovernance(governed, session, context);
|
|
478
|
+
if (context.customerId)
|
|
479
|
+
void session.warmUp(context);
|
|
480
|
+
return governed;
|
|
481
|
+
}
|
|
429
482
|
/**
|
|
430
483
|
* An agent graph's governance middleware - the orchestration counterpart to `wrap`.
|
|
431
484
|
*
|
package/dist/types.d.ts
CHANGED
|
@@ -509,10 +509,21 @@ export type AgentBudget = {
|
|
|
509
509
|
* A scoped budget governs only the requests it names: one naming no model, or a model with no
|
|
510
510
|
* budget, is limited by the agent's whole budget and its ancestors' alone - with neither, by
|
|
511
511
|
* nothing. Register the whole budget too.
|
|
512
|
+
*
|
|
513
|
+
* A scope with no `limit` meters that scope only: it is created unlimited, like a whole budget
|
|
514
|
+
* with no limit, so it shows that scope's usage and refuses nothing.
|
|
512
515
|
*/
|
|
513
516
|
scope?: string[];
|
|
514
517
|
} & ({
|
|
515
|
-
/**
|
|
518
|
+
/**
|
|
519
|
+
* The limit, written on every call. `null` clears whatever is assigned.
|
|
520
|
+
*
|
|
521
|
+
* Omitted, the agent is metering only: the first call creates an unlimited assignment, so its
|
|
522
|
+
* usage is tracked in the governance tree and its own assignment never refuses it - its
|
|
523
|
+
* ancestors' budgets and the customer's entitlement still can. Later calls leave the limit
|
|
524
|
+
* assigned by then - a cap set in the dashboard included - untouched, though a `cadence` they
|
|
525
|
+
* pass is still applied. `cadence` defaults to `'P1M'` on that create.
|
|
526
|
+
*/
|
|
516
527
|
limit?: number | null;
|
|
517
528
|
/** Writes the limit every time. The default. */
|
|
518
529
|
apply?: 'always';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stigg/agent-sdk-js",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.7",
|
|
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",
|
|
@@ -49,6 +49,8 @@
|
|
|
49
49
|
"example-client-ollama": "tsx examples/client/ollama.ts",
|
|
50
50
|
"example-client-openrouter": "tsx examples/client/openrouter.ts",
|
|
51
51
|
"example-client-litellm": "tsx examples/client/litellm.ts",
|
|
52
|
+
"example-client-agentgateway": "tsx examples/client/agentgateway.ts",
|
|
53
|
+
"example-client-solo": "tsx examples/client/solo.ts",
|
|
52
54
|
"example-client-databricks": "tsx examples/client/databricks.ts",
|
|
53
55
|
"example-client-typesafe": "tsx examples/client/typesafe.ts",
|
|
54
56
|
"example-two-agents": "tsx examples/client/two-agents.ts",
|
|
@@ -61,6 +63,7 @@
|
|
|
61
63
|
"example-gateway": "tsx examples/gateway/openrouter.ts",
|
|
62
64
|
"example-gateway-openrouter": "tsx examples/gateway/openrouter.ts",
|
|
63
65
|
"example-gateway-litellm": "tsx examples/gateway/litellm.ts",
|
|
66
|
+
"example-gateway-agentgateway": "tsx examples/gateway/agentgateway.ts",
|
|
64
67
|
"example-gateway-cloudflare": "tsx examples/gateway/cloudflare.ts",
|
|
65
68
|
"example-gateway-bedrock": "tsx examples/gateway/bedrock.ts",
|
|
66
69
|
"example-gateway-databricks": "tsx examples/gateway/databricks.ts"
|