@usagekit/providers 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.
@@ -0,0 +1,234 @@
1
+ const credentialQuery = /^(api[_-]?key|access[_-]?token|token|authorization|password|secret|(?:.*[_-])?credentials?)$/i;
2
+ const credentialHeader = /^(authorization|proxy-authorization|cookie|set-cookie|(?:x-)?api[_-]?key|(?:x-)?access[_-]?token|(?:x-)?auth[_-]?token|(?:x-)?(?:.*[_-])?credentials?)$/i;
3
+ export class RequestPricingError extends Error {
4
+ code;
5
+ constructor(code, message = code) {
6
+ super(message);
7
+ this.code = code;
8
+ }
9
+ }
10
+ /** RFC 9110 media types are case-insensitive; structured JSON suffixes are JSON too. */
11
+ export function isJsonMediaType(value) {
12
+ const type = value?.split(";")[0]?.trim().toLowerCase() ?? "";
13
+ return (type === "application/json" ||
14
+ /^[!#$%&'*+.^_`|~0-9a-z-]+\/[!#$%&'*+.^_`|~0-9a-z-]+\+json$/.test(type));
15
+ }
16
+ function freeze(value) {
17
+ if (value && typeof value === "object") {
18
+ for (const child of Object.values(value))
19
+ freeze(child);
20
+ Object.freeze(value);
21
+ }
22
+ return value;
23
+ }
24
+ /** Only pricing/admission can construct this snapshot. Bytes are copied on access, and all
25
+ * other state is deeply frozen. Neither caller mutations nor dispatch can change the price
26
+ * inputs. Credential fields are stripped before pricing; dispatch adds trusted authentication
27
+ * to a copy without retaining it in this snapshot. */
28
+ export class PricedRequest {
29
+ method;
30
+ url;
31
+ headers;
32
+ parsed;
33
+ matched;
34
+ input;
35
+ admission;
36
+ estimate;
37
+ auth;
38
+ #bytes;
39
+ constructor(method, url, headers, parsed, matched, input, admission, estimate, bytes, auth) {
40
+ this.method = method;
41
+ this.url = url;
42
+ this.headers = headers;
43
+ this.parsed = parsed;
44
+ this.matched = matched;
45
+ this.input = input;
46
+ this.admission = admission;
47
+ this.estimate = estimate;
48
+ this.auth = auth;
49
+ this.#bytes = bytes;
50
+ Object.freeze(this);
51
+ }
52
+ get body() {
53
+ return this.#bytes?.slice();
54
+ }
55
+ static async price({ meter, catalog, input, request, expectedOperation, rejectUnknown, allowUnknownDispatch = false, }) {
56
+ const descriptor = catalog.providers().find((p) => p.id === input.provider);
57
+ if (!descriptor)
58
+ throw new RequestPricingError("invalid_provider_request");
59
+ let target;
60
+ let headers;
61
+ // Resolve and validate before catalog callbacks. Reject userinfo rather than retaining it.
62
+ try {
63
+ const base = new URL(descriptor.upstream);
64
+ target = new URL(request.url, base);
65
+ const prefix = base.pathname.replace(/\/$/, "");
66
+ if (base.username ||
67
+ base.password ||
68
+ target.username ||
69
+ target.password ||
70
+ target.protocol !== "https:" ||
71
+ target.origin !== base.origin ||
72
+ (prefix && target.pathname !== prefix && !target.pathname.startsWith(prefix + "/")))
73
+ throw new Error("InvalidUpstream");
74
+ const auth = descriptor.auth;
75
+ for (const key of [...target.searchParams.keys()]) {
76
+ if (credentialQuery.test(key) ||
77
+ (auth.kind === "query" && key.toLowerCase() === auth.param.toLowerCase()))
78
+ target.searchParams.delete(key);
79
+ }
80
+ const cleanHeaders = new Headers(request.headers);
81
+ for (const key of [...cleanHeaders.keys()]) {
82
+ if (credentialHeader.test(key) ||
83
+ (auth.kind === "header" && key === auth.name.toLowerCase()) ||
84
+ descriptor.stripRequestHeaders?.some((name) => key === name.toLowerCase()))
85
+ cleanHeaders.delete(key);
86
+ }
87
+ headers = Object.fromEntries(cleanHeaders);
88
+ }
89
+ catch {
90
+ throw new RequestPricingError("invalid_provider_request");
91
+ }
92
+ const url = target.href;
93
+ const auth = freeze(structuredClone(descriptor.auth));
94
+ const pricesBody = descriptor.pricingInputs.includes("body");
95
+ let bytes;
96
+ let value;
97
+ let invalidJson = false;
98
+ if (request.body !== undefined) {
99
+ const raw = request.body instanceof Uint8Array || typeof request.body === "string";
100
+ try {
101
+ bytes =
102
+ request.body instanceof Uint8Array
103
+ ? Uint8Array.from(request.body)
104
+ : new TextEncoder().encode(typeof request.body === "string" ? request.body : JSON.stringify(request.body));
105
+ }
106
+ catch {
107
+ throw new RequestPricingError("invalid_provider_request");
108
+ }
109
+ // Host JSON objects have an implicit JSON media type; raw bytes do not.
110
+ if (!raw && !headers["content-type"])
111
+ headers["content-type"] = "application/json";
112
+ value = new TextDecoder().decode(bytes);
113
+ if (pricesBody && isJsonMediaType(headers["content-type"])) {
114
+ try {
115
+ value = JSON.parse(value);
116
+ }
117
+ catch {
118
+ invalidJson = true;
119
+ }
120
+ }
121
+ }
122
+ const parsed = {
123
+ method: request.method,
124
+ url,
125
+ headers,
126
+ ...(bytes === undefined ? {} : { body: value }),
127
+ };
128
+ let matched;
129
+ try {
130
+ matched = catalog.match(input.provider, parsed);
131
+ }
132
+ catch (error) {
133
+ throw new RequestPricingError(invalidJson ? "invalid_json" : "invalid_provider_request", error instanceof Error ? error.message : undefined);
134
+ }
135
+ if (matched.operation === "unknown") {
136
+ if (rejectUnknown)
137
+ throw new RequestPricingError("unknown_operation");
138
+ }
139
+ else if (pricesBody && matched.operation.billable && bytes !== undefined) {
140
+ if (invalidJson)
141
+ throw new RequestPricingError("invalid_json");
142
+ if (!isJsonMediaType(headers["content-type"]))
143
+ throw new RequestPricingError("invalid_provider_request");
144
+ bytes = new TextEncoder().encode(JSON.stringify(value));
145
+ headers["content-type"] = "application/json";
146
+ }
147
+ const uncertain = matched.operation !== "unknown" && !catalog.canEstimate(input.provider, matched.options);
148
+ if (expectedOperation !== undefined &&
149
+ (matched.operation === "unknown" || matched.operation.id !== expectedOperation))
150
+ throw new RequestPricingError("operation_mismatch");
151
+ // Isolate even references retained by a custom matcher before the first await.
152
+ const parsedSnapshot = freeze(structuredClone(parsed));
153
+ const headerSnapshot = freeze({ ...headers });
154
+ const snapshot = freeze(structuredClone(input));
155
+ const match = freeze(structuredClone(matched));
156
+ const { estimate: requestedEstimate, estimateSource, providerPriceVersion, ...restInput } = snapshot;
157
+ // Request uncertainty replaces every caller price and its provenance. The Meter owns
158
+ // funding, passthrough and budget admission, including requests-only unknown estimates.
159
+ const baseInput = uncertain
160
+ ? restInput
161
+ : {
162
+ ...restInput,
163
+ ...(estimateSource === undefined ? {} : { estimateSource }),
164
+ ...(providerPriceVersion === undefined ? {} : { providerPriceVersion }),
165
+ };
166
+ const boundedEstimate = requestedEstimate &&
167
+ input.fundingSource === "byok" &&
168
+ match.operation !== "unknown" &&
169
+ !uncertain
170
+ ? catalog.boundEstimate(input.provider, match.operation.id, match.options, requestedEstimate)
171
+ : requestedEstimate;
172
+ const admission = await meter.reserve({
173
+ ...baseInput,
174
+ operation: match.operation === "unknown" ? "unknown" : match.operation.id,
175
+ ...(uncertain
176
+ ? {
177
+ estimate: [{ unit: "requests", value: 1n, scale: 0 }],
178
+ estimateSource: "unknown",
179
+ }
180
+ : match.operation === "unknown"
181
+ ? allowUnknownDispatch
182
+ ? {
183
+ estimate: [{ unit: "requests", value: 1n, scale: 0 }],
184
+ estimateSource: "unknown",
185
+ }
186
+ : {}
187
+ : {
188
+ ...(boundedEstimate === undefined ? {} : { estimate: boundedEstimate }),
189
+ options: match.options,
190
+ }),
191
+ });
192
+ const estimate = admission.outcome === "reserved" ? admission.operation.estimate : [];
193
+ return new PricedRequest(parsedSnapshot.method, url, headerSnapshot, parsedSnapshot, match, snapshot, freeze(structuredClone(admission)), freeze(structuredClone(estimate)), bytes, auth);
194
+ }
195
+ }
196
+ /** Apply trusted credentials to disposable transport objects. Hosts validate on throwaway
197
+ * objects before pricing; using this same helper at dispatch keeps both paths identical.
198
+ * Invalid credentials always throw a stable error without the credential value or cause. */
199
+ export function applyProviderCredentials(auth, url, headers, credentials) {
200
+ try {
201
+ const { secret } = credentials;
202
+ if (auth.kind === "query") {
203
+ // URLSearchParams mutation re-encodes unrelated values, bare flags and invalid bytes.
204
+ const pair = new URLSearchParams([[auth.param, secret]]).toString();
205
+ url.search = url.search ? `${url.search}&${pair}` : `?${pair}`;
206
+ }
207
+ else if (auth.kind === "basic") {
208
+ const encoded = btoa(Array.from(new TextEncoder().encode(secret), (byte) => String.fromCharCode(byte)).join(""));
209
+ headers.set("Authorization", `Basic ${encoded}`);
210
+ }
211
+ else if (auth.kind === "bearer")
212
+ headers.set("Authorization", `Bearer ${secret}`);
213
+ else
214
+ headers.set(auth.name, `${auth.prefix ?? ""}${secret}`);
215
+ }
216
+ catch {
217
+ throw new Error("invalid_provider_credentials");
218
+ }
219
+ }
220
+ /** The only fetch boundary used by proxy and recorder. Pass null explicitly for no auth. */
221
+ export async function dispatchPricedRequest(request, transport, signal, credentials) {
222
+ const url = new URL(request.url);
223
+ const headers = new Headers(request.headers);
224
+ if (credentials !== null)
225
+ applyProviderCredentials(request.auth, url, headers, credentials);
226
+ const body = request.body;
227
+ return transport(url.href, {
228
+ method: request.method,
229
+ headers,
230
+ redirect: "error",
231
+ signal,
232
+ ...(body === undefined ? {} : { body }),
233
+ });
234
+ }
package/dist/prices.d.ts CHANGED
@@ -7,5 +7,7 @@ import type { EstimateInput } from "./types.js";
7
7
  * win, then later validFrom. An operation priced per plan resolves nothing without a plan.
8
8
  */
9
9
  export declare function selectPriceRow(input: EstimateInput): PriceRow | undefined;
10
- /** Default estimate: one call at the selected row's per-unit price; empty without a row. */
10
+ /** List formula or item override; an absent required item count leaves the price unknown. */
11
+ export declare function calculatePrice(row: PriceRow, options: Readonly<Record<string, string>>, itemPrice?: Quantity): Quantity | undefined;
12
+ /** Default estimate: selected base price and optional formula; empty without a row or count. */
11
13
  export declare function listEstimate(input: EstimateInput): Quantity[];
package/dist/prices.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { addQuantity, formatQuantity } from "@usagekit/core";
1
2
  import { decimalQuantity } from "./receipts.js";
2
3
  /**
3
4
  * The price row behind a list estimate. Rows must name the operation, match the connection plan
@@ -18,8 +19,64 @@ export function selectPriceRow(input) {
18
19
  return bp - ap || bo - ao || b.validFrom.localeCompare(a.validFrom);
19
20
  })[0];
20
21
  }
21
- /** Default estimate: one call at the selected row's per-unit price; empty without a row. */
22
+ /** Exact multiplication; reduce only trailing zeros, never round a coefficient. */
23
+ function multiply(a, b) {
24
+ let value = a.value * b.value, scale = a.scale + b.scale;
25
+ while (scale > 18 && value % 10n === 0n) {
26
+ value /= 10n;
27
+ scale--;
28
+ }
29
+ if (scale > 18)
30
+ throw new Error("InvalidInput: calculation precision exceeds 18");
31
+ return decimalQuantity(formatQuantity({ value, scale, unit: a.unit }), a.unit);
32
+ }
33
+ const count = (value, fallback) => {
34
+ if (value === undefined)
35
+ return fallback;
36
+ if (!/^\d+$/.test(value))
37
+ throw new Error("InvalidInput: expected integer pricing count");
38
+ return BigInt(value);
39
+ };
40
+ /** List formula or item override; an absent required item count leaves the price unknown. */
41
+ export function calculatePrice(row, options, itemPrice) {
42
+ const base = decimalQuantity(row.perUnit, row.unit), c = row.calculation;
43
+ if (!c) {
44
+ if (itemPrice)
45
+ throw new Error("InvalidInput: item price requires a base-plus-items row");
46
+ return base;
47
+ }
48
+ if (c.kind === "additional_pages") {
49
+ if (itemPrice)
50
+ throw new Error("InvalidInput: item price requires a base-plus-items row");
51
+ const depth = count(options[c.depthOption], BigInt(c.pageSize));
52
+ if (depth < 1n)
53
+ throw new Error("InvalidInput: depth must be positive");
54
+ const pages = (depth + BigInt(c.pageSize) - 1n) / BigInt(c.pageSize);
55
+ const extra = multiply(base, decimalQuantity(c.additionalPageFactor, row.unit));
56
+ return decimalQuantity(formatQuantity(addQuantity(base, { ...extra, value: extra.value * (pages - 1n) })), row.unit);
57
+ }
58
+ if (c.kind === "option_multiplier") {
59
+ if (itemPrice)
60
+ throw new Error("InvalidInput: item price requires a base-plus-items row");
61
+ return options[c.multiplierOption.key] === c.multiplierOption.value
62
+ ? multiply(base, decimalQuantity(c.multiplierOption.factor, row.unit))
63
+ : base;
64
+ }
65
+ if (itemPrice && itemPrice.unit !== row.unit)
66
+ throw new Error("InvalidInput: item price unit differs from row");
67
+ // An omitted count is unknown, not zero. Never reserve just the base for an item formula.
68
+ if (options[c.itemCountOption] === undefined)
69
+ return undefined;
70
+ const coefficient = itemPrice ?? decimalQuantity(c.perItem, row.unit);
71
+ const items = count(options[c.itemCountOption], 0n);
72
+ let total = addQuantity(base, { ...coefficient, value: coefficient.value * items });
73
+ if (c.multiplierOption && options[c.multiplierOption.key] === c.multiplierOption.value)
74
+ total = multiply(total, decimalQuantity(c.multiplierOption.factor, row.unit));
75
+ return decimalQuantity(formatQuantity(total), row.unit);
76
+ }
77
+ /** Default estimate: selected base price and optional formula; empty without a row or count. */
22
78
  export function listEstimate(input) {
23
79
  const row = selectPriceRow(input);
24
- return row ? [decimalQuantity(row.perUnit, row.unit)] : [];
80
+ const quantity = row && calculatePrice(row, input.options);
81
+ return quantity ? [quantity] : [];
25
82
  }
@@ -1,14 +1,14 @@
1
1
  export const pricing = "https://serpapi.com/pricing";
2
2
  const checked = "2026-09-25";
3
3
  const monthly = [
4
- ["free", "Free (250 searches per month)", 250n],
5
- ["starter", "Starter (25 USD, 1,000 searches per month)", 1000n],
6
- ["developer", "Developer (75 USD, 5,000 searches per month)", 5000n],
7
- ["production", "Production (150 USD, 15,000 searches per month)", 15000n],
8
- ["bigdata", "Big Data (275 USD, 30,000 searches per month)", 30000n],
9
- ["searcher", "Searcher (725 USD, 100,000 searches per month)", 100000n],
10
- ["volume", "Volume (1,475 USD, 250,000 searches per month)", 250000n],
11
- ["infrastructure", "Infrastructure (2,750 USD, 500,000 searches per month)", 500000n],
4
+ ["free", "Free (250 searches per month)", 250n, 0n],
5
+ ["starter", "Starter (25 USD, 1,000 searches per month)", 1000n, 2500n],
6
+ ["developer", "Developer (75 USD, 5,000 searches per month)", 5000n, 7500n],
7
+ ["production", "Production (150 USD, 15,000 searches per month)", 15000n, 15000n],
8
+ ["bigdata", "Big Data (275 USD, 30,000 searches per month)", 30000n, 27500n],
9
+ ["searcher", "Searcher (725 USD, 100,000 searches per month)", 100000n, 72500n],
10
+ ["volume", "Volume (1,475 USD, 250,000 searches per month)", 250000n, 147500n],
11
+ ["infrastructure", "Infrastructure (2,750 USD, 500,000 searches per month)", 500000n, 275000n],
12
12
  ];
13
13
  const row = (extra) => ({
14
14
  validFrom: checked,
@@ -28,15 +28,22 @@ export const descriptor = {
28
28
  id: "serpapi",
29
29
  label: "SerpApi",
30
30
  upstream: "https://serpapi.com",
31
+ pricingInputs: [],
31
32
  auth: { kind: "query", param: "api_key" },
32
33
  billing: { unit: "units", cycle: "monthly_plan" },
33
34
  plans: [
34
- ...monthly.map(([id, label, searches]) => ({
35
+ ...monthly.map(([id, label, searches, feeCents]) => ({
35
36
  id,
36
37
  label,
37
38
  cycle: "monthly_plan",
38
39
  allowance: { unit: "units", value: searches },
39
40
  allowanceMode: "hard",
41
+ subscription: {
42
+ period: "month",
43
+ fee: { currency: "USD", units: feeCents * 10000n },
44
+ source: pricing,
45
+ checkedAt: checked,
46
+ },
40
47
  })),
41
48
  { id: "extra_credits", label: "Extra credits", cycle: "prepaid", allowanceMode: "hard" },
42
49
  ],
@@ -9,6 +9,7 @@ const source = "https://example.com/pricing";
9
9
  export const exampleDescriptor = {
10
10
  id: "example",
11
11
  label: "Example provider",
12
+ pricingInputs: ["query"],
12
13
  upstream: "https://api.example.com/v1",
13
14
  auth: { kind: "header", name: "X-Api-Key" },
14
15
  billing: { unit: "units", cycle: "monthly_plan" },
package/dist/types.d.ts CHANGED
@@ -27,6 +27,10 @@ export type EstimateInput = {
27
27
  };
28
28
  /** The only code in a descriptor. Every function is pure and synchronous. */
29
29
  export type Extractors = {
30
+ /** False means request uncertainty cannot be overridden by a host or BYOK price. */
31
+ canEstimate?(options: Readonly<Record<string, string>>): boolean;
32
+ /** True when documented surcharges require the list upper bound even with an override. */
33
+ requiresListPriceFloor?(options: Readonly<Record<string, string>>): boolean;
30
34
  /** Estimate before the call from the matched operation, options and connection plan. Empty means no price. */
31
35
  estimate(input: EstimateInput): Quantity[];
32
36
  /** Options that select a price row, read from the request. Options are request data, never guessed. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usagekit/providers",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "Provider descriptors, catalog, price tables, extractors, fixtures and probes.",
@@ -31,7 +31,7 @@
31
31
  "prepack": "node ../../scripts/package-license.mjs"
32
32
  },
33
33
  "dependencies": {
34
- "@usagekit/core": "0.8.0"
34
+ "@usagekit/core": "0.9.0"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "vitest": ">=5.0.1 <6",