Skip to content
Open
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
10 changes: 8 additions & 2 deletions src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ export type PriceWhen = Record<string, string | number | boolean>;
/** 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. */
Expand All @@ -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). */
Expand Down
32 changes: 22 additions & 10 deletions src/output/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
Price,
PriceAmount,
PriceVariant,
PriceWhen,
Resource,
ResourceEvent,
ResourceEventsResponse,
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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}` : ''}`,
);
}
}
Expand Down
87 changes: 85 additions & 2 deletions test/output/price.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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);
});
});