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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 0.3.2

### Changed

- `anyapi search` now uses the dedicated ranked catalog search endpoint.
- Discovery responses are normalized to AnyAPI-branded, nested USD pricing. This
bridge release reads both the current credit-based contract and its replacement
so it remains compatible across the gateway cutover.

## 0.3.1

### Changed
Expand Down
217 changes: 217 additions & 0 deletions __tests__/discovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { describe, expect, it } from 'vitest';
import { AnyApiClient } from '../src/api.js';
import { formatCatalogPrice } from '../src/format.js';
import type { FetchLike } from '../src/types.js';

const legacyList = {
apis: [{
slug: 'reddit.search',
category: 'social',
name: 'Reddit Search',
description: 'Search Reddit',
fromCredits: 400,
baseCredits: 5,
perItemCredits: 10,
perItemUnit: 'result',
providers: ['hidden-upstream'],
quotes: [
{ fromCredits: 400, baseCredits: 5, perItemCredits: 10, uptimePct: 99.5, latencyP50Ms: 240, requests: 80 },
{ fromCredits: 500, baseCredits: 10, perItemCredits: 12 },
],
}],
};

const replacementList = {
apis: [{
id: 'reddit.search',
slug: 'reddit.search',
category: 'social',
name: 'Reddit Search',
provider: 'AnyAPI',
pricing: {
from: { model: 'linear', unit: 'result', baseUsd: 0.00005, perUnitUsd: 0.0001, maxUsd: 0.004 },
failoverMaxUsd: 0.005,
},
lanes: [{
pricing: { model: 'linear', unit: 'result', baseUsd: 0.00005, perUnitUsd: 0.0001, maxUsd: 0.004 },
health: { window: '30d', uptimePct: 99.5, latencyP50Ms: 240, requests: 80 },
}],
}],
};

describe('discovery compatibility reader', () => {
it('normalizes the legacy browse response into nested USD pricing', async () => {
const client = clientFor(legacyList);
const response = await client.catalog({ category: 'social' });

expect(response.apis[0]).toMatchObject({
slug: 'reddit.search',
category: 'social',
name: 'Reddit Search',
description: 'Search Reddit',
provider: 'AnyAPI',
pricing: replacementList.apis[0]!.pricing,
});
expect(response.apis[0]!.lanes).toHaveLength(2);
expect(response.apis[0]!.lanes?.[0]).toEqual(replacementList.apis[0]!.lanes?.[0]);
expect(formatCatalogPrice(response.apis[0]!)).toBe(
'from USD 0.00005 + USD 0.0001/result (max USD 0.0040/request)',
);
expect(JSON.stringify(response)).not.toMatch(/credit|hidden-upstream/i);
});

it('passes the replacement browse response through the same customer-safe model', async () => {
const client = clientFor(replacementList);
const response = await client.catalog();

expect(response).toEqual(replacementList);
});

it('uses dedicated search and adapts the legacy per-1k result', async () => {
let requested = '';
const client = clientFor({
results: [{
slug: 'amazon.product',
name: 'Amazon Product',
category: 'shopping',
priceUsdPer1k: 5,
score: 0.8,
}],
total: 1,
mode: 'usecase',
}, (url) => { requested = url; });

const response = await client.search({ query: 'wireless headphones', category: 'shopping', platform: 'amazon', limit: 10 });

const url = new URL(requested);
expect(url.pathname).toBe('/catalog/search');
expect(Object.fromEntries(url.searchParams)).toEqual({
q: 'wireless headphones',
category: 'shopping',
platform: 'amazon',
limit: '10',
});
expect(response).toEqual({
results: [{
slug: 'amazon.product',
name: 'Amazon Product',
category: 'shopping',
provider: 'AnyAPI',
pricing: {
from: { model: 'flat', unit: 'request', maxUsd: 0.005 },
failoverMaxUsd: 0.005,
},
relevance: 0.8,
}],
total: 1,
ranking: 'usecase',
});
});

it('reads replacement ranked search results without legacy fields', async () => {
const client = clientFor({
results: [{
slug: 'amazon.product',
name: 'Amazon Product',
provider: 'AnyAPI',
pricing: {
from: { model: 'flat', unit: 'request', maxUsd: 0.005 },
failoverMaxUsd: 0.006,
},
relevance: 0.92,
highlightFields: [{ path: 'items[].price', type: 'number' }],
}],
total: 1,
ranking: 'semantic',
});

const response = await client.search({ query: 'product prices' });

expect(response.ranking).toBe('semantic');
expect(response.results[0]).toMatchObject({
slug: 'amazon.product',
provider: 'AnyAPI',
pricing: { from: { model: 'flat', unit: 'request', maxUsd: 0.005 } },
relevance: 0.92,
highlightFields: [{ path: 'items[].price', type: 'number' }],
});
});

it('normalizes a legacy describe response before customer sanitization', async () => {
let authorization = '';
const client = clientFor({
id: 'youtube.comments',
slug: 'youtube.comments',
name: 'YouTube Comments',
description: 'Read comments',
priceCredits: 2000,
fromCredits: 1500,
baseCredits: 0,
perItemCredits: 0,
inputSchema: { type: 'object' },
outputSchema: { type: 'array' },
provider: 'hidden-upstream',
}, undefined, (init) => {
authorization = new Headers(init?.headers).get('Authorization') ?? '';
}, true);

const response = await client.describe('youtube.comments');

expect(authorization).toBe('Bearer aa_live_test');
expect(response).toEqual({
id: 'youtube.comments',
slug: 'youtube.comments',
name: 'YouTube Comments',
description: 'Read comments',
inputSchema: { type: 'object' },
outputSchema: { type: 'array' },
provider: 'AnyAPI',
pricing: {
from: { model: 'flat', unit: 'request', maxUsd: 0.015 },
failoverMaxUsd: 0.02,
},
});
expect(JSON.stringify(response)).not.toMatch(/credit|hidden-upstream/i);
});

it('reads a replacement describe response with schemas and discriminated pricing', async () => {
const body = {
id: 'youtube.comments',
slug: 'youtube.comments',
name: 'YouTube Comments',
provider: 'AnyAPI',
pricing: {
from: { model: 'flat', unit: 'request', maxUsd: 0.015 },
failoverMaxUsd: 0.02,
},
inputSchema: { type: 'object' },
outputSchema: { type: 'array' },
heavy: true,
};
const client = clientFor(body, undefined, undefined, true);

expect(await client.describe('youtube.comments')).toEqual(body);
});
});

function clientFor(
body: unknown,
onUrl?: (url: string) => void,
onInit?: (init?: RequestInit) => void,
authenticated = false,
): AnyApiClient {
const fetchImpl: FetchLike = async (input, init) => {
onUrl?.(input.toString());
onInit?.(init);
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
return new AnyApiClient({
apiKey: authenticated ? 'aa_live_test' : undefined,
fetchImpl,
catalogUrl: 'https://api.example.test/catalog',
restBaseUrl: 'https://api.example.test/v1',
});
}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "anyapi-cli",
"version": "0.3.1",
"version": "0.3.2",
"description": "Official CLI for AnyAPI, a unified marketplace for scraping and data APIs.",
"type": "module",
"bin": {
Expand Down
2 changes: 1 addition & 1 deletion skills/anyapi-discover/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Search and list are public. Describe is authenticated because it returns the ful

## Key options

- `anyapi search <query>` filters the public catalog with the production `query` parameter.
- `anyapi search <query>` uses the dedicated ranked discovery search.
- `anyapi list --category <cat>` narrows by category.
- `anyapi describe <sku>` prints input schema, output schema, and USD pricing.

Expand Down
31 changes: 25 additions & 6 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { CATALOG_URL, REST_BASE_URL, SIGNUP_URL } from './constants.js';
import { readCatalogResponse, readDiscoveryApi, readSearchResponse } from './discovery.js';
import { ApiError } from './errors.js';
import type {
CatalogResponse,
ClientRegistrationResponse,
FetchLike,
OAuthMetadata,
RunResult,
SearchResponse,
SignupResponse,
TokenResponse,
} from './types.js';
Expand Down Expand Up @@ -88,21 +90,38 @@ export class AnyApiClient {
);
}

async catalog(options: { query?: string; category?: string } = {}): Promise<CatalogResponse> {
async catalog(options: { category?: string } = {}): Promise<CatalogResponse> {
const url = new URL(this.catalogUrl);
if (options.query) {
url.searchParams.set('query', options.query);
if (options.category) {
url.searchParams.set('category', options.category);
}
const body = await this.requestJson<unknown>(url, undefined, { sanitize: false });
return readCatalogResponse(body);
}

async search(options: { query: string; category?: string; platform?: string; limit?: number }): Promise<SearchResponse> {
const url = new URL(this.catalogUrl);
url.pathname = `${url.pathname.replace(/\/$/, '')}/search`;
url.search = '';
url.searchParams.set('q', options.query);
if (options.category) {
url.searchParams.set('category', options.category);
}
return this.requestJson<CatalogResponse>(url, undefined, { sanitize: false });
if (options.platform) {
url.searchParams.set('platform', options.platform);
}
if (options.limit !== undefined) {
url.searchParams.set('limit', String(options.limit));
}
const body = await this.requestJson<unknown>(url, undefined, { sanitize: false });
return readSearchResponse(body);
}

async describe(sku: string): Promise<unknown> {
return this.requestJson<unknown>(`${this.restBaseUrl}/apis/${encodeURIComponent(sku)}`, {
const body = await this.requestJson<unknown>(`${this.restBaseUrl}/apis/${encodeURIComponent(sku)}`, {
headers: this.authHeaders(),
});
}, { sanitize: false });
return readDiscoveryApi(body) ?? sanitizeCustomerJson(body);
}

// run always fetches the FULL result. Response shaping (fields/max_items/summary/
Expand Down
4 changes: 2 additions & 2 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ export async function loginCommand(ctx: CommandContext, options: { apiKey?: stri

export async function searchCommand(ctx: CommandContext, query: string): Promise<void> {
const client = new AnyApiClient({ fetchImpl: ctx.fetchImpl });
const catalog = await client.catalog({ query });
writeCatalogTable(ctx, catalog.apis);
const results = await client.search({ query });
writeCatalogTable(ctx, results.results);
}

export async function listCommand(ctx: CommandContext, options: { category?: string }): Promise<void> {
Expand Down
1 change: 0 additions & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,3 @@ export const OAUTH_SCOPE = 'run balance:read';
export const API_KEY_ENV = 'ANYAPI_API_KEY';
export const CONFIG_DIR_NAME = '.anyapi';
export const CONFIG_FILE_NAME = 'config.json';
export const CREDIT_TO_USD = 0.00001;
Loading
Loading