@stigg/agent-sdk-js 1.1.0 → 2.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.
Files changed (37) hide show
  1. package/README.md +135 -43
  2. package/dist/bindings/client/gateways/bedrock/bedrock-recipe.d.ts +17 -0
  3. package/dist/bindings/client/gateways/bedrock/bedrock-recipe.js +134 -0
  4. package/dist/bindings/client/gateways/bedrock/types.d.ts +13 -0
  5. package/dist/bindings/client/gateways/bedrock/types.js +2 -0
  6. package/dist/bindings/client/gateways/openrouter/openrouter-recipe.d.ts +5 -0
  7. package/dist/bindings/client/gateways/openrouter/openrouter-recipe.js +137 -34
  8. package/dist/bindings/client/gateways/openrouter/sdk-usage.d.ts +37 -0
  9. package/dist/bindings/client/gateways/openrouter/sdk-usage.js +63 -0
  10. package/dist/bindings/client/gateways/openrouter/types.d.ts +49 -0
  11. package/dist/bindings/client/providers/anthropic/anthropic-recipe.d.ts +5 -1
  12. package/dist/bindings/client/providers/anthropic/anthropic-recipe.js +103 -13
  13. package/dist/bindings/shared/token-usage.d.ts +30 -1
  14. package/dist/bindings/shared/token-usage.js +58 -3
  15. package/dist/bindings/shared/types.d.ts +16 -0
  16. package/dist/bindings/shared/weighted-metrics.js +5 -2
  17. package/dist/config.d.ts +26 -17
  18. package/dist/config.js +29 -27
  19. package/dist/core/context.d.ts +21 -12
  20. package/dist/core/context.js +76 -9
  21. package/dist/core/session.d.ts +38 -9
  22. package/dist/core/session.js +57 -12
  23. package/dist/decision-text.d.ts +3 -3
  24. package/dist/engine/decision.d.ts +11 -6
  25. package/dist/engine/decision.js +9 -2
  26. package/dist/engine/report.js +4 -1
  27. package/dist/errors.d.ts +14 -6
  28. package/dist/errors.js +13 -3
  29. package/dist/headers.d.ts +13 -0
  30. package/dist/headers.js +20 -1
  31. package/dist/index.d.ts +8 -5
  32. package/dist/index.js +8 -2
  33. package/dist/stigg-agent-sdk.d.ts +29 -13
  34. package/dist/stigg-agent-sdk.js +103 -56
  35. package/dist/types.d.ts +124 -35
  36. package/dist/types.js +66 -16
  37. package/package.json +5 -2
package/README.md CHANGED
@@ -33,6 +33,11 @@ is a `stiggContext`, given once at `wrap` as the default for that client and aga
33
33
  single call's own parameters to override it. One wrapped client therefore serves every customer
34
34
  and every agent, which is what a multi-tenant server actually needs.
35
35
 
36
+ **No subscription required.** A customer's credit balance can be checked and drawn with no
37
+ subscription in place at all, so the shortest path from nothing to a working integration is a
38
+ credit currency and a `customerId` — no plan, no entitlement wiring, no catalog modelling first.
39
+ Model the pricing once the metering is real.
40
+
36
41
  `check` happens when the context names an `entitlement`. Omit it and the call meters only: the
37
42
  request still goes out, and it is still billed.
38
43
 
@@ -59,11 +64,11 @@ request (a budget, a parent) is the only thing that fails without it.
59
64
  ```ts
60
65
  import { StiggAgentSDK } from '@stigg/agent-sdk-js';
61
66
 
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
+ // One per environment, and everything it takes is one object with a working default. The key can
68
+ // be left out entirely to read STIGG_SERVER_API_KEY, or replaced by a `baseUrl` that needs none -
69
+ // a local stand-in, or a self-hosted Stigg:
70
+ // new StiggAgentSDK({ apiKey, baseUrl: 'https://stigg.internal', ingest: 'sync' })
71
+ // A bare key is the shorthand for the common case.
67
72
  const stigg = new StiggAgentSDK(apiKey);
68
73
 
69
74
  // Provisioning the agent: a write, so it is its own call. A *bare* registration is remembered per
@@ -72,11 +77,18 @@ const stigg = new StiggAgentSDK(apiKey);
72
77
  await stigg.register({
73
78
  customerId: 'customer-acme',
74
79
  agentId: 'agent-support-bot',
80
+ // The entity: what the agent IS. `register` is the one call that creates it, so it is the one
81
+ // call that needs the type.
75
82
  entityTypeId: 'agent',
76
- entitlement: { currencyId: 'credits' },
77
- parentId: 'team-eng',
78
- usageLimitOnCreate: 5,
79
- cadence: 'P1M',
83
+ // The assignment: what it may SPEND, and where it sits. One object, because it is one write -
84
+ // over (entity x entitlement), which is why the entitlement is required inside it.
85
+ budget: {
86
+ entitlement: { currencyId: 'credits' },
87
+ parentId: 'team-eng',
88
+ limit: 5,
89
+ apply: 'on-create',
90
+ cadence: 'P1M',
91
+ },
80
92
  });
81
93
 
82
94
  // Governs the client and hands back a COPY whose calls are checked and metered. The client you
@@ -90,11 +102,11 @@ const govClient = stigg.wrap(new Anthropic(), {
90
102
  // Naming an entitlement is the enforcement switch. Omit it and the calls meter without
91
103
  // gating - every call still goes out, and nothing is ever refused.
92
104
  entitlement: { currencyId: 'credits' },
93
- // What the calls are attributed to. The type is required with it - a guessed attribution key
105
+ // What the calls are attributed to. The attribution key is required with it - a guessed one
94
106
  // resolves no entity, and the spend would land on the customer looking exactly like a call
95
107
  // that was never governed. The parent needs no key: governance walks the chain up from here.
96
108
  agentId: 'agent-support-bot',
97
- agentType: { entityTypeId: 'agent', attributionKey: 'agentId' },
109
+ attributionKey: 'agentId',
98
110
  // Optional extra attribution, sent verbatim on every check and ingest.
99
111
  // dimensions: { tenantId: 'acme-eu' },
100
112
  },
@@ -238,7 +250,9 @@ that vendor's API would have guessed — so these are what your meters filter on
238
250
  | OpenAI (chat) | `openai.chat.completions.create` | `prompt_tokens`, `completion_tokens`, `cached_tokens`, `total_tokens` |
239
251
  | OpenAI (responses) | `openai.responses.create` | `input_tokens`, `output_tokens`, `cached_tokens`, `total_tokens` |
240
252
  | OpenRouter | `openrouter.chat.completions.create`, `openrouter.responses.create` | the OpenAI set, plus `cost` |
253
+ | OpenRouter — its own SDK | `openrouter.chat.send`, `openrouter.responses.send` | the same set — `@openrouter/sdk` camelCases its counters and the recipe reads them back under the wire's names, so one meter covers either client |
241
254
  | LiteLLM | `litellm.chat.completions.create`, `litellm.responses.create` | the OpenAI set, plus `cost` |
255
+ | Bedrock | `bedrock.send` | `inputTokens`, `outputTokens`, `cacheReadInputTokens`, `cacheWriteInputTokens`, `totalTokens` — and no `cost`, because Bedrock states none |
242
256
  | TypeSafe | `typesafe.systemOne` | `input_tokens`, `output_tokens`, `total_tokens` |
243
257
  | Gemini | `gemini.models.generateContent`, `gemini.models.generateContentStream` | `promptTokenCount`, `candidatesTokenCount`, `cachedContentTokenCount`, `thoughtsTokenCount`, `toolUsePromptTokenCount`, `totalTokenCount` |
244
258
  | Gemini — a chat | `gemini.chats.sendMessage`, `gemini.chats.sendMessageStream` | the same five |
@@ -266,6 +280,11 @@ A meter to put behind one of them, in Stigg's own terms:
266
280
  > event API answers with an acknowledgement, not a balance, so nothing downstream can notice on its
267
281
  > own — configure the meter first, or run with `ingest: 'sync'`, which does answer.
268
282
 
283
+ > **None of this applies to a metric named after a credit currency.** That draws the wallet
284
+ > directly and needs no event name and no meter — see [the three ingest paths](#the-engine-sdk-first-rest-to-complete)
285
+ > below. If you are using `unitsPerTokens(currencyId, …)`, that is the path you are on, and the
286
+ > table above is about the other one.
287
+
269
288
  #### Pricing them yourself
270
289
 
271
290
  `weightedMetrics` turns a price-per-metric table into the mapper:
@@ -292,10 +311,25 @@ optional — an unpriced one is free, and a table that prices nothing meters not
292
311
  A weight is the price of **one unit** of that metric, so the example above is half a credit per
293
312
  output token. Pass `{ per: 1000 }` when the numbers read better per thousand.
294
313
 
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.
314
+ It also handles the two things a hand-written mapper has to get right: a turn that measured
315
+ nothing reports nothing rather than a zero, and a counter the response omitted is absent rather
316
+ than zero. For one number without weights, `unitsPerTokens(id, n)` is the short form.
317
+
318
+ Neither rounds. A credit currency takes a fractional amount, so a turn worth a fifth of a unit
319
+ costs a fifth of one — rounding it up would be this SDK's arithmetic on your price. **A feature is
320
+ the exception**: `POST /api/v1/usage` answers `400 Expected integer, received float`, so when the
321
+ target is a feature rather than a currency, pick a unit size that divides or round in your own
322
+ mapper.
323
+
324
+ > **An attributed run is the other exception, and the SDK rounds it up for you.** Per-entity usage
325
+ > is counted in whole units — governance validates its ingest as an integer and increments a Redis
326
+ > counter with `HINCRBY` — so a fractional amount is rejected there *while the wallet still moves*.
327
+ > The agent's budget would stop advancing with nothing in the response to say so.
328
+ >
329
+ > So a run naming an `agentId` rounds its wallet draw up, and logs that it did. A run with no
330
+ > entity attribution reports the fraction as measured. Denominate the currency finely enough that
331
+ > one unit is small, and the overcharge is noise; this goes away when governance counts fractions.
332
+
299
333
 
300
334
  #### Writing the mapper yourself
301
335
 
@@ -502,23 +536,75 @@ Two clients, composed per operation, and the SDK tells you which one it picked:
502
536
  | `ingest` | `@stigg/node-server-sdk` | it has the in-memory cache, and usage reporting is the hot path |
503
537
  | provision / archive | `@stigg/typescript` (REST, `v1-beta`) | entities and assignments exist only on the governance surface |
504
538
 
505
- **Ingest has two APIs, and the context picks one.** They are not variants of one call:
539
+ **Ingest has three paths, not two.** `ingest` picks between the first two — but **the name of the
540
+ metric decides first**, and that is the rule to read before the table:
541
+
542
+ > **If a metric is named after the credit currency the context checks, it draws the wallet
543
+ > directly, in both modes.** No event, no usage report, and no meter involved — `ingest` then only
544
+ > decides whether the call waits for the balance to come back.
545
+
546
+ That is the path `unitsPerTokens(currencyId, …)` puts you on, and it is why switching `async` and
547
+ `sync` can look like it changes nothing: both deduct credits, neither needs a metered feature, and
548
+ the only visible difference is whether `IngestResult.creditsConsumed` is filled in. Metrics named
549
+ after a **feature** take one of the other two columns.
506
550
 
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 |
551
+ | | A metric named after the currency | `ingest: 'async'` — the default | `ingest: 'sync'` |
552
+ | ---------------------- | --------------------------------------- | ----------------------------------- | ---------------------------------------------------- |
553
+ | Call | `consumeCredits` / `consumeCreditsAsync` | `events.report` / `reportEvent` | `usage.report` / `reportUsageBulk` |
554
+ | Latency and rate limit | follows the mode | low latency, much higher limit | higher latency, lower limit |
555
+ | The next check | includes it under `'sync'`; lags under `'async'` | lags while Stigg aggregates | already includes the spend |
556
+ | What you send | a currency id and an amount | the call, as one event | a `featureId` and a whole-number `value`, per metric |
557
+ | Meter type | **none — the wallet is drawn directly** | **Raw events**, configured in Stigg | calculated / incremental |
558
+ | What comes back | a balance under `'sync'`, nothing under `'async'` | an acknowledgement | a credit balance |
515
559
 
516
560
  Async sends **one event for the whole turn**, every metric on its own dimension, because one event
517
561
  feeds any number of meters — fanning out per metric buys nothing and costs a request each. Sync
518
562
  sends one report per metric, batched into a single bulk call.
519
563
 
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.
564
+ There is no event path from a raw event to a currency, which is why the first column exists at all:
565
+ a usage report takes only a `featureId`, so a currency has to be drawn rather than metered.
566
+
567
+ > **`'async'` does not settle the wallet before the next check.** `consumeCreditsAsync` is
568
+ > reconciled into the balance on Stigg's side, and nothing invalidates the entitlement cache in the
569
+ > meantime — so a check made immediately after a draw can still see the balance from before it, and
570
+ > approve a call the wallet could not afford. `'sync'` is what closes that window.
571
+
572
+ **The match is exact.** A metric draws the wallet only when its name *equals* the currency the
573
+ context checks. `unitsPerTokens('data_credit', …)` against `entitlement: { currencyId: 'credits' }`
574
+ is a feature report named `data_credit`, not a draw on `credits` — so it needs a meter like any
575
+ other feature, and silently meters nothing until one exists.
576
+
577
+ **A check asks for one unit by default, so the last call can overshoot.** The budget is allowed
578
+ below zero, and `'async'` adds a reconciliation window on top — calls made while Stigg is still
579
+ turning events into usage all see the same balance. Worst case is therefore one call's cost, plus
580
+ whatever lands in that window.
581
+
582
+ Narrow it per call with the recipe's own estimator, which asks for what the call could cost
583
+ instead of for one unit:
584
+
585
+ ```ts
586
+ import { anthropicRecipe, estimateFromMaxTokens } from '@stigg/agent-sdk-js';
587
+
588
+ stigg.wrap(new Anthropic(), {
589
+ stiggContext: { customerId, entitlement: { currencyId: 'credits' } },
590
+ // `estimate` reads the call's own parameters - here `max_tokens` - and asks for that many
591
+ // units instead of one. The ingest that follows still corrects the balance.
592
+ recipe: {
593
+ ...anthropicRecipe,
594
+ methods: anthropicRecipe.methods.map((method) => ({ ...method, estimate: estimateFromMaxTokens(1_000) })),
595
+ },
596
+ });
597
+ ```
598
+
599
+ It is opt-in on purpose: an estimate that overshoots denies calls the budget could have afforded,
600
+ and the ingest that follows corrects the balance anyway. For a hard cap, pair it with
601
+ `ingest: 'sync'` — `'async'` trades exactness for throughput, so it is not what a hard cap is for.
602
+
603
+ **Which to reach for.** Credits when the unit is money-like, shared across features, pre-purchased
604
+ or topped up — one balance, many consumers. A metered feature when the limit is per-capability,
605
+ belongs to a plan entitlement, or resets on a billing cadence. Both when a plan grants a feature
606
+ allowance and a credit pool covers the overage; name the feature in `usageMapper` and let Stigg's
607
+ consumption mapping convert it, rather than pricing it in code.
522
608
 
523
609
  **Governance is optional.** Not every account has it. When it is missing the factory falls back
524
610
  to customer-level enforcement on the Node SDK alone — every example still runs, denials just name
@@ -764,34 +850,37 @@ team-research (team) usage=0/30
764
850
  the parent team, a funded pool). That is the more realistic split: an admin sets up the account, an
765
851
  application provisions its own agents at runtime. `register()` carries it:
766
852
 
767
- One caveat about the cap, because the two halves can overlap. `usageLimitOnCreate` is exactly that
853
+ One caveat about the cap, because the two halves can overlap. `apply: 'on-create'` is exactly that
768
854
  — on create — so if the seed has already written an assignment for that agent, the application's
769
855
  cap is **not** applied and the seeded one stands. That is the point of the option (an admin who
770
856
  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.
857
+ mean `--agent-limit` on the seed wins over the number below. Leave `apply` at its default
858
+ (`'always'`) if the application is meant to own it.
773
859
 
774
860
  ```ts
775
861
  await stigg.register({
776
862
  customerId: 'customer-acme',
777
863
  agentId: 'agent-support-bot-anthropic',
778
864
  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',
865
+ budget: {
866
+ // A budget is written over (entity x entitlement), so the entitlement is required here: a
867
+ // cap has nowhere to land without one.
868
+ entitlement: { currencyId: 'credits' },
869
+ parentId: 'team-research',
870
+ limit: 5,
871
+ cadence: 'P1M',
872
+ },
785
873
  });
786
874
  ```
787
875
 
788
876
  A cap is **not** a field on the entity. An entity is just a node — id, type, display name,
789
877
  metadata — and a limit is an _assignment_ over `(entity × capability)`. So that one call is two
790
878
  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.
879
+ `parentId` rides the assignment rather than the entity — and sits inside `budget` for the same
880
+ reason: the hierarchy is per-capability, so one agent can sit under different parents for
881
+ different currencies.
793
882
 
794
- `usageLimit` is tri-state — a number caps it, `null` tracks without blocking (the observe-only
883
+ `budget.limit` is tri-state — a number caps it, `null` tracks without blocking (the observe-only
795
884
  rollout), and omitting it leaves whatever is already assigned untouched. Governance requires both
796
885
  it and `cadence` when it creates a budget, which is why both are passed above.
797
886
 
@@ -834,6 +923,8 @@ there (or exporting it in your shell) overrides the fixture without editing `dem
834
923
  | `AGENT_SDK_GEMINI_MODEL` | `gemini-3.8-flash` | Model for the Gemini client example |
835
924
  | `AGENT_SDK_OPENROUTER_MODEL` | `anthropic/claude-opus-5` | Primary model requested from the gateway |
836
925
  | `AGENT_SDK_OPENROUTER_FALLBACK_MODELS` | `anthropic/claude-sonnet-5,openai/gpt-5` | Comma-separated fallbacks tried in order |
926
+ | `AGENT_SDK_BEDROCK_REGION` | `us-east-1` (falls back to `AWS_REGION`) | Region the Bedrock model is called in. There is no Bedrock key variable: AWS authenticates through its own credential chain, and the example asks that chain rather than looking for one |
927
+ | `AGENT_SDK_BEDROCK_MODEL` | `anthropic.claude-sonnet-4-5-20250929-v1:0` | Bedrock model id. Names one model exactly — Bedrock does not route, so there is no fallback list |
837
928
  | `AGENT_SDK_LITELLM_API_KEY` | — (falls back to `LITELLM_API_KEY`) | Layer 2. Blank ⇒ the offline mock |
838
929
  | `AGENT_SDK_LITELLM_BASE_URL` | `http://localhost:4000` | Your own proxy — self-hosted, so there is no public default |
839
930
  | `AGENT_SDK_LITELLM_MODEL` | `gpt-5` | A model **group** from the proxy's config, not a provider slug |
@@ -847,7 +938,7 @@ change it to play with an example, it is a fixture.** A real application decides
847
938
  in code — what it bills against is part of its pricing, not of its deployment.
848
939
 
849
940
  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
941
+ `new StiggAgentSDK({ apiKey, baseUrl, cache, client, timeoutMs, logger, onError })` for the
851
942
  environment, and `wrap(client, { stiggContext })` — overridable inside any call's own parameters —
852
943
  for what a call is governed by.
853
944
 
@@ -878,8 +969,8 @@ The defaults are mock-only on purpose: against an environment you can write to,
878
969
  | `AGENT_SDK_DEMO_CUSTOMER_ID` | `customer-acme` | **Error.** Every governance call is scoped to a tenant, and the examples will not invent one |
879
970
  | `AGENT_SDK_DEMO_TEAM_ID` | `team-research` | The agents are **roots** — no ancestor budget binds them. `two-agents` needs one and says so |
880
971
  | `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 |
972
+ | `AGENT_SDK_DEMO_ENTITY_TYPE_ID` | `agent` *(mock only)* | `register`'s own `entityTypeId` — the entity type agents are provisioned as |
973
+ | `AGENT_SDK_DEMO_ATTRIBUTION_KEY` | `agentId` *(mock only)* | `stiggContext.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
974
  | `AGENT_SDK_OFFLINE` | empty | Set to `1` to force the mocks even when keys are present |
884
975
  | `AGENT_SDK_DEMO_MOCK_GOVERNANCE_FAIL` | empty | Mock only: make the runtime check return `503` |
885
976
 
@@ -907,6 +998,7 @@ Stigg account.
907
998
  | `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
999
  | `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
1000
  | `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 |
1001
+ | `example-gateway-bedrock` | [`gateway/bedrock.ts`](./examples/gateway/bedrock.ts) | A gateway that states no price: tokens are all there is to meter, and nothing proxies it |
910
1002
  | `example-setup` | [`shared/setup-governance.ts`](./examples/shared/setup-governance.ts) | Seeds a customer, a pool and per-entity budgets. Idempotent |
911
1003
 
912
1004
  `example-client` is an alias for the Anthropic one. Prefix any of them with `AGENT_SDK_OFFLINE=1`
@@ -1059,7 +1151,7 @@ AGENT_SDK_OFFLINE=1 AGENT_SDK_DEMO_MOCK_GOVERNANCE_FAIL=1 \
1059
1151
 
1060
1152
  # The explicit opt-in - refuse the call when the check itself could not be made. An SDK option,
1061
1153
  # not an env var, and overridable per call through `stiggContext`:
1062
- # new StiggAgentSDK(apiKey, baseUrl, { checkErrorAction: 'deny' })
1154
+ # new StiggAgentSDK({ apiKey, baseUrl, checkErrorAction: 'deny' })
1063
1155
  ```
1064
1156
 
1065
1157
  `allow` is the default: a soft limit, so an unreachable Stigg does not take the application down
@@ -1092,7 +1184,7 @@ them first.
1092
1184
  | `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
1185
  | `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
1186
  | `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. |
1187
+ | `usage value must be a whole number to report against a feature` | `POST /api/v1/usage` takes integers. Report against a **credit currency** instead, which takes fractions, or raise the unit size in your `usageMapper` so the division comes out whole. |
1096
1188
  | `... 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
1189
  | 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
1190
  | 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`. |
@@ -0,0 +1,17 @@
1
+ import { BEDROCK_METRICS } from '../../../shared/token-usage';
2
+ import type { BedrockRecipeOptions } from './types';
3
+ import type { ClientRecipe, MetricsFor, MetricsOf } from '../../core/types';
4
+ type BedrockMetric = (typeof BEDROCK_METRICS)[number];
5
+ /**
6
+ * One recipe for every model Bedrock serves.
7
+ *
8
+ * Converse normalizes Anthropic, Nova, Llama and the rest to one request and one response, so the
9
+ * usage block never changes shape - which is the whole reason this can be a single recipe rather
10
+ * than one per upstream vendor.
11
+ */
12
+ export declare function bedrockRecipe(options?: BedrockRecipeOptions): ClientRecipe<BedrockMetric>;
13
+ /** One of the metric names Bedrock reports. */
14
+ export type BedrockMetricName = MetricsOf<ReturnType<typeof bedrockRecipe>>;
15
+ /** What a mapper reads off a Bedrock call: those names, each possibly absent. */
16
+ export type BedrockMetrics = MetricsFor<ReturnType<typeof bedrockRecipe>>;
17
+ export {};
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.bedrockRecipe = bedrockRecipe;
4
+ /**
5
+ * The Bedrock recipe - a gateway whose client is a command bus, and which never states a price.
6
+ *
7
+ * Bedrock is the third shape of "gateway" these bindings cover, and it differs from the other two
8
+ * in both halves:
9
+ *
10
+ * - **The client is not a method tree.** `openai-node` and `@openrouter/sdk` hang a method off a
11
+ * namespace - `chat.completions.create`, `chat.send` - and a recipe names that path. The AWS
12
+ * SDK has exactly one method, `send`, and the operation is the *argument*: `send(new
13
+ * ConverseCommand(...))`. So the gated path is `['send']` and the recipe has to look at what
14
+ * it was handed, because the same method also lists async invocations and fetches guardrails,
15
+ * and gating those would charge a budget for calls that spend nothing.
16
+ *
17
+ * - **There is no cost on the response.** OpenRouter writes the credits it charged into the
18
+ * body; LiteLLM prices every call and puts dollars in a header. Bedrock reports tokens and
19
+ * stops - the price lives in the AWS bill, not in the response - so a run that wants to bill
20
+ * real spend cannot do it here, and one that wants to meter tokens is served exactly as well
21
+ * as anywhere else. `metrics` says so by having no `cost` in it, rather than by reporting a
22
+ * zero that a mapper would happily multiply.
23
+ *
24
+ * There is no served-model dimension either, and that absence is also a fact about Bedrock rather
25
+ * than an omission: it does not route. You name a model id and that model answers, so there is no
26
+ * drift between what was asked for and what served, and nothing for a dimension to tell you.
27
+ */
28
+ const meter_stream_1 = require("../../../shared/meter-stream");
29
+ const token_usage_1 = require("../../../shared/token-usage");
30
+ /** The service a command belongs to, as the AWS SDK's own schema spells it. */
31
+ const BEDROCK_SERVICE = 'com.amazonaws.bedrockruntime';
32
+ /**
33
+ * The two operations that run inference through the Converse API, and so the two worth gating.
34
+ *
35
+ * `InvokeModel` is inference too, and is deliberately not here: its request and response bodies
36
+ * are the *upstream vendor's* - Anthropic's shape for a Claude model, Amazon's for a Nova one - so
37
+ * one reader cannot meter them, and a recipe that gated the call while measuring nothing would
38
+ * report every one of them as a metering failure. An application on `InvokeModel` wants a recipe
39
+ * per upstream; an application on Converse gets one that works for every model Bedrock serves.
40
+ */
41
+ const METERED_OPERATIONS = new Set(['Converse', 'ConverseStream']);
42
+ /**
43
+ * Which AWS operation a command object is for.
44
+ *
45
+ * Read off the SDK's own serialization schema - `[version, service, operation, http]` - rather
46
+ * than from the class name, because a class name is the first thing a bundler rewrites and this
47
+ * decides whether a call is gated at all. The constructor name is the fallback for an SDK version
48
+ * that shapes the schema differently, where being approximately right beats refusing to gate.
49
+ */
50
+ function operationOf(command) {
51
+ const schema = command?.schema;
52
+ if (Array.isArray(schema) && typeof schema[1] === 'string' && typeof schema[2] === 'string') {
53
+ return { service: schema[1], operation: schema[2] };
54
+ }
55
+ const name = command?.constructor?.name;
56
+ return typeof name === 'string' && name.endsWith('Command') ? { operation: name.slice(0, -'Command'.length) } : {};
57
+ }
58
+ /** True for a call this recipe must leave alone - anything that is not Converse inference. */
59
+ function isNotMetered(command) {
60
+ const { service, operation } = operationOf(command);
61
+ if (service !== undefined && service !== BEDROCK_SERVICE)
62
+ return true;
63
+ return !operation || !METERED_OPERATIONS.has(operation);
64
+ }
65
+ /** Tokens from one Converse response, under Bedrock's own names. */
66
+ function measure(usage, requestId) {
67
+ if (!usage)
68
+ return undefined;
69
+ return {
70
+ metrics: (0, token_usage_1.pickMetrics)(usage, token_usage_1.BEDROCK_PATHS),
71
+ usage: (0, token_usage_1.fromBedrockUsage)(usage),
72
+ raw: usage,
73
+ // AWS's own request id, which is stable across the SDK's retries - and the SDK retries by
74
+ // default, so a local counter would bill the same answer more than once.
75
+ idempotencyKey: requestId,
76
+ };
77
+ }
78
+ /**
79
+ * One recipe for every model Bedrock serves.
80
+ *
81
+ * Converse normalizes Anthropic, Nova, Llama and the rest to one request and one response, so the
82
+ * usage block never changes shape - which is the whole reason this can be a single recipe rather
83
+ * than one per upstream vendor.
84
+ */
85
+ function bedrockRecipe(options = {}) {
86
+ const estimate = options.estimate ?? (() => 1);
87
+ return {
88
+ vendor: 'bedrock',
89
+ metrics: token_usage_1.BEDROCK_METRICS,
90
+ totalTokensMetric: 'totalTokens',
91
+ // The client's own configuration names the service, which is what separates this from every
92
+ // other AWS v3 client - they all have `send`, and none of the rest are inference. Data rather
93
+ // than a class name, for the same reason `operationOf` prefers the schema.
94
+ matches: (client) => {
95
+ const config = client?.config;
96
+ if (!config || typeof client?.send !== 'function')
97
+ return false;
98
+ return config.defaultSigningName === 'bedrock' && typeof config.serviceId === 'string';
99
+ },
100
+ methods: [
101
+ {
102
+ path: ['send'],
103
+ estimate,
104
+ // The operation rides in the argument, so this is where a non-inference call is let
105
+ // through ungated rather than charged for.
106
+ passthrough: (command) => isNotMetered(command),
107
+ // A streamed Converse resolves to `{ stream, $metadata }` - the usage is not on the
108
+ // response at all, so this returns nothing and `attachStream` takes it from there.
109
+ extractUsage: (result) => result?.stream ? undefined : measure(result?.usage, result?.$metadata?.requestId),
110
+ /**
111
+ * ConverseStream puts its usage in a `metadata` event at the end, so the stream has to be
112
+ * run to the end to know what the call cost - the same bargain every streaming binding
113
+ * makes.
114
+ *
115
+ * The iterable is `result.stream`, not `result`: AWS hands back an envelope with the
116
+ * stream hanging off it, where the other SDKs hand back the stream itself. Metering the
117
+ * envelope would patch an object nobody iterates and report nothing, with no error.
118
+ */
119
+ attachStream: (result, ingest) => {
120
+ const requestId = result?.$metadata?.requestId;
121
+ let usage;
122
+ (0, meter_stream_1.meterStream)(result?.stream, (event) => {
123
+ if (event?.metadata?.usage)
124
+ usage = event.metadata.usage;
125
+ }, () => {
126
+ const measurement = measure(usage, requestId);
127
+ if (measurement)
128
+ ingest(measurement);
129
+ });
130
+ },
131
+ },
132
+ ],
133
+ };
134
+ }
@@ -0,0 +1,13 @@
1
+ export type BedrockRecipeOptions = {
2
+ /**
3
+ * How much to ask permission for before the call.
4
+ *
5
+ * The default asks for 1 - "is there any budget left at all?" - and lets the ingest afterwards
6
+ * settle the balance, which leaves a gap of about one call's worth of tokens. A run that wants
7
+ * that gap closed knows something this recipe cannot: `inferenceConfig.maxTokens` is the only
8
+ * ceiling Bedrock is given, and whether it is worth asking for depends on how a budget is
9
+ * denominated. Reading it is one line - `(command) => command?.input?.inferenceConfig?.maxTokens
10
+ * ?? 1` - and it is the caller's line to write.
11
+ */
12
+ estimate?: (command: any) => number;
13
+ };
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -6,6 +6,11 @@ type GatewayMetric = (typeof GATEWAY_METRICS)[number];
6
6
  /**
7
7
  * One recipe covers every upstream. OpenRouter normalizes Anthropic, OpenAI and the rest to the
8
8
  * same wire shape, so the response never changes form - `model` is just a field saying who served.
9
+ *
10
+ * It also covers both ways of reaching OpenRouter from Node: the OpenAI SDK pointed at the
11
+ * gateway's base URL, and OpenRouter's own `@openrouter/sdk`. Those are different objects with
12
+ * different method names reporting the same call, and a recipe that governed only the first would
13
+ * meter nothing at all for an application that took OpenRouter's own documented advice.
9
14
  */
10
15
  export declare function openrouterRecipe(options?: OpenRouterRecipeOptions): ClientRecipe<GatewayMetric>;
11
16
  /** One of the metric names OpenRouter reports. */