@stigg/agent-sdk-js 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1147 -2
- package/dist/bindings/client/core/registry.d.ts +12 -0
- package/dist/bindings/client/core/registry.js +22 -0
- package/dist/bindings/client/core/types.d.ts +117 -0
- package/dist/bindings/client/core/types.js +13 -0
- package/dist/bindings/client/core/wrap-client.d.ts +6 -0
- package/dist/bindings/client/core/wrap-client.js +258 -0
- package/dist/bindings/client/gateways/litellm/litellm-recipe.d.ts +17 -0
- package/dist/bindings/client/gateways/litellm/litellm-recipe.js +171 -0
- package/dist/bindings/client/gateways/litellm/types.d.ts +30 -0
- package/dist/bindings/client/gateways/litellm/types.js +2 -0
- package/dist/bindings/client/gateways/openrouter/openrouter-recipe.d.ts +15 -0
- package/dist/bindings/client/gateways/openrouter/openrouter-recipe.js +141 -0
- package/dist/bindings/client/gateways/openrouter/types.d.ts +29 -0
- package/dist/bindings/client/gateways/openrouter/types.js +2 -0
- package/dist/bindings/client/providers/anthropic/anthropic-recipe.d.ts +34 -0
- package/dist/bindings/client/providers/anthropic/anthropic-recipe.js +91 -0
- package/dist/bindings/client/providers/gemini/gemini-recipe.d.ts +54 -0
- package/dist/bindings/client/providers/gemini/gemini-recipe.js +243 -0
- package/dist/bindings/client/providers/openai/openai-recipe.d.ts +43 -0
- package/dist/bindings/client/providers/openai/openai-recipe.js +80 -0
- package/dist/bindings/client/providers/typesafe/typesafe-recipe.d.ts +33 -0
- package/dist/bindings/client/providers/typesafe/typesafe-recipe.js +73 -0
- package/dist/bindings/proxy/headers.d.ts +6 -0
- package/dist/bindings/proxy/headers.js +12 -0
- package/dist/bindings/proxy/index.d.ts +21 -0
- package/dist/bindings/proxy/index.js +18 -0
- package/dist/bindings/proxy/types.d.ts +8 -0
- package/dist/bindings/proxy/types.js +2 -0
- package/dist/bindings/shared/estimate.d.ts +27 -0
- package/dist/bindings/shared/estimate.js +38 -0
- package/dist/bindings/shared/meter-stream.d.ts +18 -0
- package/dist/bindings/shared/meter-stream.js +151 -0
- package/dist/bindings/shared/token-usage.d.ts +148 -0
- package/dist/bindings/shared/token-usage.js +414 -0
- package/dist/bindings/shared/types.d.ts +79 -0
- package/dist/bindings/shared/types.js +2 -0
- package/dist/bindings/shared/weighted-metrics.d.ts +37 -0
- package/dist/bindings/shared/weighted-metrics.js +44 -0
- package/dist/config.d.ts +136 -0
- package/dist/config.js +97 -0
- package/dist/core/context.d.ts +151 -0
- package/dist/core/context.js +154 -0
- package/dist/core/dimensions.d.ts +22 -0
- package/dist/core/dimensions.js +47 -0
- package/dist/core/hooks.d.ts +21 -0
- package/dist/core/hooks.js +47 -0
- package/dist/core/session.d.ts +161 -0
- package/dist/core/session.js +403 -0
- package/dist/decision-text.d.ts +15 -0
- package/dist/decision-text.js +54 -0
- package/dist/engine/composite-engine.d.ts +72 -0
- package/dist/engine/composite-engine.js +135 -0
- package/dist/engine/create-engine.d.ts +16 -0
- package/dist/engine/create-engine.js +93 -0
- package/dist/engine/decision.d.ts +37 -0
- package/dist/engine/decision.js +128 -0
- package/dist/engine/engine.d.ts +166 -0
- package/dist/engine/engine.js +11 -0
- package/dist/engine/node-sdk-engine.d.ts +125 -0
- package/dist/engine/node-sdk-engine.js +262 -0
- package/dist/engine/report.d.ts +106 -0
- package/dist/engine/report.js +185 -0
- package/dist/engine/rest-governance-engine.d.ts +47 -0
- package/dist/engine/rest-governance-engine.js +220 -0
- package/dist/errors.d.ts +78 -0
- package/dist/errors.js +151 -0
- package/dist/headers.d.ts +27 -0
- package/dist/headers.js +63 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +85 -0
- package/dist/internal.d.ts +33 -0
- package/dist/internal.js +81 -0
- package/dist/logger.d.ts +15 -0
- package/dist/logger.js +20 -0
- package/dist/sdk-identity.d.ts +36 -0
- package/dist/sdk-identity.js +43 -0
- package/dist/stigg-agent-sdk.d.ts +242 -0
- package/dist/stigg-agent-sdk.js +594 -0
- package/dist/types.d.ts +531 -0
- package/dist/types.js +97 -0
- package/package.json +80 -3
package/README.md
CHANGED
|
@@ -1,3 +1,1148 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@stigg/agent-sdk-js` — agent governance for the agent stack (TypeScript)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A thin SDK that gates and meters agent runs against [Stigg](https://stigg.io), plus runnable
|
|
4
|
+
examples for the two layers it binds to: a client you wrap, and a gateway you call or operate.
|
|
5
|
+
The examples are not part of the published package - they live beside the source in the Stigg
|
|
6
|
+
SDK monorepo, and the paths below are relative to it.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install @stigg/agent-sdk-js # or: yarn add @stigg/agent-sdk-js
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Requires Node 22 or later. Ships CommonJS with type declarations, and a `./internal` subpath for
|
|
13
|
+
the engine seam.
|
|
14
|
+
|
|
15
|
+
Everything here runs **with no API keys and no Stigg account**: a built-in mock stands in for
|
|
16
|
+
both the LLM vendors and the Stigg API.
|
|
17
|
+
|
|
18
|
+
## What the SDK does
|
|
19
|
+
|
|
20
|
+
One core lifecycle, several bindings, one swappable engine.
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
construct → register? → wrap → [ check? → execute → ingest ] → deregister?
|
|
24
|
+
| | |
|
|
25
|
+
per env, optional: the boundary you declare; the client goes through
|
|
26
|
+
one SDK a write Stigg, and check and ingest happen inside it
|
|
27
|
+
about the
|
|
28
|
+
agent
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**There is no run to open.** What governs a call — who it is for, what gates it, what it reports —
|
|
32
|
+
is a `stiggContext`, given once at `wrap` as the default for that client and again inside any
|
|
33
|
+
single call's own parameters to override it. One wrapped client therefore serves every customer
|
|
34
|
+
and every agent, which is what a multi-tenant server actually needs.
|
|
35
|
+
|
|
36
|
+
`check` happens when the context names an `entitlement`. Omit it and the call meters only: the
|
|
37
|
+
request still goes out, and it is still billed.
|
|
38
|
+
|
|
39
|
+
`register` and `deregister` are optional. `register` hands back the agent id it was given — a
|
|
40
|
+
convenience for the next line, not a handle the rest depends on. Skip them and the agent has no
|
|
41
|
+
entity — calls still work, and checks and usage apply to the customer. That is deliberate:
|
|
42
|
+
**wrapping a client must never depend on governance being available**, and a governance-only
|
|
43
|
+
request (a budget, a parent) is the only thing that fails without it.
|
|
44
|
+
|
|
45
|
+
- **The agent is the entity; attribution is a dimension.** An entity upsert is idempotent and
|
|
46
|
+
cheap, but it is still a write — which is why provisioning is `register()` and never something a
|
|
47
|
+
gated call carries. Naming an `agentId` in a context attributes to that agent; it does not
|
|
48
|
+
create it.
|
|
49
|
+
- **A denial is a returned decision, not an exception** — it names which entity blocked and what
|
|
50
|
+
to do about it. Refusing is a commercial event (a top-up, an upgrade), not an incident. That is
|
|
51
|
+
`client.stigg.check()`. A **wrapped** call has nowhere to return one — the vendor's signature
|
|
52
|
+
owns the return type — so there it is raised as `AccessDeniedError`, and `stigg.denial(err)`
|
|
53
|
+
turns it back into the decision. A context that named no `entitlement` never gets there: it has
|
|
54
|
+
nothing to check against, so every call goes out and `check()` hands back an allow whose
|
|
55
|
+
`source` is `'unenforced'`.
|
|
56
|
+
- **Metering never breaks the call.** Ingest failures are swallowed into the `onError` hook;
|
|
57
|
+
enforcement failures are never swallowed. They are deliberately separate code paths.
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { StiggAgentSDK } from '@stigg/agent-sdk-js';
|
|
61
|
+
|
|
62
|
+
// One per environment. The key can be left out entirely to read STIGG_SERVER_API_KEY, or
|
|
63
|
+
// replaced by a `{ baseUrl }` target that needs none - a local stand-in, or a self-hosted Stigg.
|
|
64
|
+
// A key for somewhere other than the hosted API names that URL second:
|
|
65
|
+
// new StiggAgentSDK(apiKey, 'https://stigg.internal')
|
|
66
|
+
// Everything after that is tuning, and every bit of it has a working default.
|
|
67
|
+
const stigg = new StiggAgentSDK(apiKey);
|
|
68
|
+
|
|
69
|
+
// Provisioning the agent: a write, so it is its own call. A *bare* registration is remembered per
|
|
70
|
+
// process and skipped the second time; this one carries a parent and a budget, and a call that
|
|
71
|
+
// changes placement always writes - two differing instructions cannot be collapsed into one.
|
|
72
|
+
await stigg.register({
|
|
73
|
+
customerId: 'customer-acme',
|
|
74
|
+
agentId: 'agent-support-bot',
|
|
75
|
+
entityTypeId: 'agent',
|
|
76
|
+
entitlement: { currencyId: 'credits' },
|
|
77
|
+
parentId: 'team-eng',
|
|
78
|
+
usageLimitOnCreate: 5,
|
|
79
|
+
cadence: 'P1M',
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Governs the client and hands back a COPY whose calls are checked and metered. The client you
|
|
83
|
+
// passed keeps working ungoverned.
|
|
84
|
+
//
|
|
85
|
+
// Wrapped with no customer, this client serves every tenant the process handles, and each call
|
|
86
|
+
// names the one it is for. The types enforce that: `customerId` is **required** on every call
|
|
87
|
+
// through a client wrapped this way.
|
|
88
|
+
const govClient = stigg.wrap(new Anthropic(), {
|
|
89
|
+
stiggContext: {
|
|
90
|
+
// Naming an entitlement is the enforcement switch. Omit it and the calls meter without
|
|
91
|
+
// gating - every call still goes out, and nothing is ever refused.
|
|
92
|
+
entitlement: { currencyId: 'credits' },
|
|
93
|
+
// What the calls are attributed to. The type is required with it - a guessed attribution key
|
|
94
|
+
// resolves no entity, and the spend would land on the customer looking exactly like a call
|
|
95
|
+
// that was never governed. The parent needs no key: governance walks the chain up from here.
|
|
96
|
+
agentId: 'agent-support-bot',
|
|
97
|
+
agentType: { entityTypeId: 'agent', attributionKey: 'agentId' },
|
|
98
|
+
// Optional extra attribution, sent verbatim on every check and ingest.
|
|
99
|
+
// dimensions: { tenantId: 'acme-eu' },
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// The call site is otherwise untouched; the context rides in the vendor's own parameter object
|
|
104
|
+
// and is lifted back out before the vendor sees it.
|
|
105
|
+
await govClient.messages.create({
|
|
106
|
+
model,
|
|
107
|
+
max_tokens: 1024,
|
|
108
|
+
messages,
|
|
109
|
+
stiggContext: { customerId: 'customer-acme' },
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await govClient.messages.create({
|
|
113
|
+
model,
|
|
114
|
+
max_tokens: 1024,
|
|
115
|
+
messages,
|
|
116
|
+
stiggContext: { customerId: 'customer-globex' },
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Drains what is still in flight and reports what this client did.
|
|
120
|
+
await govClient.stigg.flush();
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**Nothing in the setup path touches the network.** The constructor is synchronous and so is
|
|
124
|
+
`wrap` — resolving the engine is a probe that decides which transport serves which operation, and
|
|
125
|
+
both start that work rather than waiting on it. The first call that needs an engine awaits the
|
|
126
|
+
same probe; every call after it finds one already resolved. So there is no awaited setup step to
|
|
127
|
+
forget, and no call that silently runs ungoverned because setup had not finished.
|
|
128
|
+
|
|
129
|
+
The latency moves rather than disappearing: whoever makes the first call pays for the probe. If
|
|
130
|
+
you would rather pay at boot — and have a bad key fail there instead of inside a request —
|
|
131
|
+
`await stigg.init()` forces it early and hands back the resolution.
|
|
132
|
+
|
|
133
|
+
Construct one per environment and share it. A second one for the same environment is wasted
|
|
134
|
+
rather than wrong — and only partly a second client: `Stigg.initialize` keeps one global Node SDK
|
|
135
|
+
instance per process and hands it back when the API key matches, so the entitlement cache and its
|
|
136
|
+
socket are shared while the governance REST client is not. See
|
|
137
|
+
[what the cache promises](#what-the-cache-promises) for what that means for `close()`.
|
|
138
|
+
|
|
139
|
+
**A customer given at `wrap` is a default, not a scope.** One wrapped client can serve every
|
|
140
|
+
tenant, so naming a customer there means every call that does not override it bills *that* one —
|
|
141
|
+
correctly, silently, and forever if the client outlives the request. It is the right shape for a
|
|
142
|
+
single-tenant process and the wrong one for a server, which is why the example above leaves it
|
|
143
|
+
out and the types then require it per call.
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
// Single-tenant process: a default is fine, and calls need no context at all.
|
|
147
|
+
const client = stigg.wrap(new Anthropic(), {
|
|
148
|
+
stiggContext: { customerId: 'customer-acme', entitlement: { currencyId: 'credits' } },
|
|
149
|
+
});
|
|
150
|
+
await client.messages.create({ model, max_tokens: 1024, messages });
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
`wrap()` also starts warming the entitlement cache when the context names both a customer and an
|
|
154
|
+
entitlement, so the calls that follow read local state. That is an optimization and it is not
|
|
155
|
+
awaited: a failure there is logged, never thrown, and the first check falls back to asking the
|
|
156
|
+
server.
|
|
157
|
+
|
|
158
|
+
**`wrap` returns a copy.** The object you built is never mutated and keeps working ungoverned,
|
|
159
|
+
which is what makes the governed one safe to hand around. Wrapping an already-governed client
|
|
160
|
+
throws rather than stacking a second gate on it — there is nothing a second wrap could mean now
|
|
161
|
+
that the context travels with each call.
|
|
162
|
+
|
|
163
|
+
`stigg.check(context)` and `stigg.report(context, metrics)` are the same two operations with no
|
|
164
|
+
client to wrap, which is how a framework binding and the gateway reach them.
|
|
165
|
+
|
|
166
|
+
`wrap()` recognises the vendor from the client's own shape — there is nothing there the SDK
|
|
167
|
+
cannot see for itself — and throws, naming the vendors it knows, rather than handing back a
|
|
168
|
+
client that quietly meters nothing. Pass `recipe` to name an adapter for a vendor it cannot
|
|
169
|
+
identify.
|
|
170
|
+
|
|
171
|
+
**Gemini chats are wrapped in their own right.** `new GoogleGenAI()` builds its chat module over
|
|
172
|
+
the `models` module it holds — `new Chats(this.models, …)` — and every `Chat` keeps that
|
|
173
|
+
reference privately, so a chat created from a wrapped client still calls the original, unpatched
|
|
174
|
+
module. Wrapping the client governs `models.generateContent` and `models.generateContentStream`;
|
|
175
|
+
wrap the chat to govern a conversation:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
const ai = new GoogleGenAI({ apiKey });
|
|
179
|
+
const models = stigg.wrap(ai, { stiggContext }); // one-shot calls
|
|
180
|
+
const chat = stigg.wrap(ai.chats.create({ model }), { stiggContext }); // a conversation
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Nothing is metered twice by doing both, because the chat never reaches the patched module.
|
|
184
|
+
|
|
185
|
+
**Automatic function calling is metered in full only when you stream.** Hand Gemini a callable
|
|
186
|
+
tool and its SDK drives the tool loop for you — up to ten model calls for one
|
|
187
|
+
`generateContent` — and returns only the last response. Streamed, every round's chunks reach
|
|
188
|
+
this SDK and are summed; measured on the wire, 122 + 170 = 292 of 292. Not streamed, the earlier
|
|
189
|
+
rounds are discarded inside the vendor's SDK before it returns, so there is nothing left to read:
|
|
190
|
+
a two-round call billed at 315 reports 181. That turn is still metered for what it can see, is
|
|
191
|
+
tagged `gemini_afc_rounds`, and **raises** through `onError` with the shortfall named — it is
|
|
192
|
+
counted in `summary().ingestFailures` rather than left for you to notice. To meter every round,
|
|
193
|
+
stream the call, or set `automaticFunctionCalling: { disable: true }` and drive the loop
|
|
194
|
+
yourself, which makes each round a call this SDK gates and meters on its own.
|
|
195
|
+
|
|
196
|
+
Decisions describe themselves, so printing one takes no import: `decision.summary` is the log
|
|
197
|
+
line, `decision.message` is what to tell the user. `stigg.denial(err)` turns a caught error back
|
|
198
|
+
into a decision, or `undefined` when it was a real failure.
|
|
199
|
+
|
|
200
|
+
### What a call checks, and what it reports
|
|
201
|
+
|
|
202
|
+
They are different things, and keeping them apart is what lets Stigg do the pricing instead of
|
|
203
|
+
your application.
|
|
204
|
+
|
|
205
|
+
`entitlement` is what a call **checks** — a credit currency or a metered feature. Reporting goes
|
|
206
|
+
somewhere else entirely: to `usageMapper`, one report per metric, named and valued as the vendor
|
|
207
|
+
stated them. Stigg converts a credit-backed feature into the currency on its own side, which is
|
|
208
|
+
what the API was built for and why `usage.report` answers with a credit balance.
|
|
209
|
+
|
|
210
|
+
```
|
|
211
|
+
[governance] ingest input_tokens=12 cache_read_input_tokens=3 output_tokens=46 ... credits=0.94
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
`entitlement` is also **the enforcement switch**. Name one and every call through the wrapped
|
|
215
|
+
client is checked before it leaves. Leave it out and nothing is checked at all: no budget is
|
|
216
|
+
pre-fetched, no call can be refused, and the calls meter exactly as gated ones do. There is no
|
|
217
|
+
separate flag, because a context with nothing to check against cannot be enforced and a flag would
|
|
218
|
+
only be a second place for the two to disagree — so the client says which it is, once, at `wrap`:
|
|
219
|
+
|
|
220
|
+
```
|
|
221
|
+
[stigg] this client named no entitlement, so nothing is checked and no call can be refused - it
|
|
222
|
+
meters only. Pass `entitlement: { currencyId: 'credits' }` in `stiggContext`, at the wrap or on a
|
|
223
|
+
call, to gate it.
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
The default is the vendor's own vocabulary, so **each recipe is where you read what that vendor
|
|
227
|
+
meters** — `anthropicRecipe.metrics` is the list, typed as literals rather than as `string`.
|
|
228
|
+
|
|
229
|
+
#### The events this SDK reports, and the meters to put behind them
|
|
230
|
+
|
|
231
|
+
Reporting defaults to Stigg's event API (see the ingest table further down), and an event is
|
|
232
|
+
matched by **name**. A wrapped client names each call after the call — the name someone reading
|
|
233
|
+
that vendor's API would have guessed — so these are what your meters filter on:
|
|
234
|
+
|
|
235
|
+
| Binding | Event name | Fields a meter can aggregate |
|
|
236
|
+
| ------------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
|
237
|
+
| Anthropic | `anthropic.messages.create` | `input_tokens`, `output_tokens`, `cache_read_input_tokens`, `cache_creation_input_tokens`, `total_tokens` |
|
|
238
|
+
| OpenAI (chat) | `openai.chat.completions.create` | `prompt_tokens`, `completion_tokens`, `cached_tokens`, `total_tokens` |
|
|
239
|
+
| OpenAI (responses) | `openai.responses.create` | `input_tokens`, `output_tokens`, `cached_tokens`, `total_tokens` |
|
|
240
|
+
| OpenRouter | `openrouter.chat.completions.create`, `openrouter.responses.create` | the OpenAI set, plus `cost` |
|
|
241
|
+
| LiteLLM | `litellm.chat.completions.create`, `litellm.responses.create` | the OpenAI set, plus `cost` |
|
|
242
|
+
| TypeSafe | `typesafe.systemOne` | `input_tokens`, `output_tokens`, `total_tokens` |
|
|
243
|
+
| Gemini | `gemini.models.generateContent`, `gemini.models.generateContentStream` | `promptTokenCount`, `candidatesTokenCount`, `cachedContentTokenCount`, `thoughtsTokenCount`, `toolUsePromptTokenCount`, `totalTokenCount` |
|
|
244
|
+
| Gemini — a chat | `gemini.chats.sendMessage`, `gemini.chats.sendMessageStream` | the same five |
|
|
245
|
+
| A gateway, out of process | `<vendor>.proxied` | whatever the proxy declared its vendor to be |
|
|
246
|
+
|
|
247
|
+
Every metric rides the event under its own name, so **one event feeds as many features as you
|
|
248
|
+
configure meters for** — that is why a turn costs one request however many counters it reported.
|
|
249
|
+
The vendor's own usage block rides along too, under a `raw.` prefix and flattened to scalars, so a
|
|
250
|
+
meter can read a counter this SDK does not name yet: `raw.cache_creation_input_tokens`, or a
|
|
251
|
+
gateway's `raw.cost` before it was scaled into whole units. Your run's `dimensions` — `agentId`
|
|
252
|
+
and anything else you set — are on the same event, and win any key the vendor also used.
|
|
253
|
+
|
|
254
|
+
A meter to put behind one of them, in Stigg's own terms:
|
|
255
|
+
|
|
256
|
+
```jsonc
|
|
257
|
+
{
|
|
258
|
+
"aggregation": { "function": "SUM", "field": "output_tokens" },
|
|
259
|
+
"filters": [
|
|
260
|
+
{ "conditions": [{ "field": "eventName", "operation": "EQUALS", "value": "anthropic.messages.create" }] },
|
|
261
|
+
],
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
> **An event reported against a name with no meter behind it returns 200 and meters nothing.** The
|
|
266
|
+
> event API answers with an acknowledgement, not a balance, so nothing downstream can notice on its
|
|
267
|
+
> own — configure the meter first, or run with `ingest: 'sync'`, which does answer.
|
|
268
|
+
|
|
269
|
+
#### Pricing them yourself
|
|
270
|
+
|
|
271
|
+
`weightedMetrics` turns a price-per-metric table into the mapper:
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
import { anthropicRecipe, weightedMetrics } from '@stigg/agent-sdk-js';
|
|
275
|
+
|
|
276
|
+
const govClient = stigg.wrap(new Anthropic(), {
|
|
277
|
+
stiggContext: {
|
|
278
|
+
customerId,
|
|
279
|
+
entitlement: { currencyId: 'data_credit' },
|
|
280
|
+
// A whole credit per input token, half a credit per output token, cache reads free.
|
|
281
|
+
usageMapper: weightedMetrics(anthropicRecipe, 'data_credit', {
|
|
282
|
+
input_tokens: 1,
|
|
283
|
+
output_tokens: 0.5,
|
|
284
|
+
}),
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
The table is keyed to that recipe's own names, so a typo does not compile. Naming a metric is
|
|
290
|
+
optional — an unpriced one is free, and a table that prices nothing meters nothing.
|
|
291
|
+
|
|
292
|
+
A weight is the price of **one unit** of that metric, so the example above is half a credit per
|
|
293
|
+
output token. Pass `{ per: 1000 }` when the numbers read better per thousand.
|
|
294
|
+
|
|
295
|
+
It also handles the three things a hand-written mapper has to get right: Stigg takes whole units
|
|
296
|
+
so the total rounds up, a turn that measured nothing reports nothing rather than a zero, and a
|
|
297
|
+
counter the response omitted is absent rather than zero. For one number without weights,
|
|
298
|
+
`creditsPerTokens(featureId, n)` is the short form.
|
|
299
|
+
|
|
300
|
+
#### Writing the mapper yourself
|
|
301
|
+
|
|
302
|
+
A calculation a weights table cannot express is an ordinary function, and each binding exports the
|
|
303
|
+
type of what it receives — `AnthropicMetrics` for Anthropic, and `OpenAIMetrics`,
|
|
304
|
+
`OpenRouterMetrics`, `LiteLLMMetrics`, `GeminiMetrics` and `TypeSafeMetrics` for the rest:
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
import type { AnthropicMetrics, Metrics } from '@stigg/agent-sdk-js';
|
|
308
|
+
|
|
309
|
+
const dataCredits = ({ metrics = {} }: { metrics?: AnthropicMetrics }): Metrics => {
|
|
310
|
+
const billable = (metrics.output_tokens ?? 0) * 5 + (metrics.input_tokens ?? 0);
|
|
311
|
+
return billable > 0 ? { data_credit: Math.ceil(billable / 100) } : {};
|
|
312
|
+
};
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Every counter is optional, because a vendor reports cache reads only when there were some. Use
|
|
316
|
+
`AnthropicMetric` for one name rather than the table, and `MetricsOf<typeof someRecipe>` /
|
|
317
|
+
`MetricsFor<typeof someRecipe>` for a recipe you built yourself.
|
|
318
|
+
|
|
319
|
+
#### The overlap you have to know about
|
|
320
|
+
|
|
321
|
+
Vendors disagree about whether cached tokens are already counted inside the input total, and the
|
|
322
|
+
metrics are reported **verbatim** — because a number published under a vendor's own field name
|
|
323
|
+
has to match that vendor's own dashboard.
|
|
324
|
+
|
|
325
|
+
Every vendor also reports a **total**, which by definition overlaps everything beneath it. Where
|
|
326
|
+
the vendor publishes one it is reported verbatim — `total_tokens`, or `totalTokenCount` for
|
|
327
|
+
Gemini. Anthropic and TypeSafe publish none, so theirs is summed from counters that are disjoint,
|
|
328
|
+
which is the only case where the SDK adds anything up. **Price the total or the breakdown, never
|
|
329
|
+
both.**
|
|
330
|
+
|
|
331
|
+
| Vendor | Input-ish key | Includes cached tokens? |
|
|
332
|
+
| ---------------- | --------------- | -------------------------------------------------------- |
|
|
333
|
+
| Anthropic | `input_tokens` | **No** — cache keys are separate; the four sum correctly |
|
|
334
|
+
| OpenAI Chat | `prompt_tokens` | **Yes**, includes `cached_tokens` |
|
|
335
|
+
| OpenAI Responses | `input_tokens` | **Yes**, includes `cached_tokens` |
|
|
336
|
+
| LangChain | `input_tokens` | **Yes**, includes both cache keys |
|
|
337
|
+
| Gemini | `promptTokenCount` | **Yes**, includes `cachedContentTokenCount` |
|
|
338
|
+
|
|
339
|
+
Gemini overlaps in both directions at once, so it is worth stating separately: pricing
|
|
340
|
+
`promptTokenCount` **and** `cachedContentTokenCount` double-bills the cached tokens, while
|
|
341
|
+
`thoughtsTokenCount` and `toolUsePromptTokenCount` sit *outside* the two headline counters —
|
|
342
|
+
leave them out of a table and a reasoning model's thinking tokens, usually the expensive half of
|
|
343
|
+
the answer, are billed at nothing. The four add back up to the vendor's own `totalTokenCount`,
|
|
344
|
+
which is the arithmetic to check a table against.
|
|
345
|
+
|
|
346
|
+
So if you price `prompt_tokens` **and** `cached_tokens` for OpenAI traffic, you bill the cached
|
|
347
|
+
ones twice. Price one or the other, or use `normalizedMetrics` — four disjoint counters under
|
|
348
|
+
SDK-owned names, comparable across every vendor:
|
|
349
|
+
|
|
350
|
+
```ts
|
|
351
|
+
import { normalizedMetrics } from '@stigg/agent-sdk-js';
|
|
352
|
+
stigg.wrap(client, { stiggContext: { customerId, usageMapper: normalizedMetrics } });
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
OpenAI also has no raw name for cache creation, so its default set is three keys and a
|
|
356
|
+
`cache_creation_input_tokens` feature stays at zero for OpenAI traffic.
|
|
357
|
+
|
|
358
|
+
#### The failure that looks like success
|
|
359
|
+
|
|
360
|
+
A feature only drains a credit budget when your catalog maps it to that currency. Until it does,
|
|
361
|
+
every call succeeds, every ingest returns `ok`, and the budget never moves — so the call gates on a
|
|
362
|
+
number nothing spends. Stigg answers those reports with no credit balance, so the first one says
|
|
363
|
+
so:
|
|
364
|
+
|
|
365
|
+
```
|
|
366
|
+
[stigg] reported input_tokens but the response carried no credit balance - this feature is not
|
|
367
|
+
mapped to credits, so the budget this run checks against will never drain. Map it in Stigg, or
|
|
368
|
+
report to the currency directly.
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
### Bindings, by solution
|
|
372
|
+
|
|
373
|
+
Organised by where the customer's business logic sits, not by vendor. Each has a generic core;
|
|
374
|
+
vendor directories are thin recipes over it.
|
|
375
|
+
|
|
376
|
+
The word "gateway" below does two jobs, so the directories do not: one you **call** is an ordinary
|
|
377
|
+
client and lives in `bindings/client/gateways/`, beside `bindings/client/providers/` because both
|
|
378
|
+
are recipes over the same wrapper. One you **operate** is the opposite binding and lives in
|
|
379
|
+
`bindings/proxy/`.
|
|
380
|
+
|
|
381
|
+
| Binding | Reached as | Mechanism | Can refuse? |
|
|
382
|
+
| ------------------------------------------------ | ---------------------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------ |
|
|
383
|
+
| Client | `stigg.wrap(client, { stiggContext })` | gates the recipe's methods on a copy, vendor matched by shape | Yes — when the context names an entitlement |
|
|
384
|
+
| Client — a gateway you **call** | `wrap(client, { recipe: openrouterRecipe() })` | same wrapper; the recipe bills `usage.cost` and attributes `response.model` | Yes, same as any client |
|
|
385
|
+
| Client — a gateway whose cost is in a **header** | `wrap(client, { recipe: litellmRecipe() })` | same wrapper; the recipe bills `x-litellm-response-cost` and attributes `response.model` | Yes, same as any client |
|
|
386
|
+
| Gateway you **operate** (`bindings/proxy`) | `client.stigg.headers()` → `stigg.ingestFromHeaders(...)` | attribution travels as `x-stigg-*` headers; `vendor` names the shape it observed | **No — ingest only** |
|
|
387
|
+
|
|
388
|
+
A client run that names no `entitlement` lands in the same column as the gateway — metering
|
|
389
|
+
without a gate — except that it is in-process, and naming one turns it back on.
|
|
390
|
+
|
|
391
|
+
#### Providers that speak the OpenAI wire format
|
|
392
|
+
|
|
393
|
+
A large part of the ecosystem exposes an OpenAI-compatible API rather than one of its own:
|
|
394
|
+
OpenRouter, LiteLLM, Groq, Together, Fireworks, DeepSeek, xAI and Perplexity, plus self-hosted
|
|
395
|
+
servers like vLLM and Ollama. There is no separate client to install for any of them. You point
|
|
396
|
+
the OpenAI client at their base URL, and `wrap` matches on the **shape of the client**, not on who
|
|
397
|
+
is at the other end of it:
|
|
398
|
+
|
|
399
|
+
```ts
|
|
400
|
+
// Groq, Together, Ollama, vLLM - anything OpenAI-compatible
|
|
401
|
+
const client = stigg.wrap(
|
|
402
|
+
new OpenAI({ baseURL: 'https://api.groq.com/openai/v1', apiKey: process.env.GROQ_API_KEY }),
|
|
403
|
+
{ stiggContext: { customerId, entitlement: { currencyId: 'credits' } } },
|
|
404
|
+
);
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
So `OpenAI` in that line names the **wire format**, not the vendor being billed. What differs is
|
|
408
|
+
which recipe matches, and that decides what you can meter:
|
|
409
|
+
|
|
410
|
+
| Pointed at | Recipe matched | Events it emits | Reports |
|
|
411
|
+
| ------------------------------------ | ------------------ | --------------------------------- | --------------------------------- |
|
|
412
|
+
| `https://openrouter.ai/api/v1` | `openrouterRecipe` | `openrouter.*` (see below) | tokens, `cost`, model that served |
|
|
413
|
+
| the LiteLLM proxy URL you configured | `litellmRecipe` | `litellm.*` | tokens, `cost`, model that served |
|
|
414
|
+
| anything else OpenAI-compatible | `openAIRecipe` | `openai.*` | tokens only |
|
|
415
|
+
|
|
416
|
+
Each of those covers **both OpenAI surfaces**, and a surface is its own event: Chat Completions
|
|
417
|
+
reports `<vendor>.chat.completions.create` and the Responses API reports
|
|
418
|
+
`<vendor>.responses.create`. A meter filtering on one sees nothing from the other, so configure the
|
|
419
|
+
one your code actually calls - or both. The full list is in the event table earlier in this file.
|
|
420
|
+
|
|
421
|
+
OpenRouter is recognised by its **hostname** and LiteLLM by the base URL you hand its recipe, which is
|
|
422
|
+
why those two get a `cost` the token counters cannot recover. Everything else is OpenAI as far as
|
|
423
|
+
this SDK can tell, because that is all the client reveals.
|
|
424
|
+
|
|
425
|
+
> **One exception to that last row.** Given no base URL of its own, the LiteLLM recipe falls back to
|
|
426
|
+
> looking for `litellm` anywhere in the client's URL - a self-hosted proxy has no canonical hostname
|
|
427
|
+
> to compare against, the way OpenRouter's own hostname does. So an unrelated OpenAI-compatible
|
|
428
|
+
> endpoint that happens to contain the string, like `https://api.acme.com/litellm-compat/v1`, is
|
|
429
|
+
> claimed by it and reports under `litellm.*`. Pass `recipe: openAIRecipe` to settle it explicitly.
|
|
430
|
+
|
|
431
|
+
**The consequence worth planning for:** every provider in that last row reports under `openai.*`.
|
|
432
|
+
A raw-events meter cannot tell Groq from Ollama from OpenAI itself, and neither can a per-provider
|
|
433
|
+
budget. If you need them separated, give the call a dimension of your own —
|
|
434
|
+
|
|
435
|
+
```ts
|
|
436
|
+
stiggContext: { customerId, dimensions: { provider: 'groq' } }
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
— and filter the meter on it, or pass a recipe with its own `vendor` name, which changes the event
|
|
440
|
+
name to match.
|
|
441
|
+
|
|
442
|
+
> **Not covered yet:** OpenRouter's own `@openrouter/sdk` package is a different shape — its method
|
|
443
|
+
> is `chat.send` and its counters are camelCase — so `wrap` does not recognise it and will refuse
|
|
444
|
+
> the client rather than meter it silently. Use the OpenAI-compatible endpoint above.
|
|
445
|
+
|
|
446
|
+
Client recipes are data — method paths and usage extractors — so the factory wires the built-in
|
|
447
|
+
ones with no vendor package loaded. Nothing in `src/index.ts` pulls a vendor package in at
|
|
448
|
+
runtime, which is what keeps one import enough for every example.
|
|
449
|
+
|
|
450
|
+
"Gateway" covers two deployments that want opposite bindings, and the split is worth naming.
|
|
451
|
+
|
|
452
|
+
**A gateway you call** — OpenRouter as a service — returns its response into your process, and
|
|
453
|
+
that response carries two things no token counter can recover: the credits it actually charged
|
|
454
|
+
(`usage.cost`) and which model actually served after fallback routing (`response.model`). That is
|
|
455
|
+
an ordinary client, so it takes the ordinary client binding: `openrouterRecipe()` reads both, and
|
|
456
|
+
the call is gated before it ever leaves. It **is** registered by default and matched by base URL
|
|
457
|
+
ahead of `openAIRecipe`, because reading a richer response is not a decision anyone needs to opt
|
|
458
|
+
into — out of the box it reports the same token metrics the plain OpenAI recipe would, and adds
|
|
459
|
+
`cost`, the model that served and the gateway's own request id.
|
|
460
|
+
|
|
461
|
+
Tokens and money are different features, so both are reported and you price each in Stigg. When a
|
|
462
|
+
response carries no cost, the key is simply absent rather than substituted with something else.
|
|
463
|
+
`unitsPerCredit` (micro-credits by default) is the denomination of the `cost` feature.
|
|
464
|
+
|
|
465
|
+
**A gateway you call whose price is in a header** — LiteLLM Proxy — is the same binding with one
|
|
466
|
+
wrinkle worth knowing before you budget on it. LiteLLM prices every call itself, but writes the
|
|
467
|
+
number into `x-litellm-response-cost` rather than into the body, so `litellmRecipe()` reads the
|
|
468
|
+
response and not just the payload. Headers go on the wire before the model has produced a token,
|
|
469
|
+
so a **streamed** call cannot carry a cost at all — tokens survive a stream fine, a price does
|
|
470
|
+
not. So a non-streaming call reports its tokens **and** a `cost`, while a streamed one reports
|
|
471
|
+
the tokens alone — the same features either way, one of them simply missing where the protocol
|
|
472
|
+
cannot carry it.
|
|
473
|
+
That is the protocol rather than a limit of the binding, and the gateway example prints both
|
|
474
|
+
numbers side by side so the difference is not a surprise in an invoice.
|
|
475
|
+
|
|
476
|
+
**A gateway you operate** — a proxy fronting services that may not even be Node — is the one that
|
|
477
|
+
is not a binding in the full sense, and deliberately so: it is out-of-process and sees
|
|
478
|
+
**requests, not runs**. It cannot provision, cannot check against run-scoped state, and cannot
|
|
479
|
+
archive, and there is no client object out there to wrap. So the lifecycle splits across the
|
|
480
|
+
boundary — the application checks in-process, attribution travels as headers,
|
|
481
|
+
and the proxy only ingests. Governance never leaves the process.
|
|
482
|
+
|
|
483
|
+
A recipe reports a `Measurement`, not just token counts: `metrics` carries the vendor's own field
|
|
484
|
+
names and is what gets reported (a gateway puts its real spend in `metrics.cost` there, where a
|
|
485
|
+
token mapper would only lose it), `raw` rides an event's dimensions under a `raw.` prefix,
|
|
486
|
+
`dimensions` are merged over the context's own, and `idempotencyKey` lets the vendor's own request
|
|
487
|
+
id — stable across _its_ retries, which a local step counter cannot be — replace the default
|
|
488
|
+
`correlationId:step`. Returning
|
|
489
|
+
a bare `TokenUsage` still works and stays the common case.
|
|
490
|
+
|
|
491
|
+
### The engine: SDK first, REST to complete
|
|
492
|
+
|
|
493
|
+
Two clients, composed per operation, and the SDK tells you which one it picked:
|
|
494
|
+
|
|
495
|
+
```text
|
|
496
|
+
[governance] mode=governance check=rest report=node-sdk provisioning=rest (governance enabled)
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
| Operation | Client | Why |
|
|
500
|
+
| ------------------- | ------------------------------------- | ------------------------------------------------------------------------ |
|
|
501
|
+
| `check` | `@stigg/typescript` (REST, `v1-beta`) | only the REST surface takes `dimensions` and returns governance `chains` |
|
|
502
|
+
| `ingest` | `@stigg/node-server-sdk` | it has the in-memory cache, and usage reporting is the hot path |
|
|
503
|
+
| provision / archive | `@stigg/typescript` (REST, `v1-beta`) | entities and assignments exist only on the governance surface |
|
|
504
|
+
|
|
505
|
+
**Ingest has two APIs, and the context picks one.** They are not variants of one call:
|
|
506
|
+
|
|
507
|
+
| | `ingest: 'async'` — the default | `ingest: 'sync'` |
|
|
508
|
+
| ---------------------- | ----------------------------------- | ---------------------------------------------------- |
|
|
509
|
+
| Call | `events.report` / `reportEvent` | `usage.report` / `reportUsageBulk` |
|
|
510
|
+
| Latency and rate limit | low latency, much higher limit | higher latency, lower limit |
|
|
511
|
+
| The next check | lags while Stigg aggregates | already includes the spend |
|
|
512
|
+
| What you send | the call, as one event | a `featureId` and a whole-number `value`, per metric |
|
|
513
|
+
| Meter type | **Raw events**, configured in Stigg | calculated / incremental |
|
|
514
|
+
| What comes back | an acknowledgement | a credit balance |
|
|
515
|
+
|
|
516
|
+
Async sends **one event for the whole turn**, every metric on its own dimension, because one event
|
|
517
|
+
feeds any number of meters — fanning out per metric buys nothing and costs a request each. Sync
|
|
518
|
+
sends one report per metric, batched into a single bulk call.
|
|
519
|
+
|
|
520
|
+
A metric named after the currency the context checks is neither: it draws the wallet directly, since
|
|
521
|
+
no raw event can reach a currency. It follows the mode only in whether it waits for the balance.
|
|
522
|
+
|
|
523
|
+
**Governance is optional.** Not every account has it. When it is missing the factory falls back
|
|
524
|
+
to customer-level enforcement on the Node SDK alone — every example still runs, denials just name
|
|
525
|
+
the customer instead of an agent:
|
|
526
|
+
|
|
527
|
+
```text
|
|
528
|
+
[governance] mode=customer-level check=node-sdk report=node-sdk provisioning=unavailable
|
|
529
|
+
(403 GovernanceNotEnabled - customer-level fallback)
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
Nothing declares this. The client asks the account once at init and acts on the answer, so there
|
|
533
|
+
is no second source of truth to disagree with the first. Without governance, checks and usage
|
|
534
|
+
reporting still work at the customer level — and `register()` follows that split. A bare
|
|
535
|
+
registration warns and hands the id back: it asked for an identity, and a call needs none. A call
|
|
536
|
+
carrying a **budget or a parent** throws, because there is nowhere to write the assignment, and a
|
|
537
|
+
run that looked governed with a budget that was never written is the failure worth refusing.
|
|
538
|
+
|
|
539
|
+
### What the cache promises
|
|
540
|
+
|
|
541
|
+
**This package has no cache.** Checks are served by the Node server SDK's entitlement cache, which
|
|
542
|
+
is a far better one than a TTL over decisions could be:
|
|
543
|
+
|
|
544
|
+
- it writes the server's own measurement response back into cached usage, so a report that moved
|
|
545
|
+
the budget is visible to the very next check;
|
|
546
|
+
- it invalidates on realtime updates, so a plan change or a cancelled subscription lands without
|
|
547
|
+
waiting out a window;
|
|
548
|
+
- it applies usage-period resets at read time, rather than serving a number that expired;
|
|
549
|
+
- it falls back to configured entitlements when Stigg is unreachable;
|
|
550
|
+
- and it can be backed by **Redis**, so sibling processes share one view instead of each holding
|
|
551
|
+
their own.
|
|
552
|
+
|
|
553
|
+
All of that is configured through `cache`, which is the Node SDK's own configuration passed
|
|
554
|
+
straight through — `redis`, `memoryCacheMaxSizeBytes`, `entitlementsFallback`,
|
|
555
|
+
`creditEntitlementsFallback`, `realtimeUpdatesEnabled`, `enableEdge`, `offline`:
|
|
556
|
+
|
|
557
|
+
```ts
|
|
558
|
+
const stigg = new StiggAgentSDK(apiKey, {
|
|
559
|
+
cache: { redis: { host: 'redis', port: 6379, environmentPrefix: 'prod', ttl: -1 } },
|
|
560
|
+
});
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
`wrap()` primes it for the customer in the context, loading their whole entitlement state rather
|
|
564
|
+
than one decision — so a later check against a different entitlement is warm too, and each check
|
|
565
|
+
is evaluated against the amount it actually asks for.
|
|
566
|
+
|
|
567
|
+
#### If your application already has a Stigg client
|
|
568
|
+
|
|
569
|
+
Pass it, and the SDK uses it instead of building one:
|
|
570
|
+
|
|
571
|
+
```ts
|
|
572
|
+
const stigg = new StiggAgentSDK(apiKey, { client: appStiggClient });
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
This is about **ownership**, not deduplication. `Stigg.initialize` already keeps one global
|
|
576
|
+
instance per process and hands it back when the API key matches — so without `client` you may
|
|
577
|
+
well be handed the application's own, and nothing observable can tell that apart from a client
|
|
578
|
+
the SDK just built: the instance exposes neither its configuration nor its identity. Since
|
|
579
|
+
`close()` nulls that global and tears the runtime down, `stigg.close()` would then take the
|
|
580
|
+
application's cache and socket with it.
|
|
581
|
+
|
|
582
|
+
A client passed here is **borrowed and never closed**; one the SDK builds is owned and closed. It
|
|
583
|
+
replaces only the cached half — the governance REST client is still built from the API key,
|
|
584
|
+
because the Node SDK has no governance surface — and it is mutually exclusive with `cache`, which
|
|
585
|
+
configures a client the SDK is no longer building.
|
|
586
|
+
|
|
587
|
+
An earlier design cached decisions here for a `cacheTtlMs` (default 5s). It duplicated the
|
|
588
|
+
machinery above and degraded it: a cached allow outlived the report that spent the budget, could
|
|
589
|
+
not hear an invalidation, and did not carry the requested amount in its key — so an allow fetched
|
|
590
|
+
for one unit authorized a call asking for ten thousand. `cacheTtlMs` is now **ignored**, and
|
|
591
|
+
warns when set.
|
|
592
|
+
|
|
593
|
+
**The one path that caches nothing is an attributed governance check** — a check carrying
|
|
594
|
+
dimensions, which is how per-agent budgets are enforced. It resolves an entity, which only the
|
|
595
|
+
governance API can do, so it is a live call every time. That is why
|
|
596
|
+
`RestGovernanceEngine.capabilities.strictCheck` is `true`: there is no window in which two
|
|
597
|
+
processes can each approve the same budget. It costs a round trip per call, which is the right
|
|
598
|
+
trade in front of a multi-second model call and the wrong one to optimize away with a stale allow.
|
|
599
|
+
|
|
600
|
+
None of it applies to a context that names no `entitlement`: it warms nothing and checks nothing,
|
|
601
|
+
because it never asks.
|
|
602
|
+
|
|
603
|
+
#### How this SDK identifies itself
|
|
604
|
+
|
|
605
|
+
**There is no telemetry.** Nothing is emitted on a schedule, nothing is batched in the background,
|
|
606
|
+
and there is no second network path that could fail or leak. The SDK names itself on the requests
|
|
607
|
+
it was already making, and that is all:
|
|
608
|
+
|
|
609
|
+
| Half | What it sends |
|
|
610
|
+
| --- | --- |
|
|
611
|
+
| Cached reads and ingest (GraphQL) | `apollographql-client-name: agent-sdk-js`, `apollographql-client-version: <this package's version>` |
|
|
612
|
+
| Governance (REST) | `X-SDK-CLIENT-NAME: agent-sdk-js`, `X-SDK-CLIENT-VERSION: <this package's version>` |
|
|
613
|
+
|
|
614
|
+
No customer data, no prompts, no model output — the package name and its version, next to the
|
|
615
|
+
process identity (`X-SDK-INSTANCE-ID` and friends) the Node SDK already sends.
|
|
616
|
+
|
|
617
|
+
Two things it deliberately does **not** do. It does not send a distinct `source`: that header is a
|
|
618
|
+
server-side enum with no member for this SDK, the API groups on it, so a new member is a server
|
|
619
|
+
change that has to land first — the client name carries the distinction until then. And it does not
|
|
620
|
+
rename a client you passed in through `client`, which keeps whatever name your application gave it;
|
|
621
|
+
`Stigg.initialize` may also hand back a client your application built first, in which case the same
|
|
622
|
+
applies. Both cases are correct — the name belongs to whoever owns the client.
|
|
623
|
+
|
|
624
|
+
## 0. Try it with no setup at all
|
|
625
|
+
|
|
626
|
+
```bash
|
|
627
|
+
yarn workspace @stigg/agent-sdk-js run example-client-anthropic # or -openai, -gemini, -openrouter, -litellm
|
|
628
|
+
yarn workspace @stigg/agent-sdk-js run example-gateway # or example-gateway-litellm
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
The full list, with what each one demonstrates, is in [section 6](#6-run-the-examples).
|
|
632
|
+
|
|
633
|
+
The vendor mock speaks the real wire formats, so the vendor SDK parses a genuine response and
|
|
634
|
+
every field the examples read (`usage`, `model`, `cost`, tool calls) is populated. The Stigg mock
|
|
635
|
+
implements real entities, budgets and hierarchy, so a demo genuinely hits a denial and can name
|
|
636
|
+
which entity caused it. Only the prose and the storage are fake.
|
|
637
|
+
|
|
638
|
+
To force the mocks even when keys **are** present — useful when a key is valid but the account
|
|
639
|
+
has no credits — set `AGENT_SDK_OFFLINE=1` in `.env`.
|
|
640
|
+
|
|
641
|
+
## 1. Prerequisites
|
|
642
|
+
|
|
643
|
+
- **Node 22** — the version in the repo's `.nvmrc`. Check with `node --version`.
|
|
644
|
+
- **Yarn 4** via Corepack:
|
|
645
|
+
|
|
646
|
+
```bash
|
|
647
|
+
corepack enable
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
## 2. Get an Anthropic API key (optional)
|
|
651
|
+
|
|
652
|
+
Used by the client examples. Skip this to stay on the offline mock.
|
|
653
|
+
|
|
654
|
+
1. Go to **<https://console.anthropic.com>** and sign up or sign in.
|
|
655
|
+
2. Open **Billing** and add a payment method or buy credits. A brand-new org has no balance,
|
|
656
|
+
and requests fail with `400 credit balance is too low` until you do.
|
|
657
|
+
3. Open **Settings → API Keys** and click **Create Key**. Give it a name.
|
|
658
|
+
4. **Copy the key immediately** — it is shown only once. It starts with `sk-ant-`.
|
|
659
|
+
5. Put it in `.env` as `AGENT_SDK_ANTHROPIC_API_KEY` (step 5 below).
|
|
660
|
+
|
|
661
|
+
The examples default to the `claude-opus-5` model. Override with `AGENT_SDK_ANTHROPIC_MODEL` in `.env`.
|
|
662
|
+
|
|
663
|
+
## 3. Gateway access (optional)
|
|
664
|
+
|
|
665
|
+
Used by the gateway examples. Skip this entirely to stay on the offline mock, which speaks both
|
|
666
|
+
wire formats — including LiteLLM's header-borne cost, and its absence on a stream.
|
|
667
|
+
|
|
668
|
+
### OpenRouter
|
|
669
|
+
|
|
670
|
+
1. Go to **<https://openrouter.ai>** and sign in with Google, GitHub, or email.
|
|
671
|
+
2. Open **Settings → Credits** and add credits (a few dollars is plenty for these examples).
|
|
672
|
+
3. Go to **<https://openrouter.ai/keys>** and click **Create Key**.
|
|
673
|
+
4. Optionally set a **credit limit** on the key — a hard cap on what this key can ever spend.
|
|
674
|
+
Recommended while experimenting.
|
|
675
|
+
5. Copy the key (starts with `sk-or-`) and put it in `.env` as `AGENT_SDK_OPENROUTER_API_KEY`.
|
|
676
|
+
|
|
677
|
+
The example asks for `AGENT_SDK_OPENROUTER_MODEL` and falls back through
|
|
678
|
+
`AGENT_SDK_OPENROUTER_FALLBACK_MODELS`.
|
|
679
|
+
Both are model slugs — browse or verify them at **<https://openrouter.ai/models>**.
|
|
680
|
+
|
|
681
|
+
### LiteLLM
|
|
682
|
+
|
|
683
|
+
There is no signup: a LiteLLM proxy is something you host, so this is a URL rather than an account.
|
|
684
|
+
|
|
685
|
+
1. Start one — `pip install 'litellm[proxy]'`, then `litellm --model gpt-4o --port 4000`. It needs
|
|
686
|
+
the upstream key for whatever it fronts (`OPENAI_API_KEY`, and so on) in its own environment.
|
|
687
|
+
2. Put its URL in `.env` as `AGENT_SDK_LITELLM_BASE_URL`. The default is `http://localhost:4000` —
|
|
688
|
+
note a **local Stigg** uses port 4000 too, so move one of them if you run both.
|
|
689
|
+
3. Put a virtual key in `AGENT_SDK_LITELLM_API_KEY` if the proxy requires one (`--master_key`, or
|
|
690
|
+
a key minted through `/key/generate`). Leave it blank and the examples use the offline mock.
|
|
691
|
+
|
|
692
|
+
`AGENT_SDK_LITELLM_MODEL` is a **model group** from the proxy's own config, not a provider slug.
|
|
693
|
+
A group is free to resolve to a different deployment, and that difference is the routing drift
|
|
694
|
+
the examples flag.
|
|
695
|
+
|
|
696
|
+
## 4. Point at a Stigg environment (optional)
|
|
697
|
+
|
|
698
|
+
One key and one URL, pointed at whichever environment you mean. The key is environment-bound, so
|
|
699
|
+
it and the URL always move together — there is no separate profile name to keep in sync with them.
|
|
700
|
+
|
|
701
|
+
| Environment | `STIGG_BASE_URL` |
|
|
702
|
+
| ----------- | --------------------------------------------------------------- |
|
|
703
|
+
| production | `https://api.stigg.io` (the default when the variable is blank) |
|
|
704
|
+
| staging | `https://api-staging.stigg.io` |
|
|
705
|
+
| local | `http://localhost:4000` |
|
|
706
|
+
|
|
707
|
+
Get a server key for that environment from **app.stigg.io → Integrations → API keys** and put it
|
|
708
|
+
in `STIGG_API_KEY`.
|
|
709
|
+
|
|
710
|
+
**Leave `STIGG_API_KEY` blank and everything still runs** against a built-in Stigg mock, with no
|
|
711
|
+
account at all — the same rule the vendor keys follow. `AGENT_SDK_OFFLINE=1` forces the mocks even
|
|
712
|
+
when keys are present, which is what you want when a key is valid but the account has no credits.
|
|
713
|
+
|
|
714
|
+
Two things adjust themselves to the URL, so you do not configure them twice:
|
|
715
|
+
|
|
716
|
+
- The mock answers both transports — REST and `/graphql` — so the engine resolves exactly as it
|
|
717
|
+
does against a real account: governance checks and provisioning over REST, ingest and
|
|
718
|
+
unattributed checks over the Node SDK. The line the SDK prints at startup says which it picked.
|
|
719
|
+
- Against `localhost` the Node SDK's edge cache and realtime socket are switched off, because a
|
|
720
|
+
local API serves neither and the SDK maps a localhost base to edge port 3060.
|
|
721
|
+
|
|
722
|
+
### Seed the environment
|
|
723
|
+
|
|
724
|
+
```bash
|
|
725
|
+
yarn workspace @stigg/agent-sdk-js run example-setup # 5-credit agent budget
|
|
726
|
+
yarn workspace @stigg/agent-sdk-js run example-setup --agent-limit 6 # raise it
|
|
727
|
+
yarn workspace @stigg/agent-sdk-js run example-setup --no-governance # seed without entities
|
|
728
|
+
yarn workspace @stigg/agent-sdk-js run example-setup --archive # clean up, bottom-up
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
`--no-governance` describes this invocation only: it skips the entity and budget steps, so the
|
|
732
|
+
account is seeded without a tree. It is not a mode the examples inherit — offline, each one starts
|
|
733
|
+
its own mock, and that mock has governance enabled. To see customer-level enforcement end to end,
|
|
734
|
+
use an account that genuinely has no governance.
|
|
735
|
+
|
|
736
|
+
Idempotent per step, and safe to re-run — but not safe to run twice **at once**: the credit grant
|
|
737
|
+
reads the balance and then grants, with nothing between the two, so two concurrent invocations can
|
|
738
|
+
both decide the pool is short and both fund it. Run it once at a time. Steps are gated by what the
|
|
739
|
+
account actually has:
|
|
740
|
+
|
|
741
|
+
| Step | Needs governance? | What it does |
|
|
742
|
+
| -------------------------- | ----------------- | -------------------------------------------------------------------------------- |
|
|
743
|
+
| 1. customer | no | creates it if missing |
|
|
744
|
+
| 2. credit pool | no | grants credits when the balance is below `--pool-min`, guarded by a balance read |
|
|
745
|
+
| 3. entity types + entities | **yes** | `team` (attribution key `teamId`) and `agent` (`agentId`) |
|
|
746
|
+
| 4. assignments | **yes** | One agent per example, each = 5 per `P1M`, all parented to `team-research` |
|
|
747
|
+
|
|
748
|
+
Each example registers **its own agent** — `agent-support-bot-anthropic`, `-openai`, `-gemini`, `-openrouter`,
|
|
749
|
+
`-litellm`, and the base `agent-support-bot` for the gateway examples. Budgets attach to the agent, so
|
|
750
|
+
sharing one would mean running the OpenAI example spends what the Anthropic example had left. The
|
|
751
|
+
shared parent team keeps the ancestor rule demonstrable, and its budget is sized so one agent
|
|
752
|
+
exhausting itself does not block the others:
|
|
753
|
+
|
|
754
|
+
```text
|
|
755
|
+
team-research (team) usage=0/30
|
|
756
|
+
agent-support-bot usage=0/5
|
|
757
|
+
agent-support-bot-anthropic usage=0/5
|
|
758
|
+
agent-support-bot-openai usage=0/5
|
|
759
|
+
agent-support-bot-openrouter usage=0/5
|
|
760
|
+
agent-support-bot-litellm usage=0/5
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
**The client examples provision their own agent** — the seed prepares the account (entity types,
|
|
764
|
+
the parent team, a funded pool). That is the more realistic split: an admin sets up the account, an
|
|
765
|
+
application provisions its own agents at runtime. `register()` carries it:
|
|
766
|
+
|
|
767
|
+
One caveat about the cap, because the two halves can overlap. `usageLimitOnCreate` is exactly that
|
|
768
|
+
— on create — so if the seed has already written an assignment for that agent, the application's
|
|
769
|
+
cap is **not** applied and the seeded one stands. That is the point of the option (an admin who
|
|
770
|
+
raises a budget out of band should not have it stamped back on every process start), but it does
|
|
771
|
+
mean `--agent-limit` on the seed wins over the number below. Use `usageLimit` instead if the
|
|
772
|
+
application is meant to own it.
|
|
773
|
+
|
|
774
|
+
```ts
|
|
775
|
+
await stigg.register({
|
|
776
|
+
customerId: 'customer-acme',
|
|
777
|
+
agentId: 'agent-support-bot-anthropic',
|
|
778
|
+
entityTypeId: 'agent',
|
|
779
|
+
// A budget is written over (entity x entitlement), so a call carrying one names it. `register`
|
|
780
|
+
// refuses a placement without it rather than provisioning an agent whose cap lands nowhere.
|
|
781
|
+
entitlement: { currencyId: 'credits' },
|
|
782
|
+
parentId: 'team-research',
|
|
783
|
+
usageLimit: 5,
|
|
784
|
+
cadence: 'P1M',
|
|
785
|
+
});
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
A cap is **not** a field on the entity. An entity is just a node — id, type, display name,
|
|
789
|
+
metadata — and a limit is an _assignment_ over `(entity × capability)`. So that one call is two
|
|
790
|
+
idempotent upserts underneath: `PUT …/entities` then `PUT …/assignments`. It is also why
|
|
791
|
+
`parentId` rides the assignment rather than the entity: the hierarchy is per-capability, so one
|
|
792
|
+
agent can sit under different parents for different currencies.
|
|
793
|
+
|
|
794
|
+
`usageLimit` is tri-state — a number caps it, `null` tracks without blocking (the observe-only
|
|
795
|
+
rollout), and omitting it leaves whatever is already assigned untouched. Governance requires both
|
|
796
|
+
it and `cadence` when it creates a budget, which is why both are passed above.
|
|
797
|
+
|
|
798
|
+
The pool is deliberately much larger than the agent budget, so a demo denial comes from the
|
|
799
|
+
agent's own cap (`BudgetExceeded`) rather than an empty wallet (`InsufficientCredits`). Those are
|
|
800
|
+
different products — an upsell versus an escalation to whoever set the budget — and the SDK keeps
|
|
801
|
+
them distinct all the way to the message the user sees.
|
|
802
|
+
|
|
803
|
+
## 5. Configure and install
|
|
804
|
+
|
|
805
|
+
Two files, deliberately separate. Both are git-ignored, and each has a committed `.template`
|
|
806
|
+
beside it to copy from.
|
|
807
|
+
|
|
808
|
+
**`.env`** (from `.env.template`) — credentials and SDK configuration. Everything in it is either a
|
|
809
|
+
secret or a real setting you would also make in your own application.
|
|
810
|
+
|
|
811
|
+
**`examples/demo.env`** (from `examples/demo.env.template`) — the demo's fixtures: the tenant, the
|
|
812
|
+
team, the agent. Nothing in it is configuration — a real application knows its tenant from the
|
|
813
|
+
request it is serving, never from an env file.
|
|
814
|
+
|
|
815
|
+
Neither file is required **against the mocks**: every fixture default is compiled into the
|
|
816
|
+
examples, so with no files and no keys at all they still run end to end. Those defaults are
|
|
817
|
+
mock-only. Once `STIGG_API_KEY` is set the examples stop inventing ids for an environment they
|
|
818
|
+
can actually write to — `AGENT_SDK_DEMO_CUSTOMER_ID` becomes required, and the rest degrade to a
|
|
819
|
+
smaller demo (see the fixtures table below).
|
|
820
|
+
|
|
821
|
+
`.env` is loaded first and is never overwritten, so setting one of the `AGENT_SDK_DEMO_*` names
|
|
822
|
+
there (or exporting it in your shell) overrides the fixture without editing `demo.env`.
|
|
823
|
+
|
|
824
|
+
### `.env` — credentials and endpoints
|
|
825
|
+
|
|
826
|
+
| Variable | Default | Purpose |
|
|
827
|
+
| -------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
|
828
|
+
| `AGENT_SDK_ANTHROPIC_API_KEY` | — (falls back to `ANTHROPIC_API_KEY`) | Layers 1 and 3 |
|
|
829
|
+
| `AGENT_SDK_ANTHROPIC_MODEL` | `claude-opus-5` | Claude model for layers 1 and 3 |
|
|
830
|
+
| `AGENT_SDK_OPENAI_API_KEY` | — (falls back to `OPENAI_API_KEY`) | The OpenAI client example |
|
|
831
|
+
| `AGENT_SDK_OPENAI_MODEL` | `gpt-5` | Model for the OpenAI client example |
|
|
832
|
+
| `AGENT_SDK_OPENROUTER_API_KEY` | — (falls back to `OPENROUTER_API_KEY`) | Layer 2 |
|
|
833
|
+
| `AGENT_SDK_GEMINI_API_KEY` | — | Falls back to `GEMINI_API_KEY` / `GOOGLE_API_KEY`. Absent, the Gemini example runs on the built-in mock |
|
|
834
|
+
| `AGENT_SDK_GEMINI_MODEL` | `gemini-3.8-flash` | Model for the Gemini client example |
|
|
835
|
+
| `AGENT_SDK_OPENROUTER_MODEL` | `anthropic/claude-opus-5` | Primary model requested from the gateway |
|
|
836
|
+
| `AGENT_SDK_OPENROUTER_FALLBACK_MODELS` | `anthropic/claude-sonnet-5,openai/gpt-5` | Comma-separated fallbacks tried in order |
|
|
837
|
+
| `AGENT_SDK_LITELLM_API_KEY` | — (falls back to `LITELLM_API_KEY`) | Layer 2. Blank ⇒ the offline mock |
|
|
838
|
+
| `AGENT_SDK_LITELLM_BASE_URL` | `http://localhost:4000` | Your own proxy — self-hosted, so there is no public default |
|
|
839
|
+
| `AGENT_SDK_LITELLM_MODEL` | `gpt-5` | A model **group** from the proxy's config, not a provider slug |
|
|
840
|
+
| `STIGG_API_KEY` | — | **Server** key (`server-…`). A publishable `client-…` key fails every call with `IdentityForbidden`. Blank ⇒ the built-in Stigg mock |
|
|
841
|
+
| `STIGG_BASE_URL` | `https://api.stigg.io` | Which environment that key belongs to |
|
|
842
|
+
|
|
843
|
+
### What the examples gate and meter against — in `examples/demo.env`
|
|
844
|
+
|
|
845
|
+
These moved out of `.env`, because they fail the test that separates the two files: **if you would
|
|
846
|
+
change it to play with an example, it is a fixture.** A real application decides every one of them
|
|
847
|
+
in code — what it bills against is part of its pricing, not of its deployment.
|
|
848
|
+
|
|
849
|
+
The SDK's own settings are arguments rather than variables, which is why none of them appear here:
|
|
850
|
+
`new StiggAgentSDK(apiKey, baseUrl, { cache, client, timeoutMs, logger, onError })` for the
|
|
851
|
+
environment, and `wrap(client, { stiggContext })` — overridable inside any call's own parameters —
|
|
852
|
+
for what a call is governed by.
|
|
853
|
+
|
|
854
|
+
| Variable | Default | Purpose |
|
|
855
|
+
| -------------------------------- | ----------------------- | ---------------------------------------------------------------------------------- |
|
|
856
|
+
| `AGENT_SDK_DEMO_CURRENCY_ID` | `credits` *(mock only)* | `stiggContext.entitlement` — credit currency to budget against. The default is the examples' own and applies only offline; the SDK has none. Naming neither this nor a feature is legal and means the calls **meter without gating** — `entitlement` is itself the enforcement switch. `two-agents` and the gateway examples refuse to run that way, since a budget draining is their whole subject |
|
|
857
|
+
| `AGENT_SDK_DEMO_FEATURE_ID` | — | `stiggContext.entitlement` — metered feature instead; exclusive with the above |
|
|
858
|
+
| `AGENT_SDK_DEMO_EVENT_NAME` | — | `stiggContext.eventName` — overrides the name derived from the vendor call |
|
|
859
|
+
|
|
860
|
+
### `examples/demo.env` — demo fixtures
|
|
861
|
+
|
|
862
|
+
Scaffolding, not configuration. Your own application supplies all of this from its own context.
|
|
863
|
+
|
|
864
|
+
Each fixture has **three** states, because "no `demo.env` at all" and "I want no agent" are
|
|
865
|
+
different things and an absent variable cannot mean both:
|
|
866
|
+
|
|
867
|
+
| The variable is… | Against the mock | With a real `STIGG_API_KEY` |
|
|
868
|
+
| --------------------- | ----------------------------- | ----------------------------------------------- |
|
|
869
|
+
| **absent** | the default below | unset — the demo degrades, or says what it needs |
|
|
870
|
+
| **set** | that value | that value |
|
|
871
|
+
| **present but blank** | unset, deliberately | unset, deliberately |
|
|
872
|
+
|
|
873
|
+
The defaults are mock-only on purpose: against an environment you can write to, a guessed
|
|
874
|
+
`customer-acme` is either someone else's tenant or one nobody asked for.
|
|
875
|
+
|
|
876
|
+
| Variable | Default *(mock only)* | Unset ⇒ |
|
|
877
|
+
| ------------------------------------- | --------------------- | --------------------------------------------------------------------- |
|
|
878
|
+
| `AGENT_SDK_DEMO_CUSTOMER_ID` | `customer-acme` | **Error.** Every governance call is scoped to a tenant, and the examples will not invent one |
|
|
879
|
+
| `AGENT_SDK_DEMO_TEAM_ID` | `team-research` | The agents are **roots** — no ancestor budget binds them. `two-agents` needs one and says so |
|
|
880
|
+
| `AGENT_SDK_DEMO_AGENT_ID` | `agent-support-bot` | **No agent at all**: checks and usage land on the customer's own pool. Blank it to see that path against the mock. Each client example appends its vendor (`…-anthropic`) |
|
|
881
|
+
| `AGENT_SDK_DEMO_ENTITY_TYPE_ID` | `agent` *(mock only)* | `stiggContext.agentType.entityTypeId` — the entity type agents are provisioned as |
|
|
882
|
+
| `AGENT_SDK_DEMO_ATTRIBUTION_KEY` | `agentId` *(mock only)* | `stiggContext.agentType.attributionKey` — the key that type identifies its agents under. **Both or neither**: the SDK defaults neither half, because a guessed key resolves no entity and the spend lands on the customer looking exactly like an ungoverned call |
|
|
883
|
+
| `AGENT_SDK_OFFLINE` | empty | Set to `1` to force the mocks even when keys are present |
|
|
884
|
+
| `AGENT_SDK_DEMO_MOCK_GOVERNANCE_FAIL` | empty | Mock only: make the runtime check return `503` |
|
|
885
|
+
|
|
886
|
+
Install dependencies from the **repo root**:
|
|
887
|
+
|
|
888
|
+
```bash
|
|
889
|
+
corepack enable
|
|
890
|
+
yarn install
|
|
891
|
+
```
|
|
892
|
+
|
|
893
|
+
## 6. Run the examples
|
|
894
|
+
|
|
895
|
+
All commands work from anywhere in the repo, and all of them run with no keys at all — except
|
|
896
|
+
`example-client-typesafe`, whose vendor the built-in mock does not speak. That one still needs no
|
|
897
|
+
Stigg account.
|
|
898
|
+
|
|
899
|
+
| Command | Runs | What it shows |
|
|
900
|
+
| --------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
901
|
+
| `example-client-anthropic` | [`client/anthropic.ts`](./examples/client/anthropic.ts) | A governed chat. One `wrap()` gates and meters every turn |
|
|
902
|
+
| `example-client-openai` | [`client/openai.ts`](./examples/client/openai.ts) | The same script, a different vendor — governance is not vendor-specific work |
|
|
903
|
+
| `example-client-gemini` | [`client/gemini.ts`](./examples/client/gemini.ts) | The same script again, on a vendor that counts in its own names and streams running totals |
|
|
904
|
+
| `example-client-openrouter` | [`client/openrouter.ts`](./examples/client/openrouter.ts) | `openrouterRecipe()` billing the credits actually charged instead of a token count |
|
|
905
|
+
| `example-client-litellm` | [`client/litellm.ts`](./examples/client/litellm.ts) | The same script against a self-hosted proxy. Streams, so it meters tokens — see the gateway example for why |
|
|
906
|
+
| `example-client-typesafe` | [`client/typesafe.ts`](./examples/client/typesafe.ts) | A non-chat client: typed questions, batched or one per call. **Needs a real TypeSafe key** |
|
|
907
|
+
| `example-two-agents` | [`client/two-agents.ts`](./examples/client/two-agents.ts) | Two agents, one team: a sibling denied by its own cap, then the survivor denied by the team above it |
|
|
908
|
+
| `example-gateway-openrouter`| [`gateway/openrouter.ts`](./examples/gateway/openrouter.ts) | The two gateway topologies: calling one gates, operating one only ingests. `example-gateway` runs this one |
|
|
909
|
+
| `example-gateway-litellm` | [`gateway/litellm.ts`](./examples/gateway/litellm.ts) | The same two topologies when the cost is in a header: real spend, then tokens, from one request |
|
|
910
|
+
| `example-setup` | [`shared/setup-governance.ts`](./examples/shared/setup-governance.ts) | Seeds a customer, a pool and per-entity budgets. Idempotent |
|
|
911
|
+
|
|
912
|
+
`example-client` is an alias for the Anthropic one. Prefix any of them with `AGENT_SDK_OFFLINE=1`
|
|
913
|
+
to force the mocks even when keys are present.
|
|
914
|
+
|
|
915
|
+
### Layer 1 — client abstraction (interactive chat)
|
|
916
|
+
|
|
917
|
+
An interactive, multi-turn chat, governed end to end, against any of five vendors. Type `exit`,
|
|
918
|
+
`quit`, an empty line, or press **Ctrl-D** / **Ctrl-C** to end it.
|
|
919
|
+
|
|
920
|
+
```bash
|
|
921
|
+
yarn workspace @stigg/agent-sdk-js run example-client-anthropic # also: example-client
|
|
922
|
+
yarn workspace @stigg/agent-sdk-js run example-client-openai
|
|
923
|
+
yarn workspace @stigg/agent-sdk-js run example-client-gemini
|
|
924
|
+
yarn workspace @stigg/agent-sdk-js run example-client-openrouter
|
|
925
|
+
yarn workspace @stigg/agent-sdk-js run example-client-litellm
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
All five run [the same script](./examples/client/core/chat.ts) — same governance wiring, same
|
|
929
|
+
output, not one line of it vendor-specific. What differs is a helper per vendor
|
|
930
|
+
([anthropic](./examples/client/anthropic.ts), [openai](./examples/client/openai.ts),
|
|
931
|
+
[gemini](./examples/client/gemini.ts), [openrouter](./examples/client/openrouter.ts),
|
|
932
|
+
[litellm](./examples/client/litellm.ts)) holding the four things that genuinely differ:
|
|
933
|
+
which key to read, how to build the client, how to stream a turn, and where usage sits on the
|
|
934
|
+
response. That is the same split the SDK makes internally, for the same reason.
|
|
935
|
+
|
|
936
|
+
All five report the names their own vendor used, which is why the ingest lines differ between
|
|
937
|
+
them: Anthropic states `input_tokens`/`output_tokens`, OpenAI states `prompt_tokens`/
|
|
938
|
+
`completion_tokens`, and Gemini states `promptTokenCount`/`candidatesTokenCount` — plus a
|
|
939
|
+
`thoughtsTokenCount` that is billed as output and counted beside the candidates rather than
|
|
940
|
+
inside them. Run `example-gateway-litellm` to watch a gateway add `cost` beside them.
|
|
941
|
+
|
|
942
|
+
```text
|
|
943
|
+
[governance] stigg=in-process mock customer=customer-acme currency=credits
|
|
944
|
+
[stigg] mode=governance check=rest (node-sdk when unattributed) report=node-sdk provisioning=rest (governance enabled)
|
|
945
|
+
[stigg] agent agent-support-bot-anthropic registered under customer customer-acme
|
|
946
|
+
|
|
947
|
+
you> what is metering?
|
|
948
|
+
[governance] check hasAccess=true remaining=5 source=rest
|
|
949
|
+
assistant> Metering is the practice of recording how much of a service ...
|
|
950
|
+
[usage:turn 1] input=12 output=46 cache_read=3 cache_creation=0
|
|
951
|
+
[governance] ingest input_tokens=12 cache_read_input_tokens=3 output_tokens=46 key=9d63...:1 ok=true credits=0.94
|
|
952
|
+
|
|
953
|
+
...
|
|
954
|
+
|
|
955
|
+
you> one more
|
|
956
|
+
[governance] check hasAccess=false remaining=0.5742 deniedBy=agent-support-bot-anthropic reason=BudgetExceeded remedy=raise-budget source=rest
|
|
957
|
+
Budget exhausted for agent-support-bot-anthropic. Ask whoever set this budget to raise it, or wait for the next reset.
|
|
958
|
+
Raise the budget in another shell, then just keep typing - the next message re-checks:
|
|
959
|
+
yarn workspace @stigg/agent-sdk-js run example-setup --agent-limit 6
|
|
960
|
+
```
|
|
961
|
+
|
|
962
|
+
The chat stays open after a denial. Run the suggested command in another shell and keep typing:
|
|
963
|
+
because denials are never cached, the next message goes through. That is the whole point of
|
|
964
|
+
making a refusal a commercial event rather than an error.
|
|
965
|
+
|
|
966
|
+
Every check reads `source=rest`, and `remaining` falls as the ingests land. This is an attributed
|
|
967
|
+
check — the agent travels as a dimension, so it resolves an entity and only the governance API can
|
|
968
|
+
answer it. Nothing is served from a decision cache, which is why the denial arrives on the first
|
|
969
|
+
call that actually exceeds the budget rather than one window later.
|
|
970
|
+
|
|
971
|
+
### Layer 2 — gateways, in both directions
|
|
972
|
+
|
|
973
|
+
The same prompt through OpenRouter three times: wrapped with `openrouterRecipe()` (non-streaming,
|
|
974
|
+
then streaming), then once more down the header path, so the two topologies sit side by side.
|
|
975
|
+
|
|
976
|
+
```bash
|
|
977
|
+
yarn workspace @stigg/agent-sdk-js run example-gateway
|
|
978
|
+
```
|
|
979
|
+
|
|
980
|
+
```text
|
|
981
|
+
--- wrapped, non-streaming ---
|
|
982
|
+
requested model : anthropic/claude-opus-5
|
|
983
|
+
served model : anthropic/claude-sonnet-5 <- routing drift
|
|
984
|
+
cost (credits) : 0.00108
|
|
985
|
+
prompt tokens : 21 (cached: 7)
|
|
986
|
+
completion tok. : 39 (reasoning: 4)
|
|
987
|
+
[governance] hasAccess=true remaining=25000 source=rest
|
|
988
|
+
[governance] ingest prompt_tokens=21 completion_tokens=39 cached_tokens=7 cost=1080 key=gen-mock... ok=true
|
|
989
|
+
|
|
990
|
+
--- header path, non-streaming ---
|
|
991
|
+
...
|
|
992
|
+
[governance] proxy ingest prompt_tokens=21 completion_tokens=39 cached_tokens=7 key=gen-mock... ok=true
|
|
993
|
+
(ingest-only: no check on this path, and tokens not cost - the mapper cannot see usage.cost)
|
|
994
|
+
```
|
|
995
|
+
|
|
996
|
+
The contrast is the whole point. Both report the same three token counters; only the wrapped call
|
|
997
|
+
also reports **`cost=1080`** — 0.00108 credits in micro-credits, what the gateway really charged —
|
|
998
|
+
attributed to the model that actually served. The header path carries a usage report rather than a
|
|
999
|
+
price, so the one feature that says what the call cost in money is simply missing from it. Same
|
|
1000
|
+
request, same seams; the difference is a feature, not a denomination.
|
|
1001
|
+
|
|
1002
|
+
If the primary model is down, rate-limited, or refuses, OpenRouter serves a fallback slug instead
|
|
1003
|
+
and `served model` differs — flagged as `<- routing drift`, which silently changes COGS. The
|
|
1004
|
+
recipe puts that slug on the ingest as a `model` dimension, so the drift is visible in Stigg and
|
|
1005
|
+
not just in the log.
|
|
1006
|
+
|
|
1007
|
+
The `cost` feature is denominated by the recipe: `openrouterRecipe()` reports micro-credits by
|
|
1008
|
+
default, because OpenRouter charges fractional credits and Stigg ingests whole units. Set
|
|
1009
|
+
`unitsPerCredit` to match however the Stigg feature is defined. When a response carries no cost at
|
|
1010
|
+
all — BYOK, and a few upstreams — the `cost` key is simply absent; the token metrics were never
|
|
1011
|
+
conditional on it.
|
|
1012
|
+
|
|
1013
|
+
#### When the price is in a header
|
|
1014
|
+
|
|
1015
|
+
LiteLLM makes the same comparison and splits the wrapped call in two, because for this gateway
|
|
1016
|
+
_how_ you called it decides whether the price is knowable at all:
|
|
1017
|
+
|
|
1018
|
+
```bash
|
|
1019
|
+
yarn workspace @stigg/agent-sdk-js run example-gateway-litellm
|
|
1020
|
+
```
|
|
1021
|
+
|
|
1022
|
+
```text
|
|
1023
|
+
--- wrapped, non-streaming ---
|
|
1024
|
+
requested group : gpt-5
|
|
1025
|
+
served model : gpt-5-2025-08-07 <- routing drift
|
|
1026
|
+
cost (USD) : 0.001005
|
|
1027
|
+
[governance] ingest prompt_tokens=21 completion_tokens=36 cached_tokens=7 cost=1005 key=chatcmpl-... ok=true
|
|
1028
|
+
|
|
1029
|
+
--- wrapped, streaming ---
|
|
1030
|
+
cost (USD) : (not reported - the header precedes the body)
|
|
1031
|
+
[governance] ingest prompt_tokens=21 completion_tokens=36 cached_tokens=7 key=chatcmpl-... ok=true
|
|
1032
|
+
|
|
1033
|
+
--- header path, non-streaming ---
|
|
1034
|
+
[governance] proxy ingest prompt_tokens=21 completion_tokens=36 cached_tokens=7 key=chatcmpl-... ok=true
|
|
1035
|
+
(ingest-only: no check on this path, and tokens not cost - what crosses the boundary is a usage
|
|
1036
|
+
report, so the price the proxy computed has nowhere to go)
|
|
1037
|
+
```
|
|
1038
|
+
|
|
1039
|
+
Same proxy, same recipe, same prompt. The tokens are identical in all three; what differs is
|
|
1040
|
+
whether `cost` is there at all. The proxy knew the price every time — it computed it — but a
|
|
1041
|
+
header is flushed before generation starts, so the streamed call has nothing to read
|
|
1042
|
+
([BerriAI/litellm#12689](https://github.com/BerriAI/litellm/issues/12689)), and the ingest-only
|
|
1043
|
+
boundary carries a usage report rather than a price. Gating is unaffected throughout: both wrapped
|
|
1044
|
+
legs are refused before the request leaves, whatever they end up reporting.
|
|
1045
|
+
|
|
1046
|
+
So if you budget in spend against LiteLLM, price the `cost` feature and know that streamed turns
|
|
1047
|
+
will not contribute to it — the token features still will. `litellmRecipe()` reports micro-dollars
|
|
1048
|
+
by default; set `unitsPerDollar` to match however the Stigg feature is defined.
|
|
1049
|
+
|
|
1050
|
+
### Seeing the fail-closed path
|
|
1051
|
+
|
|
1052
|
+
The governance check is fail-closed server-side: it returns `503` rather than silently allowing.
|
|
1053
|
+
|
|
1054
|
+
```bash
|
|
1055
|
+
AGENT_SDK_OFFLINE=1 AGENT_SDK_DEMO_MOCK_GOVERNANCE_FAIL=1 \
|
|
1056
|
+
yarn workspace @stigg/agent-sdk-js run example-client-anthropic
|
|
1057
|
+
# [stigg] check failed: check: 503 governance cache unavailable
|
|
1058
|
+
# [governance] check hasAccess=false reason=EngineError remedy=retry source=policy
|
|
1059
|
+
|
|
1060
|
+
# The explicit opt-in - refuse the call when the check itself could not be made. An SDK option,
|
|
1061
|
+
# not an env var, and overridable per call through `stiggContext`:
|
|
1062
|
+
# new StiggAgentSDK(apiKey, baseUrl, { checkErrorAction: 'deny' })
|
|
1063
|
+
```
|
|
1064
|
+
|
|
1065
|
+
`allow` is the default: a soft limit, so an unreachable Stigg does not take the application down
|
|
1066
|
+
with it. `deny` mirrors the server's own fail-closed behaviour and is a decision worth making
|
|
1067
|
+
deliberately — "never break the customer's run" and "never spend past a budget" are not the same
|
|
1068
|
+
promise, and only you know which one this call is for.
|
|
1069
|
+
|
|
1070
|
+
## 7. Typecheck, lint and test
|
|
1071
|
+
|
|
1072
|
+
```bash
|
|
1073
|
+
yarn workspace @stigg/agent-sdk-js run build # typecheck, then emit dist/ - what gets published
|
|
1074
|
+
yarn workspace @stigg/agent-sdk-js run lint
|
|
1075
|
+
yarn workspace @stigg/agent-sdk-js run test # node:test via tsx; no network, literal fakes
|
|
1076
|
+
```
|
|
1077
|
+
|
|
1078
|
+
`build` is two passes over two configs, and the split is the point. `tsconfig.json` is the
|
|
1079
|
+
typechecker's view: it covers `src`, `examples`, `test` and `testing`, and emits nothing.
|
|
1080
|
+
`tsconfig.build.json` compiles the two entry points - `src/index.ts` and `src/internal.ts` - and
|
|
1081
|
+
whatever they import, so only `src/` reaches `dist/`. It clears `dist/` first, because a module
|
|
1082
|
+
that was renamed or deleted would otherwise sit there and ship.
|
|
1083
|
+
|
|
1084
|
+
Either pass resolves workspace dependencies to their built `.d.ts` (see `paths` in
|
|
1085
|
+
`tsconfig.json`) rather than to their source, which is compiled with different flags; Nx builds
|
|
1086
|
+
them first.
|
|
1087
|
+
|
|
1088
|
+
## 8. Troubleshooting
|
|
1089
|
+
|
|
1090
|
+
| Symptom | Cause and fix |
|
|
1091
|
+
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1092
|
+
| `403 GovernanceNotEnabled` | The account has no governance. Checks and usage still work; `register()` throws, because there is nowhere to create the agent entity. Contact Stigg to enable it. |
|
|
1093
|
+
| `No STIGG_API_KEY` | Set it to a server key for the environment in `STIGG_BASE_URL`, or leave it blank to run on the built-in mock. |
|
|
1094
|
+
| `503` and every turn denied | The governance cache is unavailable and `checkErrorAction` is `deny`. The default is `allow`, so this is a policy that was asked for; drop it to keep running through the outage, or relax a single call with `stiggContext: { checkErrorAction: 'allow' }`. |
|
|
1095
|
+
| `usage value must be a whole number` | Stigg rejects fractional usage under `'sync'`. Raise the unit size in your `usageMapper` — `creditsPerTokens(id, n)` — or map to a coarser unit. |
|
|
1096
|
+
| `... is backed by a raw-events meter` | A `sync` run reported to a feature only a meter can fill. Report that run with `ingest: 'async'` (the default), or point it at a feature whose value your application computes. |
|
|
1097
|
+
| Ingest reports `ok=true` but usage stays 0 | Under `async` an acknowledgement is all the event API returns. Check a raw-events meter filters on the event name (see the event table above); against a **local** Stigg, also that the `ingest-usage-stream-processor` and `calculate-usage-stream-processor` lambdas are running. `ingest: 'sync'` answers with a balance and will say so instead. |
|
|
1098
|
+
| Still denied right after a top-up | Budgets reset per cadence window, so a spent agent stays spent. Re-run `example-setup --agent-limit N` with a higher limit, or use a new `AGENT_SDK_DEMO_AGENT_ID`. |
|
|
1099
|
+
| Denial names the customer, not the agent | Customer-level mode (no governance), or the context named no `agentId` and carried no attribution dimensions of its own. |
|
|
1100
|
+
| Denial names an agent you did not pass | A `dimensions` key resolves an entity that already exists in the account. The agent belongs in `stiggContext.agentId`; `dimensions` gates on whatever it names, registered here or not. |
|
|
1101
|
+
| `401` / `authentication_error` | The key is present but wrong. Clear it from `.env` to fall back to the offline mock, or fix it. |
|
|
1102
|
+
| `400 credit balance is too low` (Anthropic) | Add credits under Billing, or set `AGENT_SDK_OFFLINE=1` to run on the mock. |
|
|
1103
|
+
| `402` / insufficient credits (OpenRouter) | Add credits under Settings → Credits, or set `AGENT_SDK_OFFLINE=1` to run on the mock. |
|
|
1104
|
+
| `400` naming a model (OpenRouter) | A slug in `AGENT_SDK_OPENROUTER_MODEL` / `AGENT_SDK_OPENROUTER_FALLBACK_MODELS` does not exist. Check <https://openrouter.ai/models>. |
|
|
1105
|
+
| `ECONNREFUSED` on port 4000 (LiteLLM) | No proxy is running there. Start one (`litellm --model gpt-4o --port 4000`), fix `AGENT_SDK_LITELLM_BASE_URL`, or clear `AGENT_SDK_LITELLM_API_KEY` to fall back to the mock. |
|
|
1106
|
+
| `400` / unknown model group (LiteLLM) | `AGENT_SDK_LITELLM_MODEL` must name a group from the proxy's own config, not a provider slug. List them with `curl $AGENT_SDK_LITELLM_BASE_URL/v1/models`. |
|
|
1107
|
+
| LiteLLM turns bill `units=1`, not spend | Expected on a streamed call: `x-litellm-response-cost` is flushed before the body, so there is no cost to read. Call non-streaming to bill real spend. |
|
|
1108
|
+
| `Cannot find module` | Run `yarn install` from the repo root, not from this directory. |
|
|
1109
|
+
| Chat prints nothing and exits | Input is not a TTY (piped input hits end-of-file). Try `printf 'hi\nexit\n' \| yarn workspace @stigg/agent-sdk-js run example-client-anthropic`. |
|
|
1110
|
+
| A CLI hangs after the last line | The Node SDK keeps a background cache alive. Call `gov.flush({ close: true })`, as the examples do. |
|
|
1111
|
+
|
|
1112
|
+
## 9. Layout
|
|
1113
|
+
|
|
1114
|
+
```
|
|
1115
|
+
src/
|
|
1116
|
+
index.ts the whole public surface, one import
|
|
1117
|
+
stigg-agent-sdk.ts the composition root: the one file that knows both sides
|
|
1118
|
+
types.ts the vocabulary, and every call's argument list
|
|
1119
|
+
config.ts errors.ts logger.ts validated per-environment config, flat errors, structural logger
|
|
1120
|
+
decision-text.ts headers.ts how a decision reads; the cross-process attribution convention
|
|
1121
|
+
engine/ engine.ts decision.ts the four-operation seam; chains -> decision (pure)
|
|
1122
|
+
node-sdk-engine.ts the only file importing @stigg/node-server-sdk
|
|
1123
|
+
rest-governance-engine.ts the only file importing @stigg/typescript
|
|
1124
|
+
composite-engine.ts create-engine.ts routing per operation, and who picked what
|
|
1125
|
+
core/ run-handle.ts the handle every binding consumes
|
|
1126
|
+
bindings/ client/core/ the recipe contract, the registry, the wrapper
|
|
1127
|
+
client/providers/ client/gateways/ a model provider, or a gateway you call
|
|
1128
|
+
proxy/ a gateway you operate: headers in, ingest only
|
|
1129
|
+
shared/ usage shapes and metric tables both halves read
|
|
1130
|
+
|
|
1131
|
+
docs/ instances.md what exists at runtime, and what is shared
|
|
1132
|
+
|
|
1133
|
+
testing/ mock-stigg-server.ts the Stigg stand-in the examples and the tests share
|
|
1134
|
+
|
|
1135
|
+
examples/
|
|
1136
|
+
shared/ demo-env, the two mocks, setup-governance scaffolding every example uses
|
|
1137
|
+
client/ core/{chat,vendor} + anthropic|openai|gemini|openrouter|litellm|typesafe a helper per vendor
|
|
1138
|
+
two-agents two siblings, one team budget between them
|
|
1139
|
+
gateway/ core/{gateway,vendor,proxy} + openrouter|litellm calling one; operating one on a real socket
|
|
1140
|
+
```
|
|
1141
|
+
|
|
1142
|
+
The examples mirror the bindings directory deliberately — a generic core plus a thin per-vendor
|
|
1143
|
+
file is the shape the problem has, at both layers.
|
|
1144
|
+
|
|
1145
|
+
Core never imports a binding or a concrete engine, and no binding imports another — only
|
|
1146
|
+
`stigg-agent-sdk.ts` knows both, which is what lets `wrap()` live on the SDK object
|
|
1147
|
+
without core learning a vendor's name. Adding a provider is a recipe file; adding an engine is a
|
|
1148
|
+
leaf file plus a branch in the factory.
|