@usagekit/providers 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/catalog.d.ts CHANGED
@@ -1,21 +1,10 @@
1
- import type { BalanceSnapshot, Budget, EstimateSource, OperationId, ProviderDescriptor, ProviderId, ProviderOperation, ProviderPlan, Quantity } from "@usagekit/core";
1
+ import type { BalanceSnapshot, Budget, EstimateSource, ConnectionPolicy, OperationId, ProviderDescriptor, ProviderId, ProviderOperation, ProviderPlan, Quantity } from "@usagekit/core";
2
2
  import type { Extractors, ProviderModule, ProviderRequest, ProviderResponse, ReceiptDraft } from "./types.js";
3
3
  import type { BudgetTarget } from "./budget.js";
4
4
  export type { EstimateSource };
5
5
  /** Connection data the catalog needs; the catalog itself stays storage-free. */
6
- export type CatalogConnection = {
6
+ export type CatalogConnection = Omit<ConnectionPolicy, "tracking"> & {
7
7
  id: string;
8
- provider: ProviderId;
9
- plan?: string;
10
- /** Per-call manual price for (connection, operation). */
11
- manualPrices?: Readonly<Record<OperationId, Quantity>>;
12
- /** Median of this connection's settled receipts for the operation, with its sample size. */
13
- measured?: (operation: OperationId) => {
14
- quantity: Quantity;
15
- samples: number;
16
- } | undefined;
17
- /** The plan allowance is exhausted: only overage rows apply. */
18
- overage?: boolean;
19
8
  };
20
9
  export type CatalogEstimate = {
21
10
  /** Always includes one request for a billable operation; empty for a free operation. */
@@ -23,6 +12,8 @@ export type CatalogEstimate = {
23
12
  source: EstimateSource;
24
13
  /** "<provider>:<validFrom>" of the list row, when the source is list. */
25
14
  priceVersion?: string;
15
+ checkedAt?: string;
16
+ sampleSize?: number;
26
17
  };
27
18
  export type CatalogMatch = {
28
19
  operation: ProviderOperation;
@@ -45,7 +36,10 @@ export declare function createCatalog({ providers, enabled, measuredMinSamples,
45
36
  }): {
46
37
  providers: () => ProviderDescriptor[];
47
38
  match(providerId: ProviderId, request: ProviderRequest): CatalogMatch;
48
- /** Resolution order: manual, measured with enough samples, list row, unknown. */
39
+ canEstimate(providerId: ProviderId, options: Readonly<Record<string, string>>): boolean;
40
+ /** BYOK caller estimates also retain any documented surcharge floor. */
41
+ boundEstimate(providerId: ProviderId, operationId: OperationId, options: Readonly<Record<string, string>>, quantities: readonly Quantity[]): Quantity[];
42
+ /** Resolution order: request safety, manual, measured with enough samples, list row, unknown. */
49
43
  estimate(connection: CatalogConnection, operationId: OperationId, options: Readonly<Record<string, string>>): CatalogEstimate;
50
44
  extract(providerId: ProviderId, operationId: OperationId | "unknown", request: ProviderRequest, response: ProviderResponse, body: unknown): ReceiptDraft;
51
45
  /** Price list and billing export parsers of an enabled descriptor, when it has them. */
package/dist/catalog.js CHANGED
@@ -1,11 +1,23 @@
1
- import { validateDescriptor } from "@usagekit/core";
1
+ import { compareQuantity, validateDescriptor } from "@usagekit/core";
2
2
  import { matchOperation } from "./match.js";
3
- import { selectPriceRow } from "./prices.js";
3
+ import { calculatePrice, selectPriceRow } from "./prices.js";
4
4
  import { pointer } from "./pointer.js";
5
5
  import { decimalQuantity, requestsMeasured } from "./receipts.js";
6
6
  import { proposePlanBudget } from "./budget.js";
7
7
  const oneRequest = () => ({ value: 1n, scale: 0, unit: "requests" });
8
8
  const withRequest = (q) => q.some((x) => x.unit === "requests") ? q : [...q, oneRequest()];
9
+ /** Compare exact quantities at their own scales; never round a reservation down. */
10
+ function withFloor(quantities, floor) {
11
+ const result = [...quantities];
12
+ for (const bound of floor) {
13
+ const index = result.findIndex((q) => q.unit === bound.unit);
14
+ if (index < 0)
15
+ result.push(bound);
16
+ else if (compareQuantity(result[index], bound) < 0)
17
+ result[index] = bound;
18
+ }
19
+ return result;
20
+ }
9
21
  function isoTimestamp(value) {
10
22
  if (typeof value !== "string" || !value)
11
23
  return undefined;
@@ -72,26 +84,60 @@ export function createCatalog({ providers, enabled, measuredMinSamples = 5, now
72
84
  providers: () => [...enabled].map((id) => modules.get(id).descriptor),
73
85
  match(providerId, request) {
74
86
  const module = active(providerId);
75
- const operation = module && matchOperation(module.descriptor, request);
76
- if (!module || !operation)
87
+ if (!module)
77
88
  return { operation: "unknown" };
78
- // Extractors always see an absolute URL, even for proxy paths.
79
- const url = new URL(request.url, module.descriptor.upstream).href;
80
- return { operation, options: module.extractors.options(operation, { ...request, url }) };
89
+ const url = new URL(request.url, module.descriptor.upstream);
90
+ if (module.descriptor.pricingInputs.includes("query")) {
91
+ const keys = new Set();
92
+ for (const key of url.searchParams.keys()) {
93
+ if (keys.has(key))
94
+ throw new Error("InvalidInput: duplicate query key");
95
+ keys.add(key);
96
+ }
97
+ }
98
+ else
99
+ url.search = "";
100
+ // Pricing sees only declared inputs. Receipts and transport retain the full request.
101
+ const { body, ...rest } = request;
102
+ const pricingRequest = {
103
+ ...rest,
104
+ url: url.href,
105
+ ...(module.descriptor.pricingInputs.includes("body") && body !== undefined ? { body } : {}),
106
+ };
107
+ const operation = matchOperation(module.descriptor, pricingRequest);
108
+ if (!operation)
109
+ return { operation: "unknown" };
110
+ return { operation, options: module.extractors.options(operation, pricingRequest) };
111
+ },
112
+ canEstimate(providerId, options) {
113
+ return require(providerId).extractors.canEstimate?.(options) ?? true;
81
114
  },
82
- /** Resolution order: manual, measured with enough samples, list row, unknown. */
115
+ /** BYOK caller estimates also retain any documented surcharge floor. */
116
+ boundEstimate(providerId, operationId, options, quantities) {
117
+ const module = require(providerId);
118
+ const operation = module.descriptor.operations.find((o) => o.id === operationId);
119
+ if (!operation || !module.extractors.requiresListPriceFloor?.(options))
120
+ return [...quantities];
121
+ const listed = module.extractors.estimate({
122
+ operation,
123
+ options,
124
+ prices: validPrices(module.descriptor),
125
+ });
126
+ return withFloor(quantities, listed);
127
+ },
128
+ /** Resolution order: request safety, manual, measured with enough samples, list row, unknown. */
83
129
  estimate(connection, operationId, options) {
84
130
  const module = active(connection.provider), operation = module?.descriptor.operations.find((o) => o.id === operationId);
85
131
  if (!module || !operation)
86
132
  return { quantities: [oneRequest()], source: "unknown" };
87
133
  if (!operation.billable)
88
134
  return { quantities: [], source: "list" };
89
- const manual = connection.manualPrices?.[operationId];
90
- if (manual)
91
- return { quantities: withRequest([manual]), source: "manual" };
92
- const measured = connection.measured?.(operationId);
93
- if (measured && measured.samples >= measuredMinSamples)
94
- return { quantities: withRequest([measured.quantity]), source: "measured" };
135
+ // Provider prices estimate BYOK upstream cost only. A host must supply customer prices
136
+ // explicitly for platform funding; neither manual nor list prices price credits.
137
+ if (connection.fundingSource !== "byok")
138
+ return { quantities: [oneRequest()], source: "unknown" };
139
+ if (module.extractors.canEstimate?.(options) === false)
140
+ return { quantities: [oneRequest()], source: "unknown" };
95
141
  const input = {
96
142
  operation,
97
143
  options,
@@ -99,14 +145,53 @@ export function createCatalog({ providers, enabled, measuredMinSamples = 5, now
99
145
  ...(connection.plan !== undefined ? { plan: connection.plan } : {}),
100
146
  ...(connection.overage ? { overage: true } : {}),
101
147
  };
102
- const listed = module.extractors.estimate(input);
148
+ const listed = module.extractors.estimate(input), row = selectPriceRow(input);
149
+ const manual = connection.manualPrices?.[operationId];
150
+ const measured = manual ? undefined : connection.measured?.(operationId);
151
+ const override = manual ??
152
+ (measured &&
153
+ Number.isSafeInteger(measured.samples) &&
154
+ measured.samples >= measuredMinSamples
155
+ ? measured.quantity
156
+ : undefined);
157
+ if (override) {
158
+ const basis = manual ? connection.manualPriceBasis?.[operationId] : measured?.basis;
159
+ if (basis === "item" && (!row || row.calculation?.kind !== "base_plus_items"))
160
+ return {
161
+ quantities: withRequest(listed.filter((q) => q.unit !== override.unit)),
162
+ source: "unknown",
163
+ };
164
+ const quantity = basis === "item" ? calculatePrice(row, options, override) : override;
165
+ if (!quantity)
166
+ return {
167
+ quantities: withRequest(listed.filter((q) => q.unit !== override.unit)),
168
+ source: "unknown",
169
+ };
170
+ return {
171
+ quantities: withRequest(withFloor([
172
+ quantity,
173
+ ...listed.filter((q) => q.unit !== quantity.unit && q.unit !== "requests"),
174
+ ], module.extractors.requiresListPriceFloor?.(options) ? listed : [])),
175
+ source: manual ? "manual" : "measured",
176
+ ...(basis === "item" && row
177
+ ? { priceVersion: `${module.descriptor.id}:${row.validFrom}`, checkedAt: row.checkedAt }
178
+ : {}),
179
+ ...(!manual && measured
180
+ ? {
181
+ sampleSize: measured.samples,
182
+ ...(measured.checkedAt ? { checkedAt: measured.checkedAt } : {}),
183
+ }
184
+ : {}),
185
+ };
186
+ }
103
187
  if (!listed.length)
104
188
  return { quantities: [oneRequest()], source: "unknown" };
105
- const row = selectPriceRow(input);
106
189
  return {
107
190
  quantities: withRequest(listed),
108
191
  source: "list",
109
- ...(row ? { priceVersion: `${module.descriptor.id}:${row.validFrom}` } : {}),
192
+ ...(row
193
+ ? { priceVersion: `${module.descriptor.id}:${row.validFrom}`, checkedAt: row.checkedAt }
194
+ : {}),
110
195
  };
111
196
  },
112
197
  extract(providerId, operationId, request, response, body) {
@@ -4,7 +4,7 @@ export declare const labsPricing = "https://dataforseo.com/pricing/dataforseo-la
4
4
  export declare const backlinksPricing = "https://dataforseo.com/pricing/backlinks/backlinks";
5
5
  /**
6
6
  * Provider A: cost per call in every response, prepaid money balance, basic auth. List prices
7
- * are the per-task base price in cents; per-result charges, depth multipliers and paid
8
- * parameters come from the receipt.
7
+ * include request formulas in cents. Actual settlement continues to use response evidence.
8
+ * Formulas preserve host characterization values; the existing check date is not refreshed.
9
9
  */
10
10
  export declare const descriptor: ProviderDescriptor;
@@ -1,14 +1,17 @@
1
+ import { requestPricing } from "./request-pricing.js";
1
2
  const docs = "https://docs.dataforseo.com/v3/";
2
3
  export const serpPricing = "https://dataforseo.com/pricing/serp/google-organic-serp-api";
3
4
  export const labsPricing = "https://dataforseo.com/pricing/dataforseo-labs/dataforseo-google-api";
4
5
  export const backlinksPricing = "https://dataforseo.com/pricing/backlinks/backlinks";
5
6
  const checked = "2026-09-25";
6
- const live = (id, label, path) => ({
7
+ const live = (id, label, path, optionKeys) => ({
7
8
  id,
8
9
  label,
9
10
  billable: true,
11
+ requestPricing: requestPricing[id],
10
12
  match: [{ method: "POST", path }],
11
13
  costEvidence: "response",
14
+ ...(optionKeys ? { optionKeys } : {}),
12
15
  });
13
16
  const free = (id, label, method, path) => ({
14
17
  id,
@@ -32,34 +35,64 @@ const backlinks = [
32
35
  ["history", "Backlinks history"],
33
36
  ["backlinks", "Backlinks live"],
34
37
  ];
35
- const row = (operation, perUnit, source, option) => ({
38
+ const row = (operation, perUnit, source, option, calculation) => ({
36
39
  validFrom: checked,
37
40
  operation,
38
41
  ...(option ? { option } : {}),
39
42
  unit: "cents",
40
43
  perUnit,
44
+ ...(calculation ? { calculation } : {}),
41
45
  source,
42
46
  checkedAt: checked,
43
47
  });
48
+ // Bill each SERP of up to 10 results at the full base: base * ceil(depth / 10).
49
+ // https://docs.dataforseo.com/v3/serp/google/organic/live/advanced/
50
+ // https://docs.dataforseo.com/v3/serp/google/organic/task_post/
51
+ // The pricing page multiplies for each 10 results; the cost formula charges a full page 2.
52
+ // https://dataforseo.com/pricing/serp/google-organic-serp-api
53
+ // https://dataforseo.com/help-center/serp-api-cost-explained
54
+ // The conflicting 25% later-page discount can only lower the charge, so do not apply it.
55
+ // Unreturned results are refunded: full base per page is a conservative upper bound
56
+ // that never under-reserves; omitted depth still means one page.
57
+ // https://dataforseo.com/help-center/above-100-results-with-serp-api
58
+ const pages = {
59
+ kind: "additional_pages",
60
+ depthOption: "depth",
61
+ pageSize: 10,
62
+ additionalPageFactor: "1",
63
+ };
64
+ const items = (perItem, clickstream = true) => ({
65
+ kind: "base_plus_items",
66
+ itemCountOption: "itemCount",
67
+ perItem,
68
+ ...(clickstream ? { multiplierOption: { key: "clickstream", value: "true", factor: "2" } } : {}),
69
+ });
44
70
  /**
45
71
  * Provider A: cost per call in every response, prepaid money balance, basic auth. List prices
46
- * are the per-task base price in cents; per-result charges, depth multipliers and paid
47
- * parameters come from the receipt.
72
+ * include request formulas in cents. Actual settlement continues to use response evidence.
73
+ * Formulas preserve host characterization values; the existing check date is not refreshed.
48
74
  */
49
75
  export const descriptor = {
50
76
  id: "dataforseo",
51
77
  label: "DataForSEO",
52
78
  upstream: "https://api.dataforseo.com",
79
+ pricingInputs: ["body"],
53
80
  auth: { kind: "basic" },
54
81
  stripRequestHeaders: ["authorization"],
55
82
  billing: { unit: "cents", cycle: "prepaid" },
56
83
  plans: [{ id: "prepaid", label: "Prepaid balance", cycle: "prepaid", allowanceMode: "hard" }],
57
84
  prices: [
58
- row("serp.google.organic.live.advanced", "0.2", serpPricing),
59
- row("serp.google.organic.task_post", "0.06", serpPricing, { priority: "normal" }),
60
- row("serp.google.organic.task_post", "0.12", serpPricing, { priority: "high" }),
61
- ...labs.map(([name]) => row(`dataforseo_labs.google.${name}.live`, name === "historical_rank_overview" ? "12" : "1.2", labsPricing)),
62
- ...backlinks.map(([name]) => row(`backlinks.${name}.live`, "2.4", backlinksPricing)),
85
+ row("serp.google.organic.live.advanced", "0.2", serpPricing, undefined, pages),
86
+ row("serp.google.organic.task_post", "0.06", serpPricing, { priority: "normal" }, pages),
87
+ row("serp.google.organic.task_post", "0.12", serpPricing, { priority: "high" }, pages),
88
+ ...labs.map(([name]) => row(`dataforseo_labs.google.${name}.live`, name === "historical_rank_overview" ? "12" : "1.2", labsPricing, undefined,
89
+ // Per-request plus per-item, including ranked_keywords/domain_rank_overview.
90
+ // https://dataforseo.com/pricing/dataforseo-labs/dataforseo-google-api
91
+ items(name === "historical_rank_overview" ? "0.12" : "0.012"))),
92
+ ...backlinks.map(([name]) => row(`backlinks.${name}.live`, "2.4", backlinksPricing, undefined,
93
+ // Every returned row is billed, including summary and history.
94
+ // https://dataforseo.com/pricing/backlinks/backlinks
95
+ items("0.0036", false))),
63
96
  ],
64
97
  priceList: {
65
98
  kind: "endpoint",
@@ -68,19 +101,27 @@ export const descriptor = {
68
101
  mapping: "provider-specific",
69
102
  },
70
103
  operations: [
71
- live("serp.google.organic.live.advanced", "Google organic SERP, live advanced", "/v3/serp/google/organic/live/advanced"),
104
+ live("serp.google.organic.live.advanced", "Google organic SERP, live advanced", "/v3/serp/google/organic/live/advanced", ["depth"]),
72
105
  {
73
106
  id: "serp.google.organic.task_post",
74
107
  label: "Google organic SERP, task post",
75
108
  billable: true,
76
109
  match: [{ method: "POST", path: "/v3/serp/google/organic/task_post" }],
77
- optionKeys: ["priority"],
110
+ optionKeys: ["priority", "depth"],
78
111
  costEvidence: "deferred",
112
+ requestPricing: requestPricing["serp.google.organic.task_post"],
79
113
  },
80
114
  free("serp.google.organic.tasks_ready", "Google organic SERP, tasks ready", "GET", "/v3/serp/google/organic/tasks_ready"),
81
115
  free("serp.google.organic.task_get.advanced", "Google organic SERP, task get advanced", "GET", "/v3/serp/google/organic/task_get/advanced/:id"),
82
- ...labs.map(([name, label]) => live(`dataforseo_labs.google.${name}.live`, label, `/v3/dataforseo_labs/google/${name}/live`)),
83
- ...backlinks.map(([name, label]) => live(`backlinks.${name}.live`, label, `/v3/backlinks/${name}/live`)),
116
+ ...labs.map(([name, label]) => live(`dataforseo_labs.google.${name}.live`, label, `/v3/dataforseo_labs/google/${name}/live`, [
117
+ "itemCount",
118
+ "clickstream",
119
+ ...(name === "historical_rank_overview" ? ["dateFrom", "dateTo"] : []),
120
+ ])),
121
+ ...backlinks.map(([name, label]) => live(`backlinks.${name}.live`, label, `/v3/backlinks/${name}/live`, [
122
+ "itemCount",
123
+ ...(name === "history" ? ["dateFrom", "dateTo"] : []),
124
+ ])),
84
125
  free("appendix.user_data", "User data (balance, limits, prices)", "GET", "/v3/appendix/user_data"),
85
126
  free("dataforseo_labs.status", "Labs status", "GET", "/v3/dataforseo_labs/status"),
86
127
  ],