@bymax-one/nest-ai-tokens 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/CHANGELOG.md +113 -0
- package/LICENSE +21 -0
- package/README.md +795 -0
- package/dist/prices/index.cjs +218 -0
- package/dist/prices/index.d.cts +131 -0
- package/dist/prices/index.d.ts +131 -0
- package/dist/prices/index.mjs +216 -0
- package/dist/prisma/index.cjs +1197 -0
- package/dist/prisma/index.d.cts +677 -0
- package/dist/prisma/index.d.ts +677 -0
- package/dist/prisma/index.mjs +1195 -0
- package/dist/prisma/migrations/0001_init.sql +198 -0
- package/dist/prisma/schema.prisma.fragment +197 -0
- package/dist/redis/index.cjs +110 -0
- package/dist/redis/index.d.cts +98 -0
- package/dist/redis/index.d.ts +98 -0
- package/dist/redis/index.mjs +107 -0
- package/dist/server/index.cjs +4405 -0
- package/dist/server/index.d.cts +3202 -0
- package/dist/server/index.d.ts +3202 -0
- package/dist/server/index.mjs +4354 -0
- package/dist/shared/index.cjs +659 -0
- package/dist/shared/index.d.cts +1044 -0
- package/dist/shared/index.d.ts +1044 -0
- package/dist/shared/index.mjs +629 -0
- package/package.json +216 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
-- @bymax-one/nest-ai-tokens — initial schema (spec §15.3).
|
|
2
|
+
-- Seven tables; all money is BIGINT nano-USD; every table carries "tenantId".
|
|
3
|
+
-- Apply with `prisma db execute --file` / `psql`, or let `prisma migrate` generate
|
|
4
|
+
-- the equivalent from `schema.prisma.fragment`. The two PARTIAL indexes at the end
|
|
5
|
+
-- cannot be expressed in the Prisma schema and are the reason for the shipped SQL.
|
|
6
|
+
|
|
7
|
+
-- ---------------------------------------------------------------------------
|
|
8
|
+
-- ai_usage_records — the immutable usage ledger (append-only).
|
|
9
|
+
-- ---------------------------------------------------------------------------
|
|
10
|
+
CREATE TABLE "ai_usage_records" (
|
|
11
|
+
"id" TEXT NOT NULL,
|
|
12
|
+
"tenantId" TEXT NOT NULL,
|
|
13
|
+
"scopeType" TEXT NOT NULL,
|
|
14
|
+
"scopeId" TEXT NOT NULL,
|
|
15
|
+
"beneficiaryType" TEXT,
|
|
16
|
+
"beneficiaryId" TEXT,
|
|
17
|
+
"requestedBy" TEXT,
|
|
18
|
+
"provider" TEXT NOT NULL,
|
|
19
|
+
"model" TEXT NOT NULL,
|
|
20
|
+
"requestedModel" TEXT,
|
|
21
|
+
"operation" TEXT NOT NULL,
|
|
22
|
+
"serviceTier" TEXT NOT NULL DEFAULT 'standard',
|
|
23
|
+
"feature" TEXT NOT NULL,
|
|
24
|
+
"tags" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
|
25
|
+
"inputTokens" INTEGER NOT NULL DEFAULT 0,
|
|
26
|
+
"outputTokens" INTEGER NOT NULL DEFAULT 0,
|
|
27
|
+
"cacheReadTokens" INTEGER NOT NULL DEFAULT 0,
|
|
28
|
+
"cacheWrite5mTokens" INTEGER NOT NULL DEFAULT 0,
|
|
29
|
+
"cacheWrite1hTokens" INTEGER NOT NULL DEFAULT 0,
|
|
30
|
+
"reasoningTokens" INTEGER NOT NULL DEFAULT 0,
|
|
31
|
+
"audioInTokens" INTEGER NOT NULL DEFAULT 0,
|
|
32
|
+
"audioOutTokens" INTEGER NOT NULL DEFAULT 0,
|
|
33
|
+
"imageInTokens" INTEGER NOT NULL DEFAULT 0,
|
|
34
|
+
"imageOutTokens" INTEGER NOT NULL DEFAULT 0,
|
|
35
|
+
"totalTokens" INTEGER NOT NULL,
|
|
36
|
+
"extraUnits" JSONB,
|
|
37
|
+
"priceVersionId" TEXT,
|
|
38
|
+
"rawCostNanoUsd" BIGINT NOT NULL,
|
|
39
|
+
"surchargeNanoUsd" BIGINT NOT NULL DEFAULT 0,
|
|
40
|
+
"billedCostNanoUsd" BIGINT NOT NULL,
|
|
41
|
+
"markupMultiplier" DECIMAL(10,4) NOT NULL,
|
|
42
|
+
"currency" TEXT NOT NULL DEFAULT 'USD',
|
|
43
|
+
"priceMissing" BOOLEAN NOT NULL DEFAULT false,
|
|
44
|
+
"status" TEXT NOT NULL DEFAULT 'posted',
|
|
45
|
+
"reversedByRecordId" TEXT,
|
|
46
|
+
"reversesRecordId" TEXT,
|
|
47
|
+
"idempotencyKey" TEXT NOT NULL,
|
|
48
|
+
"payloadHash" TEXT NOT NULL,
|
|
49
|
+
"correlationId" TEXT,
|
|
50
|
+
"requestId" TEXT,
|
|
51
|
+
"isSystemCost" BOOLEAN NOT NULL DEFAULT false,
|
|
52
|
+
"systemCostCategory" TEXT,
|
|
53
|
+
"enforced" BOOLEAN NOT NULL DEFAULT false,
|
|
54
|
+
"prevHash" TEXT,
|
|
55
|
+
"hash" TEXT,
|
|
56
|
+
"occurredAt" TIMESTAMP(3) NOT NULL,
|
|
57
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
58
|
+
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
59
|
+
CONSTRAINT "ai_usage_records_pkey" PRIMARY KEY ("id")
|
|
60
|
+
);
|
|
61
|
+
CREATE UNIQUE INDEX "ai_usage_records_tenantId_idempotencyKey_key" ON "ai_usage_records" ("tenantId", "idempotencyKey");
|
|
62
|
+
CREATE INDEX "ai_usage_records_tenantId_occurredAt_idx" ON "ai_usage_records" ("tenantId", "occurredAt");
|
|
63
|
+
CREATE INDEX "ai_usage_records_tenantId_scopeType_scopeId_occurredAt_idx" ON "ai_usage_records" ("tenantId", "scopeType", "scopeId", "occurredAt");
|
|
64
|
+
CREATE INDEX "ai_usage_records_tenantId_feature_occurredAt_idx" ON "ai_usage_records" ("tenantId", "feature", "occurredAt");
|
|
65
|
+
CREATE INDEX "ai_usage_records_tenantId_provider_model_idx" ON "ai_usage_records" ("tenantId", "provider", "model");
|
|
66
|
+
CREATE INDEX "ai_usage_records_tenantId_systemCostCategory_idx" ON "ai_usage_records" ("tenantId", "systemCostCategory");
|
|
67
|
+
CREATE INDEX "ai_usage_records_tenantId_beneficiaryType_beneficiaryId_idx" ON "ai_usage_records" ("tenantId", "beneficiaryType", "beneficiaryId");
|
|
68
|
+
CREATE INDEX "ai_usage_records_priceVersionId_idx" ON "ai_usage_records" ("priceVersionId");
|
|
69
|
+
CREATE INDEX "ai_usage_records_status_createdAt_idx" ON "ai_usage_records" ("status", "createdAt");
|
|
70
|
+
|
|
71
|
+
-- ---------------------------------------------------------------------------
|
|
72
|
+
-- ai_model_prices — effective-dated price registry.
|
|
73
|
+
-- ---------------------------------------------------------------------------
|
|
74
|
+
CREATE TABLE "ai_model_prices" (
|
|
75
|
+
"id" TEXT NOT NULL,
|
|
76
|
+
"provider" TEXT NOT NULL,
|
|
77
|
+
"model" TEXT NOT NULL,
|
|
78
|
+
"operation" TEXT NOT NULL,
|
|
79
|
+
"serviceTier" TEXT NOT NULL DEFAULT 'standard',
|
|
80
|
+
"inputNanoUsdPerMillion" BIGINT NOT NULL DEFAULT 0,
|
|
81
|
+
"outputNanoUsdPerMillion" BIGINT NOT NULL DEFAULT 0,
|
|
82
|
+
"cacheReadNanoUsdPerMillion" BIGINT NOT NULL DEFAULT 0,
|
|
83
|
+
"cacheWrite5mNanoUsdPerMillion" BIGINT NOT NULL DEFAULT 0,
|
|
84
|
+
"cacheWrite1hNanoUsdPerMillion" BIGINT NOT NULL DEFAULT 0,
|
|
85
|
+
"reasoningNanoUsdPerMillion" BIGINT NOT NULL DEFAULT 0,
|
|
86
|
+
"audioInNanoUsdPerMillion" BIGINT NOT NULL DEFAULT 0,
|
|
87
|
+
"audioOutNanoUsdPerMillion" BIGINT NOT NULL DEFAULT 0,
|
|
88
|
+
"imageInNanoUsdPerMillion" BIGINT NOT NULL DEFAULT 0,
|
|
89
|
+
"imageOutNanoUsdPerMillion" BIGINT NOT NULL DEFAULT 0,
|
|
90
|
+
"tierThresholdTokens" INTEGER,
|
|
91
|
+
"tierInputNanoUsdPerMillion" BIGINT,
|
|
92
|
+
"tierOutputNanoUsdPerMillion" BIGINT,
|
|
93
|
+
"unitRates" JSONB,
|
|
94
|
+
"currency" TEXT NOT NULL DEFAULT 'USD',
|
|
95
|
+
"effectiveFrom" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
96
|
+
"effectiveTo" TIMESTAMP(3),
|
|
97
|
+
"source" TEXT NOT NULL DEFAULT 'snapshot',
|
|
98
|
+
CONSTRAINT "ai_model_prices_pkey" PRIMARY KEY ("id")
|
|
99
|
+
);
|
|
100
|
+
CREATE INDEX "ai_model_prices_provider_model_operation_serviceTier_effecti_idx" ON "ai_model_prices" ("provider", "model", "operation", "serviceTier", "effectiveFrom");
|
|
101
|
+
|
|
102
|
+
-- ---------------------------------------------------------------------------
|
|
103
|
+
-- ai_wallets + ai_wallet_entries + ai_wallet_debit_allocations (wallet feature).
|
|
104
|
+
-- ---------------------------------------------------------------------------
|
|
105
|
+
CREATE TABLE "ai_wallets" (
|
|
106
|
+
"id" TEXT NOT NULL,
|
|
107
|
+
"tenantId" TEXT NOT NULL,
|
|
108
|
+
"ownerType" TEXT NOT NULL,
|
|
109
|
+
"ownerId" TEXT NOT NULL,
|
|
110
|
+
"balanceNanoUsd" BIGINT NOT NULL DEFAULT 0,
|
|
111
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
112
|
+
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
113
|
+
CONSTRAINT "ai_wallets_pkey" PRIMARY KEY ("id")
|
|
114
|
+
);
|
|
115
|
+
CREATE UNIQUE INDEX "ai_wallets_tenantId_ownerType_ownerId_key" ON "ai_wallets" ("tenantId", "ownerType", "ownerId");
|
|
116
|
+
|
|
117
|
+
CREATE TABLE "ai_wallet_entries" (
|
|
118
|
+
"id" TEXT NOT NULL,
|
|
119
|
+
"walletId" TEXT NOT NULL,
|
|
120
|
+
"type" TEXT NOT NULL,
|
|
121
|
+
"amountNanoUsd" BIGINT NOT NULL,
|
|
122
|
+
"priority" INTEGER NOT NULL DEFAULT 0,
|
|
123
|
+
"effectiveAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
124
|
+
"expiresAt" TIMESTAMP(3),
|
|
125
|
+
"usageRecordId" TEXT,
|
|
126
|
+
"idempotencyKey" TEXT NOT NULL,
|
|
127
|
+
"reason" TEXT,
|
|
128
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
129
|
+
CONSTRAINT "ai_wallet_entries_pkey" PRIMARY KEY ("id")
|
|
130
|
+
);
|
|
131
|
+
CREATE UNIQUE INDEX "ai_wallet_entries_walletId_idempotencyKey_key" ON "ai_wallet_entries" ("walletId", "idempotencyKey");
|
|
132
|
+
CREATE INDEX "ai_wallet_entries_walletId_effectiveAt_idx" ON "ai_wallet_entries" ("walletId", "effectiveAt");
|
|
133
|
+
CREATE INDEX "ai_wallet_entries_walletId_type_expiresAt_idx" ON "ai_wallet_entries" ("walletId", "type", "expiresAt");
|
|
134
|
+
|
|
135
|
+
CREATE TABLE "ai_wallet_debit_allocations" (
|
|
136
|
+
"id" TEXT NOT NULL,
|
|
137
|
+
"debitEntryId" TEXT NOT NULL,
|
|
138
|
+
"grantEntryId" TEXT NOT NULL,
|
|
139
|
+
"amountNanoUsd" BIGINT NOT NULL,
|
|
140
|
+
CONSTRAINT "ai_wallet_debit_allocations_pkey" PRIMARY KEY ("id")
|
|
141
|
+
);
|
|
142
|
+
CREATE INDEX "ai_wallet_debit_allocations_grantEntryId_idx" ON "ai_wallet_debit_allocations" ("grantEntryId");
|
|
143
|
+
CREATE INDEX "ai_wallet_debit_allocations_debitEntryId_idx" ON "ai_wallet_debit_allocations" ("debitEntryId");
|
|
144
|
+
|
|
145
|
+
-- ---------------------------------------------------------------------------
|
|
146
|
+
-- ai_budgets + ai_budget_windows (budget feature).
|
|
147
|
+
-- ---------------------------------------------------------------------------
|
|
148
|
+
CREATE TABLE "ai_budgets" (
|
|
149
|
+
"id" TEXT NOT NULL,
|
|
150
|
+
"tenantId" TEXT NOT NULL,
|
|
151
|
+
"scopeType" TEXT NOT NULL,
|
|
152
|
+
"scopeId" TEXT NOT NULL,
|
|
153
|
+
"features" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
|
154
|
+
"limitNanoUsd" BIGINT,
|
|
155
|
+
"limitTokens" BIGINT,
|
|
156
|
+
"limitCount" INTEGER,
|
|
157
|
+
"window" TEXT NOT NULL,
|
|
158
|
+
"anchorAt" TIMESTAMP(3),
|
|
159
|
+
"expiresAt" TIMESTAMP(3),
|
|
160
|
+
"softThresholds" JSONB NOT NULL,
|
|
161
|
+
"policy" TEXT NOT NULL DEFAULT 'block',
|
|
162
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
163
|
+
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
164
|
+
CONSTRAINT "ai_budgets_pkey" PRIMARY KEY ("id")
|
|
165
|
+
);
|
|
166
|
+
CREATE INDEX "ai_budgets_tenantId_scopeType_scopeId_idx" ON "ai_budgets" ("tenantId", "scopeType", "scopeId");
|
|
167
|
+
|
|
168
|
+
CREATE TABLE "ai_budget_windows" (
|
|
169
|
+
"id" TEXT NOT NULL,
|
|
170
|
+
"budgetId" TEXT NOT NULL,
|
|
171
|
+
"windowStart" TIMESTAMP(3) NOT NULL,
|
|
172
|
+
"spentNanoUsd" BIGINT NOT NULL DEFAULT 0,
|
|
173
|
+
"spentTokens" BIGINT NOT NULL DEFAULT 0,
|
|
174
|
+
"spentCount" INTEGER NOT NULL DEFAULT 0,
|
|
175
|
+
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
176
|
+
CONSTRAINT "ai_budget_windows_pkey" PRIMARY KEY ("id")
|
|
177
|
+
);
|
|
178
|
+
CREATE UNIQUE INDEX "ai_budget_windows_budgetId_windowStart_key" ON "ai_budget_windows" ("budgetId", "windowStart");
|
|
179
|
+
|
|
180
|
+
-- Foreign keys for the wallet/budget relation graph.
|
|
181
|
+
ALTER TABLE "ai_wallet_entries" ADD CONSTRAINT "ai_wallet_entries_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "ai_wallets" ("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
182
|
+
ALTER TABLE "ai_wallet_debit_allocations" ADD CONSTRAINT "ai_wallet_debit_allocations_debitEntryId_fkey" FOREIGN KEY ("debitEntryId") REFERENCES "ai_wallet_entries" ("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
183
|
+
ALTER TABLE "ai_wallet_debit_allocations" ADD CONSTRAINT "ai_wallet_debit_allocations_grantEntryId_fkey" FOREIGN KEY ("grantEntryId") REFERENCES "ai_wallet_entries" ("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
184
|
+
ALTER TABLE "ai_budget_windows" ADD CONSTRAINT "ai_budget_windows_budgetId_fkey" FOREIGN KEY ("budgetId") REFERENCES "ai_budgets" ("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
185
|
+
|
|
186
|
+
-- ---------------------------------------------------------------------------
|
|
187
|
+
-- PARTIAL indexes (PostgreSQL-only; not expressible in the Prisma schema).
|
|
188
|
+
-- ---------------------------------------------------------------------------
|
|
189
|
+
-- Exactly one OPEN price row per (provider, model, operation, serviceTier) — the
|
|
190
|
+
-- seed/upsert race guard that makes the effective-dated close-and-insert atomic.
|
|
191
|
+
CREATE UNIQUE INDEX "ai_model_prices_open_row_key"
|
|
192
|
+
ON "ai_model_prices" ("provider", "model", "operation", "serviceTier")
|
|
193
|
+
WHERE "effectiveTo" IS NULL;
|
|
194
|
+
|
|
195
|
+
-- Fast reaper scan over pending holds only.
|
|
196
|
+
CREATE INDEX "ai_usage_records_pending_createdAt_idx"
|
|
197
|
+
ON "ai_usage_records" ("createdAt")
|
|
198
|
+
WHERE "status" = 'pending';
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// @bymax-one/nest-ai-tokens — Prisma schema fragment (spec §15.3).
|
|
2
|
+
//
|
|
3
|
+
// The library owns its tables but not your ORM. The supported merge path is
|
|
4
|
+
// Prisma's MULTI-FILE SCHEMA (requires Prisma >= 6):
|
|
5
|
+
//
|
|
6
|
+
// 1. Copy this file into your host schema folder, e.g.
|
|
7
|
+
// `prisma/schema/ai-tokens.prisma` (keep your `datasource`/`generator`
|
|
8
|
+
// blocks in your main `prisma/schema/schema.prisma`).
|
|
9
|
+
// 2. Run `prisma migrate dev` — Prisma diffs these seven models and emits the
|
|
10
|
+
// migration for you.
|
|
11
|
+
//
|
|
12
|
+
// Hosts that manage SQL by hand instead apply the shipped
|
|
13
|
+
// `migrations/0001_init.sql` (which additionally creates the two PostgreSQL
|
|
14
|
+
// PARTIAL indexes that Prisma cannot express). Library upgrades that change the
|
|
15
|
+
// models ship new incremental SQL files plus a documented fragment diff.
|
|
16
|
+
//
|
|
17
|
+
// All money is `BigInt` nano-USD; every table carries `tenantId`. Table names are
|
|
18
|
+
// snake_case (via `@@map`); columns keep their camelCase field names — the
|
|
19
|
+
// official `PrismaAiTokensStore` adapter references them exactly as written here.
|
|
20
|
+
|
|
21
|
+
model AiUsageRecord {
|
|
22
|
+
id String @id @default(uuid())
|
|
23
|
+
tenantId String
|
|
24
|
+
scopeType String // tenant | team | user | key (the payer)
|
|
25
|
+
scopeId String
|
|
26
|
+
beneficiaryType String?
|
|
27
|
+
beneficiaryId String?
|
|
28
|
+
requestedBy String?
|
|
29
|
+
provider String
|
|
30
|
+
model String
|
|
31
|
+
requestedModel String?
|
|
32
|
+
operation String
|
|
33
|
+
serviceTier String @default("standard")
|
|
34
|
+
feature String
|
|
35
|
+
tags String[] @default([])
|
|
36
|
+
inputTokens Int @default(0)
|
|
37
|
+
outputTokens Int @default(0)
|
|
38
|
+
cacheReadTokens Int @default(0)
|
|
39
|
+
cacheWrite5mTokens Int @default(0)
|
|
40
|
+
cacheWrite1hTokens Int @default(0)
|
|
41
|
+
reasoningTokens Int @default(0)
|
|
42
|
+
audioInTokens Int @default(0)
|
|
43
|
+
audioOutTokens Int @default(0)
|
|
44
|
+
imageInTokens Int @default(0)
|
|
45
|
+
imageOutTokens Int @default(0)
|
|
46
|
+
totalTokens Int
|
|
47
|
+
extraUnits Json? // Record<string, number>
|
|
48
|
+
priceVersionId String? // no FK by design: records outlive price-row lifecycle; see index below
|
|
49
|
+
rawCostNanoUsd BigInt
|
|
50
|
+
surchargeNanoUsd BigInt @default(0)
|
|
51
|
+
billedCostNanoUsd BigInt
|
|
52
|
+
markupMultiplier Decimal @db.Decimal(10, 4)
|
|
53
|
+
currency String @default("USD")
|
|
54
|
+
priceMissing Boolean @default(false)
|
|
55
|
+
status String @default("posted") // record() fast path; holds insert 'pending'
|
|
56
|
+
reversedByRecordId String?
|
|
57
|
+
reversesRecordId String?
|
|
58
|
+
idempotencyKey String
|
|
59
|
+
payloadHash String
|
|
60
|
+
correlationId String?
|
|
61
|
+
requestId String?
|
|
62
|
+
isSystemCost Boolean @default(false)
|
|
63
|
+
systemCostCategory String?
|
|
64
|
+
enforced Boolean @default(false)
|
|
65
|
+
prevHash String?
|
|
66
|
+
hash String?
|
|
67
|
+
occurredAt DateTime
|
|
68
|
+
createdAt DateTime @default(now())
|
|
69
|
+
updatedAt DateTime @updatedAt
|
|
70
|
+
|
|
71
|
+
@@unique([tenantId, idempotencyKey])
|
|
72
|
+
@@index([tenantId, occurredAt])
|
|
73
|
+
@@index([tenantId, scopeType, scopeId, occurredAt])
|
|
74
|
+
@@index([tenantId, feature, occurredAt])
|
|
75
|
+
@@index([tenantId, provider, model])
|
|
76
|
+
@@index([tenantId, systemCostCategory])
|
|
77
|
+
@@index([tenantId, beneficiaryType, beneficiaryId])
|
|
78
|
+
@@index([priceVersionId])
|
|
79
|
+
@@index([status, createdAt]) // reaper scan; Postgres partial index (WHERE status = 'pending') in the shipped SQL
|
|
80
|
+
@@map("ai_usage_records")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
model AiModelPrice {
|
|
84
|
+
id String @id @default(uuid())
|
|
85
|
+
provider String
|
|
86
|
+
model String
|
|
87
|
+
operation String
|
|
88
|
+
serviceTier String @default("standard")
|
|
89
|
+
inputNanoUsdPerMillion BigInt @default(0)
|
|
90
|
+
outputNanoUsdPerMillion BigInt @default(0)
|
|
91
|
+
cacheReadNanoUsdPerMillion BigInt @default(0)
|
|
92
|
+
cacheWrite5mNanoUsdPerMillion BigInt @default(0)
|
|
93
|
+
cacheWrite1hNanoUsdPerMillion BigInt @default(0)
|
|
94
|
+
reasoningNanoUsdPerMillion BigInt @default(0)
|
|
95
|
+
audioInNanoUsdPerMillion BigInt @default(0)
|
|
96
|
+
audioOutNanoUsdPerMillion BigInt @default(0)
|
|
97
|
+
imageInNanoUsdPerMillion BigInt @default(0)
|
|
98
|
+
imageOutNanoUsdPerMillion BigInt @default(0)
|
|
99
|
+
tierThresholdTokens Int?
|
|
100
|
+
tierInputNanoUsdPerMillion BigInt?
|
|
101
|
+
tierOutputNanoUsdPerMillion BigInt?
|
|
102
|
+
unitRates Json? // Record<string, string> — nano-USD per unit as decimal strings
|
|
103
|
+
currency String @default("USD")
|
|
104
|
+
effectiveFrom DateTime @default(now())
|
|
105
|
+
effectiveTo DateTime?
|
|
106
|
+
source String @default("snapshot")
|
|
107
|
+
|
|
108
|
+
@@index([provider, model, operation, serviceTier, effectiveFrom])
|
|
109
|
+
// Shipped SQL adds: CREATE UNIQUE INDEX ... ON ai_model_prices ("provider", "model", "operation", "serviceTier")
|
|
110
|
+
// WHERE "effectiveTo" IS NULL; -- exactly one open row per key (seed/upsert race guard)
|
|
111
|
+
@@map("ai_model_prices")
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
model AiWallet {
|
|
115
|
+
id String @id @default(uuid())
|
|
116
|
+
tenantId String
|
|
117
|
+
ownerType String // tenant | team | user
|
|
118
|
+
ownerId String
|
|
119
|
+
balanceNanoUsd BigInt @default(0) // materialized; source of truth = Σ entries (§9.4)
|
|
120
|
+
createdAt DateTime @default(now())
|
|
121
|
+
updatedAt DateTime @updatedAt
|
|
122
|
+
entries AiWalletEntry[]
|
|
123
|
+
|
|
124
|
+
@@unique([tenantId, ownerType, ownerId])
|
|
125
|
+
@@map("ai_wallets")
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
model AiWalletEntry {
|
|
129
|
+
id String @id @default(uuid())
|
|
130
|
+
walletId String
|
|
131
|
+
type String // grant | debit | refund | adjustment | expiry
|
|
132
|
+
amountNanoUsd BigInt
|
|
133
|
+
priority Int @default(0)
|
|
134
|
+
effectiveAt DateTime @default(now())
|
|
135
|
+
expiresAt DateTime?
|
|
136
|
+
usageRecordId String?
|
|
137
|
+
idempotencyKey String
|
|
138
|
+
reason String?
|
|
139
|
+
createdAt DateTime @default(now())
|
|
140
|
+
wallet AiWallet @relation(fields: [walletId], references: [id])
|
|
141
|
+
allocationsAsDebit AiWalletDebitAllocation[] @relation("debit")
|
|
142
|
+
allocationsAsGrant AiWalletDebitAllocation[] @relation("grant")
|
|
143
|
+
|
|
144
|
+
@@unique([walletId, idempotencyKey])
|
|
145
|
+
@@index([walletId, effectiveAt])
|
|
146
|
+
@@index([walletId, type, expiresAt])
|
|
147
|
+
@@map("ai_wallet_entries")
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
model AiWalletDebitAllocation {
|
|
151
|
+
id String @id @default(uuid())
|
|
152
|
+
debitEntryId String
|
|
153
|
+
grantEntryId String
|
|
154
|
+
amountNanoUsd BigInt
|
|
155
|
+
debitEntry AiWalletEntry @relation("debit", fields: [debitEntryId], references: [id])
|
|
156
|
+
grantEntry AiWalletEntry @relation("grant", fields: [grantEntryId], references: [id])
|
|
157
|
+
|
|
158
|
+
@@index([grantEntryId])
|
|
159
|
+
@@index([debitEntryId])
|
|
160
|
+
@@map("ai_wallet_debit_allocations")
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
model AiBudget {
|
|
164
|
+
id String @id @default(uuid())
|
|
165
|
+
tenantId String
|
|
166
|
+
scopeType String
|
|
167
|
+
scopeId String
|
|
168
|
+
features String[] @default([])
|
|
169
|
+
limitNanoUsd BigInt?
|
|
170
|
+
limitTokens BigInt?
|
|
171
|
+
limitCount Int?
|
|
172
|
+
window String // day | week | month | total | custom:<seconds>
|
|
173
|
+
anchorAt DateTime?
|
|
174
|
+
expiresAt DateTime?
|
|
175
|
+
softThresholds Json // number[]
|
|
176
|
+
policy String @default("block")
|
|
177
|
+
createdAt DateTime @default(now())
|
|
178
|
+
updatedAt DateTime @updatedAt
|
|
179
|
+
windows AiBudgetWindow[]
|
|
180
|
+
|
|
181
|
+
@@index([tenantId, scopeType, scopeId])
|
|
182
|
+
@@map("ai_budgets")
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
model AiBudgetWindow {
|
|
186
|
+
id String @id @default(uuid())
|
|
187
|
+
budgetId String
|
|
188
|
+
windowStart DateTime
|
|
189
|
+
spentNanoUsd BigInt @default(0)
|
|
190
|
+
spentTokens BigInt @default(0) // BigInt: 'total' windows on busy tenants exceed int4
|
|
191
|
+
spentCount Int @default(0)
|
|
192
|
+
updatedAt DateTime @updatedAt
|
|
193
|
+
budget AiBudget @relation(fields: [budgetId], references: [id])
|
|
194
|
+
|
|
195
|
+
@@unique([budgetId, windowStart])
|
|
196
|
+
@@map("ai_budget_windows")
|
|
197
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/redis/index.ts
|
|
4
|
+
var SAFE_INTEGER_MAX = "9007199254740991";
|
|
5
|
+
var INCR_IF_BELOW_LUA = `
|
|
6
|
+
local amount = tonumber(ARGV[1])
|
|
7
|
+
local limit = tonumber(ARGV[2])
|
|
8
|
+
if amount > ${SAFE_INTEGER_MAX} or amount < -${SAFE_INTEGER_MAX} or limit > ${SAFE_INTEGER_MAX} or limit < -${SAFE_INTEGER_MAX} then
|
|
9
|
+
return -1
|
|
10
|
+
end
|
|
11
|
+
local newValue = redis.call('INCRBY', KEYS[1], ARGV[1])
|
|
12
|
+
if newValue > ${SAFE_INTEGER_MAX} or newValue < -${SAFE_INTEGER_MAX} then
|
|
13
|
+
redis.call('DECRBY', KEYS[1], ARGV[1])
|
|
14
|
+
return -1
|
|
15
|
+
end
|
|
16
|
+
if newValue > limit then
|
|
17
|
+
redis.call('DECRBY', KEYS[1], ARGV[1])
|
|
18
|
+
return 0
|
|
19
|
+
end
|
|
20
|
+
redis.call('PEXPIRE', KEYS[1], ARGV[3])
|
|
21
|
+
return 1
|
|
22
|
+
`;
|
|
23
|
+
var DECR_FLOOR_LUA = `
|
|
24
|
+
local newValue = redis.call('DECRBY', KEYS[1], ARGV[1])
|
|
25
|
+
if newValue < 0 then
|
|
26
|
+
redis.call('SET', KEYS[1], '0')
|
|
27
|
+
end
|
|
28
|
+
return 1
|
|
29
|
+
`;
|
|
30
|
+
var CounterValueOutOfRangeError = class extends Error {
|
|
31
|
+
isCounterValueOutOfRange = true;
|
|
32
|
+
constructor() {
|
|
33
|
+
super("counter operand or result exceeds the safe-integer range");
|
|
34
|
+
this.name = "CounterValueOutOfRangeError";
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
var RedisBudgetCounterStore = class {
|
|
38
|
+
keyPrefix;
|
|
39
|
+
source;
|
|
40
|
+
connecting = null;
|
|
41
|
+
/**
|
|
42
|
+
* @param redis A live `ioredis` client, or a connection URL to lazily connect.
|
|
43
|
+
* @param options Optional key prefix.
|
|
44
|
+
*/
|
|
45
|
+
constructor(redis, options = {}) {
|
|
46
|
+
this.keyPrefix = options.keyPrefix ?? "";
|
|
47
|
+
this.source = redis;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Atomically increment `key` by `amount` iff the result stays `<= limit`, setting
|
|
51
|
+
* the TTL. Returns `false` when the increment would exceed the limit.
|
|
52
|
+
*
|
|
53
|
+
* @param key The dimension counter key.
|
|
54
|
+
* @param amount The amount to add (int64 nano-USD or token/count units).
|
|
55
|
+
* @param limit The dimension limit.
|
|
56
|
+
* @param ttlSeconds The key TTL (window length + grace).
|
|
57
|
+
* @returns `true` when incremented within the limit, `false` when it would exceed it.
|
|
58
|
+
* @throws {CounterValueOutOfRangeError} when an operand or the resulting counter exceeds the safe-integer range.
|
|
59
|
+
*/
|
|
60
|
+
async incrIfBelow(key, amount, limit, ttlSeconds) {
|
|
61
|
+
const client = await this.resolve();
|
|
62
|
+
const result = await client.eval(
|
|
63
|
+
INCR_IF_BELOW_LUA,
|
|
64
|
+
1,
|
|
65
|
+
this.prefixed(key),
|
|
66
|
+
amount.toString(),
|
|
67
|
+
limit.toString(),
|
|
68
|
+
Math.ceil(ttlSeconds * 1e3).toString()
|
|
69
|
+
);
|
|
70
|
+
if (result === -1) throw new CounterValueOutOfRangeError();
|
|
71
|
+
return result === 1;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Decrement `key` by `amount`, flooring the stored value at zero (release/reverse).
|
|
75
|
+
*
|
|
76
|
+
* @param key The dimension counter key.
|
|
77
|
+
* @param amount The amount to subtract.
|
|
78
|
+
*/
|
|
79
|
+
async decr(key, amount) {
|
|
80
|
+
const client = await this.resolve();
|
|
81
|
+
await client.eval(DECR_FLOOR_LUA, 1, this.prefixed(key), amount.toString());
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Reset (delete) `key` — used on window rotation.
|
|
85
|
+
*
|
|
86
|
+
* @param key The dimension counter key.
|
|
87
|
+
*/
|
|
88
|
+
async reset(key) {
|
|
89
|
+
const client = await this.resolve();
|
|
90
|
+
await client.del(this.prefixed(key));
|
|
91
|
+
}
|
|
92
|
+
/** Prepend the configured key prefix. */
|
|
93
|
+
prefixed(key) {
|
|
94
|
+
return `${this.keyPrefix}${key}`;
|
|
95
|
+
}
|
|
96
|
+
/** Resolve the live client, lazily connecting a URL via a dynamic `ioredis` import. */
|
|
97
|
+
resolve() {
|
|
98
|
+
if (typeof this.source !== "string") return Promise.resolve(this.source);
|
|
99
|
+
this.connecting ??= this.connect(this.source);
|
|
100
|
+
return this.connecting;
|
|
101
|
+
}
|
|
102
|
+
/** Dynamically import `ioredis` and connect the given URL. */
|
|
103
|
+
async connect(url) {
|
|
104
|
+
const { default: RedisClient } = await import('ioredis');
|
|
105
|
+
return new RedisClient(url);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
exports.CounterValueOutOfRangeError = CounterValueOutOfRangeError;
|
|
110
|
+
exports.RedisBudgetCounterStore = RedisBudgetCounterStore;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Redis } from 'ioredis';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview The optional live cross-replica budget counter port (spec §15.1,
|
|
5
|
+
* §10.8). The fast path for budget enforcement; the DB conditional consume is the
|
|
6
|
+
* fallback. Values are serialized as int64 decimal strings. Implemented by the
|
|
7
|
+
* official Redis adapter (`./redis`).
|
|
8
|
+
* @layer server
|
|
9
|
+
*/
|
|
10
|
+
/** The live budget counter port. */
|
|
11
|
+
interface IBudgetCounterStore {
|
|
12
|
+
/**
|
|
13
|
+
* Atomically increment `key` by `amount` iff the result stays `<= limit`,
|
|
14
|
+
* setting the TTL. Returns `false` when the increment would exceed the limit.
|
|
15
|
+
*/
|
|
16
|
+
incrIfBelow(key: string, amount: bigint, limit: bigint, ttlSeconds: number): Promise<boolean>;
|
|
17
|
+
/** Decrement `key` by `amount` (release/reverse). */
|
|
18
|
+
decr(key: string, amount: bigint): Promise<void>;
|
|
19
|
+
/** Reset `key` (window rotation). */
|
|
20
|
+
reset(key: string): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @fileoverview `RedisBudgetCounterStore` — the optional live cross-replica budget
|
|
25
|
+
* counter (`./redis` subpath, spec §10.8, §15.1). It makes budget enforcement cheap
|
|
26
|
+
* under high concurrency: `incrIfBelow` is a SINGLE atomic Lua script (increment,
|
|
27
|
+
* then undo and reject when the result would exceed the limit), so two racing
|
|
28
|
+
* replicas can never both pass a nearly-full budget. The DB conditional consume
|
|
29
|
+
* stays authoritative — this counter is the fast path, not the source of truth.
|
|
30
|
+
* Values are int64 (Redis native) written/compared as decimal strings; keys carry
|
|
31
|
+
* the caller's optional prefix. `ioredis` is an OPTIONAL peer: the constructor
|
|
32
|
+
* accepts a live `Redis` instance, or a connection URL that is lazily connected via
|
|
33
|
+
* a DYNAMIC import so the base library never statically depends on the client.
|
|
34
|
+
* Connection URLs (which may embed credentials) are never logged or placed on an
|
|
35
|
+
* exception.
|
|
36
|
+
* @layer redis
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** Construction options for {@link RedisBudgetCounterStore}. */
|
|
40
|
+
interface RedisBudgetCounterStoreOptions {
|
|
41
|
+
/** Prepended to every counter key (namespacing a shared Redis). */
|
|
42
|
+
keyPrefix?: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Signals that a counter operand or result falls outside the IEEE-754 safe-integer
|
|
46
|
+
* range (`±(2^53 − 1)`), where the Lua/Redis double arithmetic the fast path relies
|
|
47
|
+
* on would silently lose precision. It is thrown so `BudgetService` treats the counter
|
|
48
|
+
* as unavailable and falls back to the authoritative DB conditional consume, which
|
|
49
|
+
* compares int64 nano-USD exactly.
|
|
50
|
+
*/
|
|
51
|
+
declare class CounterValueOutOfRangeError extends Error {
|
|
52
|
+
readonly isCounterValueOutOfRange = true;
|
|
53
|
+
constructor();
|
|
54
|
+
}
|
|
55
|
+
/** The official Redis adapter for the live budget counter port. */
|
|
56
|
+
declare class RedisBudgetCounterStore implements IBudgetCounterStore {
|
|
57
|
+
private readonly keyPrefix;
|
|
58
|
+
private readonly source;
|
|
59
|
+
private connecting;
|
|
60
|
+
/**
|
|
61
|
+
* @param redis A live `ioredis` client, or a connection URL to lazily connect.
|
|
62
|
+
* @param options Optional key prefix.
|
|
63
|
+
*/
|
|
64
|
+
constructor(redis: Redis | string, options?: RedisBudgetCounterStoreOptions);
|
|
65
|
+
/**
|
|
66
|
+
* Atomically increment `key` by `amount` iff the result stays `<= limit`, setting
|
|
67
|
+
* the TTL. Returns `false` when the increment would exceed the limit.
|
|
68
|
+
*
|
|
69
|
+
* @param key The dimension counter key.
|
|
70
|
+
* @param amount The amount to add (int64 nano-USD or token/count units).
|
|
71
|
+
* @param limit The dimension limit.
|
|
72
|
+
* @param ttlSeconds The key TTL (window length + grace).
|
|
73
|
+
* @returns `true` when incremented within the limit, `false` when it would exceed it.
|
|
74
|
+
* @throws {CounterValueOutOfRangeError} when an operand or the resulting counter exceeds the safe-integer range.
|
|
75
|
+
*/
|
|
76
|
+
incrIfBelow(key: string, amount: bigint, limit: bigint, ttlSeconds: number): Promise<boolean>;
|
|
77
|
+
/**
|
|
78
|
+
* Decrement `key` by `amount`, flooring the stored value at zero (release/reverse).
|
|
79
|
+
*
|
|
80
|
+
* @param key The dimension counter key.
|
|
81
|
+
* @param amount The amount to subtract.
|
|
82
|
+
*/
|
|
83
|
+
decr(key: string, amount: bigint): Promise<void>;
|
|
84
|
+
/**
|
|
85
|
+
* Reset (delete) `key` — used on window rotation.
|
|
86
|
+
*
|
|
87
|
+
* @param key The dimension counter key.
|
|
88
|
+
*/
|
|
89
|
+
reset(key: string): Promise<void>;
|
|
90
|
+
/** Prepend the configured key prefix. */
|
|
91
|
+
private prefixed;
|
|
92
|
+
/** Resolve the live client, lazily connecting a URL via a dynamic `ioredis` import. */
|
|
93
|
+
private resolve;
|
|
94
|
+
/** Dynamically import `ioredis` and connect the given URL. */
|
|
95
|
+
private connect;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export { CounterValueOutOfRangeError, RedisBudgetCounterStore, type RedisBudgetCounterStoreOptions };
|