diff --git a/__tests__/discovery.test.ts b/__tests__/discovery.test.ts index e4a89a9..3a0241c 100644 --- a/__tests__/discovery.test.ts +++ b/__tests__/discovery.test.ts @@ -12,11 +12,16 @@ const catalogResponse = { description: 'Search Reddit', provider: 'AnyAPI', pricing: { - from: { model: 'linear', unit: 'result', baseUsd: 0.00005, perUnitUsd: 0.0001, maxUsd: 0.004 }, + from: { + model: 'linear', unit: 'result', baseUsd: 0.00005, perUnitUsd: 0.0001, maxUsd: 0.004, maxPer1kUsd: 4, + }, failoverMaxUsd: 0.005, + failoverMaxPer1kUsd: 5, }, lanes: [{ - pricing: { model: 'linear', unit: 'result', baseUsd: 0.00005, perUnitUsd: 0.0001, maxUsd: 0.004 }, + pricing: { + model: 'linear', unit: 'result', baseUsd: 0.00005, perUnitUsd: 0.0001, maxUsd: 0.004, maxPer1kUsd: 4, + }, health: { window: '30d', uptimePct: 99.5, latencyP50Ms: 240, requests: 80 }, }], tryEligible: true, @@ -35,11 +40,71 @@ describe('customer-safe discovery reader', () => { expect(Object.fromEntries(url.searchParams)).toEqual({ category: 'social' }); expect(response).toEqual(catalogResponse); expect(formatCatalogPrice(response.apis[0]!)).toBe( - 'from USD 0.00005 + USD 0.0001/result (max USD 0.0040/request)', + 'up to USD 4.00/1k req (USD 0.00005 + USD 0.0001/result)', ); expectCustomerSafe(response); }); + it('quotes the published per-1k rate instead of scaling the per-request price', async () => { + // booking.search in the live catalog: 0.0966 * 1000 is 96.60000000000001, + // so the displayed rate is only exact when the published field is read. + const client = clientFor({ + apis: [{ + ...catalogResponse.apis[0], + pricing: { + from: { model: 'flat', unit: 'request', maxUsd: 0.0966, maxPer1kUsd: 96.6 }, + failoverMaxUsd: 0.0966, + failoverMaxPer1kUsd: 96.6, + }, + lanes: undefined, + }], + }); + + const api = (await client.catalog()).apis[0]!; + + expect(api.pricing.from.maxPer1kUsd).toBe(96.6); + expect(api.pricing.from.maxPer1kUsd).not.toBe(0.0966 * 1000); + expect(api.pricing.failoverMaxPer1kUsd).toBe(96.6); + expect(formatCatalogPrice(api)).toBe('from USD 96.60/1k req'); + }); + + // A rate for a thousand requests is always a whole number of cents, because + // one credit is $0.00001 and lane prices are whole credits. The shared + // sub-dollar formatter pads to four decimals for real per-request charges, + // which would print 39 of the live catalog's rates as `USD 0.9000/1k req`. + it('prints a sub-dollar rate in cents rather than padded millionths', async () => { + const client = clientFor({ + apis: [{ + ...catalogResponse.apis[0], + pricing: { + from: { model: 'flat', unit: 'request', maxUsd: 0.0009, maxPer1kUsd: 0.9 }, + failoverMaxUsd: 0.0009, + failoverMaxPer1kUsd: 0.9, + }, + lanes: undefined, + }], + }); + + const api = (await client.catalog()).apis[0]!; + + expect(formatCatalogPrice(api)).toBe('from USD 0.90/1k req'); + }); + + it('rejects offers published without the per-1k rate', async () => { + const client = clientFor({ + apis: [{ + ...catalogResponse.apis[0], + pricing: { + from: { model: 'flat', unit: 'request', maxUsd: 0.0966 }, + failoverMaxUsd: 0.0966, + failoverMaxPer1kUsd: 96.6, + }, + }], + }); + + await expect(client.catalog()).rejects.toThrow('Invalid AnyAPI API discovery response.'); + }); + it('accepts discovery from older gateways without optional routing booleans', async () => { const api = { ...catalogResponse.apis[0] }; delete (api as Partial).failover; @@ -59,8 +124,9 @@ describe('customer-safe discovery reader', () => { category: 'shopping', provider: 'AnyAPI', pricing: { - from: { model: 'flat', unit: 'request', maxUsd: 0.005 }, + from: { model: 'flat', unit: 'request', maxUsd: 0.005, maxPer1kUsd: 5 }, failoverMaxUsd: 0.006, + failoverMaxPer1kUsd: 6, }, relevance: 0.92, highlightFields: [{ path: 'items[].price', type: 'number' }], @@ -88,7 +154,7 @@ describe('customer-safe discovery reader', () => { results: [{ slug: 'amazon.product', provider: 'AnyAPI', - pricing: { from: { model: 'flat', unit: 'request', maxUsd: 0.005 } }, + pricing: { from: { model: 'flat', unit: 'request', maxUsd: 0.005, maxPer1kUsd: 5 } }, relevance: 0.92, }], }); @@ -136,9 +202,11 @@ describe('customer-safe discovery reader', () => { baseUsd: 0.2, perUnitUsd: 0.3, maxUsd: 0.4, + maxPer1kUsd: 400, futureOfferField: 'ignored', }, failoverMaxUsd: 0.1, + failoverMaxPer1kUsd: 100, futurePricingField: 'ignored', }, lanes: [{ @@ -147,6 +215,7 @@ describe('customer-safe discovery reader', () => { model: 'flat', unit: 'request', maxUsd: 0.9, + maxPer1kUsd: 900, futureOfferField: 'ignored', }, health: { @@ -175,11 +244,13 @@ describe('customer-safe discovery reader', () => { baseUsd: 0.2, perUnitUsd: 0.3, maxUsd: 0.4, + maxPer1kUsd: 400, }, failoverMaxUsd: 0.1, + failoverMaxPer1kUsd: 100, }, lanes: [{ - pricing: { model: 'flat', unit: 'request', maxUsd: 0.9 }, + pricing: { model: 'flat', unit: 'request', maxUsd: 0.9, maxPer1kUsd: 900 }, health: { window: '7d', uptimePct: 42, @@ -201,8 +272,9 @@ describe('customer-safe discovery reader', () => { category: 'shopping', provider: 'AnyAPI', pricing: { - from: { model: 'flat', unit: 'request', maxUsd: 0.005 }, + from: { model: 'flat', unit: 'request', maxUsd: 0.005, maxPer1kUsd: 5 }, failoverMaxUsd: 0.006, + failoverMaxPer1kUsd: 6, }, relevance: 0.92, highlightFields: [{ @@ -268,8 +340,9 @@ describe('customer-safe discovery reader', () => { apis: [{ ...catalogResponse.apis[0], pricing: { - from: { model: 'flat', unit: 'request', maxUsd }, + from: { model: 'flat', unit: 'request', maxUsd, maxPer1kUsd: 10 }, failoverMaxUsd: 0.01, + failoverMaxPer1kUsd: 10, }, }, ], diff --git a/src/discovery.ts b/src/discovery.ts index ee4ecd3..d068973 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -96,24 +96,33 @@ function readPricing(value: unknown): DiscoveryPricing | undefined { const record = asRecord(value); const from = readOffer(record?.from); const failoverMaxUsd = usdNumber(record?.failoverMaxUsd); - if (!from || failoverMaxUsd === undefined) { + const failoverMaxPer1kUsd = usdNumber(record?.failoverMaxPer1kUsd); + if (!from || failoverMaxUsd === undefined || failoverMaxPer1kUsd === undefined) { return undefined; } - return { from, failoverMaxUsd }; + return { from, failoverMaxUsd, failoverMaxPer1kUsd }; } +// Every static price arrives in both denominations: maxUsd for one request and +// maxPer1kUsd for 1,000 of them. Both are read from the wire and neither is +// derived here, because scaling dollars in binary floating point turns the +// published 96.6 into 96.60000000000001 on 20 of the live catalog's prices. function readOffer(value: unknown): PricingOffer | undefined { const record = asRecord(value); const model = stringValue(record?.model); const unit = stringValue(record?.unit); const maxUsd = usdNumber(record?.maxUsd); - if (model === 'flat' && unit === 'request' && maxUsd !== undefined) { - return { model, unit, maxUsd }; + const maxPer1kUsd = usdNumber(record?.maxPer1kUsd); + if (maxUsd === undefined || maxPer1kUsd === undefined) { + return undefined; + } + if (model === 'flat' && unit === 'request') { + return { model, unit, maxUsd, maxPer1kUsd }; } const baseUsd = usdNumber(record?.baseUsd); const perUnitUsd = usdNumber(record?.perUnitUsd); - if (model === 'linear' && unit && baseUsd !== undefined && perUnitUsd !== undefined && maxUsd !== undefined) { - return { model, unit, baseUsd, perUnitUsd, maxUsd }; + if (model === 'linear' && unit && baseUsd !== undefined && perUnitUsd !== undefined) { + return { model, unit, baseUsd, perUnitUsd, maxUsd, maxPer1kUsd }; } return undefined; } diff --git a/src/format.ts b/src/format.ts index cda0247..f213c0a 100644 --- a/src/format.ts +++ b/src/format.ts @@ -7,15 +7,36 @@ export function formatUsd(value: unknown): string { return `USD ${formatUsdNumber(value)}`; } +/** Catalog prices are quoted at this shared rate; a run is still billed per request. */ +const REQUEST_RATE_LABEL = '/1k req'; + +/** + * A per-1,000-request rate in USD. + * + * Two decimals is exact here, not a rounding: one internal credit is $0.00001 + * and lane prices are whole credits, so a rate for a thousand requests is always + * a whole number of cents. `formatUsd` pads sub-dollar amounts to four decimals + * because a per-request charge really is that small, which would print the + * cheapest 39 of the catalog's rates as `USD 0.9000/1k req`. + */ +function formatRateUsd(value: number): string { + return `USD ${value.toFixed(2)}`; +} + export function formatCatalogPrice(api: CatalogApi): string { return formatPricingOffer(api.pricing.from); } +// Catalog prices are quoted per 1,000 requests because most of the catalog costs +// a fraction of a cent per call. The rate is the gateway's published +// maxPer1kUsd, never maxUsd scaled here. A metered offer keeps its per-item rate +// per item, which is what the customer's `limit` actually moves. export function formatPricingOffer(offer: PricingOffer): string { + const rate = `${formatRateUsd(offer.maxPer1kUsd)}${REQUEST_RATE_LABEL}`; if (offer.model === 'flat') { - return `from ${formatUsd(offer.maxUsd)}/request`; + return `from ${rate}`; } - return `from ${formatUsd(offer.baseUsd)} + ${formatUsd(offer.perUnitUsd)}/${offer.unit} (max ${formatUsd(offer.maxUsd)}/request)`; + return `up to ${rate} (${formatUsd(offer.baseUsd)} + ${formatUsd(offer.perUnitUsd)}/${offer.unit})`; } export function printTable(rows: string[][]): string { diff --git a/src/types.ts b/src/types.ts index 8212b04..fb106ab 100644 --- a/src/types.ts +++ b/src/types.ts @@ -74,7 +74,10 @@ export interface TokenResponse { export interface FlatPricingOffer { model: 'flat'; unit: 'request'; + /** USD billed for one request */ maxUsd: number; + /** the same maximum per 1,000 requests, published by the gateway */ + maxPer1kUsd: number; } export interface LinearPricingOffer { @@ -82,7 +85,10 @@ export interface LinearPricingOffer { unit: string; baseUsd: number; perUnitUsd: number; + /** USD ceiling for one request */ maxUsd: number; + /** the same ceiling per 1,000 requests, published by the gateway */ + maxPer1kUsd: number; } export type PricingOffer = FlatPricingOffer | LinearPricingOffer; @@ -90,6 +96,7 @@ export type PricingOffer = FlatPricingOffer | LinearPricingOffer; export interface DiscoveryPricing { from: PricingOffer; failoverMaxUsd: number; + failoverMaxPer1kUsd: number; } export interface DiscoveryLane {