Skip to content
Draft
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ xapi-to register xapito # positional shorthand f
xapi-to register --force # replace an existing file-based key
xapi-to balance # show USD balance
xapi-to usage <request-id> # finalized cost + balance-after receipt
xapi-to usage wait <request-id> --timeout 1m # poll until a streaming receipt is finalized
xapi-to earnings # spendable balance + provider earnings
xapi-to earnings list --status SETTLED --limit 20 # provider earning records
xapi-to earnings transfer 1 --idempotency-key reinvest-001 # reinvest settled earnings
Expand Down
1 change: 1 addition & 0 deletions skills/xapi/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,7 @@ npx xapi-to balance

# Read the finalized cost of one request (use X-XAPI-Request-Id or the final xapi.usage SSE event)
npx xapi-to usage <request-id>
npx xapi-to usage wait <request-id> --timeout 1m

# Provider economy (requires earnings:read)
npx xapi-to earnings
Expand Down
113 changes: 99 additions & 14 deletions src/commands/usage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
/** Read a finalized per-request cost receipt with the same key that made the call. */
/** Read or wait for a finalized per-request cost receipt. */

import { request } from '../client.ts';
import {
HttpError,
isRetryableRequestError,
request,
} from '../client.ts';
import {
getConfig,
requireApiKey,
Expand All @@ -15,34 +19,115 @@ export const USAGE_HELP = `xapi-to usage - Read a finalized request cost receipt

USAGE
xapi-to usage <request-id> [--format json|pretty|table]
xapi-to usage wait <request-id> [--interval 1s] [--timeout 30s]

Use the request ID returned in X-XAPI-Request-Id or the final xapi.usage SSE event.
The receipt is visible only to the API key that made the request.

"usage wait" polls through the normal finalization window. A 404 means the
receipt is not finalized yet; invalid credentials and other permanent errors
still fail immediately.
`;

function parsePositiveDurationMs(raw: string, flagName: string): number {
const match = raw.trim().toLowerCase().match(/^(\d+)(ms|s|m|h)?$/);
if (!match) {
err(`${flagName} must be a duration such as 500ms, 2s, 5m, or 1h`);
}
const value = Number(match![1]);
const unit = match![2] || 'ms';
const multiplier =
unit === 'h' ? 3_600_000 : unit === 'm' ? 60_000 : unit === 's' ? 1_000 : 1;
const result = value * multiplier;
if (!Number.isSafeInteger(result) || result <= 0) {
err(`${flagName} must be greater than 0`);
}
return result;
}

function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}

function receiptUrl(requestId: string) {
return `${scheme(XAPI_API_HOST)}://${XAPI_API_HOST}/api/usage/requests/${encodeURIComponent(requestId)}`;
}

async function fetchReceipt(
requestId: string,
apiKey: string,
timeoutMs: number,
retries: number,
) {
return request<unknown>(
receiptUrl(requestId),
{
method: 'GET',
headers: { 'XAPI-KEY': apiKey },
},
timeoutMs,
retries,
);
}

async function waitForReceipt(
requestId: string,
apiKey: string,
flags: Record<string, string>,
) {
const intervalMs = parsePositiveDurationMs(flags.interval || '1s', '--interval');
const timeoutMs = parsePositiveDurationMs(flags.timeout || '30s', '--timeout');
const startedAt = Date.now();
const deadline = startedAt + timeoutMs;

while (true) {
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) {
err(
'usage receipt wait timeout',
`request_id=${requestId}, elapsed_ms=${Date.now() - startedAt}, timeout_ms=${timeoutMs}`,
);
}

try {
return await fetchReceipt(requestId, apiKey, remainingMs, 0);
} catch (e) {
const pending = e instanceof HttpError && e.status === 404;
if (!pending && !isRetryableRequestError(e)) throw e;
}

await sleep(Math.min(intervalMs, Math.max(0, deadline - Date.now())));
}
}

export async function usage(args: string[], flags: Record<string, string>) {
if (flags.help) {
console.log(USAGE_HELP);
return;
}
const requestId = args[0]?.trim();
if (!requestId) err('request ID required', 'Run: xapi-to usage <request-id>');
const shouldWait = args[0] === 'wait';
const requestId = args[shouldWait ? 1 : 0]?.trim();
if (!requestId) {
err(
'request ID required',
shouldWait
? 'Run: xapi-to usage wait <request-id>'
: 'Run: xapi-to usage <request-id>',
);
}

const cfg = getConfig();
requireApiKey(cfg);

try {
const result = await request<unknown>(
`${scheme(XAPI_API_HOST)}://${XAPI_API_HOST}/api/usage/requests/${encodeURIComponent(requestId!)}`,
{
method: 'GET',
headers: { 'XAPI-KEY': cfg.apiKey! },
},
30_000,
READ_RETRIES,
);
const result = shouldWait
? await waitForReceipt(requestId!, cfg.apiKey!, flags)
: await fetchReceipt(requestId!, cfg.apiKey!, 30_000, READ_RETRIES);
output(result, flags.format as any);
} catch (e: any) {
err('usage receipt fetch failed', e.message);
err(
shouldWait ? 'usage receipt wait failed' : 'usage receipt fetch failed',
e.message,
);
}
}
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ COMMANDS
--force Replace an existing file-based apiKey
balance Show current account balance
usage <request-id> Show a finalized per-request cost receipt
usage wait <request-id> Wait until a request receipt is finalized
--interval <duration> Poll interval (default: 1s)
--timeout <duration> Max wait duration (default: 30s)
earnings [summary] Show spendable balance and provider earnings
earnings list List provider earning records
earnings transfer <usd> --idempotency-key <key>
Expand Down Expand Up @@ -134,6 +137,7 @@ EXAMPLES
xapi-to config set apiKey=xapi_abc123
xapi-to earnings
xapi-to usage c7fe24d5-e1d4-4bc1-a9bb-e16df8ab93b0
xapi-to usage wait c7fe24d5-e1d4-4bc1-a9bb-e16df8ab93b0 --timeout 1m
xapi-to earnings transfer 1 --idempotency-key reinvest-001
xapi-to health
`;
Expand Down
35 changes: 35 additions & 0 deletions src/tests/usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,39 @@ describe('usage command', () => {
'Run: xapi-to usage <request-id>',
);
});

it('waits through a not-finalized receipt and prints the finalized result', async () => {
const receipt = {
requestId: 'request-2',
actualCostUsd: '0.01000000',
};
const requestSpy = spyOn(client, 'request')
.mockRejectedValueOnce(new client.HttpError(404, 'not finalized'))
.mockResolvedValueOnce(receipt);

await usage(['wait', 'request-2'], { interval: '1ms', timeout: '100ms' });

expect(requestSpy).toHaveBeenCalledTimes(2);
expect(requestSpy.mock.calls[0]?.[2]).toBeGreaterThan(0);
expect(requestSpy.mock.calls[0]?.[3]).toBe(0);
expect(outputSpy).toHaveBeenCalledWith(receipt, undefined);
requestSpy.mockRestore();
});

it('does not retry permanent errors in wait mode', async () => {
const requestSpy = spyOn(client, 'request').mockRejectedValue(
new client.HttpError(403, 'missing scope'),
);

await expect(
usage(['wait', 'request-3'], { interval: '1ms', timeout: '100ms' }),
).rejects.toThrow('err called');

expect(requestSpy).toHaveBeenCalledTimes(1);
expect(errSpy).toHaveBeenCalledWith(
'usage receipt wait failed',
expect.stringContaining('HTTP 403'),
);
requestSpy.mockRestore();
});
});
Loading