Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 81 additions & 8 deletions __tests__/discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<typeof api>).failover;
Expand All @@ -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' }],
Expand Down Expand Up @@ -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,
}],
});
Expand Down Expand Up @@ -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: [{
Expand All @@ -147,6 +215,7 @@ describe('customer-safe discovery reader', () => {
model: 'flat',
unit: 'request',
maxUsd: 0.9,
maxPer1kUsd: 900,
futureOfferField: 'ignored',
},
health: {
Expand Down Expand Up @@ -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,
Expand All @@ -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: [{
Expand Down Expand Up @@ -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,
},
},
],
Expand Down
21 changes: 15 additions & 6 deletions src/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
25 changes: 23 additions & 2 deletions src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,22 +74,29 @@ 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 {
model: 'linear';
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;

export interface DiscoveryPricing {
from: PricingOffer;
failoverMaxUsd: number;
failoverMaxPer1kUsd: number;
}

export interface DiscoveryLane {
Expand Down
Loading