usage-sdk-test 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # @chronnote/usage
2
+
3
+ 一个 Bun/Node 服务端可用的、解耦的“积分(credits)/用量(usage)/限额(limits)”SDK(v0:预付余额、CNY、accountId 级 RPM、日/月额度按 credits)。
4
+
5
+ ## Quick Start
6
+
7
+ ```ts
8
+ import { createUsageSdk } from "@chronnote/usage";
9
+
10
+ const sdk = createUsageSdk({
11
+ plans: [
12
+ {
13
+ planId: "default",
14
+ rules: [
15
+ {
16
+ meter: "tokens.input",
17
+ when: { model: "*", provider: "*" },
18
+ unitPriceMinor: 1, // 1 分 / token(示例)
19
+ currency: "CNY",
20
+ },
21
+ {
22
+ meter: "tokens.output",
23
+ when: { model: "*", provider: "*" },
24
+ unitPriceMinor: 2,
25
+ currency: "CNY",
26
+ },
27
+ ],
28
+ },
29
+ ],
30
+ initialBalances: [
31
+ { accountId: "u1", money: { currency: "CNY", amountMinor: 10_000 } },
32
+ ],
33
+ });
34
+
35
+ const result = await sdk.enforce({
36
+ accountId: "u1",
37
+ planId: "default",
38
+ idempotencyKey: "req_001",
39
+ limits: { rpm: 60, dailyLimitMinor: 5000, monthlyLimitMinor: 50_000 },
40
+ events: [
41
+ { meter: "tokens.input", quantity: 1000, dimensions: { model: "gpt-4.1", provider: "openai" } },
42
+ { meter: "tokens.output", quantity: 300, dimensions: { model: "gpt-4.1", provider: "openai" } },
43
+ ],
44
+ });
45
+
46
+ if (!result.ok) {
47
+ throw new Error(`${result.code}: ${result.message ?? ""}`);
48
+ }
49
+ console.log("cost", result.cost, "balanceAfter", result.balanceAfter);
50
+ ```
51
+
52
+ ## Run Example (Bun)
53
+
54
+ ```bash
55
+ bun packages/usage/examples/basic.ts
56
+ ```
@@ -0,0 +1,35 @@
1
+ import { createUsageSdk } from "../src/index.ts";
2
+
3
+ const sdk = createUsageSdk({
4
+ plans: [
5
+ {
6
+ planId: "default",
7
+ rules: [
8
+ { meter: "tokens.input", when: { model: "*", provider: "*" }, unitPriceMinor: 1, currency: "CNY", ruleId: "in" },
9
+ { meter: "tokens.output", when: { model: "*", provider: "*" }, unitPriceMinor: 2, currency: "CNY", ruleId: "out" },
10
+ ],
11
+ },
12
+ ],
13
+ initialBalances: [{ accountId: "u1", money: { currency: "CNY", amountMinor: 10_000 } }],
14
+ });
15
+
16
+ const run = async () => {
17
+ const before = await sdk.getBalance({ accountId: "u1", currency: "CNY" });
18
+ console.log("before", before);
19
+
20
+ const result = await sdk.enforce({
21
+ accountId: "u1",
22
+ planId: "default",
23
+ idempotencyKey: "req_001",
24
+ limits: { rpm: 60, dailyLimitMinor: 5000, monthlyLimitMinor: 50_000 },
25
+ events: [
26
+ { meter: "tokens.input", quantity: 1000, dimensions: { model: "gpt-4.1", provider: "openai" } },
27
+ { meter: "tokens.output", quantity: 300, dimensions: { model: "gpt-4.1", provider: "openai" } },
28
+ ],
29
+ });
30
+
31
+ console.log("enforce", result);
32
+ };
33
+
34
+ await run();
35
+
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "usage-sdk-test",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.ts",
9
+ "default": "./src/index.ts"
10
+ }
11
+ },
12
+ "scripts": {
13
+ "build": "tsc -p tsconfig.build.json",
14
+ "typecheck": "tsc -p tsconfig.json"
15
+ },
16
+ "devDependencies": {
17
+ "typescript": "^5.9.2"
18
+ }
19
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,19 @@
1
+ import type { EnforceErrorCode } from "./types.ts";
2
+
3
+ export class UsageError extends Error {
4
+ readonly code: EnforceErrorCode;
5
+ readonly retryAfterMs?: number;
6
+
7
+ constructor(code: EnforceErrorCode, message?: string, retryAfterMs?: number) {
8
+ super(message ?? code);
9
+ this.code = code;
10
+ this.retryAfterMs = retryAfterMs;
11
+ }
12
+ }
13
+
14
+ export const toEnforceResultError = (err: unknown) => {
15
+ if (err instanceof UsageError) {
16
+ return { ok: false as const, code: err.code, message: err.message, retryAfterMs: err.retryAfterMs };
17
+ }
18
+ return { ok: false as const, code: "INVALID_ARGUMENT" as const, message: err instanceof Error ? err.message : String(err) };
19
+ };
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./types.ts";
2
+ export * from "./errors.ts";
3
+ export { createUsageSdk } from "./sdk/createUsageSdk.ts";
@@ -0,0 +1,36 @@
1
+ import type { PriceRule, UsageEvent } from "../types.ts";
2
+
3
+ const matchWhen = (when: PriceRule["when"] | undefined, dimensions: UsageEvent["dimensions"] | undefined) => {
4
+ if (!when) return { ok: true, score: 0 };
5
+ const dims = dimensions ?? {};
6
+ let score = 0;
7
+ for (const [key, expected] of Object.entries(when)) {
8
+ const actual = dims[key];
9
+ if (expected === "*") continue;
10
+ if (Array.isArray(expected)) {
11
+ if (!actual || !expected.includes(actual)) return { ok: false, score: 0 };
12
+ score += 1;
13
+ continue;
14
+ }
15
+ if (actual !== expected) return { ok: false, score: 0 };
16
+ score += 1;
17
+ }
18
+ return { ok: true, score };
19
+ };
20
+
21
+ export const pickBestRule = (rules: PriceRule[], event: UsageEvent): PriceRule | null => {
22
+ const candidates: Array<{ rule: PriceRule; score: number; priority: number }> = [];
23
+ for (const rule of rules) {
24
+ if (rule.meter !== event.meter) continue;
25
+ const matched = matchWhen(rule.when, event.dimensions);
26
+ if (!matched.ok) continue;
27
+ candidates.push({ rule, score: matched.score, priority: rule.priority ?? 0 });
28
+ }
29
+ if (candidates.length === 0) return null;
30
+ candidates.sort((a, b) => {
31
+ if (b.score !== a.score) return b.score - a.score;
32
+ if (b.priority !== a.priority) return b.priority - a.priority;
33
+ return 0;
34
+ });
35
+ return candidates[0]?.rule ?? null;
36
+ };
@@ -0,0 +1,38 @@
1
+ import { UsageError } from "../errors.ts";
2
+ import type { Money, PricePlan, Quote, QuoteInput, QuoteItem } from "../types.ts";
3
+ import { assertNonNegativeInteger, assertUsageEvent } from "../utils/guards.ts";
4
+ import { pickBestRule } from "./matchRule.ts";
5
+
6
+ export const quoteWithPlan = (input: QuoteInput, plan: PricePlan): Quote => {
7
+ if (!plan.rules.length) throw new UsageError("NO_PRICE_RULE", `plan ${plan.planId} has no rules`);
8
+
9
+ const items: QuoteItem[] = [];
10
+ let currency: Money["currency"] | null = null;
11
+ let totalMinor = 0;
12
+
13
+ for (let i = 0; i < input.events.length; i++) {
14
+ const event = input.events[i]!;
15
+ assertUsageEvent(event, `events[${i}]`);
16
+
17
+ const rule = pickBestRule(plan.rules, event);
18
+ if (!rule) {
19
+ throw new UsageError("NO_PRICE_RULE", `no price rule for meter=${event.meter}`);
20
+ }
21
+
22
+ if (!currency) currency = rule.currency;
23
+ if (currency !== rule.currency) {
24
+ throw new UsageError("CURRENCY_MISMATCH", `mixed currencies in a single quote are not supported (got ${currency} and ${rule.currency})`);
25
+ }
26
+
27
+ assertNonNegativeInteger(rule.unitPriceMinor, "rule.unitPriceMinor");
28
+
29
+ const costMinor = rule.unitPriceMinor * event.quantity;
30
+ assertNonNegativeInteger(costMinor, "computed costMinor");
31
+ totalMinor += costMinor;
32
+
33
+ items.push({ event, costMinor, ruleId: rule.ruleId });
34
+ }
35
+
36
+ const total: Money = { currency: currency ?? "CNY", amountMinor: totalMinor };
37
+ return { total, items };
38
+ };
@@ -0,0 +1,90 @@
1
+ import { UsageError } from "../../errors.ts";
2
+ import type { LedgerApplyResult, LedgerCreditInput, LedgerDebitInput, LedgerStore } from "../../stores/LedgerStore.ts";
3
+ import type { Money } from "../../types.ts";
4
+ import { assertMoney, assertNonNegativeInteger } from "../../utils/guards.ts";
5
+
6
+ const makeBalanceKey = (accountId: string, currency: Money["currency"]) => `${accountId}::${currency}`;
7
+
8
+ const randomId = () => {
9
+ const c = globalThis.crypto as Crypto | undefined;
10
+ if (c?.randomUUID) return c.randomUUID();
11
+ return `tx_${Date.now()}_${Math.random().toString(16).slice(2)}`;
12
+ };
13
+
14
+ type IdempotencyRecord = {
15
+ fingerprint: string;
16
+ result: LedgerApplyResult;
17
+ };
18
+
19
+ export class MemoryLedgerStore implements LedgerStore {
20
+ private readonly balances = new Map<string, number>();
21
+ private readonly idempotency = new Map<string, IdempotencyRecord>();
22
+
23
+ constructor(initialBalances?: Array<{ accountId: string; money: Money }>) {
24
+ for (const b of initialBalances ?? []) {
25
+ assertMoney(b.money, "initialBalances[].money");
26
+ const key = makeBalanceKey(b.accountId, b.money.currency);
27
+ this.balances.set(key, b.money.amountMinor);
28
+ }
29
+ }
30
+
31
+ async getBalance(input: { accountId: string; currency: Money["currency"] }): Promise<Money> {
32
+ const key = makeBalanceKey(input.accountId, input.currency);
33
+ const amountMinor = this.balances.get(key) ?? 0;
34
+ return { currency: input.currency, amountMinor };
35
+ }
36
+
37
+ async debit(input: LedgerDebitInput): Promise<LedgerApplyResult> {
38
+ assertMoney(input.money, "money");
39
+ if (!input.idempotencyKey) throw new UsageError("INVALID_ARGUMENT", "idempotencyKey is required");
40
+ if (!input.fingerprint) throw new UsageError("INVALID_ARGUMENT", "fingerprint is required");
41
+ assertNonNegativeInteger(input.nowMs, "nowMs");
42
+
43
+ const existing = this.idempotency.get(input.idempotencyKey);
44
+ if (existing) {
45
+ if (existing.fingerprint !== input.fingerprint) throw new UsageError("IDEMPOTENCY_CONFLICT", "idempotencyKey already used with different input");
46
+ return existing.result;
47
+ }
48
+
49
+ const key = makeBalanceKey(input.accountId, input.money.currency);
50
+ const current = this.balances.get(key) ?? 0;
51
+ if (current < input.money.amountMinor) throw new UsageError("INSUFFICIENT_BALANCE", "insufficient balance");
52
+
53
+ const next = current - input.money.amountMinor;
54
+ this.balances.set(key, next);
55
+
56
+ const result: LedgerApplyResult = {
57
+ txId: randomId(),
58
+ balanceAfter: { currency: input.money.currency, amountMinor: next },
59
+ };
60
+
61
+ this.idempotency.set(input.idempotencyKey, { fingerprint: input.fingerprint, result });
62
+ return result;
63
+ }
64
+
65
+ async credit(input: LedgerCreditInput): Promise<LedgerApplyResult> {
66
+ assertMoney(input.money, "money");
67
+ if (!input.idempotencyKey) throw new UsageError("INVALID_ARGUMENT", "idempotencyKey is required");
68
+ if (!input.fingerprint) throw new UsageError("INVALID_ARGUMENT", "fingerprint is required");
69
+ assertNonNegativeInteger(input.nowMs, "nowMs");
70
+
71
+ const existing = this.idempotency.get(input.idempotencyKey);
72
+ if (existing) {
73
+ if (existing.fingerprint !== input.fingerprint) throw new UsageError("IDEMPOTENCY_CONFLICT", "idempotencyKey already used with different input");
74
+ return existing.result;
75
+ }
76
+
77
+ const key = makeBalanceKey(input.accountId, input.money.currency);
78
+ const current = this.balances.get(key) ?? 0;
79
+ const next = current + input.money.amountMinor;
80
+ this.balances.set(key, next);
81
+
82
+ const result: LedgerApplyResult = {
83
+ txId: randomId(),
84
+ balanceAfter: { currency: input.money.currency, amountMinor: next },
85
+ };
86
+
87
+ this.idempotency.set(input.idempotencyKey, { fingerprint: input.fingerprint, result });
88
+ return result;
89
+ }
90
+ }
@@ -0,0 +1,102 @@
1
+ import { UsageError } from "../../errors.ts";
2
+ import type { LimitStore } from "../../stores/LimitStore.ts";
3
+ import { assertNonNegativeInteger } from "../../utils/guards.ts";
4
+
5
+ type Counter = { value: number; expiresAtMs: number };
6
+
7
+ const pad2 = (n: number) => String(n).padStart(2, "0");
8
+
9
+ const getMinuteKey = (nowMs: number) => {
10
+ const d = new Date(nowMs);
11
+ return `${d.getUTCFullYear()}${pad2(d.getUTCMonth() + 1)}${pad2(d.getUTCDate())}${pad2(d.getUTCHours())}${pad2(d.getUTCMinutes())}`;
12
+ };
13
+
14
+ const getDayKey = (nowMs: number) => {
15
+ const d = new Date(nowMs);
16
+ return `${d.getUTCFullYear()}${pad2(d.getUTCMonth() + 1)}${pad2(d.getUTCDate())}`;
17
+ };
18
+
19
+ const getMonthKey = (nowMs: number) => {
20
+ const d = new Date(nowMs);
21
+ return `${d.getUTCFullYear()}${pad2(d.getUTCMonth() + 1)}`;
22
+ };
23
+
24
+ const endOfUtcMinute = (nowMs: number) => Math.floor(nowMs / 60000) * 60000 + 60000;
25
+
26
+ const endOfUtcDay = (nowMs: number) => {
27
+ const d = new Date(nowMs);
28
+ const next = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1, 0, 0, 0, 0);
29
+ return next;
30
+ };
31
+
32
+ const endOfUtcMonth = (nowMs: number) => {
33
+ const d = new Date(nowMs);
34
+ const next = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1, 0, 0, 0, 0);
35
+ return next;
36
+ };
37
+
38
+ export class MemoryLimitStore implements LimitStore {
39
+ private readonly counters = new Map<string, Counter>();
40
+
41
+ private getCounter(key: string, nowMs: number): Counter | null {
42
+ const c = this.counters.get(key);
43
+ if (!c) return null;
44
+ if (c.expiresAtMs <= nowMs) {
45
+ this.counters.delete(key);
46
+ return null;
47
+ }
48
+ return c;
49
+ }
50
+
51
+ private setCounter(key: string, value: number, expiresAtMs: number) {
52
+ this.counters.set(key, { value, expiresAtMs });
53
+ }
54
+
55
+ async consumeRpm(input: { accountId: string; rpm: number; requestCount: number; nowMs: number }): Promise<void> {
56
+ assertNonNegativeInteger(input.nowMs, "nowMs");
57
+ assertNonNegativeInteger(input.requestCount, "requestCount");
58
+ if (input.rpm <= 0) throw new UsageError("RPM_LIMIT_EXCEEDED", "rpm limit exceeded", 60_000);
59
+
60
+ const window = getMinuteKey(input.nowMs);
61
+ const key = `rpm::${input.accountId}::${window}`;
62
+ const current = this.getCounter(key, input.nowMs)?.value ?? 0;
63
+
64
+ const next = current + input.requestCount;
65
+ if (next > input.rpm) {
66
+ const retryAfterMs = Math.max(0, endOfUtcMinute(input.nowMs) - input.nowMs);
67
+ throw new UsageError("RPM_LIMIT_EXCEEDED", "rpm limit exceeded", retryAfterMs);
68
+ }
69
+
70
+ this.setCounter(key, next, endOfUtcMinute(input.nowMs));
71
+ }
72
+
73
+ async consumeDailyMinor(input: { accountId: string; amountMinor: number; limitMinor: number; nowMs: number }): Promise<void> {
74
+ assertNonNegativeInteger(input.nowMs, "nowMs");
75
+ assertNonNegativeInteger(input.amountMinor, "amountMinor");
76
+ if (input.limitMinor <= 0) throw new UsageError("DAILY_LIMIT_EXCEEDED", "daily limit exceeded");
77
+
78
+ const window = getDayKey(input.nowMs);
79
+ const key = `dailyMinor::${input.accountId}::${window}`;
80
+ const current = this.getCounter(key, input.nowMs)?.value ?? 0;
81
+
82
+ const next = current + input.amountMinor;
83
+ if (next > input.limitMinor) throw new UsageError("DAILY_LIMIT_EXCEEDED", "daily limit exceeded");
84
+
85
+ this.setCounter(key, next, endOfUtcDay(input.nowMs));
86
+ }
87
+
88
+ async consumeMonthlyMinor(input: { accountId: string; amountMinor: number; limitMinor: number; nowMs: number }): Promise<void> {
89
+ assertNonNegativeInteger(input.nowMs, "nowMs");
90
+ assertNonNegativeInteger(input.amountMinor, "amountMinor");
91
+ if (input.limitMinor <= 0) throw new UsageError("MONTHLY_LIMIT_EXCEEDED", "monthly limit exceeded");
92
+
93
+ const window = getMonthKey(input.nowMs);
94
+ const key = `monthlyMinor::${input.accountId}::${window}`;
95
+ const current = this.getCounter(key, input.nowMs)?.value ?? 0;
96
+
97
+ const next = current + input.amountMinor;
98
+ if (next > input.limitMinor) throw new UsageError("MONTHLY_LIMIT_EXCEEDED", "monthly limit exceeded");
99
+
100
+ this.setCounter(key, next, endOfUtcMonth(input.nowMs));
101
+ }
102
+ }
@@ -0,0 +1,14 @@
1
+ import type { PlanStore } from "../../stores/PlanStore.ts";
2
+ import type { PricePlan } from "../../types.ts";
3
+
4
+ export class MemoryPlanStore implements PlanStore {
5
+ private readonly plans = new Map<string, PricePlan>();
6
+
7
+ constructor(plans: PricePlan[]) {
8
+ for (const plan of plans) this.plans.set(plan.planId, plan);
9
+ }
10
+
11
+ async getPlan(planId: string): Promise<PricePlan | null> {
12
+ return this.plans.get(planId) ?? null;
13
+ }
14
+ }
@@ -0,0 +1,10 @@
1
+ import type { UsageStore } from "../../stores/UsageStore.ts";
2
+ import type { UsageRecord } from "../../types.ts";
3
+
4
+ export class MemoryUsageStore implements UsageStore {
5
+ readonly records: UsageRecord[] = [];
6
+
7
+ async append(record: UsageRecord): Promise<void> {
8
+ this.records.push(record);
9
+ }
10
+ }
@@ -0,0 +1,117 @@
1
+ import type { LedgerStore } from "../stores/LedgerStore.ts";
2
+ import type { LimitStore } from "../stores/LimitStore.ts";
3
+ import type { PlanStore } from "../stores/PlanStore.ts";
4
+ import type { UsageStore } from "../stores/UsageStore.ts";
5
+ import type { EnforceInput, EnforceResult, Money, Quote, QuoteInput } from "../types.ts";
6
+ import { toEnforceResultError, UsageError } from "../errors.ts";
7
+ import { quoteWithPlan } from "../pricing/quote.ts";
8
+ import { assertNonNegativeInteger } from "../utils/guards.ts";
9
+ import { makeFingerprint } from "../utils/fingerprint.ts";
10
+
11
+ export type UsageSdkDeps = {
12
+ planStore: PlanStore;
13
+ ledgerStore: LedgerStore;
14
+ limitStore: LimitStore;
15
+ usageStore: UsageStore;
16
+ };
17
+
18
+ export class UsageSdk {
19
+ constructor(private readonly deps: UsageSdkDeps) {}
20
+
21
+ async quote(input: QuoteInput): Promise<Quote> {
22
+ const plan = await this.deps.planStore.getPlan(input.planId);
23
+ if (!plan) throw new UsageError("INVALID_ARGUMENT", `unknown planId: ${input.planId}`);
24
+ return quoteWithPlan(input, plan);
25
+ }
26
+
27
+ async getBalance(input: { accountId: string; currency: Money["currency"] }): Promise<Money> {
28
+ return this.deps.ledgerStore.getBalance(input);
29
+ }
30
+
31
+ async enforce(input: EnforceInput): Promise<EnforceResult> {
32
+ try {
33
+ if (!input.idempotencyKey) throw new UsageError("INVALID_ARGUMENT", "idempotencyKey is required");
34
+ const nowMs = input.nowMs ?? Date.now();
35
+ assertNonNegativeInteger(nowMs, "nowMs");
36
+
37
+ const quote = await this.quote(input);
38
+
39
+ const debitFingerprint = makeFingerprint({
40
+ op: "debit",
41
+ accountId: input.accountId,
42
+ planId: input.planId,
43
+ idempotencyKey: input.idempotencyKey,
44
+ total: quote.total,
45
+ events: quote.items.map((i) => ({ meter: i.event.meter, quantity: i.event.quantity, dimensions: i.event.dimensions ?? {} })),
46
+ limits: input.limits ?? null,
47
+ });
48
+
49
+ const debitResult = await this.deps.ledgerStore.debit({
50
+ accountId: input.accountId,
51
+ money: quote.total,
52
+ idempotencyKey: input.idempotencyKey,
53
+ fingerprint: debitFingerprint,
54
+ metadata: input.metadata,
55
+ nowMs,
56
+ });
57
+
58
+ try {
59
+ const requestCount = input.requestCount ?? 1;
60
+ assertNonNegativeInteger(requestCount, "requestCount");
61
+ const limits = input.limits;
62
+ if (limits?.rpm !== undefined) {
63
+ await this.deps.limitStore.consumeRpm({ accountId: input.accountId, rpm: limits.rpm, requestCount, nowMs });
64
+ }
65
+ if (limits?.dailyLimitMinor !== undefined) {
66
+ await this.deps.limitStore.consumeDailyMinor({
67
+ accountId: input.accountId,
68
+ amountMinor: quote.total.amountMinor,
69
+ limitMinor: limits.dailyLimitMinor,
70
+ nowMs,
71
+ });
72
+ }
73
+ if (limits?.monthlyLimitMinor !== undefined) {
74
+ await this.deps.limitStore.consumeMonthlyMinor({
75
+ accountId: input.accountId,
76
+ amountMinor: quote.total.amountMinor,
77
+ limitMinor: limits.monthlyLimitMinor,
78
+ nowMs,
79
+ });
80
+ }
81
+ } catch (limitErr) {
82
+ const refundKey = `${input.idempotencyKey}::refund`;
83
+ const refundFingerprint = makeFingerprint({
84
+ op: "refund",
85
+ accountId: input.accountId,
86
+ currency: quote.total.currency,
87
+ amountMinor: quote.total.amountMinor,
88
+ for: input.idempotencyKey,
89
+ });
90
+ await this.deps.ledgerStore.credit({
91
+ accountId: input.accountId,
92
+ money: quote.total,
93
+ idempotencyKey: refundKey,
94
+ fingerprint: refundFingerprint,
95
+ metadata: { reason: "limits_failed", for: input.idempotencyKey },
96
+ nowMs,
97
+ });
98
+ throw limitErr;
99
+ }
100
+
101
+ await this.deps.usageStore.append({
102
+ accountId: input.accountId,
103
+ planId: input.planId,
104
+ idempotencyKey: input.idempotencyKey,
105
+ txId: debitResult.txId,
106
+ cost: quote.total,
107
+ events: input.events,
108
+ tsMs: nowMs,
109
+ metadata: input.metadata,
110
+ });
111
+
112
+ return { ok: true, txId: debitResult.txId, cost: quote.total, balanceAfter: debitResult.balanceAfter };
113
+ } catch (err) {
114
+ return toEnforceResultError(err);
115
+ }
116
+ }
117
+ }
@@ -0,0 +1,38 @@
1
+ import type { Limits, Money, PricePlan } from "../types.ts";
2
+ import { MemoryLedgerStore } from "../providers/memory/MemoryLedgerStore.ts";
3
+ import { MemoryLimitStore } from "../providers/memory/MemoryLimitStore.ts";
4
+ import { MemoryPlanStore } from "../providers/memory/MemoryPlanStore.ts";
5
+ import { MemoryUsageStore } from "../providers/memory/MemoryUsageStore.ts";
6
+ import { UsageSdk } from "./UsageSdk.ts";
7
+
8
+ export type CreateUsageSdkInput = {
9
+ plans: PricePlan[];
10
+ initialBalances?: Array<{ accountId: string; money: Money }>;
11
+ /**
12
+ * Optional: default limits applied by callers (you can also pass per-call limits in enforce()).
13
+ */
14
+ defaultLimits?: Limits;
15
+ };
16
+
17
+ export const createUsageSdk = (input: CreateUsageSdkInput) => {
18
+ const planStore = new MemoryPlanStore(input.plans);
19
+ const ledgerStore = new MemoryLedgerStore(input.initialBalances);
20
+ const limitStore = new MemoryLimitStore();
21
+ const usageStore = new MemoryUsageStore();
22
+
23
+ const sdk = new UsageSdk({ planStore, ledgerStore, limitStore, usageStore });
24
+
25
+ return {
26
+ quote: sdk.quote.bind(sdk),
27
+ enforce: async (enforceInput: Parameters<typeof sdk.enforce>[0]) =>
28
+ sdk.enforce({
29
+ ...enforceInput,
30
+ limits: enforceInput.limits ?? input.defaultLimits,
31
+ }),
32
+ getBalance: sdk.getBalance.bind(sdk),
33
+ /**
34
+ * Memory-only escape hatch for debugging/analytics.
35
+ */
36
+ __unsafe: { usageStore, ledgerStore, limitStore, planStore },
37
+ };
38
+ };
@@ -0,0 +1,30 @@
1
+ import type { Money } from "../types.ts";
2
+
3
+ export type LedgerDebitInput = {
4
+ accountId: string;
5
+ money: Money;
6
+ idempotencyKey: string;
7
+ fingerprint: string;
8
+ metadata?: Record<string, unknown>;
9
+ nowMs: number;
10
+ };
11
+
12
+ export type LedgerCreditInput = {
13
+ accountId: string;
14
+ money: Money;
15
+ idempotencyKey: string;
16
+ fingerprint: string;
17
+ metadata?: Record<string, unknown>;
18
+ nowMs: number;
19
+ };
20
+
21
+ export type LedgerApplyResult = {
22
+ txId: string;
23
+ balanceAfter: Money;
24
+ };
25
+
26
+ export interface LedgerStore {
27
+ getBalance(input: { accountId: string; currency: Money["currency"] }): Promise<Money>;
28
+ debit(input: LedgerDebitInput): Promise<LedgerApplyResult>;
29
+ credit(input: LedgerCreditInput): Promise<LedgerApplyResult>;
30
+ }
@@ -0,0 +1,5 @@
1
+ export interface LimitStore {
2
+ consumeRpm(input: { accountId: string; rpm: number; requestCount: number; nowMs: number }): Promise<void>;
3
+ consumeDailyMinor(input: { accountId: string; amountMinor: number; limitMinor: number; nowMs: number }): Promise<void>;
4
+ consumeMonthlyMinor(input: { accountId: string; amountMinor: number; limitMinor: number; nowMs: number }): Promise<void>;
5
+ }
@@ -0,0 +1,5 @@
1
+ import type { PricePlan } from "../types.ts";
2
+
3
+ export interface PlanStore {
4
+ getPlan(planId: string): Promise<PricePlan | null>;
5
+ }
@@ -0,0 +1,5 @@
1
+ import type { UsageRecord } from "../types.ts";
2
+
3
+ export interface UsageStore {
4
+ append(record: UsageRecord): Promise<void>;
5
+ }
package/src/types.ts ADDED
@@ -0,0 +1,116 @@
1
+ export type Currency = "CNY" | (string & {});
2
+
3
+ export type Money = {
4
+ currency: Currency;
5
+ /**
6
+ * Minor units (e.g. CNY=分). Integer only.
7
+ */
8
+ amountMinor: number;
9
+ };
10
+
11
+ export type UsageEvent = {
12
+ meter: string;
13
+ quantity: number;
14
+ dimensions?: Record<string, string>;
15
+ ts?: number;
16
+ };
17
+
18
+ export type PriceRule = {
19
+ /**
20
+ * A logical meter name, e.g. "tokens.input", "tokens.output", "requests".
21
+ */
22
+ meter: string;
23
+ /**
24
+ * Dimension matchers. v0 supports:
25
+ * - "*" wildcard
26
+ * - string equality
27
+ * - string[] membership
28
+ */
29
+ when?: Record<string, "*" | string | string[]>;
30
+ /**
31
+ * Price per unit in minor units (e.g. 分). Integer only.
32
+ */
33
+ unitPriceMinor: number;
34
+ currency: Currency;
35
+ priority?: number;
36
+ ruleId?: string;
37
+ };
38
+
39
+ export type PricePlan = {
40
+ planId: string;
41
+ rules: PriceRule[];
42
+ };
43
+
44
+ export type Limits = {
45
+ /**
46
+ * Requests per minute. v0: each `enforce()` call consumes 1 request by default.
47
+ */
48
+ rpm?: number;
49
+ /**
50
+ * Daily spending limit in minor units.
51
+ */
52
+ dailyLimitMinor?: number;
53
+ /**
54
+ * Monthly spending limit in minor units.
55
+ */
56
+ monthlyLimitMinor?: number;
57
+ };
58
+
59
+ export type QuoteInput = {
60
+ accountId: string;
61
+ planId: string;
62
+ events: UsageEvent[];
63
+ };
64
+
65
+ export type QuoteItem = {
66
+ event: UsageEvent;
67
+ costMinor: number;
68
+ ruleId?: string;
69
+ };
70
+
71
+ export type Quote = {
72
+ total: Money;
73
+ items: QuoteItem[];
74
+ };
75
+
76
+ export type EnforceInput = QuoteInput & {
77
+ idempotencyKey: string;
78
+ limits?: Limits;
79
+ /**
80
+ * Overrides RPM consumption for this call. Defaults to 1.
81
+ */
82
+ requestCount?: number;
83
+ nowMs?: number;
84
+ metadata?: Record<string, unknown>;
85
+ };
86
+
87
+ export type EnforceErrorCode =
88
+ | "INSUFFICIENT_BALANCE"
89
+ | "RPM_LIMIT_EXCEEDED"
90
+ | "DAILY_LIMIT_EXCEEDED"
91
+ | "MONTHLY_LIMIT_EXCEEDED"
92
+ | "NO_PRICE_RULE"
93
+ | "CURRENCY_MISMATCH"
94
+ | "IDEMPOTENCY_CONFLICT"
95
+ | "INVALID_ARGUMENT";
96
+
97
+ export type EnforceResult =
98
+ | { ok: true; txId: string; cost: Money; balanceAfter: Money }
99
+ | {
100
+ ok: false;
101
+ code: EnforceErrorCode;
102
+ message?: string;
103
+ retryAfterMs?: number;
104
+ };
105
+
106
+ export type UsageRecord = {
107
+ accountId: string;
108
+ planId: string;
109
+ idempotencyKey: string;
110
+ txId: string;
111
+ cost: Money;
112
+ events: UsageEvent[];
113
+ tsMs: number;
114
+ metadata?: Record<string, unknown>;
115
+ };
116
+
@@ -0,0 +1,14 @@
1
+ const stableStringifyValue = (value: unknown): unknown => {
2
+ if (Array.isArray(value)) return value.map(stableStringifyValue);
3
+ if (!value || typeof value !== "object") return value;
4
+ const record = value as Record<string, unknown>;
5
+ const keys = Object.keys(record).sort();
6
+ const out: Record<string, unknown> = {};
7
+ for (const key of keys) out[key] = stableStringifyValue(record[key]);
8
+ return out;
9
+ };
10
+
11
+ export const stableStringify = (value: unknown) => JSON.stringify(stableStringifyValue(value));
12
+
13
+ export const makeFingerprint = (value: unknown) => stableStringify(value);
14
+
@@ -0,0 +1,23 @@
1
+ import { UsageError } from "../errors.ts";
2
+ import type { Money, UsageEvent } from "../types.ts";
3
+
4
+ export const assertInteger = (value: number, field: string) => {
5
+ if (!Number.isInteger(value)) throw new UsageError("INVALID_ARGUMENT", `${field} must be an integer`);
6
+ };
7
+
8
+ export const assertNonNegativeInteger = (value: number, field: string) => {
9
+ assertInteger(value, field);
10
+ if (value < 0) throw new UsageError("INVALID_ARGUMENT", `${field} must be >= 0`);
11
+ };
12
+
13
+ export const assertMoney = (money: Money, field: string) => {
14
+ if (!money || typeof money !== "object") throw new UsageError("INVALID_ARGUMENT", `${field} is required`);
15
+ if (!money.currency) throw new UsageError("INVALID_ARGUMENT", `${field}.currency is required`);
16
+ assertNonNegativeInteger(money.amountMinor, `${field}.amountMinor`);
17
+ };
18
+
19
+ export const assertUsageEvent = (event: UsageEvent, field: string) => {
20
+ if (!event || typeof event !== "object") throw new UsageError("INVALID_ARGUMENT", `${field} is required`);
21
+ if (!event.meter) throw new UsageError("INVALID_ARGUMENT", `${field}.meter is required`);
22
+ assertNonNegativeInteger(event.quantity, `${field}.quantity`);
23
+ };
@@ -0,0 +1,14 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": false,
5
+ "outDir": "dist",
6
+ "rootDir": "src",
7
+ "declaration": true,
8
+ "declarationMap": true,
9
+ "sourceMap": true,
10
+ "rewriteRelativeImportExtensions": true
11
+ },
12
+ "include": ["src/**/*"]
13
+ }
14
+
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ES2022",
5
+ "moduleResolution": "bundler",
6
+ "allowImportingTsExtensions": true,
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "noEmit": true,
10
+ "forceConsistentCasingInFileNames": true
11
+ },
12
+ "include": ["src/**/*"]
13
+ }