From aa0fae7ca7044657e4c8d76758d61a6965313d32 Mon Sep 17 00:00:00 2001 From: "Li, Amazing Ang" Date: Mon, 17 Aug 2026 04:23:26 +0800 Subject: [PATCH] feat(usage): wait for finalized request receipts --- README.md | 1 + skills/xapi/SKILL.md | 1 + src/commands/usage.ts | 113 +++++++++++++++++++++++++++++++++++----- src/index.ts | 4 ++ src/tests/usage.test.ts | 35 +++++++++++++ 5 files changed, 140 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 052d845..b21a959 100644 --- a/README.md +++ b/README.md @@ -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 # finalized cost + balance-after receipt +xapi-to usage wait --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 diff --git a/skills/xapi/SKILL.md b/skills/xapi/SKILL.md index 3652b43..7efc65b 100644 --- a/skills/xapi/SKILL.md +++ b/skills/xapi/SKILL.md @@ -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 +npx xapi-to usage wait --timeout 1m # Provider economy (requires earnings:read) npx xapi-to earnings diff --git a/src/commands/usage.ts b/src/commands/usage.ts index 9e92ac8..6459c31 100644 --- a/src/commands/usage.ts +++ b/src/commands/usage.ts @@ -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, @@ -15,34 +19,115 @@ export const USAGE_HELP = `xapi-to usage - Read a finalized request cost receipt USAGE xapi-to usage [--format json|pretty|table] + xapi-to usage wait [--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((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( + receiptUrl(requestId), + { + method: 'GET', + headers: { 'XAPI-KEY': apiKey }, + }, + timeoutMs, + retries, + ); +} + +async function waitForReceipt( + requestId: string, + apiKey: string, + flags: Record, +) { + 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) { if (flags.help) { console.log(USAGE_HELP); return; } - const requestId = args[0]?.trim(); - if (!requestId) err('request ID required', 'Run: xapi-to usage '); + const shouldWait = args[0] === 'wait'; + const requestId = args[shouldWait ? 1 : 0]?.trim(); + if (!requestId) { + err( + 'request ID required', + shouldWait + ? 'Run: xapi-to usage wait ' + : 'Run: xapi-to usage ', + ); + } const cfg = getConfig(); requireApiKey(cfg); try { - const result = await request( - `${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, + ); } } diff --git a/src/index.ts b/src/index.ts index cb51844..76191e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -89,6 +89,9 @@ COMMANDS --force Replace an existing file-based apiKey balance Show current account balance usage Show a finalized per-request cost receipt + usage wait Wait until a request receipt is finalized + --interval Poll interval (default: 1s) + --timeout Max wait duration (default: 30s) earnings [summary] Show spendable balance and provider earnings earnings list List provider earning records earnings transfer --idempotency-key @@ -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 `; diff --git a/src/tests/usage.test.ts b/src/tests/usage.test.ts index b27a327..6edd1f6 100644 --- a/src/tests/usage.test.ts +++ b/src/tests/usage.test.ts @@ -53,4 +53,39 @@ describe('usage command', () => { 'Run: xapi-to usage ', ); }); + + 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(); + }); });