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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ jobs:
- run: npm ci
- run: npm run build
- run: npm test
- run: npm run canary:live
1 change: 1 addition & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <query>` - search the public catalog and print SKU, name, and USD price terms.
- `anyapi list [--category <cat>]` - list catalog APIs.
- `anyapi describe <sku>` - print the authenticated API definition, including schemas and USD pricing.
- `anyapi describe <sku>` - print the authenticated API definition, including opaque schemas and gateway-published USD pricing, lane order, and failover metadata.
- `anyapi run <sku> [--input '<json>'] [-i file] [--idempotency-key <key>] [--jq <expr>] [--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 <expr>] [--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.
Expand All @@ -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:
Expand Down Expand Up @@ -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.
2 changes: 2 additions & 0 deletions __tests__/bundled-skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
187 changes: 170 additions & 17 deletions __tests__/discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const catalogResponse = {
health: { window: '30d', uptimePct: 99.5, latencyP50Ms: 240, requests: 80 },
}],
tryEligible: true,
failover: false,
}],
};

Expand All @@ -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<typeof api>).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({
Expand All @@ -58,6 +67,7 @@ describe('customer-safe discovery reader', () => {
}],
total: 1,
ranking: 'semantic',
futureEnvelopeField: true,
}, (url) => { requested = url; });

const response = await client.search({
Expand All @@ -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') ?? '';
Expand All @@ -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<string, unknown>) => ({ ...body, internalCredits: 500 }),
},
{
name: 'case-insensitive nested credit metadata',
mutate: (body: Record<string, unknown>) => ({
...body,
inputSchema: { type: 'object', CreditScore: { type: 'number' } },
}),
},
{
name: 'non-AnyAPI provider metadata',
mutate: (body: Record<string, unknown>) => ({ ...body, provider: 'hidden-upstream' }),
},
{
name: 'nested non-AnyAPI provider metadata',
mutate: (body: Record<string, unknown>) => ({
...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 () => {
Expand Down Expand Up @@ -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');
}
Expand Down
42 changes: 42 additions & 0 deletions __tests__/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
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.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand All @@ -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"
Expand Down
Loading
Loading