diff --git a/src/api/types.ts b/src/api/types.ts index 0b9dbe1..27e0619 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -56,7 +56,9 @@ export type PriceWhen = Record; /** One PER_UNIT_MATRIX row. Current wire carries the typed leaf under * `price`; pre-2026-07 backends sent it under `amount` — accept BOTH. */ export interface PriceVariant { - when: PriceWhen; + /** Cell coordinates. Absent on an uncoordinated cell — renderers must + * tolerate it (see `PriceTier.when`). */ + when?: PriceWhen; /** Current wire: the typed leaf price for this cell. */ price?: PriceAmount; /** Old wire shape only. */ @@ -68,7 +70,11 @@ export interface PriceVariant { * when its `when` gate matches the request. */ export interface PriceTier { label: string; - when: PriceWhen; + /** The gate that switches this tier on. ABSENT on an ungated tier — one + * that always applies and is metered purely by its `selector` (e.g. exa + * `/search` charges per result above 10 with no `when` on the wire). + * Optional because the wire omits it; renderers must tolerate it. */ + when?: PriceWhen; /** WHERE the metered quantity lives; absent ⇒ quantity 1. */ selector?: { label: string; key: string; in: string }; /** The add-on's leaf price (× the metered quantity). */ diff --git a/src/output/format.ts b/src/output/format.ts index adde65f..47ce477 100644 --- a/src/output/format.ts +++ b/src/output/format.ts @@ -13,6 +13,7 @@ import type { Price, PriceAmount, PriceVariant, + PriceWhen, Resource, ResourceEvent, ResourceEventsResponse, @@ -71,6 +72,20 @@ export function formatDiscoverResults(data: DiscoverResponse): void { // --- Inspect --- +/** + * Render a price gate (`when`) as `key=value, key=value`. + * + * The wire OMITS `when` on an ungated tier or cell — one that always applies + * and is metered by its `selector` alone. `Object.entries(undefined)` throws, + * so an ungated tier used to abort the whole `inspect` render; it now yields + * an empty string and callers drop the clause. + */ +function formatPriceWhen(when: PriceWhen | undefined): string { + return Object.entries(when ?? {}) + .map(([k, val]) => `${k}=${val}`) + .join(', '); +} + export function formatInspectResult(data: InspectResponse): void { console.log(); @@ -109,25 +124,22 @@ export function formatInspectResult(data: InspectResponse): void { if (data.price.type === 'PER_UNIT_MATRIX' && data.price.variants?.length) { console.log(` Variants: ${data.price.variants.length} (price varies by input)`); for (const v of data.price.variants) { - const when = Object.entries(v.when) - .map(([k, val]) => `${k}=${val}`) - .join(', '); + const gate = formatPriceWhen(v.when); const label = v.label ? ` ${chalk.gray(`(${v.label})`)}` : ''; - console.log(` - ${when}: ${formatAmount(variantCell(v))}${label}`); + console.log(` - ${gate ? `${gate}: ` : ''}${formatAmount(variantCell(v))}${label}`); } } if (data.price.type === 'TIERED') { - // Always-on default + gated add-on tiers (SUMMED when their `when` - // gate matches the request). + // Always-on default + add-on tiers (SUMMED on top of it). A tier with + // a `when` gate applies when the gate matches; an ungated tier always + // applies and is metered by its `selector`. if (data.price.default) { console.log(` Base: ${formatAmount(data.price.default)} (always)`); } for (const t of data.price.tiers ?? []) { - const when = Object.entries(t.when) - .map(([k, val]) => `${k}=${val}`) - .join(', '); + const gate = formatPriceWhen(t.when); console.log( - ` + ${t.label}: ${formatAmount(t.price)} when ${when}`, + ` + ${t.label}: ${formatAmount(t.price)}${gate ? ` when ${gate}` : ''}`, ); } } diff --git a/test/output/price.test.ts b/test/output/price.test.ts index 2c7e59d..d9e3510 100644 --- a/test/output/price.test.ts +++ b/test/output/price.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'bun:test'; -import { formatPriceCompact } from '../../src/output/format.js'; -import type { Price } from '../../src/api/types.js'; +import { formatPriceCompact, formatInspectResult } from '../../src/output/format.js'; +import type { InspectResponse, Price } from '../../src/api/types.js'; /** Strip ANSI color codes so assertions are stable regardless of chalk state. */ function plain(s: string): string { @@ -175,3 +175,86 @@ describe('formatPriceCompact', () => { expect(out).not.toContain('[object Object]'); }); }); + +/** Capture everything `formatInspectResult` writes to stdout. */ +function captureInspect(data: InspectResponse): string { + const lines: string[] = []; + const original = console.log; + console.log = (...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }; + try { + formatInspectResult(data); + } finally { + console.log = original; + } + return plain(lines.join('\n')); +} + +/** The exa `/search` price card exactly as the backend sends it: a TIERED + * card whose single add-on tier carries NO `when` — it always applies and is + * metered by its `selector`. */ +const UNGATED_TIER_INSPECT: InspectResponse = { + provider: 'exa', + providerName: 'Exa', + endpoint: '/search', + price: { + type: 'TIERED', + amount: { value: 0.01, currency: 'USD' }, + default: { type: 'PER_CALL', amount: { value: 0.01, currency: 'USD' } }, + tiers: [ + { + label: 'Results above 10', + selector: { label: 'Results above 10', key: 'numResults', in: 'body' }, + price: { type: 'PER_RESULT', amount: { value: 0.001, currency: 'USD' } }, + }, + ], + }, +} as unknown as InspectResponse; + +describe('formatInspectResult', () => { + it('renders a TIERED tier that has no `when` gate instead of throwing', () => { + // Regression: `Object.entries(t.when)` threw "Cannot convert undefined or + // null to object" and aborted the whole render right after the base price. + const out = captureInspect(UNGATED_TIER_INSPECT); + expect(out).toContain('Base: $0.01 (always)'); + expect(out).toContain('+ Results above 10: $0.001 / result'); + // No dangling gate clause when there is nothing to gate on. + expect(out).not.toContain('when undefined'); + expect(out).not.toMatch(/when\s*$/m); + }); + + it('still renders the gate for a tier that has one', () => { + const gated = { + ...UNGATED_TIER_INSPECT, + price: { + ...UNGATED_TIER_INSPECT.price, + tiers: [ + { + label: 'Deep mode', + when: { deep: true }, + price: { type: 'PER_CALL', amount: { value: 0.05, currency: 'USD' } }, + }, + ], + }, + } as unknown as InspectResponse; + expect(captureInspect(gated)).toContain('+ Deep mode: $0.05 when deep=true'); + }); + + it('renders a PER_UNIT_MATRIX variant that has no `when` instead of throwing', () => { + const matrix = { + ...UNGATED_TIER_INSPECT, + price: { + type: 'PER_UNIT_MATRIX', + amount: { value: 0.02, currency: 'USD' }, + variants: [ + { price: { type: 'PER_CALL', amount: { value: 0.02, currency: 'USD' } }, label: 'flat' }, + ], + }, + } as unknown as InspectResponse; + const out = captureInspect(matrix); + expect(out).toContain('- $0.02 (flat)'); + // No orphaned "key=value:" separator when there are no coordinates. + expect(out).not.toMatch(/^\s+- :/m); + }); +});