@bymax-one/nest-ai-tokens 1.1.2 → 1.1.4
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/CHANGELOG.md +148 -3
- package/README.md +120 -32
- package/dist/prisma/index.cjs +37 -2
- package/dist/prisma/index.d.cts +23 -1
- package/dist/prisma/index.d.ts +23 -1
- package/dist/prisma/index.mjs +37 -2
- package/dist/server/index.cjs +81 -30
- package/dist/server/index.d.cts +67 -17
- package/dist/server/index.d.ts +67 -17
- package/dist/server/index.mjs +81 -30
- package/dist/shared/index.cjs +1 -0
- package/dist/shared/index.d.cts +1 -0
- package/dist/shared/index.d.ts +1 -0
- package/dist/shared/index.mjs +1 -0
- package/package.json +2 -7
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,149 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [1.1.4] - 2026-11-01
|
|
12
|
+
|
|
13
|
+
**Apply:** `verifyChain` has a new result, `{ valid: false, status: 'never-enabled' }`, which has no
|
|
14
|
+
`brokenAtRecordId`; handle it wherever you read that field. Enable `ledger.hashChain` before the
|
|
15
|
+
first write: records written earlier are reported as unchained and are not protected.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **Enabling `ledger.hashChain` on a ledger that already has records no longer makes `verifyChain`
|
|
20
|
+
report the oldest record as tampered.** `verifyChain` required every settled record to carry a
|
|
21
|
+
hash, so the first record written before the chain was enabled failed verification and ended the
|
|
22
|
+
walk, and the chained records after it were never checked. It now follows the chain by its links:
|
|
23
|
+
records without a hash are counted in the new `unchainedCount` and reported as not protected, the
|
|
24
|
+
hashed records must start at one genesis with no gap in the links, and a record whose hash was
|
|
25
|
+
removed while its `prevHash` stayed is reported as broken. A ledger with settled records and no
|
|
26
|
+
hashed one returns `{ valid: false, status: 'never-enabled' }`. The result type gains
|
|
27
|
+
`unchainedCount` and an optional `chainStartsAtRecordId`; the new `never-enabled` variant has no
|
|
28
|
+
`brokenAtRecordId`. A `from`/`to` call still checks each record's own hash only. A record that no other
|
|
29
|
+
record names as its predecessor (the newest one, and a hold that settled while a later-created record
|
|
30
|
+
was already hashed) can be deleted without detection, and the chain is unkeyed; both limits are in the README.
|
|
31
|
+
|
|
32
|
+
**Apply:** code that reads `brokenAtRecordId` after `valid === false` must also handle the
|
|
33
|
+
`never-enabled` variant, which has none. Enable the chain before the first write; a rise in
|
|
34
|
+
`unchainedCount` after enabling is worth investigating.
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- The GitHub Release body is now the tag's own `CHANGELOG.md` section instead of the generated
|
|
39
|
+
pull-request list, and the release stops before publishing when the version has no section.
|
|
40
|
+
No change to the package.
|
|
41
|
+
- The release-notes step starts the section only at a line that begins `## [<version>]`, so a
|
|
42
|
+
version token elsewhere in a heading, such as `## [Unreleased] [1.1.1]`, no longer selects the
|
|
43
|
+
wrong body.
|
|
44
|
+
- The unused optional peer `@opentelemetry/api` is removed from `peerDependencies` and
|
|
45
|
+
`peerDependenciesMeta`. The package imports nothing from it; telemetry goes through the
|
|
46
|
+
`ITelemetrySink` you implement. Consumers lose only an install hint.
|
|
47
|
+
|
|
48
|
+
### Documentation
|
|
49
|
+
|
|
50
|
+
- The README says that `MODEL_PRICES_SEED` has no rows for `azure-openai`, `vertex`, `bedrock` or
|
|
51
|
+
`openrouter`, so with `pricing.strict` on a call through the first three presets throws
|
|
52
|
+
`AI_TOKENS_PRICE_NOT_FOUND` until the host adds a price (the `openrouter` preset rates from the
|
|
53
|
+
provider-reported cost), and shows a minimal `upsertPrice` call.
|
|
54
|
+
|
|
55
|
+
## [1.1.3] - 2026-10-31
|
|
56
|
+
|
|
57
|
+
**Billing:** if you set prices with `upsertPrice` for a model the snapshot also prices while
|
|
58
|
+
`pricing.seedFromSnapshot` was on, a restart of an earlier version could replace them with the
|
|
59
|
+
snapshot. Run the two queries under the seed entry below to look for affected rows.
|
|
60
|
+
|
|
61
|
+
### Fixed
|
|
62
|
+
|
|
63
|
+
- **`forRootAsync({ useFactory })` accepts a factory with typed parameters.** The declared
|
|
64
|
+
signature `(...args: unknown[]) => ...` rejected the form Nest code normally writes,
|
|
65
|
+
`useFactory: (config: ConfigService) => ...`, so every consumer had to take `unknown` and cast.
|
|
66
|
+
The member is now declared with method syntax, which makes its parameters bivariant, and the
|
|
67
|
+
variadic form still compiles. No runtime change.
|
|
68
|
+
|
|
69
|
+
**Apply:** no action required. A cast such as `(prisma: unknown) => ... prisma as PrismaService`
|
|
70
|
+
can be replaced by a typed parameter.
|
|
71
|
+
|
|
72
|
+
- **A disabled wallet, budget or guard service no longer injects as `null` with `forRootAsync`.**
|
|
73
|
+
`forRootAsync` resolves its options after the providers are declared, so it registers
|
|
74
|
+
`WalletService`, `BudgetService` and `BudgetGuard` even when the options leave them out, and it
|
|
75
|
+
used to inject `null`: a consumer got `TypeError: Cannot read properties of null` at its first
|
|
76
|
+
call, with a type that said `WalletService`. It now injects a stand-in whose methods throw
|
|
77
|
+
`AI_TOKENS_INVALID_CONFIG`; the reason names the missing options block, `wallets` or
|
|
78
|
+
`budgets`. The stand-in is an instance of the service's class. `forRoot` is unchanged: it does not register a disabled service.
|
|
79
|
+
|
|
80
|
+
**Apply:** code that tested the injected service for `null` to detect a disabled feature must
|
|
81
|
+
check its own options instead.
|
|
82
|
+
|
|
83
|
+
- **`AI_TOKENS_NOT_CONFIGURED` is documented as reserved.** Nest finishes resolving the async
|
|
84
|
+
options before it builds any service, so nothing raises it.
|
|
85
|
+
|
|
86
|
+
- **A restart no longer reverts prices the host set, and the snapshot seed no longer adds rows.**
|
|
87
|
+
With `pricing.seedFromSnapshot` on (the default), every boot re-ran each seed row through
|
|
88
|
+
`upsertPrice`, which closes the open row of that model and inserts a new one. A second boot
|
|
89
|
+
against a seeded database added one closed zero-length row and one new open row per seed row
|
|
90
|
+
(34 rows after two boots, 17 closed), and a rate the host had set with `upsertPrice` was
|
|
91
|
+
closed before its own start date while the snapshot row became the open row again, so the
|
|
92
|
+
snapshot price silently replaced the host's price on every restart. The seed now inserts
|
|
93
|
+
only the model, operation and tier combinations that have no row at all, in one transaction
|
|
94
|
+
under a per-combination `pg_advisory_xact_lock`, and never touches existing history. The
|
|
95
|
+
session-scoped `pg_try_advisory_lock` the seed used is no longer taken: a session lock stays
|
|
96
|
+
held until its connection closes, so whether a boot seeded depended on which old connections
|
|
97
|
+
were still alive. `PrismaAiTokensStore.acquireSeedLock` stays for compatibility and is
|
|
98
|
+
deprecated. `IPricingStore` gains the optional method `seedPrice`; a custom store without it
|
|
99
|
+
is seeded from `getPriceHistory` and `upsertPrice`, which is not atomic across replicas.
|
|
100
|
+
|
|
101
|
+
**Apply:** a 1.1.x database may hold duplicated zero-length rows and reverted overrides. Find
|
|
102
|
+
them with the queries below; deleting or re-entering rows is your call, nothing here changes
|
|
103
|
+
data.
|
|
104
|
+
|
|
105
|
+
```sql
|
|
106
|
+
-- Rows the old seed closed at the instant they began (one per extra boot).
|
|
107
|
+
SELECT "provider", "model", "operation", "serviceTier", COUNT(*) AS "rows"
|
|
108
|
+
FROM "ai_model_prices"
|
|
109
|
+
WHERE "effectiveTo" = "effectiveFrom"
|
|
110
|
+
GROUP BY 1, 2, 3, 4;
|
|
111
|
+
|
|
112
|
+
-- Rows closed before their own start: a price set with upsertPrice that a later boot reverted.
|
|
113
|
+
SELECT "provider", "model", "operation", "serviceTier", "inputNanoUsdPerMillion",
|
|
114
|
+
"effectiveFrom", "effectiveTo"
|
|
115
|
+
FROM "ai_model_prices"
|
|
116
|
+
WHERE "effectiveTo" < "effectiveFrom";
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
A row from the second query is a rate you set; re-enter it with `upsertPrice` at its original
|
|
120
|
+
`effectiveFrom`.
|
|
121
|
+
|
|
122
|
+
### Added
|
|
123
|
+
|
|
124
|
+
- `ChainVerification`, the result of `LedgerService.verifyChain`, is exported by name from the
|
|
125
|
+
package root (a type only).
|
|
126
|
+
|
|
127
|
+
### Documentation
|
|
128
|
+
|
|
129
|
+
- **The README says how to create the tables and what the public API is.** The SQL migration and
|
|
130
|
+
the Prisma schema fragment ship in the package under `dist/prisma/` but were not mentioned; the
|
|
131
|
+
setup section now names both. The API reference lists the decorators (including `@AiFeature`),
|
|
132
|
+
the money and ledger helpers, the catalogs, the injection tokens and `CounterValueOutOfRangeError`.
|
|
133
|
+
The `budgets.defaultPolicy` Type column read `'block' | 'alert'`; the type is
|
|
134
|
+
`'block' | 'throttle' | 'allow'`. The content sidecar section states that the library ships no
|
|
135
|
+
`IContentStore` implementation or table, and the configuration section describes the
|
|
136
|
+
`forRootAsync` and `forRoot` behaviour for a disabled wallet or budget block. The 1.0.0 entries
|
|
137
|
+
that named an `OtelTelemetrySink` and a six-step resolution chain were corrected to the code.
|
|
138
|
+
|
|
139
|
+
- **The README now matches the code.** The configuration table gave the wrong default for
|
|
140
|
+
`pricing.seedFromSnapshot` (`true`, not `false`), `budgets.defaultPolicy` (`'block'`, not
|
|
141
|
+
`'alert'`) and `holds.ttlSeconds` (`3600`, not `300`), and gave no default for
|
|
142
|
+
`wallets.creditRateNanoUsd` (`1_000_000_000n`). It told readers to pass an
|
|
143
|
+
`OtelTelemetrySink` that the library does not ship; the option is `telemetry.sink`, which
|
|
144
|
+
takes your own `ITelemetrySink`, next to `telemetry.metrics`. The Bedrock preset is
|
|
145
|
+
`providerPresets.bedrock`, not `providerPresets.bedrockConverse`. The artifact-size table,
|
|
146
|
+
the stack list (it named a `ConfigurableModuleBuilder` the module does not use) and the
|
|
147
|
+
`AI_TOKENS_PRICE_NOT_FOUND` description (it counted resolution steps) were corrected too.
|
|
148
|
+
- **The README examples compile.** Some examples called methods or read fields that do not
|
|
149
|
+
exist: `wallets.getOrCreate` (the method is `getBalance`, and it returns `nanoUsd` and
|
|
150
|
+
`credits`), `metering.summarize` (it is `UsageReportService.summarize`), `reports.export`
|
|
151
|
+
with a `format` property (the format is the second argument), and a markup policy typed on
|
|
152
|
+
`MeteringContext` (its `resolve` takes the narrower call context).
|
|
153
|
+
|
|
11
154
|
## [1.1.2] - 2026-10-30
|
|
12
155
|
|
|
13
156
|
### Fixed
|
|
@@ -203,7 +346,7 @@ have regressed from. They are kept because the reasoning is worth having.
|
|
|
203
346
|
### Added
|
|
204
347
|
|
|
205
348
|
- **Nine provider normalizers** — OpenAI Chat Completions, OpenAI Responses API, OpenAI-compatible (DeepSeek, xAI, Groq, Azure OpenAI), Anthropic Messages, Google Gemini / Vertex AI, AWS Bedrock Converse, Mistral, OpenRouter, Vercel AI SDK v5/v6. All are pure functions over plain objects; no provider SDK peer dependency.
|
|
206
|
-
- **Versioned effective-dated pricing** — `PricingService` with a
|
|
349
|
+
- **Versioned effective-dated pricing** — `PricingService` with a model-resolution chain (exact, `baseModel`, alias map, normalized id, longest prefix), in-memory TTL cache, `upsertPrice()`, and idempotent `MODEL_PRICES_SEED` snapshot seed. Rates are bigint nano-USD per million tokens; past records are never re-rated.
|
|
207
350
|
- **Immutable append-only ledger** — `LedgerService` with content-derived idempotency (`deriveIdempotencyKey`), exactly-once semantics (payload-hash replay detection), lifecycle transitions (pending → posted → reversed/released), filtered queries, and optional per-tenant tamper-evident hash chain (`verifyChain()`).
|
|
208
351
|
- **First-class markup / margin** — `MarkupResolver` with a fixed multiplier or a per-call `IMarkupPolicy`. Every record stores both `rawCostNanoUsd` (provider cost) and `billedCostNanoUsd` (after markup). The resale lever is a first-class configuration option, not application code.
|
|
209
352
|
- **Prepaid wallets** — `WalletService` with grant/debit/refund/adjust entries, configurable burn order (expiry/priority/FIFO), overdraft support, and debit-allocation tracking.
|
|
@@ -214,7 +357,7 @@ have regressed from. They are kept because the reasoning is worth having.
|
|
|
214
357
|
- **NestJS enforcement** — `BudgetGuard` (CanActivate), `MeteringInterceptor` (NestInterceptor), and the `@Meter`, `@RequireBudget`, `@AiFeature` decorators for declarative controller-level metering.
|
|
215
358
|
- **Usage reporting** — `UsageReportService` with SQL-aggregated `summarize()`, CSV/JSON `export()`, paginated history, per-model analytics, and currency conversion.
|
|
216
359
|
- **Events** — Typed domain events (`ai_tokens.*`) delivered to `@nestjs/event-emitter` (optional peer) and/or an `IEventSink` port.
|
|
217
|
-
- **OpenTelemetry** —
|
|
360
|
+
- **OpenTelemetry** — The `ITelemetrySink` port, which you implement over `@opentelemetry/api` (optional peer); the library ships no sink and imports nothing from `@opentelemetry/api`.
|
|
218
361
|
- **PostgreSQL adapter** — `PrismaAiTokensStore` (`./prisma` subpath) implementing all four storage ports via parameterized raw SQL. Seven-table schema with migrations.
|
|
219
362
|
- **Five subpaths** — `.` (server), `./shared` (zero-dep), `./prices` (data-only), `./prisma` (adapter), `./redis` (counter).
|
|
220
363
|
- **Zero runtime dependencies** — `"dependencies": {}`. All runtime functionality via peer dependencies.
|
|
@@ -297,7 +440,9 @@ have regressed from. They are kept because the reasoning is worth having.
|
|
|
297
440
|
[1.0.2]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.1...v1.0.2
|
|
298
441
|
[1.1.0]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.4...v1.1.0
|
|
299
442
|
[1.1.2]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.1.1...v1.1.2
|
|
443
|
+
[1.1.3]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.1.2...v1.1.3
|
|
444
|
+
[1.1.4]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.1.3...v1.1.4
|
|
300
445
|
[1.1.1]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.1.0...v1.1.1
|
|
301
446
|
[1.0.4]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.3...v1.0.4
|
|
302
447
|
[1.0.3]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.0.2...v1.0.3
|
|
303
|
-
[Unreleased]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.1.
|
|
448
|
+
[Unreleased]: https://github.com/bymaxone/nest-ai-tokens/compare/v1.1.4...HEAD
|
package/README.md
CHANGED
|
@@ -134,10 +134,10 @@ the feature that needs them is enabled.
|
|
|
134
134
|
|
|
135
135
|
| Artifact | Brotli size | Budget |
|
|
136
136
|
| ------------------------------------------- | ----------- | ------- |
|
|
137
|
-
| server (`@bymax-one/nest-ai-tokens`) |
|
|
137
|
+
| server (`@bymax-one/nest-ai-tokens`) | 38 KB | < 40 KB |
|
|
138
138
|
| shared (`@bymax-one/nest-ai-tokens/shared`) | 5 KB | < 10 KB |
|
|
139
139
|
| prisma (`@bymax-one/nest-ai-tokens/prisma`) | 10 KB | < 15 KB |
|
|
140
|
-
| redis (`@bymax-one/nest-ai-tokens/redis`) | 1.
|
|
140
|
+
| redis (`@bymax-one/nest-ai-tokens/redis`) | 1.3 KB | < 5 KB |
|
|
141
141
|
| prices (data-only, exempt from budget) | 1.3 KB | exempt |
|
|
142
142
|
|
|
143
143
|
---
|
|
@@ -180,6 +180,20 @@ npm i @prisma/adapter-pg
|
|
|
180
180
|
> moves to a `prisma.config.ts` at your project root. Neither change reaches this
|
|
181
181
|
> library's API — `PrismaAiTokensStore` receives whatever client you built.
|
|
182
182
|
|
|
183
|
+
#### Create the tables
|
|
184
|
+
|
|
185
|
+
`PrismaAiTokensStore` needs seven tables, and the package ships what creates them, inside the
|
|
186
|
+
installed package:
|
|
187
|
+
|
|
188
|
+
- `node_modules/@bymax-one/nest-ai-tokens/dist/prisma/migrations/0001_init.sql` — the SQL for all
|
|
189
|
+
seven tables. Apply it with `psql -f` or `prisma db execute --file` before the first boot.
|
|
190
|
+
- `node_modules/@bymax-one/nest-ai-tokens/dist/prisma/schema.prisma.fragment` — the same models
|
|
191
|
+
for merging into your own Prisma schema if you manage migrations with `prisma migrate`.
|
|
192
|
+
|
|
193
|
+
The SQL also creates two partial indexes (exactly one open price row per model, and a fast scan
|
|
194
|
+
of pending holds) that a Prisma schema cannot express. If you migrate from the fragment, add them
|
|
195
|
+
yourself.
|
|
196
|
+
|
|
183
197
|
### 2 — Register the module
|
|
184
198
|
|
|
185
199
|
```typescript
|
|
@@ -284,19 +298,26 @@ export class AiController {
|
|
|
284
298
|
|
|
285
299
|
`BymaxAiTokensModule.forRoot(options)` / `.forRootAsync({ useFactory })` accept `BymaxAiTokensModuleOptions`:
|
|
286
300
|
|
|
287
|
-
| Option | Type | Default
|
|
288
|
-
| --------------------------- | ------------------------------------- |
|
|
289
|
-
| `store` | `IAiTokensStore` | — (required)
|
|
290
|
-
| `scopeResolver` | `(ctx) => MeteringContext` | —
|
|
291
|
-
| `ratingMode` | `'rate-table' \| 'provider-reported'` | `'rate-table'`
|
|
292
|
-
| `markup` | `number \| IMarkupPolicy` | `1.0`
|
|
293
|
-
| `pricing.seedFromSnapshot` | `boolean` | `
|
|
294
|
-
| `wallets.creditRateNanoUsd` | `bigint` |
|
|
295
|
-
| `wallets.burnOrder` | `'expiry' \| 'priority' \| 'fifo'` | `'expiry'`
|
|
296
|
-
| `budgets.defaultPolicy` | `'block' \| '
|
|
297
|
-
| `budgets.alertThresholds` | `number[]` | `[0.8, 1.0]`
|
|
298
|
-
| `holds.ttlSeconds` | `number` | `
|
|
299
|
-
| `telemetry`
|
|
301
|
+
| Option | Type | Default | Purpose |
|
|
302
|
+
| --------------------------- | ------------------------------------- | ---------------- | ------------------------------------------------------------------------------- |
|
|
303
|
+
| `store` | `IAiTokensStore` | — (required) | Persistence adapter (use `PrismaAiTokensStore`) |
|
|
304
|
+
| `scopeResolver` | `(ctx) => MeteringContext` | — | Resolves the caller's scope from the request (required for guard/interceptor) |
|
|
305
|
+
| `ratingMode` | `'rate-table' \| 'provider-reported'` | `'rate-table'` | Default cost rating mode |
|
|
306
|
+
| `markup` | `number \| IMarkupPolicy` | `1.0` | The resale multiplier (e.g. `4.0` = 4× provider cost) |
|
|
307
|
+
| `pricing.seedFromSnapshot` | `boolean` | `true` | Add seed rows only for combinations with no price row; never changes a price |
|
|
308
|
+
| `wallets.creditRateNanoUsd` | `bigint` | `1_000_000_000n` | Exchange rate: 1 credit = this many nano-USD |
|
|
309
|
+
| `wallets.burnOrder` | `'expiry' \| 'priority' \| 'fifo'` | `'expiry'` | Order in which grants are consumed |
|
|
310
|
+
| `budgets.defaultPolicy` | `'block' \| 'throttle' \| 'allow'` | `'block'` | Default enforcement when a budget is exhausted |
|
|
311
|
+
| `budgets.alertThresholds` | `number[]` | `[0.8, 1.0]` | Soft-threshold events at these fractions |
|
|
312
|
+
| `holds.ttlSeconds` | `number` | `3600` | Spend-hold TTL; the reaper voids expired holds |
|
|
313
|
+
| `telemetry.sink` | `ITelemetrySink` | — (off) | Your OpenTelemetry sink; the library ships none and telemetry is off without it |
|
|
314
|
+
| `telemetry.metrics` | `boolean` | `true` | Emit token-usage and duration metrics when a sink is present |
|
|
315
|
+
|
|
316
|
+
With `forRootAsync`, the options arrive after the providers are declared, so `WalletService`,
|
|
317
|
+
`BudgetService` and `BudgetGuard` are always registered. When the options leave `wallets` or
|
|
318
|
+
`budgets` out, the injected object throws `AI_TOKENS_INVALID_CONFIG` on any method call, with a
|
|
319
|
+
reason naming the missing block. With `forRoot` they are not registered, so injecting one fails
|
|
320
|
+
when the application boots.
|
|
300
321
|
|
|
301
322
|
---
|
|
302
323
|
|
|
@@ -309,13 +330,20 @@ export class AiController {
|
|
|
309
330
|
| OpenAI-compatible | DeepSeek, xAI, Groq, Azure OpenAI | `normalizeOpenAiCompatibleUsage` / `providerPresets.openaiCompatible` |
|
|
310
331
|
| Anthropic | Messages API | `normalizeAnthropicUsage` / `providerPresets.anthropic` |
|
|
311
332
|
| Google | Gemini / Vertex AI | `normalizeGeminiUsage` / `providerPresets.gemini` |
|
|
312
|
-
| AWS | Bedrock Converse | `normalizeBedrockConverseUsage` / `providerPresets.
|
|
333
|
+
| AWS | Bedrock Converse | `normalizeBedrockConverseUsage` / `providerPresets.bedrock` |
|
|
313
334
|
| Mistral | Chat API | `normalizeMistralUsage` / `providerPresets.mistral` |
|
|
314
335
|
| OpenRouter | Chat | `normalizeOpenRouterUsage` / `providerPresets.openrouter` (provider-reported cost) |
|
|
315
336
|
| Vercel | AI SDK v5/v6 | `normalizeVercelAiSdkUsage` / `providerPresets.vercelAiSdk` |
|
|
316
337
|
|
|
317
338
|
All normalizers are pure functions over plain objects — no provider SDK peer dependency. Pass the raw response object and get back a `NormalizedUsage`.
|
|
318
339
|
|
|
340
|
+
`MODEL_PRICES_SEED` has no rows for `azure-openai`, `vertex`, `bedrock` or `openrouter`; it covers
|
|
341
|
+
`openai`, `anthropic`, `gemini`, `mistral`, `deepseek`, `xai` and `groq`. With `pricing.strict` on
|
|
342
|
+
(the default), a call through the `azure-openai`, `vertex` or `bedrock` preset throws
|
|
343
|
+
`AI_TOKENS_PRICE_NOT_FOUND` until you add its price. The `openrouter` preset rates from the cost the
|
|
344
|
+
provider reports, so it needs no price unless a call sets `ratingMode: 'rate-table'`. See
|
|
345
|
+
[Pricing & Markup](#-pricing--markup).
|
|
346
|
+
|
|
319
347
|
---
|
|
320
348
|
|
|
321
349
|
## 💰 Pricing & Markup
|
|
@@ -325,7 +353,7 @@ The fundamental difference between "what the provider charged you" and "what you
|
|
|
325
353
|
```typescript
|
|
326
354
|
// Configure at module level:
|
|
327
355
|
BymaxAiTokensModule.forRootAsync({
|
|
328
|
-
useFactory: (prisma) => ({
|
|
356
|
+
useFactory: (prisma: PrismaService) => ({
|
|
329
357
|
store: new PrismaAiTokensStore(prisma),
|
|
330
358
|
markup: 4.0, // 4× resale margin
|
|
331
359
|
wallets: { creditRateNanoUsd: 5_000_000_000n }, // sell credits at $5 each
|
|
@@ -338,11 +366,25 @@ BymaxAiTokensModule.forRootAsync({
|
|
|
338
366
|
// Margin: $0.015 per call
|
|
339
367
|
```
|
|
340
368
|
|
|
369
|
+
A provider or model the snapshot does not price needs a rate before it can be rated. Add one with
|
|
370
|
+
`PricingService.upsertPrice`; the new row closes the previous one at its `effectiveFrom`, which
|
|
371
|
+
defaults to now. Rates are nano-USD per million tokens:
|
|
372
|
+
|
|
373
|
+
```typescript
|
|
374
|
+
await pricing.upsertPrice({
|
|
375
|
+
provider: 'bedrock',
|
|
376
|
+
model: 'my-model', // the model id your calls report
|
|
377
|
+
operation: 'chat',
|
|
378
|
+
inputNanoUsdPerMillion: 3_000_000_000n, // $3 per million input tokens
|
|
379
|
+
outputNanoUsdPerMillion: 15_000_000_000n, // $15 per million output tokens
|
|
380
|
+
})
|
|
381
|
+
```
|
|
382
|
+
|
|
341
383
|
For per-tenant or per-feature pricing, implement `IMarkupPolicy`:
|
|
342
384
|
|
|
343
385
|
```typescript
|
|
344
386
|
class MyMarkupPolicy implements IMarkupPolicy {
|
|
345
|
-
resolve(ctx:
|
|
387
|
+
resolve(ctx: { feature?: string }): number | Promise<number> {
|
|
346
388
|
return ctx.feature === 'premium.summarize' ? 5.0 : 3.0
|
|
347
389
|
}
|
|
348
390
|
}
|
|
@@ -370,8 +412,8 @@ await wallets.grant(
|
|
|
370
412
|
|
|
371
413
|
// Credits are debited automatically when the ledger posts a billed cost.
|
|
372
414
|
// Check balance:
|
|
373
|
-
const
|
|
374
|
-
console.log(
|
|
415
|
+
const balance = await wallets.getBalance({ tenantId, ownerType: 'user', ownerId: userId })
|
|
416
|
+
console.log(balance.nanoUsd) // current balance
|
|
375
417
|
```
|
|
376
418
|
|
|
377
419
|
### Multi-Dimension Budgets
|
|
@@ -443,7 +485,7 @@ On abort, the collector falls back to a tokenizer estimate of the output tokens
|
|
|
443
485
|
|
|
444
486
|
```typescript
|
|
445
487
|
// Usage summary — by feature, for the last 30 days:
|
|
446
|
-
const summary = await
|
|
488
|
+
const summary = await reports.summarize({
|
|
447
489
|
tenantId,
|
|
448
490
|
scope: { type: 'user', id: userId },
|
|
449
491
|
from: thirtyDaysAgo,
|
|
@@ -452,12 +494,7 @@ const summary = await metering.summarize({
|
|
|
452
494
|
})
|
|
453
495
|
|
|
454
496
|
// Export as CSV (streaming):
|
|
455
|
-
const csv = await reports.export({
|
|
456
|
-
tenantId,
|
|
457
|
-
from,
|
|
458
|
-
to,
|
|
459
|
-
format: 'csv',
|
|
460
|
-
})
|
|
497
|
+
const csv = await reports.export({ tenantId, from, to }, 'csv')
|
|
461
498
|
csv.pipe(res)
|
|
462
499
|
|
|
463
500
|
// Access status / remaining budget:
|
|
@@ -495,11 +532,11 @@ All errors are `AiTokensException extends HttpException`. Use the exported `AI_T
|
|
|
495
532
|
|
|
496
533
|
| Code | HTTP | When |
|
|
497
534
|
| -------------------------------- | ---- | ------------------------------------------------------------------------------------- |
|
|
498
|
-
| `AI_TOKENS_NOT_CONFIGURED` | 503 |
|
|
535
|
+
| `AI_TOKENS_NOT_CONFIGURED` | 503 | Reserved; nothing raises it |
|
|
499
536
|
| `AI_TOKENS_INVALID_CONFIG` | 500 | Bad markup, negative limits, missing `scopeResolver`, missing fx |
|
|
500
537
|
| `AI_TOKENS_UNKNOWN_PROVIDER` | 400 | No preset/normalizer and usage is not already `NormalizedUsage` |
|
|
501
538
|
| `AI_TOKENS_USAGE_MALFORMED` | 422 | Normalizer could not read required token fields |
|
|
502
|
-
| `AI_TOKENS_PRICE_NOT_FOUND` | 422 | No effective-dated rate after
|
|
539
|
+
| `AI_TOKENS_PRICE_NOT_FOUND` | 422 | No effective-dated rate after the model-resolution chain (strict mode) |
|
|
503
540
|
| `AI_TOKENS_FX_REQUIRED` | 500 | `currency !== 'USD'` with no `fx` resolver |
|
|
504
541
|
| `AI_TOKENS_BUDGET_EXCEEDED` | 402 | A hard spend budget blocks the call |
|
|
505
542
|
| `AI_TOKENS_QUOTA_EXCEEDED` | 429 | A hard token/count quota blocks the call |
|
|
@@ -522,7 +559,7 @@ import { toJsonSafe } from '@bymax-one/nest-ai-tokens'
|
|
|
522
559
|
|
|
523
560
|
// In your controller:
|
|
524
561
|
return toJsonSafe(await metering.getStatus(tenantId, scope))
|
|
525
|
-
// bigint fields (balanceNanoUsd,
|
|
562
|
+
// bigint fields (wallet.balanceNanoUsd, budgets[].spent.nanoUsd, etc.) become decimal strings.
|
|
526
563
|
|
|
527
564
|
// For display/formatting:
|
|
528
565
|
import { formatNanoUsd } from '@bymax-one/nest-ai-tokens/shared'
|
|
@@ -548,6 +585,55 @@ The sections above document each of these with a runnable example. This is the i
|
|
|
548
585
|
| `UsageReportService` | `summarize` · `export` |
|
|
549
586
|
| `StreamUsageCollector` | Accumulates streamed chunks; prefers the provider's final usage over its own count |
|
|
550
587
|
|
|
588
|
+
#### Verifying the hash chain
|
|
589
|
+
|
|
590
|
+
`LedgerService.verifyChain(tenantId, from?, to?)` resolves to a `ChainVerification`:
|
|
591
|
+
|
|
592
|
+
- `{ valid: true, unchainedCount, chainStartsAtRecordId? }` — every hashed record recomputes and, on a call
|
|
593
|
+
without `from`/`to`, the hashed records form one chain. `chainStartsAtRecordId` is the genesis record
|
|
594
|
+
when the verified records include it.
|
|
595
|
+
- `{ valid: false, brokenAtRecordId, unchainedCount }` — the record that fails: a record whose hash was
|
|
596
|
+
removed while its link stayed, a changed record, or, without `from`/`to`, a gap (a second chain start)
|
|
597
|
+
or a chain that does not start at a genesis record (its first head). Those checks run in that order,
|
|
598
|
+
and within each the earliest record in listing order is reported. Two records may share a predecessor:
|
|
599
|
+
a hold that settles after a later record links to the record created last, as the next record does.
|
|
600
|
+
- `{ valid: false, status: 'never-enabled', unchainedCount }` — settled records exist and none carries a hash or a link. This
|
|
601
|
+
is not evidence of tampering; `ledger.hashChain` was simply never on for this tenant.
|
|
602
|
+
|
|
603
|
+
Enable `ledger.hashChain` before the first write. Records written before it was enabled are **unchained**:
|
|
604
|
+
they are counted in `unchainedCount`, not verified and not protected.
|
|
605
|
+
|
|
606
|
+
What it does not catch, plainly:
|
|
607
|
+
|
|
608
|
+
1. A record that no other record names as its predecessor can be deleted without detection. That is
|
|
609
|
+
the newest record, and a hold that settled while a later-created record was already hashed (a record
|
|
610
|
+
links to the hashed record created last, so that hold is never linked to). A hold that settles as
|
|
611
|
+
the newest-created record is linked by the next record and is not affected.
|
|
612
|
+
2. The chain is unkeyed. Someone with write access who recomputes the whole tail is not detected, so
|
|
613
|
+
anchor the latest hash outside the database.
|
|
614
|
+
3. An `unchainedCount` that rises after the chain was enabled is a signal to investigate. A rolling deploy
|
|
615
|
+
with mixed `hashChain` settings can also cause it.
|
|
616
|
+
4. A call with `from` or `to` checks each record's own hash only, not the links between records.
|
|
617
|
+
|
|
618
|
+
### Decorators, helpers and catalogs
|
|
619
|
+
|
|
620
|
+
| Export | Role |
|
|
621
|
+
| ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
|
622
|
+
| `@Meter` · `@RequireBudget` · `@AiFeature(name)` | Handler metadata read by the interceptor and the guard; `@AiFeature` only tags the feature name |
|
|
623
|
+
| `deriveIdempotencyKey` · `toJsonSafe` · `formatNanoUsd` | Idempotency key from content, `bigint` to decimal strings for JSON, and display formatting |
|
|
624
|
+
| `floatUsdToNanoUsd` · `perMillion` · `computeCostNanoUsd` | The money math: the only float-to-`bigint` entry, a cost at a nano-USD-per-million rate, and a usage priced against a rate |
|
|
625
|
+
| `applyMarkup` · `resolveMultiplier4dp` | A markup applied in exact `bigint`, and a multiplier validated and rounded to 4 decimal places |
|
|
626
|
+
| `isLegalLedgerTransition` · `isLegalTransitionPatchKey` | The ledger lifecycle rules a custom store can reuse: which status changes and which columns each may touch |
|
|
627
|
+
| `PROVIDER_IDS` · `SERVICE_TIERS` · `AI_OPERATIONS` · `TOKEN_CATEGORIES` · `WALLET_ENTRY_TYPES` | The catalogs the types are derived from |
|
|
628
|
+
| `CounterValueOutOfRangeError` (`./redis`) | Thrown when a counter value leaves the safe-integer range; the budget service then falls back to the database consume |
|
|
629
|
+
|
|
630
|
+
### Injection tokens
|
|
631
|
+
|
|
632
|
+
`BYMAX_AI_TOKENS_OPTIONS` (the resolved options) and the port tokens `BYMAX_AI_TOKENS_LEDGER_STORE`,
|
|
633
|
+
`_PRICING_STORE`, `_WALLET_STORE`, `_BUDGET_STORE`, `_BUDGET_COUNTER`, `_TOKENIZER`, `_TELEMETRY`,
|
|
634
|
+
`_EVENT_SINK`, `_CONTENT_STORE` and `_LOGGER`. The wallet and budget tokens exist only when their
|
|
635
|
+
options block is set (with `forRoot`; `forRootAsync` always exports them).
|
|
636
|
+
|
|
551
637
|
### Request-scoped enforcement
|
|
552
638
|
|
|
553
639
|
| Class | Role |
|
|
@@ -672,7 +758,9 @@ There is one place text can be persisted, and it is opt-in: `IContentStore`, the
|
|
|
672
758
|
sidecar. A host that enables it stores **masked** text under a short TTL, separate from the
|
|
673
759
|
ledger, with a `purge()` that deletes by tenant, record or subject so an erasure request can
|
|
674
760
|
be honoured without touching the accounting. It is off unless you provide a store — and if
|
|
675
|
-
you do, text is being written, and where it is written is your implementation.
|
|
761
|
+
you do, text is being written, and where it is written is your implementation. The library ships
|
|
762
|
+
no `IContentStore` implementation and creates no table for one: you bring the store, its schema
|
|
763
|
+
and its retention job.
|
|
676
764
|
|
|
677
765
|
### Budget checks are atomic
|
|
678
766
|
|
|
@@ -713,7 +801,7 @@ that reads a tenant id from request input lets a caller bill someone else.
|
|
|
713
801
|
## 🧱 Tech Stack
|
|
714
802
|
|
|
715
803
|
- **Runtime:** Node.js 24+
|
|
716
|
-
- **Framework:** NestJS 11 (guard + interceptor, `
|
|
804
|
+
- **Framework:** NestJS 11 (guard + interceptor, `Symbol()` tokens)
|
|
717
805
|
- **Persistence:** `@prisma/client ^6 || ^7` (peer) over PostgreSQL — both majors supported
|
|
718
806
|
- **Budget acceleration:** `ioredis ^6` (peer), optional
|
|
719
807
|
- **Events:** `@nestjs/event-emitter >=2` (peer), optional
|
package/dist/prisma/index.cjs
CHANGED
|
@@ -421,6 +421,9 @@ function mapPriceRow(row) {
|
|
|
421
421
|
source: row.source
|
|
422
422
|
};
|
|
423
423
|
}
|
|
424
|
+
function priceLockKey(provider, model, operation, serviceTier) {
|
|
425
|
+
return JSON.stringify([provider, model, operation, serviceTier]);
|
|
426
|
+
}
|
|
424
427
|
function firstOrThrow(rows) {
|
|
425
428
|
const row = rows[0];
|
|
426
429
|
if (row === void 0) throw new nestAiTokens.AiTokensException("AI_TOKENS_STORE_ERROR", void 0, {});
|
|
@@ -828,7 +831,7 @@ var PrismaAiTokensStore = class {
|
|
|
828
831
|
async upsertPrice(input) {
|
|
829
832
|
const serviceTier = input.serviceTier ?? "standard";
|
|
830
833
|
const effectiveFrom = input.effectiveFrom ?? /* @__PURE__ */ new Date();
|
|
831
|
-
const key =
|
|
834
|
+
const key = priceLockKey(input.provider, input.model, input.operation, serviceTier);
|
|
832
835
|
return this.prisma.$transaction(async (tx) => {
|
|
833
836
|
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${key})::bigint)`;
|
|
834
837
|
await tx.$executeRaw(client.Prisma.sql`
|
|
@@ -843,6 +846,33 @@ var PrismaAiTokensStore = class {
|
|
|
843
846
|
return mapPriceRow(firstOrThrow(rows));
|
|
844
847
|
});
|
|
845
848
|
}
|
|
849
|
+
/**
|
|
850
|
+
* Insert a price row only when its (provider, model, operation, serviceTier) tuple has no
|
|
851
|
+
* row yet. The check and the insert run in one transaction under the same per-tuple
|
|
852
|
+
* `pg_advisory_xact_lock` as {@link upsertPrice}, so concurrent boots serialize on the
|
|
853
|
+
* tuple, the loser finds the winner's row and inserts nothing, and the lock is released
|
|
854
|
+
* when the transaction ends rather than when the connection does.
|
|
855
|
+
*/
|
|
856
|
+
async seedPrice(input) {
|
|
857
|
+
const serviceTier = input.serviceTier ?? "standard";
|
|
858
|
+
const effectiveFrom = input.effectiveFrom ?? /* @__PURE__ */ new Date();
|
|
859
|
+
const key = priceLockKey(input.provider, input.model, input.operation, serviceTier);
|
|
860
|
+
return this.prisma.$transaction(async (tx) => {
|
|
861
|
+
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${key})::bigint)`;
|
|
862
|
+
const existing = await tx.$queryRaw(client.Prisma.sql`
|
|
863
|
+
SELECT "id" FROM "ai_model_prices"
|
|
864
|
+
WHERE "provider" = ${input.provider} AND "model" = ${input.model} AND "operation" = ${input.operation}
|
|
865
|
+
AND "serviceTier" = ${serviceTier}
|
|
866
|
+
LIMIT 1
|
|
867
|
+
`);
|
|
868
|
+
if (existing.length > 0) return false;
|
|
869
|
+
await tx.$queryRaw(client.Prisma.sql`
|
|
870
|
+
INSERT INTO "ai_model_prices" (${PRICE_COLUMNS_SQL}) VALUES (${client.Prisma.join(priceValues(input, serviceTier, effectiveFrom))})
|
|
871
|
+
RETURNING *
|
|
872
|
+
`);
|
|
873
|
+
return true;
|
|
874
|
+
});
|
|
875
|
+
}
|
|
846
876
|
async getPriceHistory(provider, model, operation, serviceTier) {
|
|
847
877
|
const tier = serviceTier === void 0 ? client.Prisma.empty : client.Prisma.sql`AND "serviceTier" = ${serviceTier}`;
|
|
848
878
|
const rows = await this.prisma.$queryRaw(client.Prisma.sql`
|
|
@@ -858,7 +888,12 @@ var PrismaAiTokensStore = class {
|
|
|
858
888
|
`);
|
|
859
889
|
return rows.map((row) => ({ model: row.model, operation: row.operation, serviceTier: row.serviceTier }));
|
|
860
890
|
}
|
|
861
|
-
/**
|
|
891
|
+
/**
|
|
892
|
+
* Session-scoped advisory lock: the first caller on a connection wins, and the lock is held
|
|
893
|
+
* until that connection closes, not when a boot ends.
|
|
894
|
+
*
|
|
895
|
+
* @deprecated Use {@link seedPrice}, which seeds atomically under a transaction-scoped lock.
|
|
896
|
+
*/
|
|
862
897
|
async acquireSeedLock(key) {
|
|
863
898
|
const rows = await this.prisma.$queryRaw(
|
|
864
899
|
client.Prisma.sql`SELECT pg_try_advisory_lock(hashtext(${key})::bigint) AS "acquired"`
|
package/dist/prisma/index.d.cts
CHANGED
|
@@ -505,6 +505,15 @@ interface IPricingStore {
|
|
|
505
505
|
resolveRate(provider: ProviderId, model: string, operation: AiOperation, serviceTier: ServiceTier, at: Date): Promise<PriceVersion | null>;
|
|
506
506
|
/** Close the current open row (`effectiveTo = now`) and insert the new open row. */
|
|
507
507
|
upsertPrice(input: NewPriceVersion): Promise<PriceVersion>;
|
|
508
|
+
/**
|
|
509
|
+
* Insert `input` only when no row, open or closed, exists for its
|
|
510
|
+
* (provider, model, operation, serviceTier) tuple; resolves `true` when it inserted.
|
|
511
|
+
* Atomic per tuple, so concurrent boots cannot both insert, and it never closes or
|
|
512
|
+
* alters an existing row. The snapshot seed uses it so a restart cannot revert a rate
|
|
513
|
+
* the host set with {@link IPricingStore.upsertPrice}. A store without it is seeded
|
|
514
|
+
* from {@link IPricingStore.getPriceHistory} and `upsertPrice`, which is not atomic.
|
|
515
|
+
*/
|
|
516
|
+
seedPrice?(input: NewPriceVersion): Promise<boolean>;
|
|
508
517
|
/** Full effective-dated history for a tuple (newest first). */
|
|
509
518
|
getPriceHistory(provider: ProviderId, model: string, operation: AiOperation, serviceTier?: ServiceTier): Promise<PriceVersion[]>;
|
|
510
519
|
/** All distinct (model, operation, serviceTier) for a provider — powers §6.6 prefix matching. */
|
|
@@ -615,9 +624,22 @@ declare class PrismaAiTokensStore implements IAiTokensStore {
|
|
|
615
624
|
lastHash(tenantId: string): Promise<string | null>;
|
|
616
625
|
resolveRate(provider: ProviderId, model: string, operation: AiOperation, serviceTier: ServiceTier, at: Date): Promise<PriceVersion | null>;
|
|
617
626
|
upsertPrice(input: NewPriceVersion): Promise<PriceVersion>;
|
|
627
|
+
/**
|
|
628
|
+
* Insert a price row only when its (provider, model, operation, serviceTier) tuple has no
|
|
629
|
+
* row yet. The check and the insert run in one transaction under the same per-tuple
|
|
630
|
+
* `pg_advisory_xact_lock` as {@link upsertPrice}, so concurrent boots serialize on the
|
|
631
|
+
* tuple, the loser finds the winner's row and inserts nothing, and the lock is released
|
|
632
|
+
* when the transaction ends rather than when the connection does.
|
|
633
|
+
*/
|
|
634
|
+
seedPrice(input: NewPriceVersion): Promise<boolean>;
|
|
618
635
|
getPriceHistory(provider: ProviderId, model: string, operation: AiOperation, serviceTier?: ServiceTier): Promise<PriceVersion[]>;
|
|
619
636
|
listModels(provider: ProviderId): Promise<PricedModel[]>;
|
|
620
|
-
/**
|
|
637
|
+
/**
|
|
638
|
+
* Session-scoped advisory lock: the first caller on a connection wins, and the lock is held
|
|
639
|
+
* until that connection closes, not when a boot ends.
|
|
640
|
+
*
|
|
641
|
+
* @deprecated Use {@link seedPrice}, which seeds atomically under a transaction-scoped lock.
|
|
642
|
+
*/
|
|
621
643
|
acquireSeedLock(key: string): Promise<boolean>;
|
|
622
644
|
/** Insert one usage row and return it; shared by the plain and chained paths. */
|
|
623
645
|
private insert;
|
package/dist/prisma/index.d.ts
CHANGED
|
@@ -505,6 +505,15 @@ interface IPricingStore {
|
|
|
505
505
|
resolveRate(provider: ProviderId, model: string, operation: AiOperation, serviceTier: ServiceTier, at: Date): Promise<PriceVersion | null>;
|
|
506
506
|
/** Close the current open row (`effectiveTo = now`) and insert the new open row. */
|
|
507
507
|
upsertPrice(input: NewPriceVersion): Promise<PriceVersion>;
|
|
508
|
+
/**
|
|
509
|
+
* Insert `input` only when no row, open or closed, exists for its
|
|
510
|
+
* (provider, model, operation, serviceTier) tuple; resolves `true` when it inserted.
|
|
511
|
+
* Atomic per tuple, so concurrent boots cannot both insert, and it never closes or
|
|
512
|
+
* alters an existing row. The snapshot seed uses it so a restart cannot revert a rate
|
|
513
|
+
* the host set with {@link IPricingStore.upsertPrice}. A store without it is seeded
|
|
514
|
+
* from {@link IPricingStore.getPriceHistory} and `upsertPrice`, which is not atomic.
|
|
515
|
+
*/
|
|
516
|
+
seedPrice?(input: NewPriceVersion): Promise<boolean>;
|
|
508
517
|
/** Full effective-dated history for a tuple (newest first). */
|
|
509
518
|
getPriceHistory(provider: ProviderId, model: string, operation: AiOperation, serviceTier?: ServiceTier): Promise<PriceVersion[]>;
|
|
510
519
|
/** All distinct (model, operation, serviceTier) for a provider — powers §6.6 prefix matching. */
|
|
@@ -615,9 +624,22 @@ declare class PrismaAiTokensStore implements IAiTokensStore {
|
|
|
615
624
|
lastHash(tenantId: string): Promise<string | null>;
|
|
616
625
|
resolveRate(provider: ProviderId, model: string, operation: AiOperation, serviceTier: ServiceTier, at: Date): Promise<PriceVersion | null>;
|
|
617
626
|
upsertPrice(input: NewPriceVersion): Promise<PriceVersion>;
|
|
627
|
+
/**
|
|
628
|
+
* Insert a price row only when its (provider, model, operation, serviceTier) tuple has no
|
|
629
|
+
* row yet. The check and the insert run in one transaction under the same per-tuple
|
|
630
|
+
* `pg_advisory_xact_lock` as {@link upsertPrice}, so concurrent boots serialize on the
|
|
631
|
+
* tuple, the loser finds the winner's row and inserts nothing, and the lock is released
|
|
632
|
+
* when the transaction ends rather than when the connection does.
|
|
633
|
+
*/
|
|
634
|
+
seedPrice(input: NewPriceVersion): Promise<boolean>;
|
|
618
635
|
getPriceHistory(provider: ProviderId, model: string, operation: AiOperation, serviceTier?: ServiceTier): Promise<PriceVersion[]>;
|
|
619
636
|
listModels(provider: ProviderId): Promise<PricedModel[]>;
|
|
620
|
-
/**
|
|
637
|
+
/**
|
|
638
|
+
* Session-scoped advisory lock: the first caller on a connection wins, and the lock is held
|
|
639
|
+
* until that connection closes, not when a boot ends.
|
|
640
|
+
*
|
|
641
|
+
* @deprecated Use {@link seedPrice}, which seeds atomically under a transaction-scoped lock.
|
|
642
|
+
*/
|
|
621
643
|
acquireSeedLock(key: string): Promise<boolean>;
|
|
622
644
|
/** Insert one usage row and return it; shared by the plain and chained paths. */
|
|
623
645
|
private insert;
|