From f2ca0501b903073747ff6b18acd6dee87c2d3020 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Tue, 28 Jul 2026 02:13:48 -0700 Subject: [PATCH] Restore thin discovery adapter boundaries --- .github/workflows/ci.yml | 1 + .github/workflows/publish.yml | 1 + README.md | 19 ++- __tests__/bundled-skills.test.ts | 2 + __tests__/discovery.test.ts | 187 +++++++++++++++++++++++++++--- __tests__/run.test.ts | 42 +++++++ package-lock.json | 4 +- package.json | 5 +- scripts/live-discovery-canary.mjs | 64 ++++++++++ skills/anyapi-discover/SKILL.md | 7 +- src/api.ts | 42 +------ src/discovery.ts | 104 ++++++++++++----- src/types.ts | 2 + 13 files changed, 392 insertions(+), 88 deletions(-) create mode 100644 scripts/live-discovery-canary.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2772c73..40d81ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,3 +16,4 @@ jobs: - run: npm ci - run: npm run build - run: npm test + - run: npm run canary:live diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 856b85a..fb08510 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,6 +21,7 @@ jobs: - run: npm ci - run: npm run build - run: npm test + - run: npm run canary:live - run: npm publish --provenance env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/README.md b/README.md index fbc76d3..7227429 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ anyapi run reddit.search --input '{"query":"anyapi","limit":5}' - `anyapi login --api-key aa_live_...` - store an existing dashboard key locally. - `anyapi search ` - search the public catalog and print SKU, name, and USD price terms. - `anyapi list [--category ]` - list catalog APIs. -- `anyapi describe ` - print the authenticated API definition, including schemas and USD pricing. +- `anyapi describe ` - print the authenticated API definition, including opaque schemas and gateway-published USD pricing, lane order, and failover metadata. - `anyapi run [--input ''] [-i file] [--idempotency-key ] [--jq ] [--fields a,b] [--max-items N] [--summary] [-o path] [--json]` - run an API. Always saves the full result; shape flags trim only the stdout view. - `anyapi view [path] [--last [sku]] [--jq ] [--fields a,b] [--max-items N] [--summary] [--json]` - re-shape a saved run file locally. Zero network, zero cost. - `anyapi balance` - print the remaining USD balance. @@ -43,6 +43,19 @@ anyapi run reddit.search --input '{"query":"anyapi","limit":5}' Auth resolution order is `--api-key`, then `ANYAPI_API_KEY`, then `~/.anyapi/config.json`, then trial self-signup. When the trial budget is spent, runs return HTTP 402 `trial_cap_reached`; run `anyapi connect` to continue. +## Gateway and CLI responsibilities + +The AnyAPI gateway owns input validation, provider normalization, pricing, lane order, +routing, failover, and billing. The CLI is a thin transport and presentation adapter: +it reads the known discovery fields it displays, tolerates safe additive discovery +fields, and preserves input/output schemas as opaque JSON. In particular, +`pricing.from`, `pricing.failoverMaxUsd`, and `failover` are authoritative gateway +facts; the CLI does not recompute them from `lanes`. + +Successful `run` payloads are customer data and pass through unchanged. Fields such +as `creditScore`, `provider`, or `providers` inside a SKU's output are not discovery +metadata and are never recursively removed or rewritten by the CLI. + ## Run idempotency Use an explicit idempotency key when a run may need to be repeated without another charge: @@ -86,4 +99,6 @@ Migration note: shape flags used to be sent to the server and trimmed the saved ## Publish -Tags matching `v*` publish to npm through GitHub Actions using the `NPM_TOKEN` secret and npm provenance. +Tags matching `v*` run the compiled CLI against the live credentialless discovery +endpoints, then publish to npm through GitHub Actions using the `NPM_TOKEN` secret +and npm provenance. diff --git a/__tests__/bundled-skills.test.ts b/__tests__/bundled-skills.test.ts index f5db55b..e0ae619 100644 --- a/__tests__/bundled-skills.test.ts +++ b/__tests__/bundled-skills.test.ts @@ -18,6 +18,8 @@ describe('bundled agent skills', () => { expect(discover).toContain('nested under `pricing`'); expect(discover).toContain('`pricing.from`'); expect(discover).toContain('`pricing.failoverMaxUsd`'); + expect(discover).toContain('`failover`'); + expect(discover).toContain('Do not derive'); const onboarding = readSkill('anyapi-onboarding'); expect(onboarding).toContain('npx -y anyapi-cli@latest init'); diff --git a/__tests__/discovery.test.ts b/__tests__/discovery.test.ts index 3ebe179..e4a89a9 100644 --- a/__tests__/discovery.test.ts +++ b/__tests__/discovery.test.ts @@ -20,6 +20,7 @@ const catalogResponse = { health: { window: '30d', uptimePct: 99.5, latencyP50Ms: 240, requests: 80 }, }], tryEligible: true, + failover: false, }], }; @@ -39,6 +40,14 @@ describe('customer-safe discovery reader', () => { expectCustomerSafe(response); }); + it('accepts discovery from older gateways without optional routing booleans', async () => { + const api = { ...catalogResponse.apis[0] }; + delete (api as Partial).failover; + const client = clientFor({ apis: [api] }); + + await expect(client.catalog()).resolves.toEqual({ apis: [api] }); + }); + it('uses dedicated ranked search and accepts only relevance and ranking', async () => { let requested = ''; const client = clientFor({ @@ -58,6 +67,7 @@ describe('customer-safe discovery reader', () => { }], total: 1, ranking: 'semantic', + futureEnvelopeField: true, }, (url) => { requested = url; }); const response = await client.search({ @@ -82,16 +92,23 @@ describe('customer-safe discovery reader', () => { relevance: 0.92, }], }); + expect(response).not.toHaveProperty('futureEnvelopeField'); expectCustomerSafe(response); }); - it('reads authenticated detail responses with schemas', async () => { + it('reads authenticated detail responses and preserves schemas as opaque JSON', async () => { let authorization = ''; const body = { ...catalogResponse.apis[0], - inputSchema: { type: 'object', properties: { query: { type: 'string' } } }, + inputSchema: { + type: 'object', + properties: { query: { type: 'string' } }, + 'x-future-schema-keyword': { nested: true }, + providers: ['schema-vocabulary-value'], + }, outputSchema: { type: 'array' }, heavy: true, + excludesCallerDelay: true, }; const client = clientFor(body, undefined, (init) => { authorization = new Headers(init?.headers).get('Authorization') ?? ''; @@ -104,24 +121,161 @@ describe('customer-safe discovery reader', () => { expectCustomerSafe(response); }); - it('recursively strips forbidden accounting and provider metadata', async () => { + it('ignores safe additive fields while trusting gateway-owned routing and pricing facts', async () => { const body = { - ...catalogResponse.apis[0], - provider: 'hidden-upstream', - inputSchema: { - type: 'object', - internalCredits: 500, - provider: 'hidden-upstream', - providers: ['hidden-upstream'], - properties: { query: { type: 'string' } }, - }, + futureEnvelopeField: 'ignored', + apis: [{ + ...catalogResponse.apis[0], + futureApiField: 'ignored', + failover: true, + excludesCallerDelay: true, + pricing: { + from: { + model: 'linear', + unit: 'result', + baseUsd: 0.2, + perUnitUsd: 0.3, + maxUsd: 0.4, + futureOfferField: 'ignored', + }, + failoverMaxUsd: 0.1, + futurePricingField: 'ignored', + }, + lanes: [{ + futureLaneField: 'ignored', + pricing: { + model: 'flat', + unit: 'request', + maxUsd: 0.9, + futureOfferField: 'ignored', + }, + health: { + window: '7d', + uptimePct: 42, + latencyP50Ms: 123, + requests: 1, + futureHealthField: 'ignored', + }, + }], + }], }; - const client = clientFor(body, undefined, undefined, true); + const client = clientFor(body); - const response = await client.describe('reddit.search'); + const response = await client.catalog(); - expect(JSON.stringify(response)).not.toContain('hidden-upstream'); - expectCustomerSafe(response); + expect(response).toEqual({ + apis: [{ + ...catalogResponse.apis[0], + failover: true, + excludesCallerDelay: true, + pricing: { + from: { + model: 'linear', + unit: 'result', + baseUsd: 0.2, + perUnitUsd: 0.3, + maxUsd: 0.4, + }, + failoverMaxUsd: 0.1, + }, + lanes: [{ + pricing: { model: 'flat', unit: 'request', maxUsd: 0.9 }, + health: { + window: '7d', + uptimePct: 42, + latencyP50Ms: 123, + requests: 1, + }, + }], + }], + }); + }); + + it('projects known search highlight fields and ignores additive highlight metadata', async () => { + const client = clientFor({ + results: [{ + slug: 'amazon.product', + platformId: 'amazon', + name: 'Amazon Product', + description: 'Get product details', + category: 'shopping', + provider: 'AnyAPI', + pricing: { + from: { model: 'flat', unit: 'request', maxUsd: 0.005 }, + failoverMaxUsd: 0.006, + }, + relevance: 0.92, + highlightFields: [{ + path: 'items[].price', + type: 'number', + why: 'Price returned by the API.', + futureHighlightField: 'ignored', + }], + }], + total: 1, + ranking: 'keyword', + }); + + const response = await client.search({ query: 'price' }); + + expect(response.results[0]?.highlightFields).toEqual([{ + path: 'items[].price', + type: 'number', + why: 'Price returned by the API.', + }]); + }); + + it.each([ + { + name: 'credit metadata', + mutate: (body: Record) => ({ ...body, internalCredits: 500 }), + }, + { + name: 'case-insensitive nested credit metadata', + mutate: (body: Record) => ({ + ...body, + inputSchema: { type: 'object', CreditScore: { type: 'number' } }, + }), + }, + { + name: 'non-AnyAPI provider metadata', + mutate: (body: Record) => ({ ...body, provider: 'hidden-upstream' }), + }, + { + name: 'nested non-AnyAPI provider metadata', + mutate: (body: Record) => ({ + ...body, + inputSchema: { type: 'object', provider: 'hidden-upstream' }, + }), + }, + ])('rejects forbidden discovery $name instead of rewriting it', async ({ mutate }) => { + const client = clientFor(mutate({ ...catalogResponse.apis[0] }), undefined, undefined, true); + + await expect(client.describe('reddit.search')).rejects.toThrow( + 'Invalid AnyAPI API discovery response.', + ); + }); + + it('accepts empty lane arrays without treating them as a routing invariant', async () => { + const accepted = clientFor({ + apis: [{ ...catalogResponse.apis[0], lanes: [] }], + }); + await expect(accepted.catalog()).resolves.toMatchObject({ apis: [{ lanes: [] }] }); + }); + + it.each([-0.01, Number.POSITIVE_INFINITY])('rejects invalid USD pricing: %s', async (maxUsd) => { + const rejected = clientFor({ + apis: [{ + ...catalogResponse.apis[0], + pricing: { + from: { model: 'flat', unit: 'request', maxUsd }, + failoverMaxUsd: 0.01, + }, + }, + ], + }); + + await expect(rejected.catalog()).rejects.toThrow('Invalid AnyAPI API discovery response.'); }); it('rejects discovery entries without nested pricing', async () => { @@ -149,7 +303,6 @@ function expectCustomerSafe(value: unknown): void { } for (const [key, child] of Object.entries(value)) { expect(key.toLowerCase()).not.toContain('credit'); - expect(key.toLowerCase()).not.toBe('providers'); if (key.toLowerCase() === 'provider') { expect(child).toBe('AnyAPI'); } diff --git a/__tests__/run.test.ts b/__tests__/run.test.ts index f58ec44..159074d 100644 --- a/__tests__/run.test.ts +++ b/__tests__/run.test.ts @@ -16,6 +16,48 @@ import type { CommandContext } from '../src/io.js'; import type { FetchLike } from '../src/types.js'; describe('run idempotency', () => { + it('preserves customer output fields without recursive rewriting', async () => { + const responseBody = { + output: { + found: true, + data: { + creditScore: 812, + provider: 'source named by the customer API', + providers: ['first source', 'second source'], + nested: { provider: { name: 'structured provider value' } }, + }, + }, + provider: 'AnyAPI', + costUsd: 0.01, + items: 1, + }; + const ctx = commandContext(async () => Response.json(responseBody)); + + await runCommand(ctx, { apiKey: 'aa_live_test' }, 'finance.profile', { + input: '{}', + json: true, + }); + + const stdout = ctx.stdout.read()?.toString().trim(); + expect(JSON.parse(stdout)).toEqual(responseBody); + }); + + it('preserves balance response fields without recursive rewriting', async () => { + const responseBody = { + balanceUsd: 1.25, + creditScore: 812, + provider: 'account-data-source', + providers: ['account-data-source'], + }; + const client = new AnyApiClient({ + apiKey: 'aa_live_test', + fetchImpl: async () => Response.json(responseBody), + restBaseUrl: 'https://example.test/v1', + }); + + await expect(client.balance()).resolves.toEqual(responseBody); + }); + it('passes the command flag through to the idempotency key header', async () => { let requestInit: RequestInit | undefined; const fetchImpl: FetchLike = async (_input, init) => { diff --git a/package-lock.json b/package-lock.json index 417b5f7..8485251 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "anyapi-cli", - "version": "0.4.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "anyapi-cli", - "version": "0.4.0", + "version": "0.4.1", "license": "MIT", "dependencies": { "commander": "^12.1.0", diff --git a/package.json b/package.json index 856597a..1841a5c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "anyapi-cli", - "version": "0.4.0", + "version": "0.4.1", "description": "Official CLI for AnyAPI, a unified marketplace for scraping and data APIs.", "type": "module", "bin": { @@ -14,7 +14,8 @@ ], "scripts": { "build": "tsc -p tsconfig.json", - "test": "vitest run" + "test": "vitest run", + "canary:live": "node scripts/live-discovery-canary.mjs" }, "engines": { "node": ">=18" diff --git a/scripts/live-discovery-canary.mjs b/scripts/live-discovery-canary.mjs new file mode 100644 index 0000000..d84227d --- /dev/null +++ b/scripts/live-discovery-canary.mjs @@ -0,0 +1,64 @@ +import { AnyApiClient } from '../dist/api.js'; +import { readDiscoveryApi } from '../dist/discovery.js'; + +const origin = (process.env.ANYAPI_API_ORIGIN ?? 'https://api.getanyapi.com').replace(/\/$/, ''); +const fetchImpl = async (input, init) => { + let lastError; + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + const response = await fetch(input, { + ...init, + signal: init?.signal ?? AbortSignal.timeout(10_000), + }); + if (response.status !== 429 && response.status < 500) { + return response; + } + lastError = new Error(`HTTP ${response.status}`); + } catch (error) { + lastError = error; + } + if (attempt < 3) { + await new Promise((resolve) => setTimeout(resolve, attempt * 250)); + } + } + throw lastError instanceof Error ? lastError : new Error('AnyAPI live discovery request failed.'); +}; + +const client = new AnyApiClient({ + fetchImpl, + catalogUrl: `${origin}/catalog`, + restBaseUrl: `${origin}/v1`, +}); + +const catalog = await client.catalog(); +assert(catalog.apis.length > 0, 'catalog returned no APIs'); + +const search = await client.search({ query: 'web', limit: 1 }); +assert(search.results.length > 0, 'search returned no APIs'); + +const candidate = catalog.apis.find((api) => api.tryEligible === true); +assert(candidate, 'catalog returned no try-eligible API'); + +const detailResponse = await fetchImpl( + `${origin}/public/try/${encodeURIComponent(candidate.slug)}/schema`, +); +assert(detailResponse.ok, `public schema returned HTTP ${detailResponse.status}`); +const detail = readDiscoveryApi(await detailResponse.json()); +assert(detail.slug === candidate.slug, 'public schema slug did not match the selected catalog API'); +assert(isRecord(detail.inputSchema), 'public schema had no usable input schema'); +assert(isRecord(detail.outputSchema), 'public schema had no usable output schema'); + +console.log( + `Live discovery canary passed: ${catalog.apis.length} catalog APIs, ` + + `${search.results.length} search result, ${detail.slug} public schema.`, +); + +function assert(condition, message) { + if (!condition) { + throw new Error(`Live discovery canary failed: ${message}.`); + } +} + +function isRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/skills/anyapi-discover/SKILL.md b/skills/anyapi-discover/SKILL.md index 0c343bd..29543d7 100644 --- a/skills/anyapi-discover/SKILL.md +++ b/skills/anyapi-discover/SKILL.md @@ -36,9 +36,10 @@ Search and list are public. Describe is authenticated because it returns the ful - `anyapi describe ` prints input schema, output schema, and USD pricing. - Discovery pricing is always nested under `pricing`; ranked search reports `relevance` per result and `ranking` for the response. -- Read the complete first customer-routable, non-mock runtime-lane offer, in provider-cost - execution-plan order, from `pricing.from`; read the greatest lane maximum from - `pricing.failoverMaxUsd`. +- Treat `pricing.from`, `pricing.failoverMaxUsd`, lane order, and `failover` as + authoritative facts published by the AnyAPI gateway. +- Do not derive pricing or failover from the lane list, and do not reject discovery + because those fields have relationships you did not expect. ## Tips diff --git a/src/api.ts b/src/api.ts index 3273894..94cdf66 100644 --- a/src/api.ts +++ b/src/api.ts @@ -50,13 +50,13 @@ export class AnyApiClient { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), - }, { sanitize: false }); + }); } // oauthMetadata fetches the RFC 8414 authorization-server document. The caller // falls back to hardcoded endpoints when discovery fails. async oauthMetadata(url: string): Promise { - return this.requestJson(url, undefined, { sanitize: false }); + return this.requestJson(url); } // registerClient performs OAuth 2.1 Dynamic Client Registration for a public @@ -77,7 +77,6 @@ export class AnyApiClient { token_endpoint_auth_method: 'none', }), }, - { sanitize: false }, ); } @@ -91,7 +90,6 @@ export class AnyApiClient { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(params).toString(), }, - { sanitize: false }, ); } @@ -100,7 +98,7 @@ export class AnyApiClient { if (options.category) { url.searchParams.set('category', options.category); } - const body = await this.requestJson(url, undefined, { sanitize: false }); + const body = await this.requestJson(url); return readCatalogResponse(body); } @@ -118,14 +116,14 @@ export class AnyApiClient { if (options.limit !== undefined) { url.searchParams.set('limit', String(options.limit)); } - const body = await this.requestJson(url, undefined, { sanitize: false }); + const body = await this.requestJson(url); return readSearchResponse(body); } async describe(sku: string): Promise { const body = await this.requestJson(`${this.restBaseUrl}/apis/${encodeURIComponent(sku)}`, { headers: this.authHeaders(), - }, { sanitize: false }); + }); return readDiscoveryApi(body); } @@ -158,42 +156,14 @@ export class AnyApiClient { private async requestJson( input: string | URL, init?: RequestInit, - options: { sanitize?: boolean } = {}, ): Promise { const response = await this.fetchImpl(input, init); const body = await parseBody(response); if (!response.ok) { throw new ApiError(errorMessage(body, response.status), response.status, body); } - return (options.sanitize === false ? body : sanitizeCustomerJson(body)) as T; - } -} - -export function sanitizeCustomerJson(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map((item) => sanitizeCustomerJson(item)); - } - if (!isRecord(value)) { - return value; - } - - const output: Record = {}; - for (const [key, child] of Object.entries(value)) { - const lower = key.toLowerCase(); - if (lower.includes('credit')) { - continue; - } - if (lower === 'provider') { - output[key] = 'AnyAPI'; - continue; - } - if (lower === 'providers') { - output[key] = ['AnyAPI']; - continue; - } - output[key] = sanitizeCustomerJson(child); + return body as T; } - return output; } function compactObject(input: Record): Record { diff --git a/src/discovery.ts b/src/discovery.ts index 156e8b0..ee4ecd3 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -8,14 +8,16 @@ import type { } from './types.js'; export function readCatalogResponse(value: unknown): CatalogResponse { + assertSafeDiscovery(value, 'catalog'); const record = requireRecord(value, 'catalog'); if (!Array.isArray(record.apis)) { throw contractError('catalog'); } - return { apis: record.apis.map(readDiscoveryApi) }; + return { apis: record.apis.map(mapDiscoveryApi) }; } export function readSearchResponse(value: unknown): SearchResponse { + assertSafeDiscovery(value, 'search'); const record = requireRecord(value, 'search'); if (!Array.isArray(record.results)) { throw contractError('search'); @@ -25,7 +27,7 @@ export function readSearchResponse(value: unknown): SearchResponse { if (total === undefined || total < 0 || !Number.isInteger(total) || !ranking) { throw contractError('search'); } - const results = record.results.map(readDiscoveryApi); + const results = record.results.map(mapDiscoveryApi); if (results.some((result) => result.relevance === undefined)) { throw contractError('search'); } @@ -37,13 +39,25 @@ export function readSearchResponse(value: unknown): SearchResponse { } export function readDiscoveryApi(value: unknown): CatalogApi { + assertSafeDiscovery(value, 'API'); + return mapDiscoveryApi(value); +} + +function mapDiscoveryApi(value: unknown): CatalogApi { const record = requireRecord(value, 'API'); const slug = stringValue(record.slug); const category = stringValue(record.category); const name = stringValue(record.name); const description = stringValue(record.description); const pricing = readPricing(record.pricing); - if (!slug || !category || !name || description === undefined || !pricing) { + if ( + !slug + || !category + || !name + || description === undefined + || record.provider !== 'AnyAPI' + || !pricing + ) { throw contractError('API'); } @@ -51,9 +65,11 @@ export function readDiscoveryApi(value: unknown): CatalogApi { const platformId = stringValue(record.platformId); const lanes = record.lanes === undefined ? undefined : readLanes(record.lanes); const relevance = finiteNumber(record.relevance); - const highlightFields = Array.isArray(record.highlightFields) - ? sanitizeDiscoveryJson(record.highlightFields) - : undefined; + const highlightFields = record.highlightFields === undefined + ? undefined + : readHighlightFields(record.highlightFields); + const failover = optionalBoolean(record, 'failover', 'API'); + const excludesCallerDelay = optionalBoolean(record, 'excludesCallerDelay', 'API'); return { ...(id ? { id } : {}), @@ -62,22 +78,24 @@ export function readDiscoveryApi(value: unknown): CatalogApi { category, name, description, - provider: 'AnyAPI', + provider: record.provider, pricing, - ...(lanes ? { lanes } : {}), - ...(hasOwn(record, 'inputSchema') ? { inputSchema: sanitizeDiscoveryJson(record.inputSchema) } : {}), - ...(hasOwn(record, 'outputSchema') ? { outputSchema: sanitizeDiscoveryJson(record.outputSchema) } : {}), + ...(lanes !== undefined ? { lanes } : {}), + ...(hasOwn(record, 'inputSchema') ? { inputSchema: record.inputSchema } : {}), + ...(hasOwn(record, 'outputSchema') ? { outputSchema: record.outputSchema } : {}), ...(typeof record.heavy === 'boolean' ? { heavy: record.heavy } : {}), ...(typeof record.tryEligible === 'boolean' ? { tryEligible: record.tryEligible } : {}), + ...(failover !== undefined ? { failover } : {}), + ...(excludesCallerDelay !== undefined ? { excludesCallerDelay } : {}), ...(relevance !== undefined ? { relevance } : {}), - ...(highlightFields ? { highlightFields: highlightFields as unknown[] } : {}), + ...(highlightFields !== undefined ? { highlightFields } : {}), }; } function readPricing(value: unknown): DiscoveryPricing | undefined { const record = asRecord(value); const from = readOffer(record?.from); - const failoverMaxUsd = finiteNumber(record?.failoverMaxUsd); + const failoverMaxUsd = usdNumber(record?.failoverMaxUsd); if (!from || failoverMaxUsd === undefined) { return undefined; } @@ -88,12 +106,12 @@ function readOffer(value: unknown): PricingOffer | undefined { const record = asRecord(value); const model = stringValue(record?.model); const unit = stringValue(record?.unit); - const maxUsd = finiteNumber(record?.maxUsd); + const maxUsd = usdNumber(record?.maxUsd); if (model === 'flat' && unit === 'request' && maxUsd !== undefined) { return { model, unit, maxUsd }; } - const baseUsd = finiteNumber(record?.baseUsd); - const perUnitUsd = finiteNumber(record?.perUnitUsd); + 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 }; } @@ -127,27 +145,42 @@ function readHealth(value: unknown): DiscoveryLane['health'] { return { window, uptimePct, latencyP50Ms, requests }; } -function sanitizeDiscoveryJson(value: unknown): unknown { +function readHighlightFields(value: unknown): unknown[] { + if (!Array.isArray(value)) { + throw contractError('search highlight fields'); + } + return value.map((candidate) => { + const record = requireRecord(candidate, 'search highlight field'); + const path = stringValue(record.path); + const type = stringValue(record.type); + const why = stringValue(record.why); + return { + ...(path !== undefined ? { path } : {}), + ...(type !== undefined ? { type } : {}), + ...(why !== undefined ? { why } : {}), + }; + }); +} + +function assertSafeDiscovery(value: unknown, subject: string): void { if (Array.isArray(value)) { - return value.map(sanitizeDiscoveryJson); + value.forEach((child) => assertSafeDiscovery(child, subject)); + return; } const record = asRecord(value); if (!record) { - return value; + return; } - const output: Record = {}; for (const [key, child] of Object.entries(record)) { const lower = key.toLowerCase(); - if (lower.includes('credit') || lower === 'providers') { - continue; + if (lower.includes('credit')) { + throw contractError(subject); } - if (lower === 'provider') { - output[key] = 'AnyAPI'; - continue; + if (lower === 'provider' && child !== 'AnyAPI') { + throw contractError(subject); } - output[key] = sanitizeDiscoveryJson(child); + assertSafeDiscovery(child, subject); } - return output; } function readRanking(value: unknown): SearchResponse['ranking'] | undefined { @@ -176,10 +209,29 @@ function finiteNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined; } +function usdNumber(value: unknown): number | undefined { + const parsed = finiteNumber(value); + return parsed !== undefined && parsed >= 0 ? parsed : undefined; +} + function stringValue(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined; } +function optionalBoolean( + record: Record, + key: string, + subject: string, +): boolean | undefined { + if (!hasOwn(record, key)) { + return undefined; + } + if (typeof record[key] !== 'boolean') { + throw contractError(subject); + } + return record[key]; +} + function hasOwn(record: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(record, key); } diff --git a/src/types.ts b/src/types.ts index b8bac9f..ead7817 100644 --- a/src/types.ts +++ b/src/types.ts @@ -105,6 +105,8 @@ export interface CatalogApi { outputSchema?: unknown; heavy?: boolean; tryEligible?: boolean; + failover?: boolean; + excludesCallerDelay?: boolean; relevance?: number; highlightFields?: unknown[]; }