@flop-labs/client
A small ESM Fetch client for FLOP's public gateway, for browsers and Node.js 22 or later. Its only runtime dependency is @flop-labs/contracts.
pnpm add @flop-labs/client
import { FlopClient, FlopHTTPError } from "@flop-labs/client";
const client = new FlopClient({ apiKey: "your-flop-api-key" });
const models = await client.listModels();
const model = await client.getModel(models.data[0].id);
try {
const completion = await client.createChatCompletion({
model: model.data.id,
messages: [{ role: "user", content: "Hello" }],
stream: false,
}, { signal: AbortSignal.timeout(30_000) });
console.log(completion.choices);
} catch (error) {
if (error instanceof FlopHTTPError) {
console.log(error.status, error.retryAfter);
// error.body retains the gateway's structured error, or non-JSON text.
}
throw error;
}
The default baseURL is https://api.flop.network/v1, the public alias documented in services/rest-gateway/README.md. An origin-only URL gets /v1 appended. An explicit API path must end in /v1 (including /api/v1); trailing slashes are removed. HTTP(S) URLs with credentials, queries, or fragments are rejected. Redirects are never followed; browsers may expose them as an opaque HTTP error with status 0.
Model discovery needs no API key and sends no bearer credential. Chat completions and receipt lookup require apiKey and send it only to the configured gateway. Keep privileged credentials on a server. fetch can be injected for tests or a compatible transport. Each method accepts an optional caller signal. Requests use credentials: "omit" and redirect: "manual".
Requests are validated before network access; successful JSON responses are checked structurally using the shared contracts. Provider extensions and receipt fields are returned unchanged. These are gateway-reported data: the client does not verify receipts, attestations, payments, chain inclusion, or finality.
The client supports listModels(), getModel(id), createChatCompletion(request) (JSON), streamChatCompletion(request) (SSE), and getCredentialReceipt(requestId). Wallet operations, x402 funding, and grant minting are outside this surface. Paid requests are sent once: there are no automatic retries, including on 429, cancellation, or transport failure. A timeout does not establish that the gateway did no work; resolve that outcome before manually repeating a paid call.
listModels also accepts offset (a non-negative integer), limit (1–1000), output_modalities and supported_parameters (arrays of names), alongside signal. These serialize to the public model feed's query parameters; model detail ids are encoded as one path segment.
FlopHTTPError retains status, body, and the raw retryAfter header. Its message excludes request credentials and gateway body content. FlopValidationError reports malformed configuration, input, or successful responses. FlopTransportError preserves the underlying transport failure as cause; caller cancellation propagates the signal's reason. Avoid logging error bodies or causes without applying your application's data policy.
Streaming chat completions
streamChatCompletion returns an async generator. It dispatches one POST when first
iterated, forces stream: true, and rejects an explicit stream: false. It yields
structurally validated ChatCompletionChunk objects and returns a
ChatCompletionStreamResult on successful termination. Use next() to retain that
terminal return value (for await consumes chunks but discards the return value):
const stream = client.streamChatCompletion({
model: "flop/model",
messages: [{ role: "user", content: "Hello" }],
}, { signal: AbortSignal.timeout(30_000) });
while (true) {
const next = await stream.next();
if (next.done) {
console.log(next.value.finishReason, next.value.usage, next.value.receipt);
console.log(next.value.toolCalls); // Complete calls assembled by index.
break;
}
console.log(next.value.choices[0]?.delta.content ?? "");
}
The iterator enforces the role/content/tools/finish/usage/[DONE] lifecycle.
Only a valid success
finish, usage/receipt frame, [DONE], and a closed body produce a terminal result.
No result is returned on early iterator closure; closing it cancels the body
reader. The client reads through EOF to reject data frames after [DONE].
Before a stream opens, non-success HTTP statuses throw FlopHTTPError, retaining
the body and Retry-After. HTTP 200 requires text/event-stream; a JSON replay
response to a streaming request is rejected. In-band failures throw FlopStreamError
with body, usageSource ("estimated" when supplied), retryAfter (seconds), and
outputStreamed. Receipt usage and estimated error usage remain distinct.
Malformed frames or ordering throw FlopValidationError. Missing [DONE] or a
transport failure throws FlopStreamInterruptionError, with the cause when known.
Aborting rejects with the signal's exact reason and cancels the reader. Cancellation stops reading; it does not undo accepted work or payment. No error or interruption triggers a retry. Reconciliation and any replay are the caller's decision.
Caller-owned replay identity
Both paid methods accept { idempotencyKey } alongside signal. The key is sent as
Idempotency-Key, must contain 1–128 printable ASCII characters without whitespace,
and is rejected on discovery GETs. The client never creates a key or retries for you.
For durable recovery use createChatCompletion with stream: false: the gateway
stores a completed JSON response for replay, rather than a replayable SSE body.
Recovering an ambiguous request
Persist the request ID, original correlation ID, exact request body and gateway URL
before the first paid POST. Idempotency-Key is the request ID unless the caller
also supplies X-Request-ID, which takes precedence. After a lost response, use
getCredentialReceipt with that same ID before considering replay:
import { FlopClient, FlopHTTPError } from "@flop-labs/client";
// Loaded from the application's durable job record; no new ID is generated here.
async function observeLostResponse(client: FlopClient, originalRequestId: string) {
try {
const receipt = await client.getCredentialReceipt(originalRequestId, {
signal: AbortSignal.timeout(10_000),
});
if (receipt.state === "reserved" || receipt.state === "estimated") {
return { action: "wait-or-reconcile" as const, receipt };
}
return { action: "review-terminal-observation" as const, receipt };
} catch (error) {
if (error instanceof FlopHTTPError && error.status === 404) {
return { action: "investigate-missing-record" as const };
}
// 401: obtain authorized parent access; timeout/transport failure: outcome unknown.
throw error;
}
}
This example submits no inference and logs no credentials, prompts or outputs. Neither a missing row nor a timeout proves that paid work did not occur. They must not trigger automatic replacement paid work. If an operator subsequently chooses JSON replay, use the original credential, ID, correlation ID, body and route; an active parent may inspect its child's ledger but cannot replay as that child.
The lookup uses GET /keys/me/receipts/{request_id} and reads an exact, case-sensitive
ID independently of the latest-20 list. IDs follow the client's 1–128 printable ASCII
token rule; . and .. path segments are invalid. The client URL-encodes the ID and
parses the response as CredentialReceipt. Malformed IDs fail locally with
FlopValidationError (HTTP callers receive 400 invalid_request_id); 401 denies
missing/invalid/expired/revoked authority; 404 receipt_not_found covers both absent
and out-of-scope records without disclosing another credential's requests.
An active parent sees its own and all child requests. A child sees only its own for its existing introspection/session lifetime, even after its uses are exhausted or presentation window closes. Lookup neither presents a new session nor extends one. Child expiry or revocation denies recovery with that child; use its authorized, active parent instead. Parent expiry or revocation denies both. Expired child grant metadata can be pruned after unresolved requests finish, while retained ledger rows remain readable by the active parent.
reserved holds quota without a completion checkpoint; estimated retains the
reservation after completion with actual spend unresolved. settled records reconciled
spend and released records returned reservation quota. A released row does not
invent zero settlement evidence. response_status describes the cached HTTP checkpoint
independently of settlement. A fresh lookup can become settled while the original
cached JSON completion still reports pending; replay returns that unchanged checkpoint.
No prompt, output or cached completion body is returned by lookup.
reserved_spend and settled_spend are legacy floating-point FLOP quota values.
settled_spend_raw, when available, is the exact allocation as a decimal integer
string in raw FLOP base units: 10^18 raw = 1 FLOP. Unknown amounts, channel IDs
and terminal events remain null. terminal_event identifies available terminal
channel evidence; pooled requests share a channel event and allocation, not separate
per-request miner-payment or burn transactions. These observations are gateway data,
not independent cryptographic verification or a promise of immediate settlement.
Recovery covers only the configured gateway's retained SQLite ledger. There is no automatic ledger TTL today, but no archival or cross-gateway guarantee: database loss, operator deletion or switching gateways can make records unavailable. Preserve your own job identity and evidence under your application's retention policy.
Task correlation
The gateway groups an agent task's turns by X-Correlation-ID. Call client.task() once
per task and make that task's paid calls through the returned handle: each call sends the
same random id, and the next task() call starts a new one.
const task = client.task(); // or client.task("job-42") to supply your own id
await task.createChatCompletion({ model: "qwen/qwen3-8b", messages });
await task.createChatCompletion({ model: "qwen/qwen3-8b", messages: [...messages, reply, toolResult] });
Either method also accepts { correlationId } directly. The value follows the
idempotency-key token rules and is rejected on discovery GETs. Do not reuse one id across
tasks, because the gateway would merge them into one session. A replay under an
idempotency key must send its original correlation id.