@usagekit/core 0.3.0 → 0.5.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.
@@ -0,0 +1,97 @@
1
+ import type { BudgetAlertCrossed, Cost, FundingSource, Money, Operation, Receipt, Scope, ValidationFailure } from "./contracts.js";
2
+ import type { BillingLine } from "./providers.js";
3
+ /** Trusted historical attribution for calls the application did not observe. */
4
+ export type ImportAttribution = {
5
+ fundingSource: FundingSource;
6
+ costOwner: string;
7
+ creditAccountRef?: string;
8
+ customerPriceVersion?: string;
9
+ providerPriceVersion?: string;
10
+ platformPools?: readonly string[];
11
+ };
12
+ /** A complete export for one connection and half-open window. Never grants dispatch. */
13
+ export type BillingImportInput = {
14
+ scope: Scope;
15
+ provider: string;
16
+ /** Lower-case SHA-256 of the original file, computed before the atomic command. */
17
+ fileHash: string;
18
+ window: {
19
+ from: string;
20
+ to: string;
21
+ };
22
+ /** null for the first import; the current import id for a revision. Prevents lost updates. */
23
+ expectedPreviousImportId: string | null;
24
+ attribution: ImportAttribution;
25
+ lines: readonly BillingLine[];
26
+ };
27
+ /** Exact comparison. Unknown ledger cost remains unknown; signed differences are never prorated. */
28
+ export type ReconciliationEntry = {
29
+ id: string;
30
+ importId: string;
31
+ scope: Scope;
32
+ provider: string;
33
+ /** Present for operation-specific aggregate evidence. */
34
+ operation?: string;
35
+ window: {
36
+ from: string;
37
+ to: string;
38
+ };
39
+ kind: "aggregate" | "import_total";
40
+ ledgerTotal: Cost;
41
+ evidenceTotal: Money;
42
+ /** evidenceTotal - ledgerTotal, or null when any observed cost is unknown. */
43
+ differenceUnits: bigint | null;
44
+ unknownOperations: bigint;
45
+ evidenceRef: string;
46
+ };
47
+ export type BillingImportRecord = {
48
+ id: string;
49
+ scope: Scope;
50
+ provider: string;
51
+ fileHash: string;
52
+ window: {
53
+ from: string;
54
+ to: string;
55
+ };
56
+ recordedAt: string;
57
+ supersedes?: string;
58
+ /** Projected from the import family pointer; earlier evidence remains available. */
59
+ supersededBy?: string;
60
+ matchedOperationIds: readonly string[];
61
+ unobservedOperationIds: readonly string[];
62
+ reconciliations: readonly ReconciliationEntry[];
63
+ alerts: readonly BudgetAlertCrossed[];
64
+ };
65
+ export type BillingImportResult = ValidationFailure | {
66
+ outcome: "imported";
67
+ replayed: boolean;
68
+ record: BillingImportRecord;
69
+ } | {
70
+ outcome: "rejected";
71
+ reason: "previous_import_conflict" | "payload_conflict" | "ambiguous_request" | "active_operation" | "operation_state" | "evidence_conflict";
72
+ latestImportId: string | null;
73
+ line?: number;
74
+ };
75
+ export type BillingImportsQuery = {
76
+ scope: {
77
+ namespace: string;
78
+ principal: string;
79
+ };
80
+ connection: string;
81
+ from: string;
82
+ to: string;
83
+ /** Include superseded import evidence; defaults to false. */
84
+ history?: boolean;
85
+ limit?: number;
86
+ };
87
+ export type BillingImportsPage = {
88
+ records: readonly BillingImportRecord[];
89
+ asOf: string;
90
+ truncated: boolean;
91
+ };
92
+ /** Atomic adapter changes prepared against its transaction's operation snapshot. */
93
+ export type BillingOperationChange = {
94
+ before: Operation | null;
95
+ after: Operation;
96
+ receipt: Receipt;
97
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -1,3 +1,4 @@
1
+ import type { BillingImportInput, BillingImportResult, BillingImportsQuery, BillingImportsPage } from "./billing.js";
1
2
  export type ValidationFailure = {
2
3
  outcome: "invalid";
3
4
  field: string;
@@ -46,7 +47,7 @@ export type Scope = {
46
47
  };
47
48
  export type FundingSource = "byok" | "platform";
48
49
  export type Surface = "app" | "programmatic";
49
- export type Source = "app" | "worker" | "api" | "sdk" | "cli" | "mcp" | "proxy";
50
+ export type Source = "app" | "worker" | "api" | "sdk" | "cli" | "mcp" | "proxy" | "import";
50
51
  /** Timestamps are UTC ISO strings. Resolved reservation epochs remain immutable. */
51
52
  export type BudgetWindow = {
52
53
  kind: "calendar_month";
@@ -163,6 +164,8 @@ export type Cost = {
163
164
  */
164
165
  export type Receipt = {
165
166
  id: string;
167
+ /** Existing receipts without this field are dispatch evidence. */
168
+ source?: "dispatch" | "probe" | "import";
166
169
  supersedes?: string;
167
170
  measurements: readonly Measurement[];
168
171
  cost: Cost;
@@ -208,6 +211,10 @@ export type ReserveInput = {
208
211
  provider: string;
209
212
  operation: string;
210
213
  estimate: readonly Quantity[];
214
+ /** Set by a Meter that resolved the estimate from a catalog; hosts may set it themselves. */
215
+ estimateSource?: EstimateSource;
216
+ /** "<provider>:<validFrom>" of the list price row behind the estimate. */
217
+ providerPriceVersion?: string;
211
218
  correlationId?: string;
212
219
  parentOperationId?: string;
213
220
  };
@@ -260,6 +267,84 @@ export type ReserveResult = ValidationFailure | {
260
267
  outcome: "conflict";
261
268
  reason: "semantic_mismatch";
262
269
  operation: Operation;
270
+ }
271
+ /**
272
+ * The connection's tracking policy is passthrough for this operation, or the catalog marks it
273
+ * free: counted with state passthrough, never reserved. The caller may forward the call.
274
+ */
275
+ | {
276
+ outcome: "passthrough";
277
+ replayed: boolean;
278
+ operation: null;
279
+ }
280
+ /**
281
+ * No estimate was given and the catalog does not know the operation: counted with state
282
+ * unpriced, never reserved. Host policy decides whether to forward the call.
283
+ */
284
+ | {
285
+ outcome: "unpriced";
286
+ replayed: boolean;
287
+ operation: null;
288
+ };
289
+ /** Where a reserve estimate came from, in resolution order. */
290
+ export type EstimateSource = "manual" | "measured" | "list" | "unknown";
291
+ /**
292
+ * Meter reserve input. With a catalog, estimate may be omitted: the Meter resolves it from the
293
+ * catalog for the connection and the request options. Store always receives a ReserveInput.
294
+ */
295
+ export type MeterReserveInput = Omit<ReserveInput, "estimate"> & {
296
+ /** Omitted: the Meter resolves it through its catalog and the connection policy. */
297
+ estimate?: readonly Quantity[];
298
+ /** Request options that move the price, read from the request by the descriptor. */
299
+ options?: Readonly<Record<string, string>>;
300
+ };
301
+ /**
302
+ * Non-operation outcomes, counted per state and never reserved. Metered requests are the
303
+ * operations themselves and are not counted here.
304
+ */
305
+ export type RequestState = "passthrough" | "unpriced" | "cached" | "rate_limited";
306
+ /**
307
+ * One counted request. commandId is the replay key: an identical replay counts once and returns
308
+ * replayed: true; a changed payload under the same commandId is a conflict. Counts carry no
309
+ * content and never touch budgets.
310
+ */
311
+ export type CountRequestInput = {
312
+ commandId: string;
313
+ scope: Scope;
314
+ surface: Surface;
315
+ source: Source;
316
+ provider: string;
317
+ operation: string;
318
+ state: RequestState;
319
+ platformPools?: readonly string[];
320
+ };
321
+ export type CountRequestResult = ValidationFailure | {
322
+ outcome: "counted";
323
+ replayed: boolean;
324
+ } | {
325
+ outcome: "conflict";
326
+ reason: "command_mismatch";
327
+ };
328
+ /** Requests counted at store time in [from, to), with the scope and access rules of usage. */
329
+ export type RequestCountsQuery = {
330
+ scope: UsageScope;
331
+ from: string;
332
+ to: string;
333
+ connection?: string;
334
+ groupBy: readonly ("provider" | "operation" | "connection" | "source" | "day")[];
335
+ /** Rows per page, 1..1000, default 1000; truncated reports more rows. */
336
+ limit?: number;
337
+ };
338
+ /** One row per dimension combination and state, ordered by dimensions then state. */
339
+ export type RequestCountRow = {
340
+ dimensions: Readonly<Record<string, string>>;
341
+ state: RequestState;
342
+ count: bigint;
343
+ };
344
+ export type RequestCountsPage = {
345
+ rows: readonly RequestCountRow[];
346
+ asOf: string;
347
+ truncated: boolean;
263
348
  };
264
349
  export type OperationRef = Pick<Scope, "namespace" | "principal"> & {
265
350
  operationId: string;
@@ -432,6 +517,8 @@ export type AccessContext = {
432
517
  readablePools: readonly string[] | "*";
433
518
  canReadBillingDetail: boolean;
434
519
  canManageBudgets: boolean;
520
+ /** Explicit billing write permission. Read access or budget management does not imply it. */
521
+ canImportBilling?: boolean;
435
522
  };
436
523
  /**
437
524
  * groupBy: "principal" is allowed only for group or namespace scope.
@@ -549,8 +636,13 @@ export type ExpireReservationsResult = ValidationFailure | {
549
636
  * Admission sweeps at most one default batch. Maintenance owns the remaining expired backlog.
550
637
  */
551
638
  export interface Meter {
639
+ importBilling(access: AccessContext, input: BillingImportInput): Promise<BillingImportResult | {
640
+ outcome: "forbidden";
641
+ }>;
642
+ billingImports(access: AccessContext, query: BillingImportsQuery): Promise<ReadResult<BillingImportsPage>>;
552
643
  expireReservations(input: ExpireReservationsInput): Promise<ExpireReservationsResult>;
553
- reserve(input: ReserveInput): Promise<ReserveResult>;
644
+ reserve(input: MeterReserveInput): Promise<ReserveResult>;
645
+ countRequest(input: CountRequestInput): Promise<CountRequestResult>;
554
646
  markDispatchIntent(input: DispatchIntentInput): Promise<DispatchGrant>;
555
647
  renewLease(input: LeaseRenewalInput): Promise<LeaseRenewal>;
556
648
  claimForRecovery(input: RecoveryClaimInput): Promise<RecoveryClaim>;
@@ -560,6 +652,7 @@ export interface Meter {
560
652
  getOperation(access: AccessContext, input: OperationRef): Promise<ReadResult<Operation | null>>;
561
653
  usage(access: AccessContext, query: UsageQuery): Promise<ReadResult<UsagePage>>;
562
654
  listOperations(access: AccessContext, query: OperationsQuery): Promise<ReadResult<OperationsPage>>;
655
+ requestCounts(access: AccessContext, query: RequestCountsQuery): Promise<ReadResult<RequestCountsPage>>;
563
656
  definedBudgets(access: AccessContext, query: DefinedBudgetsQuery): Promise<ReadResult<readonly Budget[]>>;
564
657
  applicableBudgets(access: AccessContext, query: ApplicableBudgetsQuery): Promise<ReadResult<readonly BudgetStatus[]>>;
565
658
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
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, } 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
5
  export { fromDecimalString, toDecimalString, formatMoney, add as addMoney, sub as subtractMoney, compare as compareMoney, } from "./money.js";
@@ -7,3 +8,6 @@ export type { ValidationFailure, AdmissionPolicy, BudgetOwner } from "./contract
7
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";
13
+ export type { ImportAttribution, BillingImportInput, ReconciliationEntry, BillingImportRecord, BillingImportResult, BillingImportsQuery, BillingImportsPage, BillingOperationChange, } from "./billing.js";
package/dist/index.js CHANGED
@@ -1,5 +1,7 @@
1
+ export { requestStates } from "./policy.js";
1
2
  export { resolveWindow } from "./windows.js";
2
3
  export { fromDecimalString, toDecimalString, formatMoney, add as addMoney, sub as subtractMoney, compare as compareMoney, } from "./money.js";
3
4
  export { normalizeScale, add as addQuantity, compare as compareQuantity, format as formatQuantity, } from "./quantity.js";
4
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/policy.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { BudgetScope, Source, Surface } from "./contracts.js";
1
+ import type { BudgetScope, RequestState, Source, Surface } from "./contracts.js";
2
2
  /** Single source of the default admission order; hosts may permute it, never shorten it. */
3
3
  export declare const defaultBudgetOrder: readonly BudgetScope["kind"][];
4
4
  export declare const tagPattern: RegExp;
@@ -8,3 +8,5 @@ export declare function normalizeTags(tags: readonly string[]): string[] | null;
8
8
  export declare const sourcesOf: (surface: Surface) => readonly Source[];
9
9
  /** True when a budget surface value bounds an operation with this surface and source. */
10
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
@@ -19,7 +19,7 @@ export function normalizeTags(tags) {
19
19
  }
20
20
  const surfaceSources = Object.freeze({
21
21
  app: Object.freeze(["app", "worker"]),
22
- programmatic: Object.freeze(["api", "sdk", "cli", "mcp", "proxy"]),
22
+ programmatic: Object.freeze(["api", "sdk", "cli", "mcp", "proxy", "import"]),
23
23
  });
24
24
  /** Sources a surface budget covers. Every Source belongs to exactly one Surface. */
25
25
  export const sourcesOf = (surface) => surfaceSources[surface];
@@ -27,3 +27,10 @@ export const sourcesOf = (surface) => surfaceSources[surface];
27
27
  export const surfaceMatches = (budgetSurface, surface, source) => budgetSurface === "any" ||
28
28
  budgetSurface === surface ||
29
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
+ }
package/dist/wire.js CHANGED
@@ -23,6 +23,26 @@ 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) };
32
+ if (typeof v.id === "string" &&
33
+ typeof v.importId === "string" &&
34
+ ["aggregate", "import_total"].includes(String(v.kind)) &&
35
+ v.scope &&
36
+ v.window &&
37
+ v.ledgerTotal &&
38
+ v.evidenceTotal &&
39
+ decimal(v.unknownOperations) &&
40
+ (v.differenceUnits === null || decimal(v.differenceUnits)))
41
+ return {
42
+ ...v,
43
+ unknownOperations: BigInt(v.unknownOperations),
44
+ differenceUnits: v.differenceUnits === null ? null : BigInt(v.differenceUnits),
45
+ };
26
46
  return value;
27
47
  });
28
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usagekit/core",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "Exact quantities, ownership and pure domain types.",