Two structural fixes for demo concurrency (3 single-slot suppliers thrashed
under Open WebUI's ~3 conversations per message):
Multi-slot suppliers (MAX_CHAT_SESSIONS, default 1):
- SupplierState is now capacity-aware: a Set of active escrow refs with
free-until-full status semantics, ref-scoped release(ref) (also fixes the
latent bug where handler catch-alls released another request's slot), and
a walletMutex serializing every wallet-spending chain op — Claim, Submit,
and wallet-health consolidation queue instead of racing the single
working/collateral UTxO. /status gains active_sessions/max_sessions.
Entry-ticket settle mode (CHAT_SETTLE_MODE=ticket, default full, both sides):
- The buyer's escrow post at session open is the ONLY chain op. The supplier
verifies the Open escrow as an entry ticket but never Claims/Submits
(/v1/chat/start → {status:"ticket"}; /v1/chat/end → receipt-less
{status:"ended"}); no hard-cap timer, so sessions may outlive deliver_by.
Used tickets can't reopen sessions (records retained; 409 session_exists).
- Buyer SDK: startChat/endChat are settle-mode aware, driven by the
supplier's response — mixed-mode rollout windows settle correctly.
- Gateway: ticket closes bill cost 0; the sweeper Reclaims Open tickets
after deliver_by WITHOUT closing live sessions (the conversation and its
affinity mirror survive their reclaimed ticket), gates reclaims on
deliver_by, and keeps an in-process settled-refs memory so terminal
indexer rows aren't retried every sweep forever.
Deployment (kimi-chat trio + gateway): ticket mode, 8 slots per supplier,
idle policy gateway 10 min / supplier 15 min (fixes the inversion where the
supplier idle-closed before the gateway janitor).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
18 KiB
OpenAI-Compatible API Gateway for the Vector Marketplace
Context
Today the only way to send a prompt to the marketplace is the single-operator buyer web app (cookie auth, one shared wallet). We want external developers to use any OpenAI SDK against the marketplace. Design (locked through interview + a 5-lens adversarial audit of an earlier draft):
- Public, multi-tenant, custodial gateway. Each API key is its own buyer wallet (own Ed25519 key, held AES-GCM-encrypted by the gateway). The user funds their own deposit address with AP3X; each request posts an on-chain escrow from that user's wallet. Not the deployed operator buyer.
- Two surfaces, two escrow models:
- One-shot completions —
POST /openai/v1/chat/completions(stateless, OpenAI-standard). Routes to anllm.text.generate.v1supplier. One escrow per call.stream:true→ buffered pseudo-stream. - Chat sessions — a Vector REST extension. Routes to an
llm.chat.v1supplier. One escrow per session: fixed price locked at open, real token streaming per turn (no per-turn charge), settled at session close.
- One-shot completions —
- Routing matches capability + model, not cheapest. Per-prompt cost = the matched supplier's advertised price.
- Gateway auto-maintains each user's wallet (collateral + consolidation) so requests past #1 keep working.
- Onboarding is self-serve via a small web UI; the response is a spec-clean OpenAI body +
x_vectorreceipt extension; custody is protected with settle-on-success + a state-aware reclaim sweeper + a withdrawal path.
The audit confirmed 39 holes in the first draft (10 distinct themes). This document incorporates every fix; the "Audit-driven corrections" section maps each theme to its resolution.
Locked decisions
| Area | Decision |
|---|---|
| Scope | Public multi-tenant OpenAI-compatible gateway; new gateway/ pnpm package reusing the buyer Marketplace SDK |
| Identity | Each API key = its own custodial buyer wallet |
| Custody | AES-256-GCM, GATEWAY_MASTER_KEY (env), per-row nonce; decrypt in-memory at request time; rotation CLI; withdrawal endpoint is the user exit |
| One-shot | /openai/v1/chat/completions → llm.text.generate.v1, 1 escrow/call, buffered (+ pseudo-stream) |
| Chat session | /openai/v1/chat/sessions* → llm.chat.v1, 1 escrow/session, real SSE per turn, settle at close |
| Routing | match model + required capability; pick an available supplier (status free|unknown); not cheapest; fallback to next match on busy/offline |
| Pricing | cost = matched supplier price_lovelace; required balance = price + buyer_bond + supplier_bond + 5 ADA collateral + fee reserve |
| Wallet health | gateway auto-consolidates each wallet to {5-ADA collateral, working} after settle / on a tick |
| Settlement | resolve the Submitted ref via indexer, acceptResult, await confirm before returning success; sweeper is backstop |
| Sweeper | state-aware: Open/Claimed past deliver_by → reclaim; Submitted → retry Accept in-window; never blind-reclaim Submitted |
| Response | OpenAI object {id, object, created, model, choices, usage} + x_vector:{receipt, receipt_signature, escrow_ref}; full streaming chunk transform + final usage chunk |
| Hardening | /signup IP rate-limit, per-key request rate-limit, SdkRegistry LRU cap, structured logging + audit trail |
Architecture
New standalone pnpm package gateway/ (add to pnpm-workspace.yaml). Boots one shared ChainProvider (Ogmios, LIVE_CHAIN=1) + indexer URL, and an SdkRegistry that lazily builds one Marketplace per API key bound to that user's WalletKey (LRU-capped, evicted by idle). All on-chain work for a key is serialized by a per-key promise-chain mutex.
Verified reuse points (do not re-implement)
MarketplacectorMarketplaceOpts{chain, indexerUrl, walletKey, networkParams, historyStore?}—buyer/src/sdk/Marketplace.ts:117. Per-user field is onlywalletKey.deriveWalletKey+ ed25519 hook —buyer/src/index.ts:24-64.- SDK methods:
discoverSuppliers,submitPrompt(Marketplace.ts:242, verifies receipt but does not settle),startChat/endChat(chat session;endChatreturnsacceptedRefand awaits Accept),acceptResult,reclaim. - Escrow value/bonds: buyer funds
totalLocked = price + buyer_bond + supplier_bondat PostEscrow —packages/shared/src/tx/escrow/postEscrow.ts:132-200. On Accept supplier getspayment+supplier_bond, buyer getsbuyer_bondback. - Submitted-ref resolution: the buyer
/v1/acceptqueries the indexer and matches the Submitted row byposted_at—buyer/src/server.ts/v1/accept. Gateway must mirror this. - Wallet health:
planConsolidate/consolidateWallet({collateral, working}shape,DEFAULT_COLLATERAL_LOVELACE = 5 ADA) —packages/shared/src/tx/wallet/. Collateral assertion:assertCollateralCandidate—packages/shared/src/tx/internal/liveCbor.ts. - Supplier chat-session SSE
{type:"token"|"done"|"error"}+ headers —supplier/src/server.tsmakeChatSessionHandlers; buyer relay inbuyer/src/server.ts/v1/chat/message(note: verbatim passthrough — the gateway must transform to OpenAI chunks). - SQLite pattern (
better-sqlite3, WAL) —buyer/src/db/archive.ts. Auth helperstimingSafeCompareStrings,createRateLimiter—buyer/src/auth.ts. - Indexer
SupplierView(model,capability_id,price_lovelace,buyer_bond_lovelace,supplier_bond_lovelace,statusfree/working/offline/unknown,advert_status) —indexer/src/routes/suppliers.ts.
Package layout (gateway/)
src/
index.ts # boot: config → shared chain/indexer → GatewayStore → SdkRegistry → app → listen + sweeper + wallet-health tick
config.ts # GATEWAY_MASTER_KEY(64hex), INDEXER_URL, OGMIOS_URL, LIVE_CHAIN, NETWORK_ID, GATEWAY_PORT, GATEWAY_DB_DIR, rate-limit knobs
wallet.ts # deriveWalletKey + ed25519 hook + genPrivKey
crypto/seal.ts # aes-256-gcm seal()/open(), per-row nonce
db/{schema.ts,store.ts} # api_keys, usage, sessions
sdk/registry.ts # per-key Marketplace cache (LRU) + per-key Mutex
routing/selectSupplier.ts # indexer query → capability+model match → available → fallback
openai/{shapes.ts,errors.ts,chatCompletions.ts,sessions.ts,models.ts}
account/routes.ts # POST /signup, GET /account, POST /account/withdraw
middleware/apiKeyAuth.ts, middleware/rateLimit.ts
walletHealth.ts # post-settle + ticker consolidation (reuse planConsolidate)
sweeper.ts # state-aware reclaim/accept-retry
ui/index.html
cli/rotate-master-key.ts # re-encrypt all rows old→new master key
Build wiring: add "exports": { "./sdk": "./dist/sdk/index.js" } to buyer/package.json, depend @marketplace/buyer: workspace:* + @marketplace/shared: workspace:*.
SQLite schema
CREATE TABLE api_keys ( id TEXT PRIMARY KEY, key_hash TEXT UNIQUE NOT NULL, key_prefix TEXT NOT NULL, label TEXT,
wallet_pkh TEXT NOT NULL, deposit_address TEXT NOT NULL,
enc_priv_nonce TEXT NOT NULL, enc_priv_ct TEXT NOT NULL, enc_priv_tag TEXT NOT NULL,
master_key_version INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, disabled INTEGER NOT NULL DEFAULT 0 );
CREATE TABLE usage ( id TEXT PRIMARY KEY, key_id TEXT NOT NULL, created_at INTEGER NOT NULL, kind TEXT NOT NULL,
model TEXT, capability_id TEXT, supplier_pkh TEXT, escrow_ref TEXT, cost_lovelace TEXT,
prompt_tokens INTEGER DEFAULT 0, completion_tokens INTEGER DEFAULT 0, status TEXT NOT NULL, failure_reason TEXT );
CREATE TABLE sessions ( id TEXT PRIMARY KEY, key_id TEXT NOT NULL, escrow_ref TEXT NOT NULL, session_nonce TEXT NOT NULL,
supplier_base_url TEXT NOT NULL, model TEXT NOT NULL, state TEXT NOT NULL, opened_at INTEGER NOT NULL, closed_at INTEGER );
Raw API key never persisted (only sha256(key)); key_prefix for display. master_key_version enables rotation.
Endpoints
One-shot (OpenAI standard):
POST /openai/v1/chat/completions— Bearer. Parse{model, messages, max_tokens?, stream?}; rejecttools/tool_choice/functionswith 400; ignoretemperature/n/stop/top_p/...(documented). Lifecycle below.GET /openai/v1/models— distinctmodelover Active suppliers → OpenAI list shape.
Chat sessions (Vector extension — documented as non-standard):
POST /openai/v1/chat/sessions {model}— open: route tollm.chat.v1,startChat(lock fixed price), persist session row →{session_id, model}.POST /openai/v1/chat/sessions/{id}/messages {messages|content, stream}— real SSE turn; transform supplier{type:token}→chat.completion.chunk; final usage chunk +[DONE]. No new escrow.POST /openai/v1/chat/sessions/{id}/close— settle:endChat(Submit+Accept, awaits), record usage, then run wallet-health.
Account / UI:
POST /signup {label?}— IP rate-limited. Generate priv → derive → seal → store →{api_key (once), deposit_address, key_prefix}.GET /account— Bearer.{deposit_address, balance:{available_lovelace, locked_in_escrow_lovelace, ap3x}, collateral_ok:bool, spend:{total_cost_lovelace, request_count}, recent_usage[]}.POST /account/withdraw {to_address, amount_lovelace?}— Bearer. Build a wallet→address transfer (newbuildWithdrawTxin packages/shared; repo has no send tx today) sending available funds out. The custodial exit path.GET /— self-serve UI (create key, copy deposit address, live balances, usage, withdraw).
Request lifecycle — one-shot /chat/completions
All on-chain steps run inside the per-key mutex.run(...).
- Route (
selectSupplier): indexer rows wheremodelmatches,capability_id==="llm.text.generate.v1",advert_status==="Active", livestatus ∈ {free, unknown}. Pick an available one (not cheapest). No match → 404model_not_found(type: invalid_request_error). - Pre-flight: required =
price + buyer_bond + supplier_bond + 5_000_000 (collateral) + ~2 AP3X fee. Verify total balance and a pure-ADA UTxO ≥5 ADA exists (assertCollateralCandidate-style). Else 402insufficient_funds+x_vector:{required_lovelace, available_lovelace, collateral_ok, deposit_address}. - Execute:
submitPrompt({advertRef, messages, payment_lovelace:price, max_output_tokens})(posts escrow → supplier → verifies receipt). - Settle: resolve the Submitted escrow ref via the indexer (mirror buyer
/v1/accept: match by buyer pkh +posted_at),acceptResult(submittedRef), await confirmation. On 409/offline mid-route, retry next matching supplier (bounded). On any post-escrow failure, record + leave for the sweeper (don't block the response on reclaim). - Respond:
{id:"chatcmpl-…", object:"chat.completion", created:<sec>, model, choices:[{index:0,message:{role:"assistant",content},finish_reason:"stop"}], usage:{prompt_tokens,completion_tokens,total_tokens}, x_vector:{receipt,receipt_signature,escrow_ref}}. Recordusage. - Wallet health: after settle, consolidate the wallet back to
{collateral, working}(inside the mutex or queued). stream:true: same flow; emit one (or chunked)chat.completion.chunkdelta + finalfinish_reason:"stop"chunk withusage+data: [DONE]. Emit: keepaliveSSE comments during the escrow-confirm gap.
Chat-session lifecycle
- open →
startChat(escrow at fixed price), store{escrow_ref, session_nonce, supplier_base_url}. - messages → POST the turn to
${supplier_base_url}/v1/chat/messagewithX-Escrow-Ref; transform each{type:token,value}→chat.completion.chunk;{type:done}→ final chunk + usage +[DONE]. (System messages: fold into the first turn's content; documented limitation since chat.v1 has no system channel.) - close →
endChat(Submit + Accept, awaits) → settle + bond refund; record usage; wallet-health. - Sessions are reclaimable by the sweeper if abandoned before close.
Settle modes (CHAT_SETTLE_MODE, gateway + supplier, default full)
- full — the lifecycle above: supplier Claims at start, Submits a transcript receipt at end, gateway Accepts (buyer charged).
- ticket — the buyer's escrow post at open is the ONLY chain op. The supplier verifies the Open escrow as an entry ticket (
/v1/chat/start→{status:"ticket"}) but never Claims/Submits;/v1/chat/end→{status:"ended", settle_mode:"ticket"}with no receipt; the sweeper Reclaims the Open escrow afterdeliver_by, returning all locked funds (net cost ≈ tx fees). Usage rows recordcost_lovelace: 0. Sessions may outlivedeliver_by(no hard-cap) — a live session whose ticket got reclaimed keeps running; only the idle janitor/close ends it. Close behavior is driven per-session by the supplier's/endresponse, so mixed-mode rollout windows settle correctly. - Ticket caveats (accepted, demo-grade): a supplier restart forgets used tickets, so an unexpired un-reclaimed escrow ref could reopen a session (bounded by
deliver_by+ the sweep);/account.locked_in_escrow_lovelaceover-reports because the indexer never updates spent rows. - Suppliers additionally take
MAX_CHAT_SESSIONS(default 1) — concurrent session slots;/statusreportsactive_sessions/max_sessionsand staysfreeuntil all slots are taken.
Sweeper & wallet-health
- Sweeper (interval ~60s): per key, classify escrows. Open/Claimed past
deliver_by→reclaim. Submitted with a verified receipt still in accept-window → retryacceptResult. Submitted past accept-window → leave for supplier Release (log). Never blind-reclaimSubmitted. - Wallet-health tick: periodically + after each settle,
planConsolidate→consolidateWalletso every wallet keeps a ≥5 ADA collateral UTxO. Skip if already healthy.
Errors (OpenAI shape {error:{message, type, code, param:null}})
no supplier → 404 invalid_request_error/model_not_found; underfunded/no-collateral → 402 invalid_request_error/insufficient_funds; bad/missing key → 401 authentication_error/invalid_api_key; rate limit → 429 rate_limit_error + Retry-After; supplier timeout → 504 server_error; supplier busy race (after retries) → 503 server_error/overloaded; receipt verify → 502 server_error/receipt_verification_failed; tx build → 409/502 server_error/escrow_failed; unsupported tools/functions → 400 invalid_request_error/unsupported_parameter.
Audit-driven corrections (themes → resolution)
- Settlement/Submitted-ref + fire-and-forget Accept → lifecycle step 4 (indexer resolution + awaited Accept); sweeper backstop.
- No withdrawal →
POST /account/withdraw+ newbuildWithdrawTx. - Master-key SPOF / offline-stranding → documented backup,
rotate-master-keyCLI (master_key_version), withdrawal as exit; trustless recovery (pre-signed reclaim / multisig) explicitly out of scope, documented as residual risk. - Stateless↔stateful chat → sessions are explicit (open/messages/close), one escrow/session; system-message folding documented.
- Collateral/fragmentation → corrected required-balance formula + pre-flight collateral check + auto wallet-health consolidation.
- ~0.5 req/min throughput (awaitTx in mutex) → documented per-key SLA; "move awaitTx out of the lock" noted as fast-follow.
- Sweeper can't reclaim Submitted → state-aware sweeper.
- OpenAI wire format → full response object + chunk transform + final usage chunk + keepalive + error taxonomy + param policy.
- Hardening →
/signupIP limit, per-key request limit,SdkRegistryLRU cap,status ∈ {free,unknown}+ freshness + fallback retry. - Economics/observability → v1 = no on-chain margin (operator subsidizes infra; a fee needs a separate transfer or off-chain credit — folded into the deferred "billing" phase); add structured per-request logging + audit (request_id, key_id, model, supplier_pkh, escrow_ref, status, latency) + basic metrics/alerts (indexer stale, Ogmios slow, sweeper idle).
- Doc fix: clarify the off-chain tx builder enforces
payment==advert.price; the validator only enforces the state machine, not pricing.
Out of scope for v1 (documented)
Real billing/markup & fee hook; trustless/self-custody recovery; multi-funding-UTxO optimization beyond the consolidation loop; moving awaitTx out of the per-key lock; real chat.v1 system-role support (needs a supplier API change).
Verification (testnet, end-to-end)
- Indexer + Ogmios + ≥1
llm.text.generate.v1and ≥1llm.chat.v1supplier for the samemodel, Active. - Boot gateway:
LIVE_CHAIN=1,GATEWAY_MASTER_KEY=$(openssl rand -hex 32),OGMIOS_URL,INDEXER_URL,NETWORK_ID,GATEWAY_DB_DIR,GATEWAY_PORT. POST /signup→ key + deposit address. Fund ≥ price + both bonds + 5 ADA collateral + ~2 ADA fees; confirm viaGET /account(collateral_ok:true).- One-shot non-stream via curl and via the OpenAI Python/JS SDK (
base_url=…/openai/v1) → validchat.completion+x_vector; confirm the escrow settled (Accepted) and ausagerow exists. - Run a 2nd and 3rd request → confirm wallet-health kept
collateral_okand they succeed (the audit's request-#2 failure must not reproduce). - One-shot
stream:true→ chunks + final usage +[DONE]. - Chat session: open → stream 2–3 turns → close; confirm one escrow for the whole session, settled at close.
POST /account/withdraw→ unspent AP3X returns to an external address.- Underfunded / no-collateral key → 402 with guidance. Unknown model → 404.
tools:[…]→ 400. - Kill gateway mid-request → restart → sweeper reclaims Open/Claimed and retries Accept on Submitted; balances recover.