@fleeex/sdk
v0.2.0
Published
Thin fleeex wrapper over the OpenAI SDK: preconfigured base URL, key and x-fleeex-user header, plus billing helpers. The wire stays iso-OpenAI, so dropping back to the raw OpenAI SDK is always possible.
Maintainers
Readme
@fleeex/sdk
A thin wrapper over the OpenAI SDK for the fleeex LLM proxy:
it preconfigures the base URL, your API key and the x-fleeex-user header, and adds
billing helpers (getBalance(), a typed 402 → top-up link) and webhook verification. Two lines to start, and
no lock-in — the wire is iso-OpenAI, so you can drop back to the raw OpenAI SDK at any
time (see Drop back to the raw OpenAI SDK).
Two things to know before your first call, because they are where an OpenAI habit stops
being true: which model ids exist (Choosing a model) and which
parameters are honored (Parameter surface). The wire is OpenAI's; the
parameter surface is a subset of it, and an unsupported parameter is a 400 naming that
parameter rather than a field silently dropped.
Backend only. The API key is a server-side secret. Never ship it in a browser or mobile client — a leaked key impersonates your app. See the auth model.
Install
npm install @fleeex/sdk openaiopenai is a peer dependency — you bring your own version, and the SDK never pins you
to a different one. Node 18+ (global fetch).
Quick start
import { FleeexClient } from '@fleeex/sdk';
const client = new FleeexClient({
apiKey: process.env.FLEEEX_API_KEY!, // flx_… — server-side secret
userId: 'user-123', // the end user your app vouches for
// baseURL: 'http://localhost:3000', // for local dev; defaults to the hosted endpoint
});
const completion = await client.chat.completions.create({
model: 'nova-lite',
messages: [{ role: 'user', content: 'Hello!' }],
});
console.log(completion.choices[0]?.message.content);client.chat.completions.create is the OpenAI method itself, with OpenAI's own request
and response types — this package only preconfigures the connection and maps the 402. What
the server accepts inside that request is narrower than what those types allow: see
Parameter surface.
Sandbox: integrate without paying
Every app can hold a test key next to its live one. A sandbox key runs the whole fleeex pipeline — the same wire, the same validation, the same refusals — but calls no model provider and spends a fake balance instead of your end user's wallet. Nothing it does is charged, and nothing it does appears in your usage metrics or in anyone's invoice.
Mint one from your fleeex account (the same session that registers apps):
curl -X POST https://api.fleeex.dev/apps/<appId>/keys \
-H 'authorization: Bearer <your fleeex session token>' \
-H 'content-type: application/json' \
-d '{"mode":"sandbox"}'
# → { "appId": "...", "keyId": "...", "apiKey": "flx_test_…", "mode": "sandbox" }Then point the client at it — nothing else changes:
const client = new FleeexClient({
apiKey: process.env.FLEEEX_TEST_KEY!, // flx_test_… — a sandbox credential
userId: 'test-user-1',
});
const completion = await client.chat.completions.create({
model: 'nova-lite',
messages: [{ role: 'user', content: 'Hello!' }],
});
// completion.choices[0].message.content → a canned fleeex response, not a model answerA test key is visibly one (flx_test_ prefix) and cannot be promoted: a leaked one buys
nothing. Sandbox answers also carry an x-fleeex-mode: sandbox response header, so you can
tell the two worlds apart from the answer alone.
What is the same, and what is not
| | Sandbox |
|---|---|
| The wire, the parameter surface, validation errors | identical |
| Which models you may call (the resale boundary) | identical — a reserved model is still a 403 |
| 402 codes and bodies (PAYMENT_REQUIRED, APP_SPEND_CAP_EXCEEDED, WALLET_SUSPENDED) | identical, and drivable on demand (below) |
| Rate limits | identical — a sandbox key counts in your app's own bucket |
| The completion text | a fixed canned answer; no provider is called |
| The money | a fake balance per test user, funded automatically on the first call |
| topupUrl in a sandbox 402 | a placeholder sandbox URL: it carries no credential and cannot take a payment |
| Your usage metrics, invoices and spend reports | sandbox traffic never appears in them |
| getBalance() / getUsageSummary() | live-only today: with a test key they report the real (unfunded) wallet — a 402 and an empty summary. Read the fake balance from GET /apps/<appId>/sandbox instead. |
Drive the flows you have to handle
The hardest paths to test are the ones that need you to actually run out of money. In sandbox you set the state instead:
BASE=https://api.fleeex.dev/apps/<appId>/sandbox
AUTH='authorization: Bearer <your fleeex session token>'
# Force "insufficient funds": the next call answers 402 with its top-up link.
curl -X POST "$BASE/users/test-user-1/reset" -H "$AUTH" \
-H 'content-type: application/json' -d '{"balanceMicros":0}'
# Force the monthly spend cap: the next call answers 402 APP_SPEND_CAP_EXCEEDED.
curl -X POST "$BASE/users/test-user-1/reset" -H "$AUTH" \
-H 'content-type: application/json' -d '{"spendCapMicros":1}'
# Force a suspended wallet: 402 WALLET_SUSPENDED, no top-up link.
curl -X POST "$BASE/users/test-user-1/reset" -H "$AUTH" \
-H 'content-type: application/json' -d '{"suspended":true}'
# Put the grant back and carry on.
curl -X POST "$BASE/users/test-user-1/reset" -H "$AUTH"
# See where every test user stands (fake balance, reservations, requests, tokens).
curl "$BASE" -H "$AUTH"A reset also clears reservations and counters, so each scenario starts from a state you named. Note the asymmetry, and it is deliberate: a sandbox API key cannot refill itself — funding is a control-plane gesture on the app you own, or "insufficient funds" would be a state your integration could never be made to face.
Choosing a model
Pass an alias — a stable public name such as nova-lite — rather than the versioned
provider id it resolves to (amazon.nova-lite-v1:0). Both are valid values for model, but
the alias is the one that survives the next model revision: fleeex re-points it, your code
does not change. (Your usage events always record the resolved id, so billing history stays
auditable across a re-point.)
Which models you may call depends on your app. Model licences do not all permit resale, so
the proprietary families are reserved for FlexCorp's own apps: a third-party app neither sees
them in the catalog nor may call them — asking for one is a 403 with code
MODEL_NOT_ENTITLED. Open-weight models and Amazon Nova carry no such restriction and are
open to any app. That is why every example here uses nova-lite: it is a model an ordinary
third-party app really can call, so the snippets work as written for the audience reading them.
Don't hardcode a list — ask:
List the models you may call
GET /v1/models answers in OpenAI's own list shape, scoped to the app your key belongs to:
reserved models are omitted rather than listed-and-refused, so everything it returns is
callable. Aliases are listed next to the provider ids they resolve to — both are usable values
for model, the way a provider lists a family name next to a dated snapshot.
FleeexClient wraps chat completions only, so list with the OpenAI client (already a peer
dependency of this package) pointed at the same base URL. Unlike a completion, discovery needs
no x-fleeex-user — the catalog is a property of your app, not of one of its end users:
import OpenAI from 'openai';
const catalog = new OpenAI({
apiKey: process.env.FLEEEX_API_KEY!,
baseURL: 'https://api.fleeex.dev/v1', // note the trailing /v1
});
const page = await catalog.models.list();
for await (const model of page) {
console.log(model.id, model.owned_by); // e.g. "nova-lite" "amazon"
}
// One model, by alias or provider id: 404 when nothing serves that name,
// 403 MODEL_NOT_ENTITLED when it exists but is reserved for other apps.
const model = await catalog.models.retrieve('nova-lite');Parameter surface
The wire is iso-OpenAI; the parameter surface is a documented subset of OpenAI's, and it
fails loudly rather than quietly. The rule: a parameter that would change the answer, the
amount billed, or a guarantee fleeex would be implying is a 400 naming that parameter —
silently dropping it would hand you a wrong answer you could not detect. A parameter that
changes nothing observable is accepted and ignored, because a 400 there would break an
integration for no benefit.
Honored — forwarded to the model and billed accordingly:
model,messages— includingrole: 'tool'withtool_call_id,tool_callsreplayed on an assistant message, andcontentas an array oftext/image_urlparts.toolsandtool_choice(auto,required, or{ type: 'function', function: { name } }). A tool call comes back asfinish_reason: 'tool_calls'withargumentsas a JSON string, and streams asdelta.tool_callsfragments — OpenAI's own shapes.response_format: { type: 'json_schema', json_schema: { name, schema } }for structured output, forwarded to the provider's own schema-constrained mode.- Images, via
contentparts:image_url.urlmust be adata:image/<png|jpeg|gif|webp>;base64,…URI (fleeex never fetches a remote URL on the billing path), on ausermessage, at most 8 per request. max_completion_tokens(or the deprecatedmax_tokens),temperature,top_p,stop.stream, andstream_options: { include_usage: true }— the trailing usage chunk reports the counts you were actually charged, read back from the recorded usage event.
Accepted and inert — validated, then ignored, because they change nothing observable:
user (the billed identity is the verified key plus x-fleeex-user, never a body field),
metadata, store (no prompt or completion is ever retained), n: 1, logit_bias: {},
frequency_penalty: 0, presence_penalty: 0, logprobs: false,
response_format: { type: 'text' }, image_url.detail: 'auto'. The OpenAI SDK, LangChain,
LiteLLM and the Vercel AI SDK all send several of these by default.
A 400 naming the parameter — seed, n > 1, a non-empty logit_bias, a non-zero
penalty, logprobs: true, tool_choice: 'none', tool_choice without tools,
response_format: { type: 'json_object' }, a non-data: image_url.url, an
image_url.detail other than 'auto', stream_options without stream: true, and any
parameter not listed above — undeclared sub-fields of a tool included. The message says
which parameter and why, so none of this has to be discovered by trial and error.
The authoritative, always-current version of this list is the route's own OpenAPI
description — POST /v1/chat/completions in openapi.json, or /docs
on a running instance. The reasoning per parameter, plus the applicative bounds (message
sizes, tool-schema depth, image sizes), is in
docs/modules/proxy.md.
Streaming
Identical to the OpenAI SDK — pass stream: true and iterate:
const stream = await client.chat.completions.create({
model: 'nova-lite',
messages: [{ role: 'user', content: 'Write a haiku.' }],
stream: true,
});
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta?.content ?? '');
}Add stream_options: { include_usage: true } to learn what the stream cost: every chunk then
carries usage (null until the end) and one final chunk — empty choices, populated
usage — arrives just before data: [DONE].
A stream that fails after it has started — the provider breaking mid-answer, or ending
without reporting its token usage (a truncated answer) — ends on an error object instead of
[DONE], so the for await loop throws an APIError carrying code
(UPSTREAM_PROVIDER_ERROR, UPSTREAM_THROTTLED or UPSTREAM_STREAM_TRUNCATED) rather than
handing you a half answer that looks complete. Whatever was streamed before the failure is
still billed.
Billing helpers
getBalance()
const { balanceMicros, currency } = await client.getBalance();
// balanceMicros is EUR micro-units: 1_000_000 = €1.00Handling 402 Payment Required
When the (app, user) has no funded, authorized wallet, both the proxy and getBalance()
raise a typed PaymentRequiredError. Most of the time it carries a single-use,
short-lived top-up link (err.topupUrl) — redirect the end user there, as-is, to fund
the wallet (its credential is in the URL fragment: never rewrite or strip it). But
topupUrl is not always present, so check it before using it:
err.code === 'WALLET_SUSPENDED'— the wallet was suspended (a claw-back), and funding it would not lift that. There is no link because topping up is not the remedy; the end user (or you, on their behalf) needs to contact support.err.code === 'APP_SPEND_CAP_EXCEEDED'reached via the rare settle-time re-check (a concurrent-spend race, not the common pre-check path) also arrives with no link. The remedy here is different: your app's per-app spend cap needs raising, by the end user or by you as the app owner — not something the end user's own top-up can fix either.
import { PaymentRequiredError } from '@fleeex/sdk';
try {
await client.chat.completions.create({ model, messages });
} catch (err) {
if (err instanceof PaymentRequiredError) {
if (err.topupUrl) {
redirect(err.topupUrl); // send the end user through Checkout
} else {
// WALLET_SUSPENDED or a cap hit with no link to offer — show a message
// instead of redirecting to `undefined`.
showPaymentIssue(err.code);
}
} else {
throw err;
}
}Prefer a single place to handle it? Register a hook — it fires before the error is thrown:
const client = new FleeexClient({
apiKey,
userId,
onPaymentRequired: (err) => {
if (err.topupUrl) redirect(err.topupUrl);
else showPaymentIssue(err.code);
},
});getConnection() — connect a user without an AI call
Don't want to wait for a 402 on the first chat call? Ask up front whether the
(app, user) is linked and funded, and get the onboarding link to open:
const { connected, funded, connectUrl } = await client.getConnection({
// optional — where fleeex returns the user once connected/funded. Must be
// registered on your app (PUT /apps/:id/redirect-uris), else the call fails 400.
redirectUri: 'https://app.example.com/fleeex/return',
});
if (!connected || !funded) {
redirect(connectUrl); // sign in → authorize the app → top up, then back to redirectUri
}connectUrl is the same signed onboarding link a 402 hands back — minted on demand,
with no chat call, no charge. Unlike the proxy and getBalance(), getConnection()
never raises a 402; it always returns the status.
getUsageSummary() — spend this month
Month-to-date usage for the configured (app, user) — to show "spent this month" in
your own UI, no metering of your own:
const { spentMicros, totalTokens, requestCount, periodStart, periodEnd } =
await client.getUsageSummary();
// spentMicros is EUR micro-units; period is [periodStart, periodEnd) in UTCNever raises a 402; a user with no usage this month reads as zeros.
Webhooks: be told before your user is cut off
Polling getBalance() puts the burden on you and still misses the moment — the request that
runs out is the one that fails. Instead, register one https endpoint for your app and
fleeex calls you.
| Event | Fires when | data |
|---|---|---|
| balance.low | your user's spendable balance crosses below the threshold you set — once per crossing, not on every request underneath it | userId, availableMicros, thresholdMicros, currency |
| topup.completed | your user funded their wallet (the verified Stripe payment, never a redirect) | userId, balanceMicros, availableMicros, currency |
| connection.created | one of your users connected their wallet to your app for the first time | userId, connectedAt |
Configure it from the fleeex dashboard, or with your Cognito session:
PUT /apps/{appId}/webhook
{ "endpointUrl": "https://api.example.com/hooks/fleeex",
"events": ["balance.low", "topup.completed"],
"lowBalanceThresholdMicros": 2000000 }The response of the call that creates the subscription contains signingSecret
(whsec_…) — shown once. Store it where your server can read it. POST
/apps/{appId}/webhook/secret rotates it; the previous secret stops verifying immediately,
so deploy the new one before you rotate. The endpoint must be https and publicly routable
(no localhost, no private addresses — use a tunnel in development).
The delivery
POST /hooks/fleeex
content-type: application/json
x-fleeex-event-id: evt_9f2c1ab04d7e5688c3a1bd40f7e21c93
x-fleeex-event-type: balance.low
x-fleeex-delivery-attempt: 1
x-fleeex-signature: t=1785240000,v1=6f1c…64 hex chars…
{"id":"evt_9f2c…","type":"balance.low","createdAt":"2026-07-28T11:30:00.000Z",
"appId":"…","data":{"userId":"your-own-user-id","availableMicros":1500000,
"thresholdMicros":2000000,"currency":"EUR"}}userId is the id you vouched for in x-fleeex-user. A payload never contains anything
you could not already read for that user with your own key: no prompt, no email, no amount
paid, and no fleeex-internal identity id.
Verify the signature
verifyWebhook checks the signature in constant time, then freshness, then the payload
shape, and returns a typed event (a union discriminated on type). Pass the raw body:
if your framework parses JSON before you see it, keep the raw bytes, since re-serializing
gives a different digest.
import { verifyWebhook, WebhookSignatureError } from '@fleeex/sdk';
// e.g. a Next.js / fetch-style route handler
export async function POST(req: Request): Promise<Response> {
let event;
try {
event = verifyWebhook({
rawBody: await req.text(),
headers: req.headers, // fetch Headers or a Node headers object
secret: process.env.FLEEEX_WEBHOOK_SECRET!, // whsec_…
// toleranceSeconds: 300 (default, WEBHOOK_SIGNATURE_TOLERANCE_SECONDS)
});
} catch (err) {
if (err instanceof WebhookSignatureError) {
return Response.json({ error: err.reason }, { status: 401 });
}
throw err;
}
switch (event.type) {
case 'balance.low':
await warnUser(event.data.userId, event.data.availableMicros);
break;
case 'topup.completed':
case 'connection.created':
break;
}
return Response.json({ received: true });
}WebhookSignatureError.reason is one of missing_signature, malformed_header,
bad_signature, expired (outside the tolerance window) or malformed_payload (signed,
but not a fleeex event). Fields fleeex may add later are kept on the returned event, never
rejected. The header names are exported (WEBHOOK_SIGNATURE_HEADER,
WEBHOOK_EVENT_ID_HEADER, WEBHOOK_EVENT_TYPE_HEADER, WEBHOOK_ATTEMPT_HEADER), and
signWebhook({ secret, rawBody }) builds a valid signature header to test your endpoint
locally.
verifyWebhook needs node:crypto (Node, or a Node-compatible server runtime). Elsewhere,
or in another language, implement the scheme directly:
v1 = HMAC_SHA256( key = utf8(your signing secret, "whsec_" included),
msg = utf8( t + "." + <raw request body> ) )- Parse
tandv1fromx-fleeex-signature(tolerate unknown elements and any order). - Recompute the HMAC over
`${t}.${rawBody}`, the raw bytes of the request. - Compare in constant time.
- Only then check freshness: reject if
|now − t| > 300seconds. The timestamp is inside the signed material, so it cannot be moved without breaking the signature.
v1 is a version label: if fleeex ever adds a second scheme it arrives as an extra v2=
element, and code that checks only v1 keeps working.
Deduplicate on the event id — delivery is at-least-once
You will occasionally receive the same event twice. Its id (also in
x-fleeex-event-id) is derived from the state change, not from the attempt, so a redelivery
carries the same id and a byte-identical body — only the signature timestamp differs.
Store ids you have handled and treat a repeat as already done.
Retries, and what "we gave up" looks like
- Answer
2xxas soon as you have durably accepted the event (put it on your own queue). Do the work afterwards: fleeex waits at most 5 seconds per attempt. - Any non-
2xx, a timeout or a connection error is retried with a fixed backoff, six attempts in all: 10s, 20s, 40s, 80s, 160s between attempts 1-5, then a sixth and final attempt (x-fleeex-delivery-attempttells you which one you are looking at). The schedule caps at 300s per wait, but that only matters if fleeex is configured with more than six attempts. Redirects are not followed. - After the sixth attempt the event is dropped and never delivered. Nothing will re-deliver it later.
- If events keep exhausting their retries, fleeex disables your subscription and stops
sending.
GET /apps/{appId}/webhookthen showsstatus: "disabled"withdisabledReason: "delivery_failures". Fix the endpoint andPUTthe subscription again to turn it back on. - Removing the subscription (
DELETE /apps/{appId}/webhook) stops deliveries at once and destroys the signing secret.
Sandbox traffic (a flx_test_… key) never produces a webhook: it touches no real wallet, so
there is nothing to notify about.
Options
| Option | Type | Default | Notes |
|---|---|---|---|
| apiKey | string | — | App API key (flx_…, or flx_test_… for sandbox). Sent as Authorization: Bearer. |
| userId | string | — | End user the app vouches for. Sent as x-fleeex-user. |
| baseURL | string | hosted endpoint | fleeex API root, without /v1. When unset, the FLEEEX_BASE_URL env var is used if present (Node only) — handy to repoint an app at a local or staging instance without a code change. |
| onPaymentRequired | (err) => void \| Promise<void> | — | Hook fired on any mapped 402. |
| maxRetries | number | OpenAI default | Forwarded to the OpenAI client. |
| defaultHeaders | Record<string,string> | — | Extra headers on every request. |
| fetch | typeof fetch | global fetch | Custom transport (proxies, tests). |
Errors
| Error | When |
|---|---|
| PaymentRequiredError | 402 from the proxy or getBalance(). Exposes topupUrl, code, correlationId. Reachable on demand with a sandbox key. |
| FleeexApiError | Any other non-2xx from a billing helper. Exposes status, code, correlationId. |
| WebhookSignatureError | Thrown by verifyWebhook. Exposes reason. |
| OpenAI.APIError (and subclasses) | Raised by the OpenAI SDK for proxy errors other than 402: 400 for an unsupported model or an unsupported parameter (named in the message), 403 MODEL_NOT_ENTITLED for a model reserved to other apps, 429 when throttled, 502 when the provider fails. |
Drop back to the raw OpenAI SDK
The wire is iso-OpenAI, so this package is never a lock-in. You can skip it entirely
and point the official OpenAI SDK at the fleeex base URL — every request is byte-identical
to what @fleeex/sdk sends (there is a non-regression test asserting exactly that):
import OpenAI from 'openai';
const client = new OpenAI({
apiKey: process.env.FLEEEX_API_KEY!,
baseURL: 'https://api.fleeex.dev/v1', // note the trailing /v1
defaultHeaders: { 'x-fleeex-user': 'user-123' },
});
const completion = await client.chat.completions.create({
model: 'nova-lite',
messages: [{ role: 'user', content: 'Hello!' }],
});What you give up versus @fleeex/sdk:
baseURLmust include/v1, and you setx-fleeex-useryourself.- A
402arrives asOpenAI.APIError(status === 402). The top-up link is in the response body, but the OpenAI SDK surfaces only the body'serrorfield — so thetopupUrlisn't readily accessible.@fleeex/sdklifts it out intoPaymentRequiredError.topupUrlfor you. getBalance()is not part of the OpenAI SDK — callGET /balanceyourself (same bearer key +x-fleeex-user).
Nothing is given up on the model surface: models.list() / models.retrieve() are the same
calls shown in List the models you may call, which already go
through the plain OpenAI client.
Test
npm install
npm test # vitest — a stubbed fetch, no backend requiredLicense
MIT.
