@usagekit/core 0.8.0 → 0.9.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.
@@ -269,8 +269,8 @@ export type ReserveResult = ValidationFailure | {
269
269
  operation: Operation;
270
270
  }
271
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.
272
+ * The BYOK connection's tracking policy is passthrough for this operation, or the catalog
273
+ * marks it free: counted with state passthrough, never reserved. The caller may forward the call.
274
274
  */
275
275
  | {
276
276
  outcome: "passthrough";
@@ -289,15 +289,30 @@ export type ReserveResult = ValidationFailure | {
289
289
  /** Where a reserve estimate came from, in resolution order. */
290
290
  export type EstimateSource = "manual" | "measured" | "list" | "unknown";
291
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.
292
+ * Meter reserve input. For BYOK with a catalog, estimate may be omitted: the Meter resolves it
293
+ * for the connection and request options. Billable platform-funded calls require a host
294
+ * customer-price estimate; catalog-declared nonbillable calls are counted without a reservation.
295
+ * Store reservations always receive a ReserveInput.
294
296
  */
295
297
  export type MeterReserveInput = Omit<ReserveInput, "estimate"> & {
296
- /** Omitted: the Meter resolves it through its catalog and the connection policy. */
298
+ /** BYOK may omit for catalog resolution; billable platform calls require host customer pricing. */
297
299
  estimate?: readonly Quantity[];
298
300
  /** Request options that move the price, read from the request by the descriptor. */
299
301
  options?: Readonly<Record<string, string>>;
300
302
  };
303
+ /** Authorized advisory estimate. No operation identity or accounting side effects. */
304
+ export type MeterQuoteInput = Omit<MeterReserveInput, "operationId" | "reservationTtlMs" | "correlationId" | "parentOperationId">;
305
+ export type MeterQuote = {
306
+ quantities: readonly Quantity[];
307
+ /** Matches stored reservation provenance; omitted when none was supplied or resolved. */
308
+ estimateSource?: EstimateSource;
309
+ providerPriceVersion?: string;
310
+ checkedAt?: string;
311
+ sampleSize?: number;
312
+ tracking: "metered" | "passthrough" | "unpriced";
313
+ budgets: readonly BudgetStatus[];
314
+ asOf: string;
315
+ };
301
316
  /**
302
317
  * Non-operation outcomes, counted per state and never reserved. Metered requests are the
303
318
  * operations themselves and are not counted here.
@@ -636,6 +651,7 @@ export type ExpireReservationsResult = ValidationFailure | {
636
651
  * Admission sweeps at most one default batch. Maintenance owns the remaining expired backlog.
637
652
  */
638
653
  export interface Meter {
654
+ quote(access: AccessContext, input: MeterQuoteInput): Promise<ReadResult<MeterQuote>>;
639
655
  importBilling(access: AccessContext, input: BillingImportInput): Promise<BillingImportResult | {
640
656
  outcome: "forbidden";
641
657
  }>;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
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";
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, MeterQuoteInput, MeterQuote, EstimateSource, RequestState, CountRequestInput, CountRequestResult, RequestCountsQuery, RequestCountRow, RequestCountsPage, } from "./contracts.js";
2
2
  export { requestStates } from "./policy.js";
3
3
  export { resolveWindow } from "./windows.js";
4
4
  export type { ResolvedWindow } from "./windows.js";
@@ -8,6 +8,6 @@ export type { ValidationFailure, AdmissionPolicy, BudgetOwner } from "./contract
8
8
  export { defaultBudgetOrder, sourcesOf, surfaceMatches, tagPattern, normalizeTags, } from "./policy.js";
9
9
  export type { ExpireReservationsInput, ExpireReservationsResult } from "./contracts.js";
10
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";
11
+ export type { ProviderId, OperationId, ProviderAuth, BillingUnit, ProviderPlan, PriceRow, PriceBasis, PriceCalculation, RequestMatch, ProviderOperation, BalanceProbe, PriceListSource, BillingExport, ProviderDescriptor, BalanceSnapshot, BillingLine, DescriptorProblem, PricingCatalog, ConnectionPolicy, TrackingPolicy, MeasuredPrice, } from "./providers.js";
12
+ export { validateDescriptor, validateConnectionPolicy } from "./providers.js";
13
13
  export type { ImportAttribution, BillingImportInput, ReconciliationEntry, BillingImportRecord, BillingImportResult, BillingImportsQuery, BillingImportsPage, BillingOperationChange, } from "./billing.js";
package/dist/index.js CHANGED
@@ -4,4 +4,4 @@ export { fromDecimalString, toDecimalString, formatMoney, add as addMoney, sub a
4
4
  export { normalizeScale, add as addQuantity, compare as compareQuantity, format as formatQuantity, } from "./quantity.js";
5
5
  export { defaultBudgetOrder, sourcesOf, surfaceMatches, tagPattern, normalizeTags, } from "./policy.js";
6
6
  export { decodeMeterJson } from "./wire.js";
7
- export { validateDescriptor } from "./providers.js";
7
+ export { validateDescriptor, validateConnectionPolicy } from "./providers.js";
@@ -1,4 +1,4 @@
1
- import type { EstimateSource, Money, Quantity } from "./contracts.js";
1
+ import type { EstimateSource, FundingSource, Money, Quantity, ValidationFailure } from "./contracts.js";
2
2
  /**
3
3
  * Provider descriptors: data that tells usagekit how to price, route, meter and reconcile one
4
4
  * provider. Descriptors live in `@usagekit/providers`; the core contract does not depend on
@@ -36,11 +36,41 @@ export type ProviderPlan = {
36
36
  value: bigint;
37
37
  };
38
38
  allowanceMode: "hard" | "soft";
39
+ /** Provider subscription data; never a per-receipt debit. */
40
+ subscription?: {
41
+ period: "month";
42
+ fee: Money;
43
+ source: string;
44
+ checkedAt: string;
45
+ };
39
46
  };
40
47
  /**
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.
48
+ * Overrides default to a whole-call price; item overrides replace a formula coefficient.
43
49
  */
50
+ export type PriceBasis = "call" | "item";
51
+ export type PriceCalculation = {
52
+ kind: "option_multiplier";
53
+ multiplierOption: {
54
+ key: string;
55
+ value: string;
56
+ factor: string;
57
+ };
58
+ } | {
59
+ kind: "additional_pages";
60
+ depthOption: string;
61
+ pageSize: number;
62
+ additionalPageFactor: string;
63
+ } | {
64
+ kind: "base_plus_items";
65
+ itemCountOption: string;
66
+ perItem: string;
67
+ multiplierOption?: {
68
+ key: string;
69
+ value: string;
70
+ factor: string;
71
+ };
72
+ };
73
+ /** One public list price, with optional request calculation metadata. */
44
74
  export type PriceRow = {
45
75
  /** ISO date. */
46
76
  validFrom: string;
@@ -54,6 +84,7 @@ export type PriceRow = {
54
84
  unit: BillingUnit;
55
85
  /** Exact nonnegative decimal in that unit. */
56
86
  perUnit: string;
87
+ calculation?: PriceCalculation;
57
88
  /** URL of the public price source. */
58
89
  source: string;
59
90
  /** ISO date of the last check against the source. */
@@ -76,6 +107,19 @@ export type ProviderOperation = {
76
107
  match: readonly RequestMatch[];
77
108
  /** Options that move the price; values are extracted from the request by `options`. */
78
109
  optionKeys?: readonly string[];
110
+ /** Exhaustive documented request fields; unclassified fields cannot be safely priced. */
111
+ requestPricing?: {
112
+ source: string;
113
+ formula: string;
114
+ priceFields: Readonly<Record<string, {
115
+ formula: string;
116
+ /** A supplied opaque value can prevent any defensible upper bound. */
117
+ unpricedWhen?: "present" | "true";
118
+ /** Documented item count when this field is omitted. */
119
+ defaultCount?: number;
120
+ }>>;
121
+ neutralFields: readonly string[];
122
+ };
79
123
  /**
80
124
  * Cost is known from the response body, only after a later call, or never per call.
81
125
  * response: settle on stream close. deferred: settle with unknown cost, keep pending, resolve
@@ -132,6 +176,8 @@ export type ProviderDescriptor = {
132
176
  /** Origin and optional base path, for example "https://api.example.com". */
133
177
  upstream: string;
134
178
  auth: ProviderAuth;
179
+ /** Pricing and matching see body/query only when declared; method and URL path are always available. */
180
+ pricingInputs: readonly ("body" | "query")[];
135
181
  stripRequestHeaders?: readonly string[];
136
182
  billing: {
137
183
  unit: BillingUnit;
@@ -176,26 +222,39 @@ export type BillingLine = {
176
222
  to: string;
177
223
  };
178
224
  };
179
- /** Per-connection choice per catalog operation; the default is metered. */
225
+ /** Per-operation tracking; passthrough opts BYOK out of spend enforcement only. */
180
226
  export type TrackingPolicy = "metered" | "passthrough";
181
227
  /** Median of a connection's settled receipts for one operation, with its sample size. */
182
228
  export type MeasuredPrice = (operation: OperationId) => {
183
229
  quantity: Quantity;
184
230
  samples: number;
231
+ basis?: PriceBasis;
232
+ checkedAt?: string;
185
233
  } | undefined;
186
234
  /**
187
- * Connection data a host resolves for the Meter: provider, selected plan, manual per-call prices,
235
+ * Connection data a host resolves for the Meter: provider, selected plan, BYOK manual prices,
188
236
  * measured prices and tracking policy, all keyed by OperationId. overage is true when the host
189
237
  * knows the plan allowance is exhausted.
190
238
  */
191
239
  export type ConnectionPolicy = {
192
240
  provider: ProviderId;
241
+ /** Actual connection funding, derived by the host from trusted server state. */
242
+ fundingSource: FundingSource;
193
243
  plan?: string;
244
+ /** BYOK only: estimates the user's own provider cost, including genuine zero. */
194
245
  manualPrices?: Readonly<Record<OperationId, Quantity>>;
246
+ manualPriceBasis?: Readonly<Record<OperationId, PriceBasis>>;
195
247
  measured?: MeasuredPrice;
196
248
  tracking?: Readonly<Record<OperationId, TrackingPolicy>>;
197
249
  overage?: boolean;
198
250
  };
251
+ /** Validate raw connection settings before saving. Manual prices are BYOK-only. */
252
+ export declare function validateConnectionPolicy(policy: {
253
+ fundingSource: FundingSource;
254
+ tracking?: unknown;
255
+ manualPrices?: unknown;
256
+ manualPriceBasis?: unknown;
257
+ }, operations: readonly Pick<ProviderOperation, "id" | "billable">[]): ValidationFailure | null;
199
258
  /**
200
259
  * What the Meter needs from a provider catalog. `@usagekit/providers` createCatalog satisfies
201
260
  * it; the Meter does not depend on that package. operationsOf lists the operations of an
@@ -208,10 +267,17 @@ export type PricingCatalog = {
208
267
  quantities: Quantity[];
209
268
  source: EstimateSource;
210
269
  priceVersion?: string;
270
+ checkedAt?: string;
271
+ sampleSize?: number;
211
272
  };
212
273
  operationsOf(providerId: ProviderId): readonly ProviderOperation[];
213
274
  };
214
275
  export type DescriptorProblem = {
276
+ kind: "invalid_pricing_inputs";
277
+ } | {
278
+ kind: "undeclared_query_input";
279
+ operation: OperationId;
280
+ } | {
215
281
  kind: "duplicate_operation";
216
282
  operation: OperationId;
217
283
  } | {
@@ -236,6 +302,14 @@ export type DescriptorProblem = {
236
302
  kind: "invalid_price";
237
303
  row: number;
238
304
  field: "perUnit" | "validFrom" | "checkedAt" | "source";
305
+ } | {
306
+ kind: "invalid_calculation";
307
+ row: number;
308
+ field: string;
309
+ } | {
310
+ kind: "invalid_subscription";
311
+ plan: string;
312
+ field: string;
239
313
  } | {
240
314
  kind: "unknown_probe_operation";
241
315
  operation: OperationId;
package/dist/providers.js CHANGED
@@ -1,3 +1,37 @@
1
+ /** Validate raw connection settings before saving. Manual prices are BYOK-only. */
2
+ export function validateConnectionPolicy(policy, operations) {
3
+ const invalid = (field, reason) => ({
4
+ outcome: "invalid",
5
+ field,
6
+ reason,
7
+ });
8
+ if (policy.fundingSource === "platform") {
9
+ for (const field of ["manualPrices", "manualPriceBasis"]) {
10
+ const value = policy[field];
11
+ const absent = value == null ||
12
+ (typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0);
13
+ if (!absent)
14
+ return invalid(field, "manual provider prices are BYOK-only");
15
+ }
16
+ }
17
+ const { tracking } = policy;
18
+ if (tracking === undefined)
19
+ return null;
20
+ if (!tracking || typeof tracking !== "object" || Array.isArray(tracking))
21
+ return invalid("tracking", "must be an operation-to-tracking map");
22
+ for (const [id, mode] of Object.entries(tracking)) {
23
+ const operation = operations.find((entry) => entry.id === id);
24
+ if (policy.fundingSource === "platform" &&
25
+ operation?.billable !== false &&
26
+ (mode === "passthrough" || mode === "unpriced"))
27
+ return invalid(`tracking.${id}`, "billable platform operations must be metered; credits charges cannot be opted out");
28
+ if (!operation)
29
+ return invalid(`tracking.${id}`, "unknown operation");
30
+ if (mode !== "metered" && mode !== "passthrough")
31
+ return invalid(`tracking.${id}`, "must be metered or passthrough");
32
+ }
33
+ return null;
34
+ }
1
35
  const isoDate = (text) => /^\d{4}-\d{2}-\d{2}/.test(text) && Number.isFinite(Date.parse(text));
2
36
  /**
3
37
  * Structural problems of a descriptor, in a stable order: operations, plans, prices, probe,
@@ -5,8 +39,15 @@ const isoDate = (text) => /^\d{4}-\d{2}-\d{2}/.test(text) && Number.isFinite(Dat
5
39
  */
6
40
  export function validateDescriptor(d) {
7
41
  const problems = [];
42
+ if (!Array.isArray(d.pricingInputs) ||
43
+ d.pricingInputs.some((part) => part !== "body" && part !== "query") ||
44
+ new Set(d.pricingInputs).size !== d.pricingInputs.length)
45
+ problems.push({ kind: "invalid_pricing_inputs" });
8
46
  const operations = new Map();
9
47
  for (const op of d.operations) {
48
+ if (!(Array.isArray(d.pricingInputs) && d.pricingInputs.includes("query")) &&
49
+ op.match.some((rule) => Object.keys(rule.query ?? {}).length > 0))
50
+ problems.push({ kind: "undeclared_query_input", operation: op.id });
10
51
  if (operations.has(op.id))
11
52
  problems.push({ kind: "duplicate_operation", operation: op.id });
12
53
  else
@@ -20,6 +61,17 @@ export function validateDescriptor(d) {
20
61
  if (plans.has(plan.id))
21
62
  problems.push({ kind: "duplicate_plan", plan: plan.id });
22
63
  plans.add(plan.id);
64
+ const fee = plan.subscription;
65
+ if (fee) {
66
+ for (const [field, valid] of Object.entries({
67
+ period: fee.period === "month",
68
+ fee: fee.fee.currency === "USD" && typeof fee.fee.units === "bigint" && fee.fee.units >= 0n,
69
+ source: Boolean(fee.source.trim()),
70
+ checkedAt: isoDate(fee.checkedAt),
71
+ }))
72
+ if (!valid)
73
+ problems.push({ kind: "invalid_subscription", plan: plan.id, field });
74
+ }
23
75
  }
24
76
  d.prices.forEach((row, index) => {
25
77
  const op = operations.get(row.operation);
@@ -30,8 +82,37 @@ export function validateDescriptor(d) {
30
82
  for (const key of Object.keys(row.option ?? {}))
31
83
  if (op && !(op.optionKeys ?? []).includes(key))
32
84
  problems.push({ kind: "unknown_price_option", row: index, option: key });
33
- if (!/^\d+(\.\d+)?$/.test(row.perUnit))
85
+ if (!/^\d+(\.\d{1,18})?$/.test(row.perUnit))
34
86
  problems.push({ kind: "invalid_price", row: index, field: "perUnit" });
87
+ const c = row.calculation;
88
+ if (c) {
89
+ const decimal = (x) => /^\d+(\.\d{1,18})?$/.test(x);
90
+ const option = (key) => {
91
+ if (op && !(op.optionKeys ?? []).includes(key))
92
+ problems.push({ kind: "unknown_price_option", row: index, option: key });
93
+ };
94
+ if (c.kind === "additional_pages") {
95
+ option(c.depthOption);
96
+ if (!Number.isSafeInteger(c.pageSize) || c.pageSize < 1)
97
+ problems.push({ kind: "invalid_calculation", row: index, field: "pageSize" });
98
+ if (!decimal(c.additionalPageFactor))
99
+ problems.push({ kind: "invalid_calculation", row: index, field: "additionalPageFactor" });
100
+ }
101
+ else if (c.kind === "base_plus_items") {
102
+ option(c.itemCountOption);
103
+ if (!decimal(c.perItem))
104
+ problems.push({ kind: "invalid_calculation", row: index, field: "perItem" });
105
+ }
106
+ else if (c.kind !== "option_multiplier")
107
+ problems.push({ kind: "invalid_calculation", row: index, field: "kind" });
108
+ if (c.kind === "option_multiplier" && !c.multiplierOption)
109
+ problems.push({ kind: "invalid_calculation", row: index, field: "multiplierOption" });
110
+ if ((c.kind === "base_plus_items" || c.kind === "option_multiplier") && c.multiplierOption) {
111
+ option(c.multiplierOption.key);
112
+ if (!c.multiplierOption.value.trim() || !decimal(c.multiplierOption.factor))
113
+ problems.push({ kind: "invalid_calculation", row: index, field: "multiplierOption" });
114
+ }
115
+ }
35
116
  if (!isoDate(row.validFrom))
36
117
  problems.push({ kind: "invalid_price", row: index, field: "validFrom" });
37
118
  if (!isoDate(row.checkedAt))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usagekit/core",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "Exact quantities, ownership and pure domain types.",