57 Commits

Author SHA1 Message Date
david
b0c83ca985 docs(deploy): correct DNS guidance for Hetzner suppliers — no wildcard exists
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
The PR #12 runbook and compose pre-flight headers claimed a wildcard
*.vector.apexfusion.org record covers new supplier hosts. It does not:
each mp-suppliers-* host is an individual DNS-only A record to
91.98.147.172, and on-box Let's Encrypt HTTP-01 issuance requires the
record to be unproxied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 14:53:07 +02:00
dcejtlin-apex
bfcb0c46b8 Merge pull request #12 from Apex-Fusion/feat/hetzner-suppliers
feat(deploy): Hetzner Inference supplier fleet — 4 models × {text, chat}
2026-08-11 14:32:40 +02:00
david
88e549bd1d fix(supplier): use https Ogmios URL in Hetzner env template, matching the live fleet
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 14:28:11 +02:00
david
6c5a068fd9 feat(deploy): Hetzner Inference supplier fleet — 4 models x {text, chat}
Eight new mainnet suppliers backed by Hetzner's OpenAI-compatible
inference API (https://inference.hetzner.com/api). Pure configuration —
the LLM_BACKEND=openai path is reused unchanged, following the
HuggingFace-router precedent:

  kimi-code[-chat]     Kimi-K2.7-Code            (ctx 262144)
  glm[-chat]           GLM-5.2-NVFP4             (ctx 512000)
  ds-flash-htz[-chat]  DeepSeek-V4-Flash-0731    (ctx 512000)
  qwen35b[-chat]       Qwen/Qwen3.6-35B-A3B-FP8  (ctx 262144)

Uniform 0.2 AP3X per job, default 1 AP3X bonds, no upstream max_tokens
(advert cap = model context length), 300s SLA (1800s for chat sessions).
All four models smoke-tested for non-empty content and tools round-trip.

Runbook: docs/HETZNER_SETUP.md. Adverts, wallets, and host env files are
operator steps after merge; first `up -d` is manual (CD skips projects
with no running containers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 14:24:51 +02:00
dcejtlin-apex
83f81ab5fc Merge pull request #7 from Apex-Fusion/feat/huggingface-router-v1
Huggingface Adapter integration
2026-08-11 13:24:09 +02:00
dcejtlin-apex
6859486334 Merge pull request #11 from Apex-Fusion/feat/mainnet-cd
feat(deploy): CI-gated continuous deployment to the vector-marketplace host
2026-08-11 11:11:14 +02:00
david
304bbc62ad fix(deploy): run the deploy job on the self-hosted nixos runner
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
Keep SSH key material in $RUNNER_TEMP instead of ~/.ssh since a
self-hosted runner's home persists across jobs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 11:00:23 +02:00
david
312f3e730e feat(deploy): CI-gated continuous deployment to the vector-marketplace host
Merges to main now deploy automatically once CI passes:

- .github/workflows/deploy-mainnet.yml: fires on successful CI runs of
  main (or manually via workflow_dispatch with an optional force input),
  SSHes into the mainnet host with the DEPLOY_SSH_KEY secret (host key
  pinned) and runs deploy/mainnet/deploy.sh at the new commit via
  git show FETCH_HEAD:, so deploy logic ships with the code it deploys.
- deploy/mainnet/deploy.sh: hard-resets /root/agents-marketplace to
  origin/main, maps the old..new diff to affected compose projects
  (packages/, patches/, contracts/ and root manifests rebuild all image
  builds; the 17 supplier projects share one Dockerfile so builds after
  the first are cache hits), builds everything up front, then does a
  rolling up -d with a per-project health gate. Suppliers drain first:
  /status is polled until not "working" (DRAIN_TIMEOUT_SECS, default
  600s) so an in-flight job is not killed and its bond forfeited.
  FORCE=1 redeploys the current sha, DRY_RUN=1 previews the plan, and
  each deploy logs old -> new to /var/log/marketplace-deploy.log with a
  printed rollback one-liner.

Verified: DRY_RUN=1 on the host against the pending 678ee86..6156900
delta selects indexer, buyer, gateway and all 17 suppliers, skips the
non-running wallet-monitor, and leaves image-only projects untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 10:56:50 +02:00
dcejtlin-apex
6156900fc2 Merge pull request #10 from Apex-Fusion/feat/ocr-capability
feat(ocr): OCR capability + gateway /v1/ocr/extract route
2026-08-11 09:40:25 +02:00
FilipBl4gojevic
7d348e762f fix(ocr): live busy pre-check before posting a candidate's escrow
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
runOcrOneShot posted the escrow before calling the supplier, so a busy
supplier's 409 stranded an already-paid escrow until reclaim - and the
buyer-side busy-retry multiplies this into several stranded escrows per
page. Add a live /status check per candidate, immediately before its
escrow posts, mirroring sessions.ts's startChat pre-check: "working"
skips to the next candidate without posting, anything else (free,
unreachable, malformed, slow) is treated as usable so the check is an
optimisation, never a gate. Bounded to a 2s timeout so a stalled
supplier cannot meaningfully delay the request.

Existing candidate-fallback, escrow-ref capture, usage recording, and
receipt labels are unchanged.
2026-08-10 19:31:22 +02:00
FilipBl4gojevic
7bf3252cdb fix(gateway): match both path separators in the direct-invocation guard
process.argv[1] arrives with backslashes on Windows, so the
forward-slash-only pattern never matched and `start` exited 0 without
starting the server, and without printing anything. Found while running
the gateway locally to verify this branch's OCR route end to end;
bundled here because it blocks that verification on Windows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 18:44:41 +02:00
FilipBl4gojevic
feabd12876 fix(ocr): capture escrow_ref from the escrow_posted event, not just submitOcr's return
escrowRefStr was only assigned after submitOcr resolved, but submitOcr posts
the escrow internally and THEN calls the supplier over HTTP — a SupplierError
there (timeout, network failure, non-2xx, malformed response, receipt
mismatch) meant the escrow was already on chain while the failure path still
recorded escrow_ref: null. Given the OCR upstream's 120s timeout budget this
is likely the most common paid-but-failed shape, and the exact one the
reconciliation spine exists for.

Subscribe to the SDK's "progress" event (type "escrow_posted") around each
submitOcr attempt so the ref is captured the instant it lands, regardless of
whether submitOcr goes on to resolve or reject. Listener is scoped per
candidate and removed in a finally so it cannot accumulate.

Adds a regression test proving the failure-path usage row and the ocr_failed
structured log line both carry the real ref instead of null; verified it
fails without the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:34:55 +02:00
FilipBl4gojevic
2b2295b7ed fix(ocr): review pass — min-ada floor regression test, auth-before-parse, failure-path reconciliation, disclosure label coverage
Fixes 2 critical, 2 important, and 5 minor findings from review of 069edcb:

- C1: add a happy-path test that reaches postOcrEscrow.ts's escrowLockFloor
  call and asserts the locked amount; verified it fails against a bare
  economicTotal and passes against the real code.
- C2: gate /v1/ocr/extract (auth + rate limits) before the 16mb body parser
  so an unauthenticated caller can no longer force a full buffer/JSON.parse.
- I2: hoist result/used/escrowRef in the OCR one-shot handler so the failure
  path records escrow_ref/supplier/cost when an escrow was actually posted,
  and emit an ocr_failed structured log line mirroring ocr_settled.
- I3: export venueAttribution and cover its sanitization directly; add a
  stubbed happy-path route test asserting the first-party-brokered
  disclosure label reaches the response.
- Minor: drop unused hfWeightsSource end to end, rename a misleading test,
  make the OCR capability template's pinning language conditional on the
  self-hosted upstream, tighten the /v1/ocr path-prefix guard, and make
  preflight's required_lovelace use the same escrowLockFloor the builder
  locks (exported from the shared tx barrel for gateway use).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:12:12 +02:00
FilipBl4gojevic
069edcb076 feat(ocr): model-scoped OCR capability + gateway /v1/ocr/extract route
Adds the ocr.page.extract.chandra-ocr-2.v1 capability end to end: the
postOcrEscrow builder (locking the min-ada floor for the Submitted-state
datum, required since the min-ada fix), the gateway OCR route that selects
an OCR supplier by capability, posts a per-page escrow, calls the supplier,
verifies and settles, and returns content plus the signed receipt labelled
first-party-brokered. Supplier OCR handler with a hosted document-API
upstream and buyer-SDK OCR support included.

Squashed from the 2026-08-06 working subset; the deploy pack is
intentionally excluded (it lives in a private operator repo).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 16:40:00 +02:00
HalukG
5377d379b7 feat(supplier): HuggingFace Inference Providers router backend preset
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
OpenAI-compatible compute-backend preset (config only, reuses LLM_BACKEND=openai) for pointing a supplier node at the HuggingFace Inference Providers router. Adds a boot guard that rejects the OpenRouter-only reasoning param against the HF router, unit tests, and an operator runbook.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 16:51:45 +03:00
dcejtlin-apex
d5349057b0 Merge pull request #9 from Apex-Fusion/fix/escrow-minada-floor
fix(escrow): min-ada floor so long-capability Submits settle
2026-08-10 15:08:11 +02:00
david
678ee862ea fix(docker): COPY patches/ into every builder stage
The lockfile gained patchedDependencies (libsodium-wrappers-sumo) in
2aa1688, and the package manager hashes the patch file during install —
every image build since then fails with ENOENT on /repo/patches. No
image had been rebuilt since, so it surfaced only on the next deploy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 14:30:49 +02:00
FilipBl4gojevic
c391b98af8 fix(escrow): lock min-ada for the largest future datum so long-capability Submits settle
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
The escrow locks payment + both bonds and the validator requires exact
value preservation (value_equal) on Claim and Submit. Submit grows the
inline datum ~43 bytes (submitted_at None->Some, result_receipt_hash
None->Some 32 bytes). When capability_id is long enough, the
Submitted-state output's min-ada exceeds the locked total; lucid bumps
the continuing output, value_equal fails, and the validator errors with
no traces (opaque evaluateTransaction 3010/3012). Measured on mainnet at
utxoCostPerByte 4310: a 20-byte chat capability clears a 2.2M lock by
49,310, a 33-byte OCR capability is over by 11,030 - so short-capability
escrows settle and long ones never can, and because Claim's datum is
smaller it always passes, making a Submit-only failure look chain-side.

escrowLockFloor() computes min-ada for the Submitted state plus a margin
and locks max(economic total, that floor) in the post builders. Buyer-
side only, no contract or supplier changes; lucid never bumps once the
lock is sufficient, and the surplus returns through the >= terminal
distribution. postOcrEscrow's identical change ships with the OCR
feature branch. New unit test covers the floor across capability lengths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 14:22:02 +02:00
dcejtlin-apex
6ca7f2927a Merge pull request #8 from Apex-Fusion/fix/wallet-health-idempotence
fix(wallet): make wallet-health idempotent - structural health predicate + circuit breakers
2026-08-10 14:11:59 +02:00
david
4d94aa5040 feat(deploy): Let's Encrypt TLS at traefik for all 17 mainnet supplier hosts
Same pattern as the gateway (a54fdcb): the mp-suppliers-* DNS records are no
longer Cloudflare-proxied, so each supplier gets a websecure router with the
letsencrypt resolver and its :80 router becomes a permanent HTTPS redirect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 12:28:51 +02:00
FilipBl4gojevic
36c5cfc6be fix(wallet): make wallet-health idempotent - structural health predicate + circuit breakers
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
planConsolidate returned already-healthy only for wallets with EXACTLY
2 UTxOs, but buildConsolidateWalletTx lands 3 (collateral + working +
the fee-reserve remainder as change). The shape consolidation produces
can never satisfy its own health check, so every wallet-health tick
re-consolidates and pays the ~0.5 AP3X min fee again. Measured in a
production deployment: a 45s ticker submitted 232 consolidations and
burned ~116 AP3X in about 2.5 idle hours; at the default 600s interval
the same loop leaks ~3 AP3X per hour whenever the wallet is not exactly
2 UTxOs.

Healthy is now structural: a pure-lovelace UTxO >= collateralLovelace
exists, at least 1 AP3X sits outside it, and fragmentation is bounded
(<= 6 UTxOs, new optional maxUtxos param). The health check also runs
BEFORE the low-balance guard, so a wallet that can already run script
transactions is never reshaped or refused. walletHealth gains two
independent circuit breakers - halt after 3 consecutive consolidations
that never reach healthy, halt past 6 consolidations per 24h - so any
future non-convergence costs at most ~1.5 AP3X instead of the whole
wallet, plus a docstring that matches reality.

Tests: the exact post-consolidation-shape regression, one-step
idempotence against the builder output model, candidate boundaries
(native assets, 1,628-lovelace-short), healthy-below-reshape-threshold,
and breaker behavior including error and reset paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 12:10:45 +02:00
david
a54fdcb24c feat(deploy): terminate TLS at traefik for the gateway host via Let's Encrypt
api.marketplace.vector.apexfusion.org is no longer Cloudflare-proxied, so
traefik now terminates TLS itself: a websecure router with the letsencrypt
ACME resolver, and the :80 router demoted to a permanent HTTPS redirect.
Other hosts on the box stay Cloudflare-terminated on :80.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 13:30:53 +02:00
FilipBl4gojevic
926300dee5 Merge pull request #6 from FilipBl4gojevic/brand-vocab-scrub
Brand vocabulary scrub: settlement framing, UI strings, retire M1-era design docs
2026-07-30 14:24:52 +02:00
FilipBl4gojevic
73700f983a Brand vocabulary scrub: settlement framing, UI strings, retire M1-era design docs
- docs/ARCHITECTURE.md: settlement-asset framing (AP3X bonded in escrow,
  settles on accepted work); remove references to external network
  adapters that are not part of the project
- docs/buyer + docs/supplier READMEs: commission/bond/settle vocabulary
- Buyer web UI: "Accept & Settle", escrow-framed labels, gateway-managed
  wallet wording; gateway landing title punctuation
- Delete EXECUTIVE_SUMMARY and local-llm-marketplace docs + exports:
  M1-era internal design snapshots superseded by ARCHITECTURE.md and the
  public docs site

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 13:23:35 +02:00
david
2aa1688a17 fix(ci): green the test suite — pnpm 10 build scripts, libsodium ESM, stale time fixtures
CI has been red since the pnpm 10 migration. Four independent causes:

- better-sqlite3: pnpm 10 blocks dependency build scripts by default, so the
  native binding was never compiled on CI. Allowlist it via
  onlyBuiltDependencies in pnpm-workspace.yaml.
- libsodium-wrappers-sumo 0.7.16: the published ESM wrapper does
  `import "./libsodium-sumo.mjs"` but the tarball does not ship that file
  (it lives in the libsodium-sumo package). Patch the import to the bare
  specifier; local runs only worked because of a hand-copied file in
  node_modules.
- tx-accept/claim [live] tests: the live path checks deadlines against
  Date.now(), and the fixed fixture timestamps expired in June. Freeze the
  clock inside the fixture windows with vi.useFakeTimers.
- CML set_exunits panic ("RuntimeError: unreachable"): with
  localUPLCEval: false the provider asks Ogmios for ex-units; the test mocks
  answered `{}`, so the provider fell back to a spend redeemer at index 0
  while the escrow input sorts to index 1 — CML panics on the unknown
  redeemer key. Mock evaluateTransaction with the true index like real
  Ogmios.

Also regenerate the advert-datum golden: the fixture builders passed plain JS
numbers to cbor-x (minimal ints, float64 for the timestamp) while the
production codec — whose output is live on mainnet — emits full-width uint64.
Builders now encode integer fields as BigInt so builders, codec, and golden
agree byte-for-byte.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 11:16:19 +02:00
dcejtlin-apex
3ae2a827c4 Merge pull request #5 from FilipBl4gojevic/gateway-ap3x-strings
Gateway: AP3X terminology in user-facing strings
2026-07-29 10:32:28 +02:00
dcejtlin-apex
fec0ac8d0a Merge pull request #4 from FilipBl4gojevic/dispute-resolution-naming
Naming: Dispute Resolution module
2026-07-29 10:32:16 +02:00
david
be9febffb1 fix(wallet-monitor): query wallets sequentially with retry to avoid Ogmios 500s
The public Ogmios endpoint only serves a few concurrent ledger-state
queries and returns 500 for the rest, so the Promise.all burst over the
(now 19-wallet) list reliably failed several wallets every run. Query
sequentially instead (full sweep ~2.5s) and retry each wallet once after
2s so a lone blip no longer posts a false "query failed" alert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:19:39 +00:00
david
e5793031f8 chore(deploy): track mainnet compose files for ds-flash-chat suppliers 1-3
These were live on the box but never committed. Chat-session replicas of
the deepseek-flash suppliers (OpenRouter deepseek-v4-flash, llm.chat.v1,
ticket settle mode); secrets stay in the gitignored supplier/.env files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:14:42 +02:00
FilipBl4gojevic
716a8bf2ef Gateway: AP3X terminology in user-facing strings and docs
The signup page said '~5 ADA collateral' and two API error messages
told agents to 'deposit a little more ADA' - the coin is AP3X. Strings,
comments, and docs/gateway.md updated; code identifiers unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:14:32 +02:00
david
b10706cf69 fix(wallet-monitor): post only on state changes, drop reminder re-alerts
Slack posts now fire only when a wallet newly drops below its threshold
or recovers above it; a wallet that stays low never re-posts. The
reminder_hours mechanism is removed entirely — per-wallet reminder
clocks drifted apart (Ogmios blips skip wallets from a run), so with
many chronically-low wallets some clock came due nearly every run,
collapsing the 6h cadence to the hourly cron interval.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:12:45 +02:00
FilipBl4gojevic
1f587867c6 README: Dispute Resolution naming
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:06:14 +02:00
HalukG
0b253c73bb Merge pull request #3 from FilipBl4gojevic/docs-consolidation-readme
README: marketplace front door - status, flow, layout, links
2026-07-24 13:46:46 +03:00
FilipBl4gojevic
1648edeba7 README: vocabulary pass on Related line
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:26:58 +02:00
FilipBl4gojevic
4090e064cb README: note internal audit coverage
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:07:03 +02:00
FilipBl4gojevic
3fac04b00e README: marketplace front door - status, flow, layout, links
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 10:57:27 +02:00
david
2f7b0194a5 feat(supplier,gateway,buyer): N-slot chat suppliers + entry-ticket settle mode
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>
2026-07-09 20:48:52 +02:00
david
9a4f9f0699 fix(gateway,supplier): demo sessions stick to their supplier; free pinned suppliers
The demo endpoint lost its session on nearly every second message: affinity
required clients to re-echo the assistant reply byte-identically, so clients
that strip <think> blocks or reshape whitespace (Open WebUI, litellm) missed
every turn, opened a fresh escrow session on the next free single-slot
supplier, and left the old one pinned. Open WebUI's background title/tag-gen
requests each pinned another supplier — one message could exhaust all three
kimi replicas ("no available chat supplier").

- affinity: lenient assistant-content matching (think-strip + whitespace
  collapse); user/system/tool messages and tool_calls stay byte-strict;
  strict prefixes outrank lenient ones. The mirror is never rewritten with
  client text, so receipt hashes stay valid.
- demoChat: trim trailing assistant/system messages (regenerate semantics)
  instead of 400 "the last message must have role user or tool".
- supplier: roll the transcript back when the LLM call fails so retries
  don't duplicate the delta (ChatSessionStore.truncateTranscript).
- demoChat: LRU-evict a demo session when no supplier is available (supplier
  /v1/chat/end frees the slot, sweeper Accepts the escrow) and retry the
  open with selectCandidates ignoreStatusFor to bypass the ~20s-stale
  indexer status poll. Max 2 evictions per request.
- sweeper: idle janitor closes demo sessions unused for DEMO_SESSION_IDLE_MS
  (default 3 min, below the supplier's 5 min CHAT_IDLE_TIMEOUT_MS) so
  abandoned one-off sessions release their suppliers quickly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 02:29:10 +02:00
david
3d2b4bb203 chore(buyer): hide Book Summarizer tab from the nav
Route and all PDF summarize code preserved; only the nav link is
commented out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:30:18 +02:00
david
4ebe7c9683 feat(gateway,supplier,shared): shared demo key with session-backed OpenAI endpoint + tool-calling
MVP demo: one operator-funded API key anyone can plug into an IDE/harness,
running real escrow-per-session marketplace conversations (llm.chat.v1)
behind the standard OpenAI /chat/completions wire shape.

- shared: ChatMessage gains tool role + tool_calls; normalizeChatMessage/
  chatMessagesEquivalent keep supplier transcript and gateway mirror
  hash-identical for receipts (field presence is part of the identity)
- supplier: /v1/chat/message accepts {messages,tools,tool_choice} alongside
  legacy {content}; callOpenAiStream forwards tools, accumulates fragmented
  delta.tool_calls, allows content-less tool turns; done frame carries the
  assistant message verbatim + finish_reason + usage
- gateway: demo flag on api_keys (additive migration), create-demo-key CLI,
  withdraw 403 + hidden deposit address for demo keys, per-IP demo rate
  limit (demo keys skip the shared per-key limit), models filtered to chat
  capability
- gateway executor (demoChat.ts): transcript-prefix affinity maps stateless
  calls onto open sessions, forwards only unseen deltas, transparently
  reopens on supplier idle-close; never falls back to one-shot (its settle
  path assumes <=1 in-flight escrow per wallet)
- mutex scope: key mutex held only for chain work (open/close); off-chain
  turns serialize on per-session locks so replicas serve users concurrently
- sweeper: chat-aware reclaim guard (35min vs 11min) and abandoned-session
  cleanup; terminal state confirmed via on-chain queryUtxo (indexer keeps
  lineage rows at their old state forever)
- deploy: kimi-chat replicas 2+3 compose files; chat idle timeout 2.5min

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:30:17 +02:00
david
b92bff56a7 feat(supplier): env-gated OpenRouter reasoning-off + max_tokens
The supplier sent a bare {model,messages,stream} to the OpenAI/OpenRouter
endpoint — no max_tokens and no reasoning control — so reasoning ("thinking")
models like kimi-k2.6 emitted reasoning tokens that could consume the
completion budget and finish with reason=length (truncated/empty answer =
failed job). deepseek-v4-flash has no thinking; kimi-k2.6 does by default.

Add two optional, env-gated request params (defaults preserve current behavior,
so ChatMock/DeepSeek-direct suppliers are unaffected):
  - OPENAI_REASONING=off  -> send reasoning:{enabled:false} (disable thinking:
    faster, cheaper, straight answers; no reasoning-starved length truncation)
  - OPENAI_MAX_TOKENS=N   -> forward max_tokens:N. Must stay below the model
    context (kimi context = 262144), so the advert's 262144 cap cannot be sent
    verbatim (provider 400s when prompt+max_tokens > context).

openai.ts builds the body conditionally; config.ts parses the env; wired
through jobRunner.ts (text) and server.ts (chat-session stream).

Deployed on the 4 kimi + 3 deepseek-flash suppliers with OPENAI_REASONING=off
and OPENAI_MAX_TOKENS=131072 (env files are host-only/gitignored).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:47:30 +02:00
david
ab649c55da feat(supplier): add 3 OpenRouter deepseek-v4-flash suppliers (deepseek-flash-1/2/3)
Add 3 single-slot suppliers serving deepseek/deepseek-v4-flash via OpenRouter,
increasing concurrent capacity for the independent buyer machines. Same shape
as the kimi replicas: capability llm.text.generate.v1, price 200000 lovelace,
max_output_tokens=262144, max_processing_ms=3600000, OPENAI_TIMEOUT_MS=3600000,
sharing the one OpenRouter key (accepted concurrency ceiling; no 429/backoff).

- deploy/mainnet/docker-compose.supplier-deepseek-flash-{1,2,3}.yml (new)
- buyer/scripts/monitor-wallets.ts: register the 3 replica wallets

Per-instance wallets, .env files, and on-chain adverts are provisioned on the
mainnet host (gitignored); the 3 containers are live and Active on-chain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:35:55 +02:00
david
99c44442e7 feat(supplier): add 3 kimi replicas + raise all kimi caps to OpenRouter max
Add 3 parallel-capacity replicas of supplier-kimi (kimi-2/3/4) so the
marketplace can serve more concurrent jobs from the independent buyer
machines. Each is a single-slot worker on the same capability
(llm.text.generate.v1) and model (moonshotai/kimi-k2.6) at the same 0.2 AP3X
flat price, sharing the one OpenRouter key (accepted concurrency ceiling).

Also bring the original supplier-kimi up to the same config: raise its advert
caps (re-posted on-chain) and OPENAI_TIMEOUT_MS to match. All four kimi
suppliers now advertise max_output_tokens=262144, max_processing_ms=3600000
(OpenRouter's ceiling for kimi-k2.6), with OPENAI_TIMEOUT_MS=3600000 so the
upstream call isn't cut short before the advertised deadline.

- deploy/mainnet/docker-compose.supplier-kimi-{2,3,4}.yml (new)
- deploy/mainnet/docker-compose.supplier-kimi.yml: OPENAI_TIMEOUT_MS 300000 -> 3600000
- buyer/scripts/monitor-wallets.ts: register the 3 replica wallets

Per-instance wallets, .env files, and on-chain adverts are provisioned on the
mainnet host (gitignored); all four containers are live and Active on-chain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:11:55 +02:00
david
9b5e5b5710 feat(gateway,buyer): self-serve "Generate API key" page in the frontend
Add an "API Keys" page to the buyer SPA (marketplace.vector.apexfusion.org)
that mints a custodial gateway API key by POSTing directly to the gateway's
public /signup, then shows the key once + deposit address + a base_url/curl
usage snippet. Page lives behind the SPA's existing operator login; per-user
registration is a later feature.

Gateway:
- GATEWAY_CORS_ORIGINS env -> corsOrigins allowlist; corsMiddleware echoes
  Access-Control-* only for allowlisted Origins, answers OPTIONS preflight
  204, no credentials. Empty allowlist = CORS off (default safe).

Buyer:
- GATEWAY_PUBLIC_URL env -> injected into __BUYER_BOOT__.gatewayUrl (only in
  the authenticated boot block). SPA resolveGatewayUrl() falls back to
  deriving "api." + location.host so the post-login-no-reload case (boot
  block absent) still works.
- New ApiKeys page + /api-keys route + nav link.

Deploy (mainnet compose): gateway GATEWAY_CORS_ORIGINS and buyer
GATEWAY_PUBLIC_URL set to the marketplace/api hosts. NOTE: both images must be
REBUILT (gateway runs tsx from copied source; buyer SPA is vite-built at image
build) — an env-only recreate would silently ship the old code.

Typecheck + vite build clean; 205 tests pass. Adversarial multi-agent review
(4 dimensions, per-finding verification) surfaced only the rebuild-required
deploy note above; no code/security defects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:43:02 +02:00
david
f4f35866f5 fix(escrow): reserve pure-ADA collateral UTxO in PostEscrow coin selection
buildLiveTxForEscrow called .complete() with no presetWalletInputs, so lucid was
free to spend the dedicated 5-ADA collateral UTxO as an ordinary input. On
back-to-back requests this left the wallet with no collateral candidate and the
subsequent Accept failed with collateral_required, stranding the escrow.

Reserve the smallest pure-ADA UTxO >= 5 ADA and fund PostEscrow from the rest
(falls back to default selection for single/large-UTxO cases, which keep an
ample pure-ADA change UTxO anyway). Benefits the buyer too (same shared path).
Adds reserveCollateralInputs unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:05:08 +02:00
david
4ba074020c fix(gateway): set mainnet VECTOR_ZERO_TIME_MS + ref-script UTxOs in compose
Without VECTOR_ZERO_TIME_MS the live tx builder defaults to the testnet genesis,
placing PostEscrow validity bounds ~51 days in the future (Ogmios 3118). Escrow
spends (Accept/Reclaim) also need ESCROW_REF_UTXO. Mirror buyer/supplier mainnet env.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 10:48:20 +02:00
david
6de62b8cf0 feat(gateway): OpenAI-compatible custodial gateway
New multi-tenant gateway package: external developers point any OpenAI SDK at
the marketplace. Each API key is its own custodial buyer wallet (AES-256-GCM
sealed under GATEWAY_MASTER_KEY); every request posts an on-chain escrow from
that user's wallet.

- POST /openai/v1/chat/completions (one escrow/call, llm.text.generate.v1,
  buffered + pseudo-stream), chat sessions (one escrow/session, llm.chat.v1,
  real SSE), GET /openai/v1/models, /signup, /account, /account/withdraw.
- Settles by resolving the Submitted ref via the indexer + awaited Accept;
  state-aware reclaim sweeper + per-wallet auto-consolidation for collateral.
- Reuses the buyer Marketplace SDK via new buyer exports ./sdk; new shared
  buildWithdrawTx (custodial exit). Runs via tsx; Dockerfile + mainnet compose.

Design in docs/gateway.md. Typecheck passes for all packages; 20 vitest tests
pass. Not yet exercised against a live chain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 10:22:58 +02:00
david
900b2259e0 fix(wallet-monitor): honest log when wallets are low-but-deduped
The no-post branch logged "all wallets healthy" even when wallets were below
threshold but simply already alerted within the reminder window — confusing in
the cron log. Now distinguish: report the low-but-deduped count vs genuinely
all-healthy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:32:16 +02:00
david
ac6689275f feat(wallet-monitor): list every wallet with full address in each alert
When an alert fires, the Slack message now includes the full roster — low (🔴)
and healthy (🟢) wallets alike — each with its complete, copy-pasteable address
(backticked), instead of only the low wallets with a truncated address. The
posting trigger and dedup/reminder logic are unchanged: still silent when all
wallets are healthy; the buyer (normally healthy) now appears for context.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:28:43 +02:00
david
06e7cd8800 chore: gitignore generated wallet key dumps and operator .bak backups
Keeps secret wallet dumps (*-wallet.json, e.g. supplier/.deepseek-wallet.json
which contains privateKeyHex) and hand-made .bak hotfix backups out of git so
operator boxes stay clean. None of these were ever tracked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:05:36 +02:00
david
3f2a37c157 feat(wallet-monitor): Slack alerts for low operator wallet balances
A one-shot monitor for the vector-marketplace box: checks each wallet in
wallets.json against a threshold (default 10 APEX) via Ogmios
(ReadOnlyOgmiosProvider) and posts a combined Slack message when any wallet is
below it. Re-reminds every 6h while a wallet stays low and posts a recovery
message when it climbs back above, deduped through a persisted state file.

Runs hourly under host cron via `docker compose run --rm` — no long-running
server, no ports/healthcheck. Reuses the buyer/supplier tsx Docker pattern and
the existing balance-query client; addresses come from a static, gitignored
wallets.json so the monitor never reads a private key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:00:30 +02:00
david
b2866506fc fix(buyer): set TTS_PIPER_BASE_URL on mainnet so demo TTS works
The mainnet buyer compose never set TTS_PIPER_BASE_URL (only testnet did),
so the buyer booted with an empty value and POST /v1/synth-speech — the
no-escrow demo proxy behind the "PiperTTS demo" tile — returned 503
"booted without TTS_PIPER_BASE_URL". Point it at the private piper backend
over internal docker DNS (buyer is already on marketplace-mainnet-net).

Verified: authenticated POST /v1/synth-speech returns 200 audio/mpeg.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:32:21 +02:00
david
599033bef1 feat(chat): animated "Thinking…" placeholder while awaiting first token
Replaces the static "…" in the streaming bubble with a "Thinking" label plus
three staggered bouncing dots, shown until the model's first token streams in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:05:27 +02:00
david
a0f6ff4444 feat(chat): block new sessions while the supplier is busy
The supplier is single-slot, but the buyer posted the escrow BEFORE learning
the supplier was busy, stranding a second user's funds in an Open escrow. Now:
- startChat does a pre-flight GET /status and refuses (409, no escrow posted)
  when the supplier is "working".
- ChatForm polls the supplier status (indexer) every 8s and disables the
  Start chat button (\"Supplier busy\") until it returns to free.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:00:26 +02:00
david
4434c787be feat(buyer): show live wallet balance on the Wallet tab
Adds GET /v1/wallet/balance (sums the buyer wallet UTxOs via
queryUtxosByAddress from Ogmios) and renders it on the Wallet page as AP3X
with a UTxO count and a refresh button.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:47:19 +02:00
david
5d2397d79b fix(buyer): endChat must not query the spent Open escrow UTxO
End chat 502'd with "escrow ref not on chain": endChat queried the original
Open escrow ref, but the supplier's Claim already spent it (Open->Claimed),
so queryUtxo returned null before the supplier was ever called. Now endChat
uses the supplier endpoint cached by the buyer-app at startChat, resolves
supplier identity from /capability for verification, and Accepts the Submitted
UTxO the supplier returns. Adds supplierBaseUrl to EndChatOptions; the
/v1/chat/end route passes it from chatRouteState (404 if the session is gone).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:36:06 +02:00
david
5ddc47f1f4 fix(buyer): drop crypto verifyReceipt from chat SDK to keep browser bundle clean
The static import of verifyReceipt pulled receipt/sign.ts (top-level
`import { createHash } from "crypto"`) into the Vite SPA bundle, breaking
`build:ui`. endChat now does structural-only receipt verification, matching
the existing submitPrompt/submitTts discipline (M1-E/M1-F note).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:28:35 +02:00
176 changed files with 15106 additions and 1216 deletions

69
.github/workflows/deploy-mainnet.yml vendored Normal file
View File

@@ -0,0 +1,69 @@
name: Deploy mainnet
# CD for the vector-marketplace host (91.98.147.172).
#
# Trigger: automatically after the "CI" workflow succeeds on main, or manually
# via workflow_dispatch (Actions tab -> Deploy mainnet -> Run workflow).
#
# The runner only SSHes in and executes deploy/mainnet/deploy.sh AT THE NEW
# COMMIT (fetched server-side via `git show FETCH_HEAD:`), so deploy logic is
# versioned with the code it deploys. The script itself decides which compose
# projects to rebuild based on the old..new diff, drains busy suppliers, and
# health-gates every restart — see deploy/mainnet/deploy.sh.
#
# Secrets: DEPLOY_SSH_KEY — ed25519 private key whose public half is in
# root@vector-marketplace:~/.ssh/authorized_keys.
on:
workflow_run:
workflows: ["CI"]
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
force:
description: "Redeploy even if the server is already at origin/main"
type: boolean
default: false
concurrency:
group: deploy-mainnet
cancel-in-progress: false # never kill a deploy mid-rollout
env:
DEPLOY_HOST: 91.98.147.172
DEPLOY_USER: root
# Pinned host key (ssh-keyscan -t ed25519 91.98.147.172)
DEPLOY_HOSTKEY: "91.98.147.172 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJvjM8D+pNUb2Qr4yJbqVcRrUzbpbfyv0/tupUgD5Th0"
jobs:
deploy:
name: deploy to vector-marketplace
runs-on: nixos
# workflow_run fires on failure too — only deploy green main.
if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
steps:
# Self-hosted runner: keep key material in the job's temp dir, not the
# runner user's ~/.ssh (which persists across jobs).
- name: Set up SSH
run: |
install -m 700 -d "$RUNNER_TEMP/ssh"
printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY }}" > "$RUNNER_TEMP/ssh/id_ed25519"
chmod 600 "$RUNNER_TEMP/ssh/id_ed25519"
printf '%s\n' "$DEPLOY_HOSTKEY" > "$RUNNER_TEMP/ssh/known_hosts"
- name: Run deploy script on server
run: |
FORCE=0
[ "${{ github.event.inputs.force }}" = "true" ] && FORCE=1
ssh -o BatchMode=yes \
-o IdentitiesOnly=yes \
-i "$RUNNER_TEMP/ssh/id_ed25519" \
-o UserKnownHostsFile="$RUNNER_TEMP/ssh/known_hosts" \
"$DEPLOY_USER@$DEPLOY_HOST" "
set -euo pipefail
cd /root/agents-marketplace
git fetch origin main
git show FETCH_HEAD:deploy/mainnet/deploy.sh > /run/marketplace-deploy.sh
FORCE=$FORCE bash /run/marketplace-deploy.sh
"

14
.gitignore vendored
View File

@@ -11,9 +11,21 @@ coverage
!.env.*.example
# Local data / caches (matches at any depth — covers
# deploy/simulation/data/, indexer/data/, etc.)
# deploy/simulation/data/, indexer/data/, wallet-monitor/data/, etc.)
data/
*.log
# wallet-monitor: the real wallet list holds production mainnet addresses —
# keep it out of git; only wallets.example.json is committed.
wallet-monitor/wallets.json
# Generated wallet key dumps (e.g. tx:gen-keypair output, supplier/.deepseek-wallet.json)
# — these contain privateKeyHex. Never commit.
*-wallet.json
# Operator local file backups (e.g. docker-compose.buyer.yml.bak-20260609)
*.bak
*.bak-*
.vitest-cache
.DS_Store

View File

@@ -1,3 +1,36 @@
## Local Agents Marketplace
# Local Agents Marketplace
Local Agents Marketplace MVP. See `docs/ARCHITECTURE.md` for the architecture spec. Monorepo managed with pnpm workspaces. Work-in-progress; not production-ready.
Bonded-escrow coordination for AI agent work on **Vector**, the Apex Fusion eUTXO L2.
Buyers commission work. Suppliers claim it. Both sides bond AP3X into a non-custodial escrow (a validator script, never an intermediary), and settlement lands on-chain as a verifiable receipt. Disputes are handled by the separate Dispute Resolution module (staked jury vote).
**Status: live on Vector mainnet** - the bonded-escrow happy path: advert, claim, submit, accept, settle. The contracts have been through the internal audit pipeline (methodology published in [vector-ai-agents](https://github.com/Apex-Fusion/vector-ai-agents)); they have not yet undergone independent third-party audit.
## Flow
1. **Advert** - a buyer posts a job with escrow bonded
2. **Claim** - a supplier claims it, bonding their side
3. **Submit** - work is delivered
4. **Accept** - the buyer accepts the work
5. **Settle** - the escrow releases; the settlement is a signed on-chain receipt
## Repository layout
| Path | What |
|---|---|
| `contracts/advert`, `contracts/escrow`, `contracts/marketplace` | Aiken validators |
| `docs/ARCHITECTURE.md` | Architecture spec |
| `docs/buyer/`, `docs/supplier/` | Role guides |
| `deploy/` | Deployment and simulation tooling |
Monorepo managed with pnpm workspaces.
## Getting started
Developer docs: [Vector AI documentation](https://apex-fusion.github.io/vector-ai-documentation/). For the contract-level picture, start with [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
## Related
- [mcp-server](https://github.com/Apex-Fusion/mcp-server) - hosted MCP server for Vector (testnet and mainnet)
- [agent-sdk-py](https://github.com/Apex-Fusion/agent-sdk-py) / [agent-sdk-ts](https://github.com/Apex-Fusion/agent-sdk-ts) - Python and TypeScript SDKs
- [vector-agent-modules](https://github.com/Apex-Fusion/vector-agent-modules) - dispute resolution, reputation staking, self-improvement

View File

@@ -35,6 +35,8 @@ RUN apk add --no-cache python3 make g++
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./
# pnpm-lock.yaml's patchedDependencies hash the patch files at install time.
COPY patches ./patches
COPY packages/shared/package.json ./packages/shared/package.json
COPY buyer/package.json ./buyer/package.json
COPY supplier/package.json ./supplier/package.json

View File

@@ -3,6 +3,9 @@
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./sdk": "./src/sdk/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"build:ui": "vite build --config vite.config.ts",

View File

@@ -83,6 +83,48 @@ const OPERATOR_SOURCES: OperatorSource[] = [
keyVar: "SUPPLIER_PRIV_KEY_HEX",
role: "supplier",
},
{
label: "supplier-kimi-2@vector-marketplace",
host: "root@91.98.147.172",
envPath: "/root/agents-marketplace/supplier/.env.kimi-2",
keyVar: "SUPPLIER_PRIV_KEY_HEX",
role: "supplier",
},
{
label: "supplier-kimi-3@vector-marketplace",
host: "root@91.98.147.172",
envPath: "/root/agents-marketplace/supplier/.env.kimi-3",
keyVar: "SUPPLIER_PRIV_KEY_HEX",
role: "supplier",
},
{
label: "supplier-kimi-4@vector-marketplace",
host: "root@91.98.147.172",
envPath: "/root/agents-marketplace/supplier/.env.kimi-4",
keyVar: "SUPPLIER_PRIV_KEY_HEX",
role: "supplier",
},
{
label: "supplier-deepseek-flash-1@vector-marketplace",
host: "root@91.98.147.172",
envPath: "/root/agents-marketplace/supplier/.env.deepseek-flash-1",
keyVar: "SUPPLIER_PRIV_KEY_HEX",
role: "supplier",
},
{
label: "supplier-deepseek-flash-2@vector-marketplace",
host: "root@91.98.147.172",
envPath: "/root/agents-marketplace/supplier/.env.deepseek-flash-2",
keyVar: "SUPPLIER_PRIV_KEY_HEX",
role: "supplier",
},
{
label: "supplier-deepseek-flash-3@vector-marketplace",
host: "root@91.98.147.172",
envPath: "/root/agents-marketplace/supplier/.env.deepseek-flash-3",
keyVar: "SUPPLIER_PRIV_KEY_HEX",
role: "supplier",
},
{
label: "buyer@open-webui",
host: "open-webui",

View File

@@ -36,6 +36,12 @@
* Defaults to "./data/archive" relative to cwd.
* When unset, archive is disabled (responses are
* not persisted; /v1/responses* return 503).
* GATEWAY_PUBLIC_URL — Public base URL of the OpenAI-compatible gateway
* (e.g. https://api.marketplace.vector.apexfusion.org).
* Injected into the SPA boot block so the "Generate
* API key" page knows where to POST /signup. When
* unset, the SPA falls back to deriving "api." +
* current host.
*/
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
@@ -56,6 +62,7 @@ export interface BuyerConfig {
password: string;
sessionSecret: string;
cookieSecure: boolean;
gatewayPublicUrl: string;
}
function requireField(env: Record<string, string | undefined>, name: string): string {
@@ -141,6 +148,11 @@ export function loadConfig(env: Record<string, string | undefined>): BuyerConfig
cookieSecure = cookieSecureStr === "1";
}
// GATEWAY_PUBLIC_URL — optional. Public base URL of the gateway, surfaced to
// the SPA via the boot block (no trailing slash). Empty string means the SPA
// derives the URL from its own host ("api." + location.host).
const gatewayPublicUrl = (env.GATEWAY_PUBLIC_URL ?? "").trim().replace(/\/+$/, "");
return {
privKeyHex,
indexerUrl,
@@ -155,5 +167,6 @@ export function loadConfig(env: Record<string, string | undefined>): BuyerConfig
password,
sessionSecret,
cookieSecure,
gatewayPublicUrl,
};
}

View File

@@ -133,6 +133,7 @@ export async function runMain(env: Record<string, string | undefined>): Promise<
password: config.password,
sessionSecret: config.sessionSecret,
cookieSecure: config.cookieSecure,
gatewayPublicUrl: config.gatewayPublicUrl,
jobStore,
pdfCaps,
});

View File

@@ -73,14 +73,15 @@ import {
buildPostEscrowTx,
buildPostTtsEscrowTx,
buildPostChatEscrowTx,
buildPostOcrEscrowTx,
ttsPromptHash,
chatSessionPromptHash,
ocrPromptHash,
buildAcceptTx,
buildReclaimTx,
TxConstructionError,
} from "@marketplace/shared/tx";
import { decodeAdvertDatum, decodeEscrowDatum, canonicalize } from "@marketplace/shared/cbor";
import { verifyReceipt } from "@marketplace/shared/receipt";
import type { AdvertDatum } from "@marketplace/shared/cbor";
import type {
SupplierView,
@@ -89,8 +90,11 @@ import type {
SubmitPromptResult,
SubmitTtsOptions,
SubmitTtsResult,
SubmitOcrOptions,
SubmitOcrResult,
StartChatOptions,
StartChatResult,
ChatSettleMode,
EndChatOptions,
EndChatResult,
AcceptResultOptions,
@@ -807,6 +811,278 @@ export class Marketplace extends EventEmitterBase {
};
}
// ─── submitOcr — full lifecycle for ocr.page.extract.<model-slug>.v1 ──
async submitOcr(opts: SubmitOcrOptions): Promise<SubmitOcrResult> {
const { advertRef, image_b64, mime, output_format, payment_lovelace } = opts;
const request = { image_b64, mime, output_format };
let advertDatum: AdvertDatum | null = null;
let escrowOutputRef: OutputReference | null = null;
let escrowRefStr = "";
let postedAtMs = Date.now();
const previewStr = `[ocr:${mime} ${output_format} ${image_b64.length}b64]`;
const recordFailure = (reason: string): void => {
this.historyStore.save({
escrow_ref: escrowRefStr || `${"0".repeat(64)}#0`,
supplier_pkh: advertDatum?.supplier_pkh ?? "",
capability_id: advertDatum?.capability_id ?? "",
prompt_preview: previewStr,
posted_at: postedAtMs,
status: "failed",
failure_reason: reason,
});
};
// ── 1. Resolve advert + post escrow (OCR prompt_hash) ─────────────
let escrowResult;
try {
const utxo = await this.chain.queryUtxo(advertRef);
if (utxo && utxo.datumHex) {
try {
advertDatum = decodeAdvertDatum(utxo.datumHex);
} catch { /* builder will re-throw structurally */ }
}
escrowResult = await buildPostOcrEscrowTx({
chain: this.chain,
buyerKey: this.walletKey,
advertRef,
request,
payment_lovelace,
});
} catch (err) {
if (err instanceof TxConstructionError) {
recordFailure(err.reason);
throw err;
}
this.emitProgress({ type: "chain_submit_failed", detail: (err as Error).message });
recordFailure((err as Error).message);
throw err;
}
escrowOutputRef = escrowResult.escrowOutputRef;
escrowRefStr = refToString(escrowOutputRef);
this.emitProgress({ type: "escrow_posted", escrow_ref: escrowRefStr });
// ── 2. Wait for the escrow tx to confirm ──────────────────────────
try {
await this.chain.awaitTx(escrowResult.expectedTxHash, 120_000);
} catch { /* mock providers / tests */ }
// ── 3. Re-fetch escrow datum to capture posted_at ─────────────────
try {
const escrowUtxo = await this.chain.queryUtxo(escrowOutputRef);
if (escrowUtxo && escrowUtxo.datumHex) {
const ed = decodeEscrowDatum(escrowUtxo.datumHex);
postedAtMs = ed.posted_at;
}
} catch { /* metadata best-effort */ }
if (!advertDatum) {
const reason = "advert datum unavailable after escrow post";
recordFailure(reason);
throw new ReceiptVerificationError(reason);
}
// ── 4. Call supplier /v1/ocr/extract ──────────────────────────────
const supplierBaseUrl = advertDatum.endpoint_url;
const supplierHttp = new HttpClient({
baseUrl: supplierBaseUrl,
fetch: this.fetchImpl,
});
const ocrBody = { image_b64, mime, output_format };
let ocrResult;
try {
ocrResult = await supplierHttp.postJson("/v1/ocr/extract", ocrBody, {
headers: { "X-Escrow-Ref": escrowRefStr },
});
} catch (err) {
if (err instanceof HttpError) {
const reason = err.kind === "timeout" ? "timeout" : "network_error";
const sErr = new SupplierError(reason, { message: err.message });
recordFailure(reason);
throw sErr;
}
recordFailure((err as Error).message);
throw err;
}
// 202 + poll mirror of submitTts's async handling. OCR inference for a
// page runs seconds; the long tail is the Submit tx confirmation.
if (ocrResult.status === 202 && ocrResult.body && typeof ocrResult.body === "object") {
const jobId = (ocrResult.body as { job_id?: string }).job_id;
if (typeof jobId !== "string" || jobId.length === 0) {
const sErr = new SupplierError("malformed_response", {
status: ocrResult.status,
message: "supplier 202 response missing job_id",
});
recordFailure("malformed_response");
throw sErr;
}
const POLL_INTERVAL_MS = 2_000;
const POLL_TIMEOUT_MS = 180_000;
const pollDeadline = Date.now() + POLL_TIMEOUT_MS;
let polled = ocrResult;
let firstIter = true;
while (Date.now() < pollDeadline) {
if (!firstIter) {
await new Promise<void>((r) => setTimeout(r, POLL_INTERVAL_MS));
}
firstIter = false;
try {
polled = await supplierHttp.getJson(`/v1/ocr/extract/${jobId}`);
} catch (err) {
if (err instanceof HttpError) {
const reason = err.kind === "timeout" ? "timeout" : "network_error";
const sErr = new SupplierError(reason, { message: err.message });
recordFailure(reason);
throw sErr;
}
throw err;
}
if (polled.status === 200) break;
if (polled.status === 202) continue;
const failBody = (polled.body && typeof polled.body === "object")
? polled.body as { reason?: string; message?: string }
: {};
const sErr = new SupplierError(failBody.reason ?? "supplier_http_error", {
status: polled.status,
message: failBody.message ?? `supplier returned ${polled.status}`,
});
recordFailure(sErr.reason);
throw sErr;
}
if (polled.status !== 200) {
const sErr = new SupplierError("timeout", {
status: polled.status,
message: `supplier job ${jobId} not done within ${POLL_TIMEOUT_MS}ms`,
});
recordFailure("timeout");
throw sErr;
}
ocrResult = polled;
}
if (!ocrResult.ok) {
const bodyReason =
ocrResult.body && typeof ocrResult.body === "object"
? ((ocrResult.body as { reason?: string }).reason ?? "supplier_http_error")
: "supplier_http_error";
const sErr = new SupplierError(bodyReason, {
status: ocrResult.status,
message: `supplier returned ${ocrResult.status}`,
});
recordFailure(bodyReason);
throw sErr;
}
if (ocrResult.parseError || !ocrResult.body || typeof ocrResult.body !== "object") {
const sErr = new SupplierError("malformed_response", {
status: ocrResult.status,
message: "supplier body is not valid JSON",
});
recordFailure("malformed_response");
throw sErr;
}
const responseBody = ocrResult.body as {
output_format?: string;
content?: string;
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
receipt?: import("@marketplace/shared/receipt").Receipt;
receipt_signature?: string;
};
if (!responseBody.content || !responseBody.receipt || !responseBody.receipt_signature) {
const sErr = new SupplierError("malformed_response", {
status: ocrResult.status,
message: "supplier response is missing content / receipt / receipt_signature",
});
recordFailure("malformed_response");
throw sErr;
}
const receipt = responseBody.receipt;
const receiptSignature = responseBody.receipt_signature;
this.emitProgress({ type: "supplier_called", escrow_ref: escrowRefStr });
// ── 5. Receipt verification ───────────────────────────────────────
if (receipt.supplier_pkh !== advertDatum.supplier_pkh) {
recordFailure("wrong_supplier");
throw new ReceiptVerificationError("wrong_supplier");
}
if (receipt.escrow_ref !== escrowRefStr) {
recordFailure("wrong_escrow_ref");
throw new ReceiptVerificationError("wrong_escrow_ref");
}
// The OCR prompt commitment uses the SAME canonical hash the supplier
// validated against the escrow datum. Recompute from the envelope we
// sent; a mismatch means the supplier signed a different request.
const expectedPromptHash = ocrPromptHash(request);
if (receipt.prompt_hash !== expectedPromptHash) {
recordFailure("prompt_hash_mismatch");
throw new ReceiptVerificationError("prompt_hash_mismatch");
}
if (receipt.model !== advertDatum.model) {
recordFailure("request_spec_hash_mismatch");
throw new ReceiptVerificationError("request_spec_hash_mismatch");
}
// The receipt's response_hash must commit the content we actually
// received in the requested output shape.
const expectedResponseHash = nodeCrypto.createHash("sha256")
.update(canonicalize({
content: responseBody.content,
output_format: responseBody.output_format ?? output_format,
}), "utf8")
.digest("hex");
if (receipt.response_hash !== expectedResponseHash) {
recordFailure("response_hash_mismatch");
throw new ReceiptVerificationError("response_hash_mismatch");
}
if (typeof receiptSignature !== "string" || !SIG_RE.test(receiptSignature)) {
recordFailure("invalid_signature");
throw new ReceiptVerificationError("invalid_signature");
}
if (receiptSignature === ZERO_SIGNATURE) {
recordFailure("invalid_signature");
throw new ReceiptVerificationError("invalid_signature");
}
if (!HEX64_RE.test(receipt.prompt_hash) || !HEX64_RE.test(receipt.response_hash)) {
recordFailure("malformed_receipt");
throw new ReceiptVerificationError("malformed_receipt");
}
this.emitProgress({ type: "receipt_verified", escrow_ref: escrowRefStr });
this.historyStore.save({
escrow_ref: escrowRefStr,
supplier_pkh: advertDatum.supplier_pkh,
capability_id: advertDatum.capability_id,
prompt_preview: previewStr,
posted_at: postedAtMs,
status: "completed",
response: responseBody.content.length <= 200
? responseBody.content
: responseBody.content.slice(0, 200),
receipt,
receipt_signature: receiptSignature,
});
return {
output_format: responseBody.output_format ?? output_format,
content: responseBody.content,
usage: {
prompt_tokens: responseBody.usage?.prompt_tokens ?? 0,
completion_tokens: responseBody.usage?.completion_tokens ?? 0,
total_tokens: responseBody.usage?.total_tokens ?? 0,
},
receipt,
receiptSignature,
escrowRef: escrowOutputRef,
};
}
// ─── startChat / endChat — multi-turn llm.chat.v1 lifecycle ────────────
/**
@@ -823,6 +1099,30 @@ export class Marketplace extends EventEmitterBase {
}
const advertDatum = decodeAdvertDatum(advertUtxo.datumHex);
const supplierHttp = new HttpClient({ baseUrl: advertDatum.endpoint_url, fetch: this.fetchImpl });
// Pre-flight: refuse to lock any funds if the supplier is already serving
// another chat. The supplier is single-slot, so a second concurrent escrow
// would only 409 at Claim and be left stranded Open with funds locked.
// Checking /status first keeps the buyer's funds unlocked. Best-effort: if
// /status is unreachable we proceed, and the supplier's Claim still guards
// the slot.
try {
const statusRes = await supplierHttp.getJson("/status");
const liveStatus = statusRes.body && typeof statusRes.body === "object"
? (statusRes.body as { status?: string }).status
: undefined;
if (liveStatus === "working") {
throw new SupplierError("supplier_busy", {
status: 409,
message: "supplier is busy with another chat session",
});
}
} catch (err) {
if (err instanceof SupplierError) throw err;
/* /status unreachable — proceed; Claim still enforces the single slot */
}
const sessionNonce = randomNonce();
let escrowResult;
try {
@@ -851,7 +1151,6 @@ export class Marketplace extends EventEmitterBase {
/* mock providers / tests */
}
const supplierHttp = new HttpClient({ baseUrl: advertDatum.endpoint_url, fetch: this.fetchImpl });
let startRes;
try {
startRes = await supplierHttp.postJson(
@@ -877,7 +1176,13 @@ export class Marketplace extends EventEmitterBase {
}
this.emitProgress({ type: "chat_started", escrow_ref: escrowRefStr });
return { escrowRef: escrowOutputRef, sessionNonce, supplierBaseUrl: advertDatum.endpoint_url };
// Ticket-mode suppliers answer {status:"ticket"} (no Claim); anything else
// — "claimed" or an older supplier without the field — is full settlement.
const startStatus = startRes.body && typeof startRes.body === "object"
? (startRes.body as { status?: string }).status
: undefined;
const settleMode: ChatSettleMode = startStatus === "ticket" ? "ticket" : "full";
return { escrowRef: escrowOutputRef, sessionNonce, supplierBaseUrl: advertDatum.endpoint_url, settleMode };
}
/**
@@ -886,22 +1191,15 @@ export class Marketplace extends EventEmitterBase {
* Submitted escrow — which is when the user is actually charged.
*/
async endChat(opts: EndChatOptions): Promise<EndChatResult> {
const { escrowRef, sessionNonce, transcript } = opts;
const { escrowRef, sessionNonce, transcript, supplierBaseUrl } = opts;
const escrowRefStr = refToString(escrowRef);
// Resolve escrow → advert for endpoint_url, supplier_pkh, model, pub key.
const escrowUtxo = await this.chain.queryUtxo(escrowRef);
if (!escrowUtxo || !escrowUtxo.datumHex) {
throw new TxConstructionError("escrow ref not on chain", `no escrow UTxO at ${escrowRefStr}`);
}
const escrowDatum = decodeEscrowDatum(escrowUtxo.datumHex);
const advertUtxo = await this.chain.queryUtxo(escrowDatum.advert_ref);
if (!advertUtxo || !advertUtxo.datumHex) {
throw new TxConstructionError("advert ref not on chain", "advert UTxO for this escrow is missing");
}
const advertDatum = decodeAdvertDatum(advertUtxo.datumHex);
const supplierHttp = new HttpClient({ baseUrl: advertDatum.endpoint_url, fetch: this.fetchImpl });
// The original Open escrow UTxO has ALREADY been spent by the supplier's
// Claim (Open→Claimed) by the time End runs, so we must NOT queryUtxo it
// (that was the cause of the "escrow ref not on chain" 502). Use the
// supplier endpoint the buyer-app cached from startChat, and resolve
// supplier identity (pkh, model) from /capability for verification.
const supplierHttp = new HttpClient({ baseUrl: supplierBaseUrl, fetch: this.fetchImpl });
let endRes;
try {
endRes = await supplierHttp.postJson("/v1/chat/end", {}, { headers: { "X-Escrow-Ref": escrowRefStr } });
@@ -922,18 +1220,43 @@ export class Marketplace extends EventEmitterBase {
});
}
const endBody = endRes.body as {
status?: string;
settle_mode?: string;
receipt?: import("@marketplace/shared/receipt").Receipt;
receipt_signature?: string;
submitted_ref?: string;
};
// Ticket sessions produce no receipt and nothing to Accept — the Open
// escrow returns via reclaim after deliver_by. Trusting the supplier's
// response is incentive-safe: lying in either direction only forfeits the
// supplier's own payout (the buyer's sweeper reclaims unsettled escrows).
if (endBody.settle_mode === "ticket") {
this.emitProgress({ type: "chat_ended", escrow_ref: escrowRefStr });
return { settleMode: "ticket", escrowRef };
}
if (!endBody.receipt || !endBody.receipt_signature) {
throw new SupplierError("malformed_response", { message: "supplier end response missing receipt" });
}
const receipt = endBody.receipt;
const receiptSignature = endBody.receipt_signature;
// ── Resolve supplier identity from /capability for verification ───────
let capSupplierPkh = "";
let capModel = "";
try {
const capRes = await supplierHttp.getJson("/capability");
if (capRes.body && typeof capRes.body === "object") {
capSupplierPkh = (capRes.body as { supplier_pkh?: string }).supplier_pkh ?? "";
capModel = (capRes.body as { model?: string }).model ?? "";
}
} catch {
/* best-effort; the escrow_ref + prompt_hash + signature checks below,
plus the on-chain Accept (which routes funds per the Submitted datum),
still hold even if /capability is briefly unavailable */
}
// ── Verify receipt ───────────────────────────────────────────────────
if (receipt.supplier_pkh !== escrowDatum.supplier_pkh) {
if (capSupplierPkh && receipt.supplier_pkh !== capSupplierPkh) {
throw new ReceiptVerificationError("wrong_supplier");
}
if (receipt.escrow_ref !== escrowRefStr) {
@@ -943,7 +1266,7 @@ export class Marketplace extends EventEmitterBase {
if (receipt.prompt_hash !== chatSessionPromptHash({ session_nonce: sessionNonce })) {
throw new ReceiptVerificationError("prompt_hash_mismatch");
}
if (receipt.model !== advertDatum.model) {
if (capModel && receipt.model !== capModel) {
throw new ReceiptVerificationError("request_spec_hash_mismatch");
}
if (typeof receiptSignature !== "string" || !SIG_RE.test(receiptSignature) || receiptSignature === ZERO_SIGNATURE) {
@@ -961,21 +1284,13 @@ export class Marketplace extends EventEmitterBase {
);
}
}
// Cryptographic verification via the supplier's published pub key (best-effort).
try {
const capRes = await supplierHttp.getJson("/capability");
const pubKeyHex = capRes.body && typeof capRes.body === "object"
? (capRes.body as { pub_key_hex?: string }).pub_key_hex
: undefined;
if (typeof pubKeyHex === "string" && /^[0-9a-fA-F]{64}$/.test(pubKeyHex)) {
if (!verifyReceipt({ receipt, signature: receiptSignature }, pubKeyHex)) {
throw new ReceiptVerificationError("invalid_signature");
}
}
} catch (err) {
if (err instanceof ReceiptVerificationError) throw err;
/* /capability unreachable — structural verification stands */
}
// NOTE: cryptographic Ed25519 verifyReceipt is intentionally NOT called
// here. It lives in @marketplace/shared/receipt/sign.ts, whose top-level
// `import { createHash } from "crypto"` cannot be bundled into the browser
// SPA (Vite externalizes `crypto`). The one-off submitPrompt/submitTts
// paths take the same structural-only stance (see the M1-E/M1-F note at the
// top of this file). The structural checks above + the on-chain
// result_receipt_hash commitment are the v1 verification surface.
this.emitProgress({ type: "receipt_verified", escrow_ref: escrowRefStr });
@@ -1000,7 +1315,7 @@ export class Marketplace extends EventEmitterBase {
this.emitProgress({ type: "accept_submitted", escrow_ref: refToString(acceptedRef) });
this.emitProgress({ type: "chat_ended", escrow_ref: escrowRefStr });
return { receipt, receiptSignature, escrowRef, acceptedRef };
return { settleMode: "full", receipt, receiptSignature, escrowRef, acceptedRef };
}
// ─── acceptResult / reclaim ───────────────────────────────────────────

View File

@@ -16,6 +16,9 @@ export type {
AcceptResultOptions,
ReclaimOptions,
GetTaskHistoryOptions,
ChatSettleMode,
StartChatResult,
EndChatResult,
} from "./types.js";
export { ReceiptVerificationError, IndexerError, SupplierError } from "./types.js";
export type { TaskHistoryStore } from "./history.js";

View File

@@ -187,6 +187,35 @@ export interface SubmitTtsResult {
escrowRef: OutputReference;
}
/**
* SubmitOcrOptions — arguments to Marketplace.submitOcr() for model-scoped
* `ocr.page.extract.<model-slug>.v1` capabilities. The same `{image_b64,
* mime, output_format}` envelope is hashed (sha256 ∘ canonical) into the
* escrow datum's `prompt_hash` AND posted to the supplier's /v1/ocr/extract
* body, so the supplier can verify the request matches the on-chain commit.
*/
export interface SubmitOcrOptions {
advertRef: OutputReference;
image_b64: string; // plain base64, no data: prefix
mime: string; // image/png | image/jpeg | image/webp
output_format: string; // markdown | html | json
payment_lovelace: bigint;
}
/**
* SubmitOcrResult — returned from Marketplace.submitOcr() on success.
* `content` is the extraction in the requested output_format; the receipt's
* response_hash commits sha256(canonical({content, output_format})).
*/
export interface SubmitOcrResult {
output_format: string;
content: string;
usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
receipt: Receipt;
receiptSignature: string;
escrowRef: OutputReference;
}
/**
* StartChatOptions / StartChatResult — Marketplace.startChat() for the
* `llm.chat.v1` capability. Opens the escrow (session-init prompt_hash) and
@@ -197,6 +226,11 @@ export interface StartChatOptions {
advertRef: OutputReference;
payment_lovelace: bigint;
}
/** Chain settlement mode a chat session runs under (reported by the supplier
* at /v1/chat/start): "full" = Claim/Submit/Accept lifecycle; "ticket" = the
* buyer's escrow post is the only chain op (reclaimed after deliver_by). */
export type ChatSettleMode = "full" | "ticket";
export interface StartChatResult {
escrowRef: OutputReference;
/** Random nonce committed into the escrow's session-init prompt_hash. The
@@ -204,6 +238,8 @@ export interface StartChatResult {
sessionNonce: string;
/** Supplier endpoint_url, cached by the buyer-app for message routing. */
supplierBaseUrl: string;
/** How the supplier settles this session ("full" when it Claimed). */
settleMode: ChatSettleMode;
}
/**
@@ -213,16 +249,28 @@ export interface StartChatResult {
export interface EndChatOptions {
escrowRef: OutputReference;
sessionNonce: string;
/** Supplier endpoint_url cached by the buyer-app from startChat. Required
* because the Open escrow UTxO is already spent (Claimed) by End time, so we
* resolve the supplier via its cached URL + /capability rather than chain. */
supplierBaseUrl: string;
/** The browser's local transcript mirror, used to verify response_hash. */
transcript?: import("@marketplace/shared/tx").ChatMessage[];
}
export interface EndChatResult {
receipt: Receipt;
receiptSignature: string;
escrowRef: OutputReference;
/** The Submitted escrow UTxO that was Accepted. */
acceptedRef: OutputReference;
}
export type EndChatResult =
| {
settleMode: "full";
receipt: Receipt;
receiptSignature: string;
escrowRef: OutputReference;
/** The Submitted escrow UTxO that was Accepted. */
acceptedRef: OutputReference;
}
| {
/** Ticket session: nothing was Submitted/Accepted — no receipt exists.
* The Open escrow returns to the buyer via reclaim after deliver_by. */
settleMode: "ticket";
escrowRef: OutputReference;
};
/**
* AcceptResultOptions — arguments to Marketplace.acceptResult().

View File

@@ -109,6 +109,11 @@ export interface AppDeps {
* brute-force rate-limit window. Defaults to Date.now. Tests use a fake
* clock to drive expiry and rolling-renewal assertions deterministically. */
nowMs?: () => number;
/** Public base URL of the OpenAI-compatible gateway, injected into the SPA
* boot block so the "Generate API key" page can POST cross-origin to
* `${gatewayUrl}/signup`. Public, non-secret value. When empty/omitted the
* SPA derives "api." + its own host. */
gatewayPublicUrl?: string;
/** PDF book summarizer job registry. When provided, the /v1/pdf-* routes
* are live; when omitted they respond 503 (feature disabled). Built in
* runMain from the same marketplace/chain/wallet/archive deps. */
@@ -379,6 +384,37 @@ export function createApp(deps: AppDeps): Express {
}
});
// ── GET /v1/wallet/balance — live buyer-wallet balance from Ogmios.
// Sums lovelace across all UTxOs at the buyer's address. The Wallet page
// renders this as AP3X (lovelace / 1e6).
app.get("/v1/wallet/balance", async (_req: Request, res: Response) => {
if (!resolved) {
return jsonError(
res,
503,
"service_unavailable",
"buyer-app booted without chain deps; /v1/wallet/balance disabled",
);
}
try {
const utxos = await resolved.chain.queryUtxosByAddress(resolved.walletKey.address);
let lovelace = 0n;
for (const u of utxos) lovelace += u.lovelace;
return res.status(200).json({
address: resolved.walletKey.address,
lovelace: lovelace.toString(),
utxo_count: utxos.length,
});
} catch (err) {
return jsonError(
res,
502,
"chain_unreachable",
err instanceof Error ? err.message : String(err),
);
}
});
// ── /v1/indexer/* — generic GET passthrough to the internal indexer.
// The SPA's SDK is configured with `indexerUrl = ${origin}/v1/indexer`,
// so calls like `/suppliers`, `/escrows?buyer=…`, `/capabilities` land
@@ -649,9 +685,11 @@ export function createApp(deps: AppDeps): Express {
const DEMO_CHAT_MODEL = "moonshotai/kimi-k2.6";
function isChatMessageArray(v: unknown): v is ChatMessage[] {
// The free chat demo stays text-only: the `tool` role (added to
// ChatMessage for the gateway demo endpoint) is not accepted here.
return Array.isArray(v) && v.length > 0 && v.every(
(m) => m && typeof m === "object"
&& (["system", "user", "assistant"] as const).includes((m as ChatMessage).role)
&& ["system", "user", "assistant"].includes((m as ChatMessage).role)
&& typeof (m as ChatMessage).content === "string",
);
}
@@ -881,11 +919,29 @@ export function createApp(deps: AppDeps): Express {
const transcript = Array.isArray(body.transcript)
? (body.transcript as ChatMessage[])
: undefined;
const state = chatRouteState.get(rawRef);
if (!state) {
return jsonError(res, 404, "chat_session_not_found",
"no active chat session for this escrow (the buyer-app may have restarted); please start a new chat");
}
const m = ESCROW_REF_RE.exec(rawRef)!;
const escrowRef = { txHash: m[1], index: Number(m[2]) };
try {
const result = await deps.marketplace.endChat({ escrowRef, sessionNonce, transcript });
const result = await deps.marketplace.endChat({
escrowRef,
sessionNonce,
transcript,
supplierBaseUrl: state.supplierBaseUrl,
});
chatRouteState.delete(rawRef);
if (result.settleMode === "ticket") {
// Ticket session: no receipt/Accept — funds return via reclaim.
return res.status(200).json({
status: "ended",
escrow_ref: rawRef,
settle_mode: "ticket",
});
}
return res.status(200).json({
status: "accepted",
escrow_ref: rawRef,
@@ -1269,6 +1325,9 @@ export function createApp(deps: AppDeps): Express {
pubKeyHash: resolved.walletKey.pubKeyHash,
address: displayAddress,
},
// Public gateway base URL for the "Generate API key" page. Empty
// string when unconfigured — the SPA then derives "api." + host.
gatewayUrl: deps.gatewayPublicUrl ?? "",
})};</script>`
: "";
const indexWithBoot = cachedTemplate.replace(

View File

@@ -6,6 +6,7 @@
* /tasks → <TaskHistory />
* /pending → <PendingReceipts /> UX-1: buyer-side Accept button
* /wallet → <Wallet />
* /api-keys → <ApiKeys /> self-serve gateway API key generation
*/
import { Routes, Route, Link } from "react-router-dom";
@@ -14,6 +15,7 @@ import TaskHistory from "./pages/TaskHistory.js";
import Wallet from "./pages/Wallet.js";
import PendingReceipts from "./pages/PendingReceipts.js";
import BookSummarizer from "./pages/BookSummarizer.js";
import ApiKeys from "./pages/ApiKeys.js";
import { RequireAuth, useAuth } from "./state/AuthContext.js";
function SignOutButton() {
@@ -35,10 +37,13 @@ export default function App() {
<div className="min-h-screen bg-gray-50 text-gray-900">
<nav className="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-6">
<Link to="/" className="font-medium hover:underline">Dashboard</Link>
<Link to="/book" className="font-medium hover:underline">Book Summarizer</Link>
{/* Book Summarizer tab hidden for now — the /book route and all PDF
summarize code remain intact; restore by re-adding this link:
<Link to="/book" className="font-medium hover:underline">Book Summarizer</Link> */}
<Link to="/tasks" className="font-medium hover:underline">Tasks</Link>
<Link to="/pending" className="font-medium hover:underline">Pending</Link>
<Link to="/wallet" className="font-medium hover:underline">Wallet</Link>
<Link to="/api-keys" className="font-medium hover:underline">API Keys</Link>
<SignOutButton />
</nav>
<main className="p-6">
@@ -48,6 +53,7 @@ export default function App() {
<Route path="/tasks" element={<TaskHistory />} />
<Route path="/pending" element={<PendingReceipts />} />
<Route path="/wallet" element={<Wallet />} />
<Route path="/api-keys" element={<ApiKeys />} />
</Routes>
</main>
</div>

View File

@@ -21,7 +21,7 @@
* data: {"type":"error","message":"…"}\n\n
*/
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { OutputReference } from "@marketplace/shared/chain";
export interface ChatFormProps {
@@ -102,8 +102,28 @@ async function streamChat(
}
}
/** Animated "Thinking …" placeholder shown while waiting for the model's
* first token (the assistant bubble exists but no content has streamed yet). */
function ThinkingDots(): JSX.Element {
return (
<span className="inline-flex items-center gap-1 text-gray-500" data-testid="chat-thinking">
Thinking
<span className="inline-flex items-end gap-0.5 pb-0.5">
{[0, 150, 300].map((d) => (
<span
key={d}
className="h-1.5 w-1.5 animate-bounce rounded-full bg-gray-400"
style={{ animationDelay: `${d}ms` }}
/>
))}
</span>
</span>
);
}
export default function ChatForm({ advertRef, payment_lovelace }: ChatFormProps = {}): JSX.Element {
const isPaid = advertRef !== undefined && payment_lovelace !== undefined;
const advertRefStr = advertRef ? `${advertRef.txHash}#${advertRef.index}` : null;
const [turns, setTurns] = useState<ChatTurn[]>([]);
const [streaming, setStreaming] = useState<string | null>(null); // in-progress assistant text
@@ -116,11 +136,43 @@ export default function ChatForm({ advertRef, payment_lovelace }: ChatFormProps
const [starting, setStarting] = useState(false);
const [ending, setEnding] = useState(false);
const [charged, setCharged] = useState<string | null>(null);
// Live supplier availability (paid mode): null = unknown/checking, true = free, false = busy/offline.
const [available, setAvailable] = useState<boolean | null>(null);
const sessionRef = useRef<Session | null>(null);
sessionRef.current = session;
const active = isPaid ? session !== null && charged === null : true;
// Poll the supplier's status while the Start button is showing, so a second
// user can't start a chat while one is already active. The supplier is
// single-slot (status "working" between Claim and Submit); we disable Start
// until it's "free" again. (startChat also does an authoritative pre-flight
// /status check, so this is the UX layer over that guard.)
useEffect(() => {
if (!isPaid || !advertRefStr || session !== null || charged !== null) return;
let cancelled = false;
const check = async (): Promise<void> => {
try {
const r = await fetch("/v1/indexer/suppliers?capability_id=llm.chat.v1");
if (!r.ok) throw new Error(String(r.status));
const rows = (await r.json()) as Array<{ utxo_ref: string; status: string }>;
const row = rows.find((x) => x.utxo_ref === advertRefStr);
// Unknown (row missing / indexer hiccup) → null, which does NOT hard-block
// (the pre-flight check still guards funds); only an explicit non-free
// status disables the button.
if (!cancelled) setAvailable(row ? row.status === "free" : null);
} catch {
if (!cancelled) setAvailable(null);
}
};
void check();
const id = setInterval(check, 8000);
return () => {
cancelled = true;
clearInterval(id);
};
}, [isPaid, advertRefStr, session, charged]);
async function startChat(): Promise<void> {
if (!advertRef || payment_lovelace === undefined) return;
setStarting(true);
@@ -215,7 +267,18 @@ export default function ChatForm({ advertRef, payment_lovelace }: ChatFormProps
} catch { /* keep fallback */ }
throw new Error(msg);
}
setCharged(payment_lovelace !== undefined ? ap3x(payment_lovelace) : "0");
let endBody: { settle_mode?: string } = {};
try {
endBody = (await resp.json()) as { settle_mode?: string };
} catch { /* older buyer-app responses stay full-settle */ }
// Ticket sessions never charge — the escrow returns via reclaim.
setCharged(
endBody.settle_mode === "ticket"
? "0 (ticket session — escrow reclaimed)"
: payment_lovelace !== undefined
? ap3x(payment_lovelace)
: "0",
);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -226,15 +289,26 @@ export default function ChatForm({ advertRef, payment_lovelace }: ChatFormProps
return (
<div className="space-y-4 rounded border border-gray-200 bg-white p-4" data-testid="chat-form">
{isPaid && session === null && (
<button
type="button"
onClick={startChat}
disabled={starting}
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:bg-gray-400"
data-testid="chat-start"
>
{starting ? "Opening escrow…" : "Start chat"}
</button>
<div className="space-y-2">
<button
type="button"
onClick={startChat}
disabled={starting || available === false}
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:bg-gray-400"
data-testid="chat-start"
>
{starting
? "Opening escrow…"
: available === false
? "Supplier busy"
: "Start chat"}
</button>
{available === false && (
<p className="text-sm text-amber-700" data-testid="chat-busy">
Another chat is in progress on this supplier. The button re-enables when it ends.
</p>
)}
</div>
)}
{(active || streaming !== null || turns.length > 0) && (
@@ -257,7 +331,7 @@ export default function ChatForm({ advertRef, payment_lovelace }: ChatFormProps
{streaming !== null && (
<div className="text-left">
<span className="inline-block max-w-[85%] whitespace-pre-wrap rounded bg-gray-100 px-3 py-2 text-sm text-gray-900">
{streaming.length > 0 ? streaming : "…"}
{streaming.length > 0 ? streaming : <ThinkingDots />}
</span>
</div>
)}

View File

@@ -94,7 +94,7 @@ export default function JobEstimate({
<dd className="text-right">{est.mapCalls}</dd>
<dt>Reduce calls</dt>
<dd className="text-right">{est.reduceCalls}</dd>
<dt className="font-medium">Total paid calls</dt>
<dt className="font-medium">Total escrowed calls</dt>
<dd className="text-right font-medium">{est.totalCalls}</dd>
<dt className="font-medium">Est. max cost</dt>
<dd className="text-right font-medium">{est.totalAp3x} AP3X</dd>
@@ -126,7 +126,7 @@ export default function JobEstimate({
disabled={blocked || starting}
className="rounded bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:bg-gray-400"
>
{starting ? "Starting…" : `Proceed & pay (~${est.totalAp3x} AP3X)`}
{starting ? "Starting…" : `Proceed (locks ~${est.totalAp3x} AP3X in escrow)`}
</button>
</div>
);

View File

@@ -50,7 +50,7 @@ export default function PdfUploadForm({ onUploaded }: { onUploaded: (info: Uploa
return (
<form onSubmit={handleSubmit} className="space-y-4">
<p className="text-sm text-gray-600">
Upload a text-based PDF book. The buyer will chunk it and pay marketplace
Upload a text-based PDF book. The buyer will chunk it and commission marketplace
suppliers to summarize each chunk, then synthesize a full-book summary.
</p>
<input

32
buyer/src/ui/gateway.ts Normal file
View File

@@ -0,0 +1,32 @@
/**
* buyer/src/ui/gateway.ts — resolve the OpenAI-compatible gateway base URL the
* SPA should call for the "Generate API key" page.
*
* Resolution order:
* 1. window.__BUYER_BOOT__.gatewayUrl — injected by the buyer server from the
* GATEWAY_PUBLIC_URL env (the configured value). Present on any page load
* made while the buyer_session cookie is valid.
* 2. Derived "api." + current host — the deploy convention is exactly
* api.<buyer-host> (marketplace.… → api.marketplace.…). This covers the
* gap right after a fresh login, when the SPA flips auth state WITHOUT a
* page reload so the boot block (which is only injected into
* server-rendered HTML) hasn't been re-fetched yet.
*
* The result never has a trailing slash.
*/
interface BuyerBootMaybeGateway {
gatewayUrl?: string;
}
function stripTrailingSlashes(url: string): string {
return url.replace(/\/+$/, "");
}
export function resolveGatewayUrl(): string {
const boot = (window as unknown as { __BUYER_BOOT__?: BuyerBootMaybeGateway })
.__BUYER_BOOT__;
const configured = boot?.gatewayUrl?.trim();
if (configured) return stripTrailingSlashes(configured);
return `${window.location.protocol}//api.${window.location.host}`;
}

View File

@@ -0,0 +1,205 @@
/**
* buyer/src/ui/pages/ApiKeys.tsx — self-serve "Generate API key" page.
*
* Mints a custodial gateway API key by POSTing directly to the gateway's
* public /signup endpoint (cross-origin; the gateway CORS-allows this origin).
* The raw key is shown ONCE — it is never stored server-side in retrievable
* form, so it cannot be listed or recovered later. The page also surfaces the
* funding deposit address and a ready-to-paste usage snippet.
*
* Access is gated by the SPA's existing operator login (this page only renders
* inside <RequireAuth>). The gateway /signup itself stays public + IP
* rate-limited; per-user accounts are a later feature.
*/
import { useState } from "react";
import { resolveGatewayUrl } from "../gateway.js";
interface SignupResult {
api_key: string;
key_prefix: string;
deposit_address: string;
}
/** Pull a human-readable message out of whatever error shape the gateway
* returns (OpenAI `{error:{message}}`, plain `{error|message}`, or neither). */
function errorMessage(body: unknown, status: number, statusText: string): string {
if (body && typeof body === "object") {
const b = body as Record<string, unknown>;
const err = b.error;
if (err && typeof err === "object" && typeof (err as Record<string, unknown>).message === "string") {
return (err as Record<string, string>).message;
}
if (typeof err === "string") return err;
if (typeof b.message === "string") return b.message;
}
return `${status} ${statusText}`.trim() || "request failed";
}
function CopyButton({ value, label }: { value: string; label?: string }) {
const [copied, setCopied] = useState(false);
return (
<button
type="button"
onClick={() => {
void navigator.clipboard
?.writeText(value)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
})
.catch(() => {
/* clipboard blocked (insecure context / permissions) — no-op */
});
}}
className="rounded border border-gray-300 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50"
>
{copied ? "Copied!" : (label ?? "Copy")}
</button>
);
}
export default function ApiKeys() {
const [label, setLabel] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<SignupResult | null>(null);
const gatewayUrl = resolveGatewayUrl();
const baseUrl = `${gatewayUrl}/openai/v1`;
async function generate() {
setLoading(true);
setError(null);
setResult(null);
try {
const res = await fetch(`${gatewayUrl}/signup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(label.trim() ? { label: label.trim() } : {}),
});
const body = (await res.json().catch(() => null)) as unknown;
if (!res.ok) {
throw new Error(errorMessage(body, res.status, res.statusText));
}
const r = body as Partial<SignupResult> | null;
if (!r || typeof r.api_key !== "string" || typeof r.deposit_address !== "string") {
throw new Error("unexpected response from gateway");
}
setResult({
api_key: r.api_key,
key_prefix: r.key_prefix ?? r.api_key.slice(0, 12),
deposit_address: r.deposit_address,
});
} catch (e) {
setError(e instanceof Error ? e.message : "could not reach the gateway");
} finally {
setLoading(false);
}
}
const curlSnippet =
result === null
? ""
: [
`curl ${baseUrl}/chat/completions \\`,
` -H "Authorization: Bearer ${result.api_key}" \\`,
` -H "Content-Type: application/json" \\`,
` -d '{"model": "<model>", "messages": [{"role": "user", "content": "Hello"}]}'`,
].join("\n");
return (
<div className="max-w-3xl space-y-6">
<div className="space-y-1">
<h1 className="text-2xl font-semibold">API Keys</h1>
<p className="text-sm text-gray-600">
Generate an OpenAI-compatible API key for the marketplace gateway. Each key has its own
gateway-managed wallet (the gateway holds this wallet's key) - fund its deposit address with AP3X, then point any OpenAI SDK at{" "}
<code className="font-mono text-xs">{baseUrl}</code>.
</p>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4 space-y-3">
<label className="block text-sm font-medium text-gray-700">
Label <span className="font-normal text-gray-400">(optional)</span>
<input
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="e.g. my-laptop"
maxLength={120}
disabled={loading}
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-gray-400 focus:outline-none disabled:opacity-50"
/>
</label>
<button
type="button"
onClick={() => void generate()}
disabled={loading}
className="rounded bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-50"
data-testid="generate-api-key"
>
{loading ? "Generating…" : "Generate API key"}
</button>
{error !== null && (
<p className="text-sm text-red-600" data-testid="api-key-error">
{error}
</p>
)}
</div>
{result !== null && (
<div className="space-y-4 rounded-lg border border-amber-300 bg-amber-50 p-4" data-testid="api-key-result">
<div className="rounded border border-amber-400 bg-amber-100 px-3 py-2 text-sm text-amber-900">
Copy your API key now — it is shown <strong>only once</strong> and cannot be recovered. If you
lose it, generate a new one.
</div>
<div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">API key</span>
<CopyButton value={result.api_key} />
</div>
<code className="mt-1 block break-all rounded bg-white px-3 py-2 font-mono text-sm">
{result.api_key}
</code>
</div>
<div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">Deposit address (fund with AP3X)</span>
<CopyButton value={result.deposit_address} />
</div>
<code className="mt-1 block break-all rounded bg-white px-3 py-2 font-mono text-sm">
{result.deposit_address}
</code>
</div>
<div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">Base URL</span>
<CopyButton value={baseUrl} />
</div>
<code className="mt-1 block break-all rounded bg-white px-3 py-2 font-mono text-sm">
{baseUrl}
</code>
</div>
<div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">Quick start</span>
<CopyButton value={curlSnippet} />
</div>
<pre className="mt-1 overflow-x-auto rounded bg-gray-900 px-3 py-2 font-mono text-xs text-gray-100">
{curlSnippet}
</pre>
<p className="mt-1 text-xs text-gray-500">
Replace <code className="font-mono">&lt;model&gt;</code> with a model from{" "}
<code className="font-mono">{baseUrl}/models</code>.
</p>
</div>
</div>
)}
</div>
);
}

View File

@@ -152,7 +152,7 @@ export default function BookSummarizer() {
<div>
<h1 className="text-2xl font-semibold">Book Summarizer</h1>
<p className="mt-1 text-sm text-gray-500">
Upload a PDF → pay suppliers to summarize each chunk → get a full-book summary.
Upload a PDF → commission suppliers to summarize each chunk → get a full-book summary.
Jobs keep running if you navigate away; reopen them below.
</p>
</div>

View File

@@ -1,6 +1,6 @@
/**
* buyer/src/ui/pages/PendingReceipts.tsx — list of currently-Submitted escrows
* for this buyer, with an "Accept & Pay" action per row.
* for this buyer, with an "Accept & Settle" action per row.
*
* Data flow:
* GET /v1/pending-receipts → buyer-app server proxies indexer /escrows?buyer=<pkh>
@@ -190,7 +190,7 @@ export default function PendingReceipts() {
<th className="px-2 py-2">Submitted</th>
<th className="px-2 py-2">Supplier</th>
<th className="px-2 py-2">Capability</th>
<th className="px-2 py-2">Payment</th>
<th className="px-2 py-2">Escrowed</th>
<th className="px-2 py-2">Window left</th>
<th className="px-2 py-2">Action</th>
</tr>
@@ -254,7 +254,7 @@ export default function PendingReceipts() {
: "bg-indigo-600 text-white hover:bg-indigo-700")
}
>
{isAccepting ? "accepting…" : expired ? "expired" : "Accept & Pay"}
{isAccepting ? "accepting…" : expired ? "expired" : "Accept & Settle"}
</button>
)}
</td>

View File

@@ -304,7 +304,7 @@ function LifecycleCard({ lc }: { lc: Lifecycle }): JSX.Element {
</>
)}
<span className="text-gray-500">·</span>
<span>{fmtAda(lc.payment_lovelace)} payment</span>
<span>{fmtAda(lc.payment_lovelace)} escrowed</span>
<span className="text-gray-500">·</span>
<span>{fmtAda(lc.buyer_bond_lovelace)} bond</span>
<span className="text-gray-500">·</span>

View File

@@ -1,16 +1,89 @@
/**
* buyer/src/ui/pages/Wallet.tsx — wallet address + balance view.
* buyer/src/ui/pages/Wallet.tsx — wallet address + live balance view.
*
* Balance is fetched from /v1/wallet/balance, which sums the buyer wallet's
* UTxOs live from Ogmios, and rendered as AP3X (lovelace / 1e6).
*/
import { useEffect, useState } from "react";
import { useMarketplace } from "../state/MarketplaceContext.js";
function formatAp3x(lovelace: string): string {
return (Number(lovelace) / 1e6).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 6,
});
}
export default function Wallet() {
const marketplace = useMarketplace();
const walletKey = marketplace.getWalletKey();
const [lovelace, setLovelace] = useState<string | null>(null);
const [utxoCount, setUtxoCount] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetch("/v1/wallet/balance")
.then(async (r) => {
if (!r.ok) {
const j = (await r.json().catch(() => ({}))) as { message?: string; error?: string };
throw new Error(j.message ?? j.error ?? `${r.status} ${r.statusText}`);
}
return r.json() as Promise<{ lovelace: string; utxo_count: number }>;
})
.then((j) => {
if (!cancelled) {
setLovelace(j.lovelace);
setUtxoCount(j.utxo_count);
}
})
.catch((e: Error) => {
if (!cancelled) setError(e.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [reloadKey]);
return (
<div className="space-y-4">
<h1 className="text-2xl font-semibold">Wallet</h1>
<dl className="space-y-2">
<div>
<dt className="text-sm text-gray-500">Balance</dt>
<dd className="flex items-baseline gap-3" data-testid="wallet-balance">
{loading ? (
<span className="text-gray-400">Loading…</span>
) : error ? (
<span className="text-sm text-red-600">{error}</span>
) : (
<>
<span className="text-2xl font-semibold">{formatAp3x(lovelace ?? "0")} AP3X</span>
{utxoCount !== null && (
<span className="text-xs text-gray-400">({utxoCount} UTxO{utxoCount === 1 ? "" : "s"})</span>
)}
</>
)}
<button
type="button"
onClick={() => setReloadKey((k) => k + 1)}
disabled={loading}
className="ml-2 rounded border border-gray-300 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-50"
data-testid="wallet-balance-refresh"
>
Refresh
</button>
</dd>
</div>
<div>
<dt className="text-sm text-gray-500">Address</dt>
<dd className="font-mono text-sm break-all">{walletKey.address}</dd>

View File

@@ -289,3 +289,34 @@ For the M1-F-2 (LiveOgmiosProvider) handoff: the supplier compose already
joins `apex-dashboard_apex-net` and overrides `OGMIOS_URL=ws://ogmios:1337`,
so once the supplier code stops booting `ReadOnlyOgmiosProvider` and
starts talking to a real Ogmios, no compose changes are needed.
## 10. Continuous deployment (mainnet, vector-marketplace host)
Merges to `main` deploy automatically to the mainnet host once CI is green:
1. `.github/workflows/ci.yml` runs typecheck + vitest on the merge commit.
2. On success, `.github/workflows/deploy-mainnet.yml` SSHes into the host
(secret `DEPLOY_SSH_KEY`, host key pinned in the workflow) and executes
`deploy/mainnet/deploy.sh` **at the new commit** (`git show FETCH_HEAD:...`),
so deploy logic always matches the code being deployed.
3. The script hard-resets `/root/agents-marketplace` to `origin/main`, maps the
old..new diff to affected compose projects (changes under `packages/`,
`patches/`, `contracts/` or root manifests rebuild everything), builds all
affected images up front, then does a rolling `up -d` with a health gate per
project. Suppliers are drained first: `/status` is polled until not
`working` (up to `DRAIN_TIMEOUT_SECS`, default 600) so an in-flight job
isn't killed and its escrow bond forfeited.
Manual controls:
- **Manual deploy / redeploy**: Actions → "Deploy mainnet" → Run workflow
(check *force* to redeploy the same sha), or on the host:
`bash /root/agents-marketplace/deploy/mainnet/deploy.sh` (`FORCE=1` to
redeploy, `DRY_RUN=1` to preview which projects would restart).
- **Rollback**: `cd /root/agents-marketplace && git reset --hard <old-sha> &&
FORCE=1 bash deploy/mainnet/deploy.sh`. Every deploy appends
`old -> new (projects)` to `/var/log/marketplace-deploy.log`.
Projects with no running containers on the host (e.g. wallet-monitor) are
skipped; image-only projects (ollama, chatmock, tts-piper) redeploy only when
their own compose file changes.

178
deploy/mainnet/deploy.sh Executable file
View File

@@ -0,0 +1,178 @@
#!/usr/bin/env bash
# deploy/mainnet/deploy.sh — pull-based CD for the vector-marketplace host.
#
# Runs ON the server (root@vector-marketplace, repo at /root/agents-marketplace).
# Invoked by .github/workflows/deploy-mainnet.yml over SSH, but safe to run by
# hand: `bash deploy/mainnet/deploy.sh`.
#
# What it does:
# 1. Fetches origin/main and hard-resets the checkout to it.
# 2. Diffs old..new to work out which compose projects actually need a
# rebuild (gateway vs suppliers vs buyer vs indexer ...). Changes under
# packages/, patches/, contracts/ or the root manifests rebuild everything,
# because every Dockerfile COPYs those into its build context.
# 3. Builds affected images first (all 17 supplier projects share one
# Dockerfile/context, so build #2..#17 are layer-cache hits), THEN does a
# rolling `up -d` so the downtime window is restart-only, not build+restart.
# 4. Suppliers are drained before recreate: /status is polled until the
# supplier is not "working" (a busy supplier killed mid-job forfeits its
# escrow bond). After DRAIN_TIMEOUT_SECS we recreate anyway, so a wedged
# supplier cannot block the deploy forever.
# 5. Every restarted container must report healthy (or plain running when it
# has no healthcheck) within HEALTH_TIMEOUT_SECS or the deploy fails.
#
# Env knobs:
# FORCE=1 redeploy even if already at origin/main
# DRY_RUN=1 print what would be rebuilt/restarted, change nothing
# DRAIN_TIMEOUT_SECS=N max wait per busy supplier (default 600, 0 = skip)
# HEALTH_TIMEOUT_SECS=N max wait for healthy (default 180)
#
# Rollback: git reset --hard <old-sha> && FORCE=1 bash deploy/mainnet/deploy.sh
# (old sha is printed below and appended to /var/log/marketplace-deploy.log).
set -euo pipefail
REPO=/root/agents-marketplace
COMPOSE_DIR="$REPO/deploy/mainnet"
LOCK=/run/marketplace-deploy.lock
LOG=/var/log/marketplace-deploy.log
DRAIN_TIMEOUT_SECS="${DRAIN_TIMEOUT_SECS:-600}"
HEALTH_TIMEOUT_SECS="${HEALTH_TIMEOUT_SECS:-180}"
FORCE="${FORCE:-0}"
DRY_RUN="${DRY_RUN:-0}"
exec 9>"$LOCK"
flock -n 9 || { echo "another deploy is already running (lock: $LOCK)"; exit 1; }
cd "$REPO"
OLD=$(git rev-parse HEAD)
git fetch origin main
NEW=$(git rev-parse FETCH_HEAD)
if [ "$OLD" = "$NEW" ] && [ "$FORCE" != "1" ]; then
echo "already at origin/main ($NEW) — nothing to deploy (FORCE=1 to override)"
exit 0
fi
echo "deploying $OLD -> $NEW"
if [ "$DRY_RUN" = "1" ]; then
echo "(dry run — checkout not reset, nothing rebuilt or restarted)"
else
git reset --hard FETCH_HEAD
fi
CHANGED=$(git diff --name-only "$OLD" "$NEW" || true)
[ "$FORCE" = "1" ] && [ -z "$CHANGED" ] && CHANGED="(forced)"
# Paths COPY'd into every image's build context (see */Dockerfile).
SHARED_RE='^(packages/|patches/|contracts/|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsconfig\.base\.json$|\.npmrc$)'
changed_matches() { # changed_matches <ere> -> 0 if any changed path matches
[ "$FORCE" = "1" ] && return 0
echo "$CHANGED" | grep -qE "$1"
}
# Compose project -> the source prefixes that require rebuilding its image.
# Image-only projects (ollama, chatmock, tts-piper) redeploy only when their
# own compose file changes.
project_re() {
case "$1" in
docker-compose.indexer.yml) echo "^indexer/|^indexer-ui/|$SHARED_RE" ;;
docker-compose.buyer.yml) echo "^buyer/|$SHARED_RE" ;;
docker-compose.gateway.yml) echo "^gateway/|$SHARED_RE" ;;
docker-compose.wallet-monitor.yml) echo "^wallet-monitor/|$SHARED_RE" ;;
docker-compose.supplier*.yml) echo "^supplier/|$SHARED_RE" ;;
*) echo "" ;;
esac
}
# Restart order: infra first, suppliers last (each supplier drains first).
ORDERED_NON_SUPPLIER="docker-compose.indexer.yml docker-compose.buyer.yml docker-compose.gateway.yml docker-compose.wallet-monitor.yml docker-compose.ollama.yml docker-compose.chatmock.yml docker-compose.tts-piper.yml"
SUPPLIERS=$(cd "$COMPOSE_DIR" && ls docker-compose.supplier*.yml)
AFFECTED=()
for f in $ORDERED_NON_SUPPLIER $SUPPLIERS; do
[ -f "$COMPOSE_DIR/$f" ] || continue
re=$(project_re "$f")
hit=0
[ -n "$re" ] && changed_matches "$re" && hit=1
changed_matches "^deploy/mainnet/$f\$" && hit=1
[ "$hit" = "1" ] || continue
if [ -z "$(docker compose -f "$COMPOSE_DIR/$f" ps -q 2>/dev/null)" ]; then
echo "skip $f — affected but no running containers on this host"
continue
fi
AFFECTED+=("$f")
done
if [ "${#AFFECTED[@]}" -eq 0 ]; then
echo "no running compose project is affected by $OLD..$NEW — done"
echo "$(date -Is) $OLD -> $NEW (no services affected)" >> "$LOG"
exit 0
fi
echo "affected projects: ${AFFECTED[*]}"
if [ "$DRY_RUN" = "1" ]; then
echo "(dry run — stopping before build/rollout)"
exit 0
fi
echo "== build phase =="
for f in "${AFFECTED[@]}"; do
echo "-- building $f"
docker compose -f "$COMPOSE_DIR/$f" build
done
drain_supplier() { # drain_supplier <compose-file>
[ "$DRAIN_TIMEOUT_SECS" = "0" ] && return 0
local cid deadline status
cid=$(docker compose -f "$COMPOSE_DIR/$1" ps -q | head -1)
[ -n "$cid" ] || return 0
deadline=$(( $(date +%s) + DRAIN_TIMEOUT_SECS ))
while [ "$(date +%s)" -lt "$deadline" ]; do
status=$(docker exec "$cid" wget -qO- http://localhost:8080/status 2>/dev/null \
| grep -o '"status":"[^"]*"' | head -1 | cut -d'"' -f4 || true)
if [ "$status" != "working" ]; then
return 0
fi
echo " supplier busy (status=working) — waiting for job to finish..."
sleep 15
done
echo " WARNING: still busy after ${DRAIN_TIMEOUT_SECS}s — recreating anyway"
}
wait_healthy() { # wait_healthy <compose-file>
local deadline cid state
deadline=$(( $(date +%s) + HEALTH_TIMEOUT_SECS ))
for cid in $(docker compose -f "$COMPOSE_DIR/$1" ps -q); do
while :; do
state=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$cid")
case "$state" in
healthy|running) break ;;
starting|unhealthy|created|restarting)
if [ "$(date +%s)" -ge "$deadline" ]; then
echo " FAILED: $(docker inspect -f '{{.Name}}' "$cid") is '$state' after ${HEALTH_TIMEOUT_SECS}s; last logs:"
docker logs --tail 50 "$cid" 2>&1 | sed 's/^/ | /'
return 1
fi
sleep 5 ;;
*) echo " FAILED: container state '$state'"; docker logs --tail 50 "$cid" 2>&1 | sed 's/^/ | /'; return 1 ;;
esac
done
done
}
echo "== rollout phase =="
for f in "${AFFECTED[@]}"; do
echo "-- rolling out $f"
case "$f" in docker-compose.supplier*) drain_supplier "$f" ;; esac
docker compose -f "$COMPOSE_DIR/$f" up -d
wait_healthy "$f"
echo " ok"
done
echo "$(date -Is) $OLD -> $NEW (${AFFECTED[*]})" >> "$LOG"
echo "== deploy complete: $NEW =="
echo "rollback: cd $REPO && git reset --hard $OLD && FORCE=1 bash deploy/mainnet/deploy.sh"

View File

@@ -21,10 +21,19 @@ services:
environment:
INDEXER_URL: http://marketplace-mainnet-indexer:8090
BUYER_PORT: "8070"
# PiperTTS demo (Path B): direct-proxy target for /v1/synth-speech. Piper is
# private on marketplace-mainnet-net (no public host), so use internal DNS.
# Without this the demo TTS tile 503s ("booted without TTS_PIPER_BASE_URL").
TTS_PIPER_BASE_URL: http://marketplace-mainnet-tts-piper:8000
# Pin the archive dir so it lands on the persistent volume below — keeps
# the response archive + PDF job store (past summaries) across container
# recreation/redeploys.
ARCHIVE_DIR: /repo/data/archive
# Public base URL of the OpenAI gateway, surfaced to the SPA boot block so
# the "API Keys" page can POST cross-origin to ${gatewayUrl}/signup. The
# gateway must CORS-allow https://marketplace.vector.apexfusion.org (see
# GATEWAY_CORS_ORIGINS in docker-compose.gateway.yml).
GATEWAY_PUBLIC_URL: https://api.marketplace.vector.apexfusion.org
# NETWORK_ID intentionally not pinned — see supplier compose comment.
volumes:
- buyer-mainnet-archive:/repo/data/archive

View File

@@ -0,0 +1,90 @@
# deploy/mainnet/docker-compose.gateway.yml — OpenAI-compatible Gateway — MAINNET
#
# Multi-tenant, custodial OpenAI gateway. Each API key is its own buyer wallet;
# every request posts an on-chain escrow from that user's wallet. See docs/gateway.md.
#
# Secrets live in gateway/.env (NOT committed):
# GATEWAY_MASTER_KEY=<openssl rand -hex 32> # losing this locks ALL user funds
# Back up BOTH gateway/.env (the master key) AND the gateway-mainnet-data volume
# (the gateway.db holds every user's encrypted wallet key + usage).
name: marketplace-mainnet-gateway
services:
gateway:
build:
context: ../..
dockerfile: gateway/Dockerfile
container_name: marketplace-mainnet-gateway
restart: unless-stopped
env_file:
- path: ../../gateway/.env
required: true
environment:
INDEXER_URL: http://marketplace-mainnet-indexer:8090
OGMIOS_URL: https://ogmios.vector.mainnet.apexfusion.org
LIVE_CHAIN: "1"
NETWORK_ID: "1"
GATEWAY_PORT: "8080"
GATEWAY_DB_DIR: /repo/data/gateway
# Mainnet chain genesis (slot↔wallclock). MUST match buyer/supplier or
# PostEscrow validity bounds land ~51 days off and Ogmios rejects (code 3118).
VECTOR_ZERO_TIME_MS: "1756485600000"
# On-chain reference-script UTxOs (escrow spending validator) used by
# Accept/Reclaim/Submit. Same values the buyer/suppliers use on mainnet.
ESCROW_REF_UTXO: c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#0
ADVERT_REF_UTXO: c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#1
# Browser Origins allowed to call the gateway cross-origin. The buyer
# frontend's "API Keys" page (served from marketplace.vector…) POSTs to
# /signup here. Comma-separated; empty disables CORS entirely.
GATEWAY_CORS_ORIGINS: https://marketplace.vector.apexfusion.org
# Ticket mode: the escrow post at session open is the only chain op;
# the sweeper reclaims Open escrows after deliver_by. Must match the
# kimi-chat suppliers' CHAT_SETTLE_MODE.
CHAT_SETTLE_MODE: ticket
# Demo janitor closes idle sessions BEFORE the suppliers' 15 min
# CHAT_IDLE_TIMEOUT_MS backstop.
DEMO_SESSION_IDLE_MS: "600000"
volumes:
- gateway-mainnet-data:/repo/data/gateway
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
# This host is NOT Cloudflare-proxied: traefik terminates TLS itself with
# a Let's Encrypt cert (resolver defined in /root/traefik/traefik.yml on
# the box). The :80 router only redirects; ACME HTTP-01 challenges are
# answered by traefik before the redirect applies.
- "traefik.http.routers.mp-gateway-mainnet.entrypoints=web"
- "traefik.http.routers.mp-gateway-mainnet.rule=Host(`api.marketplace.vector.apexfusion.org`)"
- "traefik.http.routers.mp-gateway-mainnet.middlewares=mp-gateway-mainnet-https"
- "traefik.http.middlewares.mp-gateway-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-gateway-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-gateway-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-gateway-mainnet-tls.rule=Host(`api.marketplace.vector.apexfusion.org`)"
- "traefik.http.routers.mp-gateway-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-gateway-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: traefik-net
volumes:
gateway-mainnet-data:

View File

@@ -0,0 +1,71 @@
# deploy/mainnet/docker-compose.supplier-deepseek-flash-1.yml — Supplier — MAINNET (OpenRouter / DeepSeek V4 Flash #1)
#
# Parallel-capacity supplier for deepseek/deepseek-v4-flash via OpenRouter's
# OpenAI-compatible API. Same shape as the kimi replicas; distinct only in
# instance name, container, env file, Traefik host/router, and advert model.
# Shares the same OpenRouter API key as the kimi suppliers (accepted ceiling;
# supplier has no 429/backoff). Advert caps: max_output_tokens=262144,
# max_processing_ms=3600000; OPENAI_TIMEOUT_MS matched.
#
# Pre-flight:
# 1. DNS (proxied): mp-suppliers-deepseek-flash-1.vector.apexfusion.org → host
# 2. supplier/.env.deepseek-flash-1 on host (chmod 600): SUPPLIER_PRIV_KEY_HEX,
# OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1, VECTOR_ZERO_TIME_MS,
# ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF, LIVE_CHAIN=1
# 3. Networks marketplace-mainnet-net + traefik-net already exist.
name: marketplace-mainnet-supplier-deepseek-flash-1
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-deepseek-flash-1
restart: unless-stopped
env_file:
- path: ../../supplier/.env.deepseek-flash-1
required: false
environment:
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
OPENAI_BASE_URL: https://openrouter.ai/api
# Matches the advert's max_processing_ms=3600000 (60 min).
OPENAI_TIMEOUT_MS: "3600000"
PORT: "8080"
LIVE_CHAIN: "1"
# NETWORK_ID and OGMIOS_URL come from supplier/.env.deepseek-flash-1.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet.rule=Host(`mp-suppliers-deepseek-flash-1.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet.middlewares=mp-suppliers-deepseek-flash-1-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-1-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-1-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet-tls.rule=Host(`mp-suppliers-deepseek-flash-1.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-deepseek-flash-1-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: traefik-net

View File

@@ -0,0 +1,71 @@
# deploy/mainnet/docker-compose.supplier-deepseek-flash-2.yml — Supplier — MAINNET (OpenRouter / DeepSeek V4 Flash #2)
#
# Parallel-capacity supplier for deepseek/deepseek-v4-flash via OpenRouter's
# OpenAI-compatible API. Same shape as the kimi replicas; distinct only in
# instance name, container, env file, Traefik host/router, and advert model.
# Shares the same OpenRouter API key as the kimi suppliers (accepted ceiling;
# supplier has no 429/backoff). Advert caps: max_output_tokens=262144,
# max_processing_ms=3600000; OPENAI_TIMEOUT_MS matched.
#
# Pre-flight:
# 1. DNS (proxied): mp-suppliers-deepseek-flash-2.vector.apexfusion.org → host
# 2. supplier/.env.deepseek-flash-2 on host (chmod 600): SUPPLIER_PRIV_KEY_HEX,
# OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1, VECTOR_ZERO_TIME_MS,
# ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF, LIVE_CHAIN=1
# 3. Networks marketplace-mainnet-net + traefik-net already exist.
name: marketplace-mainnet-supplier-deepseek-flash-2
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-deepseek-flash-2
restart: unless-stopped
env_file:
- path: ../../supplier/.env.deepseek-flash-2
required: false
environment:
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
OPENAI_BASE_URL: https://openrouter.ai/api
# Matches the advert's max_processing_ms=3600000 (60 min).
OPENAI_TIMEOUT_MS: "3600000"
PORT: "8080"
LIVE_CHAIN: "1"
# NETWORK_ID and OGMIOS_URL come from supplier/.env.deepseek-flash-2.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet.rule=Host(`mp-suppliers-deepseek-flash-2.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet.middlewares=mp-suppliers-deepseek-flash-2-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-2-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-2-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet-tls.rule=Host(`mp-suppliers-deepseek-flash-2.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-deepseek-flash-2-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: traefik-net

View File

@@ -0,0 +1,71 @@
# deploy/mainnet/docker-compose.supplier-deepseek-flash-3.yml — Supplier — MAINNET (OpenRouter / DeepSeek V4 Flash #3)
#
# Parallel-capacity supplier for deepseek/deepseek-v4-flash via OpenRouter's
# OpenAI-compatible API. Same shape as the kimi replicas; distinct only in
# instance name, container, env file, Traefik host/router, and advert model.
# Shares the same OpenRouter API key as the kimi suppliers (accepted ceiling;
# supplier has no 429/backoff). Advert caps: max_output_tokens=262144,
# max_processing_ms=3600000; OPENAI_TIMEOUT_MS matched.
#
# Pre-flight:
# 1. DNS (proxied): mp-suppliers-deepseek-flash-3.vector.apexfusion.org → host
# 2. supplier/.env.deepseek-flash-3 on host (chmod 600): SUPPLIER_PRIV_KEY_HEX,
# OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1, VECTOR_ZERO_TIME_MS,
# ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF, LIVE_CHAIN=1
# 3. Networks marketplace-mainnet-net + traefik-net already exist.
name: marketplace-mainnet-supplier-deepseek-flash-3
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-deepseek-flash-3
restart: unless-stopped
env_file:
- path: ../../supplier/.env.deepseek-flash-3
required: false
environment:
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
OPENAI_BASE_URL: https://openrouter.ai/api
# Matches the advert's max_processing_ms=3600000 (60 min).
OPENAI_TIMEOUT_MS: "3600000"
PORT: "8080"
LIVE_CHAIN: "1"
# NETWORK_ID and OGMIOS_URL come from supplier/.env.deepseek-flash-3.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet.rule=Host(`mp-suppliers-deepseek-flash-3.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet.middlewares=mp-suppliers-deepseek-flash-3-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-3-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-3-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet-tls.rule=Host(`mp-suppliers-deepseek-flash-3.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-deepseek-flash-3-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: traefik-net

View File

@@ -65,6 +65,12 @@ services:
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-deepseek-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-deepseek-mainnet.rule=Host(`mp-suppliers-deepseek.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-deepseek-mainnet.middlewares=mp-suppliers-deepseek-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-deepseek-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-deepseek-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-deepseek-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-deepseek-mainnet-tls.rule=Host(`mp-suppliers-deepseek.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-deepseek-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-deepseek-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file

View File

@@ -0,0 +1,80 @@
# deploy/mainnet/docker-compose.supplier-ds-flash-chat-1.yml — Supplier — MAINNET
# (OpenRouter / deepseek-v4-flash — MULTI-TURN CHAT capability `llm.chat.v1`, REPLICA 1)
#
# Chat-session sibling of the deepseek-flash one-off suppliers. Same OpenRouter
# backend (deepseek/deepseek-v4-flash) but CAPABILITY_KIND=chat-session so it
# mounts /v1/chat/{start,message,end}. Ticket settle mode (matches the mainnet
# gateway): the buyer's Open escrow post is the only chain op; the supplier
# never Claims/Submits; the gateway sweeper reclaims after deliver_by.
#
# Pre-flight:
# 1. DNS A-record: mp-suppliers-ds-flash-chat-1.vector.apexfusion.org -> host IP
# 2. New supplier wallet (gen-keypair), funded (advert post + fee float).
# 3. Post a fresh llm.chat.v1 advert for this wallet:
# --capability-id llm.chat.v1 --model deepseek/deepseek-v4-flash
# --max-processing-ms 1800000 --price-lovelace 200000
# 4. supplier/.env.ds-flash-chat-1 written on host (chmod 600) with at least:
# SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
# LIVE_CHAIN=1, OPENAI_REASONING=off, OPENAI_MAX_TOKENS=131072
name: marketplace-mainnet-supplier-ds-flash-chat-1
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-ds-flash-chat-1
restart: unless-stopped
env_file:
- path: ../../supplier/.env.ds-flash-chat-1
required: false
environment:
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
CAPABILITY_KIND: chat-session
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
OPENAI_BASE_URL: https://openrouter.ai/api
OPENAI_TIMEOUT_MS: "300000"
CHAT_IDLE_TIMEOUT_MS: "900000"
CHAT_SETTLE_MODE: ticket
MAX_CHAT_SESSIONS: "8"
PORT: "8080"
# Enable LiveOgmiosProvider so wallet-health/consolidate can broadcast.
LIVE_CHAIN: "1"
# NETWORK_ID and OGMIOS_URL come from supplier/.env.ds-flash-chat-1.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet.rule=Host(`mp-suppliers-ds-flash-chat-1.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet.middlewares=mp-suppliers-ds-flash-chat-1-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-1-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-1-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet-tls.rule=Host(`mp-suppliers-ds-flash-chat-1.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-ds-flash-chat-1-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: traefik-net

View File

@@ -0,0 +1,80 @@
# deploy/mainnet/docker-compose.supplier-ds-flash-chat-2.yml — Supplier — MAINNET
# (OpenRouter / deepseek-v4-flash — MULTI-TURN CHAT capability `llm.chat.v1`, REPLICA 2)
#
# Chat-session sibling of the deepseek-flash one-off suppliers. Same OpenRouter
# backend (deepseek/deepseek-v4-flash) but CAPABILITY_KIND=chat-session so it
# mounts /v1/chat/{start,message,end}. Ticket settle mode (matches the mainnet
# gateway): the buyer's Open escrow post is the only chain op; the supplier
# never Claims/Submits; the gateway sweeper reclaims after deliver_by.
#
# Pre-flight:
# 1. DNS A-record: mp-suppliers-ds-flash-chat-2.vector.apexfusion.org -> host IP
# 2. New supplier wallet (gen-keypair), funded (advert post + fee float).
# 3. Post a fresh llm.chat.v1 advert for this wallet:
# --capability-id llm.chat.v1 --model deepseek/deepseek-v4-flash
# --max-processing-ms 1800000 --price-lovelace 200000
# 4. supplier/.env.ds-flash-chat-2 written on host (chmod 600) with at least:
# SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
# LIVE_CHAIN=1, OPENAI_REASONING=off, OPENAI_MAX_TOKENS=131072
name: marketplace-mainnet-supplier-ds-flash-chat-2
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-ds-flash-chat-2
restart: unless-stopped
env_file:
- path: ../../supplier/.env.ds-flash-chat-2
required: false
environment:
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
CAPABILITY_KIND: chat-session
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
OPENAI_BASE_URL: https://openrouter.ai/api
OPENAI_TIMEOUT_MS: "300000"
CHAT_IDLE_TIMEOUT_MS: "900000"
CHAT_SETTLE_MODE: ticket
MAX_CHAT_SESSIONS: "8"
PORT: "8080"
# Enable LiveOgmiosProvider so wallet-health/consolidate can broadcast.
LIVE_CHAIN: "1"
# NETWORK_ID and OGMIOS_URL come from supplier/.env.ds-flash-chat-2.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet.rule=Host(`mp-suppliers-ds-flash-chat-2.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet.middlewares=mp-suppliers-ds-flash-chat-2-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-2-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-2-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet-tls.rule=Host(`mp-suppliers-ds-flash-chat-2.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-ds-flash-chat-2-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: traefik-net

View File

@@ -0,0 +1,80 @@
# deploy/mainnet/docker-compose.supplier-ds-flash-chat-3.yml — Supplier — MAINNET
# (OpenRouter / deepseek-v4-flash — MULTI-TURN CHAT capability `llm.chat.v1`, REPLICA 3)
#
# Chat-session sibling of the deepseek-flash one-off suppliers. Same OpenRouter
# backend (deepseek/deepseek-v4-flash) but CAPABILITY_KIND=chat-session so it
# mounts /v1/chat/{start,message,end}. Ticket settle mode (matches the mainnet
# gateway): the buyer's Open escrow post is the only chain op; the supplier
# never Claims/Submits; the gateway sweeper reclaims after deliver_by.
#
# Pre-flight:
# 1. DNS A-record: mp-suppliers-ds-flash-chat-3.vector.apexfusion.org -> host IP
# 2. New supplier wallet (gen-keypair), funded (advert post + fee float).
# 3. Post a fresh llm.chat.v1 advert for this wallet:
# --capability-id llm.chat.v1 --model deepseek/deepseek-v4-flash
# --max-processing-ms 1800000 --price-lovelace 200000
# 4. supplier/.env.ds-flash-chat-3 written on host (chmod 600) with at least:
# SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
# LIVE_CHAIN=1, OPENAI_REASONING=off, OPENAI_MAX_TOKENS=131072
name: marketplace-mainnet-supplier-ds-flash-chat-3
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-ds-flash-chat-3
restart: unless-stopped
env_file:
- path: ../../supplier/.env.ds-flash-chat-3
required: false
environment:
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
CAPABILITY_KIND: chat-session
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
OPENAI_BASE_URL: https://openrouter.ai/api
OPENAI_TIMEOUT_MS: "300000"
CHAT_IDLE_TIMEOUT_MS: "900000"
CHAT_SETTLE_MODE: ticket
MAX_CHAT_SESSIONS: "8"
PORT: "8080"
# Enable LiveOgmiosProvider so wallet-health/consolidate can broadcast.
LIVE_CHAIN: "1"
# NETWORK_ID and OGMIOS_URL come from supplier/.env.ds-flash-chat-3.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet.rule=Host(`mp-suppliers-ds-flash-chat-3.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet.middlewares=mp-suppliers-ds-flash-chat-3-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-3-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-3-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet-tls.rule=Host(`mp-suppliers-ds-flash-chat-3.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-ds-flash-chat-3-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: traefik-net

View File

@@ -0,0 +1,101 @@
# deploy/mainnet/docker-compose.supplier-ds-flash-htz-chat.yml — Supplier — MAINNET
# (Hetzner Inference / DeepSeek-V4-Flash-0731 — MULTI-TURN CHAT capability `llm.chat.v1`)
#
# The chat-session sibling of docker-compose.supplier-ds-flash-htz.yml. Same
# image, same Hetzner backend (DeepSeek-V4-Flash-0731), but CAPABILITY_KIND=chat-session so
# it mounts /v1/chat/{start,message,end} (escrow bookends, off-chain streaming
# turns) instead of the one-off /v1/chat/completions route.
#
# A SEPARATE process/advert from the one-off supplier on purpose: the chat
# session holds the supplier's single-slot lock for the whole conversation
# (minutes), which would starve one-off traffic if co-hosted.
#
# One of 8 Hetzner-backed suppliers (4 models x {one-off, chat-session});
# fleet runbook: docs/HETZNER_SETUP.md.
#
# Pre-flight:
# 1. DNS: mp-suppliers-ds-flash-htz-chat.vector.apexfusion.org → host
# (individual DNS-only A record → 91.98.147.172; NO wildcard exists —
# letsencrypt HTTP-01 issuance needs the unproxied direct record)
# 2. supplier/.env.ds-flash-htz-chat written on host (chmod 600) with at minimum:
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
# SUPPLIER_PUB_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
# LIVE_CHAIN=1
# The advert must be posted with --capability-id llm.chat.v1 and a large
# --max-processing-ms (~1800000) so Claim+Submit both land before deliver_by.
# 3. Networks already exist (created by the canonical supplier compose):
# docker network create marketplace-mainnet-net # one-shot
# # traefik-net is created by the Traefik project
name: marketplace-mainnet-supplier-ds-flash-htz-chat
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-ds-flash-htz-chat
restart: unless-stopped
env_file:
# Self-contained env for this Hetzner-backed variant. Holds this
# supplier's distinct SUPPLIER_PRIV_KEY_HEX (+ derived wallet vars),
# ADVERT_REF, the shared Hetzner OPENAI_API_KEY, and the shared mainnet
# plumbing duplicated in full (NETWORK_ID, OGMIOS_URL, VECTOR_ZERO_TIME_MS,
# ref-script UTxOs, LIVE_CHAIN). No env_file ordering / override magic.
- path: ../../supplier/.env.ds-flash-htz-chat
required: false
environment:
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
CAPABILITY_KIND: chat-session
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions
# (Hetzner serves at https://inference.hetzner.com/api/v1/...).
OPENAI_BASE_URL: https://inference.hetzner.com/api
# Reasoning-style models can be slow; mirror the one-off compose.
OPENAI_TIMEOUT_MS: "300000"
# Idle end (supplier Submits the transcript): single-slot suppliers must
# free up quickly (the gateway transparently reopens a session on the
# next turn). Mirrors the kimi-chat fleet.
CHAT_IDLE_TIMEOUT_MS: "900000"
CHAT_SETTLE_MODE: ticket
MAX_CHAT_SESSIONS: "8"
PORT: "8080"
# Enable LiveOgmiosProvider so Claim/Submit actually broadcast.
LIVE_CHAIN: "1"
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
# come from the operator-provided supplier/.env.ds-flash-htz-chat on a mainnet host.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-ds-flash-htz-chat-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-ds-flash-htz-chat-mainnet.rule=Host(`mp-suppliers-ds-flash-htz-chat.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-ds-flash-htz-chat-mainnet.middlewares=mp-suppliers-ds-flash-htz-chat-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-ds-flash-htz-chat-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-ds-flash-htz-chat-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-ds-flash-htz-chat-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-ds-flash-htz-chat-mainnet-tls.rule=Host(`mp-suppliers-ds-flash-htz-chat.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-ds-flash-htz-chat-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-ds-flash-htz-chat-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: traefik-net

View File

@@ -0,0 +1,89 @@
# deploy/mainnet/docker-compose.supplier-ds-flash-htz.yml — Supplier — MAINNET
# (Hetzner Inference / DeepSeek-V4-Flash-0731 — one-off `llm.text.generate.v1`)
#
# Hetzner-backed supplier. LLM_BACKEND=openai pointed directly at Hetzner's
# hosted inference API (OpenAI-compatible at https://inference.hetzner.com/api).
# No local proxy container — reachable over public HTTPS.
#
# One of 8 Hetzner-backed suppliers (4 models x {one-off, chat-session});
# fleet runbook: docs/HETZNER_SETUP.md.
#
# Pre-flight:
# 1. DNS: mp-suppliers-ds-flash-htz.vector.apexfusion.org → host
# (individual DNS-only A record → 91.98.147.172; NO wildcard exists —
# letsencrypt HTTP-01 issuance needs the unproxied direct record)
# 2. supplier/.env.ds-flash-htz written on host (chmod 600) with at minimum:
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
# SUPPLIER_PUB_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
# LIVE_CHAIN=1
# 3. Networks already exist (created by the canonical supplier compose):
# docker network create marketplace-mainnet-net # one-shot
# # traefik-net is created by the Traefik project
name: marketplace-mainnet-supplier-ds-flash-htz
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-ds-flash-htz
restart: unless-stopped
env_file:
# Self-contained env for this Hetzner-backed variant. Holds this
# supplier's distinct SUPPLIER_PRIV_KEY_HEX (+ derived wallet vars),
# ADVERT_REF, the shared Hetzner OPENAI_API_KEY, and the shared mainnet
# plumbing duplicated in full (NETWORK_ID, OGMIOS_URL, VECTOR_ZERO_TIME_MS,
# ref-script UTxOs, LIVE_CHAIN). No env_file ordering / override magic.
- path: ../../supplier/.env.ds-flash-htz
required: false
environment:
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions
# (Hetzner serves at https://inference.hetzner.com/api/v1/...).
OPENAI_BASE_URL: https://inference.hetzner.com/api
# Reasoning-style models can be slow; matches the advert's
# max_processing_ms (300000).
OPENAI_TIMEOUT_MS: "300000"
# OPENAI_MAX_TOKENS intentionally unset: no max_tokens forwarded upstream,
# the provider grants context − prompt. Advert cap = model context length.
PORT: "8080"
# Enable LiveOgmiosProvider so submitTx/awaitTx actually broadcast.
LIVE_CHAIN: "1"
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
# come from the operator-provided supplier/.env.ds-flash-htz on a mainnet host.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-ds-flash-htz-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-ds-flash-htz-mainnet.rule=Host(`mp-suppliers-ds-flash-htz.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-ds-flash-htz-mainnet.middlewares=mp-suppliers-ds-flash-htz-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-ds-flash-htz-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-ds-flash-htz-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-ds-flash-htz-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-ds-flash-htz-mainnet-tls.rule=Host(`mp-suppliers-ds-flash-htz.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-ds-flash-htz-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-ds-flash-htz-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: traefik-net

View File

@@ -0,0 +1,101 @@
# deploy/mainnet/docker-compose.supplier-glm-chat.yml — Supplier — MAINNET
# (Hetzner Inference / GLM-5.2-NVFP4 — MULTI-TURN CHAT capability `llm.chat.v1`)
#
# The chat-session sibling of docker-compose.supplier-glm.yml. Same
# image, same Hetzner backend (GLM-5.2-NVFP4), but CAPABILITY_KIND=chat-session so
# it mounts /v1/chat/{start,message,end} (escrow bookends, off-chain streaming
# turns) instead of the one-off /v1/chat/completions route.
#
# A SEPARATE process/advert from the one-off supplier on purpose: the chat
# session holds the supplier's single-slot lock for the whole conversation
# (minutes), which would starve one-off traffic if co-hosted.
#
# One of 8 Hetzner-backed suppliers (4 models x {one-off, chat-session});
# fleet runbook: docs/HETZNER_SETUP.md.
#
# Pre-flight:
# 1. DNS: mp-suppliers-glm-chat.vector.apexfusion.org → host
# (individual DNS-only A record → 91.98.147.172; NO wildcard exists —
# letsencrypt HTTP-01 issuance needs the unproxied direct record)
# 2. supplier/.env.glm-chat written on host (chmod 600) with at minimum:
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
# SUPPLIER_PUB_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
# LIVE_CHAIN=1
# The advert must be posted with --capability-id llm.chat.v1 and a large
# --max-processing-ms (~1800000) so Claim+Submit both land before deliver_by.
# 3. Networks already exist (created by the canonical supplier compose):
# docker network create marketplace-mainnet-net # one-shot
# # traefik-net is created by the Traefik project
name: marketplace-mainnet-supplier-glm-chat
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-glm-chat
restart: unless-stopped
env_file:
# Self-contained env for this Hetzner-backed variant. Holds this
# supplier's distinct SUPPLIER_PRIV_KEY_HEX (+ derived wallet vars),
# ADVERT_REF, the shared Hetzner OPENAI_API_KEY, and the shared mainnet
# plumbing duplicated in full (NETWORK_ID, OGMIOS_URL, VECTOR_ZERO_TIME_MS,
# ref-script UTxOs, LIVE_CHAIN). No env_file ordering / override magic.
- path: ../../supplier/.env.glm-chat
required: false
environment:
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
CAPABILITY_KIND: chat-session
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions
# (Hetzner serves at https://inference.hetzner.com/api/v1/...).
OPENAI_BASE_URL: https://inference.hetzner.com/api
# Reasoning-style models can be slow; mirror the one-off compose.
OPENAI_TIMEOUT_MS: "300000"
# Idle end (supplier Submits the transcript): single-slot suppliers must
# free up quickly (the gateway transparently reopens a session on the
# next turn). Mirrors the kimi-chat fleet.
CHAT_IDLE_TIMEOUT_MS: "900000"
CHAT_SETTLE_MODE: ticket
MAX_CHAT_SESSIONS: "8"
PORT: "8080"
# Enable LiveOgmiosProvider so Claim/Submit actually broadcast.
LIVE_CHAIN: "1"
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
# come from the operator-provided supplier/.env.glm-chat on a mainnet host.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-glm-chat-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-glm-chat-mainnet.rule=Host(`mp-suppliers-glm-chat.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-glm-chat-mainnet.middlewares=mp-suppliers-glm-chat-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-glm-chat-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-glm-chat-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-glm-chat-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-glm-chat-mainnet-tls.rule=Host(`mp-suppliers-glm-chat.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-glm-chat-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-glm-chat-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: traefik-net

View File

@@ -0,0 +1,89 @@
# deploy/mainnet/docker-compose.supplier-glm.yml — Supplier — MAINNET
# (Hetzner Inference / GLM-5.2-NVFP4 — one-off `llm.text.generate.v1`)
#
# Hetzner-backed supplier. LLM_BACKEND=openai pointed directly at Hetzner's
# hosted inference API (OpenAI-compatible at https://inference.hetzner.com/api).
# No local proxy container — reachable over public HTTPS.
#
# One of 8 Hetzner-backed suppliers (4 models x {one-off, chat-session});
# fleet runbook: docs/HETZNER_SETUP.md.
#
# Pre-flight:
# 1. DNS: mp-suppliers-glm.vector.apexfusion.org → host
# (individual DNS-only A record → 91.98.147.172; NO wildcard exists —
# letsencrypt HTTP-01 issuance needs the unproxied direct record)
# 2. supplier/.env.glm written on host (chmod 600) with at minimum:
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
# SUPPLIER_PUB_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
# LIVE_CHAIN=1
# 3. Networks already exist (created by the canonical supplier compose):
# docker network create marketplace-mainnet-net # one-shot
# # traefik-net is created by the Traefik project
name: marketplace-mainnet-supplier-glm
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-glm
restart: unless-stopped
env_file:
# Self-contained env for this Hetzner-backed variant. Holds this
# supplier's distinct SUPPLIER_PRIV_KEY_HEX (+ derived wallet vars),
# ADVERT_REF, the shared Hetzner OPENAI_API_KEY, and the shared mainnet
# plumbing duplicated in full (NETWORK_ID, OGMIOS_URL, VECTOR_ZERO_TIME_MS,
# ref-script UTxOs, LIVE_CHAIN). No env_file ordering / override magic.
- path: ../../supplier/.env.glm
required: false
environment:
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions
# (Hetzner serves at https://inference.hetzner.com/api/v1/...).
OPENAI_BASE_URL: https://inference.hetzner.com/api
# Reasoning-style models can be slow; matches the advert's
# max_processing_ms (300000).
OPENAI_TIMEOUT_MS: "300000"
# OPENAI_MAX_TOKENS intentionally unset: no max_tokens forwarded upstream,
# the provider grants context − prompt. Advert cap = model context length.
PORT: "8080"
# Enable LiveOgmiosProvider so submitTx/awaitTx actually broadcast.
LIVE_CHAIN: "1"
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
# come from the operator-provided supplier/.env.glm on a mainnet host.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-glm-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-glm-mainnet.rule=Host(`mp-suppliers-glm.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-glm-mainnet.middlewares=mp-suppliers-glm-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-glm-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-glm-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-glm-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-glm-mainnet-tls.rule=Host(`mp-suppliers-glm.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-glm-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-glm-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: traefik-net

View File

@@ -61,6 +61,12 @@ services:
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-gpt-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-gpt-mainnet.rule=Host(`mp-suppliers-gpt.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-gpt-mainnet.middlewares=mp-suppliers-gpt-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-gpt-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-gpt-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-gpt-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-gpt-mainnet-tls.rule=Host(`mp-suppliers-gpt.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-gpt-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-gpt-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file

View File

@@ -0,0 +1,73 @@
# deploy/mainnet/docker-compose.supplier-kimi-2.yml — Supplier — MAINNET (OpenRouter / Kimi K2.6 replica #2)
#
# Parallel-capacity replica of supplier-kimi (OpenRouter, moonshotai/kimi-k2.6).
# Identical to docker-compose.supplier-kimi.yml except for the instance name,
# container name, env file, Traefik host/router, and OPENAI_TIMEOUT_MS (raised
# to 3600000 to match this advert's max_processing_ms=3600000, max_output_tokens=262144).
# Shares the same OpenRouter API key as the other kimi suppliers (accepted
# concurrency ceiling; supplier has no 429/backoff).
#
# Pre-flight:
# 1. DNS (proxied): mp-suppliers-kimi-2.vector.apexfusion.org → host
# 2. supplier/.env.kimi-2 on host (chmod 600): SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY,
# OGMIOS_URL, NETWORK_ID=1, VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO,
# ADVERT_REF, LIVE_CHAIN=1
# 3. Networks marketplace-mainnet-net + traefik-net already exist.
name: marketplace-mainnet-supplier-kimi-2
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-kimi-2
restart: unless-stopped
env_file:
- path: ../../supplier/.env.kimi-2
required: false
environment:
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
OPENAI_BASE_URL: https://openrouter.ai/api
# Raised to match the advert's max_processing_ms=3600000 (60 min) so the
# upstream call isn't aborted before the advertised processing deadline.
OPENAI_TIMEOUT_MS: "3600000"
PORT: "8080"
LIVE_CHAIN: "1"
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
# come from supplier/.env.kimi-2 on the mainnet host (footgun avoidance).
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet.rule=Host(`mp-suppliers-kimi-2.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet.middlewares=mp-suppliers-kimi-2-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-kimi-2-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-kimi-2-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet-tls.rule=Host(`mp-suppliers-kimi-2.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-kimi-2-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: traefik-net

View File

@@ -0,0 +1,73 @@
# deploy/mainnet/docker-compose.supplier-kimi-3.yml — Supplier — MAINNET (OpenRouter / Kimi K2.6 replica #3)
#
# Parallel-capacity replica of supplier-kimi (OpenRouter, moonshotai/kimi-k2.6).
# Identical to docker-compose.supplier-kimi.yml except for the instance name,
# container name, env file, Traefik host/router, and OPENAI_TIMEOUT_MS (raised
# to 3600000 to match this advert's max_processing_ms=3600000, max_output_tokens=262144).
# Shares the same OpenRouter API key as the other kimi suppliers (accepted
# concurrency ceiling; supplier has no 429/backoff).
#
# Pre-flight:
# 1. DNS (proxied): mp-suppliers-kimi-3.vector.apexfusion.org → host
# 2. supplier/.env.kimi-3 on host (chmod 600): SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY,
# OGMIOS_URL, NETWORK_ID=1, VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO,
# ADVERT_REF, LIVE_CHAIN=1
# 3. Networks marketplace-mainnet-net + traefik-net already exist.
name: marketplace-mainnet-supplier-kimi-3
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-kimi-3
restart: unless-stopped
env_file:
- path: ../../supplier/.env.kimi-3
required: false
environment:
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
OPENAI_BASE_URL: https://openrouter.ai/api
# Raised to match the advert's max_processing_ms=3600000 (60 min) so the
# upstream call isn't aborted before the advertised processing deadline.
OPENAI_TIMEOUT_MS: "3600000"
PORT: "8080"
LIVE_CHAIN: "1"
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
# come from supplier/.env.kimi-3 on the mainnet host (footgun avoidance).
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet.rule=Host(`mp-suppliers-kimi-3.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet.middlewares=mp-suppliers-kimi-3-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-kimi-3-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-kimi-3-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet-tls.rule=Host(`mp-suppliers-kimi-3.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-kimi-3-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: traefik-net

View File

@@ -0,0 +1,73 @@
# deploy/mainnet/docker-compose.supplier-kimi-4.yml — Supplier — MAINNET (OpenRouter / Kimi K2.6 replica #4)
#
# Parallel-capacity replica of supplier-kimi (OpenRouter, moonshotai/kimi-k2.6).
# Identical to docker-compose.supplier-kimi.yml except for the instance name,
# container name, env file, Traefik host/router, and OPENAI_TIMEOUT_MS (raised
# to 3600000 to match this advert's max_processing_ms=3600000, max_output_tokens=262144).
# Shares the same OpenRouter API key as the other kimi suppliers (accepted
# concurrency ceiling; supplier has no 429/backoff).
#
# Pre-flight:
# 1. DNS (proxied): mp-suppliers-kimi-4.vector.apexfusion.org → host
# 2. supplier/.env.kimi-4 on host (chmod 600): SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY,
# OGMIOS_URL, NETWORK_ID=1, VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO,
# ADVERT_REF, LIVE_CHAIN=1
# 3. Networks marketplace-mainnet-net + traefik-net already exist.
name: marketplace-mainnet-supplier-kimi-4
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-kimi-4
restart: unless-stopped
env_file:
- path: ../../supplier/.env.kimi-4
required: false
environment:
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
OPENAI_BASE_URL: https://openrouter.ai/api
# Raised to match the advert's max_processing_ms=3600000 (60 min) so the
# upstream call isn't aborted before the advertised processing deadline.
OPENAI_TIMEOUT_MS: "3600000"
PORT: "8080"
LIVE_CHAIN: "1"
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
# come from supplier/.env.kimi-4 on the mainnet host (footgun avoidance).
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet.rule=Host(`mp-suppliers-kimi-4.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet.middlewares=mp-suppliers-kimi-4-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-kimi-4-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-kimi-4-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet-tls.rule=Host(`mp-suppliers-kimi-4.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-kimi-4-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: traefik-net

View File

@@ -0,0 +1,84 @@
# deploy/mainnet/docker-compose.supplier-kimi-chat-2.yml — Supplier — MAINNET
# (OpenRouter / Kimi K2.6 — MULTI-TURN CHAT capability `llm.chat.v1`, REPLICA 2)
#
# Second llm.chat.v1 replica: a chat session holds a supplier's single slot for
# the whole conversation, so concurrent chat users = number of chat suppliers.
# Deployed for the shared-demo-key endpoint (session-backed /chat/completions).
# Identical to docker-compose.supplier-kimi-chat.yml except for its own wallet,
# advert, env file and hostname.
#
# Pre-flight:
# 1. DNS A-record: mp-suppliers-kimi-chat-2.vector.apexfusion.org → host IP
# 2. New supplier wallet (gen-keypair), funded with advert bond + fee float.
# 3. Post a fresh llm.chat.v1 advert for this wallet:
# --capability-id llm.chat.v1 --model moonshotai/kimi-k2.6
# --max-processing-ms 1800000 (same price as the primary chat advert)
# 4. supplier/.env.kimi-chat-2 written on host (chmod 600) with at minimum:
# SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
# LIVE_CHAIN=1
name: marketplace-mainnet-supplier-kimi-chat-2
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-kimi-chat-2
restart: unless-stopped
env_file:
- path: ../../supplier/.env.kimi-chat-2
required: false
environment:
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
CAPABILITY_KIND: chat-session
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
OPENAI_BASE_URL: https://openrouter.ai/api
# Kimi K2.6 generations can be slow; mirror the one-off Kimi compose.
OPENAI_TIMEOUT_MS: "300000"
# 2.5 min idle end: single-slot suppliers must free up quickly for the
# shared demo endpoint (the gateway transparently reopens on next turn).
CHAT_IDLE_TIMEOUT_MS: "900000"
CHAT_SETTLE_MODE: ticket
MAX_CHAT_SESSIONS: "8"
PORT: "8080"
# Enable LiveOgmiosProvider so Claim/Submit actually broadcast.
LIVE_CHAIN: "1"
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
# come from the operator-provided supplier/.env.kimi-chat-2 on a mainnet host.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet.rule=Host(`mp-suppliers-kimi-chat-2.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet.middlewares=mp-suppliers-kimi-chat-2-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-kimi-chat-2-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-kimi-chat-2-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet-tls.rule=Host(`mp-suppliers-kimi-chat-2.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-kimi-chat-2-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: traefik-net

View File

@@ -0,0 +1,84 @@
# deploy/mainnet/docker-compose.supplier-kimi-chat-3.yml — Supplier — MAINNET
# (OpenRouter / Kimi K2.6 — MULTI-TURN CHAT capability `llm.chat.v1`, REPLICA 3)
#
# Third llm.chat.v1 replica: a chat session holds a supplier's single slot for
# the whole conversation, so concurrent chat users = number of chat suppliers.
# Deployed for the shared-demo-key endpoint (session-backed /chat/completions).
# Identical to docker-compose.supplier-kimi-chat.yml except for its own wallet,
# advert, env file and hostname.
#
# Pre-flight:
# 1. DNS A-record: mp-suppliers-kimi-chat-3.vector.apexfusion.org → host IP
# 2. New supplier wallet (gen-keypair), funded with advert bond + fee float.
# 3. Post a fresh llm.chat.v1 advert for this wallet:
# --capability-id llm.chat.v1 --model moonshotai/kimi-k2.6
# --max-processing-ms 1800000 (same price as the primary chat advert)
# 4. supplier/.env.kimi-chat-3 written on host (chmod 600) with at minimum:
# SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
# LIVE_CHAIN=1
name: marketplace-mainnet-supplier-kimi-chat-3
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-kimi-chat-3
restart: unless-stopped
env_file:
- path: ../../supplier/.env.kimi-chat-3
required: false
environment:
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
CAPABILITY_KIND: chat-session
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
OPENAI_BASE_URL: https://openrouter.ai/api
# Kimi K2.6 generations can be slow; mirror the one-off Kimi compose.
OPENAI_TIMEOUT_MS: "300000"
# 2.5 min idle end: single-slot suppliers must free up quickly for the
# shared demo endpoint (the gateway transparently reopens on next turn).
CHAT_IDLE_TIMEOUT_MS: "900000"
CHAT_SETTLE_MODE: ticket
MAX_CHAT_SESSIONS: "8"
PORT: "8080"
# Enable LiveOgmiosProvider so Claim/Submit actually broadcast.
LIVE_CHAIN: "1"
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
# come from the operator-provided supplier/.env.kimi-chat-3 on a mainnet host.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet.rule=Host(`mp-suppliers-kimi-chat-3.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet.middlewares=mp-suppliers-kimi-chat-3-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-kimi-chat-3-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-kimi-chat-3-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet-tls.rule=Host(`mp-suppliers-kimi-chat-3.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-kimi-chat-3-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: traefik-net

View File

@@ -49,8 +49,12 @@ services:
OPENAI_BASE_URL: https://openrouter.ai/api
# Kimi K2.6 generations can be slow; mirror the one-off Kimi compose.
OPENAI_TIMEOUT_MS: "300000"
# Auto-end an idle session after 5 min (supplier Submits the transcript).
CHAT_IDLE_TIMEOUT_MS: "300000"
# 2.5 min idle end (supplier Submits the transcript): single-slot
# suppliers must free up quickly for the shared demo endpoint (the
# gateway transparently reopens a session on the next turn).
CHAT_IDLE_TIMEOUT_MS: "900000"
CHAT_SETTLE_MODE: ticket
MAX_CHAT_SESSIONS: "8"
PORT: "8080"
# Enable LiveOgmiosProvider so Claim/Submit actually broadcast.
LIVE_CHAIN: "1"
@@ -70,6 +74,12 @@ services:
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet.rule=Host(`mp-suppliers-kimi-chat.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet.middlewares=mp-suppliers-kimi-chat-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-kimi-chat-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-kimi-chat-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet-tls.rule=Host(`mp-suppliers-kimi-chat.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-kimi-chat-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file

View File

@@ -0,0 +1,101 @@
# deploy/mainnet/docker-compose.supplier-kimi-code-chat.yml — Supplier — MAINNET
# (Hetzner Inference / Kimi-K2.7-Code — MULTI-TURN CHAT capability `llm.chat.v1`)
#
# The chat-session sibling of docker-compose.supplier-kimi-code.yml. Same
# image, same Hetzner backend (Kimi-K2.7-Code), but CAPABILITY_KIND=chat-session so
# it mounts /v1/chat/{start,message,end} (escrow bookends, off-chain streaming
# turns) instead of the one-off /v1/chat/completions route.
#
# A SEPARATE process/advert from the one-off supplier on purpose: the chat
# session holds the supplier's single-slot lock for the whole conversation
# (minutes), which would starve one-off traffic if co-hosted.
#
# One of 8 Hetzner-backed suppliers (4 models x {one-off, chat-session});
# fleet runbook: docs/HETZNER_SETUP.md.
#
# Pre-flight:
# 1. DNS: mp-suppliers-kimi-code-chat.vector.apexfusion.org → host
# (individual DNS-only A record → 91.98.147.172; NO wildcard exists —
# letsencrypt HTTP-01 issuance needs the unproxied direct record)
# 2. supplier/.env.kimi-code-chat written on host (chmod 600) with at minimum:
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
# SUPPLIER_PUB_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
# LIVE_CHAIN=1
# The advert must be posted with --capability-id llm.chat.v1 and a large
# --max-processing-ms (~1800000) so Claim+Submit both land before deliver_by.
# 3. Networks already exist (created by the canonical supplier compose):
# docker network create marketplace-mainnet-net # one-shot
# # traefik-net is created by the Traefik project
name: marketplace-mainnet-supplier-kimi-code-chat
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-kimi-code-chat
restart: unless-stopped
env_file:
# Self-contained env for this Hetzner-backed variant. Holds this
# supplier's distinct SUPPLIER_PRIV_KEY_HEX (+ derived wallet vars),
# ADVERT_REF, the shared Hetzner OPENAI_API_KEY, and the shared mainnet
# plumbing duplicated in full (NETWORK_ID, OGMIOS_URL, VECTOR_ZERO_TIME_MS,
# ref-script UTxOs, LIVE_CHAIN). No env_file ordering / override magic.
- path: ../../supplier/.env.kimi-code-chat
required: false
environment:
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
CAPABILITY_KIND: chat-session
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions
# (Hetzner serves at https://inference.hetzner.com/api/v1/...).
OPENAI_BASE_URL: https://inference.hetzner.com/api
# Reasoning-style models can be slow; mirror the one-off compose.
OPENAI_TIMEOUT_MS: "300000"
# Idle end (supplier Submits the transcript): single-slot suppliers must
# free up quickly (the gateway transparently reopens a session on the
# next turn). Mirrors the kimi-chat fleet.
CHAT_IDLE_TIMEOUT_MS: "900000"
CHAT_SETTLE_MODE: ticket
MAX_CHAT_SESSIONS: "8"
PORT: "8080"
# Enable LiveOgmiosProvider so Claim/Submit actually broadcast.
LIVE_CHAIN: "1"
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
# come from the operator-provided supplier/.env.kimi-code-chat on a mainnet host.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-kimi-code-chat-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-kimi-code-chat-mainnet.rule=Host(`mp-suppliers-kimi-code-chat.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-code-chat-mainnet.middlewares=mp-suppliers-kimi-code-chat-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-kimi-code-chat-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-kimi-code-chat-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-kimi-code-chat-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-kimi-code-chat-mainnet-tls.rule=Host(`mp-suppliers-kimi-code-chat.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-code-chat-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-kimi-code-chat-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: traefik-net

View File

@@ -0,0 +1,89 @@
# deploy/mainnet/docker-compose.supplier-kimi-code.yml — Supplier — MAINNET
# (Hetzner Inference / Kimi-K2.7-Code — one-off `llm.text.generate.v1`)
#
# Hetzner-backed supplier. LLM_BACKEND=openai pointed directly at Hetzner's
# hosted inference API (OpenAI-compatible at https://inference.hetzner.com/api).
# No local proxy container — reachable over public HTTPS.
#
# One of 8 Hetzner-backed suppliers (4 models x {one-off, chat-session});
# fleet runbook: docs/HETZNER_SETUP.md.
#
# Pre-flight:
# 1. DNS: mp-suppliers-kimi-code.vector.apexfusion.org → host
# (individual DNS-only A record → 91.98.147.172; NO wildcard exists —
# letsencrypt HTTP-01 issuance needs the unproxied direct record)
# 2. supplier/.env.kimi-code written on host (chmod 600) with at minimum:
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
# SUPPLIER_PUB_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
# LIVE_CHAIN=1
# 3. Networks already exist (created by the canonical supplier compose):
# docker network create marketplace-mainnet-net # one-shot
# # traefik-net is created by the Traefik project
name: marketplace-mainnet-supplier-kimi-code
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-kimi-code
restart: unless-stopped
env_file:
# Self-contained env for this Hetzner-backed variant. Holds this
# supplier's distinct SUPPLIER_PRIV_KEY_HEX (+ derived wallet vars),
# ADVERT_REF, the shared Hetzner OPENAI_API_KEY, and the shared mainnet
# plumbing duplicated in full (NETWORK_ID, OGMIOS_URL, VECTOR_ZERO_TIME_MS,
# ref-script UTxOs, LIVE_CHAIN). No env_file ordering / override magic.
- path: ../../supplier/.env.kimi-code
required: false
environment:
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions
# (Hetzner serves at https://inference.hetzner.com/api/v1/...).
OPENAI_BASE_URL: https://inference.hetzner.com/api
# Reasoning-style models can be slow; matches the advert's
# max_processing_ms (300000).
OPENAI_TIMEOUT_MS: "300000"
# OPENAI_MAX_TOKENS intentionally unset: no max_tokens forwarded upstream,
# the provider grants context − prompt. Advert cap = model context length.
PORT: "8080"
# Enable LiveOgmiosProvider so submitTx/awaitTx actually broadcast.
LIVE_CHAIN: "1"
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
# come from the operator-provided supplier/.env.kimi-code on a mainnet host.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-kimi-code-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-kimi-code-mainnet.rule=Host(`mp-suppliers-kimi-code.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-code-mainnet.middlewares=mp-suppliers-kimi-code-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-kimi-code-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-kimi-code-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-kimi-code-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-kimi-code-mainnet-tls.rule=Host(`mp-suppliers-kimi-code.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-code-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-kimi-code-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: traefik-net

View File

@@ -48,8 +48,10 @@ services:
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
OPENAI_BASE_URL: https://openrouter.ai/api
# Kimi K2.6 generations can be slow; mirror the DeepSeek/GPT composes.
OPENAI_TIMEOUT_MS: "300000"
# Raised to match the advert's max_processing_ms=3600000 (60 min) so the
# upstream call isn't aborted before the advertised processing deadline
# (matches the kimi-2/3/4 replicas).
OPENAI_TIMEOUT_MS: "3600000"
PORT: "8080"
# Enable LiveOgmiosProvider so submitTx/awaitTx actually broadcast.
# Without this the supplier silently falls back to ReadOnlyOgmiosProvider
@@ -73,6 +75,12 @@ services:
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-kimi-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-kimi-mainnet.rule=Host(`mp-suppliers-kimi.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-mainnet.middlewares=mp-suppliers-kimi-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-kimi-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-kimi-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-kimi-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-kimi-mainnet-tls.rule=Host(`mp-suppliers-kimi.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-kimi-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-kimi-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file

View File

@@ -0,0 +1,101 @@
# deploy/mainnet/docker-compose.supplier-qwen35b-chat.yml — Supplier — MAINNET
# (Hetzner Inference / Qwen/Qwen3.6-35B-A3B-FP8 — MULTI-TURN CHAT capability `llm.chat.v1`)
#
# The chat-session sibling of docker-compose.supplier-qwen35b.yml. Same
# image, same Hetzner backend (Qwen/Qwen3.6-35B-A3B-FP8), but CAPABILITY_KIND=chat-session so
# it mounts /v1/chat/{start,message,end} (escrow bookends, off-chain streaming
# turns) instead of the one-off /v1/chat/completions route.
#
# A SEPARATE process/advert from the one-off supplier on purpose: the chat
# session holds the supplier's single-slot lock for the whole conversation
# (minutes), which would starve one-off traffic if co-hosted.
#
# One of 8 Hetzner-backed suppliers (4 models x {one-off, chat-session});
# fleet runbook: docs/HETZNER_SETUP.md.
#
# Pre-flight:
# 1. DNS: mp-suppliers-qwen35b-chat.vector.apexfusion.org → host
# (individual DNS-only A record → 91.98.147.172; NO wildcard exists —
# letsencrypt HTTP-01 issuance needs the unproxied direct record)
# 2. supplier/.env.qwen35b-chat written on host (chmod 600) with at minimum:
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
# SUPPLIER_PUB_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
# LIVE_CHAIN=1
# The advert must be posted with --capability-id llm.chat.v1 and a large
# --max-processing-ms (~1800000) so Claim+Submit both land before deliver_by.
# 3. Networks already exist (created by the canonical supplier compose):
# docker network create marketplace-mainnet-net # one-shot
# # traefik-net is created by the Traefik project
name: marketplace-mainnet-supplier-qwen35b-chat
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-qwen35b-chat
restart: unless-stopped
env_file:
# Self-contained env for this Hetzner-backed variant. Holds this
# supplier's distinct SUPPLIER_PRIV_KEY_HEX (+ derived wallet vars),
# ADVERT_REF, the shared Hetzner OPENAI_API_KEY, and the shared mainnet
# plumbing duplicated in full (NETWORK_ID, OGMIOS_URL, VECTOR_ZERO_TIME_MS,
# ref-script UTxOs, LIVE_CHAIN). No env_file ordering / override magic.
- path: ../../supplier/.env.qwen35b-chat
required: false
environment:
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
CAPABILITY_KIND: chat-session
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions
# (Hetzner serves at https://inference.hetzner.com/api/v1/...).
OPENAI_BASE_URL: https://inference.hetzner.com/api
# Reasoning-style models can be slow; mirror the one-off compose.
OPENAI_TIMEOUT_MS: "300000"
# Idle end (supplier Submits the transcript): single-slot suppliers must
# free up quickly (the gateway transparently reopens a session on the
# next turn). Mirrors the kimi-chat fleet.
CHAT_IDLE_TIMEOUT_MS: "900000"
CHAT_SETTLE_MODE: ticket
MAX_CHAT_SESSIONS: "8"
PORT: "8080"
# Enable LiveOgmiosProvider so Claim/Submit actually broadcast.
LIVE_CHAIN: "1"
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
# come from the operator-provided supplier/.env.qwen35b-chat on a mainnet host.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-qwen35b-chat-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-qwen35b-chat-mainnet.rule=Host(`mp-suppliers-qwen35b-chat.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-qwen35b-chat-mainnet.middlewares=mp-suppliers-qwen35b-chat-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-qwen35b-chat-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-qwen35b-chat-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-qwen35b-chat-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-qwen35b-chat-mainnet-tls.rule=Host(`mp-suppliers-qwen35b-chat.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-qwen35b-chat-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-qwen35b-chat-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: traefik-net

View File

@@ -0,0 +1,89 @@
# deploy/mainnet/docker-compose.supplier-qwen35b.yml — Supplier — MAINNET
# (Hetzner Inference / Qwen/Qwen3.6-35B-A3B-FP8 — one-off `llm.text.generate.v1`)
#
# Hetzner-backed supplier. LLM_BACKEND=openai pointed directly at Hetzner's
# hosted inference API (OpenAI-compatible at https://inference.hetzner.com/api).
# No local proxy container — reachable over public HTTPS.
#
# One of 8 Hetzner-backed suppliers (4 models x {one-off, chat-session});
# fleet runbook: docs/HETZNER_SETUP.md.
#
# Pre-flight:
# 1. DNS: mp-suppliers-qwen35b.vector.apexfusion.org → host
# (individual DNS-only A record → 91.98.147.172; NO wildcard exists —
# letsencrypt HTTP-01 issuance needs the unproxied direct record)
# 2. supplier/.env.qwen35b written on host (chmod 600) with at minimum:
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
# SUPPLIER_PUB_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
# LIVE_CHAIN=1
# 3. Networks already exist (created by the canonical supplier compose):
# docker network create marketplace-mainnet-net # one-shot
# # traefik-net is created by the Traefik project
name: marketplace-mainnet-supplier-qwen35b
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-qwen35b
restart: unless-stopped
env_file:
# Self-contained env for this Hetzner-backed variant. Holds this
# supplier's distinct SUPPLIER_PRIV_KEY_HEX (+ derived wallet vars),
# ADVERT_REF, the shared Hetzner OPENAI_API_KEY, and the shared mainnet
# plumbing duplicated in full (NETWORK_ID, OGMIOS_URL, VECTOR_ZERO_TIME_MS,
# ref-script UTxOs, LIVE_CHAIN). No env_file ordering / override magic.
- path: ../../supplier/.env.qwen35b
required: false
environment:
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions
# (Hetzner serves at https://inference.hetzner.com/api/v1/...).
OPENAI_BASE_URL: https://inference.hetzner.com/api
# Reasoning-style models can be slow; matches the advert's
# max_processing_ms (300000).
OPENAI_TIMEOUT_MS: "300000"
# OPENAI_MAX_TOKENS intentionally unset: no max_tokens forwarded upstream,
# the provider grants context − prompt. Advert cap = model context length.
PORT: "8080"
# Enable LiveOgmiosProvider so submitTx/awaitTx actually broadcast.
LIVE_CHAIN: "1"
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
# come from the operator-provided supplier/.env.qwen35b on a mainnet host.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-qwen35b-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-qwen35b-mainnet.rule=Host(`mp-suppliers-qwen35b.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-qwen35b-mainnet.middlewares=mp-suppliers-qwen35b-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-qwen35b-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-qwen35b-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-qwen35b-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-qwen35b-mainnet-tls.rule=Host(`mp-suppliers-qwen35b.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-qwen35b-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-qwen35b-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: traefik-net

View File

@@ -65,6 +65,12 @@ services:
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-tts-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-tts-mainnet.rule=Host(`mp-suppliers-tts.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-tts-mainnet.middlewares=mp-suppliers-tts-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-tts-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-tts-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-tts-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-tts-mainnet-tls.rule=Host(`mp-suppliers-tts.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-tts-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-tts-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file

View File

@@ -60,6 +60,12 @@ services:
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-mainnet.rule=Host(`mp-suppliers.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-mainnet.middlewares=mp-suppliers-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-mainnet-tls.rule=Host(`mp-suppliers.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file

View File

@@ -0,0 +1,40 @@
# deploy/mainnet/docker-compose.wallet-monitor.yml — Wallet-balance Slack alerter — MAINNET
#
# A ONE-SHOT, not a long-running server: it checks every wallet listed in
# wallet-monitor/wallets.json, posts a Slack alert for any below threshold,
# then exits. There is no port, healthcheck, or restart policy.
#
# Invoked hourly by cron (see /etc/cron.d/wallet-monitor):
# cd /root/agents-marketplace && \
# docker compose -f deploy/mainnet/docker-compose.wallet-monitor.yml run --rm wallet-monitor
#
# One-time setup on the box:
# 1) populate wallet-monitor/wallets.json (the 7 addresses — see the package README)
# 2) create wallet-monitor/.env with SLACK_WEBHOOK_URL=...
# 3) docker compose -f deploy/mainnet/docker-compose.wallet-monitor.yml build
# 4) smoke test: docker compose -f ... run --rm -e TEST=1 wallet-monitor
name: marketplace-mainnet-wallet-monitor
services:
wallet-monitor:
build:
context: ../..
dockerfile: wallet-monitor/Dockerfile
env_file:
- path: ../../wallet-monitor/.env
required: false
environment:
# Mainnet-only service; pin the public Ogmios endpoint explicitly.
# (config.ts also defaults to this, but this compose is mainnet-specific.)
OGMIOS_URL: https://ogmios.vector.mainnet.apexfusion.org
volumes:
# The wallet list is read-only. The dedup state dir is writable and
# persists across runs (data/ is gitignored).
- ../../wallet-monitor/wallets.json:/repo/wallet-monitor/wallets.json:ro
- ../../wallet-monitor/data:/repo/wallet-monitor/data
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"

View File

@@ -8,13 +8,13 @@
## 1. Goal and scope
**What this is.** A two-sided marketplace where buyers (agents or humans) submit prompts to suppliers running inference on their own hardware, pay with AP3X via on-chain bonded escrow on Vector L2, and receive signed-receipt responses. The primary validation goal is **technical feasibility + lifecycle** ("can it be done end-to-end"), not demand modelling or economic stress testing.
**What this is.** A two-sided marketplace where buyers (agents or humans) submit prompts to suppliers running inference on their own hardware, with each job settled in AP3X through on-chain bonded escrow on Vector L2, and receive signed-receipt responses. The primary validation goal is **technical feasibility + lifecycle** ("can it be done end-to-end"), not demand modelling or economic stress testing.
**What v1 is NOT.**
- Not agentic on the supplier side. Suppliers answer single prompts. Buyer agents drive multiple prompts.
- Not LLM-only in design. Capability model is open from day 1 (whisper / kokoro / image generation are planned siblings), but the first supplier is an LLM.
- Not multi-slot. Single-slot per supplier process. Multi-capability operators run multiple processes.
- Not multi-chain. Vector testnet first, mainnet later. Masumi / Sokosumi / USDM is target #2 (adapter, not fork).
- Not multi-chain. Vector only (testnet and mainnet).
- Not disputed. M1 ships happy-path escrow only. Module-1 disputes wire in M2.
- Not streaming. `stream: true` is rejected by suppliers.
- Not tool-calling. `tools` / `tool_choice` / `functions` are rejected by suppliers.
@@ -24,7 +24,7 @@
| # | Decision |
|---|---|
| 1 | Payment token: **AP3X** (Vector testnet, later mainnet). Masumi = USDM, target #2. |
| 1 | Settlement asset: **AP3X**, bonded in escrow; jobs settle in AP3X on accepted work. |
| 2 | v1 scope: **Option C** — happy-path escrow only, no disputes. Module-1 wiring is milestone 2. |
| 3 | Module-1 contract source: TBD — to be located or restored before M2. Not blocking M0–M1. |
| 4 | Evidence: **signed receipt** `{prompt_hash, response_hash, model, tokens, wallclock}` + Ed25519 supplier signature. Hash on-chain, plaintext off-chain. |
@@ -283,7 +283,6 @@ Malformed datum · escrow to wrong script · supplier claims escrow addressed to
| **M1** | Happy-path end-to-end, Hetzner CPU supplier | 2–3 wks | `qwen2.5:0.5b` on Hetzner, buyer posts → supplier claims → submits → buyer accepts; full Vector testnet lifecycle; indexer serves discovery |
| **M2** | Module-1 dispute wiring | 2–3 wks post-M1 | Contract source located; supplier=claimer / buyer=auditor mapping; Dispute state; evidence URI; mainnet-dry-run lifecycle |
| **M3** | Capability expansion + local lab | 2–4 wks (may overlap M2) | `speech.transcribe.v1` via Whisper live; local-lab GPU supplier with larger model; heterogeneous discovery validated |
| **M4** | Masumi / Sokosumi adapter | later | MIP-003 HTTP shim; MIP-002 NFT registration; USDM payment path; Sokosumi listing |
## 9. Open follow-ups (not blocking M0 / M1)
@@ -310,5 +309,3 @@ Malformed datum · escrow to wrong script · supplier claims escrow addressed to
- Apex-dashboard CBOR decoders: `apex-dashboard/server/cbor-decoder.ts`
- Apex-dashboard test discipline: `apex-dashboard/tests/`
- Prior Python reference (AP3X buyer/supplier): `./docs/buyer/`, `./docs/supplier/`
- Masumi docs: https://docs.masumi.network/
- MIP-003 spec: https://docs.masumi.network/mips/_mip-003

View File

@@ -1,412 +0,0 @@
---
marp: true
paginate: true
size: 16:9
---
<!--
EXECUTIVE_SUMMARY.md — slide-style overview of the Local Agents Marketplace.
Build PDF + PPTX with: bash docs/build-deck.sh
Edit this file; mermaid blocks are pre-rendered to docs/img/diagram-*.png.
-->
<style>
/* ── Local Agents Marketplace — unified dark theme ────────────────────
* Every slide uses the same navy→indigo gradient as the title slide so
* the deck reads as one continuous document. Title and closing slides
* keep the centered hero treatment; content slides stay top-aligned but
* share the same palette and accent rules. */
:root {
--ink: #f1f5f9; /* primary text on dark gradient */
--muted: #94a3b8;
--accent: #818cf8; /* indigo-400, readable on the dark gradient */
--accent-deep: #4f46e5; /* used for solid accents (h2 bar, table head) */
--accent-soft: rgba(129,140,248,0.12);
--gradient: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #312e81 100%);
--row-even: rgba(255,255,255,0.04);
--row-odd: rgba(255,255,255,0.02);
--border: rgba(255,255,255,0.10);
}
section {
background: var(--gradient);
color: var(--ink);
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
font-size: 22px;
padding: 40px 60px;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
section::after {
content: attr(data-marpit-pagination) ' / ' attr(data-marpit-pagination-total);
color: var(--muted);
font-size: 13px;
}
h1 {
font-size: 48px;
font-weight: 700;
color: #ffffff;
border-bottom: 4px solid var(--accent);
padding-bottom: 10px;
margin: 0 0 18px 0;
}
h2 {
font-size: 34px;
font-weight: 700;
color: #ffffff;
margin: 0 0 14px 0;
}
h2::before {
content: '';
display: inline-block;
width: 6px;
height: 28px;
background: var(--accent);
margin-right: 14px;
vertical-align: middle;
border-radius: 2px;
}
h3 { color: #c7d2fe; font-weight: 600; font-size: 24px; margin: 8px 0; }
p, li { margin: 6px 0; line-height: 1.45; }
ul, ol { margin: 8px 0 12px 0; padding-left: 28px; }
strong { color: #ffffff; }
a { color: #a5b4fc; }
blockquote {
border-left: 4px solid var(--accent);
background: rgba(255,255,255,0.06);
padding: 10px 18px;
color: #e0e7ff;
border-radius: 0 8px 8px 0;
margin: 10px 0;
}
code {
background: rgba(255,255,255,0.10);
color: #e0e7ff;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
/* Constrain mermaid images so they always fit on a 720px-tall slide
* (16:9 @ 1280×720 default). Soft white card behind the diagram so the
* mermaid SVG (which renders on a transparent canvas with dark ink)
* stays readable on the navy gradient. */
img {
display: block;
margin: 8px auto;
max-width: 92%;
max-height: 420px;
object-fit: contain;
background: #ffffff;
border-radius: 10px;
padding: 10px;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
}
/* Tile grid for the local-agent versatility slide. */
.backends {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
margin: 14px 0 12px 0;
}
.tile {
background: var(--accent-soft);
border: 2px solid var(--accent);
border-radius: 10px;
padding: 14px 16px;
text-align: center;
font-weight: 600;
color: #ffffff;
}
.tile small {
display: block;
margin-top: 4px;
font-weight: 400;
color: var(--muted);
font-size: 0.8em;
}
.tile.dots {
background: rgba(255,255,255,0.04);
border-style: dashed;
color: var(--muted);
font-size: 28px;
}
.pipeline {
text-align: center;
background: rgba(255,255,255,0.08);
border: 1px solid var(--border);
color: #f8fafc;
padding: 12px 18px;
border-radius: 10px;
margin: 6px auto 14px auto;
max-width: 760px;
font-weight: 600;
}
.pipeline span {
display: inline-block;
margin: 0 10px;
}
/* Tables sit on a solid white card so cell text reads cleanly against
* the dark gradient backdrop. Rounded outer corners + shadow match the
* mermaid card styling. `display:table` + `table-layout:fixed` are
* needed so the table actually consumes the full slide width — Marp's
* default theme leaves tables at content-natural width, which left
* blank gutters on either side. */
table {
display: table !important;
width: 100% !important;
table-layout: fixed;
border-collapse: collapse;
margin: 12px 0;
font-size: 0.82em;
color: #0f172a;
background: #ffffff;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
}
td, th { word-wrap: break-word; overflow-wrap: anywhere; }
th {
background: var(--accent-deep);
color: #ffffff;
text-align: left;
padding: 9px 12px;
font-weight: 700;
}
td {
padding: 7px 12px;
border-bottom: 1px solid #e2e8f0;
color: #0f172a;
}
tr:nth-child(even) td { background: #ffffff; }
tr:nth-child(odd) td { background: #f8fafc; }
/* code spans inside table cells need a light pill, not the dark one
* defined globally — otherwise they'd disappear into the white row. */
td code {
background: #eef2ff;
color: #1e293b;
}
/* Bold/strong inside cells must stay dark — the global rule paints
* <strong> white for the dark-gradient body text, which would render
* invisible against the white table card. */
td strong, th strong {
color: #0f172a;
}
th strong {
color: #ffffff;
}
/* Title slide — centered hero treatment on the same gradient. */
section.title {
text-align: center;
padding: 100px 80px;
justify-content: center;
}
section.title h1 {
display: inline-block;
font-size: 64px;
margin: 0 auto 20px auto;
}
section.title blockquote {
margin: 28px auto;
max-width: 900px;
}
/* Closing slide — same centered treatment. */
section.closing {
text-align: center;
padding: 100px 80px;
justify-content: center;
}
section.closing h2::before { display: none; }
section.closing blockquote { font-size: 26px; max-width: 900px; }
</style>
<!-- _class: title -->
# Local Agents Marketplace
### A trust-minimised, on-chain marketplace for LLM inference
Buy and sell prompt → response with **bonded escrow** on **Vector** (UTxO).
No platform fee. No central rate-limiter. Verifiable receipts on chain.
> *Status: M1 lifecycle proven end-to-end on Vector testnet — 2026-05-05.*
---
## The problem
* AI inference is **centralised** — a handful of providers, opaque pricing, account-level rate limits.
* Local-AI hardware is **idle most of the time** — GPUs and CPUs sit unused between user prompts.
* Buyers and sellers can't transact directly without a **trusted intermediary** taking a cut.
* "Verifiable" claims about which model ran are usually nothing more than the provider's word.
We need a **two-sided marketplace** where:
1. Sellers monetise spare compute,
2. Buyers get a **cryptographic receipt** of what was actually computed,
3. **Neither side has to trust the other** — bonds + on-chain settlement enforce honesty.
---
## How it works — three actors, one chain
```mermaid
flowchart LR
subgraph CHAIN[Vector — UTxO]
ADV[Advert UTxO<br/>capability, price, bonds]
ESC[Escrow UTxO<br/>locked funds + state]
end
SUP[🧠 Supplier<br/>local LLM node]
BUY[💼 Buyer<br/>app or agent]
IDX[🔭 Indexer<br/>read-only bulletin board]
SUP -->|publish advert| ADV
BUY -->|discover| IDX
IDX -.->|index events| CHAIN
BUY -->|post escrow| ESC
BUY -->|prompt + escrow_ref| SUP
SUP -->|signed receipt| BUY
SUP -->|claim & submit| ESC
BUY -->|accept & pay| ESC
```
* **Supplier** runs the inference and publishes a price.
* **Buyer** posts the work as a bonded escrow, sends the prompt, accepts the receipt.
* **Indexer** is a public mirror of chain state — anyone can run one. No platform middleman.
---
## The bonded-escrow lifecycle
```mermaid
sequenceDiagram
autonumber
participant B as Buyer
participant Chain as Vector
participant S as Supplier
participant LLM as Local LLM<br/>(OpenAI-compatible)
S->>Chain: PostAdvert (locks supplier_bond)
B->>Chain: PostEscrow → Open<br/>(locks payment + bonds)
B->>S: POST /v1/chat/completions
S->>Chain: ClaimEscrow → Claimed
S->>LLM: run inference
LLM-->>S: assistant message
S->>Chain: SubmitEscrow → Submitted
S-->>B: 200 OK + signed receipt
B->>Chain: AcceptEscrow — supplier paid
```
Every transition is on-chain, validated by an Aiken Plutus V3 script. The receipt is signed by the supplier's advert key; prompt and response hashes are bound on chain.
---
## What if someone misbehaves?
```mermaid
flowchart LR
OPEN[Open]:::norm --> CLAIMED[Claimed]:::norm --> SUBMITTED[Submitted]:::norm --> ACCEPTED[Accepted ✓]:::good
OPEN -->|deliver_by passed| RECLAIM[Reclaimed<br/>buyer recovers funds]:::warn
CLAIMED -->|deliver_by passed| RECLAIM
SUBMITTED -->|accept_window passed| RELEASE[Released<br/>supplier collects]:::warn
classDef norm fill:#e0e7ff,stroke:#4f46e5
classDef good fill:#dcfce7,stroke:#16a34a
classDef warn fill:#fef3c7,stroke:#d97706
```
| Failure mode | Recovery | Loss bearer |
|---|---|---|
| Supplier never claims/submits | Buyer **Reclaim** after `deliver_by` | Supplier — forfeits supplier_bond |
| Buyer disappears after Submit | Supplier **Release** after `submitted_at + 10 min` | Buyer — forfeits buyer_bond |
Bonds make honesty cheaper than running away. Both sides have skin in the game; no third-party arbiter is needed for the M1 happy/timeout paths.
---
## Local-agent versatility — any OpenAI-compatible engine
<div class="pipeline">
<span>💼 Buyer</span> →
<span>🧠 Supplier (OpenAI-compatible HTTP wrapper)</span> →
<span>backend ↓</span>
</div>
<div class="backends">
<div class="tile">Ollama<br/><small>local model runner</small></div>
<div class="tile">vLLM<br/><small>high-throughput GPU</small></div>
<div class="tile">llama.cpp<br/><small>CPU · Metal · ROCm</small></div>
<div class="tile">OpenRouter<br/><small>cloud API mux</small></div>
<div class="tile">openclaw / custom<br/><small>arbitrary stack</small></div>
<div class="tile dots">…<br/><small>any OpenAI-shaped API</small></div>
</div>
The supplier service is a thin HTTP wrapper — it forwards `/v1/chat/completions` to whatever backend is configured. Each supplier picks its own model, hardware, and engine, then advertises a `capability_id`. Buyers shop on capability + model + price.
---
## Capability + model = task selection
| Capability ID | Example models | Use case |
|---|---|---|
| `llm.text.generate.v1` | Llama 3 · Qwen 2.5 · Mistral · GPT-4 | Q&A, drafts, summarisation |
| `code.completion.v1` | Codestral · DeepSeek-Coder · StarCoder | IDE tab-complete, refactor |
| `vision.describe.v1` | LLaVA · Qwen-VL · Pixtral | Image captioning, OCR |
| `embedding.text.v1` | nomic-embed · BGE · ada-002 | RAG indexing |
| `audio.transcribe.v1` | whisper-large-v3 · distil-whisper | Voice-to-text |
| `…` | … | … |
* New capabilities = **just publish a new advert**. No marketplace governance gate.
* Today's deploy uses Ollama + Qwen 2.5; switching to vLLM with Llama 3 70B is a config change, not a code change.
---
## OpenClaw — agentic teams on your existing OpenAI seat
<div class="pipeline">
<span>🧠 Supplier wrapper</span> →
<span>🦾 OpenClaw</span> →
<span>🪪 OpenAI ChatGPT seat (flat-rate)</span>
</div>
* **Cost-efficient by default.** OpenClaw exposes your existing **OpenAI ChatGPT subscription** as an OpenAI-compatible API. The marketplace supplier dispatches its inference against it, so an entire **agentic team** (coders, QA automation testers, release curators…) draws from the **flat-rate seat you already pay for** — no per-token metering, no surprise invoices.
* **Reliability layer (optional, marginal cost).** OpenClaw can fall back to the **Anthropic API** or **OpenRouter** model pool when the primary seat is rate-limited, the prompt requires a different model, or you want a **second-layer hardening** of responses (e.g. cross-provider consensus on critical outputs). You only pay metered tokens for the requests that actually fall through.
---
## Proven on Vector testnet — 2026-05-05
| Step | Tx hash |
|---|---|
| Buyer posts escrow | `af762561…c2f7bc5` |
| Supplier claims | (lifecycle tx) |
| Supplier submits receipt | (lifecycle tx) |
| Buyer accepts | `125e3cfe…7dc100d` |
Funds flowed correctly: supplier received `payment + supplier_bond` (3 ADA); buyer got `buyer_bond` back.
* **Browser demo:** https://mp-buyers.vector.testnet.apexfusion.org
* **Live event stream:** https://mp-indexer.vector.testnet.apexfusion.org
* **Test suite:** 1098 unit tests passing, full lifecycle exercised in CI.
---
## Why UTxO + Vector
* **Bonded escrow is native** — Plutus validators enforce the state machine without a custom token or oracle.
* **Vector settles in seconds** — the buyer→claim→submit→accept loop fits inside a single chat-completion's wall clock.
* **AP3X token** for fees and bonds — fungible, transparent, on-chain.
* **No custom rollup, no bridge** — runs on a production chain today.
---
<!-- _class: closing -->
## The pitch in one sentence
> Local Agents Marketplace turns any OpenAI-compatible inference endpoint into a **trustlessly billable service** — buyer and supplier exchange prompt, response, and payment with on-chain proof that what was paid for is what was delivered.
### Try it
* **Indexer (live event stream):** https://mp-indexer.vector.testnet.apexfusion.org
* **Buyer:** https://mp-buyers.vector.testnet.apexfusion.org
* **Supplier:** https://mp-suppliers.vector.testnet.apexfusion.org/capability

Binary file not shown.

Binary file not shown.

133
docs/HETZNER_SETUP.md Normal file
View File

@@ -0,0 +1,133 @@
# Hetzner Inference Supplier Fleet — Setup Runbook
> **Status:** shipped. Configures 8 mainnet supplier nodes that use **Hetzner
> Inference** (`https://inference.hetzner.com`) as their **compute backend** for
> bonded inference work commissioned on Vector. Config only — no code changes.
---
## 1. What this is
Hetzner Inference is an OpenAI-compatible hosted API. Because the supplier's
upstream client (`supplier/src/openai.ts`) already speaks plain
`POST /v1/chat/completions` with a Bearer token, pointing it at Hetzner is
**pure configuration**: `LLM_BACKEND=openai` + `OPENAI_BASE_URL` + the API key.
This is the same pattern as the DeepSeek-direct and OpenRouter suppliers, and
the direct sibling of `docs/HUGGINGFACE_ROUTER_SETUP.md`.
One supplier per (model × capability) — 4 models, both the one-off
`llm.text.generate.v1` and the multi-turn `llm.chat.v1` capability:
| Supplier name | Model (on-chain + API param, verbatim) | Capability | Advert max_output_tokens |
|---|---|---|---|
| `kimi-code` | `Kimi-K2.7-Code` | `llm.text.generate.v1` | 262144 |
| `kimi-code-chat` | `Kimi-K2.7-Code` | `llm.chat.v1` | 262144 |
| `glm` | `GLM-5.2-NVFP4` | `llm.text.generate.v1` | 512000 |
| `glm-chat` | `GLM-5.2-NVFP4` | `llm.chat.v1` | 512000 |
| `ds-flash-htz` | `DeepSeek-V4-Flash-0731` | `llm.text.generate.v1` | 512000 |
| `ds-flash-htz-chat` | `DeepSeek-V4-Flash-0731` | `llm.chat.v1` | 512000 |
| `qwen35b` | `Qwen/Qwen3.6-35B-A3B-FP8` | `llm.text.generate.v1` | 262144 |
| `qwen35b-chat` | `Qwen/Qwen3.6-35B-A3B-FP8` | `llm.chat.v1` | 262144 |
Shared advert parameters: price **200000 lovelace (0.2 AP3X)** flat per job,
bonds **1000000 lovelace (1 AP3X)** both sides, `max_processing_ms` **300000**
for one-off / **1800000** for chat (the session spans the whole conversation).
`max_output_tokens` is set to the model's context length: no `max_tokens` is
forwarded upstream (`OPENAI_MAX_TOKENS` unset), so the advert value only gates
what buyers may request.
Names follow the brand-by-model convention; `ds-flash-htz` carries a `-htz`
suffix only because `deepseek-flash-*` was already taken by the OpenRouter
fleet serving `deepseek/deepseek-v4-flash`.
## 2. Prerequisites
1. A Hetzner account with Inference access and an API key (usage-based billing
to you as the node operator — the flat 0.2 AP3X job price is a business
choice, not cost-derived). One key is shared by all 8 suppliers; Hetzner
rate limits apply to the aggregate.
2. Eight funded supplier wallets (50 AP3X each recommended; bonds are 1 AP3X
per in-flight job and returned on completion).
3. The usual mainnet supplier deploy prerequisites (`deploy/README.md`).
4. **DNS**: one A record per supplier — `mp-suppliers-<name>.vector.apexfusion.org`
→ `91.98.147.172`, **DNS-only/unproxied**. There is NO wildcard for
`*.vector.apexfusion.org`; and because traefik issues certs on-box via
Let's Encrypt HTTP-01 (port 80), a proxied/CDN record would break issuance.
Certs are issued automatically within ~a minute of the record propagating —
no restarts needed.
## 3. Verify the backend before spending on-chain
The four model ids come from `GET https://inference.hetzner.com/api/v1/models`
and are sent **verbatim** as the upstream `model` param (the runtime uses
`advert.model`). Before posting adverts, smoke-test each model:
```bash
curl -sS -X POST https://inference.hetzner.com/api/v1/chat/completions \
-H "Authorization: Bearer $HETZNER_KEY" -H "Content-Type: application/json" \
-d '{"model":"GLM-5.2-NVFP4","messages":[{"role":"user","content":"ping"}]}' \
| jq '.choices[0].message.content'
```
`choices[0].message.content` must be a non-empty string with **no `max_tokens`
sent** (matches runtime config) — the supplier throws `openai_malformed`
otherwise and forfeits its 1 AP3X bond per failed job. For chat suppliers also
verify a `tools`/`tool_choice` round-trip. All four models passed both checks
on 2026-08-11.
## 4. Per-supplier bring-up (repeat ×8)
```bash
# 1. wallet — prints privateKeyHex, publicKeyHex, pubKeyHash, address
pnpm --filter @marketplace/supplier tx:gen-keypair --network 1
# fund the printed address (50 AP3X recommended), then:
# 2. env file on the mainnet host
cp supplier/.env.hetzner.example /root/agents-marketplace/supplier/.env.<name>
chmod 600 /root/agents-marketplace/supplier/.env.<name>
# fill in: ALL FOUR wallet vars (PRIV_KEY_HEX, ADDRESS, PKH, PUB_KEY_HEX —
# they are NOT derived at boot; missing derived vars ⇒ 403 wrong_supplier),
# OPENAI_API_KEY, and the ADVERT_* values from the table above.
# 3. advert (one-off shown; chat: --capability-id llm.chat.v1 --max-processing-ms 1800000)
pnpm --filter @marketplace/supplier tx:post-advert \
--capability-id llm.text.generate.v1 \
--model 'GLM-5.2-NVFP4' \
--max-output-tokens 512000 \
--max-processing-ms 300000 \
--price-lovelace 200000 \
--endpoint-url https://mp-suppliers-<name>.vector.apexfusion.org
# paste the printed "<txHash>#0" into ADVERT_REF in the env file
# 4. first start is always manual (CD skips compose projects with no running
# containers); subsequent rollouts ride the mainnet CD pipeline
docker compose -f deploy/mainnet/docker-compose.supplier-<name>.yml up -d
# 5. register the wallet for balance alerts
# add {name, address} to wallet-monitor/wallets.json on the host
```
## 5. Verification
- `curl https://mp-suppliers-<name>.vector.apexfusion.org/healthz` → `{"ok":true}`;
`/capability` shows the right model + pkh.
- Indexer: buyer `GET /v1/indexer/suppliers` lists the new entries with
`advert_status: Active`, `status: free`.
- Gateway `GET /openai/v1/models` includes the four Hetzner model ids.
- Watch `docker logs -f marketplace-mainnet-supplier-<name>` through the first
jobs for `openai_malformed` (reasoning-model content-shape risk).
## 6. Footguns
- `OPENAI_BASE_URL=https://inference.hetzner.com/api` — **no `/v1`**; the
client appends `/v1/chat/completions`.
- Do **not** set `OPENAI_REASONING` — `reasoning:{enabled:false}` is an
OpenRouter-only param.
- The advert `--model` must match the Hetzner id exactly, including the
`Qwen/` prefix on `Qwen/Qwen3.6-35B-A3B-FP8` (slash is fine on-chain;
precedent `moonshotai/kimi-k2.6`).
- `Kimi-K2.7-Code` and `DeepSeek-V4-Flash-0731` auto-join the buyer's PDF pool
(case-insensitive substring allowlist `["kimi","deepseek","gpt"]` in
`buyer/src/pdf/caps.ts`); GLM and Qwen stay out. Deliberate — adjust
`PDF_MODEL_ALLOWLIST`/`PDF_MODEL_DENYLIST` in the buyer env to change it.
- `OPENAI_TIMEOUT_MS` must stay ≤ the advert's `max_processing_ms`.

View File

@@ -0,0 +1,168 @@
# HuggingFace Router Supplier — Setup Runbook (V1 / W1)
> **Status:** shipped. Configures a Vector supplier node to use the HuggingFace
> **Inference Providers router** as its **compute backend** for bonded inference
> work commissioned on Vector. Config only — no code changes.
---
## 1. What this is
The HuggingFace **Inference Providers router** (`https://router.huggingface.co`)
is an OpenAI-compatible endpoint that fronts hundreds of hosted models across
partner providers (Together, Fireworks, DeepInfra, Groq, …). It is the direct
analogue of the OpenRouter backend the supplier already supports.
Because the supplier's upstream client (`supplier/src/openai.ts`) already speaks
plain `POST /v1/chat/completions` with a Bearer token, pointing it at HuggingFace
is **pure configuration**: set `LLM_BACKEND=openai` and point `OPENAI_BASE_URL` at
the router. It is the same as adding any OpenAI-compatible compute backend, with
the HuggingFace endpoint and token substituted in.
```
buyer commissions bonded work ──▶ supplier node ──▶ HuggingFace router
(AP3X escrow on Vector) │ (compute backend, hf_ token)
▼
supplier fulfils the work; the escrow settles on-chain
```
The supplier node runs the inference to **fulfil work a buyer has commissioned
under stake**; HuggingFace is simply the compute it uses to produce the result.
**What this does NOT do:** it does not add creator/model-author work-sharing,
change protocol economics, or change how an advert's committed amount is sized.
Those are separate, larger pieces of work tracked in the team's internal planning
notes. This preset is the fast, low-risk on-ramp that proves the path.
---
## 2. Prerequisites
1. A HuggingFace account with **Inference Providers access** (a free monthly
credit allowance, then usage-based per token — this is HuggingFace's billing
to you as the node operator).
2. An access token from <https://huggingface.co/settings/tokens> with the
**"Make calls to Inference Providers"** permission. It starts with `hf_`.
Treat it like a password.
3. A funded supplier wallet (~5 AP3X for tx fees + bonds). Generate a seed with
`openssl rand -hex 32`.
4. The usual supplier deploy prerequisites (Ogmios URL, a publicly reachable
HTTPS endpoint for buyers). See `deploy/README.md`.
---
## 3. Steps
### 3.1 Create the env file
```bash
cp supplier/.env.huggingface-chat.example supplier/.env
chmod 600 supplier/.env
```
Fill in `SUPPLIER_PRIV_KEY_HEX`, `OPENAI_API_KEY` (your `hf_…` token),
`OGMIOS_URL`, and the `ADVERT_*` values. Key settings for this preset:
| Var | Value | Why |
|-----|-------|-----|
| `LLM_BACKEND` | `openai` | routes chat through the OpenAI-compatible client |
| `OPENAI_BASE_URL` | `https://router.huggingface.co` | HF router; the `/v1/chat/completions` suffix is appended by the client — do **not** add it |
| `OPENAI_API_KEY` | `hf_…` | your HF token (Bearer) |
| `OPENAI_REASONING` | **unset** | HF 400s on the OpenRouter-only `reasoning` param (see §5) |
| `ADVERT_MODEL` | provider-qualified id, e.g. `deepseek-ai/DeepSeek-V3:fastest` | see §4 |
### 3.2 Choose a model
HF uses **provider-qualified** model names. The value goes on-chain in
`AdvertDatum.model` and is forwarded verbatim to the router:
- `deepseek-ai/DeepSeek-V3:fastest` — bare id + `:fastest` picks the fastest provider
- `deepseek-ai/DeepSeek-V3:together` — pin a specific provider
- `openai/gpt-oss-120b` — open-weight, HF-hosted
Browse models and their providers at
<https://huggingface.co/models?inference_provider=all>.
Because the model is on-chain data, you can advertise any router-supported chat
model without changing the supplier code — just post a new advert.
### 3.3 Smoke-test the token before going on-chain
Confirm your token + model + base URL work end to end, independent of the chain:
```bash
curl https://router.huggingface.co/v1/chat/completions \
-H "Authorization: Bearer $HF_TOKEN" \
-H "Content-Type: application/json" \
-d '{"model":"deepseek-ai/DeepSeek-V3:fastest",
"messages":[{"role":"user","content":"say hi in 3 words"}],
"stream":false}'
```
A `choices[0].message.content` in the response means the compute backend is
reachable. (Note: **no** `reasoning` field — see §5.)
### 3.4 Post the advert
```bash
pnpm --filter @marketplace/supplier tx:post-advert \
--capability-id llm.text.generate.v1 \
--model deepseek-ai/DeepSeek-V3:fastest \
--max-output-tokens 512 \
--max-processing-ms 60000 \
--price-lovelace 2000000 \
--endpoint-url https://your-supplier.example.org
```
Copy the printed `<txHash>#0` into `ADVERT_REF` in `supplier/.env`. Use
`--dry-run` first to inspect the tx without submitting.
### 3.5 Boot
Start read-only first (`LIVE_CHAIN` unset) to confirm the node boots and can
reach both Ogmios and the HF router, then set `LIVE_CHAIN=1` and restart to
enable real Claim/Submit. Deploy via the supplier compose file as in
`deploy/README.md`.
---
## 4. Sizing the advert (read before you post one)
`ADVERT_PRICE_LOVELACE` is the **flat AP3X amount committed in escrow per job**,
but the HuggingFace backend meters **per token**. The committed amount is fixed
per job while upstream token usage varies, so:
- Size it for your **worst-case** prompt length, not the average.
- Cap output tightly with `--max-output-tokens` — it is your main lever on
upstream token usage.
- Prefer **small** models here; large models have wide token-usage variance and
are harder to size a fixed per-job amount around.
- Buyer **input** tokens are not capped on-chain today. Until an input-token
ceiling lands, stick to small models or add your own request-size guard.
This is a pre-existing protocol design point (fixed per-job advert amount vs.
variable upstream token usage), not specific to HuggingFace. It is the main thing
to account for when sizing an advert.
---
## 5. Footgun: `OPENAI_REASONING` must stay unset
`reasoning:{enabled:false}` is an **OpenRouter-specific** parameter. The HF
router **rejects it with HTTP 400 on every request**. When copying an existing
OpenRouter env, it is easy to carry `OPENAI_REASONING=off` over by accident.
The supplier guards against this: `loadConfig` **refuses to boot** if
`OPENAI_REASONING` is disabled while `OPENAI_BASE_URL` resolves to
`router.huggingface.co`, with a clear error naming the fix
(`supplier/src/config.ts`). Just leave `OPENAI_REASONING` unset for HF.
---
## 6. Where this sits
This preset is the multi-model on-ramp that uses the HuggingFace router as a
compute backend. Further work items (additional task adapters, advert-sizing
refinements, and protocol-economics changes) are tracked separately in the team's
internal planning notes, along with the upstream-terms check every operator must
complete before pointing a node at a third-party compute backend.

View File

@@ -1,11 +1,11 @@
# Vector Buyer App
A self-contained app for purchasing AI compute on the Vector L2 testnet (Cardano). You submit prompts, pay with AP3X tokens via on-chain escrow, and receive LLM responses from supplier nodes.
A self-contained app for commissioning AI compute on the Vector L2 testnet. You submit prompts, bond AP3X in on-chain escrow, and receive LLM responses from supplier nodes; the escrow settles in AP3X on accepted work.
## How It Works
1. You type a **prompt** and set a **payment** amount in the web UI
2. The app posts an **escrow transaction** on-chain, locking your payment + bond
1. You type a **prompt** and set a **price** in the web UI
2. The app posts an **escrow transaction** on-chain, locking price + bond
3. The app sends the task to a **supplier node** via HTTP
4. The supplier calls an LLM, **claims** the escrow, and **submits** the result hash on-chain
5. You see the response in the UI and can **accept** to release payment, or **dispute**
@@ -193,8 +193,8 @@ If you accept the result, you get your 1 AP3X bond back. Total cost = payment +
- **Never share your .env file** - it contains your wallet seed phrase
- **Never share your data/ folder** - it contains task history
- The seed phrase controls your wallet funds. Back it up securely.
- Escrow protects you: the supplier cannot take your funds without delivering a result
- The seed phrase controls your wallet. Back it up securely.
- Escrow protects you: the supplier cannot take your escrowed value without delivering a result
- You have a 10-minute dispute window after receiving a result
## Troubleshooting

155
docs/gateway.md Normal file
View File

@@ -0,0 +1,155 @@
# 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 an `llm.text.generate.v1` supplier. **One escrow per call.** `stream:true` → buffered pseudo-stream.
- **Chat sessions** — a Vector REST extension. Routes to an `llm.chat.v1` supplier. **One escrow per session**: fixed price locked at open, **real token streaming** per turn (no per-turn charge), settled at session close.
- **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_vector` receipt 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 AP3X collateral + fee reserve** |
| Wallet health | gateway **auto-consolidates** each wallet to `{5-AP3X 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)
- `Marketplace` ctor `MarketplaceOpts{chain, indexerUrl, walletKey, networkParams, historyStore?}` — `buyer/src/sdk/Marketplace.ts:117`. Per-user field is only `walletKey`.
- `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; `endChat` returns `acceptedRef` and awaits Accept), `acceptResult`, `reclaim`.
- Escrow value/bonds: buyer funds `totalLocked = price + buyer_bond + supplier_bond` at PostEscrow — `packages/shared/src/tx/escrow/postEscrow.ts:132-200`. On Accept supplier gets `payment+supplier_bond`, buyer gets `buyer_bond` back.
- Submitted-ref resolution: the buyer `/v1/accept` queries the indexer and matches the Submitted row by `posted_at` — `buyer/src/server.ts` `/v1/accept`. Gateway must mirror this.
- Wallet health: `planConsolidate` / `consolidateWallet` (`{collateral, working}` shape, `DEFAULT_COLLATERAL_LOVELACE = 5 AP3X`) — `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.ts` `makeChatSessionHandlers`; buyer relay in `buyer/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 helpers `timingSafeCompareStrings`, `createRateLimiter` — `buyer/src/auth.ts`.
- Indexer `SupplierView` (`model`, `capability_id`, `price_lovelace`, `buyer_bond_lovelace`, `supplier_bond_lovelace`, `status` free/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
```sql
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?}`; **reject** `tools`/`tool_choice`/`functions` with 400; **ignore** `temperature/n/stop/top_p/...` (documented). Lifecycle below.
- `GET /openai/v1/models` — distinct `model` over Active suppliers → OpenAI list shape.
**Chat sessions (Vector extension — documented as non-standard):**
- `POST /openai/v1/chat/sessions {model}` — open: route to `llm.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 (new `buildWithdrawTx` in 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(...)`.
1. **Route** (`selectSupplier`): indexer rows where `model` matches, `capability_id==="llm.text.generate.v1"`, `advert_status==="Active"`, live `status ∈ {free, unknown}`. Pick an available one (not cheapest). No match → 404 `model_not_found` (`type: invalid_request_error`).
2. **Pre-flight**: required = `price + buyer_bond + supplier_bond + 5_000_000 (collateral) + ~2 AP3X fee`. Verify total balance **and** a pure-AP3X UTxO ≥5 AP3X exists (`assertCollateralCandidate`-style). Else 402 `insufficient_funds` + `x_vector:{required_lovelace, available_lovelace, collateral_ok, deposit_address}`.
3. **Execute**: `submitPrompt({advertRef, messages, payment_lovelace:price, max_output_tokens})` (posts escrow → supplier → verifies receipt).
4. **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).
5. **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}}`. Record `usage`.
6. **Wallet health**: after settle, consolidate the wallet back to `{collateral, working}` (inside the mutex or queued).
7. `stream:true`: same flow; emit one (or chunked) `chat.completion.chunk` delta + final `finish_reason:"stop"` chunk **with `usage`** + `data: [DONE]`. Emit `: keepalive` SSE 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/message` with `X-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 after `deliver_by`, returning all locked funds (net cost ≈ tx fees). Usage rows record `cost_lovelace: 0`. Sessions may outlive `deliver_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 `/end` response, 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_lovelace` over-reports because the indexer never updates spent rows.
- Suppliers additionally take `MAX_CHAT_SESSIONS` (default 1) — concurrent session slots; `/status` reports `active_sessions`/`max_sessions` and stays `free` until 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 → retry `acceptResult`. Submitted past accept-window → leave for supplier Release (log). Never blind-`reclaim` Submitted.
- **Wallet-health tick**: periodically + after each settle, `planConsolidate` → `consolidateWallet` so every wallet keeps a ≥5 AP3X 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)
1. **Settlement/Submitted-ref + fire-and-forget Accept** → lifecycle step 4 (indexer resolution + awaited Accept); sweeper backstop.
2. **No withdrawal** → `POST /account/withdraw` + new `buildWithdrawTx`.
3. **Master-key SPOF / offline-stranding** → documented backup, `rotate-master-key` CLI (`master_key_version`), withdrawal as exit; trustless recovery (pre-signed reclaim / multisig) explicitly **out of scope**, documented as residual risk.
4. **Stateless↔stateful chat** → sessions are explicit (open/messages/close), one escrow/session; system-message folding documented.
5. **Collateral/fragmentation** → corrected required-balance formula + pre-flight collateral check + auto wallet-health consolidation.
6. **~0.5 req/min throughput** (awaitTx in mutex) → documented per-key SLA; "move awaitTx out of the lock" noted as fast-follow.
7. **Sweeper can't reclaim Submitted** → state-aware sweeper.
8. **OpenAI wire format** → full response object + chunk transform + final usage chunk + keepalive + error taxonomy + param policy.
9. **Hardening** → `/signup` IP limit, per-key request limit, `SdkRegistry` LRU cap, `status ∈ {free,unknown}` + freshness + fallback retry.
10. **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)
1. Indexer + Ogmios + ≥1 `llm.text.generate.v1` and ≥1 `llm.chat.v1` supplier for the same `model`, Active.
2. Boot gateway: `LIVE_CHAIN=1`, `GATEWAY_MASTER_KEY=$(openssl rand -hex 32)`, `OGMIOS_URL`, `INDEXER_URL`, `NETWORK_ID`, `GATEWAY_DB_DIR`, `GATEWAY_PORT`.
3. `POST /signup` → key + deposit address. Fund ≥ price + both bonds + 5 AP3X collateral + ~2 AP3X fees; confirm via `GET /account` (`collateral_ok:true`).
4. One-shot non-stream via curl and via the OpenAI Python/JS SDK (`base_url=…/openai/v1`) → valid `chat.completion` + `x_vector`; confirm the escrow **settled (Accepted)** and a `usage` row exists.
5. **Run a 2nd and 3rd request** → confirm wallet-health kept `collateral_ok` and they succeed (the audit's request-#2 failure must not reproduce).
6. One-shot `stream:true` → chunks + final usage + `[DONE]`.
7. Chat session: open → stream 2–3 turns → close; confirm **one** escrow for the whole session, settled at close.
8. `POST /account/withdraw` → unspent AP3X returns to an external address.
9. Underfunded / no-collateral key → 402 with guidance. Unknown model → 404. `tools:[…]` → 400.
10. Kill gateway mid-request → restart → sweeper reclaims Open/Claimed and retries Accept on Submitted; balances recover.

View File

@@ -1,412 +0,0 @@
---
marp: true
paginate: true
size: 16:9
---
<!--
EXECUTIVE_SUMMARY.md — slide-style overview of the Local Agents Marketplace.
Build PDF + PPTX with: bash docs/build-deck.sh
Edit this file; mermaid blocks are pre-rendered to docs/img/diagram-*.png.
-->
<style>
/* ── Local Agents Marketplace — unified dark theme ────────────────────
* Every slide uses the same navy→indigo gradient as the title slide so
* the deck reads as one continuous document. Title and closing slides
* keep the centered hero treatment; content slides stay top-aligned but
* share the same palette and accent rules. */
:root {
--ink: #f1f5f9; /* primary text on dark gradient */
--muted: #94a3b8;
--accent: #818cf8; /* indigo-400, readable on the dark gradient */
--accent-deep: #4f46e5; /* used for solid accents (h2 bar, table head) */
--accent-soft: rgba(129,140,248,0.12);
--gradient: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #312e81 100%);
--row-even: rgba(255,255,255,0.04);
--row-odd: rgba(255,255,255,0.02);
--border: rgba(255,255,255,0.10);
}
section {
background: var(--gradient);
color: var(--ink);
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
font-size: 22px;
padding: 40px 60px;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
section::after {
content: attr(data-marpit-pagination) ' / ' attr(data-marpit-pagination-total);
color: var(--muted);
font-size: 13px;
}
h1 {
font-size: 48px;
font-weight: 700;
color: #ffffff;
border-bottom: 4px solid var(--accent);
padding-bottom: 10px;
margin: 0 0 18px 0;
}
h2 {
font-size: 34px;
font-weight: 700;
color: #ffffff;
margin: 0 0 14px 0;
}
h2::before {
content: '';
display: inline-block;
width: 6px;
height: 28px;
background: var(--accent);
margin-right: 14px;
vertical-align: middle;
border-radius: 2px;
}
h3 { color: #c7d2fe; font-weight: 600; font-size: 24px; margin: 8px 0; }
p, li { margin: 6px 0; line-height: 1.45; }
ul, ol { margin: 8px 0 12px 0; padding-left: 28px; }
strong { color: #ffffff; }
a { color: #a5b4fc; }
blockquote {
border-left: 4px solid var(--accent);
background: rgba(255,255,255,0.06);
padding: 10px 18px;
color: #e0e7ff;
border-radius: 0 8px 8px 0;
margin: 10px 0;
}
code {
background: rgba(255,255,255,0.10);
color: #e0e7ff;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
/* Constrain mermaid images so they always fit on a 720px-tall slide
* (16:9 @ 1280×720 default). Soft white card behind the diagram so the
* mermaid SVG (which renders on a transparent canvas with dark ink)
* stays readable on the navy gradient. */
img {
display: block;
margin: 8px auto;
max-width: 92%;
max-height: 420px;
object-fit: contain;
background: #ffffff;
border-radius: 10px;
padding: 10px;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
}
/* Tile grid for the local-agent versatility slide. */
.backends {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
margin: 14px 0 12px 0;
}
.tile {
background: var(--accent-soft);
border: 2px solid var(--accent);
border-radius: 10px;
padding: 14px 16px;
text-align: center;
font-weight: 600;
color: #ffffff;
}
.tile small {
display: block;
margin-top: 4px;
font-weight: 400;
color: var(--muted);
font-size: 0.8em;
}
.tile.dots {
background: rgba(255,255,255,0.04);
border-style: dashed;
color: var(--muted);
font-size: 28px;
}
.pipeline {
text-align: center;
background: rgba(255,255,255,0.08);
border: 1px solid var(--border);
color: #f8fafc;
padding: 12px 18px;
border-radius: 10px;
margin: 6px auto 14px auto;
max-width: 760px;
font-weight: 600;
}
.pipeline span {
display: inline-block;
margin: 0 10px;
}
/* Tables sit on a solid white card so cell text reads cleanly against
* the dark gradient backdrop. Rounded outer corners + shadow match the
* mermaid card styling. `display:table` + `table-layout:fixed` are
* needed so the table actually consumes the full slide width — Marp's
* default theme leaves tables at content-natural width, which left
* blank gutters on either side. */
table {
display: table !important;
width: 100% !important;
table-layout: fixed;
border-collapse: collapse;
margin: 12px 0;
font-size: 0.82em;
color: #0f172a;
background: #ffffff;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
}
td, th { word-wrap: break-word; overflow-wrap: anywhere; }
th {
background: var(--accent-deep);
color: #ffffff;
text-align: left;
padding: 9px 12px;
font-weight: 700;
}
td {
padding: 7px 12px;
border-bottom: 1px solid #e2e8f0;
color: #0f172a;
}
tr:nth-child(even) td { background: #ffffff; }
tr:nth-child(odd) td { background: #f8fafc; }
/* code spans inside table cells need a light pill, not the dark one
* defined globally — otherwise they'd disappear into the white row. */
td code {
background: #eef2ff;
color: #1e293b;
}
/* Bold/strong inside cells must stay dark — the global rule paints
* <strong> white for the dark-gradient body text, which would render
* invisible against the white table card. */
td strong, th strong {
color: #0f172a;
}
th strong {
color: #ffffff;
}
/* Title slide — centered hero treatment on the same gradient. */
section.title {
text-align: center;
padding: 100px 80px;
justify-content: center;
}
section.title h1 {
display: inline-block;
font-size: 64px;
margin: 0 auto 20px auto;
}
section.title blockquote {
margin: 28px auto;
max-width: 900px;
}
/* Closing slide — same centered treatment. */
section.closing {
text-align: center;
padding: 100px 80px;
justify-content: center;
}
section.closing h2::before { display: none; }
section.closing blockquote { font-size: 26px; max-width: 900px; }
</style>
<!-- _class: title -->
# Local Agents Marketplace
### A trust-minimised, on-chain marketplace for LLM inference
Buy and sell prompt → response with **bonded escrow** on **Vector** (UTxO).
No platform fee. No central rate-limiter. Verifiable receipts on chain.
> *Status: M1 lifecycle proven end-to-end on Vector testnet — 2026-05-05.*
---
## The problem
* AI inference is **centralised** — a handful of providers, opaque pricing, account-level rate limits.
* Local-AI hardware is **idle most of the time** — GPUs and CPUs sit unused between user prompts.
* Buyers and sellers can't transact directly without a **trusted intermediary** taking a cut.
* "Verifiable" claims about which model ran are usually nothing more than the provider's word.
We need a **two-sided marketplace** where:
1. Sellers monetise spare compute,
2. Buyers get a **cryptographic receipt** of what was actually computed,
3. **Neither side has to trust the other** — bonds + on-chain settlement enforce honesty.
---
## How it works — three actors, one chain
```mermaid
flowchart LR
subgraph CHAIN[Vector — UTxO]
ADV[Advert UTxO<br/>capability, price, bonds]
ESC[Escrow UTxO<br/>locked funds + state]
end
SUP[🧠 Supplier<br/>local LLM node]
BUY[💼 Buyer<br/>app or agent]
IDX[🔭 Indexer<br/>read-only bulletin board]
SUP -->|publish advert| ADV
BUY -->|discover| IDX
IDX -.->|index events| CHAIN
BUY -->|post escrow| ESC
BUY -->|prompt + escrow_ref| SUP
SUP -->|signed receipt| BUY
SUP -->|claim & submit| ESC
BUY -->|accept & pay| ESC
```
* **Supplier** runs the inference and publishes a price.
* **Buyer** posts the work as a bonded escrow, sends the prompt, accepts the receipt.
* **Indexer** is a public mirror of chain state — anyone can run one. No platform middleman.
---
## The bonded-escrow lifecycle
```mermaid
sequenceDiagram
autonumber
participant B as Buyer
participant Chain as Vector
participant S as Supplier
participant LLM as Local LLM<br/>(OpenAI-compatible)
S->>Chain: PostAdvert (locks supplier_bond)
B->>Chain: PostEscrow → Open<br/>(locks payment + bonds)
B->>S: POST /v1/chat/completions
S->>Chain: ClaimEscrow → Claimed
S->>LLM: run inference
LLM-->>S: assistant message
S->>Chain: SubmitEscrow → Submitted
S-->>B: 200 OK + signed receipt
B->>Chain: AcceptEscrow — supplier paid
```
Every transition is on-chain, validated by an Aiken Plutus V3 script. The receipt is signed by the supplier's advert key; prompt and response hashes are bound on chain.
---
## What if someone misbehaves?
```mermaid
flowchart LR
OPEN[Open]:::norm --> CLAIMED[Claimed]:::norm --> SUBMITTED[Submitted]:::norm --> ACCEPTED[Accepted ✓]:::good
OPEN -->|deliver_by passed| RECLAIM[Reclaimed<br/>buyer recovers funds]:::warn
CLAIMED -->|deliver_by passed| RECLAIM
SUBMITTED -->|accept_window passed| RELEASE[Released<br/>supplier collects]:::warn
classDef norm fill:#e0e7ff,stroke:#4f46e5
classDef good fill:#dcfce7,stroke:#16a34a
classDef warn fill:#fef3c7,stroke:#d97706
```
| Failure mode | Recovery | Loss bearer |
|---|---|---|
| Supplier never claims/submits | Buyer **Reclaim** after `deliver_by` | Supplier — forfeits supplier_bond |
| Buyer disappears after Submit | Supplier **Release** after `submitted_at + 10 min` | Buyer — forfeits buyer_bond |
Bonds make honesty cheaper than running away. Both sides have skin in the game; no third-party arbiter is needed for the M1 happy/timeout paths.
---
## Local-agent versatility — any OpenAI-compatible engine
<div class="pipeline">
<span>💼 Buyer</span> →
<span>🧠 Supplier (OpenAI-compatible HTTP wrapper)</span> →
<span>backend ↓</span>
</div>
<div class="backends">
<div class="tile">Ollama<br/><small>local model runner</small></div>
<div class="tile">vLLM<br/><small>high-throughput GPU</small></div>
<div class="tile">llama.cpp<br/><small>CPU · Metal · ROCm</small></div>
<div class="tile">OpenRouter<br/><small>cloud API mux</small></div>
<div class="tile">openclaw / custom<br/><small>arbitrary stack</small></div>
<div class="tile dots">…<br/><small>any OpenAI-shaped API</small></div>
</div>
The supplier service is a thin HTTP wrapper — it forwards `/v1/chat/completions` to whatever backend is configured. Each supplier picks its own model, hardware, and engine, then advertises a `capability_id`. Buyers shop on capability + model + price.
---
## Capability + model = task selection
| Capability ID | Example models | Use case |
|---|---|---|
| `llm.text.generate.v1` | Llama 3 · Qwen 2.5 · Mistral · GPT-4 | Q&A, drafts, summarisation |
| `code.completion.v1` | Codestral · DeepSeek-Coder · StarCoder | IDE tab-complete, refactor |
| `vision.describe.v1` | LLaVA · Qwen-VL · Pixtral | Image captioning, OCR |
| `embedding.text.v1` | nomic-embed · BGE · ada-002 | RAG indexing |
| `audio.transcribe.v1` | whisper-large-v3 · distil-whisper | Voice-to-text |
| `…` | … | … |
* New capabilities = **just publish a new advert**. No marketplace governance gate.
* Today's deploy uses Ollama + Qwen 2.5; switching to vLLM with Llama 3 70B is a config change, not a code change.
---
## OpenClaw — agentic teams on your existing OpenAI seat
<div class="pipeline">
<span>🧠 Supplier wrapper</span> →
<span>🦾 OpenClaw</span> →
<span>🪪 OpenAI ChatGPT seat (flat-rate)</span>
</div>
* **Cost-efficient by default.** OpenClaw exposes your existing **OpenAI ChatGPT subscription** as an OpenAI-compatible API. The marketplace supplier dispatches its inference against it, so an entire **agentic team** (coders, QA automation testers, release curators…) draws from the **flat-rate seat you already pay for** — no per-token metering, no surprise invoices.
* **Reliability layer (optional, marginal cost).** OpenClaw can fall back to the **Anthropic API** or **OpenRouter** model pool when the primary seat is rate-limited, the prompt requires a different model, or you want a **second-layer hardening** of responses (e.g. cross-provider consensus on critical outputs). You only pay metered tokens for the requests that actually fall through.
---
## Proven on Vector testnet — 2026-05-05
| Step | Tx hash |
|---|---|
| Buyer posts escrow | `af762561…c2f7bc5` |
| Supplier claims | (lifecycle tx) |
| Supplier submits receipt | (lifecycle tx) |
| Buyer accepts | `125e3cfe…7dc100d` |
Funds flowed correctly: supplier received `payment + supplier_bond` (3 ADA); buyer got `buyer_bond` back.
* **Browser demo:** https://mp-buyers.vector.testnet.apexfusion.org
* **Live event stream:** https://mp-indexer.vector.testnet.apexfusion.org
* **Test suite:** 1098 unit tests passing, full lifecycle exercised in CI.
---
## Why UTxO + Vector
* **Bonded escrow is native** — Plutus validators enforce the state machine without a custom token or oracle.
* **Vector settles in seconds** — the buyer→claim→submit→accept loop fits inside a single chat-completion's wall clock.
* **AP3X token** for fees and bonds — fungible, transparent, on-chain.
* **No custom rollup, no bridge** — runs on a production chain today.
---
<!-- _class: closing -->
## The pitch in one sentence
> Local Agents Marketplace turns any OpenAI-compatible inference endpoint into a **trustlessly billable service** — buyer and supplier exchange prompt, response, and payment with on-chain proof that what was paid for is what was delivered.
### Try it
* **Indexer (live event stream):** https://mp-indexer.vector.testnet.apexfusion.org
* **Buyer:** https://mp-buyers.vector.testnet.apexfusion.org
* **Supplier:** https://mp-suppliers.vector.testnet.apexfusion.org/capability

Binary file not shown.

Binary file not shown.

View File

@@ -164,8 +164,8 @@ If the buyer does NOT accept within the dispute window (10 minutes), you can col
- **Never share your .env file** - it contains your wallet seed phrase
- **Never share your data/ folder** - it contains task history
- The seed phrase controls your wallet funds. Back it up securely.
- The node only responds to buyers who have already locked funds on-chain, so you cannot be scammed out of compute.
- The seed phrase controls your wallet. Back it up securely.
- The node only responds to buyers who have already locked value on-chain, so you cannot be scammed out of compute.
## Troubleshooting

36
gateway/.env.example Normal file
View File

@@ -0,0 +1,36 @@
# gateway/.env.example — copy to .env and fill in.
# 32-byte (64 hex char) master key used to AES-256-GCM-encrypt every user's
# custodial wallet private key at rest. Generate with: openssl rand -hex 32
# LOSING THIS PERMANENTLY LOCKS ALL USER FUNDS. Back it up out-of-band.
GATEWAY_MASTER_KEY=
# Internal indexer base URL (e.g. http://marketplace-indexer:8090).
INDEXER_URL=
# Ogmios HTTP endpoint. Required (the gateway posts/settles escrows on chain).
OGMIOS_URL=
# Must be "1" — the gateway signs and submits real transactions.
LIVE_CHAIN=1
# "0" testnet (addr_test…), "1" mainnet (addr…). Default "0".
NETWORK_ID=0
# HTTP port. Default 3010.
GATEWAY_PORT=3010
# Directory holding gateway.db (custodial keys + usage + sessions). BACK IT UP.
# Default ./data/gateway
GATEWAY_DB_DIR=./data/gateway
# Optional tuning (defaults shown):
# SIGNUP_RATE_MAX=5 # signups per IP per window
# SIGNUP_RATE_WINDOW_MS=3600000
# KEY_RATE_MAX=60 # requests per API key per window
# KEY_RATE_WINDOW_MS=60000
# SWEEPER_INTERVAL_MS=60000
# DEMO_SESSION_IDLE_MS=180000 # close demo chat sessions idle this long (frees the supplier slot)
# CHAT_SETTLE_MODE=full # "full" (Claim/Submit/Accept) or "ticket" (escrow post only; sweeper reclaims) — must match suppliers
# WALLET_HEALTH_INTERVAL_MS=600000
# SDK_REGISTRY_MAX=500 # max cached per-key Marketplace instances

72
gateway/Dockerfile Normal file
View File

@@ -0,0 +1,72 @@
# syntax=docker/dockerfile:1.7
#
# gateway/Dockerfile — multi-stage image for the OpenAI-compatible gateway.
#
# Mirrors buyer/Dockerfile: we do NOT run `tsc` for the server because
# @marketplace/shared and @marketplace/buyer export .ts directly via
# package.json#exports — the tsx loader handles them at runtime. The gateway
# reuses the buyer's Marketplace SDK + lucid (buildAcceptTx / buildWithdrawTx),
# so it needs the same better-sqlite3 native rebuild + libsodium patch and the
# contracts/marketplace/plutus.json blueprint.
#
# Build:
# docker build -f gateway/Dockerfile -t marketplace-gateway .
ARG NODE_VERSION=20.19.0
# ─── builder ──────────────────────────────────────────────────────────────
FROM node:${NODE_VERSION}-alpine AS builder
WORKDIR /repo
RUN apk add --no-cache python3 make g++
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./
# pnpm-lock.yaml's patchedDependencies hash the patch files at install time.
COPY patches ./patches
COPY packages/shared/package.json ./packages/shared/package.json
COPY buyer/package.json ./buyer/package.json
COPY gateway/package.json ./gateway/package.json
# Install the gateway and its workspace deps (buyer + shared) with their deps.
RUN pnpm install --frozen-lockfile --filter '@marketplace/gateway...'
# better-sqlite3 native binding (same step as buyer/indexer images).
RUN BSQ_DIR=$(ls -d node_modules/.pnpm/better-sqlite3@*/node_modules/better-sqlite3 2>/dev/null | head -1) \
&& [ -n "$BSQ_DIR" ] || (echo "FATAL: better-sqlite3 not in pnpm store" && exit 1) \
&& (cd "$BSQ_DIR" && (npx --yes prebuild-install --runtime=node || npm install --build-from-source --no-save)) \
&& ls -la "$BSQ_DIR/build/Release/better_sqlite3.node"
# libsodium wrapper patch (lucid/CML dep) — same fix as the buyer image.
RUN set -e \
&& SRC=$(find node_modules/.pnpm -name "libsodium-sumo.mjs" -path "*/libsodium-sumo/dist/*" 2>/dev/null | head -1) \
&& DST=$(find node_modules/.pnpm -type d -path "*libsodium-wrappers-sumo@*/node_modules/libsodium-wrappers-sumo/dist/modules-sumo-esm" 2>/dev/null | head -1) \
&& [ -n "$SRC" ] && [ -n "$DST" ] || (echo "FATAL: libsodium-sumo paths not resolved" && exit 1) \
&& cp "$SRC" "$DST/"
COPY packages/shared ./packages/shared
COPY buyer ./buyer
COPY gateway ./gateway
# loadBlueprint reads contracts/marketplace/plutus.json at runtime (Accept/Withdraw).
COPY contracts/marketplace/plutus.json ./contracts/marketplace/plutus.json
# ─── runtime ──────────────────────────────────────────────────────────────
FROM node:${NODE_VERSION}-alpine AS runtime
WORKDIR /repo
RUN apk add --no-cache tini
COPY --from=builder /repo /repo
# Seed the gateway DB dir owned by node (so a fresh named volume inherits it).
RUN mkdir -p /repo/data/gateway && chown -R node:node /repo
USER node
ENV NODE_ENV=production
ENV GATEWAY_PORT=8080
EXPOSE 8080
HEALTHCHECK --interval=20s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://127.0.0.1:${GATEWAY_PORT}/healthz >/dev/null || exit 1
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node_modules/.bin/tsx", "gateway/src/index.ts"]

29
gateway/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "@marketplace/gateway",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"start": "tsx src/index.ts",
"dev": "tsx src/index.ts",
"rotate-master-key": "tsx src/cli/rotate-master-key.ts",
"create-demo-key": "tsx src/cli/create-demo-key.ts"
},
"dependencies": {
"@marketplace/buyer": "workspace:*",
"@marketplace/shared": "workspace:*",
"@noble/ed25519": "^2.3.0",
"@noble/hashes": "^1.8.0",
"bech32": "^2.0.0",
"better-sqlite3": "^11.9.1",
"express": "^4.21.2"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/express": "^5.0.3",
"supertest": "^7.0.0",
"tsx": "^4.19.0",
"typescript": "^5.7.2"
}
}

View File

@@ -0,0 +1,163 @@
/**
* gateway/src/account/routes.ts — signup, account, withdraw.
*
* POST /signup → mint a custodial wallet + API key (key shown once)
* GET /account → balance, collateral readiness, spend, recent usage
* POST /account/withdraw → move unspent AP3X out to an external address (exit)
*/
import { randomBytes, randomUUID } from "crypto";
import type { Request, Response } from "express";
import { buildWithdrawTx } from "@marketplace/shared/tx/server";
import type { GatewayDeps } from "../deps.js";
import { genPrivKeyHex, deriveWalletKey } from "../wallet.js";
import { seal } from "../crypto/seal.js";
import { hashApiKey, requireKey } from "../middleware/apiKeyAuth.js";
import { asyncHandler } from "../middleware/http.js";
import { totalLovelace, hasCollateral } from "../onchain/preflight.js";
import { badRequest, forbidden } from "../openai/errors.js";
const ACTIVE_ESCROW_STATES = new Set(["Open", "Claimed", "Submitted"]);
function ap3x(lovelace: bigint): string {
return (Number(lovelace) / 1e6).toFixed(2);
}
// ─── signup ──────────────────────────────────────────────────────────────────
export function makeSignupHandler(deps: GatewayDeps) {
return asyncHandler(async (req: Request, res: Response) => {
const body = (req.body ?? {}) as Record<string, unknown>;
const label = typeof body.label === "string" ? body.label.slice(0, 120) : null;
const privHex = genPrivKeyHex();
const walletKey = deriveWalletKey(privHex, deps.config.networkId);
const sealed = seal(privHex, deps.config.masterKeyHex);
const rawKey = `vk_${deps.config.networkId === 1 ? "live" : "test"}_${randomBytes(24).toString("hex")}`;
const keyPrefix = rawKey.slice(0, 12);
deps.store.insertKey({
id: randomUUID(),
key_hash: hashApiKey(rawKey),
key_prefix: keyPrefix,
label,
wallet_pkh: walletKey.pubKeyHash,
deposit_address: walletKey.address,
enc_priv_nonce: sealed.nonce,
enc_priv_ct: sealed.ct,
enc_priv_tag: sealed.tag,
master_key_version: 1,
created_at: Date.now(),
});
res.status(201).json({
api_key: rawKey,
key_prefix: keyPrefix,
deposit_address: walletKey.address,
note: "Save api_key now — it is shown only once. Fund deposit_address with AP3X to use the gateway.",
});
});
}
// ─── account ─────────────────────────────────────────────────────────────────
export function makeAccountHandler(deps: GatewayDeps) {
return asyncHandler(async (req: Request, res: Response) => {
const keyRow = requireKey(req);
const utxos = await deps.chain.queryUtxosByAddress(keyRow.deposit_address);
const available = totalLovelace(utxos);
let lockedInEscrow = 0n;
try {
const r = await deps.fetchFn(`${deps.config.indexerUrl}/escrows?buyer=${keyRow.wallet_pkh}`);
if (r.ok) {
const rows = (await r.json()) as Array<Record<string, unknown>>;
if (Array.isArray(rows)) {
for (const row of rows) {
if (!ACTIVE_ESCROW_STATES.has(String(row.state))) continue;
lockedInEscrow +=
BigInt(String(row.payment_lovelace ?? "0")) +
BigInt(String(row.buyer_bond_lovelace ?? "0")) +
BigInt(String(row.supplier_bond_lovelace ?? "0"));
}
}
}
} catch {
/* best-effort; report 0 locked on indexer hiccup */
}
res.status(200).json({
key_prefix: keyRow.key_prefix,
// The shared demo key's wallet is operator-funded; hiding the deposit
// address keeps strangers from confusing it with their own wallet.
deposit_address: keyRow.demo ? null : keyRow.deposit_address,
...(keyRow.demo ? { demo: true } : {}),
balance: {
available_lovelace: available.toString(),
locked_in_escrow_lovelace: lockedInEscrow.toString(),
ap3x: ap3x(available),
},
collateral_ok: hasCollateral(utxos),
spend: {
total_cost_lovelace: deps.store.sumCostLovelace(keyRow.id).toString(),
request_count: deps.store.countUsage(keyRow.id),
},
recent_usage: deps.store.listUsage(keyRow.id, 20).map((u) => ({
created_at: u.created_at,
kind: u.kind,
model: u.model,
status: u.status,
cost_lovelace: u.cost_lovelace,
escrow_ref: u.escrow_ref,
failure_reason: u.failure_reason,
})),
});
});
}
// ─── withdraw ────────────────────────────────────────────────────────────────
export function makeWithdrawHandler(deps: GatewayDeps) {
return asyncHandler(async (req: Request, res: Response) => {
const keyRow = requireKey(req);
if (keyRow.demo) {
// The demo key is shared publicly; without this, anyone holding it
// could drain the operator-funded wallet to their own address.
throw forbidden("demo_key_restricted", "withdrawals are disabled for the shared demo key");
}
const body = (req.body ?? {}) as Record<string, unknown>;
const toAddress = body.to_address;
if (typeof toAddress !== "string" || !/^addr(_test)?1[0-9a-z]+$/.test(toAddress)) {
throw badRequest("invalid_address", "`to_address` must be a bech32 Cardano address");
}
let amountLovelace: bigint | undefined;
if (body.amount_lovelace !== undefined && body.amount_lovelace !== null) {
try {
amountLovelace = BigInt(String(body.amount_lovelace));
} catch {
throw badRequest("invalid_amount", "`amount_lovelace` must be an integer (string or number)");
}
if (amountLovelace <= 0n) throw badRequest("invalid_amount", "`amount_lovelace` must be positive");
}
const ctx = deps.registry.getContext(keyRow);
await ctx.mutex.run(async () => {
const built = await buildWithdrawTx({
chain: deps.chain,
walletKey: ctx.walletKey,
toAddress,
amountLovelace,
});
await deps.chain.awaitTx(built.expectedTxHash, 120_000);
res.status(200).json({
status: "submitted",
tx_hash: built.expectedTxHash,
amount_lovelace: built.amountLovelace.toString(),
to_address: built.toAddress,
});
});
});
}

16
gateway/src/chain.ts Normal file
View File

@@ -0,0 +1,16 @@
/**
* gateway/src/chain.ts — the single shared LiveOgmiosProvider.
*
* The gateway always submits real transactions, so it always uses a Live
* provider (config enforces LIVE_CHAIN=1). One provider is shared across all
* per-key Marketplace instances; ChainProvider reads are concurrency-safe.
*/
import { LiveOgmiosProvider } from "@marketplace/shared/chain";
import type { GatewayConfig } from "./config.js";
export function buildChainProvider(config: GatewayConfig): LiveOgmiosProvider {
// eslint-disable-next-line no-console
console.log("[gateway] chain: LiveOgmiosProvider");
return new LiveOgmiosProvider({ ogmiosUrl: config.ogmiosUrl });
}

View File

@@ -0,0 +1,76 @@
/**
* gateway/src/cli/create-demo-key.ts — mint the shared demo API key.
*
* Same custodial machinery as POST /signup, but sets demo=1: withdraw is
* blocked, /account hides the deposit address, chat completions run through
* the session-backed demo executor, and per-IP rate limits apply. Public
* signup can never mint a demo key — this CLI is the only path.
*
* Run inside the gateway container (hits the volume-mounted DB):
* docker exec <gateway> pnpm --filter @marketplace/gateway create-demo-key
* Or locally:
* GATEWAY_MASTER_KEY=<64hex> GATEWAY_DB_DIR=./data/gateway NETWORK_ID=1 \
* pnpm --filter @marketplace/gateway create-demo-key [--label shared-demo]
*
* The raw key is printed ONCE and never stored (only its sha256).
*/
import { randomBytes, randomUUID } from "crypto";
import { GatewayStore } from "../db/store.js";
import { genPrivKeyHex, deriveWalletKey } from "../wallet.js";
import { seal } from "../crypto/seal.js";
import { hashApiKey } from "../middleware/apiKeyAuth.js";
const HEX64 = /^[0-9a-fA-F]{64}$/;
function arg(name: string): string | undefined {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 ? process.argv[i + 1] : undefined;
}
function main(): void {
const masterKey = process.env.GATEWAY_MASTER_KEY;
if (!masterKey || !HEX64.test(masterKey)) {
throw new Error("create-demo-key: GATEWAY_MASTER_KEY must be 64 hex chars");
}
const dbDir = arg("db") ?? process.env.GATEWAY_DB_DIR ?? "./data/gateway";
const networkId: 0 | 1 = process.env.NETWORK_ID === "1" ? 1 : 0;
const label = arg("label") ?? "shared-demo";
const privHex = genPrivKeyHex();
const walletKey = deriveWalletKey(privHex, networkId);
const sealed = seal(privHex, masterKey);
const rawKey = `vk_${networkId === 1 ? "live" : "test"}_${randomBytes(24).toString("hex")}`;
const store = new GatewayStore(dbDir);
store.insertKey({
id: randomUUID(),
key_hash: hashApiKey(rawKey),
key_prefix: rawKey.slice(0, 12),
label,
wallet_pkh: walletKey.pubKeyHash,
deposit_address: walletKey.address,
enc_priv_nonce: sealed.nonce,
enc_priv_ct: sealed.ct,
enc_priv_tag: sealed.tag,
master_key_version: 1,
created_at: Date.now(),
demo: 1,
});
// eslint-disable-next-line no-console
console.log(
[
`[gateway] demo key created (label=${label}, network=${networkId === 1 ? "mainnet" : "testnet"})`,
``,
` api_key: ${rawKey}`,
` deposit_address: ${walletKey.address}`,
``,
`Save the api_key now — it is shown only once. Fund the deposit address`,
`with the demo float (withdrawals are disabled for this key).`,
].join("\n"),
);
}
main();

View File

@@ -0,0 +1,52 @@
/**
* gateway/src/cli/rotate-master-key.ts — re-encrypt all custodial keys.
*
* Decrypts every api_keys row with the OLD master key and re-encrypts with the
* NEW one, bumping master_key_version. Run with the gateway stopped.
*
* GATEWAY_DB_DIR=./data/gateway \
* pnpm --filter @marketplace/gateway rotate-master-key \
* --old <64hex-old> --new <64hex-new>
*/
import { GatewayStore } from "../db/store.js";
import { seal, open as unseal } from "../crypto/seal.js";
const HEX64 = /^[0-9a-fA-F]{64}$/;
function arg(name: string): string | undefined {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 ? process.argv[i + 1] : undefined;
}
function main(): void {
const oldKey = arg("old") ?? process.env.GATEWAY_MASTER_KEY_OLD;
const newKey = arg("new") ?? process.env.GATEWAY_MASTER_KEY;
const dbDir = arg("db") ?? process.env.GATEWAY_DB_DIR ?? "./data/gateway";
if (!oldKey || !HEX64.test(oldKey)) throw new Error("rotate: --old must be 64 hex chars");
if (!newKey || !HEX64.test(newKey)) throw new Error("rotate: --new must be 64 hex chars");
if (oldKey === newKey) throw new Error("rotate: --old and --new are identical");
const store = new GatewayStore(dbDir);
const rows = store.listAllKeys();
let rotated = 0;
for (const row of rows) {
const priv = unseal(
{ nonce: row.enc_priv_nonce, ct: row.enc_priv_ct, tag: row.enc_priv_tag },
oldKey,
);
const sealed = seal(priv, newKey);
store.updateKeyEncryption(row.id, {
enc_priv_nonce: sealed.nonce,
enc_priv_ct: sealed.ct,
enc_priv_tag: sealed.tag,
master_key_version: row.master_key_version + 1,
});
rotated += 1;
}
// eslint-disable-next-line no-console
console.log(`[gateway] rotated ${rotated} key(s) at ${dbDir}; restart the gateway with the NEW GATEWAY_MASTER_KEY.`);
}
main();

150
gateway/src/config.ts Normal file
View File

@@ -0,0 +1,150 @@
/**
* gateway/src/config.ts — env loading for the OpenAI-compatible gateway.
*
* Mirrors buyer/src/config.ts conventions (requireField, HEX64_RE, LIVE_CHAIN
* semantics) but for a multi-tenant, custodial service. See docs/gateway.md.
*
* Required:
* GATEWAY_MASTER_KEY — 64-char hex (32 bytes). AES-256-GCM key wrapping every
* user's custodial wallet private key at rest. Losing it
* permanently locks all user funds.
* INDEXER_URL — internal indexer base URL.
* OGMIOS_URL — Ogmios HTTP endpoint (required; the gateway submits txs).
*
* Optional:
* LIVE_CHAIN — must be "1" (the gateway always needs a live provider).
* NETWORK_ID — "0" testnet (default) | "1" mainnet.
* GATEWAY_PORT — default 3010.
* GATEWAY_DB_DIR — default "./data/gateway".
* SIGNUP_RATE_MAX / SIGNUP_RATE_WINDOW_MS — per-IP signup limiter.
* KEY_RATE_MAX / KEY_RATE_WINDOW_MS — per-key request limiter.
* SWEEPER_INTERVAL_MS / WALLET_HEALTH_INTERVAL_MS — background tick cadences.
* SDK_REGISTRY_MAX — max cached per-key Marketplace instances (LRU).
* GATEWAY_CORS_ORIGINS — comma-separated allowlist of browser Origins that may
* call the gateway cross-origin (e.g. the marketplace
* frontend's "Generate API key" page). Default empty =
* CORS off (no Access-Control-* headers emitted).
*/
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
const POS_INT_RE = /^[1-9]\d*$/;
export interface GatewayConfig {
masterKeyHex: string;
indexerUrl: string;
ogmiosUrl: string;
networkId: 0 | 1;
liveChain: boolean;
port: number;
dbDir: string;
signupRate: { max: number; windowMs: number };
keyRate: { max: number; windowMs: number };
demoIpRate: { max: number; windowMs: number };
sweeperIntervalMs: number;
/** Close demo chat sessions idle this long (frees the supplier slot).
* Keep below the supplier's CHAT_IDLE_TIMEOUT_MS so the gateway closes
* first and records usage. */
demoSessionIdleMs: number;
/** Chat-session settlement mode this deployment runs. "full" = suppliers
* Claim/Submit and the gateway Accepts; "ticket" = the escrow post at open
* is the only chain op and the sweeper Reclaims it after deliver_by.
* Must agree with the suppliers' CHAT_SETTLE_MODE. */
chatSettleMode: "full" | "ticket";
walletHealthIntervalMs: number;
sdkRegistryMax: number;
corsOrigins: string[];
/** Capability id the /v1/ocr/extract route escrows against. Model-scoped
* per the marketplace convention (one model, one id). Env: OCR_CAPABILITY_ID. */
ocrCapabilityId: string;
}
function requireField(env: Record<string, string | undefined>, name: string): string {
const v = env[name];
if (v === undefined || v === null || v === "") {
throw new Error(`loadConfig: missing required env var ${name}`);
}
return v;
}
function chatSettleMode(env: Record<string, string | undefined>): "full" | "ticket" {
const v = env.CHAT_SETTLE_MODE ?? "full";
if (v !== "full" && v !== "ticket") {
throw new Error('loadConfig: CHAT_SETTLE_MODE must be "full" or "ticket"');
}
return v;
}
function posInt(
env: Record<string, string | undefined>,
name: string,
fallback: number,
): number {
const v = env[name];
if (v === undefined || v === "") return fallback;
if (!POS_INT_RE.test(v)) {
throw new Error(`loadConfig: ${name} must be a positive integer`);
}
return Number(v);
}
export function loadConfig(env: Record<string, string | undefined>): GatewayConfig {
const masterKeyHex = requireField(env, "GATEWAY_MASTER_KEY");
if (!HEX64_RE.test(masterKeyHex)) {
throw new Error(
"loadConfig: GATEWAY_MASTER_KEY must be 64 hex chars (32 bytes); generate with: openssl rand -hex 32",
);
}
const indexerUrl = requireField(env, "INDEXER_URL").replace(/\/+$/, "");
// The gateway always posts/settles escrows, so a live provider is mandatory.
const liveChain = env.LIVE_CHAIN === "1";
const ogmiosUrl = env.OGMIOS_URL ?? "";
if (!liveChain) {
throw new Error("loadConfig: LIVE_CHAIN=1 is required (the gateway submits transactions)");
}
if (ogmiosUrl === "") {
throw new Error("loadConfig: OGMIOS_URL is required when LIVE_CHAIN=1");
}
let networkId: 0 | 1 = 0;
const networkIdStr = env.NETWORK_ID;
if (networkIdStr !== undefined && networkIdStr !== "") {
if (networkIdStr !== "0" && networkIdStr !== "1") {
throw new Error('loadConfig: NETWORK_ID must be "0" (testnet) or "1" (mainnet)');
}
networkId = networkIdStr === "1" ? 1 : 0;
}
return {
masterKeyHex,
indexerUrl,
ogmiosUrl,
networkId,
liveChain,
port: posInt(env, "GATEWAY_PORT", 3010),
dbDir: env.GATEWAY_DB_DIR ?? "./data/gateway",
signupRate: {
max: posInt(env, "SIGNUP_RATE_MAX", 5),
windowMs: posInt(env, "SIGNUP_RATE_WINDOW_MS", 60 * 60 * 1000),
},
keyRate: {
max: posInt(env, "KEY_RATE_MAX", 60),
windowMs: posInt(env, "KEY_RATE_WINDOW_MS", 60 * 1000),
},
demoIpRate: {
max: posInt(env, "DEMO_IP_RATE_MAX", 20),
windowMs: posInt(env, "DEMO_IP_RATE_WINDOW_MS", 60 * 1000),
},
sweeperIntervalMs: posInt(env, "SWEEPER_INTERVAL_MS", 60 * 1000),
demoSessionIdleMs: posInt(env, "DEMO_SESSION_IDLE_MS", 3 * 60 * 1000),
chatSettleMode: chatSettleMode(env),
walletHealthIntervalMs: posInt(env, "WALLET_HEALTH_INTERVAL_MS", 10 * 60 * 1000),
sdkRegistryMax: posInt(env, "SDK_REGISTRY_MAX", 500),
ocrCapabilityId: env.OCR_CAPABILITY_ID ?? "ocr.page.extract.chandra-ocr-2.v1",
corsOrigins: (env.GATEWAY_CORS_ORIGINS ?? "")
.split(",")
.map((o) => o.trim().replace(/\/+$/, ""))
.filter((o) => o !== ""),
};
}

View File

@@ -0,0 +1,46 @@
/**
* gateway/src/crypto/seal.ts — AES-256-GCM sealing for custodial private keys.
*
* Each user's wallet private key (64-char hex) is encrypted at rest with the
* GATEWAY_MASTER_KEY (32 bytes). A fresh 12-byte nonce per row; the 16-byte GCM
* auth tag is stored alongside. Plaintext is produced only in-memory at request
* time (open()) and never persisted or logged.
*/
import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
export interface Sealed {
/** 12-byte GCM nonce, hex. */
nonce: string;
/** ciphertext, hex. */
ct: string;
/** 16-byte GCM auth tag, hex. */
tag: string;
}
function masterKeyBuffer(masterKeyHex: string): Buffer {
const buf = Buffer.from(masterKeyHex, "hex");
if (buf.length !== 32) {
throw new Error("seal: master key must decode to exactly 32 bytes");
}
return buf;
}
/** Encrypt `plaintext` (e.g. a wallet privKey hex) under the master key. */
export function seal(plaintext: string, masterKeyHex: string): Sealed {
const key = masterKeyBuffer(masterKeyHex);
const nonce = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, nonce);
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return { nonce: nonce.toString("hex"), ct: ct.toString("hex"), tag: tag.toString("hex") };
}
/** Decrypt a Sealed blob under the master key. Throws on tamper / wrong key. */
export function open(sealed: Sealed, masterKeyHex: string): string {
const key = masterKeyBuffer(masterKeyHex);
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(sealed.nonce, "hex"));
decipher.setAuthTag(Buffer.from(sealed.tag, "hex"));
const pt = Buffer.concat([decipher.update(Buffer.from(sealed.ct, "hex")), decipher.final()]);
return pt.toString("utf8");
}

62
gateway/src/db/schema.ts Normal file
View File

@@ -0,0 +1,62 @@
/**
* gateway/src/db/schema.ts — SQLite schema for the gateway.
*
* Three tables:
* api_keys — one custodial wallet per key; the wallet priv is AES-GCM sealed
* (enc_priv_*). The raw API key is NEVER stored — only sha256(key).
* usage — one row per billable unit (a one-shot completion or a closed chat
* session), for /account spend reporting and audit.
* sessions — open chat sessions (one escrow per session), for streaming turns
* and sweeper recovery of abandoned sessions.
*/
export const CREATE_TABLES_SQL = `
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
key_hash TEXT NOT NULL UNIQUE,
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,
demo INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_keys_hash ON api_keys(key_hash);
CREATE TABLE IF NOT EXISTS 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 NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL,
failure_reason TEXT
);
CREATE INDEX IF NOT EXISTS idx_usage_key ON usage(key_id, created_at DESC);
CREATE TABLE IF NOT EXISTS 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,
supplier_pkh TEXT NOT NULL,
model TEXT NOT NULL,
price_lovelace TEXT NOT NULL,
state TEXT NOT NULL,
opened_at INTEGER NOT NULL,
closed_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_sessions_key ON sessions(key_id, opened_at DESC);
`;

209
gateway/src/db/store.ts Normal file
View File

@@ -0,0 +1,209 @@
/**
* gateway/src/db/store.ts — synchronous SQLite store (mirrors buyer/src/db/archive.ts).
*
* better-sqlite3 + WAL + prepared statements. Low write volume (≤1 row per
* request) so synchronous is fine and simpler than async.
*/
import { mkdirSync } from "fs";
import { join, resolve } from "path";
import Database from "better-sqlite3";
import type { Database as BetterDatabase } from "better-sqlite3";
import { CREATE_TABLES_SQL } from "./schema.js";
export interface ApiKeyRow {
id: string;
key_hash: string;
key_prefix: string;
label: string | null;
wallet_pkh: string;
deposit_address: string;
enc_priv_nonce: string;
enc_priv_ct: string;
enc_priv_tag: string;
master_key_version: number;
created_at: number;
disabled: number;
/** 1 = shared demo key: withdraw blocked, session-backed completions, per-IP limits. */
demo: number;
}
export interface UsageRow {
id: string;
key_id: string;
created_at: number;
kind: string;
model: string | null;
capability_id: string | null;
supplier_pkh: string | null;
escrow_ref: string | null;
cost_lovelace: string | null;
prompt_tokens: number;
completion_tokens: number;
status: string;
failure_reason: string | null;
}
export interface SessionRow {
id: string;
key_id: string;
escrow_ref: string;
session_nonce: string;
supplier_base_url: string;
supplier_pkh: string;
model: string;
price_lovelace: string;
state: string;
opened_at: number;
closed_at: number | null;
}
export class GatewayStore {
private readonly db: BetterDatabase;
private readonly sInsertKey;
private readonly sGetKeyByHash;
private readonly sGetKeyById;
private readonly sListKeys;
private readonly sListAllKeys;
private readonly sUpdateKeyEncryption;
private readonly sInsertUsage;
private readonly sSumCost;
private readonly sCountByKey;
private readonly sListUsage;
private readonly sInsertSession;
private readonly sGetSession;
private readonly sSetSessionState;
private readonly sListOpenSessions;
constructor(dbDir: string) {
const dir = resolve(dbDir);
mkdirSync(dir, { recursive: true });
this.db = new Database(join(dir, "gateway.db"));
this.db.pragma("journal_mode = WAL");
this.db.exec(CREATE_TABLES_SQL);
// Additive migration for DBs created before the demo flag existed
// (CREATE TABLE IF NOT EXISTS won't alter an existing table).
const keyCols = this.db.pragma("table_info(api_keys)") as Array<{ name: string }>;
if (!keyCols.some((c) => c.name === "demo")) {
this.db.exec("ALTER TABLE api_keys ADD COLUMN demo INTEGER NOT NULL DEFAULT 0");
}
this.sInsertKey = this.db.prepare(`
INSERT INTO api_keys (
id, key_hash, key_prefix, label, wallet_pkh, deposit_address,
enc_priv_nonce, enc_priv_ct, enc_priv_tag, master_key_version, created_at, disabled, demo
) VALUES (
@id, @key_hash, @key_prefix, @label, @wallet_pkh, @deposit_address,
@enc_priv_nonce, @enc_priv_ct, @enc_priv_tag, @master_key_version, @created_at, 0, @demo
)
`);
this.sGetKeyByHash = this.db.prepare(`SELECT * FROM api_keys WHERE key_hash = @key_hash`);
this.sGetKeyById = this.db.prepare(`SELECT * FROM api_keys WHERE id = @id`);
this.sListKeys = this.db.prepare(`SELECT * FROM api_keys WHERE disabled = 0`);
this.sListAllKeys = this.db.prepare(`SELECT * FROM api_keys`);
this.sUpdateKeyEncryption = this.db.prepare(`
UPDATE api_keys SET enc_priv_nonce = @enc_priv_nonce, enc_priv_ct = @enc_priv_ct,
enc_priv_tag = @enc_priv_tag, master_key_version = @master_key_version WHERE id = @id
`);
this.sInsertUsage = this.db.prepare(`
INSERT INTO usage (
id, key_id, created_at, kind, model, capability_id, supplier_pkh, escrow_ref,
cost_lovelace, prompt_tokens, completion_tokens, status, failure_reason
) VALUES (
@id, @key_id, @created_at, @kind, @model, @capability_id, @supplier_pkh, @escrow_ref,
@cost_lovelace, @prompt_tokens, @completion_tokens, @status, @failure_reason
)
`);
this.sSumCost = this.db.prepare(`
SELECT COALESCE(SUM(CAST(cost_lovelace AS INTEGER)), 0) AS total
FROM usage WHERE key_id = @key_id AND status = 'completed'
`);
this.sCountByKey = this.db.prepare(`SELECT COUNT(*) AS n FROM usage WHERE key_id = @key_id`);
this.sListUsage = this.db.prepare(`
SELECT * FROM usage WHERE key_id = @key_id ORDER BY created_at DESC LIMIT @limit
`);
this.sInsertSession = this.db.prepare(`
INSERT INTO sessions (
id, key_id, escrow_ref, session_nonce, supplier_base_url, supplier_pkh,
model, price_lovelace, state, opened_at, closed_at
) VALUES (
@id, @key_id, @escrow_ref, @session_nonce, @supplier_base_url, @supplier_pkh,
@model, @price_lovelace, @state, @opened_at, NULL
)
`);
this.sGetSession = this.db.prepare(`SELECT * FROM sessions WHERE id = @id`);
this.sSetSessionState = this.db.prepare(`
UPDATE sessions SET state = @state, closed_at = @closed_at WHERE id = @id
`);
this.sListOpenSessions = this.db.prepare(`SELECT * FROM sessions WHERE state = 'open'`);
}
insertKey(row: Omit<ApiKeyRow, "disabled" | "demo"> & { demo?: number }): void {
this.sInsertKey.run({ ...row, label: row.label ?? null, demo: row.demo ?? 0 });
}
getKeyByHash(keyHash: string): ApiKeyRow | undefined {
return this.sGetKeyByHash.get({ key_hash: keyHash }) as ApiKeyRow | undefined;
}
getKeyById(id: string): ApiKeyRow | undefined {
return this.sGetKeyById.get({ id }) as ApiKeyRow | undefined;
}
listKeys(): ApiKeyRow[] {
return this.sListKeys.all() as ApiKeyRow[];
}
listAllKeys(): ApiKeyRow[] {
return this.sListAllKeys.all() as ApiKeyRow[];
}
updateKeyEncryption(
id: string,
enc: { enc_priv_nonce: string; enc_priv_ct: string; enc_priv_tag: string; master_key_version: number },
): void {
this.sUpdateKeyEncryption.run({ id, ...enc });
}
insertUsage(row: UsageRow): void {
this.sInsertUsage.run(row);
}
sumCostLovelace(keyId: string): bigint {
const r = this.sSumCost.get({ key_id: keyId }) as { total: number } | undefined;
return BigInt(r?.total ?? 0);
}
countUsage(keyId: string): number {
const r = this.sCountByKey.get({ key_id: keyId }) as { n: number } | undefined;
return r?.n ?? 0;
}
listUsage(keyId: string, limit: number): UsageRow[] {
return this.sListUsage.all({ key_id: keyId, limit }) as UsageRow[];
}
insertSession(row: Omit<SessionRow, "closed_at">): void {
this.sInsertSession.run(row);
}
getSession(id: string): SessionRow | undefined {
return this.sGetSession.get({ id }) as SessionRow | undefined;
}
setSessionState(id: string, state: string, closedAt: number | null): void {
this.sSetSessionState.run({ id, state, closed_at: closedAt });
}
listOpenSessions(): SessionRow[] {
return this.sListOpenSessions.all() as SessionRow[];
}
listOpenSessionsByKey(keyId: string): SessionRow[] {
return this.listOpenSessions().filter((s) => s.key_id === keyId);
}
}

16
gateway/src/deps.ts Normal file
View File

@@ -0,0 +1,16 @@
/**
* gateway/src/deps.ts — shared dependency bundle passed to route factories.
*/
import type { LiveOgmiosProvider } from "@marketplace/shared/chain";
import type { GatewayConfig } from "./config.js";
import type { GatewayStore } from "./db/store.js";
import type { SdkRegistry } from "./sdk/registry.js";
export interface GatewayDeps {
config: GatewayConfig;
store: GatewayStore;
chain: LiveOgmiosProvider;
registry: SdkRegistry;
fetchFn: typeof globalThis.fetch;
}

73
gateway/src/index.ts Normal file
View File

@@ -0,0 +1,73 @@
/**
* gateway/src/index.ts — entry point.
*
* Wires config → shared chain + store → SdkRegistry → Express app, and starts
* the reclaim sweeper + wallet-health ticker. Boot is wrapped in runMain() so
* importing this module (tests) does not start the server.
*/
import { loadConfig } from "./config.js";
import { buildChainProvider } from "./chain.js";
import { GatewayStore } from "./db/store.js";
import { SdkRegistry } from "./sdk/registry.js";
import { createApp } from "./server.js";
import { startSweeper } from "./sweeper.js";
import { startWalletHealthTicker } from "./walletHealth.js";
import type { GatewayDeps } from "./deps.js";
export function buildDeps(env: Record<string, string | undefined>): GatewayDeps {
const config = loadConfig(env);
const store = new GatewayStore(config.dbDir);
const chain = buildChainProvider(config);
const registry = new SdkRegistry({
chain,
indexerUrl: config.indexerUrl,
networkId: config.networkId,
masterKeyHex: config.masterKeyHex,
max: config.sdkRegistryMax,
});
return { config, store, chain, registry, fetchFn: globalThis.fetch };
}
export function runMain(env: Record<string, string | undefined>): void {
const deps = buildDeps(env);
const app = createApp(deps);
const stopSweeper = startSweeper(deps);
const stopHealth = startWalletHealthTicker(deps);
const server = app.listen(deps.config.port, () => {
// eslint-disable-next-line no-console
console.log(
`[gateway] listening on :${deps.config.port} (network=${deps.config.networkId}, indexer=${deps.config.indexerUrl})`,
);
});
const shutdown = (): void => {
// eslint-disable-next-line no-console
console.log("[gateway] shutting down...");
stopSweeper();
stopHealth();
server.close(() => process.exit(0));
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
}
const invokedDirectly =
typeof process !== "undefined" &&
process.argv?.[1] !== undefined &&
// Accept both path separators: on Windows process.argv[1] arrives with
// backslashes, so a forward-slash-only pattern silently never matches and
// the process exits 0 without ever starting the server.
/gateway[\\/](src|dist)[\\/]index\.(t|j)s$/.test(process.argv[1]);
if (invokedDirectly) {
try {
runMain(process.env);
} catch (err) {
// eslint-disable-next-line no-console
console.error("[gateway] boot failed:", err);
process.exit(1);
}
}

View File

@@ -0,0 +1,45 @@
/**
* gateway/src/middleware/apiKeyAuth.ts — Bearer API-key authentication.
*
* Looks up sha256(rawKey) in api_keys (unique index). The raw key is never
* stored. Attaches the matched row to req.gatewayKey for downstream handlers.
*/
import { createHash } from "crypto";
import type { Request, Response, NextFunction } from "express";
import type { GatewayStore, ApiKeyRow } from "../db/store.js";
import { unauthorized } from "../openai/errors.js";
import { sendError } from "./http.js";
export interface GatewayRequest extends Request {
gatewayKey?: ApiKeyRow;
}
export function hashApiKey(raw: string): string {
return createHash("sha256").update(raw, "utf8").digest("hex");
}
export function makeApiKeyAuth(store: GatewayStore) {
return (req: Request, res: Response, next: NextFunction): void => {
const header = req.header("authorization") ?? "";
const m = /^Bearer\s+(.+)$/i.exec(header.trim());
if (!m) {
sendError(res, unauthorized("missing or malformed Authorization: Bearer header"));
return;
}
const row = store.getKeyByHash(hashApiKey(m[1].trim()));
if (!row || row.disabled) {
sendError(res, unauthorized());
return;
}
(req as GatewayRequest).gatewayKey = row;
next();
};
}
/** Read the authenticated key off the request (throws if middleware missing). */
export function requireKey(req: Request): ApiKeyRow {
const row = (req as GatewayRequest).gatewayKey;
if (!row) throw unauthorized();
return row;
}

View File

@@ -0,0 +1,25 @@
/**
* gateway/src/middleware/http.ts — Express response/error helpers.
*/
import type { Request, Response } from "express";
import { GatewayError, toGatewayError, toErrorBody } from "../openai/errors.js";
/** Send a GatewayError (or any error) as an OpenAI-shaped JSON error response. */
export function sendError(res: Response, err: unknown): void {
const ge = err instanceof GatewayError ? err : toGatewayError(err);
if (res.headersSent) {
res.end();
return;
}
res.status(ge.httpStatus).json(toErrorBody(ge));
}
/** Wrap an async handler so rejections are mapped to error responses. */
export function asyncHandler(
fn: (req: Request, res: Response) => Promise<void>,
): (req: Request, res: Response) => void {
return (req, res) => {
fn(req, res).catch((err) => sendError(res, err));
};
}

View File

@@ -0,0 +1,87 @@
/**
* gateway/src/middleware/rateLimit.ts — per-IP and per-key limiters.
*
* Small sliding-window limiter (same shape as buyer/src/auth.ts createRateLimiter,
* inlined to avoid coupling the operator-auth module into the gateway). Used to
* bound public /signup abuse (per IP) and per-key request volume (429 + Retry-After).
*/
import type { Request, Response, NextFunction } from "express";
import { rateLimited } from "../openai/errors.js";
import { sendError } from "./http.js";
import { requireKey } from "./apiKeyAuth.js";
interface Bucket {
count: number;
windowStart: number;
}
interface Limiter {
attempt(key: string): { allowed: boolean; retryAfterMs: number };
}
function createLimiter(opts: { max: number; windowMs: number }): Limiter {
const buckets = new Map<string, Bucket>();
return {
attempt(key: string) {
const t = Date.now();
const existing = buckets.get(key);
if (!existing || t - existing.windowStart >= opts.windowMs) {
buckets.set(key, { count: 1, windowStart: t });
return { allowed: true, retryAfterMs: 0 };
}
if (existing.count >= opts.max) {
return { allowed: false, retryAfterMs: Math.max(0, opts.windowMs - (t - existing.windowStart)) };
}
existing.count += 1;
return { allowed: true, retryAfterMs: 0 };
},
};
}
type Keyer = (req: Request) => string;
function makeLimiter(opts: { max: number; windowMs: number }, keyOf: Keyer) {
const limiter = createLimiter(opts);
return (req: Request, res: Response, next: NextFunction): void => {
const decision = limiter.attempt(keyOf(req));
if (!decision.allowed) {
const retrySec = Math.ceil(decision.retryAfterMs / 1000);
res.setHeader("Retry-After", retrySec.toString());
sendError(res, rateLimited(`rate limit exceeded; retry in ${retrySec}s`));
return;
}
next();
};
}
/** Per-IP limiter (for the public /signup endpoint). */
export function ipRateLimit(opts: { max: number; windowMs: number }) {
return makeLimiter(opts, (req) => req.ip ?? "unknown");
}
/** Per-API-key limiter (apply AFTER apiKeyAuth). Demo keys are exempt: the
* shared demo key would let one abusive IP exhaust everyone's budget — the
* per-IP demoIpRateLimit is the demo control instead. */
export function keyRateLimit(opts: { max: number; windowMs: number }) {
const limit = makeLimiter(opts, (req) => requireKey(req).id);
return (req: Request, res: Response, next: NextFunction): void => {
if (requireKey(req).demo) {
next();
return;
}
limit(req, res, next);
};
}
/** Per-IP limiter that applies ONLY to demo-key requests (after apiKeyAuth). */
export function demoIpRateLimit(opts: { max: number; windowMs: number }) {
const limit = makeLimiter(opts, (req) => req.ip ?? "unknown");
return (req: Request, res: Response, next: NextFunction): void => {
if (!requireKey(req).demo) {
next();
return;
}
limit(req, res, next);
};
}

402
gateway/src/ocr/extract.ts Normal file
View File

@@ -0,0 +1,402 @@
/**
* gateway/src/ocr/extract.ts — POST /v1/ocr/extract.
*
* One escrow per page against a model-scoped `ocr.page.extract.<model>.v1`
* supplier (config.ocrCapabilityId). Lifecycle mirrors the one-shot chat
* path, all inside the per-key mutex: parse → select supplier → preflight →
* per-candidate live busy pre-check → submitOcr (escrow → supplier call →
* receipt verify) → resolve Submitted ref → Accept (awaited) → record usage
* → wallet-health → respond.
*
* Response (200):
* {
* output_format, content,
* usage: { prompt_tokens, completion_tokens, total_tokens },
* x_vector: {
* receipt, receipt_signature, escrow_ref,
* receipt_labels: ["first-party-brokered"]
* }
* }
*
* Venue attribution: the caller MAY send
* X-Venue: venue slug (e.g. "apify"), ≤ 32 chars
* X-Run-User-Hash: sha256 of the venue-side runner id, 64 hex
* Both are logged on a structured line per settled job. The wedge-ledger
* emitter consumes these lines; a usage-table venue
* column is deliberately deferred to later work so this route stays
* schema-additive. payer_class is derived at ledger time from the hash
* (known first-party hashes vs the rest), never asserted here.
*
* Demo keys are rejected: the demo executor is chat-session-shaped and its
* wallet holds one escrow per session; OCR needs a funded per-key wallet.
*/
import { randomUUID } from "crypto";
import type { Request, Response } from "express";
import { TxConstructionError } from "@marketplace/shared/tx";
import {
ALLOWED_OCR_MIMES,
ALLOWED_OCR_OUTPUT_FORMATS,
MAX_OCR_IMAGE_B64_CHARS,
} from "@marketplace/shared/tx";
import { SupplierError, type ProgressEvent } from "@marketplace/buyer/sdk";
import type { GatewayDeps } from "../deps.js";
import type { GatewayStore } from "../db/store.js";
import { requireKey } from "../middleware/apiKeyAuth.js";
import { asyncHandler } from "../middleware/http.js";
import { selectCandidates, type SupplierCandidate } from "../routing/selectSupplier.js";
import { preflight } from "../onchain/preflight.js";
import { resolveSubmittedRef, acceptAndConfirm } from "../onchain/settle.js";
import { ensureWalletHealthy } from "../walletHealth.js";
import { badRequest, forbidden, notFound, paymentRequired, toGatewayError } from "../openai/errors.js";
/** Operator-model v0: HAL8 operates both the custodial buyer and the
* supplier fleet, so every receipt this route returns is first-party
* brokered. Becomes per-supplier metadata when
* control-independent supply exists. */
const RECEIPT_LABELS_V0 = ["first-party-brokered"] as const;
const B64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
const VENUE_RE = /^[a-z0-9-]{1,32}$/;
/** Bound on the live busy pre-check below - cheap enough that a stalled
* supplier never meaningfully delays the request. */
const SUPPLIER_STATUS_CHECK_TIMEOUT_MS = 2_000;
interface ParsedOcrRequest {
image_b64: string;
mime: string;
output_format: string;
/** "" = any model under the capability (single-model ids). */
model: string;
}
function parseOcrRequest(body: unknown): ParsedOcrRequest {
if (typeof body !== "object" || body === null) {
throw badRequest("invalid_body", "request body must be a JSON object");
}
const b = body as Record<string, unknown>;
const image = b.image_b64;
if (typeof image !== "string" || image.length === 0) {
throw badRequest("invalid_image", "`image_b64` is required and must be a non-empty base64 string");
}
if (image.length > MAX_OCR_IMAGE_B64_CHARS) {
throw badRequest("image_too_large", `\`image_b64\` exceeds ${MAX_OCR_IMAGE_B64_CHARS} characters`);
}
if (!B64_RE.test(image)) {
throw badRequest("invalid_image", "`image_b64` must be plain base64 (no data: prefix, no whitespace)");
}
const mime = b.mime;
if (typeof mime !== "string" || !ALLOWED_OCR_MIMES.has(mime)) {
throw badRequest("invalid_mime", `\`mime\` must be one of: ${[...ALLOWED_OCR_MIMES].join(", ")}`);
}
const rawFormat = b.output_format ?? "markdown";
if (typeof rawFormat !== "string" || !ALLOWED_OCR_OUTPUT_FORMATS.has(rawFormat)) {
throw badRequest(
"invalid_output_format",
`\`output_format\` must be one of: ${[...ALLOWED_OCR_OUTPUT_FORMATS].join(", ")}`,
);
}
let model = "";
if (b.model !== undefined && b.model !== null) {
if (typeof b.model !== "string" || b.model === "") {
throw badRequest("invalid_model", "`model` must be a non-empty string when provided");
}
model = b.model;
}
return { image_b64: image, mime, output_format: rawFormat, model };
}
/** Sanitized venue-attribution headers. Invalid values drop to "".
* Exported for direct unit testing (I3 review finding) — it is the only
* gate on what lands in the ocr_settled/ocr_failed structured log lines the
* wedge-ledger emitter reads, so it needs coverage independent of the HTTP
* route. */
export function venueAttribution(req: Request): { venue: string; runUserHash: string } {
const venueRaw = req.header("X-Venue") ?? "";
const hashRaw = req.header("X-Run-User-Hash") ?? "";
return {
venue: VENUE_RE.test(venueRaw) ? venueRaw : "",
runUserHash: HEX64_RE.test(hashRaw) ? hashRaw.toLowerCase() : "",
};
}
/**
* Live busy pre-check, run immediately before a candidate's escrow posts.
* Mirrors sessions.ts's startChat pre-check: same signal ("working" on the
* supplier's own /status), same best-effort posture. Posting an escrow only
* to have the supplier reject as busy strands the escrow until reclaim and
* burns the tx fee, so this is worth a bounded, cheap HTTP round trip.
*
* Never a gate: the indexer already filters candidates on advert status, so
* a timeout, network error, non-2xx, or malformed body all resolve to "not
* busy" (unknown, not blocking) - a briefly-unreachable /status must never
* stop the candidate from being attempted.
*/
async function isSupplierBusy(
endpointUrl: string,
fetchFn: typeof globalThis.fetch,
): Promise<boolean> {
try {
const res = await fetchFn(`${endpointUrl.replace(/\/+$/, "")}/status`, {
signal: AbortSignal.timeout(SUPPLIER_STATUS_CHECK_TIMEOUT_MS),
});
if (!res.ok) return false;
const body = (await res.json()) as unknown;
const status = body && typeof body === "object" ? (body as { status?: unknown }).status : undefined;
return status === "working";
} catch {
return false;
}
}
function recordOcrUsage(
store: GatewayStore,
p: {
keyId: string;
capabilityId: string;
model: string;
supplierPkh: string | null;
escrowRef: string | null;
costLovelace: bigint | null;
promptTokens: number;
completionTokens: number;
status: "completed" | "failed";
failureReason?: string;
},
): void {
store.insertUsage({
id: randomUUID(),
key_id: p.keyId,
created_at: Date.now(),
kind: "ocr",
model: p.model,
capability_id: p.capabilityId,
supplier_pkh: p.supplierPkh,
escrow_ref: p.escrowRef,
cost_lovelace: p.costLovelace !== null ? p.costLovelace.toString() : null,
prompt_tokens: p.promptTokens,
completion_tokens: p.completionTokens,
status: p.status,
failure_reason: p.failureReason ?? null,
});
}
export function makeOcrExtractHandler(deps: GatewayDeps) {
return asyncHandler(async (req: Request, res: Response) => {
const keyRow = requireKey(req);
if (keyRow.demo) {
throw forbidden("demo_key_unsupported", "demo keys cannot use /v1/ocr/extract; create a funded API key");
}
const ctx = deps.registry.getContext(keyRow);
const parsed = parseOcrRequest(req.body);
const attribution = venueAttribution(req);
await ctx.mutex.run(() => runOcrOneShot(deps, keyRow.id, ctx, parsed, attribution, res));
});
}
async function runOcrOneShot(
deps: GatewayDeps,
keyId: string,
ctx: ReturnType<GatewayDeps["registry"]["getContext"]>,
parsed: ParsedOcrRequest,
attribution: { venue: string; runUserHash: string },
res: Response,
): Promise<void> {
const { config, store, chain, fetchFn } = deps;
const capabilityId = config.ocrCapabilityId;
// Hoisted above the try so the catch block can report whatever is known
// even when the failure happens AFTER an escrow was posted (e.g. the
// supplier call or settle step fails) — these fields are the
// reconciliation spine that joins on-chain settlement to platform
// billing, and losing them makes paid-but-failed jobs unreconcilable
// (I2 review finding).
let result: Awaited<ReturnType<typeof ctx.sdk.submitOcr>> | undefined;
let used: SupplierCandidate | undefined;
let escrowRefStr: string | null = null;
try {
const candidates = await selectCandidates({
indexerUrl: config.indexerUrl,
model: parsed.model,
capabilityId,
fetchFn,
});
if (candidates.length === 0) {
throw notFound(
"model_not_found",
parsed.model === ""
? `no available supplier for capability "${capabilityId}"`
: `no available supplier for model "${parsed.model}" under "${capabilityId}"`,
);
}
const primary = candidates[0];
const pf = await preflight(chain, ctx.walletKey.address, primary);
if (!pf.ok) {
throw paymentRequired(
pf.collateralOk
? "insufficient balance for this request"
: "wallet has no pure-AP3X UTxO ≥ 5 AP3X for collateral; deposit a little more AP3X",
{
x_vector: {
required_lovelace: pf.requiredLovelace.toString(),
available_lovelace: pf.availableLovelace.toString(),
collateral_ok: pf.collateralOk,
deposit_address: ctx.walletKey.address,
},
},
);
}
// Submit. Fall back to the next supplier ONLY on a pre-post
// TxConstructionError (no escrow was posted). A SupplierError means the
// escrow is already posted — do not retry (the sweeper recovers it).
//
// submitOcr posts the escrow internally and only returns escrowRef when
// it resolves. When the supplier call AFTER the post throws
// (SupplierError: timeout, network failure, non-2xx, malformed
// response, receipt mismatch) the escrow already landed on chain but
// escrowRefStr below would otherwise stay null — the OCR upstream has a
// 120s budget, so this is likely the most common paid-but-failed shape.
// The SDK emits a "progress" event of type "escrow_posted" (carrying
// escrow_ref) the instant the tx confirms, so listen for it around each
// attempt and capture the ref from the event rather than only from
// submitOcr's return value (I2 follow-up).
let lastErr: unknown;
for (const cand of candidates) {
// Live pre-check, run right here (not once at the top of the request):
// skip a candidate that is busy right now instead of paying to find
// out. See isSupplierBusy's docstring for the exact signal + posture.
if (await isSupplierBusy(cand.endpointUrl, fetchFn)) {
lastErr = new SupplierError("supplier_busy", {
status: 409,
message: "supplier is busy with another job",
});
continue;
}
const onProgress = (ev: unknown): void => {
const e = ev as ProgressEvent;
if (e.type === "escrow_posted" && e.escrow_ref) escrowRefStr = e.escrow_ref;
};
ctx.sdk.on("progress", onProgress);
try {
result = await ctx.sdk.submitOcr({
advertRef: cand.advertRef,
image_b64: parsed.image_b64,
mime: parsed.mime,
output_format: parsed.output_format,
payment_lovelace: cand.priceLovelace,
});
used = cand;
break;
} catch (err) {
lastErr = err;
if (err instanceof TxConstructionError) continue;
throw err;
} finally {
ctx.sdk.off("progress", onProgress);
}
}
if (!result || !used) {
throw toGatewayError(lastErr ?? new Error("no supplier could be engaged"));
}
// Settle: resolve the live Submitted ref, Accept, await confirmation.
escrowRefStr = `${result.escrowRef.txHash}#${result.escrowRef.index}`;
const submittedRef = await resolveSubmittedRef({
indexerUrl: config.indexerUrl,
buyerPkh: ctx.walletKey.pubKeyHash,
originalRefStr: escrowRefStr,
fetchFn,
});
await acceptAndConfirm(chain, ctx.walletKey, submittedRef);
recordOcrUsage(store, {
keyId,
capabilityId,
model: used.model,
supplierPkh: used.supplierPkh,
escrowRef: escrowRefStr,
costLovelace: used.priceLovelace,
promptTokens: result.usage.prompt_tokens,
completionTokens: result.usage.completion_tokens,
status: "completed",
});
// Structured settle line for the wedge-ledger emitter. One line per
// settled job; venue/run_user_hash empty when the
// caller sent none.
console.log(JSON.stringify({
evt: "ocr_settled",
escrow_ref: escrowRefStr,
capability_id: capabilityId,
model: used.model,
supplier_pkh: used.supplierPkh,
cost_lovelace: used.priceLovelace.toString(),
receipt_labels: RECEIPT_LABELS_V0,
venue: attribution.venue,
run_user_hash: attribution.runUserHash,
ts: new Date().toISOString(),
}));
// Re-shape the wallet ({collateral, working}) so the next request keeps
// a valid collateral UTxO. Best-effort; serialized by the mutex.
await ensureWalletHealthy(chain, ctx.walletKey).catch((e) =>
// eslint-disable-next-line no-console
console.error("[gateway] wallet-health after ocr:", e instanceof Error ? e.message : e),
);
res.status(200).json({
output_format: result.output_format,
content: result.content,
usage: result.usage,
x_vector: {
receipt: result.receipt,
receipt_signature: result.receiptSignature,
escrow_ref: escrowRefStr,
receipt_labels: RECEIPT_LABELS_V0,
},
});
} catch (err) {
recordOcrUsage(store, {
keyId,
capabilityId,
model: used?.model ?? parsed.model,
supplierPkh: used?.supplierPkh ?? null,
escrowRef: escrowRefStr,
costLovelace: used?.priceLovelace ?? null,
promptTokens: result?.usage.prompt_tokens ?? 0,
completionTokens: result?.usage.completion_tokens ?? 0,
status: "failed",
failureReason: err instanceof Error ? err.message : String(err),
});
// Structured failure line mirroring ocr_settled's shape (same venue /
// run_user_hash / escrow_ref fields) so paid-but-failed jobs — an
// escrow posted on-chain, then a later step (supplier call, settle)
// failed — can still be reconciled against on-chain state instead of
// vanishing from the wedge-ledger emitter's input (I2 review finding).
console.log(JSON.stringify({
evt: "ocr_failed",
escrow_ref: escrowRefStr,
capability_id: capabilityId,
model: used?.model ?? parsed.model,
supplier_pkh: used?.supplierPkh ?? null,
venue: attribution.venue,
run_user_hash: attribution.runUserHash,
error: err instanceof Error ? err.message : String(err),
ts: new Date().toISOString(),
}));
throw err;
}
}

View File

@@ -0,0 +1,89 @@
/**
* gateway/src/onchain/preflight.ts — balance + collateral pre-flight.
*
* The buyer wallet funds the FULL escrow at PostEscrow: price + buyer_bond +
* supplier_bond, raised to the Submitted-state min-ada floor so long
* capability ids (e.g. the 33-byte OCR one) don't get bumped mid-flight —
* see escrowLockFloor (packages/shared/.../minAdaFloor.ts) and
* postOcrEscrow.ts:205, the SAME floor a POST actually locks. Accept/Reclaim
* are Plutus script-spends that also need a pure-AP3X collateral UTxO ≥ 5
* AP3X (assertCollateralCandidate). So required = escrowLockFloor(price +
* buyer_bond + supplier_bond) + collateral + fee reserve, AND the wallet
* must currently hold a pure-AP3X UTxO ≥ collateral. We check both up front
* and return a clean 402 instead of failing mid-flight.
*/
import type { ChainProvider, Utxo } from "@marketplace/shared/chain";
import type { EscrowDatum } from "@marketplace/shared/cbor";
import { escrowLockFloor } from "@marketplace/shared/tx";
export const COLLATERAL_MIN_LOVELACE = 5_000_000n;
export const FEE_RESERVE_LOVELACE = 2_000_000n;
export function totalLovelace(utxos: Utxo[]): bigint {
return utxos.reduce((sum, u) => sum + u.lovelace, 0n);
}
/** True iff some UTxO is pure-AP3X (no native assets) and ≥ collateral floor. */
export function hasCollateral(utxos: Utxo[]): boolean {
return utxos.some(
(u) => u.lovelace >= COLLATERAL_MIN_LOVELACE && Object.keys(u.assets).length === 0,
);
}
export interface PreflightResult {
ok: boolean;
availableLovelace: bigint;
requiredLovelace: bigint;
collateralOk: boolean;
}
export interface PreflightCost {
capabilityId: string;
priceLovelace: bigint;
buyerBondLovelace: bigint;
supplierBondLovelace: bigint;
}
/** Placeholder datum used only to size-estimate the Submitted-state min-ada
* floor. Every field except capability_id and the cost amounts is a
* fixed-size stand-in (real hash/pkh byte widths, a realistic 13-digit
* POSIX-ms timestamp) — capability_id's actual length is what drives the
* estimate, exactly as in escrowLockFloor's own contract. */
function representativeEscrowDatum(cost: PreflightCost): EscrowDatum {
return {
buyer_pkh: "3b2fdaed0398485ff35887fb7d0ad2025348864407854f7831dc6a55",
supplier_pkh: "401af418a7e6fb106277292881fcdeaa1cc15b5b112e9c8bdb2d46c9",
advert_ref: { txHash: "a6".repeat(32), index: 0 },
capability_id: cost.capabilityId,
request_spec_hash: "1b".repeat(32),
prompt_hash: "23".repeat(32),
payment_lovelace: cost.priceLovelace,
buyer_bond_lovelace: cost.buyerBondLovelace,
supplier_bond_lovelace: cost.supplierBondLovelace,
deliver_by: 1_786_105_955_257,
posted_at: 1_786_105_625_257,
submitted_at: null,
result_receipt_hash: null,
state: "Open",
};
}
export async function preflight(
chain: ChainProvider,
address: string,
cost: PreflightCost,
): Promise<PreflightResult> {
const utxos = await chain.queryUtxosByAddress(address);
const available = totalLovelace(utxos);
const collateralOk = hasCollateral(utxos);
const economicTotal = cost.priceLovelace + cost.buyerBondLovelace + cost.supplierBondLovelace;
const escrowFloor = escrowLockFloor(representativeEscrowDatum(cost), economicTotal);
const required = escrowFloor + COLLATERAL_MIN_LOVELACE + FEE_RESERVE_LOVELACE;
return {
ok: available >= required && collateralOk,
availableLovelace: available,
requiredLovelace: required,
collateralOk,
};
}

View File

@@ -0,0 +1,125 @@
/**
* gateway/src/onchain/settle.ts — settlement + recovery helpers.
*
* submitPrompt returns the ORIGINAL Open escrow ref, but the supplier's
* Claim→Submit moves the live escrow to a NEW ref. To Accept (settle) we must
* resolve the current Submitted ref via the indexer (mirrors buyer /v1/accept:
* match the lineage by posted_at). Because the per-key mutex serializes a
* wallet's escrows, at settle time the wallet has at most one in-flight escrow,
* so a lone Submitted row is unambiguously ours.
*
* acceptResult/reclaim on the SDK are fire-and-forget; here we buildAcceptTx /
* buildReclaimTx and AWAIT confirmation so a returned success means settled.
*/
import type { ChainProvider, OutputReference } from "@marketplace/shared/chain";
import type { WalletKey } from "@marketplace/shared/tx";
import { buildAcceptTx, buildReclaimTx } from "@marketplace/shared/tx";
import { parseRef } from "../routing/selectSupplier.js";
import { GatewayError } from "../openai/errors.js";
export interface EscrowRow {
utxo_ref: string;
state: string;
posted_at: number;
/** Validator reclaim floor (indexer serves it; absent on very old rows). */
deliver_by?: number;
}
export async function fetchEscrows(
indexerUrl: string,
buyerPkh: string,
fetchFn: typeof globalThis.fetch,
): Promise<EscrowRow[]> {
const res = await fetchFn(`${indexerUrl}/escrows?buyer=${buyerPkh}`);
if (!res.ok) throw new Error(`indexer /escrows returned ${res.status}`);
const body = (await res.json()) as unknown;
if (!Array.isArray(body)) throw new Error("indexer /escrows did not return an array");
return body as EscrowRow[];
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
/** Poll the indexer for the current Submitted UTxO of our in-flight escrow. */
export async function resolveSubmittedRef(opts: {
indexerUrl: string;
buyerPkh: string;
originalRefStr: string;
fetchFn?: typeof globalThis.fetch;
timeoutMs?: number;
intervalMs?: number;
}): Promise<OutputReference> {
const fetchFn = opts.fetchFn ?? globalThis.fetch;
const timeoutMs = opts.timeoutMs ?? 60_000;
const intervalMs = opts.intervalMs ?? 2_000;
const deadline = Date.now() + timeoutMs;
let lastErr: unknown;
for (;;) {
try {
const rows = await fetchEscrows(opts.indexerUrl, opts.buyerPkh, fetchFn);
// 1. Direct: the original ref already shows Submitted.
const direct = rows.find((r) => r.utxo_ref === opts.originalRefStr && r.state === "Submitted");
if (direct) return refOrThrow(direct.utxo_ref);
// 2. Lineage: find the original row to read posted_at, then the Submitted
// row sharing it (the validator preserves posted_at across states).
const lineage = rows.find((r) => r.utxo_ref === opts.originalRefStr);
if (lineage) {
const submitted = rows.find((r) => r.posted_at === lineage.posted_at && r.state === "Submitted");
if (submitted) return refOrThrow(submitted.utxo_ref);
}
// 3. Fallback: the mutex guarantees ≤1 in-flight escrow for this wallet,
// so a single Submitted row is ours.
const submittedRows = rows.filter((r) => r.state === "Submitted");
if (submittedRows.length === 1) return refOrThrow(submittedRows[0].utxo_ref);
} catch (err) {
lastErr = err;
}
if (Date.now() >= deadline) {
throw new GatewayError(
502,
"server_error",
"escrow_settle_failed",
`could not resolve Submitted escrow within ${timeoutMs}ms${lastErr ? `: ${String(lastErr)}` : ""}`,
);
}
await sleep(intervalMs);
}
}
function refOrThrow(refStr: string): OutputReference {
const ref = parseRef(refStr);
if (!ref) {
throw new GatewayError(502, "server_error", "escrow_settle_failed", `bad escrow ref ${refStr}`);
}
return ref;
}
/** Build + submit + await the Accept tx (Submitted → Accepted, terminal). */
export async function acceptAndConfirm(
chain: ChainProvider,
walletKey: WalletKey,
escrowRef: OutputReference,
awaitTimeoutMs = 120_000,
): Promise<string> {
const built = await buildAcceptTx({ chain, buyerKey: walletKey, escrowRef });
await chain.awaitTx(built.expectedTxHash, awaitTimeoutMs);
return built.expectedTxHash;
}
/** Build + submit + await a Reclaim tx (Open/Claimed → Reclaimed). */
export async function reclaimAndConfirm(
chain: ChainProvider,
walletKey: WalletKey,
escrowRef: OutputReference,
awaitTimeoutMs = 120_000,
): Promise<string> {
const built = await buildReclaimTx({ chain, buyerKey: walletKey, escrowRef });
await chain.awaitTx(built.expectedTxHash, awaitTimeoutMs);
return built.expectedTxHash;
}

View File

@@ -0,0 +1,116 @@
/**
* gateway/src/openai/affinity.ts — transcript-prefix session matching.
*
* Stateless OpenAI clients resend the FULL message history each call; sessions
* are stateful. An incoming request belongs to an open session when that
* session's mirrored transcript is a prefix of the incoming messages — the
* suffix (delta) is the next turn. Client-authored messages (user/system/tool)
* must match byte-exactly, but clients routinely reshape ASSISTANT text when
* echoing it back (strip <think> reasoning, re-render whitespace), so
* assistant content is compared normalized. Strict (byte-exact) matches are
* preferred over lenient ones to disambiguate concurrent threads that share a
* user-message prefix. The mirror itself is NEVER rewritten with client text:
* the delta is forwarded verbatim and mirrored only after a successful turn,
* so the gateway mirror and the supplier transcript stay hash-identical for
* the receipt response_hash.
*
* Pure module — no I/O — so the matching rules are unit-testable.
*/
import { chatMessagesEquivalent, toolCallsEqual, type ChatMessage } from "@marketplace/shared/tx";
export interface AffinityCandidate {
sessionId: string;
model: string;
mirror: ChatMessage[];
lastUsedAt: number;
}
export interface AffinityMatch {
sessionId: string;
/** Messages the session has not seen yet — forwarded as the next turn. */
delta: ChatMessage[];
}
/** Canonicalize assistant text the way clients mangle it when re-echoing
* history: drop <think>…</think> reasoning blocks, collapse whitespace runs,
* trim. Exported for tests. */
export function normalizeAssistantContent(content: string): string {
return content
.replace(/<think>[\s\S]*?<\/think>/gi, "")
.replace(/\s+/g, " ")
.trim();
}
/** Lenient equality: strict for client-authored roles (the client sends the
* same bytes both times); assistant content is compared normalized while
* assistant tool_calls stay strict (ids anchor tool-result turns). */
function chatMessagesLenientEquivalent(a: ChatMessage, b: ChatMessage): boolean {
if (a.role !== b.role) return false;
if (a.role !== "assistant") return chatMessagesEquivalent(a, b);
return (
normalizeAssistantContent(a.content) === normalizeAssistantContent(b.content) &&
toolCallsEqual(a.tool_calls, b.tool_calls)
);
}
type PrefixQuality = "strict" | "lenient";
function classifyPrefix(mirror: ChatMessage[], incoming: ChatMessage[]): PrefixQuality | null {
if (mirror.length >= incoming.length) return null;
let strict = true;
for (let i = 0; i < mirror.length; i++) {
if (chatMessagesEquivalent(mirror[i], incoming[i])) continue;
if (!chatMessagesLenientEquivalent(mirror[i], incoming[i])) return null;
strict = false;
}
return strict ? "strict" : "lenient";
}
function deltaRespondable(delta: ChatMessage[]): boolean {
const last = delta[delta.length - 1];
return last !== undefined && (last.role === "user" || last.role === "tool");
}
/** Drop trailing assistant/system messages so the history ends with a
* respondable message (regenerate semantics for clients that send a trailing
* assistant turn). Empty result = nothing to answer. */
export function trimToRespondable(messages: ChatMessage[]): ChatMessage[] {
let end = messages.length;
while (end > 0 && messages[end - 1].role !== "user" && messages[end - 1].role !== "tool") end--;
return messages.slice(0, end);
}
/**
* Pick the open session whose mirror best matches the incoming history.
* Strict prefixes beat lenient ones; then longest mirror wins (most
* conversation reuse); ties go to the most recently used session. Returns
* null when nothing matches — caller opens a new session and replays the full
* history. Note `incoming.length <= mirror.length` never matches (a client
* retry of an already-answered request is a miss by design: the session
* transcript must stay append-only).
*/
export function matchSessionPrefix(
candidates: AffinityCandidate[],
incoming: ChatMessage[],
model: string,
): AffinityMatch | null {
let best: { cand: AffinityCandidate; quality: PrefixQuality } | null = null;
for (const cand of candidates) {
if (cand.model !== model) continue;
const quality = classifyPrefix(cand.mirror, incoming);
if (quality === null) continue;
if (!deltaRespondable(incoming.slice(cand.mirror.length))) continue;
if (
best === null ||
(quality === "strict" && best.quality === "lenient") ||
(quality === best.quality &&
(cand.mirror.length > best.cand.mirror.length ||
(cand.mirror.length === best.cand.mirror.length && cand.lastUsedAt > best.cand.lastUsedAt)))
) {
best = { cand, quality };
}
}
if (!best) return null;
return { sessionId: best.cand.sessionId, delta: incoming.slice(best.cand.mirror.length) };
}

View File

@@ -0,0 +1,233 @@
/**
* gateway/src/openai/chatCompletions.ts — POST /openai/v1/chat/completions.
*
* One escrow per call against an llm.text.generate.v1 supplier. Lifecycle (all
* inside the per-key mutex): route → preflight → submitPrompt → resolve the
* Submitted ref + Accept (awaited) → record usage → wallet-health → respond.
* stream:true returns the completion as a buffered pseudo-stream (one delta +
* final usage chunk + [DONE]) with keepalive comments during the escrow wait.
*/
import { randomUUID } from "crypto";
import type { Request, Response } from "express";
import { TxConstructionError } from "@marketplace/shared/tx";
import type { GatewayDeps } from "../deps.js";
import type { ApiKeyRow, GatewayStore } from "../db/store.js";
import type { KeyContext } from "../sdk/registry.js";
import { requireKey } from "../middleware/apiKeyAuth.js";
import { asyncHandler } from "../middleware/http.js";
import { selectCandidates } from "../routing/selectSupplier.js";
import { preflight } from "../onchain/preflight.js";
import { resolveSubmittedRef, acceptAndConfirm } from "../onchain/settle.js";
import { ensureWalletHealthy } from "../walletHealth.js";
import { parseChatRequest, type ParsedChatRequest } from "./validate.js";
import { runDemoChat } from "./demoChat.js";
import {
genId,
buildChatCompletion,
buildChunk,
usageFromReceipt,
sseData,
SSE_DONE,
type Usage,
} from "./shapes.js";
import { notFound, paymentRequired, toGatewayError, toErrorBody } from "./errors.js";
const CAPABILITY = "llm.text.generate.v1";
function recordUsage(
store: GatewayStore,
p: {
keyId: string;
model: string;
supplierPkh: string | null;
escrowRef: string | null;
costLovelace: bigint | null;
usage: Usage | null;
status: "completed" | "failed";
failureReason?: string;
},
): void {
store.insertUsage({
id: randomUUID(),
key_id: p.keyId,
created_at: Date.now(),
kind: "completion",
model: p.model,
capability_id: CAPABILITY,
supplier_pkh: p.supplierPkh,
escrow_ref: p.escrowRef,
cost_lovelace: p.costLovelace !== null ? p.costLovelace.toString() : null,
prompt_tokens: p.usage?.prompt_tokens ?? 0,
completion_tokens: p.usage?.completion_tokens ?? 0,
status: p.status,
failure_reason: p.failureReason ?? null,
});
}
export function makeChatCompletionsHandler(deps: GatewayDeps) {
return asyncHandler(async (req: Request, res: Response) => {
const keyRow = requireKey(req);
const ctx = deps.registry.getContext(keyRow);
if (keyRow.demo) {
// Demo keys run session-backed with tools passthrough, and never fall
// back to one-shot (its settle path assumes ≤1 in-flight escrow per
// wallet; the demo wallet holds one escrow per concurrent session).
// Not under the key mutex — runDemoChat takes it only for session opens.
const parsed = parseChatRequest(req.body, { allowTools: true });
await runDemoChat(deps, keyRow, ctx, parsed, res);
return;
}
const parsed = parseChatRequest(req.body);
await ctx.mutex.run(() => runOneShot(deps, keyRow, ctx, parsed, res));
});
}
async function runOneShot(
deps: GatewayDeps,
keyRow: ApiKeyRow,
ctx: KeyContext,
parsed: ParsedChatRequest,
res: Response,
): Promise<void> {
const { config, store, chain, fetchFn } = deps;
const candidates = await selectCandidates({
indexerUrl: config.indexerUrl,
model: parsed.model,
capabilityId: CAPABILITY,
fetchFn,
});
if (candidates.length === 0) {
throw notFound("model_not_found", `no available supplier for model "${parsed.model}"`);
}
const primary = candidates[0];
const pf = await preflight(chain, ctx.walletKey.address, primary);
if (!pf.ok) {
throw paymentRequired(
pf.collateralOk
? "insufficient balance for this request"
: "wallet has no pure-AP3X UTxO ≥ 5 AP3X for collateral; deposit a little more AP3X",
{
x_vector: {
required_lovelace: pf.requiredLovelace.toString(),
available_lovelace: pf.availableLovelace.toString(),
collateral_ok: pf.collateralOk,
deposit_address: ctx.walletKey.address,
},
},
);
}
const id = genId();
let keepalive: ReturnType<typeof setInterval> | undefined;
if (parsed.stream) {
res.status(200);
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.write(": connected\n\n");
keepalive = setInterval(() => {
try {
res.write(": keepalive\n\n");
} catch {
/* client gone */
}
}, 10_000);
}
try {
// Submit. Fall back to the next supplier ONLY on a pre-post
// TxConstructionError (no escrow was posted). A SupplierError means the
// escrow is already posted — do not retry (the sweeper recovers it).
let result: Awaited<ReturnType<typeof ctx.sdk.submitPrompt>> | undefined;
let used: (typeof candidates)[number] | undefined;
let lastErr: unknown;
for (const cand of candidates) {
try {
result = await ctx.sdk.submitPrompt({
advertRef: cand.advertRef,
messages: parsed.messages,
payment_lovelace: cand.priceLovelace,
max_output_tokens: parsed.maxTokens,
});
used = cand;
break;
} catch (err) {
lastErr = err;
if (err instanceof TxConstructionError) continue;
throw err;
}
}
if (!result || !used) {
throw toGatewayError(lastErr ?? new Error("no supplier could be engaged"));
}
// Settle: resolve the live Submitted ref, Accept, await confirmation.
const originalRefStr = `${result.escrowRef.txHash}#${result.escrowRef.index}`;
const submittedRef = await resolveSubmittedRef({
indexerUrl: config.indexerUrl,
buyerPkh: ctx.walletKey.pubKeyHash,
originalRefStr,
fetchFn,
});
await acceptAndConfirm(chain, ctx.walletKey, submittedRef);
const usage = usageFromReceipt(result.receipt);
const vector = {
receipt: result.receipt,
receipt_signature: result.receiptSignature,
escrow_ref: originalRefStr,
};
recordUsage(store, {
keyId: keyRow.id,
model: parsed.model,
supplierPkh: used.supplierPkh,
escrowRef: originalRefStr,
costLovelace: used.priceLovelace,
usage,
status: "completed",
});
// Re-shape the wallet ({collateral, working}) so the next request keeps a
// valid collateral UTxO. Best-effort; serialized by the mutex.
await ensureWalletHealthy(chain, ctx.walletKey).catch((e) =>
// eslint-disable-next-line no-console
console.error("[gateway] wallet-health after completion:", e instanceof Error ? e.message : e),
);
if (parsed.stream) {
if (keepalive) clearInterval(keepalive);
res.write(sseData(buildChunk({ id, model: parsed.model, delta: { role: "assistant", content: result.response }, finishReason: null })));
res.write(sseData(buildChunk({ id, model: parsed.model, delta: {}, finishReason: "stop", usage, vector })));
res.write(SSE_DONE);
res.end();
} else {
res.status(200).json(buildChatCompletion({ id, model: parsed.model, content: result.response, usage, vector }));
}
} catch (err) {
if (keepalive) clearInterval(keepalive);
recordUsage(store, {
keyId: keyRow.id,
model: parsed.model,
supplierPkh: null,
escrowRef: null,
costLovelace: null,
usage: null,
status: "failed",
failureReason: err instanceof Error ? err.message : String(err),
});
if (parsed.stream && res.headersSent) {
try {
res.write(sseData(toErrorBody(toGatewayError(err))));
} catch {
/* client gone */
}
res.end();
return;
}
throw err;
}
}

View File

@@ -0,0 +1,413 @@
/**
* gateway/src/openai/demoChat.ts — session-backed /chat/completions for the
* shared demo key.
*
* Demo requests keep the standard OpenAI wire shape (tools included) but run
* against real escrow-per-session marketplace conversations: transcript-prefix
* affinity maps each stateless request onto an open session and forwards only
* the unseen delta; a miss transparently opens a new session (escrow post —
* the only chain work, under the key mutex) and replays the full history as
* the first turn. Supplier idle-closes are healed by reopening; the sweeper
* settles the abandoned escrow.
*
* Demo keys NEVER fall back to the one-shot path: resolveSubmittedRef's
* lone-Submitted-row fallback assumes ≤1 in-flight escrow per wallet, and the
* demo wallet holds one escrow per concurrent session.
*/
import { randomUUID } from "crypto";
import type { Response } from "express";
import type { ChatMessage, ToolCall } from "@marketplace/shared/tx";
import type { GatewayDeps } from "../deps.js";
import type { ApiKeyRow, SessionRow } from "../db/store.js";
import type { KeyContext } from "../sdk/registry.js";
import { transcripts, getSessionLock, dropSessionState } from "./transcripts.js";
import { matchSessionPrefix, trimToRespondable, type AffinityCandidate } from "./affinity.js";
import {
CAPABILITY,
SessionGoneError,
openSessionCore,
streamSupplierTurn,
type TurnResult,
} from "./sessions.js";
import type { ParsedChatRequest } from "./validate.js";
import { genId, buildChatCompletion, buildChunk, sseData, SSE_DONE, type Usage } from "./shapes.js";
import { GatewayError, badRequest, toGatewayError, toErrorBody } from "./errors.js";
interface DemoSessionMeta {
keyId: string;
model: string;
lastUsedAt: number;
}
/** Open sessions owned by the demo executor (sessionId → meta). Mirrors live
* in transcripts.ts; this map scopes affinity to demo-opened sessions. */
const demoSessions = new Map<string, DemoSessionMeta>();
/** Forget a session (closed/settled elsewhere, e.g. by the sweeper). */
export function dropDemoSession(sessionId: string): void {
demoSessions.delete(sessionId);
}
function candidatesFor(keyId: string, model: string): AffinityCandidate[] {
const out: AffinityCandidate[] = [];
for (const [sessionId, meta] of demoSessions) {
if (meta.keyId !== keyId || meta.model !== model) continue;
const mirror = transcripts.get(sessionId);
if (!mirror) continue;
out.push({ sessionId, model: meta.model, mirror, lastUsedAt: meta.lastUsedAt });
}
return out;
}
/** Record the session's cost when the demo executor closes its row. Full
* mode: the flat session fee (supplier Submitted; the sweeper Accepts the
* escrow afterwards). Ticket mode: 0 — nothing settles, the sweeper reclaims
* the escrow and the wallet is made whole minus fees. */
function recordSessionCost(deps: GatewayDeps, session: SessionRow): void {
deps.store.insertUsage({
id: randomUUID(),
key_id: session.key_id,
created_at: Date.now(),
kind: "chat_session",
model: session.model,
capability_id: CAPABILITY,
supplier_pkh: session.supplier_pkh,
escrow_ref: session.escrow_ref,
cost_lovelace: deps.config.chatSettleMode === "ticket" ? "0" : session.price_lovelace,
prompt_tokens: 0,
completion_tokens: 0,
status: "completed",
failure_reason: null,
});
}
function markSessionGone(deps: GatewayDeps, session: SessionRow): void {
deps.store.setSessionState(session.id, "closed", Date.now());
dropSessionState(session.id);
dropDemoSession(session.id);
recordSessionCost(deps, session);
}
/** Ask the supplier to settle (Submit) the session. NO gateway chain work —
* the sweeper Accepts the resulting Submitted escrow. The supplier releases
* its single slot as part of ending, so a truthy return means the supplier is
* free again. Best-effort: on failure the sweeper reclaims later. */
async function requestSupplierEnd(deps: GatewayDeps, session: SessionRow): Promise<boolean> {
try {
const res = await deps.fetchFn(`${session.supplier_base_url.replace(/\/+$/, "")}/v1/chat/end`, {
method: "POST",
headers: { "content-type": "application/json", "X-Escrow-Ref": session.escrow_ref },
body: JSON.stringify({}),
signal: AbortSignal.timeout(90_000),
});
await res.text().catch(() => "");
return res.ok;
} catch {
return false;
}
}
/** Close one demo session (evicted for capacity, or idle-janitored). Runs
* under the session lock so an in-flight turn finishes first; takes NO key
* mutex (no chain work here — see requestSupplierEnd). Returns the freed
* supplier_pkh, or null when the session was already gone or (janitor) was
* used again while we waited for the lock. */
async function closeDemoSession(
deps: GatewayDeps,
sessionId: string,
opts: { reason: "evicted" | "idle"; idleCutoffMs?: number; nowMs?: number },
): Promise<string | null> {
if (!demoSessions.has(sessionId)) return null;
return getSessionLock(sessionId).run(async () => {
const meta = demoSessions.get(sessionId);
if (!meta) return null; // lost a race with another closer
// Re-check idleness inside the lock: a turn that won the lock first has
// bumped lastUsedAt past the sweep's reference clock.
if (opts.idleCutoffMs !== undefined && (opts.nowMs ?? Date.now()) - meta.lastUsedAt < opts.idleCutoffMs) {
return null;
}
const session = deps.store.getSession(sessionId);
if (!session || session.state !== "open") {
dropDemoSession(sessionId);
return null;
}
await requestSupplierEnd(deps, session);
markSessionGone(deps, session);
// eslint-disable-next-line no-console
console.log(`[gateway:demo] closed session ${sessionId} (${opts.reason}), freed supplier ${session.supplier_pkh}`);
return session.supplier_pkh;
});
}
/** Least-recently-used open demo session for (key, model), excluding prior
* victims. Same-model scoping doubles as a guard: a bogus model name has no
* demo sessions, so eviction can never loop on a genuine model_not_found. */
function pickLruDemoSession(keyId: string, model: string, exclude: ReadonlySet<string>): string | null {
let lruId: string | null = null;
let lruAt = Infinity;
for (const [sessionId, meta] of demoSessions) {
if (meta.keyId !== keyId || meta.model !== model || exclude.has(sessionId)) continue;
if (meta.lastUsedAt < lruAt) {
lruAt = meta.lastUsedAt;
lruId = sessionId;
}
}
return lruId;
}
/** Close demo sessions idle past deps.config.demoSessionIdleMs so abandoned
* conversations (client gone, title-gen one-offs) release their single-slot
* suppliers. Called from the sweeper tick; sequential to keep supplier
* /v1/chat/end calls orderly. */
export async function sweepIdleDemoSessions(deps: GatewayDeps, nowMs = Date.now()): Promise<void> {
const idleMs = deps.config.demoSessionIdleMs;
for (const [sessionId, meta] of [...demoSessions]) {
if (nowMs - meta.lastUsedAt < idleMs) continue;
try {
await closeDemoSession(deps, sessionId, { reason: "idle", idleCutoffMs: idleMs, nowMs });
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[gateway:demo] idle close of ${sessionId} failed:`, err instanceof Error ? err.message : err);
}
}
}
const MAX_EVICTIONS = 2;
function isNoSupplierError(err: unknown): boolean {
return err instanceof GatewayError && (err.code === "model_not_found" || err.code === "overloaded");
}
/** Open a session, evicting up to MAX_EVICTIONS LRU demo sessions when every
* supplier is pinned. Eviction frees the supplier immediately (its slot
* releases at Submit) but the indexer's status poll lags, so the retry passes
* the freed pkhs through selectCandidates' ignoreStatusFor. Lock ordering is
* safe: eviction holds only the session lock, the open holds only the key
* mutex — never nested. */
async function openSessionWithEviction(
deps: GatewayDeps,
keyRow: ApiKeyRow,
ctx: KeyContext,
model: string,
): Promise<SessionRow> {
const freedPkhs = new Set<string>();
const evicted = new Set<string>();
for (let attempt = 0; ; attempt++) {
try {
return await ctx.mutex.run(() =>
openSessionCore(deps, keyRow, ctx, model, freedPkhs.size > 0 ? { ignoreStatusFor: freedPkhs } : undefined),
);
} catch (err) {
if (attempt >= MAX_EVICTIONS || !isNoSupplierError(err)) throw err;
const victimId = pickLruDemoSession(keyRow.id, model, evicted);
if (!victimId) throw err;
evicted.add(victimId);
const pkh = await closeDemoSession(deps, victimId, { reason: "evicted" });
if (!pkh) throw err; // nothing actually freed — don't loop
freedPkhs.add(pkh);
}
}
}
type LockedTurnOutcome =
| { kind: "done"; result: TurnResult }
| { kind: "retry" } // candidate no longer valid — re-run the match
| { kind: "gone" }; // supplier lost the session — open a fresh one
/** Streaming sink shared across turn attempts on one response. */
interface StreamSink {
onToken: (token: string) => void;
emitToolCalls: (toolCalls: ToolCall[]) => void;
}
export async function runDemoChat(
deps: GatewayDeps,
keyRow: ApiKeyRow,
ctx: KeyContext,
parsed: ParsedChatRequest,
res: Response,
): Promise<void> {
// Clients sometimes send histories ending in an assistant (continue/retry
// flows) or system message; the chat.v1 supplier can only respond to a
// user/tool-terminated turn. Trim trailing unrespondable messages
// (regenerate semantics) and reject — before any chain work — only when
// nothing respondable remains.
const trimmed = trimToRespondable(parsed.messages);
if (trimmed.length === 0) {
throw badRequest("invalid_messages", "no user or tool message to respond to");
}
const effective: ParsedChatRequest =
trimmed.length === parsed.messages.length ? parsed : { ...parsed, messages: trimmed };
const id = genId();
let keepalive: ReturnType<typeof setInterval> | undefined;
let roleSent = false;
if (parsed.stream) {
res.status(200);
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.write(": connected\n\n");
keepalive = setInterval(() => {
try {
res.write(": keepalive\n\n");
} catch {
/* client gone */
}
}, 10_000);
}
const ensureRole = (): void => {
if (roleSent) return;
roleSent = true;
res.write(sseData(buildChunk({ id, model: parsed.model, delta: { role: "assistant" }, finishReason: null })));
};
const sink: StreamSink = {
onToken: (token) => {
if (!parsed.stream) return;
ensureRole();
res.write(sseData(buildChunk({ id, model: parsed.model, delta: { content: token }, finishReason: null })));
},
emitToolCalls: (toolCalls) => {
if (!parsed.stream) return;
ensureRole();
res.write(
sseData(
buildChunk({
id,
model: parsed.model,
delta: { tool_calls: toolCalls.map((tc, index) => ({ index, ...tc })) },
finishReason: null,
}),
),
);
},
};
try {
const result = await runWithAffinity(deps, keyRow, ctx, effective, sink);
if (keepalive) clearInterval(keepalive);
const usage: Usage = {
prompt_tokens: result.usage.prompt_tokens,
completion_tokens: result.usage.completion_tokens,
total_tokens: result.usage.prompt_tokens + result.usage.completion_tokens,
};
if (parsed.stream) {
if (result.assistantMessage.tool_calls?.length) sink.emitToolCalls(result.assistantMessage.tool_calls);
ensureRole();
res.write(sseData(buildChunk({ id, model: parsed.model, delta: {}, finishReason: result.finishReason, usage })));
res.write(SSE_DONE);
res.end();
} else {
res.status(200).json(
buildChatCompletion({
id,
model: parsed.model,
content: result.assistantMessage.content,
toolCalls: result.assistantMessage.tool_calls,
finishReason: result.finishReason,
usage,
}),
);
}
} catch (err) {
if (keepalive) clearInterval(keepalive);
if (parsed.stream && res.headersSent) {
try {
res.write(sseData(toErrorBody(toGatewayError(err))));
} catch {
/* client gone */
}
res.end();
return;
}
throw err;
}
}
async function runWithAffinity(
deps: GatewayDeps,
keyRow: ApiKeyRow,
ctx: KeyContext,
parsed: ParsedChatRequest,
sink: StreamSink,
): Promise<TurnResult> {
// Affinity: try matching sessions until one serves the turn. The match runs
// synchronously (no await between match and lock acquire, so the candidate
// set can't shift underneath us); the prefix is re-checked inside the lock
// because a concurrent request may have advanced the session first.
for (let attempt = 0; attempt < 3; attempt++) {
const match = matchSessionPrefix(candidatesFor(keyRow.id, parsed.model), parsed.messages, parsed.model);
if (!match) break;
const outcome: LockedTurnOutcome = await getSessionLock(match.sessionId).run(async () => {
const meta = demoSessions.get(match.sessionId);
const mirror = transcripts.get(match.sessionId);
const session = deps.store.getSession(match.sessionId);
if (!meta || !mirror || !session || session.state !== "open") {
dropDemoSession(match.sessionId);
return { kind: "retry" };
}
const recheck = matchSessionPrefix(
[{ sessionId: session.id, model: meta.model, mirror, lastUsedAt: meta.lastUsedAt }],
parsed.messages,
parsed.model,
);
if (!recheck) return { kind: "retry" };
try {
const result = await streamSupplierTurn(
deps,
session,
{ messages: recheck.delta, tools: parsed.tools, tool_choice: parsed.toolChoice },
sink.onToken,
);
meta.lastUsedAt = Date.now();
return { kind: "done", result };
} catch (err) {
if (err instanceof SessionGoneError) {
// Supplier idle-closed (or restarted). Close our row, record the
// fee (the escrow settles via the sweeper) and reopen fresh.
markSessionGone(deps, session);
return { kind: "gone" };
}
throw err;
}
});
if (outcome.kind === "done") return outcome.result;
if (outcome.kind === "gone") break;
// retry: candidate vanished/advanced — re-match against current state.
}
// Miss: open a new session (chain work → key mutex; evicts an LRU demo
// session when every supplier is pinned) and replay the full history as its
// first turn.
const session = await openSessionWithEviction(deps, keyRow, ctx, parsed.model);
demoSessions.set(session.id, { keyId: keyRow.id, model: parsed.model, lastUsedAt: Date.now() });
return getSessionLock(session.id).run(async () => {
try {
const result = await streamSupplierTurn(
deps,
session,
{ messages: parsed.messages, tools: parsed.tools, tool_choice: parsed.toolChoice },
sink.onToken,
);
// Refresh recency after the (possibly long) first stream so this live
// thread isn't the LRU/janitor's first victim.
const meta = demoSessions.get(session.id);
if (meta) meta.lastUsedAt = Date.now();
return result;
} catch (err) {
if (err instanceof SessionGoneError) {
// Freshly opened yet already gone (supplier crashed mid-claim).
// Surface as an upstream error rather than looping.
markSessionGone(deps, session);
}
throw err;
}
});
}

View File

@@ -0,0 +1,78 @@
/**
* gateway/src/openai/errors.ts — OpenAI-shaped error taxonomy.
*
* OpenAI SDKs parse error.type to drive retry/severity, so every gateway error
* is mapped to a concrete {message, type, code} triple and the documented HTTP
* status. Marketplace SDK errors (SupplierError/IndexerError/ReceiptVerification
* Error) and TxConstructionError are translated here.
*/
import { ReceiptVerificationError, IndexerError, SupplierError } from "@marketplace/buyer/sdk";
import { TxConstructionError } from "@marketplace/shared/tx";
export type OpenAiErrorType =
| "invalid_request_error"
| "authentication_error"
| "rate_limit_error"
| "server_error";
export class GatewayError extends Error {
constructor(
public readonly httpStatus: number,
public readonly type: OpenAiErrorType,
public readonly code: string,
message: string,
/** Optional vendor extension fields merged alongside `error` in the body. */
public readonly extra?: Record<string, unknown>,
) {
super(message);
this.name = "GatewayError";
}
}
export const badRequest = (code: string, message: string): GatewayError =>
new GatewayError(400, "invalid_request_error", code, message);
export const unauthorized = (message = "invalid API key"): GatewayError =>
new GatewayError(401, "authentication_error", "invalid_api_key", message);
export const paymentRequired = (message: string, extra?: Record<string, unknown>): GatewayError =>
new GatewayError(402, "invalid_request_error", "insufficient_funds", message, extra);
export const forbidden = (code: string, message: string): GatewayError =>
new GatewayError(403, "invalid_request_error", code, message);
export const notFound = (code: string, message: string): GatewayError =>
new GatewayError(404, "invalid_request_error", code, message);
export const rateLimited = (message: string): GatewayError =>
new GatewayError(429, "rate_limit_error", "rate_limit_exceeded", message);
/** Render the body OpenAI clients expect: {error:{message,type,code,param}}. */
export function toErrorBody(err: GatewayError): Record<string, unknown> {
return {
error: { message: err.message, type: err.type, code: err.code, param: null },
...(err.extra ?? {}),
};
}
/** Map any thrown value to a GatewayError. */
export function toGatewayError(err: unknown): GatewayError {
if (err instanceof GatewayError) return err;
if (err instanceof SupplierError) {
if (err.reason === "timeout") {
return new GatewayError(504, "server_error", "supplier_timeout", "supplier timed out");
}
if (err.reason === "supplier_busy") {
return new GatewayError(503, "server_error", "overloaded", "all matching suppliers are busy");
}
return new GatewayError(502, "server_error", "upstream_error", `supplier error: ${err.reason}`);
}
if (err instanceof ReceiptVerificationError) {
return new GatewayError(502, "server_error", "receipt_verification_failed", `receipt invalid: ${err.reason}`);
}
if (err instanceof IndexerError) {
return new GatewayError(502, "server_error", "indexer_error", `indexer error: ${err.reason}`);
}
if (err instanceof TxConstructionError) {
return new GatewayError(502, "server_error", "escrow_failed", `escrow build failed: ${err.reason}`);
}
const message = err instanceof Error ? err.message : String(err);
return new GatewayError(500, "server_error", "internal_error", message);
}

View File

@@ -0,0 +1,26 @@
/**
* gateway/src/openai/models.ts — GET /openai/v1/models.
*
* Distinct models advertised by Active suppliers, in the OpenAI list shape.
*/
import type { Request, Response } from "express";
import type { GatewayDeps } from "../deps.js";
import { asyncHandler } from "../middleware/http.js";
import { requireKey } from "../middleware/apiKeyAuth.js";
import { listModels } from "../routing/selectSupplier.js";
import { buildModelsList } from "./shapes.js";
import { CAPABILITY as CHAT_CAPABILITY } from "./sessions.js";
export function makeModelsHandler(deps: GatewayDeps) {
return asyncHandler(async (req: Request, res: Response) => {
const keyRow = requireKey(req);
// Demo keys only reach chat-capable suppliers (session-backed executor).
const models = await listModels({
indexerUrl: deps.config.indexerUrl,
capabilityId: keyRow.demo ? CHAT_CAPABILITY : undefined,
fetchFn: deps.fetchFn,
});
res.status(200).json(buildModelsList(models));
});
}

View File

@@ -0,0 +1,479 @@
/**
* gateway/src/openai/sessions.ts — chat sessions (Vector extension).
*
* One escrow per SESSION (fixed price locked at open, settled at close). Real
* token streaming per turn against an llm.chat.v1 supplier. Endpoints:
* POST /openai/v1/chat/sessions → open (startChat)
* POST /openai/v1/chat/sessions/:id/messages → one streamed turn
* POST /openai/v1/chat/sessions/:id/close → settle (endChat: Submit+Accept)
*
* The chat.v1 supplier has no system-role channel and accumulates the transcript
* server-side, so we send one user `content` per turn and mirror the transcript
* in-memory (for endChat's response_hash verification). A process restart loses
* the mirror; an abandoned session is reclaimed by the sweeper.
*/
import { randomUUID } from "crypto";
import type { Request, Response } from "express";
import { TxConstructionError, normalizeChatMessage, type ChatMessage } from "@marketplace/shared/tx";
import { SupplierError } from "@marketplace/buyer/sdk";
import { transcripts, getSessionLock, dropSessionState } from "./transcripts.js";
import type { GatewayDeps } from "../deps.js";
import type { ApiKeyRow, GatewayStore, SessionRow } from "../db/store.js";
import type { KeyContext } from "../sdk/registry.js";
import { requireKey } from "../middleware/apiKeyAuth.js";
import { asyncHandler } from "../middleware/http.js";
import { selectCandidates, parseRef } from "../routing/selectSupplier.js";
import { preflight } from "../onchain/preflight.js";
import { ensureWalletHealthy } from "../walletHealth.js";
import { badRequest, notFound, paymentRequired, toGatewayError, toErrorBody } from "./errors.js";
import {
genId,
nowSec,
buildChatCompletion,
buildChunk,
sseData,
SSE_DONE,
type Usage,
} from "./shapes.js";
export const CAPABILITY = "llm.chat.v1";
/** Thrown when the supplier no longer knows the session (idle-closed/restart). */
export class SessionGoneError extends Error {
constructor(sessionId: string) {
super(`supplier no longer has session ${sessionId}`);
this.name = "SessionGoneError";
}
}
function refStr(ref: { txHash: string; index: number }): string {
return `${ref.txHash}#${ref.index}`;
}
// ─── open ──────────────────────────────────────────────────────────────────
export function makeOpenSessionHandler(deps: GatewayDeps) {
return asyncHandler(async (req: Request, res: Response) => {
const keyRow = requireKey(req);
const body = (req.body ?? {}) as Record<string, unknown>;
const model = body.model;
if (typeof model !== "string" || model === "") {
throw badRequest("invalid_model", "`model` is required");
}
const ctx = deps.registry.getContext(keyRow);
const session = await ctx.mutex.run(() => openSessionCore(deps, keyRow, ctx, model));
res.status(200).json({ id: session.id, object: "chat.session", model, created: nowSec() });
});
}
/**
* Select a supplier, preflight the wallet, post+claim the session escrow and
* persist the SessionRow. Chain work — the CALLER must hold the key mutex
* (concurrent PostEscrow from one wallet double-spends the same UTxO).
*/
export async function openSessionCore(
deps: GatewayDeps,
keyRow: ApiKeyRow,
ctx: KeyContext,
model: string,
opts?: { ignoreStatusFor?: ReadonlySet<string> },
): Promise<SessionRow> {
const candidates = await selectCandidates({
indexerUrl: deps.config.indexerUrl,
model,
capabilityId: CAPABILITY,
fetchFn: deps.fetchFn,
ignoreStatusFor: opts?.ignoreStatusFor,
});
if (candidates.length === 0) {
throw notFound("model_not_found", `no available chat (llm.chat.v1) supplier for model "${model}"`);
}
const primary = candidates[0];
const pf = await preflight(deps.chain, ctx.walletKey.address, primary);
if (!pf.ok) {
throw paymentRequired(
pf.collateralOk ? "insufficient balance to open a session" : "wallet has no ≥5 AP3X pure-AP3X collateral UTxO",
{
x_vector: {
required_lovelace: pf.requiredLovelace.toString(),
available_lovelace: pf.availableLovelace.toString(),
collateral_ok: pf.collateralOk,
deposit_address: ctx.walletKey.address,
},
},
);
}
// startChat posts the escrow + supplier Claim. Fall back to the next supplier
// only on a pre-post error (TxConstructionError, or a busy supplier caught by
// startChat's /status pre-check before any funds are locked).
let started: Awaited<ReturnType<typeof ctx.sdk.startChat>> | undefined;
let used: (typeof candidates)[number] | undefined;
let lastErr: unknown;
for (const cand of candidates) {
try {
started = await ctx.sdk.startChat({ advertRef: cand.advertRef, payment_lovelace: cand.priceLovelace });
used = cand;
break;
} catch (err) {
lastErr = err;
if (err instanceof TxConstructionError) continue;
if (err instanceof SupplierError && err.reason === "supplier_busy") continue;
throw err;
}
}
if (!started || !used) throw toGatewayError(lastErr ?? new Error("no chat supplier could be engaged"));
// Deployment sanity: supplier and gateway must agree on the settle mode.
// Close behavior is driven per-session by the supplier's /end response, so
// a mismatch (rollout window) still settles correctly — just warn.
if ((started.settleMode === "ticket") !== (deps.config.chatSettleMode === "ticket")) {
// eslint-disable-next-line no-console
console.warn(
`[gateway] settle-mode mismatch: supplier ${used.supplierPkh.slice(0, 8)} ran "${started.settleMode}" but gateway is configured "${deps.config.chatSettleMode}"`,
);
}
const sessionId = randomUUID();
deps.store.insertSession({
id: sessionId,
key_id: keyRow.id,
escrow_ref: refStr(started.escrowRef),
session_nonce: started.sessionNonce,
supplier_base_url: started.supplierBaseUrl,
supplier_pkh: used.supplierPkh,
model,
price_lovelace: used.priceLovelace.toString(),
state: "open",
opened_at: Date.now(),
});
transcripts.set(sessionId, []);
const session = deps.store.getSession(sessionId);
if (!session) throw new Error(`session ${sessionId} vanished after insert`);
return session;
}
// ─── messages (one streamed turn) ────────────────────────────────────────────
export function makeSessionMessageHandler(deps: GatewayDeps) {
return asyncHandler(async (req: Request, res: Response) => {
const keyRow = requireKey(req);
const session = loadOwnedSession(deps.store, req, keyRow);
if (session.state !== "open") {
throw badRequest("session_closed", `session ${session.id} is ${session.state}`);
}
const body = (req.body ?? {}) as Record<string, unknown>;
const stream = body.stream !== false; // sessions default to streaming
const content = resolveTurnContent(session.id, body);
if (content === "") throw badRequest("content_required", "no user content in this turn");
// Turns are chain-free (one HTTP POST to the supplier + SSE relay), so the
// per-key mutex — which exists to serialize wallet UTxO spends — is NOT
// held here. What needs serialization is this session's transcript:
// concurrent turns would interleave supplier appends and corrupt both the
// mirror and the receipt hash. Hence a per-session lock.
await getSessionLock(session.id).run(() => streamTurn(deps, session, content, stream, res));
});
}
function resolveTurnContent(sessionId: string, body: Record<string, unknown>): string {
if (typeof body.content === "string") {
return foldSystem(sessionId, body.content, body);
}
if (Array.isArray(body.messages)) {
const msgs = body.messages as Array<{ role?: unknown; content?: unknown }>;
const lastUser = [...msgs].reverse().find((m) => m.role === "user" && typeof m.content === "string");
const userContent = typeof lastUser?.content === "string" ? lastUser.content : "";
return foldSystem(sessionId, userContent, body);
}
return "";
}
/** On the first turn only, prepend any system message (chat.v1 has no system channel). */
function foldSystem(sessionId: string, content: string, body: Record<string, unknown>): string {
const mirror = transcripts.get(sessionId) ?? [];
if (mirror.length > 0) return content;
if (Array.isArray(body.messages)) {
const sys = (body.messages as Array<{ role?: unknown; content?: unknown }>)
.filter((m) => m.role === "system" && typeof m.content === "string")
.map((m) => m.content as string)
.join("\n\n");
if (sys) return `System: ${sys}\n\n${content}`;
}
return content;
}
/** One turn's payload to the supplier: legacy single user string, or a
* multi-message delta (e.g. tool results) with optional tools passthrough. */
export type TurnPayload =
| { content: string }
| { messages: ChatMessage[]; tools?: unknown[]; tool_choice?: unknown };
export interface TurnResult {
assistantMessage: ChatMessage;
finishReason: "stop" | "tool_calls";
usage: { prompt_tokens: number; completion_tokens: number };
}
/**
* Drive one off-chain turn against the session's supplier and mirror the
* result into the session transcript. Caller must hold the session lock.
* Throws SessionGoneError when the supplier no longer knows the session
* (idle-closed or restarted) so callers can transparently reopen.
*/
export async function streamSupplierTurn(
deps: GatewayDeps,
session: SessionRow,
payload: TurnPayload,
onToken?: (token: string) => void,
): Promise<TurnResult> {
const upstream = await deps.fetchFn(`${session.supplier_base_url.replace(/\/+$/, "")}/v1/chat/message`, {
method: "POST",
headers: { "content-type": "application/json", accept: "text/event-stream", "X-Escrow-Ref": session.escrow_ref },
body: JSON.stringify(payload),
});
if (upstream.status === 404) {
await upstream.text().catch(() => "");
throw new SessionGoneError(session.id);
}
if (!upstream.ok || !upstream.body) {
const detail = await upstream.text().catch(() => "");
throw toGatewayError(new SupplierError("supplier_http_error", { status: upstream.status, message: detail.slice(0, 200) }));
}
let assistant = "";
let errored: string | undefined;
let doneMessage: ChatMessage | null = null;
let doneFinish: string | undefined;
let usage = { prompt_tokens: 0, completion_tokens: 0 };
await readSupplierSse(upstream.body as ReadableStream<Uint8Array>, (frame) => {
if (frame.type === "token" && typeof frame.value === "string") {
assistant += frame.value;
onToken?.(frame.value);
} else if (frame.type === "done") {
// Upgraded suppliers echo the final assistant message verbatim; mirror
// that exact object so both transcripts stay hash-identical.
doneMessage = normalizeChatMessage(frame.message);
if (typeof frame.finish_reason === "string") doneFinish = frame.finish_reason;
if (frame.usage && typeof frame.usage === "object") {
const u = frame.usage as Record<string, unknown>;
usage = {
prompt_tokens: typeof u.prompt_tokens === "number" ? u.prompt_tokens : 0,
completion_tokens: typeof u.completion_tokens === "number" ? u.completion_tokens : 0,
};
}
} else if (frame.type === "error") {
errored = typeof frame.message === "string" ? frame.message : "supplier stream error";
}
});
if (errored !== undefined) {
throw toGatewayError(new SupplierError("upstream_error", { message: errored }));
}
const assistantMessage: ChatMessage = doneMessage ?? { role: "assistant", content: assistant };
const finishReason: TurnResult["finishReason"] =
doneFinish === "tool_calls" || (assistantMessage.tool_calls?.length ?? 0) > 0 ? "tool_calls" : "stop";
// Mirror the turn for endChat's response_hash verification / affinity matching.
const mirror = transcripts.get(session.id) ?? [];
if ("content" in payload) mirror.push({ role: "user", content: payload.content });
else mirror.push(...payload.messages);
mirror.push(assistantMessage);
transcripts.set(session.id, mirror);
return { assistantMessage, finishReason, usage };
}
async function streamTurn(
deps: GatewayDeps,
session: SessionRow,
content: string,
stream: boolean,
res: Response,
): Promise<void> {
const id = genId();
if (stream) {
res.status(200);
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.write(sseData(buildChunk({ id, model: session.model, delta: { role: "assistant" }, finishReason: null })));
}
let result: TurnResult;
try {
result = await streamSupplierTurn(deps, session, { content }, (token) => {
if (stream) res.write(sseData(buildChunk({ id, model: session.model, delta: { content: token }, finishReason: null })));
});
} catch (err) {
if (err instanceof SessionGoneError) {
err = toGatewayError(new SupplierError("supplier_http_error", { status: 404, message: err.message }));
}
if (stream) {
res.write(sseData(toErrorBody(toGatewayError(err))));
res.end();
return;
}
throw toGatewayError(err);
}
if (stream) {
res.write(sseData(buildChunk({ id, model: session.model, delta: {}, finishReason: result.finishReason })));
res.write(SSE_DONE);
res.end();
} else {
const usage: Usage = {
prompt_tokens: result.usage.prompt_tokens,
completion_tokens: result.usage.completion_tokens,
total_tokens: result.usage.prompt_tokens + result.usage.completion_tokens,
};
res.status(200).json(buildChatCompletion({ id, model: session.model, content: result.assistantMessage.content, usage }));
}
}
interface SupplierFrame {
type?: string;
value?: string;
message?: unknown;
finish_reason?: unknown;
usage?: unknown;
}
/** Read a supplier SSE stream of {type:token|done|error} frames. */
async function readSupplierSse(
body: ReadableStream<Uint8Array>,
onFrame: (frame: SupplierFrame) => void,
): Promise<void> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buf = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let sep: number;
while ((sep = buf.indexOf("\n\n")) !== -1) {
const raw = buf.slice(0, sep);
buf = buf.slice(sep + 2);
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const data = trimmed.slice(5).trim();
if (data === "" || data === "[DONE]") continue;
try {
onFrame(JSON.parse(data) as SupplierFrame);
} catch {
/* ignore non-JSON keepalive/comment frames */
}
}
}
}
}
// ─── close (settle) ──────────────────────────────────────────────────────────
export function makeCloseSessionHandler(deps: GatewayDeps) {
return asyncHandler(async (req: Request, res: Response) => {
const keyRow = requireKey(req);
const session = loadOwnedSession(deps.store, req, keyRow);
if (session.state !== "open") {
throw badRequest("session_closed", `session ${session.id} is already ${session.state}`);
}
const ctx = deps.registry.getContext(keyRow);
await ctx.mutex.run(() => closeSession(deps, keyRow, ctx, session, res));
});
}
async function closeSession(
deps: GatewayDeps,
keyRow: ApiKeyRow,
ctx: KeyContext,
session: SessionRow,
res: Response,
): Promise<void> {
const escrowRef = parseRef(session.escrow_ref);
if (!escrowRef) throw badRequest("bad_session", "session has an invalid escrow ref");
const result = await ctx.sdk.endChat({
escrowRef,
sessionNonce: session.session_nonce,
supplierBaseUrl: session.supplier_base_url,
transcript: transcripts.get(session.id),
});
deps.store.setSessionState(session.id, "closed", Date.now());
dropSessionState(session.id);
if (result.settleMode === "ticket") {
// Nothing was Submitted/Accepted: no receipt, no charge (the escrow
// returns via the sweeper's reclaim), and no wallet churn to tidy up.
deps.store.insertUsage({
id: randomUUID(),
key_id: keyRow.id,
created_at: Date.now(),
kind: "chat_session",
model: session.model,
capability_id: CAPABILITY,
supplier_pkh: session.supplier_pkh,
escrow_ref: session.escrow_ref,
cost_lovelace: "0",
prompt_tokens: 0,
completion_tokens: 0,
status: "completed",
failure_reason: null,
});
res.status(200).json({
status: "ended",
escrow_ref: session.escrow_ref,
settle_mode: "ticket",
model: session.model,
});
return;
}
const prompt = result.receipt.prompt_tokens ?? 0;
const completion = result.receipt.completion_tokens ?? 0;
deps.store.insertUsage({
id: randomUUID(),
key_id: keyRow.id,
created_at: Date.now(),
kind: "chat_session",
model: session.model,
capability_id: CAPABILITY,
supplier_pkh: session.supplier_pkh,
escrow_ref: session.escrow_ref,
cost_lovelace: session.price_lovelace,
prompt_tokens: prompt,
completion_tokens: completion,
status: "completed",
failure_reason: null,
});
await ensureWalletHealthy(deps.chain, ctx.walletKey).catch((e) =>
// eslint-disable-next-line no-console
console.error("[gateway] wallet-health after session close:", e instanceof Error ? e.message : e),
);
res.status(200).json({
status: "closed",
escrow_ref: session.escrow_ref,
accepted_ref: refStr(result.acceptedRef),
model: session.model,
x_vector: { receipt: result.receipt, receipt_signature: result.receiptSignature, escrow_ref: session.escrow_ref },
});
}
function loadOwnedSession(store: GatewayStore, req: Request, keyRow: ApiKeyRow): SessionRow {
const raw = (req.params as Record<string, string | string[] | undefined>).id;
const id = typeof raw === "string" ? raw : undefined;
const session = id ? store.getSession(id) : undefined;
if (!session || session.key_id !== keyRow.id) {
throw notFound("session_not_found", `no chat session ${id ?? "(none)"}`);
}
return session;
}

View File

@@ -0,0 +1,115 @@
/**
* gateway/src/openai/shapes.ts — OpenAI response object builders.
*
* Produces spec-clean ChatCompletion / ChatCompletionChunk / model-list objects
* plus the `x_vector` receipt extension. SSE helpers frame chunks for streaming.
*/
import { randomBytes } from "crypto";
import type { Receipt } from "@marketplace/shared/receipt";
import type { ToolCall } from "@marketplace/shared/tx";
export function genId(): string {
return `chatcmpl-${randomBytes(16).toString("hex")}`;
}
export function nowSec(): number {
return Math.floor(Date.now() / 1000);
}
export interface VectorReceipt {
receipt: Receipt;
receipt_signature: string;
escrow_ref: string;
}
export interface Usage {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
}
export function usageFromReceipt(receipt: Receipt): Usage {
const prompt = receipt.prompt_tokens ?? 0;
const completion = receipt.completion_tokens ?? 0;
return { prompt_tokens: prompt, completion_tokens: completion, total_tokens: prompt + completion };
}
/** Non-streaming chat.completion object. */
export function buildChatCompletion(args: {
id: string;
model: string;
content: string;
usage: Usage;
toolCalls?: ToolCall[];
finishReason?: "stop" | "tool_calls";
vector?: VectorReceipt;
}): Record<string, unknown> {
return {
id: args.id,
object: "chat.completion",
created: nowSec(),
model: args.model,
choices: [
{
index: 0,
message: {
role: "assistant",
content: args.content,
...(args.toolCalls?.length ? { tool_calls: args.toolCalls } : {}),
},
finish_reason: args.finishReason ?? "stop",
},
],
usage: args.usage,
...(args.vector ? { x_vector: args.vector } : {}),
};
}
/** A streaming chat.completion.chunk. `delta` is the incremental content (or {}). */
export function buildChunk(args: {
id: string;
model: string;
delta:
| { role?: string; content?: string; tool_calls?: Array<{ index: number } & ToolCall> }
| Record<string, never>;
finishReason: "stop" | "tool_calls" | null;
usage?: Usage;
vector?: VectorReceipt;
}): Record<string, unknown> {
return {
id: args.id,
object: "chat.completion.chunk",
created: nowSec(),
model: args.model,
choices: [{ index: 0, delta: args.delta, finish_reason: args.finishReason }],
...(args.usage ? { usage: args.usage } : {}),
...(args.vector ? { x_vector: args.vector } : {}),
};
}
/** Frame any object as an SSE `data:` event. */
export function sseData(obj: unknown): string {
return `data: ${JSON.stringify(obj)}\n\n`;
}
export const SSE_DONE = "data: [DONE]\n\n";
export function buildModelsList(models: string[]): Record<string, unknown> {
const created = nowSec();
return {
object: "list",
data: models.map((id) => ({ id, object: "model", created, owned_by: "vector-marketplace" })),
};
}
/** Render OpenAI messages[] into a single prompt string for the chat.v1 session
* supplier (which has no system-role channel — see docs/gateway.md). */
export function renderMessages(messages: Array<{ role: string; content: string }>): string {
return messages
.map((m) => {
const role = m.role === "assistant" ? "Assistant" : m.role === "system" ? "System" : "User";
return `${role}: ${m.content}`;
})
.join("\n\n");
}

View File

@@ -0,0 +1,35 @@
/**
* gateway/src/openai/transcripts.ts — in-memory per-session state shared by
* the session routes (sessions.ts), the demo affinity executor (demoChat.ts)
* and the sweeper's housekeeping.
*
* transcripts: mirror of each open session's transcript (sessionId → messages).
* Kept hash-identical to the supplier's authoritative transcript (endChat's
* response_hash check). Lost on restart — abandoned sessions are recovered
* by the supplier idle watchdog + gateway sweeper.
* sessionLocks: per-session Mutex serializing turns on ONE session (concurrent
* turns would interleave supplier transcript appends). Distinct from the
* per-key Mutex, which only serializes on-chain wallet work.
*/
import type { ChatMessage } from "@marketplace/shared/tx";
import { Mutex } from "../sdk/registry.js";
export const transcripts = new Map<string, ChatMessage[]>();
const sessionLocks = new Map<string, Mutex>();
export function getSessionLock(sessionId: string): Mutex {
let lock = sessionLocks.get(sessionId);
if (!lock) {
lock = new Mutex();
sessionLocks.set(sessionId, lock);
}
return lock;
}
/** Forget all in-memory state for a session (closed/gone). */
export function dropSessionState(sessionId: string): void {
transcripts.delete(sessionId);
sessionLocks.delete(sessionId);
}

View File

@@ -0,0 +1,88 @@
/**
* gateway/src/openai/validate.ts — request body validation for chat endpoints.
*/
import { normalizeChatMessage, type ChatMessage } from "@marketplace/shared/tx";
import { badRequest } from "./errors.js";
export interface ParsedChatRequest {
model: string;
messages: ChatMessage[];
maxTokens?: number;
stream: boolean;
tools?: unknown[];
toolChoice?: unknown;
}
const ROLES = new Set(["system", "user", "assistant"]);
/** Validate an OpenAI chat.completions-style body. Default path rejects
* tools/functions; `allowTools` (demo keys) accepts tools/tool_choice, the
* `tool` role, null content and assistant tool_calls, normalizing every
* message into canonical mirror shape. Silently ignores temperature/n/stop/
* top_p/etc (documented). */
export function parseChatRequest(body: unknown, opts?: { allowTools?: boolean }): ParsedChatRequest {
const allowTools = opts?.allowTools === true;
if (typeof body !== "object" || body === null) {
throw badRequest("invalid_body", "request body must be a JSON object");
}
const b = body as Record<string, unknown>;
if (!allowTools && ("tools" in b || "tool_choice" in b || "functions" in b || "function_call" in b)) {
throw badRequest("unsupported_parameter", "tools and function-calling are not supported by this gateway");
}
if (allowTools && ("functions" in b || "function_call" in b)) {
throw badRequest("unsupported_parameter", "legacy functions/function_call are not supported; use tools");
}
const model = b.model;
if (typeof model !== "string" || model === "") {
throw badRequest("invalid_model", "`model` is required and must be a non-empty string");
}
const rawMessages = b.messages;
if (!Array.isArray(rawMessages) || rawMessages.length === 0) {
throw badRequest("invalid_messages", "`messages` is required and must be a non-empty array");
}
const messages: ChatMessage[] = [];
for (const m of rawMessages) {
if (allowTools) {
const msg = normalizeChatMessage(m);
if (!msg) {
throw badRequest("invalid_messages", "each message must be OpenAI-shaped {role, content, tool_calls?, tool_call_id?}");
}
messages.push(msg);
continue;
}
if (
typeof m !== "object" ||
m === null ||
typeof (m as { role?: unknown }).role !== "string" ||
typeof (m as { content?: unknown }).content !== "string" ||
!ROLES.has((m as { role: string }).role)
) {
throw badRequest("invalid_messages", "each message must be {role: system|user|assistant, content: string}");
}
const mm = m as { role: "system" | "user" | "assistant"; content: string };
messages.push({ role: mm.role, content: mm.content });
}
let tools: unknown[] | undefined;
let toolChoice: unknown;
if (allowTools && Array.isArray(b.tools) && b.tools.length > 0) {
tools = b.tools;
toolChoice = b.tool_choice;
}
let maxTokens: number | undefined;
const mt = b.max_tokens ?? b.max_completion_tokens;
if (mt !== undefined && mt !== null) {
if (typeof mt !== "number" || !Number.isInteger(mt) || mt <= 0) {
throw badRequest("invalid_max_tokens", "`max_tokens` must be a positive integer");
}
maxTokens = mt;
}
const stream = b.stream === true;
return { model, messages, maxTokens, stream, tools, toolChoice };
}

View File

@@ -0,0 +1,137 @@
/**
* gateway/src/routing/selectSupplier.ts — capability + model routing.
*
* Queries the indexer for suppliers advertising the requested model under the
* required capability. Per the design: match by capability + model, pick an
* AVAILABLE supplier (status free|unknown), NOT the cheapest. We return all
* eligible candidates so the caller can fall back to the next one when the
* chosen supplier turns out to be busy/offline at escrow-post time.
*
* `status==="unknown"` is treated as eligible: the indexer poller (~20s) leaves
* freshly-advertised suppliers as "unknown" until first polled, and excluding
* them would create artificial availability gaps.
*/
import type { OutputReference } from "@marketplace/shared/chain";
/** Subset of the indexer's SupplierView we depend on. */
interface SupplierView {
utxo_ref: string;
supplier_pkh: string;
capability_id: string;
model: string;
max_output_tokens: number;
price_lovelace: string;
supplier_bond_lovelace: string;
buyer_bond_lovelace: string;
endpoint_url: string;
advert_status: string;
status: string;
}
export interface SupplierCandidate {
advertRef: OutputReference;
utxoRef: string;
supplierPkh: string;
model: string;
capabilityId: string;
endpointUrl: string;
priceLovelace: bigint;
buyerBondLovelace: bigint;
supplierBondLovelace: bigint;
maxOutputTokens: number;
status: string;
}
const ESCROW_REF_RE = /^([0-9a-fA-F]{64})#(\d+)$/;
export function parseRef(ref: string): OutputReference | null {
const m = ESCROW_REF_RE.exec(ref);
if (!m) return null;
return { txHash: m[1], index: Number(m[2]) };
}
export interface SelectSupplierOpts {
indexerUrl: string;
/** Exact model match; "" matches ANY model under the capability (used by
* single-model capabilities like the ocr.* ids, where the capability
* already pins the model). */
model: string;
capabilityId: string;
fetchFn?: typeof globalThis.fetch;
/** supplier_pkhs eligible regardless of polled status — used after the demo
* executor frees a supplier the indexer poller (~20 s cadence) still reports
* busy. sdk.startChat's live /status pre-check still guards genuinely-busy
* suppliers. */
ignoreStatusFor?: ReadonlySet<string>;
}
/**
* Return eligible candidates for (model, capability), ordered for fallback.
* Eligible = advert Active, model matches, live status ∈ {free, unknown}.
*/
export async function selectCandidates(opts: SelectSupplierOpts): Promise<SupplierCandidate[]> {
const fetchFn = opts.fetchFn ?? globalThis.fetch;
const url = `${opts.indexerUrl}/suppliers?capability_id=${encodeURIComponent(opts.capabilityId)}`;
const res = await fetchFn(url);
if (!res.ok) {
throw new Error(`indexer /suppliers returned ${res.status}`);
}
const body = (await res.json()) as unknown;
if (!Array.isArray(body)) {
throw new Error("indexer /suppliers did not return an array");
}
const candidates: SupplierCandidate[] = [];
for (const raw of body as SupplierView[]) {
if (raw.capability_id !== opts.capabilityId) continue;
if (opts.model !== "" && raw.model !== opts.model) continue;
if (raw.advert_status !== "Active") continue;
if (raw.status !== "free" && raw.status !== "unknown" && !opts.ignoreStatusFor?.has(raw.supplier_pkh)) {
continue;
}
const advertRef = parseRef(raw.utxo_ref);
if (!advertRef) continue;
candidates.push({
advertRef,
utxoRef: raw.utxo_ref,
supplierPkh: raw.supplier_pkh,
model: raw.model,
capabilityId: raw.capability_id,
endpointUrl: raw.endpoint_url,
priceLovelace: BigInt(raw.price_lovelace),
buyerBondLovelace: BigInt(raw.buyer_bond_lovelace),
supplierBondLovelace: BigInt(raw.supplier_bond_lovelace),
maxOutputTokens: raw.max_output_tokens,
status: raw.status,
});
}
// Prefer known-free over unknown so we route to confirmed-available first.
candidates.sort((a, b) => (a.status === "free" ? 0 : 1) - (b.status === "free" ? 0 : 1));
return candidates;
}
/** Distinct models across Active suppliers (for GET /openai/v1/models).
* Pass capabilityId to list only models under one capability (demo keys see
* only llm.chat.v1 models — the session-backed executor's routing target). */
export async function listModels(opts: {
indexerUrl: string;
capabilityId?: string;
fetchFn?: typeof globalThis.fetch;
}): Promise<string[]> {
const fetchFn = opts.fetchFn ?? globalThis.fetch;
const url = opts.capabilityId
? `${opts.indexerUrl}/suppliers?capability_id=${encodeURIComponent(opts.capabilityId)}`
: `${opts.indexerUrl}/suppliers`;
const res = await fetchFn(url);
if (!res.ok) throw new Error(`indexer /suppliers returned ${res.status}`);
const body = (await res.json()) as unknown;
if (!Array.isArray(body)) throw new Error("indexer /suppliers did not return an array");
const models = new Set<string>();
for (const raw of body as SupplierView[]) {
if (raw.advert_status !== "Active" || typeof raw.model !== "string") continue;
if (opts.capabilityId !== undefined && raw.capability_id !== opts.capabilityId) continue;
models.add(raw.model);
}
return [...models].sort();
}

View File

@@ -0,0 +1,89 @@
/**
* gateway/src/sdk/registry.ts — per-API-key Marketplace SDK cache + serializer.
*
* Each API key is its own custodial wallet, so each gets its own Marketplace
* instance bound to that wallet's WalletKey. The shared ChainProvider and
* indexer URL are reused across all keys. A per-key Mutex serializes all
* on-chain work for one wallet (a single wallet cannot post two escrows from
* the same UTxO concurrently). The cache is LRU-bounded to cap memory.
*/
import { Marketplace, MemoryTaskHistoryStore } from "@marketplace/buyer/sdk";
import type { LiveOgmiosProvider } from "@marketplace/shared/chain";
import type { WalletKey } from "@marketplace/shared/tx";
import { open as unseal } from "../crypto/seal.js";
import { deriveWalletKey } from "../wallet.js";
import type { ApiKeyRow } from "../db/store.js";
/** Tiny promise-chain mutex: run(fn) executes fns strictly one at a time. */
export class Mutex {
private tail: Promise<unknown> = Promise.resolve();
run<T>(fn: () => Promise<T>): Promise<T> {
const result = this.tail.then(fn, fn);
// Keep the chain alive even if fn rejects; swallow here so the next
// run() isn't poisoned by a prior rejection.
this.tail = result.then(
() => undefined,
() => undefined,
);
return result;
}
}
export interface KeyContext {
sdk: Marketplace;
mutex: Mutex;
walletKey: WalletKey;
}
export interface SdkRegistryDeps {
chain: LiveOgmiosProvider;
indexerUrl: string;
networkId: 0 | 1;
masterKeyHex: string;
max: number;
}
export class SdkRegistry {
private readonly cache = new Map<string, KeyContext>();
constructor(private readonly deps: SdkRegistryDeps) {}
/** Build (or fetch from cache) the per-key SDK context. Decrypts the wallet
* key in-memory only when first constructing the instance. */
getContext(keyRow: ApiKeyRow): KeyContext {
const existing = this.cache.get(keyRow.id);
if (existing) {
// LRU bump: re-insert to move to the end of iteration order.
this.cache.delete(keyRow.id);
this.cache.set(keyRow.id, existing);
return existing;
}
const privHex = unseal(
{ nonce: keyRow.enc_priv_nonce, ct: keyRow.enc_priv_ct, tag: keyRow.enc_priv_tag },
this.deps.masterKeyHex,
);
const walletKey = deriveWalletKey(privHex, this.deps.networkId);
const sdk = new Marketplace({
chain: this.deps.chain,
indexerUrl: this.deps.indexerUrl,
walletKey,
networkParams: { networkId: this.deps.networkId },
historyStore: new MemoryTaskHistoryStore(),
});
const ctx: KeyContext = { sdk, mutex: new Mutex(), walletKey };
this.cache.set(keyRow.id, ctx);
this.evictIfNeeded();
return ctx;
}
private evictIfNeeded(): void {
while (this.cache.size > this.deps.max) {
const oldest = this.cache.keys().next().value as string | undefined;
if (oldest === undefined) break;
this.cache.delete(oldest);
}
}
}

105
gateway/src/server.ts Normal file
View File

@@ -0,0 +1,105 @@
/**
* gateway/src/server.ts — Express app wiring.
*
* Public: GET /healthz, GET /, POST /signup (IP rate-limited).
* Bearer: GET /account, POST /account/withdraw,
* POST /openai/v1/chat/completions, GET /openai/v1/models,
* POST /openai/v1/chat/sessions[/:id/messages|/close].
*/
import express, { type Express, type NextFunction, type Request, type Response } from "express";
import type { GatewayDeps } from "./deps.js";
import { makeApiKeyAuth } from "./middleware/apiKeyAuth.js";
import { ipRateLimit, keyRateLimit, demoIpRateLimit } from "./middleware/rateLimit.js";
import { sendError } from "./middleware/http.js";
import { notFound } from "./openai/errors.js";
import { makeChatCompletionsHandler } from "./openai/chatCompletions.js";
import { makeOcrExtractHandler } from "./ocr/extract.js";
import { makeModelsHandler } from "./openai/models.js";
import {
makeOpenSessionHandler,
makeSessionMessageHandler,
makeCloseSessionHandler,
} from "./openai/sessions.js";
import { makeSignupHandler, makeAccountHandler, makeWithdrawHandler } from "./account/routes.js";
import { INDEX_HTML } from "./ui/page.js";
/**
* CORS for browser clients on an allowlisted Origin (e.g. the marketplace
* frontend's "Generate API key" page, served from a different host than the
* gateway). Only echoes Access-Control-* headers when the request's Origin is
* in the configured allowlist; an empty allowlist disables CORS entirely.
* No credentials are allowed — the signup call is a plain bearer-less POST, so
* cookies never ride along cross-origin. Preflight OPTIONS is answered 204.
*/
function corsMiddleware(allowOrigins: string[]) {
const allow = new Set(allowOrigins);
return (req: Request, res: Response, next: NextFunction): void => {
const origin = req.headers.origin;
if (typeof origin === "string" && allow.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Max-Age", "600");
}
if (req.method === "OPTIONS") {
res.status(204).end();
return;
}
next();
};
}
export function createApp(deps: GatewayDeps): Express {
const app = express();
// Behind traefik/Cloudflare — trust the proxy chain so req.ip is the client.
app.set("trust proxy", true);
// CORS before body-parsing so preflight OPTIONS short-circuits cheaply.
app.use(corsMiddleware(deps.config.corsOrigins));
// /v1/ocr carries a base64 page image and mounts its own larger parser on
// the route; every other path keeps the 1mb ceiling.
const jsonBody = express.json({ limit: "1mb" });
app.use((req, res, next) =>
req.path.startsWith("/v1/ocr/") ? next() : jsonBody(req, res, next));
app.get("/healthz", (_req: Request, res: Response) => {
res.json({ ok: true });
});
app.get("/", (_req: Request, res: Response) => {
res.status(200).type("html").send(INDEX_HTML);
});
// Public signup, rate-limited per IP.
app.post("/signup", ipRateLimit(deps.config.signupRate), makeSignupHandler(deps));
// Bearer-gated routes: auth → per-key rate limit (skips demo keys) →
// per-IP demo limit (demo keys only) → handler.
const auth = makeApiKeyAuth(deps.store);
const krl = keyRateLimit(deps.config.keyRate);
const dirl = demoIpRateLimit(deps.config.demoIpRate);
const gated = [auth, krl, dirl] as const;
app.get("/account", auth, makeAccountHandler(deps));
app.post("/account/withdraw", auth, makeWithdrawHandler(deps));
app.post("/openai/v1/chat/completions", ...gated, makeChatCompletionsHandler(deps));
// Model-scoped OCR: 16mb parser covers MAX_OCR_IMAGE_B64_CHARS
// (~12M chars ≈ 9 MB binary) plus JSON envelope. Gates (auth + rate
// limits) run BEFORE the parser so an unauthenticated caller cannot
// buffer/JSON.parse up to 16mb before getting a 401, and rate limiting
// can actually throttle the request (2026-08 review finding C2).
app.post("/v1/ocr/extract", ...gated, express.json({ limit: "16mb" }), makeOcrExtractHandler(deps));
app.get("/openai/v1/models", auth, makeModelsHandler(deps));
app.post("/openai/v1/chat/sessions", ...gated, makeOpenSessionHandler(deps));
app.post("/openai/v1/chat/sessions/:id/messages", ...gated, makeSessionMessageHandler(deps));
app.post("/openai/v1/chat/sessions/:id/close", ...gated, makeCloseSessionHandler(deps));
// OpenAI-shaped 404 for anything else.
app.use((_req: Request, res: Response) => {
sendError(res, notFound("not_found", "unknown route"));
});
return app;
}

224
gateway/src/sweeper.ts Normal file
View File

@@ -0,0 +1,224 @@
/**
* gateway/src/sweeper.ts — state-aware custody safety net.
*
* Per key, classifies on-chain escrows and recovers stranded funds:
* Open|Claimed past the deliver_by deadline → Reclaim (returns funds + bond).
* Submitted (verified, in accept-window) → retry Accept (settle + refund).
* Submitted past the accept-window → leave for supplier Release (log).
*
* reclaim only works on Open/Claimed; Accept only on Submitted — so we branch by
* state and never blind-reclaim a Submitted escrow. The on-chain validator is
* the source of truth for deadlines, so premature/late attempts simply fail and
* are caught here; the age thresholds just avoid racing in-flight requests.
*
* Chat sessions get special handling:
* - Their deliver_by is ~30 min (vs ~10 min one-shot), so rows belonging to
* an OPEN session use CHAT_RECLAIM_AFTER_MS — otherwise every open session
* older than 11 min would trigger a doomed Reclaim attempt per sweep while
* holding the key's mutex.
* - When a session's escrow reaches a terminal state (Accepted here, spent
* elsewhere, or Reclaimed), the session row is closed, its in-memory state
* dropped, and a usage row recorded — abandoned demo sessions (nobody calls
* /close) settle and get billed through this path.
*
* Escrow↔session lineage: the indexer keeps one row per utxo_ref per state
* (terminal spends update nothing), and the validator preserves posted_at
* across states — so a session's lineage is "rows sharing the posted_at of the
* session's original Open ref row".
*
* Because terminal spends never update indexer rows, every settled escrow
* would be retried (one doomed chain query each) on every sweep forever. The
* in-process `settledRefs` memory skips refs this process has already settled
* or found spent; a restart just causes one harmless retry burst.
*
* Ticket mode (config.chatSettleMode === "ticket"): session escrows are never
* Claimed — they sit Open until reclaimed here after deliver_by. Reclaiming
* an Open ticket does NOT close a still-open session row: nothing re-checks
* the escrow after /v1/chat/start, so the conversation (and its affinity
* mirror) lives on, bounded only by the idle janitor.
*/
import { randomUUID } from "crypto";
import type { GatewayDeps } from "./deps.js";
import type { SessionRow } from "./db/store.js";
import { fetchEscrows, acceptAndConfirm, reclaimAndConfirm, type EscrowRow } from "./onchain/settle.js";
import { parseRef } from "./routing/selectSupplier.js";
import { dropSessionState } from "./openai/transcripts.js";
import { dropDemoSession, sweepIdleDemoSessions } from "./openai/demoChat.js";
import { CAPABILITY as CHAT_CAPABILITY } from "./openai/sessions.js";
// Don't touch escrows newer than this — an in-flight request is settling them.
const MIN_AGE_MS = 90_000;
// Reclaim Open/Claimed escrows older than this (past deliver_by ~10m + buffer).
const RECLAIM_AFTER_MS = 11 * 60 * 1000;
// Chat-session escrows: deliver_by ≈ posted_at + 30 min, so wait longer.
const CHAT_RECLAIM_AFTER_MS = 35 * 60 * 1000;
// Reclaim only fires once the validator allows it (validity lower bound >=
// deliver_by); attempting earlier is a guaranteed-doomed tx build.
const DELIVER_BY_BUFFER_MS = 60_000;
/** Refs this process already settled (Accepted/Reclaimed) or found spent.
* The indexer never updates terminal rows, so without this every historical
* escrow is retried each sweep. Bounded FIFO; restart = one retry burst. */
const settledRefs = new Set<string>();
const SETTLED_REFS_MAX = 5_000;
function markSettled(ref: string): void {
settledRefs.add(ref);
if (settledRefs.size > SETTLED_REFS_MAX) {
const first = settledRefs.values().next().value;
if (first !== undefined) settledRefs.delete(first);
}
}
function log(msg: string): void {
// eslint-disable-next-line no-console
console.log(`[gateway:sweeper] ${msg}`);
}
function closeSessionRow(
deps: GatewayDeps,
session: SessionRow,
outcome: { status: "completed" | "reclaimed"; costLovelace: string | null },
): void {
// Re-read: an in-flight close (route handler or demo executor) may have won.
const current = deps.store.getSession(session.id);
if (!current || current.state !== "open") return;
deps.store.setSessionState(session.id, "closed", Date.now());
dropSessionState(session.id);
dropDemoSession(session.id);
deps.store.insertUsage({
id: randomUUID(),
key_id: session.key_id,
created_at: Date.now(),
kind: "chat_session",
model: session.model,
capability_id: CHAT_CAPABILITY,
supplier_pkh: session.supplier_pkh,
escrow_ref: session.escrow_ref,
cost_lovelace: outcome.costLovelace,
prompt_tokens: 0,
completion_tokens: 0,
status: outcome.status,
failure_reason: null,
});
log(`closed abandoned session ${session.id} (${outcome.status})`);
}
/** Chain-settle functions, injectable for tests. */
export interface SweepSettleFns {
accept: typeof acceptAndConfirm;
reclaim: typeof reclaimAndConfirm;
}
export async function runSweepOnce(
deps: GatewayDeps,
settle: SweepSettleFns = { accept: acceptAndConfirm, reclaim: reclaimAndConfirm },
): Promise<void> {
// Idle demo sessions first: closing them makes the supplier Submit, so this
// sweep (or the next) can Accept the escrow right after.
await sweepIdleDemoSessions(deps).catch((err) =>
log(`demo idle janitor: ${err instanceof Error ? err.message : String(err)}`),
);
const now = Date.now();
for (const keyRow of deps.store.listKeys()) {
let rows: EscrowRow[];
try {
rows = await fetchEscrows(deps.config.indexerUrl, keyRow.wallet_pkh, deps.fetchFn);
} catch (err) {
log(`indexer error for ${keyRow.key_prefix}: ${err instanceof Error ? err.message : String(err)}`);
continue;
}
// Lineage-match open sessions: the session stores its ORIGINAL Open ref;
// rows sharing that row's posted_at are the same escrow in later states.
const sessionByPostedAt = new Map<number, SessionRow>();
for (const session of deps.store.listOpenSessionsByKey(keyRow.id)) {
const origin = rows.find((r) => r.utxo_ref === session.escrow_ref);
if (origin) sessionByPostedAt.set(origin.posted_at, session);
}
const ctx = deps.registry.getContext(keyRow);
for (const row of rows) {
const ref = parseRef(row.utxo_ref);
if (!ref) continue;
if (settledRefs.has(row.utxo_ref)) continue;
const ageMs = now - (row.posted_at ?? 0);
if (ageMs < MIN_AGE_MS) continue;
const chatSession = sessionByPostedAt.get(row.posted_at);
try {
if (row.state === "Submitted") {
try {
await ctx.mutex.run(() => settle.accept(deps.chain, ctx.walletKey, ref));
log(`accepted stranded Submitted escrow ${row.utxo_ref}`);
markSettled(row.utxo_ref);
if (chatSession) {
closeSessionRow(deps, chatSession, { status: "completed", costLovelace: chatSession.price_lovelace });
}
} catch (err) {
// Accept fails routinely when the UTxO is already spent (settled
// by an in-flight request, an earlier sweep, or supplier Release).
// For session lineages, confirm the spend on-chain before closing
// the row — an Accept that failed for another reason (timeout)
// must leave the session open for the next sweep.
if ((await deps.chain.queryUtxo(ref)) === null) {
markSettled(row.utxo_ref);
if (chatSession) {
closeSessionRow(deps, chatSession, { status: "completed", costLovelace: chatSession.price_lovelace });
}
} else if (!chatSession) {
// Preserve the historical quiet-skip for one-shot escrows.
void err;
}
}
} else if (row.state === "Open" || row.state === "Claimed") {
const reclaimAfterMs = chatSession ? CHAT_RECLAIM_AFTER_MS : RECLAIM_AFTER_MS;
// The validator only allows Reclaim at/after deliver_by — skip
// attempts that cannot succeed yet (early-closed ticket sessions).
const pastDeliverBy =
typeof row.deliver_by === "number" ? now > row.deliver_by + DELIVER_BY_BUFFER_MS : true;
if (ageMs > reclaimAfterMs && pastDeliverBy) {
await ctx.mutex.run(() => settle.reclaim(deps.chain, ctx.walletKey, ref));
log(`reclaimed stranded ${row.state} escrow ${row.utxo_ref}`);
markSettled(row.utxo_ref);
if (chatSession) {
const ticketOpen = deps.config.chatSettleMode === "ticket" && row.state === "Open";
if (ticketOpen) {
// Ticket escrow reclaimed out from under a LIVE session —
// by design. Keep the session row + mirror; only the idle
// janitor / close paths end ticket sessions.
log(`ticket mode: escrow reclaimed, session ${chatSession.id} stays open`);
} else {
// Funds returned — the session never settled; record no cost.
closeSessionRow(deps, chatSession, { status: "reclaimed", costLovelace: null });
}
}
}
}
} catch {
// Expected when the validator rejects (wrong window/state) or the UTxO
// was already spent by an in-flight request; skip quietly.
}
}
}
}
/** Start the periodic sweeper. Returns a stop() function. */
export function startSweeper(deps: GatewayDeps): () => void {
let running = false;
const tick = async (): Promise<void> => {
if (running) return;
running = true;
try {
await runSweepOnce(deps);
} catch (err) {
log(`sweep failed: ${err instanceof Error ? err.message : String(err)}`);
} finally {
running = false;
}
};
const handle = setInterval(() => void tick(), deps.config.sweeperIntervalMs);
return () => clearInterval(handle);
}

81
gateway/src/ui/page.ts Normal file
View File

@@ -0,0 +1,81 @@
/**
* gateway/src/ui/page.ts — self-serve web UI, inlined as a string so it ships
* in dist/ (tsc does not copy .html). Plain HTML + fetch, no build step.
*/
export const INDEX_HTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Vector Marketplace - OpenAI Gateway</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; }
h1 { font-size: 1.5rem; } h2 { font-size: 1.1rem; margin-top: 2rem; }
code, pre { background: #f4f4f5; border-radius: 4px; padding: .15rem .35rem; }
pre { padding: .75rem; overflow-x: auto; }
input, button { font-size: 1rem; padding: .45rem .6rem; border-radius: 6px; border: 1px solid #ccc; }
button { background: #111; color: #fff; cursor: pointer; border: none; }
.row { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; margin: .5rem 0; }
.key { background: #fffbe6; border: 1px solid #f5d90a; padding: .75rem; border-radius: 6px; word-break: break-all; }
.muted { color: #666; font-size: .9rem; }
</style>
</head>
<body>
<h1>Vector Marketplace - OpenAI-compatible Gateway</h1>
<p class="muted">Point any OpenAI SDK at <code id="base"></code> using your API key as the bearer token.</p>
<h2>1. Create an API key</h2>
<div class="row">
<input id="label" placeholder="label (optional)" />
<button onclick="signup()">Create key</button>
</div>
<div id="signupOut"></div>
<h2>2. Fund your wallet</h2>
<p class="muted">Send AP3X to your deposit address (you need price + bonds + ~5 AP3X collateral + fees).</p>
<h2>3. Account</h2>
<div class="row">
<input id="apiKey" placeholder="vk_..." style="flex:1; min-width: 320px" />
<button onclick="account()">Check account</button>
</div>
<div id="acctOut"></div>
<h2>4. Withdraw</h2>
<div class="row">
<input id="toAddr" placeholder="addr... (destination)" style="flex:1; min-width: 320px" />
<input id="amt" placeholder="amount lovelace (blank = all)" />
<button onclick="withdraw()">Withdraw</button>
</div>
<div id="wOut"></div>
<script>
document.getElementById('base').textContent = location.origin + '/openai/v1';
function pre(id, obj){ document.getElementById(id).innerHTML = '<pre>'+JSON.stringify(obj,null,2)+'</pre>'; }
async function signup(){
const label = document.getElementById('label').value;
const r = await fetch('/signup', {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({label})});
const j = await r.json();
if (j.api_key) {
document.getElementById('signupOut').innerHTML =
'<div class="key"><b>API key (shown once):</b><br>'+j.api_key+'<br><br><b>Deposit address:</b><br>'+j.deposit_address+'</div>';
document.getElementById('apiKey').value = j.api_key;
document.getElementById('toAddr').value = '';
} else pre('signupOut', j);
}
function authHeaders(){ return {'authorization':'Bearer '+document.getElementById('apiKey').value, 'content-type':'application/json'}; }
async function account(){
const r = await fetch('/account', {headers: authHeaders()});
pre('acctOut', await r.json());
}
async function withdraw(){
const to_address = document.getElementById('toAddr').value;
const amt = document.getElementById('amt').value.trim();
const body = amt ? {to_address, amount_lovelace: amt} : {to_address};
const r = await fetch('/account/withdraw', {method:'POST', headers: authHeaders(), body: JSON.stringify(body)});
pre('wOut', await r.json());
}
</script>
</body>
</html>`;

60
gateway/src/wallet.ts Normal file
View File

@@ -0,0 +1,60 @@
/**
* gateway/src/wallet.ts — per-user wallet key derivation.
*
* Copied from buyer/src/index.ts:24-64 (deriveWalletKey + ed25519 sha512 hook)
* so the gateway does not have to import the buyer entrypoint (which boots an
* Express server). genPrivKeyHex mints a fresh custodial wallet at signup.
*/
import { createHash, randomBytes } from "crypto";
import * as ed from "@noble/ed25519";
import { blake2b } from "@noble/hashes/blake2b";
import { bech32 } from "bech32";
import type { WalletKey } from "@marketplace/shared/tx";
// Wire ed25519 sha512 hook (idempotent — same as buyer/src/index.ts + receipt/sign.ts).
ed.etc.sha512Sync = (...messages: Uint8Array[]): Uint8Array => {
const h = createHash("sha512");
for (const m of messages) h.update(m);
return new Uint8Array(h.digest());
};
function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return bytes;
}
function bytesToHex(b: Uint8Array): string {
let out = "";
for (let i = 0; i < b.length; i++) out += b[i].toString(16).padStart(2, "0");
return out;
}
/** Mint a fresh 32-byte Ed25519 private key as 64-char hex. */
export function genPrivKeyHex(): string {
return bytesToHex(new Uint8Array(randomBytes(32)));
}
export function deriveWalletKey(privHex: string, networkId: 0 | 1): WalletKey {
const priv = hexToBytes(privHex);
const pub = ed.getPublicKey(priv);
const pubHex = bytesToHex(pub);
const pkh = blake2b(pub, { dkLen: 28 });
const pkhHex = bytesToHex(pkh);
const header = networkId === 0 ? 0x60 : 0x61;
const payload = new Uint8Array(29);
payload[0] = header;
payload.set(pkh, 1);
const words = bech32.toWords(payload);
const hrp = networkId === 0 ? "addr_test" : "addr";
const addr = bech32.encode(hrp, words, 1023);
return {
pubKeyHash: pkhHex,
pubKeyHex: pubHex,
privateKeyHex: privHex,
address: addr,
};
}

View File

@@ -0,0 +1,49 @@
/**
* gateway/src/walletHealth.ts — keep each custodial wallet usable.
*
* After every settled request (and on a periodic tick) we re-shape the wallet to
* {≥5 AP3X collateral UTxO, working UTxO} via the shared consolidate builder, so
* the NEXT request's Accept/Reclaim script-spend always finds a pure-AP3X
* collateral candidate. No-op when the wallet is already healthy.
*/
import type { LiveOgmiosProvider } from "@marketplace/shared/chain";
import type { WalletKey } from "@marketplace/shared/tx";
import { runConsolidateWallet, DEFAULT_COLLATERAL_LOVELACE } from "@marketplace/shared/tx/server";
import type { GatewayDeps } from "./deps.js";
export async function ensureWalletHealthy(
chain: LiveOgmiosProvider,
walletKey: WalletKey,
): Promise<void> {
await runConsolidateWallet({
chain,
walletKey,
collateralLovelace: DEFAULT_COLLATERAL_LOVELACE,
log: () => {
/* quiet; callers log failures */
},
});
}
/** Periodically re-shape every wallet so a collateral UTxO is always available.
* Serialized per key via the registry mutex; no-op for already-healthy wallets. */
export function startWalletHealthTicker(deps: GatewayDeps): () => void {
let running = false;
const tick = async (): Promise<void> => {
if (running) return;
running = true;
try {
for (const keyRow of deps.store.listKeys()) {
const ctx = deps.registry.getContext(keyRow);
await ctx.mutex.run(() => ensureWalletHealthy(deps.chain, ctx.walletKey)).catch(() => {
/* skip wallets that can't consolidate (e.g. drained) */
});
}
} finally {
running = false;
}
};
const handle = setInterval(() => void tick(), deps.config.walletHealthIntervalMs);
return () => clearInterval(handle);
}

View File

@@ -0,0 +1,143 @@
/**
* affinity.test.ts — transcript-prefix session matching rules.
*/
import { describe, it, expect } from "vitest";
import {
matchSessionPrefix,
normalizeAssistantContent,
trimToRespondable,
type AffinityCandidate,
} from "../src/openai/affinity.js";
import type { ChatMessage } from "@marketplace/shared/tx";
const u = (content: string): ChatMessage => ({ role: "user", content });
const a = (content: string): ChatMessage => ({ role: "assistant", content });
const TOOL_CALL = { id: "call_1", type: "function" as const, function: { name: "get_time", arguments: "{}" } };
function cand(sessionId: string, mirror: ChatMessage[], lastUsedAt = 0, model = "m"): AffinityCandidate {
return { sessionId, model, mirror, lastUsedAt };
}
describe("matchSessionPrefix", () => {
it("matches an exact prefix and returns the delta", () => {
const got = matchSessionPrefix([cand("s1", [u("hi"), a("hello")])], [u("hi"), a("hello"), u("more")], "m");
expect(got).toEqual({ sessionId: "s1", delta: [u("more")] });
});
it("accepts a multi-message tool delta ending with a tool result", () => {
const mirror = [u("hi"), { role: "assistant" as const, content: "", tool_calls: [TOOL_CALL] }];
const incoming = [...mirror, { role: "tool" as const, content: "3pm", tool_call_id: "call_1" }];
const got = matchSessionPrefix([cand("s1", mirror)], incoming, "m");
expect(got?.sessionId).toBe("s1");
expect(got?.delta).toHaveLength(1);
expect(got?.delta[0].role).toBe("tool");
});
it("rejects deltas that end with a non-respondable role", () => {
const got = matchSessionPrefix([cand("s1", [u("hi")])], [u("hi"), a("stray assistant")], "m");
expect(got).toBeNull();
});
it("never matches when incoming is not longer than the mirror (retry of answered request)", () => {
const mirror = [u("hi"), a("hello")];
expect(matchSessionPrefix([cand("s1", mirror)], mirror, "m")).toBeNull();
expect(matchSessionPrefix([cand("s1", mirror)], [u("hi")], "m")).toBeNull();
});
it("rejects diverged histories (prefix mismatch)", () => {
const got = matchSessionPrefix([cand("s1", [u("hi"), a("hello")])], [u("hi"), a("DIFFERENT"), u("more")], "m");
expect(got).toBeNull();
});
it("requires the model to match", () => {
expect(matchSessionPrefix([cand("s1", [u("hi")], 0, "other")], [u("hi"), u("more")], "m")).toBeNull();
});
it("prefers the longest mirror, then most recently used", () => {
const incoming = [u("hi"), a("hello"), u("more")];
const longest = matchSessionPrefix(
[cand("short", [u("hi")], 99), cand("long", [u("hi"), a("hello")], 0)],
incoming,
"m",
);
expect(longest?.sessionId).toBe("long");
const tie = matchSessionPrefix(
[cand("old", [u("hi"), a("hello")], 1), cand("recent", [u("hi"), a("hello")], 2)],
incoming,
"m",
);
expect(tie?.sessionId).toBe("recent");
});
it("matches an empty mirror (fresh session) against any respondable history", () => {
const incoming = [{ role: "system" as const, content: "be nice" }, u("hi")];
const got = matchSessionPrefix([cand("fresh", [])], incoming, "m");
expect(got).toEqual({ sessionId: "fresh", delta: incoming });
});
it("treats content null-normalization as equivalent (tool-call assistant echo)", () => {
const mirror = [u("hi"), { role: "assistant" as const, content: "", tool_calls: [TOOL_CALL] }];
// Client echoes the assistant message back with content:"" (normalized from null upstream).
const incoming = [u("hi"), { role: "assistant" as const, content: "", tool_calls: [{ ...TOOL_CALL }] }, { role: "tool" as const, content: "x", tool_call_id: "call_1" }];
expect(matchSessionPrefix([cand("s1", mirror)], incoming, "m")?.sessionId).toBe("s1");
});
it("matches leniently when the client reshapes the assistant echo (think-strip + whitespace)", () => {
const mirror = [u("hi"), a("<think>plan it</think>Hello there\n")];
const got = matchSessionPrefix([cand("s1", mirror)], [u("hi"), a("Hello there"), u("more")], "m");
expect(got).toEqual({ sessionId: "s1", delta: [u("more")] });
});
it("stays strict on user/tool divergence even with lenient assistant matching", () => {
const mirror = [u("hi "), a("Hello")];
// User content differs by whitespace — client-authored roles must be byte-exact.
expect(matchSessionPrefix([cand("s1", mirror)], [u("hi"), a("Hello"), u("more")], "m")).toBeNull();
});
it("rejects assistant tool_calls divergence under lenient matching", () => {
const mirror = [u("hi"), { role: "assistant" as const, content: "x", tool_calls: [TOOL_CALL] }];
const changed = { ...TOOL_CALL, function: { name: "get_time", arguments: '{"tz":"utc"}' } };
const incoming = [u("hi"), { role: "assistant" as const, content: "x ", tool_calls: [changed] }, { role: "tool" as const, content: "t", tool_call_id: "call_1" }];
expect(matchSessionPrefix([cand("s1", mirror)], incoming, "m")).toBeNull();
});
it("prefers a strict match over a lenient one regardless of recency", () => {
const incoming = [u("hi"), a("Hello"), u("more")];
const got = matchSessionPrefix(
[
cand("lenient", [u("hi"), a("Hello ")], 99),
cand("strict", [u("hi"), a("Hello")], 1),
],
incoming,
"m",
);
expect(got?.sessionId).toBe("strict");
});
});
describe("normalizeAssistantContent", () => {
it("strips think blocks, collapses whitespace and trims", () => {
expect(normalizeAssistantContent("<think>a\nb</think> Hi\n\nthere friend ")).toBe("Hi there friend");
expect(normalizeAssistantContent("<THINK>x</THINK>ok")).toBe("ok");
expect(normalizeAssistantContent("plain")).toBe("plain");
});
});
describe("trimToRespondable", () => {
it("drops trailing assistant/system messages", () => {
expect(trimToRespondable([u("hi"), a("partial")])).toEqual([u("hi")]);
expect(trimToRespondable([u("hi"), a("x"), { role: "system" as const, content: "nudge" }])).toEqual([u("hi")]);
});
it("returns empty when nothing respondable remains", () => {
expect(trimToRespondable([a("hello")])).toEqual([]);
expect(trimToRespondable([])).toEqual([]);
});
it("keeps already-respondable histories unchanged", () => {
const msgs = [u("hi"), a("x"), u("more")];
expect(trimToRespondable(msgs)).toEqual(msgs);
});
});

Some files were not shown because too many files have changed in this diff Show More