@usagekit/core 0.1.0 → 0.4.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.
@@ -37,6 +37,12 @@ export type Scope = {
37
37
  accessCredential?: AccessCredential;
38
38
  /** Which provider secret version was used. Rotation identity, not caller identity. */
39
39
  providerCredentialVersion?: string;
40
+ /**
41
+ * Connection labels snapshotted at reservation: 1..16 unique entries, each 1..64 characters
42
+ * matching [a-z0-9][a-z0-9_.:-]*, sorted before storage. Attribution only: excluded from
43
+ * reserve identity, so a replay keeps the first reservation's tags.
44
+ */
45
+ tags?: readonly string[];
40
46
  };
41
47
  export type FundingSource = "byok" | "platform";
42
48
  export type Surface = "app" | "programmatic";
@@ -62,6 +68,7 @@ export type BudgetWindow = {
62
68
  * Each variant names one constraint in a namespace.
63
69
  * A platform_pool bounds spend shared by many principals and applies to operations
64
70
  * naming that pool in ReserveInput. It is not the platform principal's own budget.
71
+ * A tag bounds every operation whose scope.tags contains it, across principals.
65
72
  * A wallet is not a budget: the host ledger owns balances, holds and charges,
66
73
  * coordinated through the host credit bridge. A wallet-level monthly cap may still
67
74
  * be a principal Budget because the cap is a constraint, not a balance.
@@ -86,16 +93,49 @@ export type BudgetScope = {
86
93
  kind: "platform_pool";
87
94
  namespace: string;
88
95
  poolId: string;
96
+ } | {
97
+ kind: "tag";
98
+ namespace: string;
99
+ tag: string;
89
100
  };
101
+ /**
102
+ * surface selects the traffic a budget bounds: "any" matches every operation, a Surface
103
+ * matches operations whose source belongs to that surface group (see sourcesOf), and a
104
+ * Source matches exactly that source. Hosts set surface and source at authentication.
105
+ */
90
106
  export type Budget = {
91
107
  id: string;
92
108
  version: number;
93
109
  scope: BudgetScope;
94
- surface: Surface | "any";
110
+ surface: Surface | Source | "any";
95
111
  unit: string;
96
112
  limit: Quantity | null;
97
113
  window: BudgetWindow;
98
- onExceed: "block" | "warn";
114
+ /** block denies past limit. allow admits with a warning; wire and stored rows may still say the deprecated alias for allow. */
115
+ onExceed: "block" | "allow";
116
+ /** Only with onExceed allow: same unit as limit and greater than it. Exceeding it denies admission. */
117
+ hardLimit?: Quantity;
118
+ /** At most eight thresholds in ascending order; each crossing is reported once per epoch. */
119
+ alerts?: readonly BudgetAlert[];
120
+ };
121
+ /** percent is an integer 1..100 of limit; a Quantity threshold is in the budget unit. */
122
+ export type BudgetAlert = {
123
+ at: {
124
+ percent: number;
125
+ } | Quantity;
126
+ };
127
+ /**
128
+ * Reported by the command whose admitted or settled figures first reach the threshold.
129
+ * The store records each crossing atomically per budget id, epoch and threshold, so a
130
+ * replay returns the stored crossings and no later command reports the same one again.
131
+ */
132
+ export type BudgetAlertCrossed = {
133
+ budgetId: string;
134
+ budgetVersion: number;
135
+ epoch: string;
136
+ at: BudgetAlert["at"];
137
+ used: Quantity;
138
+ reserved: Quantity;
99
139
  };
100
140
  export type LifecycleState = "reserved" | "dispatch_intended" | "pending" | "settled" | "released";
101
141
  export type Certainty = "measured" | "estimated" | "unknown";
@@ -138,14 +178,14 @@ export type Receipt = {
138
178
  * Store selects all applying budgets atomically inside reserve; Meter's pre-resolution
139
179
  * is advisory and never sufficient for admission.
140
180
  * Match namespace and the bound: principal, group, connection, accessCredential by
141
- * kind and id, or poolId listed in platformPools. Match surface or "any".
181
+ * kind and id, or poolId listed in platformPools. Match "any", the surface or the source.
142
182
  * Each applicable budget's unit must appear in estimate. A missing bounded unit is
143
183
  * a Meter validation error, not permission to bypass the bound. Store re-checks this
144
184
  * against current budgets. Units without budgets are recorded without a bound.
145
185
  * Use the current budget version at reservation time; snapshot its resolved epoch
146
186
  * and version into budgetEpochs. Check every applying budget in one atomic command.
147
- * Return the first exceeded bound in deterministic host-policy order; default:
148
- * platform_pool, principal, group, connection, access_credential.
187
+ * Return the first exceeded bound in deterministic host-policy order; the default
188
+ * is defaultBudgetOrder: platform_pool, principal, group, tag, connection, access_credential.
149
189
  * A guest session is actor, not budget owner. The platform or a dedicated guest
150
190
  * principal owns the operation; its pool and host-enforced session limit both apply.
151
191
  */
@@ -168,6 +208,10 @@ export type ReserveInput = {
168
208
  provider: string;
169
209
  operation: string;
170
210
  estimate: readonly Quantity[];
211
+ /** Set by a Meter that resolved the estimate from a catalog; hosts may set it themselves. */
212
+ estimateSource?: EstimateSource;
213
+ /** "<provider>:<validFrom>" of the list price row behind the estimate. */
214
+ providerPriceVersion?: string;
171
215
  correlationId?: string;
172
216
  parentOperationId?: string;
173
217
  };
@@ -205,11 +249,13 @@ export type ReserveResult = ValidationFailure | {
205
249
  replayed: false;
206
250
  operation: Operation;
207
251
  warnings: readonly AllowanceExceeded[];
252
+ alerts: readonly BudgetAlertCrossed[];
208
253
  } | {
209
254
  outcome: "reserved";
210
255
  replayed: true;
211
256
  operation: Operation;
212
257
  warnings: readonly AllowanceExceeded[];
258
+ alerts: readonly BudgetAlertCrossed[];
213
259
  } | {
214
260
  outcome: "exceeded";
215
261
  exceeded: AllowanceExceeded;
@@ -218,6 +264,84 @@ export type ReserveResult = ValidationFailure | {
218
264
  outcome: "conflict";
219
265
  reason: "semantic_mismatch";
220
266
  operation: Operation;
267
+ }
268
+ /**
269
+ * The connection's tracking policy is passthrough for this operation, or the catalog marks it
270
+ * free: counted with state passthrough, never reserved. The caller may forward the call.
271
+ */
272
+ | {
273
+ outcome: "passthrough";
274
+ replayed: boolean;
275
+ operation: null;
276
+ }
277
+ /**
278
+ * No estimate was given and the catalog does not know the operation: counted with state
279
+ * unpriced, never reserved. Host policy decides whether to forward the call.
280
+ */
281
+ | {
282
+ outcome: "unpriced";
283
+ replayed: boolean;
284
+ operation: null;
285
+ };
286
+ /** Where a reserve estimate came from, in resolution order. */
287
+ export type EstimateSource = "manual" | "measured" | "list" | "unknown";
288
+ /**
289
+ * Meter reserve input. With a catalog, estimate may be omitted: the Meter resolves it from the
290
+ * catalog for the connection and the request options. Store always receives a ReserveInput.
291
+ */
292
+ export type MeterReserveInput = Omit<ReserveInput, "estimate"> & {
293
+ /** Omitted: the Meter resolves it through its catalog and the connection policy. */
294
+ estimate?: readonly Quantity[];
295
+ /** Request options that move the price, read from the request by the descriptor. */
296
+ options?: Readonly<Record<string, string>>;
297
+ };
298
+ /**
299
+ * Non-operation outcomes, counted per state and never reserved. Metered requests are the
300
+ * operations themselves and are not counted here.
301
+ */
302
+ export type RequestState = "passthrough" | "unpriced" | "cached" | "rate_limited";
303
+ /**
304
+ * One counted request. commandId is the replay key: an identical replay counts once and returns
305
+ * replayed: true; a changed payload under the same commandId is a conflict. Counts carry no
306
+ * content and never touch budgets.
307
+ */
308
+ export type CountRequestInput = {
309
+ commandId: string;
310
+ scope: Scope;
311
+ surface: Surface;
312
+ source: Source;
313
+ provider: string;
314
+ operation: string;
315
+ state: RequestState;
316
+ platformPools?: readonly string[];
317
+ };
318
+ export type CountRequestResult = ValidationFailure | {
319
+ outcome: "counted";
320
+ replayed: boolean;
321
+ } | {
322
+ outcome: "conflict";
323
+ reason: "command_mismatch";
324
+ };
325
+ /** Requests counted at store time in [from, to), with the scope and access rules of usage. */
326
+ export type RequestCountsQuery = {
327
+ scope: UsageScope;
328
+ from: string;
329
+ to: string;
330
+ connection?: string;
331
+ groupBy: readonly ("provider" | "operation" | "connection" | "source" | "day")[];
332
+ /** Rows per page, 1..1000, default 1000; truncated reports more rows. */
333
+ limit?: number;
334
+ };
335
+ /** One row per dimension combination and state, ordered by dimensions then state. */
336
+ export type RequestCountRow = {
337
+ dimensions: Readonly<Record<string, string>>;
338
+ state: RequestState;
339
+ count: bigint;
340
+ };
341
+ export type RequestCountsPage = {
342
+ rows: readonly RequestCountRow[];
343
+ asOf: string;
344
+ truncated: boolean;
221
345
  };
222
346
  export type OperationRef = Pick<Scope, "namespace" | "principal"> & {
223
347
  operationId: string;
@@ -341,6 +465,7 @@ export type SettleResult = ValidationFailure | {
341
465
  outcome: "settled";
342
466
  replayed: boolean;
343
467
  operation: Operation;
468
+ alerts: readonly BudgetAlertCrossed[];
344
469
  } | {
345
470
  outcome: "rejected";
346
471
  reason: "not_holder" | "lease_expired" | "version_conflict" | "invalid_state" | "receipt_conflict";
@@ -390,14 +515,18 @@ export type AccessContext = {
390
515
  canReadBillingDetail: boolean;
391
516
  canManageBudgets: boolean;
392
517
  };
393
- /** groupBy: "principal" is allowed only for group or namespace scope. */
518
+ /**
519
+ * groupBy: "principal" is allowed only for group or namespace scope.
520
+ * "platform_pool" and "tag" explode one row per pool or tag of an operation, with an
521
+ * empty dimension when the operation has none; those rows are not additive across tags.
522
+ */
394
523
  export type UsageQuery = {
395
524
  scope: UsageScope;
396
525
  from: string;
397
526
  to: string;
398
527
  connection?: string;
399
528
  units: readonly string[];
400
- groupBy: readonly ("principal" | "provider" | "operation" | "surface" | "source" | "connection" | "day" | "access_credential" | "platform_pool")[];
529
+ groupBy: readonly ("principal" | "provider" | "operation" | "surface" | "source" | "connection" | "day" | "access_credential" | "platform_pool" | "funding_source" | "tag")[];
401
530
  cursor?: string;
402
531
  limit?: number;
403
532
  };
@@ -415,6 +544,27 @@ export type UsagePage = {
415
544
  watermark: string;
416
545
  nextCursor?: string;
417
546
  };
547
+ /**
548
+ * Operations whose createdAt lies in [from, to), in one lifecycle state of states (all when
549
+ * omitted), ordered by createdAt then operationId. Same scope and access rules as usage.
550
+ * Pagination mirrors usage: the first page fixes membership and order at its watermark; later
551
+ * pages take the cursor with the unchanged query and return each operation as currently stored.
552
+ */
553
+ export type OperationsQuery = {
554
+ scope: UsageScope;
555
+ from: string;
556
+ to: string;
557
+ states?: readonly LifecycleState[];
558
+ connection?: string;
559
+ cursor?: string;
560
+ limit?: number;
561
+ };
562
+ export type OperationsPage = {
563
+ operations: readonly Operation[];
564
+ asOf: string;
565
+ watermark: string;
566
+ nextCursor?: string;
567
+ };
418
568
  /** Budgets defined for exactly this scope, regardless of any operation. */
419
569
  export type DefinedBudgetsQuery = {
420
570
  scope: BudgetScope;
@@ -428,6 +578,8 @@ export type DefinedBudgetsQuery = {
428
578
  export type ApplicableBudgetsQuery = {
429
579
  scope: Scope;
430
580
  surface: Surface;
581
+ /** Include budgets bound to exactly this source. Omitted lists surface and any budgets only. */
582
+ source?: Source;
431
583
  units: readonly string[];
432
584
  platformPools?: readonly string[];
433
585
  };
@@ -451,10 +603,12 @@ export type ReadResult<T> = ValidationFailure | {
451
603
  } | {
452
604
  outcome: "forbidden";
453
605
  };
454
- /** Data-only budget denial. A P1 implementation may wrap it in a runtime error class. */
606
+ /** Data-only budget denial or warning. A P1 implementation may wrap it in a runtime error class. */
455
607
  export interface AllowanceExceeded {
456
608
  readonly code: "allowance_exceeded";
457
609
  readonly budget: Budget;
610
+ /** Which bound was crossed: limit warns on allow budgets and denies on block budgets; hardLimit always denies. */
611
+ readonly boundary: "limit" | "hardLimit";
458
612
  readonly used: Quantity;
459
613
  readonly reserved: Quantity;
460
614
  readonly resetsAt: string | null;
@@ -478,7 +632,8 @@ export type ExpireReservationsResult = ValidationFailure | {
478
632
  */
479
633
  export interface Meter {
480
634
  expireReservations(input: ExpireReservationsInput): Promise<ExpireReservationsResult>;
481
- reserve(input: ReserveInput): Promise<ReserveResult>;
635
+ reserve(input: MeterReserveInput): Promise<ReserveResult>;
636
+ countRequest(input: CountRequestInput): Promise<CountRequestResult>;
482
637
  markDispatchIntent(input: DispatchIntentInput): Promise<DispatchGrant>;
483
638
  renewLease(input: LeaseRenewalInput): Promise<LeaseRenewal>;
484
639
  claimForRecovery(input: RecoveryClaimInput): Promise<RecoveryClaim>;
@@ -487,6 +642,8 @@ export interface Meter {
487
642
  releaseUndispatched(input: ReleaseInput): Promise<ReleaseResult>;
488
643
  getOperation(access: AccessContext, input: OperationRef): Promise<ReadResult<Operation | null>>;
489
644
  usage(access: AccessContext, query: UsageQuery): Promise<ReadResult<UsagePage>>;
645
+ listOperations(access: AccessContext, query: OperationsQuery): Promise<ReadResult<OperationsPage>>;
646
+ requestCounts(access: AccessContext, query: RequestCountsQuery): Promise<ReadResult<RequestCountsPage>>;
490
647
  definedBudgets(access: AccessContext, query: DefinedBudgetsQuery): Promise<ReadResult<readonly Budget[]>>;
491
648
  applicableBudgets(access: AccessContext, query: ApplicableBudgetsQuery): Promise<ReadResult<readonly BudgetStatus[]>>;
492
649
  }
package/dist/index.d.ts CHANGED
@@ -1,9 +1,12 @@
1
- export type { Money, Quantity, AccessCredential, Scope, FundingSource, Surface, Source, BudgetWindow, BudgetScope, Budget, LifecycleState, Certainty, Measurement, Cost, Receipt, ReserveInput, ReserveResult, Operation, OperationRef, OperationCommand, Lease, DispatchGrant, DispatchIntentInput, LeaseRenewalInput, LeaseRenewal, RecoveryClaimInput, RecoveryClaim, Authority, SettleInput, ReleaseInput, CorrectionInput, SettleResult, ReleaseResult, UsageScope, AccessContext, UsageQuery, UsageRow, UsagePage, DefinedBudgetsQuery, ApplicableBudgetsQuery, BudgetStatus, ReadResult, AllowanceExceeded, Meter, } from "./contracts.js";
1
+ export type { Money, Quantity, AccessCredential, Scope, FundingSource, Surface, Source, BudgetWindow, BudgetScope, Budget, BudgetAlert, BudgetAlertCrossed, LifecycleState, Certainty, Measurement, Cost, Receipt, ReserveInput, ReserveResult, Operation, OperationRef, OperationCommand, Lease, DispatchGrant, DispatchIntentInput, LeaseRenewalInput, LeaseRenewal, RecoveryClaimInput, RecoveryClaim, Authority, SettleInput, ReleaseInput, CorrectionInput, SettleResult, ReleaseResult, UsageScope, AccessContext, UsageQuery, UsageRow, UsagePage, OperationsQuery, OperationsPage, DefinedBudgetsQuery, ApplicableBudgetsQuery, BudgetStatus, ReadResult, AllowanceExceeded, Meter, MeterReserveInput, EstimateSource, RequestState, CountRequestInput, CountRequestResult, RequestCountsQuery, RequestCountRow, RequestCountsPage, } from "./contracts.js";
2
+ export { requestStates } from "./policy.js";
2
3
  export { resolveWindow } from "./windows.js";
3
4
  export type { ResolvedWindow } from "./windows.js";
4
- export { fromDecimalString, toDecimalString, add as addMoney, sub as subtractMoney, compare as compareMoney, } from "./money.js";
5
- export { normalizeScale, add as addQuantity, compare as compareQuantity } from "./quantity.js";
5
+ export { fromDecimalString, toDecimalString, formatMoney, add as addMoney, sub as subtractMoney, compare as compareMoney, } from "./money.js";
6
+ export { normalizeScale, add as addQuantity, compare as compareQuantity, format as formatQuantity, } from "./quantity.js";
6
7
  export type { ValidationFailure, AdmissionPolicy, BudgetOwner } from "./contracts.js";
7
- export { defaultBudgetOrder } from "./policy.js";
8
+ export { defaultBudgetOrder, sourcesOf, surfaceMatches, tagPattern, normalizeTags, } from "./policy.js";
8
9
  export type { ExpireReservationsInput, ExpireReservationsResult } from "./contracts.js";
9
10
  export { decodeMeterJson } from "./wire.js";
11
+ export type { ProviderId, OperationId, ProviderAuth, BillingUnit, ProviderPlan, PriceRow, RequestMatch, ProviderOperation, BalanceProbe, PriceListSource, BillingExport, ProviderDescriptor, BalanceSnapshot, BillingLine, DescriptorProblem, PricingCatalog, ConnectionPolicy, TrackingPolicy, MeasuredPrice, } from "./providers.js";
12
+ export { validateDescriptor } from "./providers.js";
package/dist/index.js CHANGED
@@ -1,5 +1,7 @@
1
+ export { requestStates } from "./policy.js";
1
2
  export { resolveWindow } from "./windows.js";
2
- export { fromDecimalString, toDecimalString, add as addMoney, sub as subtractMoney, compare as compareMoney, } from "./money.js";
3
- export { normalizeScale, add as addQuantity, compare as compareQuantity } from "./quantity.js";
4
- export { defaultBudgetOrder } from "./policy.js";
3
+ export { fromDecimalString, toDecimalString, formatMoney, add as addMoney, sub as subtractMoney, compare as compareMoney, } from "./money.js";
4
+ export { normalizeScale, add as addQuantity, compare as compareQuantity, format as formatQuantity, } from "./quantity.js";
5
+ export { defaultBudgetOrder, sourcesOf, surfaceMatches, tagPattern, normalizeTags, } from "./policy.js";
5
6
  export { decodeMeterJson } from "./wire.js";
7
+ export { validateDescriptor } from "./providers.js";
package/dist/money.d.ts CHANGED
@@ -6,3 +6,5 @@ export declare function toDecimalString(m: Money): string;
6
6
  export declare const add: (a: Money, b: Money) => Money;
7
7
  export declare const sub: (a: Money, b: Money) => Money;
8
8
  export declare const compare: (a: Money, b: Money) => -1 | 0 | 1;
9
+ /** Exact cents text with four fractional digits; a named alias of toDecimalString for views. */
10
+ export declare const formatMoney: (m: Money) => string;
package/dist/money.js CHANGED
@@ -15,3 +15,5 @@ export function toDecimalString(m) {
15
15
  export const add = (a, b) => ({ currency: "USD", units: a.units + b.units });
16
16
  export const sub = (a, b) => ({ currency: "USD", units: a.units - b.units });
17
17
  export const compare = (a, b) => a.units < b.units ? -1 : a.units > b.units ? 1 : 0;
18
+ /** Exact cents text with four fractional digits; a named alias of toDecimalString for views. */
19
+ export const formatMoney = toDecimalString;
package/dist/policy.d.ts CHANGED
@@ -1,2 +1,12 @@
1
- import type { BudgetScope } from "./contracts.js";
1
+ import type { BudgetScope, RequestState, Source, Surface } from "./contracts.js";
2
+ /** Single source of the default admission order; hosts may permute it, never shorten it. */
2
3
  export declare const defaultBudgetOrder: readonly BudgetScope["kind"][];
4
+ export declare const tagPattern: RegExp;
5
+ /** Sorted unique tags, or null when the list breaks the 1..16 count, format or uniqueness rules. */
6
+ export declare function normalizeTags(tags: readonly string[]): string[] | null;
7
+ /** Sources a surface budget covers. Every Source belongs to exactly one Surface. */
8
+ export declare const sourcesOf: (surface: Surface) => readonly Source[];
9
+ /** True when a budget surface value bounds an operation with this surface and source. */
10
+ export declare const surfaceMatches: (budgetSurface: Surface | Source | "any", surface: Surface, source?: Source) => boolean;
11
+ /** Every counted request state, in display order. */
12
+ export declare const requestStates: readonly RequestState[];
package/dist/policy.js CHANGED
@@ -1,7 +1,36 @@
1
+ /** Single source of the default admission order; hosts may permute it, never shorten it. */
1
2
  export const defaultBudgetOrder = Object.freeze([
2
3
  "platform_pool",
3
4
  "principal",
4
5
  "group",
6
+ "tag",
5
7
  "connection",
6
8
  "access_credential",
7
9
  ]);
10
+ export const tagPattern = /^[a-z0-9][a-z0-9_.:-]{0,63}$/;
11
+ /** Sorted unique tags, or null when the list breaks the 1..16 count, format or uniqueness rules. */
12
+ export function normalizeTags(tags) {
13
+ if (!Array.isArray(tags) || tags.length < 1 || tags.length > 16)
14
+ return null;
15
+ if (tags.some((t) => typeof t !== "string" || !tagPattern.test(t)))
16
+ return null;
17
+ const sorted = [...tags].sort();
18
+ return sorted.some((t, n) => n > 0 && sorted[n - 1] === t) ? null : sorted;
19
+ }
20
+ const surfaceSources = Object.freeze({
21
+ app: Object.freeze(["app", "worker"]),
22
+ programmatic: Object.freeze(["api", "sdk", "cli", "mcp", "proxy"]),
23
+ });
24
+ /** Sources a surface budget covers. Every Source belongs to exactly one Surface. */
25
+ export const sourcesOf = (surface) => surfaceSources[surface];
26
+ /** True when a budget surface value bounds an operation with this surface and source. */
27
+ export const surfaceMatches = (budgetSurface, surface, source) => budgetSurface === "any" ||
28
+ budgetSurface === surface ||
29
+ (source !== undefined && budgetSurface === source);
30
+ /** Every counted request state, in display order. */
31
+ export const requestStates = Object.freeze([
32
+ "passthrough",
33
+ "unpriced",
34
+ "cached",
35
+ "rate_limited",
36
+ ]);
@@ -0,0 +1,256 @@
1
+ import type { EstimateSource, Money, Quantity } from "./contracts.js";
2
+ /**
3
+ * Provider descriptors: data that tells usagekit how to price, route, meter and reconcile one
4
+ * provider. Descriptors live in `@usagekit/providers`; the core contract does not depend on
5
+ * them. See docs/PROVIDERS.md.
6
+ */
7
+ export type ProviderId = string;
8
+ /**
9
+ * Operation identity is the pricing key. Manual overrides, tracking policy and budgets per
10
+ * operation all key on OperationId, never on a raw path.
11
+ */
12
+ export type OperationId = string;
13
+ export type ProviderAuth = {
14
+ kind: "query";
15
+ param: string;
16
+ } | {
17
+ kind: "header";
18
+ name: string;
19
+ prefix?: string;
20
+ } | {
21
+ kind: "basic";
22
+ } | {
23
+ kind: "bearer";
24
+ };
25
+ export type BillingUnit = "cents" | "units" | "requests";
26
+ /**
27
+ * allowanceMode mirrors how the provider behaves past the allowance: prepaid balances and
28
+ * no-overage plans are hard, plans that bill excess are soft.
29
+ */
30
+ export type ProviderPlan = {
31
+ id: string;
32
+ label: string;
33
+ cycle: "monthly_plan" | "prepaid" | "trial" | "none";
34
+ allowance?: {
35
+ unit: BillingUnit;
36
+ value: bigint;
37
+ };
38
+ allowanceMode: "hard" | "soft";
39
+ };
40
+ /**
41
+ * One list price. The descriptor never holds negotiated prices, discounts or volume tiers beyond
42
+ * the public list; those are manual prices on the connection.
43
+ */
44
+ export type PriceRow = {
45
+ /** ISO date. */
46
+ validFrom: string;
47
+ operation: OperationId;
48
+ /** Plan id; absent means any plan. */
49
+ plan?: string;
50
+ /** Request options that select this row. Options are request data, never guessed. */
51
+ option?: Readonly<Record<string, string>>;
52
+ /** Applies past the plan allowance. */
53
+ overage?: true;
54
+ unit: BillingUnit;
55
+ /** Exact nonnegative decimal in that unit. */
56
+ perUnit: string;
57
+ /** URL of the public price source. */
58
+ source: string;
59
+ /** ISO date of the last check against the source. */
60
+ checkedAt: string;
61
+ };
62
+ export type RequestMatch = {
63
+ method: "GET" | "POST" | "PUT" | "DELETE" | "ANY";
64
+ /** Path pattern with :params (one segment) and a trailing * (any rest). */
65
+ path: string;
66
+ query?: Readonly<Record<string, string | RegExp>>;
67
+ };
68
+ /**
69
+ * Free endpoints are operations too, with billable false. They are matched, counted and never
70
+ * reserved. A descriptor may advertise an operation only when at least one fixture exists for it.
71
+ */
72
+ export type ProviderOperation = {
73
+ id: OperationId;
74
+ label: string;
75
+ billable: boolean;
76
+ match: readonly RequestMatch[];
77
+ /** Options that move the price; values are extracted from the request by `options`. */
78
+ optionKeys?: readonly string[];
79
+ /**
80
+ * Cost is known from the response body, only after a later call, or never per call.
81
+ * response: settle on stream close. deferred: settle with unknown cost, keep pending, resolve
82
+ * through the task-get operation or billing export. none: settle with the estimate as
83
+ * estimated, reconcile from balance deltas or export.
84
+ */
85
+ costEvidence: "response" | "deferred" | "none";
86
+ /** Idempotent replay is safe for this operation when the provider returns the same result. */
87
+ cacheable?: {
88
+ keyParams: readonly string[];
89
+ defaultTtlMs: number;
90
+ };
91
+ };
92
+ /** Account identity is returned to the host as data. Duplicate detection is a host decision. */
93
+ export type BalanceProbe = {
94
+ /** The free account endpoint. */
95
+ operation: OperationId;
96
+ fields: {
97
+ /** JSON pointer. */
98
+ remaining?: string;
99
+ remainingUnit?: BillingUnit;
100
+ allowance?: string;
101
+ used?: string;
102
+ resetsAt?: string;
103
+ plan?: string;
104
+ /** E-mail or login, for duplicate detection. */
105
+ accountIdentity?: string;
106
+ rateLimitPerHour?: string;
107
+ };
108
+ };
109
+ /**
110
+ * A price list from an endpoint is a refresh source for prices, not a replacement: a pull
111
+ * request updates the rows with the fetched values and the check date; runtime never fetches
112
+ * prices to decide admission.
113
+ */
114
+ export type PriceListSource = {
115
+ kind: "static";
116
+ } | {
117
+ kind: "endpoint";
118
+ operation: OperationId;
119
+ pointer: string;
120
+ mapping: "provider-specific";
121
+ };
122
+ export type BillingExport = {
123
+ kind: "task-history" | "daily-csv" | "none";
124
+ /** For API-backed history. */
125
+ operation?: OperationId;
126
+ granularity: "per-request" | "per-day";
127
+ matchKey: "providerRequestId" | "window";
128
+ };
129
+ export type ProviderDescriptor = {
130
+ id: ProviderId;
131
+ label: string;
132
+ /** Origin and optional base path, for example "https://api.example.com". */
133
+ upstream: string;
134
+ auth: ProviderAuth;
135
+ stripRequestHeaders?: readonly string[];
136
+ billing: {
137
+ unit: BillingUnit;
138
+ cycle: ProviderPlan["cycle"];
139
+ };
140
+ plans: readonly ProviderPlan[];
141
+ prices: readonly PriceRow[];
142
+ priceList: PriceListSource;
143
+ operations: readonly ProviderOperation[];
144
+ balance?: BalanceProbe;
145
+ billingExport: BillingExport;
146
+ rateLimits?: readonly {
147
+ scope: "account";
148
+ perMinute?: number;
149
+ perHour?: number;
150
+ }[];
151
+ docs: {
152
+ pricing: string;
153
+ api: string;
154
+ };
155
+ };
156
+ /** Parsed balance probe. Every field is optional because providers report different subsets. */
157
+ export type BalanceSnapshot = {
158
+ remaining?: Quantity;
159
+ allowance?: Quantity;
160
+ used?: Quantity;
161
+ /** UTC ISO timestamp of the next allowance reset. */
162
+ resetsAt?: string;
163
+ plan?: string;
164
+ accountIdentity?: string;
165
+ rateLimitPerHour?: number;
166
+ };
167
+ /** One matchable line of a provider billing export. */
168
+ export type BillingLine = {
169
+ providerRequestId?: string;
170
+ operation?: OperationId;
171
+ occurredAt: string;
172
+ cost: Money;
173
+ /** Window of a per-day line, [from, to). */
174
+ window?: {
175
+ from: string;
176
+ to: string;
177
+ };
178
+ };
179
+ /** Per-connection choice per catalog operation; the default is metered. */
180
+ export type TrackingPolicy = "metered" | "passthrough";
181
+ /** Median of a connection's settled receipts for one operation, with its sample size. */
182
+ export type MeasuredPrice = (operation: OperationId) => {
183
+ quantity: Quantity;
184
+ samples: number;
185
+ } | undefined;
186
+ /**
187
+ * Connection data a host resolves for the Meter: provider, selected plan, manual per-call prices,
188
+ * measured prices and tracking policy, all keyed by OperationId. overage is true when the host
189
+ * knows the plan allowance is exhausted.
190
+ */
191
+ export type ConnectionPolicy = {
192
+ provider: ProviderId;
193
+ plan?: string;
194
+ manualPrices?: Readonly<Record<OperationId, Quantity>>;
195
+ measured?: MeasuredPrice;
196
+ tracking?: Readonly<Record<OperationId, TrackingPolicy>>;
197
+ overage?: boolean;
198
+ };
199
+ /**
200
+ * What the Meter needs from a provider catalog. `@usagekit/providers` createCatalog satisfies
201
+ * it; the Meter does not depend on that package. operationsOf lists the operations of an
202
+ * enabled provider and nothing for a disabled or unknown one.
203
+ */
204
+ export type PricingCatalog = {
205
+ estimate(connection: Omit<ConnectionPolicy, "tracking"> & {
206
+ id: string;
207
+ }, operation: OperationId, options: Readonly<Record<string, string>>): {
208
+ quantities: Quantity[];
209
+ source: EstimateSource;
210
+ priceVersion?: string;
211
+ };
212
+ operationsOf(providerId: ProviderId): readonly ProviderOperation[];
213
+ };
214
+ export type DescriptorProblem = {
215
+ kind: "duplicate_operation";
216
+ operation: OperationId;
217
+ } | {
218
+ kind: "duplicate_plan";
219
+ plan: string;
220
+ } | {
221
+ kind: "missing_match";
222
+ operation: OperationId;
223
+ } | {
224
+ kind: "unknown_price_operation";
225
+ row: number;
226
+ operation: OperationId;
227
+ } | {
228
+ kind: "unknown_price_plan";
229
+ row: number;
230
+ plan: string;
231
+ } | {
232
+ kind: "unknown_price_option";
233
+ row: number;
234
+ option: string;
235
+ } | {
236
+ kind: "invalid_price";
237
+ row: number;
238
+ field: "perUnit" | "validFrom" | "checkedAt" | "source";
239
+ } | {
240
+ kind: "unknown_probe_operation";
241
+ operation: OperationId;
242
+ } | {
243
+ kind: "billable_probe";
244
+ operation: OperationId;
245
+ } | {
246
+ kind: "unknown_price_list_operation";
247
+ operation: OperationId;
248
+ } | {
249
+ kind: "unknown_export_operation";
250
+ operation: OperationId;
251
+ };
252
+ /**
253
+ * Structural problems of a descriptor, in a stable order: operations, plans, prices, probe,
254
+ * price list, billing export. An empty list means the catalog may load it.
255
+ */
256
+ export declare function validateDescriptor(d: ProviderDescriptor): DescriptorProblem[];
@@ -0,0 +1,54 @@
1
+ const isoDate = (text) => /^\d{4}-\d{2}-\d{2}/.test(text) && Number.isFinite(Date.parse(text));
2
+ /**
3
+ * Structural problems of a descriptor, in a stable order: operations, plans, prices, probe,
4
+ * price list, billing export. An empty list means the catalog may load it.
5
+ */
6
+ export function validateDescriptor(d) {
7
+ const problems = [];
8
+ const operations = new Map();
9
+ for (const op of d.operations) {
10
+ if (operations.has(op.id))
11
+ problems.push({ kind: "duplicate_operation", operation: op.id });
12
+ else
13
+ operations.set(op.id, op);
14
+ }
15
+ for (const op of d.operations)
16
+ if (op.match.length === 0 && operations.get(op.id) === op)
17
+ problems.push({ kind: "missing_match", operation: op.id });
18
+ const plans = new Set();
19
+ for (const plan of d.plans) {
20
+ if (plans.has(plan.id))
21
+ problems.push({ kind: "duplicate_plan", plan: plan.id });
22
+ plans.add(plan.id);
23
+ }
24
+ d.prices.forEach((row, index) => {
25
+ const op = operations.get(row.operation);
26
+ if (!op)
27
+ problems.push({ kind: "unknown_price_operation", row: index, operation: row.operation });
28
+ if (row.plan !== undefined && !plans.has(row.plan))
29
+ problems.push({ kind: "unknown_price_plan", row: index, plan: row.plan });
30
+ for (const key of Object.keys(row.option ?? {}))
31
+ if (op && !(op.optionKeys ?? []).includes(key))
32
+ problems.push({ kind: "unknown_price_option", row: index, option: key });
33
+ if (!/^\d+(\.\d+)?$/.test(row.perUnit))
34
+ problems.push({ kind: "invalid_price", row: index, field: "perUnit" });
35
+ if (!isoDate(row.validFrom))
36
+ problems.push({ kind: "invalid_price", row: index, field: "validFrom" });
37
+ if (!isoDate(row.checkedAt))
38
+ problems.push({ kind: "invalid_price", row: index, field: "checkedAt" });
39
+ if (!row.source.trim())
40
+ problems.push({ kind: "invalid_price", row: index, field: "source" });
41
+ });
42
+ if (d.balance) {
43
+ const probe = operations.get(d.balance.operation);
44
+ if (!probe)
45
+ problems.push({ kind: "unknown_probe_operation", operation: d.balance.operation });
46
+ else if (probe.billable)
47
+ problems.push({ kind: "billable_probe", operation: probe.id });
48
+ }
49
+ if (d.priceList.kind === "endpoint" && !operations.has(d.priceList.operation))
50
+ problems.push({ kind: "unknown_price_list_operation", operation: d.priceList.operation });
51
+ if (d.billingExport.operation !== undefined && !operations.has(d.billingExport.operation))
52
+ problems.push({ kind: "unknown_export_operation", operation: d.billingExport.operation });
53
+ return problems;
54
+ }
@@ -2,3 +2,5 @@ import type { Quantity } from "./contracts.js";
2
2
  export declare function normalizeScale(q: Quantity, scale: number): Quantity;
3
3
  export declare function add(a: Quantity, b: Quantity): Quantity;
4
4
  export declare function compare(a: Quantity, b: Quantity): -1 | 0 | 1;
5
+ /** Exact decimal text of value * 10^-scale, trailing fractional zeros trimmed, no trailing dot. */
6
+ export declare function format(q: Quantity): string;
package/dist/quantity.js CHANGED
@@ -17,3 +17,9 @@ export function compare(a, b) {
17
17
  const [x, y] = pair(a, b);
18
18
  return x.value < y.value ? -1 : x.value > y.value ? 1 : 0;
19
19
  }
20
+ /** Exact decimal text of value * 10^-scale, trailing fractional zeros trimmed, no trailing dot. */
21
+ export function format(q) {
22
+ const negative = q.value < 0n, digits = (negative ? -q.value : q.value).toString().padStart(q.scale + 1, "0"), whole = digits.slice(0, digits.length - q.scale), fraction = digits.slice(digits.length - q.scale).replace(/0+$/, "");
23
+ const text = fraction ? `${whole}.${fraction}` : whole;
24
+ return negative && text !== "0" ? `-${text}` : text;
25
+ }
package/dist/wire.js CHANGED
@@ -23,6 +23,12 @@ export function decodeMeterJson(text) {
23
23
  typeof v.costOwner === "string" &&
24
24
  decimal(v.unknownOperations))
25
25
  return { ...v, unknownOperations: BigInt(v.unknownOperations) };
26
+ if (keys.length === 3 &&
27
+ v.dimensions &&
28
+ typeof v.dimensions === "object" &&
29
+ ["passthrough", "unpriced", "cached", "rate_limited"].includes(String(v.state)) &&
30
+ decimal(v.count))
31
+ return { ...v, count: BigInt(v.count) };
26
32
  return value;
27
33
  });
28
34
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usagekit/core",
3
- "version": "0.1.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "Exact quantities, ownership and pure domain types.",