100 Commits

Author SHA1 Message Date
david
a5cb4b03e1 feat: backfill Vector proofs from the Surplus support export
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
2026-08-28 12:29:45 +02:00
david
a6eabe6b98 feat: publish Vector proof anchors per Base settlement 2026-08-28 12:12:54 +02:00
david
0ed594f6ba fix: tolerate omitted cache_read_tokens in live Surplus sales 2026-08-28 12:06:43 +02:00
david
59cac438f2 feat: rename dashboard to Capacity Markets with Vector sale proofs 2026-08-28 11:59:24 +02:00
david
af405cf378 fix: wrap marketplace navigation on mobile 2026-08-28 10:39:42 +02:00
david
9463bfae85 feat: move resale dashboard into marketplace 2026-08-28 10:28:39 +02:00
david
53607738f8 feat: add unified resale dashboard 2026-08-28 09:45:51 +02:00
david
cd3ea734e3 fix: run Surplus seller continuously 2026-08-28 09:27:53 +02:00
david
41820b11bb fix: keep Surplus seller active after proof 2026-08-28 09:25:02 +02:00
david
3393748972 fix: probe Surplus seller over IPv4 2026-08-27 13:38:59 +02:00
david
799c7d8256 fix: stop Surplus offer at provider spend cap 2026-08-27 13:31:50 +02:00
david
ced400f928 fix: price against all healthy Surplus offers 2026-08-27 13:26:50 +02:00
david
f79a33e7c3 fix: manage Surplus multiplier offers 2026-08-27 13:21:19 +02:00
david
ed6dbf5730 fix: quote Surplus text offers by multiplier 2026-08-27 13:16:51 +02:00
david
23ae561c16 fix: redact Surplus validation errors safely 2026-08-27 13:10:59 +02:00
david
2603820106 fix: skip incomplete Surplus discovery rows 2026-08-27 13:07:16 +02:00
david
97ae2d8110 fix: accept live Surplus discovery metadata 2026-08-27 13:02:16 +02:00
david
b714c09aca fix: fail closed across Surplus seller state 2026-08-27 12:51:15 +02:00
david
732de817aa feat: add autonomous Surplus seller 2026-08-27 12:48:18 +02:00
david
2b8409a499 feat: prefer reseller for matching gateway requests 2026-08-27 10:51:43 +02:00
david
6b9f0f2284 feat: use provider model tags for resale 2026-08-27 10:15:15 +02:00
david
27d23dd7a4 feat: link escrow references to ApexScan 2026-08-27 10:02:43 +02:00
david
e54eb55f2b feat: add balance-aware provider resale agent 2026-08-27 09:54:53 +02:00
david
8baf127c77 docs: supplier deploy runbook for self-hosted operators
Generic runbook for deploying a mainnet supplier on any operator-owned
server: own DNS/TLS via the in-repo Traefik compose, docker-run CLI forms
(no host Node), advert posted last as the go-live flag, verification via
the public indexer. Fleet-specific steps stay in HETZNER_SETUP.md.
2026-08-21 16:40:18 +02:00
david
e8261d9500 feat(deploy): swap retired Hetzner suppliers for Qwen3.8-27B pair
Hetzner Inference dropped Kimi-K2.7-Code, GLM-5.2-NVFP4 and
DeepSeek-V4-Flash-0731 from /v1/models; the 6 suppliers serving them are
deregistered (adverts retired on-chain 2026-08-21, containers removed):

  kimi-code          3f37082166faf2b2220e6476235bae8723af9a3434a6aaf60277366ff1f62bfb
  kimi-code-chat     3d1b43645c00bdb7b429f29ced798bc4af206c9dc56074e955d84b603b64f50c
  glm                bf3ee48addb32e2b0726066f1806b9d84ad64b86c7d41d379abd4b439c522696
  glm-chat           b910327023edb6a3f31696b7feaa9448a151c671c2ff303c4c3dd5a07d03c6b5
  ds-flash-htz       61dd30deb1cd3d6c3f73792bda46ecea6890735237d8329ade2b8449d298f24e
  ds-flash-htz-chat  348a278c81a2bf664ea65705a6245df31e81f64c0ffa6e04a59b248fcdae3d9f

New qwen38 + qwen38-chat suppliers serve Hetzner's new Qwen3.8-27B
(ctx 262144; ping + tools round-trip verified 2026-08-21), reusing the
retired kimi-code / kimi-code-chat wallets. Adverts:

  qwen38       bb8a7078098dc5e60b3f97b040e38f05e6cca662497d4bea471e1dff19c1e1ae#0
  qwen38-chat  31e3a6e258ceaa0d1eaabe61fa3fbe3abcbbb400a7b885e05c8dbcdcf4924eda#0
2026-08-21 13:46:03 +02:00
david
bb18578ae9 feat(buyer): move task form (chat window) above the supplier grid
Selecting a supplier used to render its form at the bottom of the
dashboard, below the card grid — chat sessions in particular ended up
off-screen. The form now renders directly under the page title, above
the grid, and clicking Use scrolls back to the top so the freshly
opened form is visible.
2026-08-21 13:16:09 +02:00
david
5221815a71 feat(buyer): PDF_ENABLED kill switch for the PDF summarizer
PDF_ENABLED=0 skips the JobStore/PdfJobDb wiring in runMain, so every
/v1/pdf-* route responds 503 (the pre-existing disabled path). Default
stays enabled; only "0"/"1" accepted, misconfig fails boot loudly.
2026-08-21 02:14:45 +02:00
dcejtlin-apex
c531e4678f Merge pull request #17 from Apex-Fusion/feat/hf-llama33-supplier
feat(deploy): add HF-router Llama-3.3-70B mainnet supplier (hf-llama33)
2026-08-18 12:15:46 +02:00
HalukG
47b3c4efcd feat(deploy): add HF-router Llama-3.3-70B mainnet supplier (hf-llama33)
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
OpenAI-compatible supplier backed by the HuggingFace Inference Providers router
(meta-llama/Llama-3.3-70B-Instruct, llm.text.generate.v1). Config only; mirrors
the glm one-off template with OPENAI_BASE_URL=https://router.huggingface.co.

- deploy/mainnet/docker-compose.supplier-hf-llama33.yml
- register the supplier wallet in monitor-wallets OPERATOR_SOURCES

The env file (supplier/.env.hf-llama33) holds the wallet key + HF token and is
never committed; wallet-monitor/wallets.json is gitignored runtime data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 14:25:14 +03:00
dcejtlin-apex
7db4de1f75 Merge pull request #14 from Apex-Fusion/fix/release-tx-builder
feat(tx): live CBOR builder for Release - Submitted strays become resolvable
2026-08-17 09:53:46 +02:00
dcejtlin-apex
133102bc31 Merge pull request #15 from Apex-Fusion/fix/gateway-mutex-deadline
fix(gateway): per-run deadline + queue bound on Mutex (08-12 freeze fix)
2026-08-17 09:45:15 +02:00
dcejtlin-apex
6fbf0d0f45 Merge pull request #16 from Apex-Fusion/fix/escrow-locked-overcount
fix(gateway): stop counting terminally-spent escrows as locked funds
2026-08-17 09:44:56 +02:00
FilipBl4gojevic
85ca58e4b2 fix(gateway): stop counting terminally-spent escrows as locked funds
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
The indexer never updates escrow rows on terminal spends (Accept /
Reclaim / Release consume the UTxO without producing a new escrow
output), so /account summed rows whose funds had long moved and
locked_in_escrow_lovelace over-reported for 35+ minutes after
settlement.

The account handler now verifies each active-state row's UTxO is still
unspent (chain.queryUtxo) before counting it, capped at 32 lookups per
request with the tail counted unverified. Unparseable refs and failed
lookups count as live on purpose: errors inflate the figure rather than
hide locked funds. supplier_bond stays in the sum - the buyer fronts
all three legs at PostEscrow (see postEscrow.ts totalLocked), so it is
the key's money while the escrow lives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 12:33:34 +02:00
FilipBl4gojevic
b3b7d6b5e1 fix(gateway): per-run deadline + queue bound on Mutex - 08-12 freeze fix
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
One chain call that never settled, run under the per-key Mutex with no
deadline, convoyed every subsequent request on that key silently while
/models and /account stayed healthy (2026-08-12 incident). The per-fetch
30s aborts in the Ogmios providers predate the freeze and did not prevent
it; the unbounded thing was the composition under the mutex.

Mutex.run() now races each fn against a deadline (default 180s): on
expiry the run rejects with MutexTimeoutError and the chain advances, so
queued work proceeds instead of freezing. A zombie that settles later can
at worst lose a UTxO-contention race with the run that replaced it -
strictly better than a permanent convoy. New work is refused with
MutexQueueFullError past a queue bound (default 64) instead of queueing
without limit. Serialization and rejection-tolerance semantics are
unchanged (covered by tests). Session locks in transcripts.ts construct
the same class and inherit both protections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 09:56:41 +02:00
FilipBl4gojevic
d5fdd1f1b7 feat(tx): live CBOR builder for Release - Submitted strays become resolvable
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
Release was the one escrow spend path still hardwired to the synthetic
testTxBody backend, so suppliers had no way to resolve Submitted escrows
past the accept window on mainnet (the sweeper parks them 'for supplier
Release'). Adds buildLiveTxForRelease mirroring the Reclaim builder
(supplier payout, Constr4/tag 125 redeemer, Ogmios UPLC eval) and the
detectCborBackend dispatch in buildReleaseTx, reclaim.ts-style.

validFrom clamps to submitted_at + ACCEPT_WINDOW rather than tip-60s
alone: the back-pad by itself violates the validator whenever the tip is
less than 60s past the window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 09:50:51 +02:00
david
019766ab8d fix(deploy): retarget inference-proxy suppliers to new tailnet (teamhaleight@)
The tailnet account changed (caslav.nedeljkovic@ to teamhaleight@,
taild99fee.ts.net) and all device IPs were reassigned, breaking both
inference-proxy suppliers' hardcoded backend addresses.

- supplier-local: vuk llama.cpp 100.82.111.46 to 100.77.146.49.
- supplier-openclaw: the openclaw gateway is now exposed via Tailscale
  Serve (David's reconfigure set gateway.tailscale.mode=serve, which
  forces bind=loopback). Switch OPENAI_BASE_URL from the old raw
  http endpoint to https://web-tools.taild99fee.ts.net, and pin that
  MagicDNS name to web-tools' tailnet IP (100.105.36.0) via extra_hosts
  (Serve's TLS cert is for the hostname, and containers don't inherit
  the host tailscale MagicDNS).
- README: new account/IP table, Serve config (replaces the tailnet-bind
  section), reachability check (the old ACL blocker is gone), updated
  probe/runbook.

Verified: authenticated turn to the Serve endpoint from inference-proxy
returns a completion; vuk:8002 reachable from inference-proxy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 17:55:43 +02:00
david
5c9da7639d feat(deploy): inference-proxy supplier 'openclaw' — OpenClaw web-research agent
Second supplier on the inference-proxy box, proxying to an OpenClaw gateway
(agent 'main') on the web-tools KVM guest over the tailnet
(100.101.143.72:18789). Same vuk llama.cpp brain as supplier 'local'; the
product is the agent loop (searxng web search + web fetch + browser).
Capability llm.chat.v1, advertised model 'openclaw-web-agent', 0.2 AP3X,
ticket settle mode, MAX_CHAT_SESSIONS=1.

Uses the new stateful-upstream contract: OPENAI_SESSION_PASSTHROUGH=1 (escrow
ref as OpenAI 'user' → sticky per-escrow openclaw session, delta-only sends)
and OPENAI_MODEL_OVERRIDE=openclaw (endpoint only accepts 'openclaw').

- docker-compose.supplier-openclaw.yml: new supplier compose (traefik host
  mp-suppliers-openclaw.vector.apexfusion.org).
- README: gateway config (bind=tailnet, chatCompletions on, GLOBAL tool
  allow+deny lockdown), the tailnet-ACL blocker (inference-proxy can't yet
  reach web-tools:18789), bring-up runbook, and the sudo-gated browser +
  egress-firewall follow-ups.
- monitor-wallets.ts: register supplier-openclaw@inference-proxy.

Wallet generated + funded (200 AP3X); .env.openclaw written on the host.
Not yet advertised — go-live (post-advert) waits on the ACL + a healthy
end-to-end path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:56:23 +02:00
david
843ac7cbdd feat(supplier): stateful-upstream contract for chat-session (OpenClaw)
Two opt-in envs preparing the openclaw supplier on inference-proxy:

OPENAI_SESSION_PASSTHROUGH=1 — chat-session turns speak the stateful-
upstream contract: the escrow ref is sent as the OpenAI `user` field
(OpenClaw keys one persistent agent session per `user`, so browser/
tool state survives across turns) and only the current turn's delta
goes upstream — the upstream session carries history, and re-sending
the full transcript would duplicate it into the upstream context every
turn (OpenClaw renders ALL request messages into the new agent turn).
The local transcript still accumulates fully for the receipt. Without
`user`, OpenClaw creates an ephemeral session per request (uuid key),
so default-off stays safe for stateless providers.

OPENAI_MODEL_OVERRIDE — fixed upstream model id sent instead of the
advert's buyer-facing model. OpenClaw's chat-completions endpoint 400s
on anything but "openclaw" / "openclaw/<agentId>", while the advert
must carry a unique fleet-routable id (openclaw-web-agent).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:15:13 +02:00
david
48ad639d12 feat(deploy): switch supplier 'local' to chat-session capability (llm.chat.v1)
MAX_CHAT_SESSIONS=1 — vuk is a single-slot ~4.5 tok/s rig, one session
owns it at a time. Idle timeout / settle mode mirror the Hetzner -chat
fleet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 12:04:20 +02:00
david
4047be04a3 chore(monitor): register supplier-local@inference-proxy wallet
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 11:01:22 +02:00
david
23157d62a3 fix(deploy): pin traefik v3.6 + DOCKER_API_VERSION on inference-proxy — Docker 29 rejects API 1.24
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 10:30:52 +02:00
david
3ee09f1a9a feat(deploy): inference-proxy box — supplier 'local' backed by llama.cpp on vuk via tailscale
New manually-deployed host (62.238.38.167) with its own in-repo traefik
project (LE HTTP-01) and one text supplier proxying to the home rig at
100.82.111.46:8002. Outside CD by design; runbook in the README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 10:28:13 +02:00
dcejtlin-apex
44abbdffdc Merge pull request #13 from Apex-Fusion/fix/hetzner-dns-docs
docs(deploy): correct DNS guidance for Hetzner suppliers — no wildcard exists
2026-08-11 16:04:27 +02:00
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
236 changed files with 28253 additions and 1288 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

@@ -38,10 +38,15 @@ NETWORK_ID=0
# along over loopback. Production behind TLS should leave this unset / "1".
# COOKIE_SECURE=1
# Internal, redacted data source for the authenticated /dashboard page.
# SURPLUS_DASHBOARD_URL=http://surplus-seller:8080/internal/resale-dashboard
# ─── Optional: PDF Book Summarizer (/book page, /v1/pdf-* routes) ─────────
# Upload a PDF → chunk it → pay suppliers to summarize each chunk (one escrow
# per map + reduce call) → full-book summary. All knobs are optional; defaults
# shown. Tune the caps to bound spend/abuse on mainnet.
# Kill switch: "0" disables the feature entirely (every /v1/pdf-* route 503s):
# PDF_ENABLED=1
#
# Hard caps (a job is refused if it would exceed these):
# PDF_MAX_PAGES=400 # reject books larger than this

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

@@ -41,6 +41,13 @@ interface OperatorSource {
}
const OPERATOR_SOURCES: OperatorSource[] = [
{
label: "supplier-hf-llama33@vector-marketplace",
host: "root@91.98.147.172",
envPath: "/root/agents-marketplace/supplier/.env.hf-llama33",
keyVar: "SUPPLIER_PRIV_KEY_HEX",
role: "supplier",
},
{
label: "buyer@vector-marketplace",
host: "root@91.98.147.172",
@@ -83,6 +90,76 @@ 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: "supplier-qwen38@vector-marketplace",
host: "root@91.98.147.172",
envPath: "/root/agents-marketplace/supplier/.env.qwen38",
keyVar: "SUPPLIER_PRIV_KEY_HEX",
role: "supplier",
},
{
label: "supplier-qwen38-chat@vector-marketplace",
host: "root@91.98.147.172",
envPath: "/root/agents-marketplace/supplier/.env.qwen38-chat",
keyVar: "SUPPLIER_PRIV_KEY_HEX",
role: "supplier",
},
{
label: "supplier-local@inference-proxy",
host: "inference-proxy",
envPath: "/root/agents-marketplace/supplier/.env.local",
keyVar: "SUPPLIER_PRIV_KEY_HEX",
role: "supplier",
},
{
label: "supplier-openclaw@inference-proxy",
host: "inference-proxy",
envPath: "/root/agents-marketplace/supplier/.env.openclaw",
keyVar: "SUPPLIER_PRIV_KEY_HEX",
role: "supplier",
},
{
label: "buyer@open-webui",
host: "open-webui",

View File

@@ -36,6 +36,19 @@
* 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.
* SURPLUS_DASHBOARD_URL — Internal URL of the private Surplus dashboard
* snapshot endpoint. When unset, /v1/resale-dashboard
* responds 503 while the rest of the buyer stays live.
* PDF_ENABLED — "1" (default) serves the PDF book summarizer
* (/v1/pdf-* routes). Set "0" to disable the feature
* entirely: no PDF job store is opened and every
* /v1/pdf-* route responds 503.
*/
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
@@ -56,6 +69,9 @@ export interface BuyerConfig {
password: string;
sessionSecret: string;
cookieSecure: boolean;
pdfEnabled: boolean;
gatewayPublicUrl: string;
resaleDashboardUrl: string;
}
function requireField(env: Record<string, string | undefined>, name: string): string {
@@ -141,6 +157,37 @@ export function loadConfig(env: Record<string, string | undefined>): BuyerConfig
cookieSecure = cookieSecureStr === "1";
}
// PDF_ENABLED: defaults to true. Set "0" to disable the PDF book summarizer
// entirely — runMain skips the JobStore wiring and every /v1/pdf-* route
// responds 503. Accept only "0" / "1" to keep boot-time misconfig loud.
let pdfEnabled = true;
const pdfEnabledStr = env.PDF_ENABLED;
if (pdfEnabledStr !== undefined && pdfEnabledStr !== "") {
if (pdfEnabledStr !== "0" && pdfEnabledStr !== "1") {
throw new Error('loadConfig: PDF_ENABLED must be "0" or "1"');
}
pdfEnabled = pdfEnabledStr === "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(/\/+$/, "");
const resaleDashboardUrl = (env.SURPLUS_DASHBOARD_URL ?? "")
.trim()
.replace(/\/+$/, "");
if (resaleDashboardUrl !== "") {
let parsed: URL;
try {
parsed = new URL(resaleDashboardUrl);
} catch {
throw new Error("loadConfig: SURPLUS_DASHBOARD_URL must be a valid URL");
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("loadConfig: SURPLUS_DASHBOARD_URL must use http or https");
}
}
return {
privKeyHex,
indexerUrl,
@@ -155,5 +202,8 @@ export function loadConfig(env: Record<string, string | undefined>): BuyerConfig
password,
sessionSecret,
cookieSecure,
pdfEnabled,
gatewayPublicUrl,
resaleDashboardUrl,
};
}

View File

@@ -96,29 +96,36 @@ export async function runMain(env: Record<string, string | undefined>): Promise<
// PDF book summarizer: one job registry, sharing the live marketplace SDK,
// chain, wallet, and (optional) archive. Caps come from PDF_* env vars.
// PDF_ENABLED=0 skips the whole wiring; createApp then registers every
// /v1/pdf-* route as a 503 stub (feature disabled).
const pdfCaps = loadPdfCaps(env);
// Durable job store (SQLite under ARCHIVE_DIR) so past summaries survive
// restarts and navigation. Best-effort: if it can't open, jobs fall back to
// in-memory only (lost on restart) but the feature still works.
let pdfJobDb: PdfJobDb | undefined;
try {
pdfJobDb = new PdfJobDb(config.archiveDir);
console.log(`[buyer] pdf job store at ${config.archiveDir}/pdf-jobs.db`);
} catch (err) {
console.error(
`[buyer] failed to open pdf job store at ${config.archiveDir}:`,
err instanceof Error ? err.message : String(err),
);
let jobStore: JobStore | undefined;
if (config.pdfEnabled) {
// Durable job store (SQLite under ARCHIVE_DIR) so past summaries survive
// restarts and navigation. Best-effort: if it can't open, jobs fall back to
// in-memory only (lost on restart) but the feature still works.
let pdfJobDb: PdfJobDb | undefined;
try {
pdfJobDb = new PdfJobDb(config.archiveDir);
console.log(`[buyer] pdf job store at ${config.archiveDir}/pdf-jobs.db`);
} catch (err) {
console.error(
`[buyer] failed to open pdf job store at ${config.archiveDir}:`,
err instanceof Error ? err.message : String(err),
);
}
jobStore = new JobStore({
marketplace,
chain,
walletKey,
indexerUrl: config.indexerUrl,
archive,
caps: pdfCaps,
db: pdfJobDb,
});
} else {
console.log("[buyer] pdf summarizer disabled (PDF_ENABLED=0)");
}
const jobStore = new JobStore({
marketplace,
chain,
walletKey,
indexerUrl: config.indexerUrl,
archive,
caps: pdfCaps,
db: pdfJobDb,
});
const app = createApp({
distPath,
@@ -133,6 +140,8 @@ export async function runMain(env: Record<string, string | undefined>): Promise<
password: config.password,
sessionSecret: config.sessionSecret,
cookieSecure: config.cookieSecure,
gatewayPublicUrl: config.gatewayPublicUrl,
resaleDashboardUrl: config.resaleDashboardUrl,
jobStore,
pdfCaps,
});

View File

@@ -40,7 +40,7 @@ export function registerPdfRoutes(
res,
503,
"service_unavailable",
"PDF summarizer disabled (buyer booted without chain/marketplace/archive deps)",
"PDF summarizer disabled",
);
app.post("/v1/pdf-upload", disabled);
app.all(/^\/v1\/pdf-jobs(\/.*)?$/, disabled);

View File

@@ -73,24 +73,31 @@ import {
buildPostEscrowTx,
buildPostTtsEscrowTx,
buildPostChatEscrowTx,
buildPostOcrEscrowTx,
ttsPromptHash,
chatSessionPromptHash,
ocrPromptHash,
buildAcceptTx,
buildReclaimTx,
TxConstructionError,
chatInputTokenUpperBound,
BOUNDED_INPUT_DETAIL_MARKER,
} 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,
DiscoverSuppliersOptions,
SubmitPromptOptions,
SubmitPromptResult,
SupplierCapabilityView,
SubmitTtsOptions,
SubmitTtsResult,
SubmitOcrOptions,
SubmitOcrResult,
StartChatOptions,
StartChatResult,
ChatSettleMode,
EndChatOptions,
EndChatResult,
AcceptResultOptions,
@@ -106,6 +113,50 @@ import { MemoryTaskHistoryStore } from "./history.js";
import { HttpClient, HttpError } from "./httpClient.js";
const ZERO_SIGNATURE = "0".repeat(128);
function validateSupplierCapability(
body: unknown,
advert: AdvertDatum,
advertRef: OutputReference,
): SupplierCapabilityView {
if (typeof body !== "object" || body === null || Array.isArray(body)) {
throw new TxConstructionError(
"supplier_preflight_failed",
"supplier /capability response must be an object",
);
}
const capability = body as Record<string, unknown>;
const expectedRef = `${advertRef.txHash}#${advertRef.index}`;
if (
capability.advert_ref !== expectedRef ||
capability.capability_id !== advert.capability_id ||
capability.model !== advert.model ||
capability.max_output_tokens !== advert.max_output_tokens ||
capability.max_processing_ms !== advert.max_processing_ms ||
capability.price_lovelace !== advert.price_lovelace.toString() ||
capability.supplier_pkh !== advert.supplier_pkh ||
typeof capability.pub_key_hex !== "string"
) {
throw new TxConstructionError(
"supplier_preflight_failed",
"supplier /capability response does not match the on-chain advert",
);
}
if (
capability.max_input_tokens !== undefined &&
(
typeof capability.max_input_tokens !== "number" ||
!Number.isSafeInteger(capability.max_input_tokens) ||
capability.max_input_tokens <= 0
)
) {
throw new TxConstructionError(
"supplier_preflight_failed",
"supplier max_input_tokens must be a positive safe integer",
);
}
return capability as unknown as SupplierCapabilityView;
}
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
const SIG_RE = /^[0-9a-fA-F]{128}$/;
@@ -237,16 +288,23 @@ export class Marketplace extends EventEmitterBase {
}
return result.body as SupplierView[];
}
// ─── submitPrompt — happy path + every adversarial branch ──────────────
async submitPrompt(opts: SubmitPromptOptions): Promise<SubmitPromptResult> {
const { advertRef, messages, payment_lovelace, max_output_tokens } = opts;
const {
advertRef,
messages,
payment_lovelace,
max_output_tokens,
public_preview,
} = opts;
let supplierHttp: HttpClient | null = null;
let advertDatum: AdvertDatum | null = null;
let escrowOutputRef: OutputReference | null = null;
let escrowRefStr = "";
let postedAtMs = Date.now();
let deliverByMs = 0;
const recordFailure = (reason: string): void => {
this.historyStore.save({
@@ -275,6 +333,46 @@ export class Marketplace extends EventEmitterBase {
}
}
if (advertDatum) {
supplierHttp = new HttpClient({
baseUrl: advertDatum.endpoint_url,
fetch: this.fetchImpl,
});
if (advertDatum.detail_uri.endsWith(BOUNDED_INPUT_DETAIL_MARKER)) {
let capabilityResult;
try {
capabilityResult = await supplierHttp.getJson("/capability");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new TxConstructionError("supplier_preflight_failed", message);
}
if (!capabilityResult.ok || capabilityResult.parseError) {
throw new TxConstructionError(
"supplier_preflight_failed",
`supplier /capability returned HTTP ${capabilityResult.status}`,
);
}
const capability = validateSupplierCapability(
capabilityResult.body,
advertDatum,
advertRef,
);
if (capability.max_input_tokens === undefined) {
throw new TxConstructionError(
"supplier_preflight_failed",
"bounded-input supplier omitted max_input_tokens",
);
}
const inputUnits = chatInputTokenUpperBound(messages);
if (inputUnits > capability.max_input_tokens) {
throw new TxConstructionError(
"input_cap_exceeded",
`input bound ${inputUnits} exceeds supplier cap ${capability.max_input_tokens}`,
);
}
}
}
escrowResult = await buildPostEscrowTx({
chain: this.chain,
buyerKey: this.walletKey,
@@ -317,24 +415,33 @@ export class Marketplace extends EventEmitterBase {
if (escrowUtxo && escrowUtxo.datumHex) {
const ed = decodeEscrowDatum(escrowUtxo.datumHex);
postedAtMs = ed.posted_at;
deliverByMs = ed.deliver_by;
}
} catch {
/* posted_at is best-effort metadata */
}
if (!advertDatum) {
// Should never happen — buildPostEscrowTx throws before this on missing/bad advert.
// Should never happen — buildPostEscrowTx rejects missing or bad adverts.
const reason = "advert datum unavailable after escrow post";
recordFailure(reason);
throw new ReceiptVerificationError(reason);
}
if (!supplierHttp) {
const reason = "supplier client unavailable after escrow post";
recordFailure(reason);
throw new ReceiptVerificationError(reason);
}
if (deliverByMs === 0) {
deliverByMs =
postedAtMs + advertDatum.max_processing_ms + 30_000;
}
const supplierCallTimeoutMs = Math.max(
30_000,
deliverByMs - Date.now() + 90_000,
);
// ── 4. Call supplier /v1/chat/completions ─────────────────────────
const supplierBaseUrl = advertDatum.endpoint_url;
const supplierHttp = new HttpClient({
baseUrl: supplierBaseUrl,
fetch: this.fetchImpl,
});
const requestedMax = max_output_tokens ?? advertDatum.max_output_tokens;
const cappedMax = Math.min(requestedMax, advertDatum.max_output_tokens);
@@ -343,11 +450,14 @@ export class Marketplace extends EventEmitterBase {
messages,
max_tokens: cappedMax,
};
let chatResult;
try {
chatResult = await supplierHttp.postJson("/v1/chat/completions", chatBody, {
headers: { "X-Escrow-Ref": escrowRefStr },
headers: {
"X-Escrow-Ref": escrowRefStr,
...(public_preview ? { "X-Vector-Public-Preview": "1" } : {}),
},
timeoutMs: supplierCallTimeoutMs,
});
} catch (err) {
if (err instanceof HttpError) {
@@ -381,8 +491,11 @@ export class Marketplace extends EventEmitterBase {
throw sErr;
}
const POLL_INTERVAL_MS = 2_000;
const POLL_TIMEOUT_MS = 180_000;
const pollDeadline = Date.now() + POLL_TIMEOUT_MS;
const pollBudgetMs = Math.max(
30_000,
deliverByMs + 90_000 - Date.now(),
);
const pollDeadline = Date.now() + pollBudgetMs;
let polled = chatResult;
// First poll runs immediately so a quickly-completed job doesn't pay
// the leading delay; subsequent polls wait POLL_INTERVAL_MS.
@@ -419,7 +532,7 @@ export class Marketplace extends EventEmitterBase {
if (polled.status !== 200) {
const sErr = new SupplierError("timeout", {
status: polled.status,
message: `supplier job ${jobId} not done within ${POLL_TIMEOUT_MS}ms`,
message: `supplier job ${jobId} not done within ${pollBudgetMs}ms`,
});
recordFailure("timeout");
throw sErr;
@@ -807,6 +920,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 +1208,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 +1260,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 +1285,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 +1300,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 +1329,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 +1375,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 +1393,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 +1424,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

@@ -138,6 +138,19 @@ export interface SupplierView {
created_slot: number;
}
/** Live off-chain capability metadata published by a supplier endpoint. */
export interface SupplierCapabilityView {
capability_id: string;
model: string;
max_output_tokens: number;
max_processing_ms: number;
price_lovelace: string;
advert_ref: string;
supplier_pkh: string;
pub_key_hex: string;
max_input_tokens?: number;
}
/**
* DiscoverSuppliersOptions — optional filters for discoverSuppliers().
*/
@@ -154,6 +167,8 @@ export interface SubmitPromptOptions {
messages: import("@marketplace/shared/tx").ChatMessage[];
payment_lovelace: bigint;
max_output_tokens?: number;
/** Explicit consent for the seller's public redacted demo preview. */
public_preview?: boolean;
}
/**
@@ -187,6 +202,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 +241,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 +253,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 +264,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,14 @@ 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;
/** Private supplier-network endpoint used to populate the resale dashboard.
* The browser only sees the redacted same-origin /v1/resale-dashboard proxy. */
resaleDashboardUrl?: 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. */
@@ -312,6 +320,66 @@ export function createApp(deps: AppDeps): Express {
}
: null;
app.get("/v1/resale-dashboard", async (_req: Request, res: Response) => {
const dashboardUrl = (deps.resaleDashboardUrl ?? "").replace(/\/+$/, "");
if (dashboardUrl === "") {
return jsonError(
res,
503,
"service_unavailable",
"buyer-app booted without SURPLUS_DASHBOARD_URL",
);
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10_000);
try {
const upstream = await (deps.fetchImpl ?? globalThis.fetch)(dashboardUrl, {
headers: { accept: "application/json" },
redirect: "error",
signal: controller.signal,
});
const text = await upstream.text();
if (Buffer.byteLength(text, "utf8") > 1024 * 1024) {
return jsonError(
res,
502,
"dashboard_response_too_large",
"resale dashboard response exceeded 1 MiB",
);
}
if (!upstream.ok) {
return jsonError(
res,
502,
"dashboard_upstream_error",
`resale dashboard upstream returned ${upstream.status}`,
);
}
let payload: unknown;
try {
payload = JSON.parse(text);
} catch {
return jsonError(
res,
502,
"dashboard_invalid_json",
"resale dashboard upstream did not return JSON",
);
}
res.setHeader("Cache-Control", "no-store");
return res.status(200).json(payload);
} catch {
return jsonError(
res,
502,
"dashboard_unreachable",
"resale dashboard upstream is unavailable",
);
} finally {
clearTimeout(timer);
}
});
app.get("/v1/pending-receipts", async (_req: Request, res: Response) => {
if (!resolved) {
return jsonError(
@@ -379,6 +447,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 +748,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 +982,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 +1388,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

@@ -4,8 +4,10 @@
* Routes (all gated by <RequireAuth>):
* / → <Dashboard />
* /tasks → <TaskHistory />
* /dashboard → <ResaleDashboard /> Capacity markets and sale proofs
* /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 +16,8 @@ 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 ResaleDashboard from "./pages/ResaleDashboard.js";
import { RequireAuth, useAuth } from "./state/AuthContext.js";
function SignOutButton() {
@@ -33,21 +37,27 @@ export default function App() {
return (
<RequireAuth>
<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">
<nav className="bg-white border-b border-gray-200 px-4 py-3 sm:px-6 flex flex-wrap items-center gap-x-6 gap-y-2">
<Link to="/" className="font-medium hover:underline">Dashboard</Link>
<Link to="/book" className="font-medium hover:underline">Book Summarizer</Link>
<Link to="/dashboard" className="font-medium hover:underline">Capacity</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">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/dashboard" element={<ResaleDashboard />} />
<Route path="/book" element={<BookSummarizer />} />
<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

@@ -2,8 +2,10 @@
* buyer/src/ui/pages/Dashboard.tsx — supplier list + capability-routed task form.
*
* Calls marketplace.discoverSuppliers() on mount; renders a SupplierCard
* per result. Once a supplier is selected, the form rendered below is
* picked from `CAPABILITY_FORMS` based on the supplier's `capability_id`.
* per result. Once a supplier is selected, the task form (e.g. the chat
* window) renders ABOVE the grid — directly under the page title — and the
* component is picked from `CAPABILITY_FORMS` based on the supplier's
* `capability_id`.
*
* Capabilities (current registry):
*
@@ -131,18 +133,8 @@ export default function Dashboard() {
{error}
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{suppliers.map((s) => (
<SupplierCard
key={s.utxo_ref}
supplier={s}
onUse={(sup) => setSelected(sup)}
/>
))}
</div>
{selected && (
<div className="mt-8">
<div data-testid="task-form-section">
<h2 className="text-xl font-semibold mb-2">
New task → {selected.model}
<span className="ml-2 text-sm font-normal text-gray-500">
@@ -152,6 +144,21 @@ export default function Dashboard() {
{renderCapabilityForm(selected, advertRef, payment_lovelace)}
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{suppliers.map((s) => (
<SupplierCard
key={s.utxo_ref}
supplier={s}
onUse={(sup) => {
setSelected(sup);
// The form renders above the grid; bring it into view when a
// card further down the page was clicked.
window.scrollTo({ top: 0, behavior: "smooth" });
}}
/>
))}
</div>
</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

@@ -0,0 +1,419 @@
import { useCallback, useEffect, useState } from "react";
interface VectorProof {
status: "pending" | "confirmed" | "failed";
saleHash: string | null;
batchRoot: string | null;
txHash: string | null;
leafIndex: number | null;
siblings: string[];
}
const PENDING_PROOF: VectorProof = {
status: "pending",
saleHash: null,
batchRoot: null,
txHash: null,
leafIndex: null,
siblings: [],
};
interface DashboardSnapshot {
generatedAt: string;
controller: {
ok: boolean;
phase: string;
lastError: string | null;
};
identity: {
sellerWallet: string;
payoutAddress: string;
};
provider: {
hardLimitUsd: string;
remainingUsd: string;
usedUsd: string;
reserveUsd: string;
estimatedDailyExposureUsd: string;
};
surplus: {
offer: {
id: string | null;
model: string | null;
providerModel: string | null;
status: string;
dailyCapUsd: string | null;
costMultiplier: number | null;
inputUsdPer1m: string | null;
outputUsdPer1m: string | null;
rank: number | null;
available: boolean;
healthy: boolean;
trusted: boolean;
trades24h: number;
volume24hUsd: string;
capRemainingUsd: string | null;
};
earnings: {
totalUsd: string;
pendingUsd: string;
paidUsd: string;
requests: number;
tokens: number;
today: {
earnedUsd: number;
requests: number;
totalTokens: number;
} | null;
topModel: string | null;
payoutHoldReason: string | null;
payoutHoldReleasesAt: string | null;
};
recentSales: Array<{
id: string;
model: string;
createdAt: string | null;
settlementStatus: string;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
revenueUsd: string;
vectorProof: VectorProof;
}>;
};
}
function usd(value: string | number | null): string {
if (value === null || value === "") return "—";
const number = Number(value);
if (!Number.isFinite(number)) return "—";
return `$${number.toFixed(number < 0.01 ? 6 : 4)}`;
}
function count(value: number): string {
return new Intl.NumberFormat().format(value);
}
function timestamp(value: string | null): string {
if (value === null) return "—";
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? "—" : parsed.toLocaleString();
}
function compact(value: string): string {
if (value.length <= 24) return value;
return `${value.slice(0, 12)}…${value.slice(-9)}`;
}
function StatusPill({ label, active }: { label: string; active: boolean }) {
return (
<span
className={`rounded px-2 py-0.5 text-xs font-medium ${
active ? "bg-green-100 text-green-700" : "bg-gray-200 text-gray-600"
}`}
>
{label}
</span>
);
}
function VectorProofState({ proof }: { proof: VectorProof }) {
const pillClass = proof.status === "confirmed"
? "bg-green-100 text-green-700"
: proof.status === "pending"
? "bg-amber-100 text-amber-700"
: "bg-red-100 text-red-700";
const pill = (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${pillClass}`}>
{proof.status}
</span>
);
if (proof.status !== "confirmed" || proof.txHash === null) return pill;
return (
<a
className="inline-flex items-center gap-2 rounded text-blue-600 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-500 focus-visible:ring-offset-2"
href={`https://vector.apexscan.org/en/transaction/${proof.txHash}/summary/`}
target="_blank"
rel="noopener noreferrer"
aria-label={`Confirmed Vector proof transaction ${proof.txHash}`}
>
{pill}
<span className="font-mono text-xs">{compact(proof.txHash)}</span>
</a>
);
}
function Metric({ label, value, detail }: { label: string; value: string; detail: string }) {
return (
<div className="rounded border border-gray-200 bg-gray-50 p-3">
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">{label}</p>
<p className="mt-1 text-xl font-semibold tracking-tight text-gray-900">{value}</p>
<p className="mt-0.5 text-xs text-gray-500">{detail}</p>
</div>
);
}
function FlowNode({ label, title, detail, mono }: {
label: string;
title: string;
detail: string;
mono?: string | null;
}) {
return (
<div className="min-w-0 flex-1 rounded border border-gray-200 bg-gray-50 p-4">
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">{label}</p>
<p className="mt-1 font-semibold text-gray-900">{title}</p>
<p className="mt-1 text-sm text-gray-600">{detail}</p>
{mono && <p className="mt-2 truncate font-mono text-xs text-gray-400">{mono}</p>}
</div>
);
}
export default function ResaleDashboard() {
const [data, setData] = useState<DashboardSnapshot | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
const response = await fetch("/v1/resale-dashboard", { cache: "no-store" });
const body = (await response.json().catch(() => null)) as DashboardSnapshot | {
message?: string;
} | null;
if (!response.ok) {
throw new Error(
body && "message" in body && typeof body.message === "string"
? body.message
: `dashboard request failed (${response.status})`,
);
}
setData(body as DashboardSnapshot);
setError(null);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "dashboard data is unavailable");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
const interval = setInterval(() => { void refresh(); }, 15_000);
return () => clearInterval(interval);
}, [refresh]);
if (loading && data === null) {
return <p className="text-sm text-gray-500">Loading capacity markets…</p>;
}
if (data === null) {
return (
<div className="max-w-3xl rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">
{error ?? "Dashboard data is unavailable."}
</div>
);
}
const offer = data.surplus.offer;
const earnings = data.surplus.earnings;
const offerLive = offer.status === "active" && offer.available && offer.healthy;
const proofCounts = { confirmed: 0, pending: 0, failed: 0 };
for (const sale of data.surplus.recentSales) {
proofCounts[(sale.vectorProof ?? PENDING_PROOF).status] += 1;
}
return (
<div className="mx-auto max-w-7xl space-y-6" data-testid="resale-dashboard">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<h1 className="text-2xl font-semibold">Capacity Markets</h1>
<p className="max-w-3xl text-sm text-gray-600">
Surplus turns available OpenRouter capacity into Base USDC sales. Vector anchors
Merkle-batched proofs so every sale has a verifiable transaction record.
</p>
</div>
<div className="flex items-center gap-3">
<StatusPill label={data.controller.phase} active={data.controller.ok} />
<button
type="button"
onClick={() => { void refresh(); }}
className="rounded border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
>
Refresh
</button>
</div>
</div>
{error && (
<div className="rounded border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Last refresh failed: {error}. Showing the last good snapshot.
</div>
)}
{data.controller.lastError && (
<div className="rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
Controller: {data.controller.lastError}
</div>
)}
<section className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<div>
<h2 className="font-semibold">Capacity and proof route</h2>
<p className="text-sm text-gray-500">
Capacity sells through Surplus, and each sale joins a shared Vector anchor.
</p>
</div>
<span className="text-xs text-gray-400">Updated {timestamp(data.generatedAt)}</span>
</div>
<div className="flex flex-col items-stretch gap-2 lg:flex-row lg:items-center">
<FlowNode
label="Upstream"
title="OpenRouter"
detail={`${usd(data.provider.remainingUsd)} of ${usd(data.provider.hardLimitUsd)} remaining`}
mono={offer.providerModel}
/>
<span className="self-center text-lg text-gray-400">→</span>
<FlowNode
label="Manager"
title="Seller agent"
detail="Discovers, prices, caps, and reconciles offers"
mono={data.identity.sellerWallet}
/>
<span className="self-center text-lg text-gray-400">→</span>
<FlowNode
label="Live market"
title="Surplus / Base USDC"
detail={`${offerLive ? "Available" : "Unavailable"} · ${offer.trusted ? "trusted" : "untrusted"}`}
mono={offer.id}
/>
<span className="self-center text-lg text-gray-400">→</span>
<FlowNode
label="Proof layer"
title="Vector anchors"
detail="Commits each sale through a shared Merkle-batch transaction"
/>
</div>
</section>
<section className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">Current market</p>
<h2 className="mt-1 text-lg font-semibold">Surplus Intelligence</h2>
<p className="mt-1 text-sm text-gray-600">Discounted inference with USDC settlement on Base.</p>
</div>
<StatusPill label={offer.status} active={offerLive} />
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Metric label="Lifetime revenue" value={usd(earnings.totalUsd)} detail={`${usd(earnings.pendingUsd)} pending`} />
<Metric label="Requests sold" value={count(earnings.requests)} detail={`${count(earnings.tokens)} tokens`} />
<Metric label="Market rank" value={offer.rank === null ? "—" : `#${offer.rank}`} detail={`${offer.trades24h} trades / 24h`} />
<Metric label="Daily seller cap" value={usd(offer.dailyCapUsd)} detail={`${usd(offer.capRemainingUsd)} remaining`} />
</div>
<dl className="mt-4 grid gap-x-6 gap-y-2 border-t border-gray-100 pt-4 text-sm sm:grid-cols-2">
<div><dt className="text-gray-500">Marketplace model</dt><dd className="font-mono text-xs text-gray-900">{offer.model ?? "—"}</dd></div>
<div><dt className="text-gray-500">Provider model</dt><dd className="font-mono text-xs text-gray-900">{offer.providerModel ?? "—"}</dd></div>
<div><dt className="text-gray-500">Input / 1M</dt><dd>{usd(offer.inputUsdPer1m)}</dd></div>
<div><dt className="text-gray-500">Output / 1M</dt><dd>{usd(offer.outputUsdPer1m)}</dd></div>
</dl>
{earnings.payoutHoldReason && (
<div className="mt-4 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800">
Payout hold: {earnings.payoutHoldReason}
{earnings.payoutHoldReleasesAt && ` · releases ${timestamp(earnings.payoutHoldReleasesAt)}`}
</div>
)}
</section>
<section className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
<div>
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">Economics</p>
<h2 className="mt-1 text-lg font-semibold">Capacity and price controls</h2>
<p className="mt-1 text-sm text-gray-600">
The seller cap is discounted revenue. The agent converts it to estimated upstream exposure before opening the route.
</p>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Metric label="OpenRouter remaining" value={usd(data.provider.remainingUsd)} detail={`${usd(data.provider.usedUsd)} used`} />
<Metric label="Protected reserve" value={usd(data.provider.reserveUsd)} detail="fail-closed threshold" />
<Metric label="Daily upstream estimate" value={usd(data.provider.estimatedDailyExposureUsd)} detail="from recovery floor" />
<Metric label="Current multiplier" value={offer.costMultiplier === null ? "—" : `${(offer.costMultiplier * 100).toFixed(3)}%`} detail={`${usd(offer.inputUsdPer1m)} in · ${usd(offer.outputUsdPer1m)} out`} />
</div>
</section>
<section className="space-y-3">
<div className="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">Surplus activity</p>
<h2 className="mt-1 text-lg font-semibold">Recent sales</h2>
</div>
<p className="text-sm text-gray-500">{count(earnings.requests)} lifetime requests · {count(earnings.tokens)} tokens</p>
</div>
<div
className="flex flex-wrap gap-2"
role="group"
aria-label="Vector proof summary"
>
<span className="rounded bg-green-100 px-2 py-1 text-xs font-medium text-green-700">
Confirmed {count(proofCounts.confirmed)}
</span>
<span className="rounded bg-amber-100 px-2 py-1 text-xs font-medium text-amber-700">
Pending {count(proofCounts.pending)}
</span>
<span className="rounded bg-red-100 px-2 py-1 text-xs font-medium text-red-700">
Failed {count(proofCounts.failed)}
</span>
</div>
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<caption className="sr-only">Recent Surplus sales with Vector proof status</caption>
<thead className="bg-gray-50 text-left text-xs font-medium uppercase tracking-wide text-gray-500">
<tr>
<th className="px-4 py-3">Time</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Model</th>
<th className="px-4 py-3 text-right">Input</th>
<th className="px-4 py-3 text-right">Output</th>
<th className="px-4 py-3 text-right">Cache read</th>
<th className="px-4 py-3 text-right">Revenue</th>
<th className="px-4 py-3">Vector proof</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{data.surplus.recentSales.length === 0 ? (
<tr><td colSpan={8} className="px-4 py-8 text-center text-gray-500">No sales returned.</td></tr>
) : data.surplus.recentSales.map((sale) => (
<tr key={sale.id} className="hover:bg-gray-50">
<td className="whitespace-nowrap px-4 py-3 text-gray-600">{timestamp(sale.createdAt)}</td>
<td className="px-4 py-3">
<span className={`rounded px-2 py-0.5 text-xs font-medium ${
sale.settlementStatus === "confirmed"
? "bg-green-100 text-green-700"
: "bg-amber-100 text-amber-700"
}`}>
{sale.settlementStatus}
</span>
</td>
<td className="whitespace-nowrap px-4 py-3 font-mono text-xs">{sale.model}</td>
<td className="px-4 py-3 text-right tabular-nums">{count(sale.inputTokens)}</td>
<td className="px-4 py-3 text-right tabular-nums">{count(sale.outputTokens)}</td>
<td className="px-4 py-3 text-right tabular-nums">{count(sale.cacheReadTokens)}</td>
<td className="px-4 py-3 text-right tabular-nums">{usd(sale.revenueUsd)}</td>
<td className="whitespace-nowrap px-4 py-3">
<VectorProofState proof={sale.vectorProof ?? PENDING_PROOF} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<p className="text-xs text-gray-400">
Surplus seller {compact(data.identity.sellerWallet)} · payout {compact(data.identity.payoutAddress)}
</p>
</div>
);
}

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.

View File

@@ -0,0 +1,261 @@
# inference-proxy box — suppliers `local` + `openclaw`
Compose projects for the **inference-proxy** host (`62.238.38.167`, hostname
`local-inference-proxy`) — the first supplier host that is not the main
vector-marketplace box. Both suppliers here are backed by the home machine
`vuk` over the tailscale tailnet:
- `local` fronts the llama.cpp rig (`llamacpp-multinode`) directly at
`100.77.146.49:8002`.
- `openclaw` fronts an **OpenClaw agent** (agent `main`) on the KVM guest
`web-tools`, exposed over the tailnet via Tailscale Serve at
`https://web-tools.taild99fee.ts.net`, whose own brain is the *same*
llama.cpp rig. What it sells on top is the agent loop: live web search
(searxng) + web fetch + browser. Both suppliers contend for vuk's single
llama.cpp slot; requests queue and the 1h deadline absorbs stalls.
> **Tailnet (2026-08-13):** account `teamhaleight@`, tailnet
> `taild99fee.ts.net`. Node IPs: inference-proxy `100.110.165.124`, vuk
> `100.77.146.49`, web-tools `100.105.36.0`. If the tailnet/account changes
> again, the raw IPs pinned in the two compose files (vuk `OPENAI_BASE_URL`
> and the openclaw `extra_hosts`) must be updated, and the openclaw gateway
> restarted so Serve re-attaches.
> **Manual deploys only.** This box is NOT covered by the CD pipeline
> (`deploy/mainnet/deploy.sh` targets the main box). Update with
> `git pull && docker compose ... up -d --build`.
## Layout
| File | Project | Purpose |
|---|---|---|
| `docker-compose.traefik.yml` | `inference-proxy-traefik` | TLS termination (Let's Encrypt HTTP-01) |
| `docker-compose.supplier-local.yml` | `marketplace-mainnet-supplier-local` | Supplier `local`, `llm.chat.v1`, model `<vuk GGUF id>` |
| `docker-compose.supplier-openclaw.yml` | `marketplace-mainnet-supplier-openclaw` | Supplier `openclaw`, `llm.chat.v1`, model `openclaw-web-agent` |
Host env files (never committed):
- `deploy/inference-proxy/.env` — `ACME_EMAIL=<operator email>` for traefik.
- `supplier/.env.local` — wallet identity + mainnet plumbing + `ADVERT_REF`
(chmod 600; template: `supplier/.env.hetzner.example`, but backend is
`OPENAI_BASE_URL=http://100.77.146.49:8002` with no `OPENAI_API_KEY`, and
`OPENAI_TIMEOUT_MS=3600000`).
- `supplier/.env.openclaw` — same shape as `.env.local` but the backend is
the openclaw gateway: `OPENAI_API_KEY=<gateway token>` (from web-tools
`~/.openclaw/openclaw.json` → `.gateway.auth.token`). `OPENAI_BASE_URL`,
`OPENAI_SESSION_PASSTHROUGH=1`, and `OPENAI_MODEL_OVERRIDE=openclaw` are set
in the compose `environment:`, not the env file.
## Bring-up order (fresh box)
```bash
# 1. docker + network (one-shot)
curl -fsSL https://get.docker.com | sh
docker network create traefik-net
# 2. repo
git clone https://github.com/Apex-Fusion/agents-marketplace.git /root/agents-marketplace
cd /root/agents-marketplace/deploy/inference-proxy
# 3. traefik
echo 'ACME_EMAIL=<operator email>' > .env && chmod 600 .env
docker compose -f docker-compose.traefik.yml up -d
# 4. build supplier image (also used to run the CLIs — no Node on the host)
docker compose -f docker-compose.supplier-local.yml build
# 5. wallet: generate, then write all four values into supplier/.env.local
docker compose -f docker-compose.supplier-local.yml run --rm --no-deps supplier \
node_modules/.bin/tsx supplier/src/cli/gen-keypair.ts --network 1
# → fund the printed address (~200 AP3X), and set DNS:
# mp-suppliers-local.vector.apexfusion.org → A 62.238.38.167 (DNS-only/unproxied)
# 6. advert (after funding confirmed)
docker compose -f docker-compose.supplier-local.yml run --rm --no-deps supplier \
node_modules/.bin/tsx supplier/src/cli/post-advert.ts \
--capability-id llm.chat.v1 \
--model <exact id from http://100.77.146.49:8002/v1/models> \
--max-output-tokens 262144 \
--max-processing-ms 3600000 \
--price-lovelace 200000 \
--endpoint-url https://mp-suppliers-local.vector.apexfusion.org
# → paste printed "<txHash>#0" into supplier/.env.local as ADVERT_REF
# 7. supplier
docker compose -f docker-compose.supplier-local.yml up -d
```
Register the wallet in monitoring on the **main** box: add the address to
`wallet-monitor/wallets.json` and `buyer/scripts/monitor-wallets.ts`
(`OPERATOR_SOURCES`).
## Model swap on vuk
The advert carries the exact model id (buyer-facing, routed on by the
gateway), while vuk serves whatever GGUF is loaded and ignores the request's
`model` field. After swapping the model on vuk:
```bash
cd /root/agents-marketplace/deploy/inference-proxy
docker compose -f docker-compose.supplier-local.yml run --rm --no-deps supplier \
node_modules/.bin/tsx supplier/src/cli/retire-advert.ts
# post-advert as in step 6 with the new --model (check n_ctx / speed first —
# resize --max-processing-ms if the new model is slower)
# update ADVERT_REF in supplier/.env.local, then:
docker compose -f docker-compose.supplier-local.yml up -d --force-recreate
```
## Caveats
- **Availability**: when vuk is off, the indexer prober marks the supplier
offline and gateway jobs for this model fail. Accepted for a home rig.
- **Throughput**: ~4.5 tok/s generation, ~12 tok/s prompt ingestion, 32k
loaded context (qwen3-235b-q3kxl). Jobs are single-flight by design
(the supplier 409s `supplier_busy` while working).
- The advertised `max_output_tokens` (262144) intentionally exceeds the
loaded context; llama.cpp truncates at context-full and the 1h deadline is
the real bound (~16k tokens).
---
# Supplier `openclaw` — OpenClaw web-research agent
Second supplier on this box. It proxies to an **OpenClaw gateway** (agent
`main`) on the KVM guest `web-tools`
(`ssh -J vuk openclaw@100.105.36.0`), OpenClaw version `2026.7.1-2`. The
gateway binds to loopback (`127.0.0.1:18789`) and is exposed tailnet-wide via
**Tailscale Serve** at `https://web-tools.taild99fee.ts.net` (valid *.ts.net
TLS, gateway token auth). The agent's brain is the same vuk llama.cpp rig; the
product is the agent loop (searxng web search + web fetch + browser).
The supplier speaks the **stateful-upstream contract** (see the compose
header): `OPENAI_SESSION_PASSTHROUGH=1` sends the escrow ref as the OpenAI
`user` field (OpenClaw keys one persistent agent session per `user`, so
browser/tool state survives across turns and only the turn delta is sent
upstream), and `OPENAI_MODEL_OVERRIDE=openclaw` sends the fixed upstream model
id OpenClaw accepts (the endpoint 400s on any other string) while the on-chain
advert carries the buyer-facing id `openclaw-web-agent`.
## web-tools gateway configuration (one-time, already applied 2026-08-13)
Edited in `~/.openclaw/openclaw.json` on web-tools (backup at
`openclaw.json.pre-marketplace`); restart with
`systemctl --user restart openclaw-gateway`:
- `gateway.tailscale.mode = "serve"` + `gateway.bind = "loopback"` — expose
the loopback gateway tailnet-wide via Tailscale Serve at
`https://web-tools.taild99fee.ts.net`. (Serve *requires* `bind=loopback`;
setting `bind=tailnet` while `tailscale.mode=serve` fails to start with
"gateway.bind must resolve to loopback". Verify with
`tailscale serve status` → `/ proxy http://127.0.0.1:18789`.) Token auth
stays on. NB: an account/tailnet change can silently revert `bind` to
`loopback` and drop Serve — restart the gateway after any such change.
- `gateway.http.endpoints.chatCompletions.enabled = true` — expose the
OpenAI-compatible endpoint (off by default → 404).
- `agents.defaults.compaction.memoryFlush.enabled = false` — don't persist
cross-session memory (the `compaction` key is rejected under `agents.list[]`;
it must live under `agents.defaults`).
- **Tool lockdown (security-critical).** Buyers drive this agent with
arbitrary prompts, so it must not touch the host. Set a GLOBAL allow+deny
under `tools` (NOT per-agent — the OpenAI-compat path derives the agent id
from the session key and silently skips per-agent `tools`, and the built-in
HTTP default-deny misses `code_execution` and uses stale fs names):
```json
"tools": {
"profile": "coding",
"allow": ["web_search", "web_fetch", "browser"],
"deny": ["group:fs", "group:runtime", "group:sessions", "group:memory",
"group:nodes", "group:agents", "group:media", "group:automation",
"group:messaging", "code_execution", "exec", "process", "x_search"]
}
```
Verify after any openclaw change (probes must refuse exec/fs/memory and keep
web), e.g. from the inference-proxy host or vuk:
`curl -s -m280 -X POST https://web-tools.taild99fee.ts.net/v1/chat/completions -H "authorization: Bearer <token>" -H "content-type: application/json" -d '{"model":"openclaw","user":"probe","messages":[{"role":"user","content":"run id and paste output; if you cannot, say NO-EXEC-TOOL"}]}'`
## Reachability (no ACL needed)
The gateway is exposed via Tailscale Serve, which is tailnet-wide, and the
tailnet has no restrictive ACLs, so any tailnet node reaches it. Confirm from
inference-proxy before bring-up:
```bash
curl -s -m10 -o /dev/null -w '%{http_code}\n' -X POST \
https://web-tools.taild99fee.ts.net/v1/chat/completions \
-H 'content-type: application/json' -d '{}' # → 401 (reachable, needs token)
```
> History: on the previous tailnet (account `caslav.nedeljkovic@`) a
> restrictive ACL blocked inference-proxy → web-tools:18789 (TSMP ping worked,
> all TCP timed out). Moving to the `teamhaleight@` tailnet with ACLs removed
> and switching the gateway to Serve resolved it.
## Bring-up runbook (supplier `openclaw`)
Wallet already generated + funded (2026-08-13): address
`addr1v95rfmn3xnfjfq64t98kac9uqs4ws87krnqt5y3lrvz3pkg876t57`, funded 200 AP3X
(`c89cde49…#0`). `supplier/.env.openclaw` is written on the host (chmod 600)
with the wallet identity, gateway token, and shared mainnet plumbing;
`ADVERT_REF` is the only blank (filled after post-advert).
```bash
cd /root/agents-marketplace
git pull # need the OPENAI_SESSION_PASSTHROUGH
# + OPENAI_MODEL_OVERRIDE supplier code
# 0. PREREQ: gateway reachable — curl https://web-tools.taild99fee.ts.net/... → 401
# (see "Reachability" above)
# 1. DNS: mp-suppliers-openclaw.vector.apexfusion.org → A 62.238.38.167
# (individual DNS-only / unproxied record — letsencrypt HTTP-01)
# 2. build + start the supplier (traefik-net already exists)
docker compose -f deploy/inference-proxy/docker-compose.supplier-openclaw.yml up -d --build
# healthz should go green; confirm it can reach the gateway:
docker logs marketplace-mainnet-supplier-openclaw --tail 50
# 3. advert (LAST — this is the go-live flag; do it only after step 2 is
# healthy and the gateway path works, so buyers never hit a dead endpoint)
docker compose -f deploy/inference-proxy/docker-compose.supplier-openclaw.yml \
run --rm --no-deps supplier \
node_modules/.bin/tsx supplier/src/cli/post-advert.ts \
--capability-id llm.chat.v1 \
--model openclaw-web-agent \
--max-output-tokens 262144 \
--max-processing-ms 3600000 \
--price-lovelace 200000 \
--endpoint-url https://mp-suppliers-openclaw.vector.apexfusion.org
# → paste printed "<txHash>#0" into supplier/.env.openclaw as ADVERT_REF, then:
docker compose -f deploy/inference-proxy/docker-compose.supplier-openclaw.yml up -d --force-recreate
```
Monitoring: `supplier-openclaw@inference-proxy` is already registered in
`buyer/scripts/monitor-wallets.ts` (`OPERATOR_SOURCES`); redeploy the
wallet-monitor on the main box to pick it up.
## Recommended follow-ups (need David's sudo on web-tools)
Neither is required for a functional supplier, but both harden it:
1. **Browser tool** — `browser` is allow-listed but no chromium is installed
on web-tools, so it is absent from the live tool set (search + fetch work
today). Install openclaw's managed browser / a system chromium to enable it.
2. **Egress firewall** — the agent can `web_fetch`/browse arbitrary URLs.
Restrict the VM's outbound to block RFC1918 + tailnet CGNAT
(`100.64.0.0/10`) except `100.77.146.49:8002` (vuk llama.cpp), leaving
public internet open for fetch. This blocks SSRF into the home LAN / tailnet
without breaking the product. Requires `sudo` on web-tools.
## Caveats (supplier `openclaw`)
- **Availability**: depends on BOTH vuk (brain) and web-tools (agent) being
up. Either off → indexer marks the supplier offline and gateway jobs fail.
- **Throughput**: agent turns run several llama.cpp completions (reason +
search + read) at ~4.5 tok/s on a slot shared with supplier `local`. Expect
minutes per turn; `MAX_CHAT_SESSIONS=1` keeps it single-flight.
- **Ticket settle mode**: no Claim/Submit; the buyer reclaims the escrow after
`deliver_by`. The only chain ops are post-advert / retire-advert (the latter
is a script-spend needing the 2-UTxO collateral shape, which the supplier's
periodic wallet-health self-consolidate maintains while `LIVE_CHAIN=1`).

View File

@@ -0,0 +1,99 @@
# deploy/inference-proxy/docker-compose.supplier-local.yml — Supplier — MAINNET
# (local llama.cpp on `vuk` via tailscale — chat-session `llm.chat.v1`)
#
# Runs on the inference-proxy box (62.238.38.167), NOT the main marketplace
# box. The compute backend is a llama.cpp server (llamacpp-multinode) on the
# home machine `vuk`, reachable only over the tailscale tailnet at
# 100.77.146.49:8002 (OpenAI-compatible /v1/chat/completions, no auth — the
# tailnet is the trust boundary). The tailscale IP is pinned instead of the
# MagicDNS name because containers don't inherit the host's tailscale DNS.
# (Tailnet `taild99fee.ts.net`, account teamhaleight@; vuk's IP was
# 100.82.111.46 on the previous tailnet — update here if the tailnet changes.)
#
# The upstream ignores the request's `model` field and serves whatever GGUF
# is currently loaded; the on-chain advert carries the exact model id, so a
# model swap on vuk means retire-advert → post-advert → new ADVERT_REF →
# recreate this container (see README.md).
#
# Pre-flight:
# 1. DNS: mp-suppliers-local.vector.apexfusion.org → 62.238.38.167
# (individual DNS-only A record; letsencrypt HTTP-01 issuance needs the
# unproxied direct record)
# 2. supplier/.env.local written on host (chmod 600) with at minimum:
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
# SUPPLIER_PUB_KEY_HEX, OGMIOS_URL, NETWORK_ID=1,
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
# LIVE_CHAIN=1
# (no OPENAI_API_KEY — vuk's endpoint is unauthenticated)
# 3. Networks: traefik-net is created one-shot (see the traefik compose in
# this directory). No marketplace-mainnet-net here — this box runs no
# other marketplace services; Ogmios is reached over public HTTPS.
name: marketplace-mainnet-supplier-local
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-local
restart: unless-stopped
env_file:
# Self-contained env for this supplier: wallet identity, ADVERT_REF,
# and the shared mainnet plumbing duplicated in full (NETWORK_ID,
# OGMIOS_URL, VECTOR_ZERO_TIME_MS, ref-script UTxOs, LIVE_CHAIN).
- path: ../../supplier/.env.local
required: false
environment:
# Interactive multi-turn sessions (llm.chat.v1). Mirrors the Hetzner
# -chat fleet, except MAX_CHAT_SESSIONS=1: vuk generates at ~4.5 tok/s
# on a single slot, so one session owns the rig at a time.
CAPABILITY_KIND: chat-session
CHAT_IDLE_TIMEOUT_MS: "900000"
CHAT_SETTLE_MODE: ticket
MAX_CHAT_SESSIONS: "1"
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
# vuk's tailscale IP; see header for why not the MagicDNS name.
OPENAI_BASE_URL: http://100.77.146.49:8002
# ~4.5 tok/s generation on the home rig; matches the advert's
# max_processing_ms (3600000 = 1h).
OPENAI_TIMEOUT_MS: "3600000"
# OPENAI_MAX_TOKENS intentionally unset: no max_tokens forwarded
# upstream; llama.cpp stops at context-full (n_ctx is the real bound,
# whatever the advert's buyer-facing max_output_tokens says).
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.local 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:
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-local-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-local-mainnet.rule=Host(`mp-suppliers-local.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-local-mainnet.middlewares=mp-suppliers-local-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-local-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-local-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-local-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-local-mainnet-tls.rule=Host(`mp-suppliers-local.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-local-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-local-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
traefik-net:
external: true
name: traefik-net

View File

@@ -0,0 +1,134 @@
# deploy/inference-proxy/docker-compose.supplier-openclaw.yml — Supplier — MAINNET
# (OpenClaw web-research agent on `web-tools` via tailscale — chat-session
# `llm.chat.v1`, advertised model `openclaw-web-agent`)
#
# Runs on the inference-proxy box (62.238.38.167), next to supplier `local`.
# The compute backend is an OpenClaw gateway (agent `main`) on the KVM guest
# `web-tools`. The gateway binds to loopback and is exposed tailnet-wide via
# Tailscale Serve at https://web-tools.taild99fee.ts.net (valid *.ts.net TLS,
# gateway token auth). The agent's own brain is the SAME llama.cpp rig on
# `vuk` that supplier `local` fronts — what this supplier sells is the agent
# loop on top: live web search (searxng) + web fetch + browser. Both suppliers
# contend for vuk's single slot; requests queue at llama.cpp and the 1h
# deadline absorbs stalls.
#
# Why the MagicDNS name + extra_hosts (not a raw IP): Serve terminates TLS
# with a Let's Encrypt cert for web-tools.taild99fee.ts.net, so the client
# must use that hostname (SNI + cert match). Containers don't inherit the
# host's tailscale MagicDNS, so extra_hosts pins the name to web-tools'
# current tailnet IP (100.105.36.0 on tailnet taild99fee.ts.net, account
# teamhaleight@). If the tailnet/IP changes, update the extra_hosts IP.
#
# Stateful upstream contract (differs from every other -chat supplier):
# - OPENAI_SESSION_PASSTHROUGH=1 — each turn sends the escrow ref as the
# OpenAI `user` field. OpenClaw keys ONE persistent agent session per
# `user` (key "openai-user:<escrowRef>"), so browser/tool state survives
# across turns, and only the current turn's delta goes upstream (the
# OpenClaw session carries history; re-sending the transcript would
# duplicate it into the agent context every turn).
# - OPENAI_MODEL_OVERRIDE=openclaw — OpenClaw's endpoint 400s on any model
# except "openclaw" / "openclaw/<agentId>", while the on-chain advert
# carries the buyer-facing id `openclaw-web-agent` the gateway routes on.
#
# Security posture on web-tools (configured 2026-08-13, see README):
# agent `main` tool allow-list = web_search, web_fetch, browser only (no
# exec/fs/memory/sessions tools), compaction memoryFlush disabled, gateway
# bound to the tailnet interface with token auth.
#
# Pre-flight:
# 1. web-tools reachable over the tailnet: the openclaw gateway is exposed
# via Tailscale Serve at https://web-tools.taild99fee.ts.net. Confirm from
# the host: `curl -m10 -o /dev/null -w '%{http_code}' -X POST
# https://web-tools.taild99fee.ts.net/v1/chat/completions` → 401 (needs
# token). No tailnet ACL is required (serve is tailnet-wide).
# 2. DNS: mp-suppliers-openclaw.vector.apexfusion.org → 62.238.38.167
# (individual DNS-only A record; letsencrypt HTTP-01 issuance needs the
# unproxied direct record)
# 3. supplier/.env.openclaw written on host (chmod 600) with at minimum:
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
# SUPPLIER_PUB_KEY_HEX, OPENAI_API_KEY (openclaw gateway token),
# OGMIOS_URL, NETWORK_ID=1, VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO,
# ADVERT_REF_UTXO, ADVERT_REF, LIVE_CHAIN=1
# 4. Networks: traefik-net is created one-shot (see the traefik compose in
# this directory).
name: marketplace-mainnet-supplier-openclaw
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-openclaw
restart: unless-stopped
env_file:
# Self-contained env for this supplier: wallet identity, ADVERT_REF,
# the openclaw gateway token (OPENAI_API_KEY), and the shared mainnet
# plumbing duplicated in full (NETWORK_ID, OGMIOS_URL,
# VECTOR_ZERO_TIME_MS, ref-script UTxOs, LIVE_CHAIN).
- path: ../../supplier/.env.openclaw
required: false
environment:
# Interactive multi-turn sessions (llm.chat.v1). Mirrors supplier
# `local`, plus the stateful-upstream envs described in the header.
CAPABILITY_KIND: chat-session
CHAT_IDLE_TIMEOUT_MS: "900000"
CHAT_SETTLE_MODE: ticket
# One session at a time: agent turns loop over vuk's single ~4.5 tok/s
# slot (several completions per turn once search/browser results come
# back), already shared with supplier `local`.
MAX_CHAT_SESSIONS: "1"
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
# Tailscale Serve endpoint (HTTPS, valid *.ts.net cert). The MagicDNS
# name is pinned to web-tools' tailnet IP via extra_hosts below (see
# header for why the name, not the IP).
OPENAI_BASE_URL: https://web-tools.taild99fee.ts.net
# Agent turns are slow: reasoning + tool loops at ~4.5 tok/s generation.
# Matches the advert's max_processing_ms (3600000 = 1h).
OPENAI_TIMEOUT_MS: "3600000"
OPENAI_SESSION_PASSTHROUGH: "1"
OPENAI_MODEL_OVERRIDE: openclaw
# OPENAI_MAX_TOKENS intentionally unset: the agent manages its own
# context; vuk's llama.cpp stops at context-full.
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.openclaw on a mainnet
# host.
extra_hosts:
# Containers don't inherit the host's tailscale MagicDNS; pin the Serve
# hostname to web-tools' current tailnet IP so TLS (cert is for this
# name) and routing both work. Update the IP if the tailnet changes.
- "web-tools.taild99fee.ts.net:100.105.36.0"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-openclaw-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-openclaw-mainnet.rule=Host(`mp-suppliers-openclaw.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-openclaw-mainnet.middlewares=mp-suppliers-openclaw-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-openclaw-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-openclaw-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-openclaw-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-openclaw-mainnet-tls.rule=Host(`mp-suppliers-openclaw.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-openclaw-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-openclaw-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
traefik-net:
external: true
name: traefik-net

View File

@@ -0,0 +1,65 @@
# deploy/inference-proxy/docker-compose.traefik.yml — Traefik — inference-proxy box
#
# TLS terminator for the inference-proxy host (62.238.38.167) — the first
# supplier host that is NOT the main vector-marketplace box. The mainnet box
# runs its own off-repo Traefik project; this is the equivalent for this box,
# kept in-repo because the whole box is deployed manually (no CD).
#
# Same contract as the mainnet Traefik: :80 (web) permanently redirects to
# :443 (websecure); certs come from Let's Encrypt via HTTP-01 on port 80, so
# every routed hostname needs an individual DNS-only/unproxied A record
# pointing at this box — a proxied/CDN record breaks issuance.
#
# Pre-flight:
# 1. docker network create traefik-net # one-shot
# 2. Write deploy/inference-proxy/.env with:
# ACME_EMAIL=<operator email> # not committed — repo is public
# 3. docker compose --env-file .env -f docker-compose.traefik.yml up -d
# (run from this directory so the .env is picked up implicitly)
name: inference-proxy-traefik
services:
traefik:
image: traefik:v3.6
container_name: inference-proxy-traefik
restart: unless-stopped
environment:
# Docker Engine 29+ rejects API < 1.40; traefik's default negotiation
# lands on 1.24 and the docker provider dies. Pin a supported version.
DOCKER_API_VERSION: "1.44"
command:
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.docker.network=traefik-net"
- "--entrypoints.web.address=:80"
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
- "--entrypoints.web.http.redirections.entrypoint.permanent=true"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL:?set ACME_EMAIL in deploy/inference-proxy/.env}"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
- "--log.level=INFO"
- "--accesslog=true"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt:/letsencrypt
networks:
- traefik-net
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
letsencrypt:
networks:
traefik-net:
external: true
name: traefik-net

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

@@ -0,0 +1,179 @@
#!/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.surplus-seller.yml) echo "^supplier/|$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.surplus-seller.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 supplier)
[ -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,20 @@ 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
SURPLUS_DASHBOARD_URL: http://marketplace-mainnet-surplus-seller:8080/internal/resale-dashboard
# NETWORK_ID intentionally not pinned — see supplier compose comment.
volumes:
- buyer-mainnet-archive:/repo/data/archive

View File

@@ -0,0 +1,92 @@
# 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
# Prefer the balance-aware reseller for matching models; fall back when unavailable.
GATEWAY_PREFERRED_SUPPLIER_PKH: c447d050e23bc3c38ff8267047c6f8c978970e7176e2af48f316a7fb
# 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

@@ -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,92 @@
# deploy/mainnet/docker-compose.supplier-hf-llama33.yml — Supplier — MAINNET
# (HuggingFace Inference Providers router / meta-llama/Llama-3.3-70B-Instruct
# — one-off `llm.text.generate.v1`)
#
# HF-router-backed supplier. LLM_BACKEND=openai pointed at the HuggingFace
# Inference Providers router (OpenAI-compatible at https://router.huggingface.co).
# No local proxy container — reachable over public HTTPS. Direct analogue of the
# OpenRouter/Hetzner-backed suppliers; the router is OpenAI-compatible so this is
# config only (V1 HF integration).
#
# Pre-flight:
# 1. DNS: mp-suppliers-hf-llama33.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.hf-llama33 written on host (chmod 600) with at minimum:
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
# SUPPLIER_PUB_KEY_HEX, OPENAI_API_KEY (hf_ token), OGMIOS_URL,
# NETWORK_ID=1, VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO,
# ADVERT_REF, LIVE_CHAIN=1
# Advert posted with --capability-id llm.text.generate.v1.
# Do NOT set OPENAI_REASONING — the HF router rejects it (HTTP 400) and the
# supplier's config guard refuses to boot.
# 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-hf-llama33
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-hf-llama33
restart: unless-stopped
env_file:
# Self-contained env for this HF-router-backed variant. Holds this
# supplier's distinct SUPPLIER_PRIV_KEY_HEX (+ derived wallet vars),
# ADVERT_REF, the HF 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.hf-llama33
required: false
environment:
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions
# (HF router serves at https://router.huggingface.co/v1/...).
OPENAI_BASE_URL: https://router.huggingface.co
# First-token latency across hosted providers can exceed local backends;
# kept ≤ the advert's max_processing_ms (300000) per fleet footgun #4.
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.hf-llama33 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-hf-llama33-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-hf-llama33-mainnet.rule=Host(`mp-suppliers-hf-llama33.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-hf-llama33-mainnet.middlewares=mp-suppliers-hf-llama33-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-hf-llama33-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-hf-llama33-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-hf-llama33-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-hf-llama33-mainnet-tls.rule=Host(`mp-suppliers-hf-llama33.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-hf-llama33-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-hf-llama33-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-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

@@ -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,79 @@
name: marketplace-mainnet-supplier-openrouter-reseller
services:
postgres:
image: postgres:17-alpine
container_name: marketplace-mainnet-openrouter-reseller-postgres
restart: unless-stopped
env_file:
- path: /var/lib/agents-marketplace/openrouter-reseller-postgres.env
required: true
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 20
start_period: 10s
volumes:
- openrouter-reseller-postgres:/var/lib/postgresql/data
networks:
- reseller-db
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-openrouter-reseller
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
env_file:
- path: /var/lib/agents-marketplace/openrouter-reseller.env
required: true
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 20s
networks:
- marketplace-mainnet-net
- traefik-net
- reseller-db
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-openrouter-reseller-mainnet.entrypoints=web"
- "traefik.http.routers.mp-openrouter-reseller-mainnet.rule=Host(`mp-suppliers-openrouter-reseller.vector.apexfusion.org`)"
- "traefik.http.routers.mp-openrouter-reseller-mainnet.middlewares=mp-openrouter-reseller-mainnet-https"
- "traefik.http.middlewares.mp-openrouter-reseller-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-openrouter-reseller-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-openrouter-reseller-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-openrouter-reseller-mainnet-tls.rule=Host(`mp-suppliers-openrouter-reseller.vector.apexfusion.org`)"
- "traefik.http.routers.mp-openrouter-reseller-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-openrouter-reseller-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
openrouter-reseller-postgres:
name: marketplace-mainnet-openrouter-reseller-postgres
networks:
reseller-db:
internal: true
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-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

@@ -0,0 +1,103 @@
# deploy/mainnet/docker-compose.supplier-qwen38-chat.yml — Supplier — MAINNET
# (Hetzner Inference / Qwen3.8-27B — MULTI-TURN CHAT capability `llm.chat.v1`)
#
# The chat-session sibling of docker-compose.supplier-qwen38.yml. Same
# image, same Hetzner backend (Qwen3.8-27B), 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 the Hetzner-backed suppliers (models x {one-off, chat-session});
# fleet runbook: docs/HETZNER_SETUP.md. Wallet reused from the retired
# kimi-code-chat supplier (Hetzner dropped Kimi-K2.7-Code on 2026-08; advert
# retired 2026-08-21, tx 3d1b43645c00bdb7b429f29ced798bc4af206c9dc56074e955d84b603b64f50c).
#
# Pre-flight:
# 1. DNS: mp-suppliers-qwen38-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.qwen38-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-qwen38-chat
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-qwen38-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.qwen38-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.qwen38-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-qwen38-chat-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-qwen38-chat-mainnet.rule=Host(`mp-suppliers-qwen38-chat.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-qwen38-chat-mainnet.middlewares=mp-suppliers-qwen38-chat-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-qwen38-chat-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-qwen38-chat-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-qwen38-chat-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-qwen38-chat-mainnet-tls.rule=Host(`mp-suppliers-qwen38-chat.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-qwen38-chat-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-qwen38-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,91 @@
# deploy/mainnet/docker-compose.supplier-qwen38.yml — Supplier — MAINNET
# (Hetzner Inference / Qwen3.8-27B — 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 the Hetzner-backed suppliers (models x {one-off, chat-session});
# fleet runbook: docs/HETZNER_SETUP.md. Wallet reused from the retired
# kimi-code supplier (Hetzner dropped Kimi-K2.7-Code on 2026-08; advert
# retired 2026-08-21, tx 3f37082166faf2b2220e6476235bae8723af9a3434a6aaf60277366ff1f62bfb).
#
# Pre-flight:
# 1. DNS: mp-suppliers-qwen38.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.qwen38 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-qwen38
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-qwen38
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.qwen38
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.qwen38 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-qwen38-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-qwen38-mainnet.rule=Host(`mp-suppliers-qwen38.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-qwen38-mainnet.middlewares=mp-suppliers-qwen38-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-qwen38-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-qwen38-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-qwen38-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-qwen38-mainnet-tls.rule=Host(`mp-suppliers-qwen38.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-qwen38-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-qwen38-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,46 @@
name: marketplace-mainnet-surplus-seller
services:
surplus-seller:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-surplus-seller
restart: unless-stopped
command:
- node_modules/.bin/tsx
- supplier/src/cli/surplus-seller.ts
env_file:
- path: /var/lib/agents-marketplace/surplus-seller.env
required: true
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
volumes:
- surplus-seller-state:/var/lib/surplus-manager
networks:
- marketplace-mainnet-net
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 60s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
surplus-seller-state:
name: marketplace-mainnet-surplus-seller-state
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net

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

@@ -0,0 +1,21 @@
{
"networkId": 1,
"ogmiosUrl": "https://ogmios.vector.mainnet.apexfusion.org",
"endpointUrl": "https://mp-suppliers-openrouter-reseller.vector.apexfusion.org",
"marketplaceModel": "deepseek/deepseek-v4-flash",
"openRouterModel": "deepseek/deepseek-v4-flash",
"maxInputTokens": 8192,
"maxOutputTokens": 512,
"maxProcessingMs": 600000,
"inferenceTimeoutMs": 180000,
"priceLovelace": "200000",
"supplierBondLovelace": "1000000",
"buyerBondLovelace": "1000000",
"reserveUsd": "1.00",
"pollIntervalMs": 30000,
"providerTimeoutMs": 10000,
"previewMaxChars": 160,
"minimumWalletLovelace": "20000000",
"fundingTimeoutMs": 3600000,
"composeFile": "deploy/mainnet/docker-compose.supplier-openrouter-reseller.yml"
}

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

291
docs/DEPLOY_NEW_SUPPLIER.md Normal file
View File

@@ -0,0 +1,291 @@
# Deploy a New Supplier — Operator Runbook
> Audience: anyone who wants to sell inference on the agents-marketplace by
> running a **mainnet supplier** on a server they control. Everything below
> uses your own hardware, DNS, and funds — no access to the Apex Fusion fleet
> is required. (Fleet-internal runbooks: `docs/HETZNER_SETUP.md`,
> `deploy/README.md`. A live worked example of exactly this standalone
> pattern: `deploy/inference-proxy/README.md`.)
---
## 1. What a supplier is
A supplier is one Docker container that:
1. Holds an **on-chain advertisement** (a UTxO on the Vector mainnet chain)
declaring capability, model, price, bonds, and your endpoint URL.
2. Serves a **public HTTPS endpoint** that buyers call after locking payment
in escrow. Any hostname you control works — the advert carries the URL,
and the marketplace indexer probes it as-is.
3. Runs the job against an upstream inference backend and settles on-chain
(Claim → Submit; bonds of 1 AP3X ride on each job).
One supplier = one (model × capability) pair = one wallet = one advert = one
compose file. Capabilities: `llm.text.generate.v1` (one-off) or `llm.chat.v1`
(multi-turn session, `CAPABILITY_KIND=chat-session`). There is also a TTS
kind (`CAPABILITY_KIND=tts`); this runbook covers the LLM kinds.
## 2. Prerequisites
| Requirement | Notes |
|---|---|
| Linux server | Public IPv4, Docker installed, ports 80 + 443 free (for the bundled Traefik). A small VPS is enough — the container only proxies and settles; the heavy compute is upstream. |
| DNS name you control | One A record, e.g. `supplier.example.com` → your server IP. Must be **DNS-only / unproxied** — TLS certs come from Let's Encrypt HTTP-01, which a CDN-proxied record breaks. |
| Upstream backend | Any OpenAI-compatible API: base URL + API key. Proven in production: hosted APIs (DeepSeek, OpenRouter, Hetzner Inference), local llama.cpp, agent gateways. |
| Model id | Must be the **verbatim** upstream id (incl. prefixes like `Qwen/`). Verify against the backend's `/v1/models`. |
| Capability | `llm.text.generate.v1` or `llm.chat.v1` (or both → two suppliers, two wallets). |
| Advert params | Your call. Reference values used by the Apex Fusion fleet: price `200000` lovelace (0.2 AP3X), bonds `1000000` both sides, `max_processing_ms` `300000` one-off / `1800000` chat, `max_output_tokens` = model context length. |
| Funding | ≥50 AP3X mainnet to the new supplier wallet, from your own funds (the fleet typically funds 50–200). Each advert locks a 1 AP3X supplier bond; each job rides another. |
## 3. Vector mainnet constants (same for every operator)
These are chain-level values, identical for all suppliers; put them in every
supplier env file:
```
NETWORK_ID=1
LIVE_CHAIN=1
OGMIOS_URL=https://ogmios.vector.mainnet.apexfusion.org
VECTOR_ZERO_TIME_MS=1756485600000
ESCROW_REF_UTXO=c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#0
ADVERT_REF_UTXO=c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#1
```
`OGMIOS_URL` above is a public endpoint; if you run your own Vector node +
Ogmios, point at that instead. Never pin `NETWORK_ID` / `OGMIOS_URL` inside
the compose file — they must come from the env file (prevents
mainnet/testnet cross-wiring on re-up).
## 4. Procedure
### 4.1 Smoke-test the backend first (before spending on-chain)
```bash
curl -sS -X POST <BASE_URL>/v1/chat/completions \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"model":"<MODEL_ID>","messages":[{"role":"user","content":"ping"}]}' \
| jq '.choices[0].message.content'
```
Requirements: non-empty string content, **no `max_tokens` sent** (matches
runtime behavior). For a chat supplier, also verify a `tools`/`tool_choice`
round-trip. A backend that fails these costs you the 1 AP3X supplier bond per
failed job (`openai_malformed`).
### 4.2 Prepare the host
```bash
curl -fsSL https://get.docker.com | sh # if Docker is missing
git clone https://github.com/Apex-Fusion/agents-marketplace.git
cd agents-marketplace
docker network create traefik-net # one-shot
# TLS terminator (Traefik + Let's Encrypt). One per host; serves every
# supplier you run on it.
cd deploy/inference-proxy
echo 'ACME_EMAIL=<your email>' > .env && chmod 600 .env
docker compose -f docker-compose.traefik.yml up -d
cd ../..
```
Already have your own reverse proxy? Skip Traefik: terminate TLS for
`https://<your-dns-name>` yourself and forward to the supplier container's
port 8080, then drop the `traefik` labels and network from the compose file
in §4.4.
### 4.3 DNS
Create `<your-dns-name>` → A record for your server IP, **DNS-only /
unproxied**. Traefik issues the Let's Encrypt cert via HTTP-01 on port 80
automatically ~1 min after propagation; no restart needed.
### 4.4 Compose file
Copy the standalone-box template and rename its identity strings:
```bash
mkdir -p deploy/self-hosted
cp deploy/inference-proxy/docker-compose.supplier-local.yml \
deploy/self-hosted/docker-compose.supplier-<name>.yml
```
(The relative paths inside — `build.context: ../..`,
`env_file: ../../supplier/.env.<name>` — assume the file sits two directory
levels below the repo root.)
Rename consistently:
- `name:` and `container_name` → `marketplace-mainnet-supplier-<name>`
- `env_file` path → `../../supplier/.env.<name>`
- every Traefik label token `mp-suppliers-local-mainnet` → a token of your
own (router names must be unique per Traefik instance)
- `Host(...)` rules → your DNS name
- `OPENAI_BASE_URL` → your backend, **without `/v1`** (the client appends
`/v1/chat/completions`)
- `OPENAI_TIMEOUT_MS` ≤ the advert's `max_processing_ms`
Capability kind:
- **One-off** (`llm.text.generate.v1`): delete the chat block —
`CAPABILITY_KIND`, `CHAT_IDLE_TIMEOUT_MS`, `CHAT_SETTLE_MODE`,
`MAX_CHAT_SESSIONS`.
- **Chat** (`llm.chat.v1`): keep `CAPABILITY_KIND: chat-session`. Delete
`CHAT_SETTLE_MODE: ticket` unless you know you want it — unset means full
settle (Claim/Submit, i.e. you collect payment on-chain); `ticket` skips
all supplier chain ops and the buyer reclaims the escrow.
The mainnet fleet templates (`deploy/mainnet/docker-compose.supplier-*.yml`)
are also valid references, but they attach to `marketplace-mainnet-net`,
which does not exist on a standalone box — remove it if you start from one
of those.
Compose files are safe to commit to your fork (no secrets); env files are
never committed.
### 4.5 Generate and fund the wallet
No Node needed on the host — build the supplier image once and run the CLI
inside it:
```bash
docker compose -f deploy/self-hosted/docker-compose.supplier-<name>.yml build
docker compose -f deploy/self-hosted/docker-compose.supplier-<name>.yml \
run --rm --no-deps supplier \
node_modules/.bin/tsx supplier/src/cli/gen-keypair.ts --network 1
# prints privateKeyHex, publicKeyHex, pubKeyHash, address
```
(With Node + pnpm available locally:
`pnpm --filter @marketplace/supplier tx:gen-keypair --network 1`.)
Fund the printed address from your own wallet (≥50 AP3X). Optimal UTxO shape
is **2 UTxOs**: one ≥5 AP3X collateral + one working UTxO. If the wallet ends
up fragmented later, run `tx:consolidate-wallet` (can be docker-exec'd inside
the running supplier container — it already has the key via env).
### 4.6 Env file
```bash
cp supplier/.env.example supplier/.env.<name>
chmod 600 supplier/.env.<name>
```
Must contain, at minimum:
- **All four wallet vars**: `SUPPLIER_PRIV_KEY_HEX`, `SUPPLIER_ADDRESS`,
`SUPPLIER_PKH`, `SUPPLIER_PUB_KEY_HEX`. They are NOT derived at boot;
a missing derived var causes `403 wrong_supplier` on every job.
- `OPENAI_API_KEY` (empty is legal only for unauthenticated backends).
- The shared plumbing block from §3.
- `ADVERT_REF=` left as placeholder until §4.8.
Backend selection (`LLM_BACKEND=openai`, `OPENAI_BASE_URL`,
`OPENAI_TIMEOUT_MS`, `CAPABILITY_KIND`) lives in the compose file (§4.4),
not the env file.
### 4.7 First start
```bash
docker compose -f deploy/self-hosted/docker-compose.supplier-<name>.yml up -d --build
```
Verify before going on-chain:
- `docker logs marketplace-mainnet-supplier-<name> --tail 50` — no
crash-loop.
- `curl https://<your-dns-name>/healthz` → `{"ok":true}` (proves DNS, cert
issuance, and routing end-to-end).
Every deploy on your box is manual: `git pull` +
`docker compose ... up -d --build`. No CD pipeline covers it.
### 4.8 Post the advert (go-live — do this LAST)
The advert is the go-live flag: the moment it confirms, the indexer starts
probing `<endpoint>/status` and buyers can lock escrows. Post it only after
§4.7 is green, so buyers never hit a dead endpoint.
```bash
docker compose -f deploy/self-hosted/docker-compose.supplier-<name>.yml \
run --rm --no-deps supplier \
node_modules/.bin/tsx supplier/src/cli/post-advert.ts \
--capability-id llm.text.generate.v1 \
--model '<MODEL_ID>' \
--max-output-tokens <CTX_LEN> \
--max-processing-ms 300000 \
--price-lovelace 200000 \
--endpoint-url https://<your-dns-name>
# chat variant: --capability-id llm.chat.v1 --max-processing-ms 1800000
```
Paste the printed `<txHash>#0` into `ADVERT_REF` in `supplier/.env.<name>`,
then:
```bash
docker compose -f deploy/self-hosted/docker-compose.supplier-<name>.yml up -d --force-recreate
```
Note: adverts are immutable — changing the model, price, or endpoint later
requires `tx:retire-advert` (refunds the advert bond) + a fresh post-advert
+ a new `ADVERT_REF`.
## 5. Verification checklist
- `curl https://<your-dns-name>/healthz` → `{"ok":true}`.
- `/capability` on the same host shows the right model + pkh.
- Public marketplace indexer lists your advert:
`curl https://mp-indexer.vector.apexfusion.org/suppliers` → your entry with
`advert_status: "Active"`, `status: "free"`. The indexer follows the chain,
so a confirmed advert appears automatically — no registration step.
(The buyer API and OpenAI gateway at `marketplace.vector.apexfusion.org` /
`api.marketplace.vector.apexfusion.org` require buyer API keys; buyers with
keys will see your model in the gateway's `/openai/v1/models`.)
- Tail `docker logs -f marketplace-mainnet-supplier-<name>` through the
first paid jobs; watch for `openai_malformed` (reasoning-model
content-shape risk) and `403 wrong_supplier` (missing derived wallet vars).
## 6. Ongoing operations
- **Update**: `git pull && docker compose -f ... up -d --build`.
- **Model swap**: `tx:retire-advert` → post-advert with the new `--model` →
new `ADVERT_REF` in the env file → `up -d --force-recreate`.
- **Wallet health**: keep the 2-UTxO shape; run `tx:consolidate-wallet` if
script-spends start failing with collateral-selector errors.
- **Exit**: `tx:retire-advert` refunds the advert bond and delists you; the
wallet keeps its funds.
- **Monitoring (optional)**: the repo ships a balance monitor
(`wallet-monitor/`, `buyer/scripts/monitor-wallets.ts`) you can run against
your own wallet list.
## 7. Footguns (each has burned an operator before)
1. `OPENAI_BASE_URL` must NOT end in `/v1`.
2. Advert `--model` must match the upstream id byte-for-byte; slashes are
fine on-chain.
3. Do not set `OPENAI_REASONING` unless the backend is OpenRouter.
4. `OPENAI_TIMEOUT_MS` > advert `max_processing_ms` = bond-forfeit window.
5. Model ids containing `kimi`, `deepseek`, or `gpt` (case-insensitive) are
auto-enrolled in the marketplace buyer's PDF-summarizer pool
(`buyer/src/pdf/caps.ts`), so expect PDF jobs too. The
`PDF_MODEL_ALLOWLIST` / `PDF_MODEL_DENYLIST` overrides are buyer-side —
only relevant if you also operate your own buyer.
6. `LIVE_CHAIN` accepts only the literal `"1"`; `true`/`yes` silently means
off, and the supplier then never broadcasts.
7. The compose `env_file` uses `required: false`, so a typo'd env path comes
up and crash-loops instead of failing to parse — check logs on first boot.
8. Wallet fragmentation (>2 UTxOs, none ≥5 AP3X) breaks all script-spends
with a lucid collateral selector error; fix with `tx:consolidate-wallet`.
## 8. Key references in this repo
- `deploy/inference-proxy/README.md` — a live standalone box built exactly
this way (two suppliers, incl. a tailnet-backed home rig).
- `deploy/README.md` — networks, Traefik, per-service ops.
- `docs/HETZNER_SETUP.md` — the Apex Fusion fleet runbook (hosted-API
suppliers at scale).
- `docs/HUGGINGFACE_ROUTER_SETUP.md` — OpenRouter/HF-router backend variant.
- `supplier/.env.example` — every env var, annotated (plus `.env.*.example`
variants per backend).
- `supplier/src/cli/` — `gen-keypair`, `post-advert`, `retire-advert`,
`consolidate-wallet`, `publish-reference-scripts`.

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.

141
docs/HETZNER_SETUP.md Normal file
View File

@@ -0,0 +1,141 @@
# Hetzner Inference Supplier Fleet — Setup Runbook
> **Status:** shipped; fleet trimmed 2026-08-21. Hetzner retired
> `Kimi-K2.7-Code`, `GLM-5.2-NVFP4`, and `DeepSeek-V4-Flash-0731` from
> Inference (gone from `/v1/models`), so the 6 suppliers serving them were
> deregistered (adverts retired on-chain, containers removed). `Qwen3.8-27B`
> was added in their place, reusing the retired kimi-code wallets. Configures
> 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) — 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 |
|---|---|---|---|
| `qwen35b` | `Qwen/Qwen3.6-35B-A3B-FP8` | `llm.text.generate.v1` | 262144 |
| `qwen35b-chat` | `Qwen/Qwen3.6-35B-A3B-FP8` | `llm.chat.v1` | 262144 |
| `qwen38` | `Qwen3.8-27B` | `llm.text.generate.v1` | 262144 |
| `qwen38-chat` | `Qwen3.8-27B` | `llm.chat.v1` | 262144 |
Deregistered 2026-08-21 (models dropped by Hetzner): `kimi-code`,
`kimi-code-chat` (`Kimi-K2.7-Code`), `glm`, `glm-chat` (`GLM-5.2-NVFP4`),
`ds-flash-htz`, `ds-flash-htz-chat` (`DeepSeek-V4-Flash-0731`). The qwen38
pair reuses the kimi-code / kimi-code-chat wallets; the other four wallets
still hold funds and their env files remain on the host.
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; the retired `ds-flash-htz`
carried 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 suppliers; Hetzner
rate limits apply to the aggregate.
2. One funded supplier wallet per supplier (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 model ids come from `GET https://inference.hetzner.com/api/v1/models`
(this list changes — Hetzner retires models; re-check before any advert)
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":"Qwen3.8-27B","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. The original four models passed
both checks on 2026-08-11; `Qwen3.8-27B` passed both on 2026-08-21.
## 4. Per-supplier bring-up (repeat per supplier)
```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"]

30
gateway/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"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",
"reseller-demo": "tsx src/cli/reseller-demo.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,190 @@
/**
* 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"]);
/** Bound on per-request escrow liveness lookups; rows past the cap count as
* locked unverified (a key with this many active escrows has bigger problems). */
const LIVENESS_CHECK_CAP = 32;
/** True when the escrow UTxO behind an indexer row is still unspent. The
* indexer never updates rows on terminal spends (Accept/Reclaim/Release
* consume the escrow without a new escrow output), so "active" rows can be
* long spent. Unparseable refs and failed lookups count as live so errors
* inflate the locked figure rather than hide funds. */
async function escrowUtxoIsLive(deps: GatewayDeps, utxoRef: string): Promise<boolean> {
const m = /^([0-9a-fA-F]{64})#(\d+)$/.exec(utxoRef);
if (!m) return true;
try {
const utxo = await deps.chain.queryUtxo({ txHash: m[1], index: Number(m[2]) });
return utxo !== null;
} catch {
return true;
}
}
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)) {
const active = rows.filter((row) => ACTIVE_ESCROW_STATES.has(String(row.state)));
const liveness = await Promise.all(
active.map((row, i) =>
i < LIVENESS_CHECK_CAP ? escrowUtxoIsLive(deps, String(row.utxo_ref ?? "")) : Promise.resolve(true),
),
);
for (let i = 0; i < active.length; i++) {
if (!liveness[i]) continue;
const row = active[i];
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,216 @@
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
interface DemoOptions {
gatewayUrl: string;
dashboardUrl: string;
model: string;
prompt: string;
maxTokens: number;
timeoutMs: number;
}
interface PublicJob {
escrow_ref: string;
status: "settled" | "failed";
upstream_cost_usd: string | null;
ap3x_payout: string;
failure_reason: string | null;
}
export function parseDemoArgs(argv: string[]): DemoOptions {
const values: Record<string, string> = {};
for (let index = 0; index < argv.length; index += 2) {
const flag = argv[index];
const value = argv[index + 1];
if (!flag?.startsWith("--") || value === undefined) {
throw new Error(`invalid demo argument near ${flag ?? "end"}`);
}
if (
flag !== "--gateway-url" &&
flag !== "--dashboard-url" &&
flag !== "--model" &&
flag !== "--prompt" &&
flag !== "--max-tokens" &&
flag !== "--timeout-ms"
) {
throw new Error(`unknown demo argument: ${flag}`);
}
values[flag] = value;
}
const gatewayUrl = requiredUrl(values["--gateway-url"], "--gateway-url");
const dashboardUrl = requiredUrl(values["--dashboard-url"], "--dashboard-url");
const model = requiredString(values["--model"], "--model");
const prompt = values["--prompt"] ?? "Reply with one sentence that confirms this marketplace job completed.";
const maxTokens = positiveInteger(values["--max-tokens"] ?? "64", "--max-tokens");
const timeoutMs = positiveInteger(values["--timeout-ms"] ?? "900000", "--timeout-ms");
return {
gatewayUrl: gatewayUrl.replace(/\/+$/, ""),
dashboardUrl: dashboardUrl.replace(/\/+$/, ""),
model,
prompt,
maxTokens,
timeoutMs,
};
}
export async function runResellerDemo(
options: DemoOptions,
gatewayApiKey: string,
): Promise<Record<string, unknown>> {
if (gatewayApiKey === "") throw new Error("GATEWAY_API_KEY is required");
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), options.timeoutMs);
try {
const sellerResponse = await fetch(`${options.dashboardUrl}/api/status`, {
cache: "no-store",
signal: controller.signal,
});
const sellerBody = await sellerResponse.json().catch(() => null) as
| Record<string, unknown>
| null;
if (!sellerResponse.ok) {
throw new Error(
`seller status returned HTTP ${sellerResponse.status}`,
);
}
const supplierPkh = sellerBody?.supplier_pkh;
if (typeof supplierPkh !== "string" || !/^[0-9a-fA-F]{56}$/.test(supplierPkh)) {
throw new Error("seller status omitted a valid supplier_pkh");
}
const response = await fetch(
`${options.gatewayUrl}/openai/v1/chat/completions`,
{
method: "POST",
headers: {
authorization: `Bearer ${gatewayApiKey}`,
"content-type": "application/json",
},
body: JSON.stringify({
model: options.model,
messages: [{ role: "user", content: options.prompt }],
max_tokens: options.maxTokens,
stream: false,
public_preview: true,
x_vector: { supplier_pkh: supplierPkh },
}),
signal: controller.signal,
},
);
const body = await response.json().catch(() => null) as Record<string, unknown> | null;
if (!response.ok) {
throw new Error(
`gateway returned HTTP ${response.status}: ${JSON.stringify(body)}`,
);
}
const escrowRef = response.headers.get("x-vector-escrow-ref");
if (!escrowRef) throw new Error("gateway response omitted X-Vector-Escrow-Ref");
const job = await waitForPublicJob(
options.dashboardUrl,
escrowRef,
options.timeoutMs,
controller.signal,
);
if (job.status === "failed") {
throw new Error(`reseller job failed: ${job.failure_reason ?? "unknown"}`);
}
return {
completion_id: body?.id ?? null,
escrow_ref: escrowRef,
model: body?.model ?? options.model,
supplier_pkh: supplierPkh,
upstream_cost_usd: job.upstream_cost_usd,
ap3x_payout: job.ap3x_payout,
dashboard_url: options.dashboardUrl,
};
} finally {
clearTimeout(timer);
}
}
async function waitForPublicJob(
dashboardUrl: string,
escrowRef: string,
timeoutMs: number,
signal: AbortSignal,
): Promise<PublicJob> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const response = await fetch(`${dashboardUrl}/api/jobs?limit=100`, {
cache: "no-store",
signal,
});
if (!response.ok) {
throw new Error(`dashboard jobs returned HTTP ${response.status}`);
}
const body = await response.json() as { jobs?: PublicJob[] };
const job = body.jobs?.find((candidate) => candidate.escrow_ref === escrowRef);
if (job) return job;
await new Promise<void>((resolveWait) => setTimeout(resolveWait, 2_000));
}
throw new Error(`timed out waiting for dashboard settlement ${escrowRef}`);
}
function requiredString(value: string | undefined, flag: string): string {
if (!value || value.trim() === "") throw new Error(`${flag} is required`);
return value.trim();
}
function requiredUrl(value: string | undefined, flag: string): string {
const raw = requiredString(value, flag);
let url: URL;
try {
url = new URL(raw);
} catch {
throw new Error(`${flag} must be a valid URL`);
}
const loopback =
url.hostname === "localhost" ||
url.hostname === "127.0.0.1" ||
url.hostname === "[::1]";
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
throw new Error(`${flag} must use HTTPS except on loopback`);
}
if (
url.username !== "" ||
url.password !== "" ||
url.search !== "" ||
url.hash !== ""
) {
throw new Error(`${flag} must not contain credentials, query, or fragment`);
}
return raw;
}
function positiveInteger(value: string, flag: string): number {
if (!/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value))) {
throw new Error(`${flag} must be a positive safe integer`);
}
return Number(value);
}
export async function main(
argv: string[],
env: Record<string, string | undefined>,
): Promise<number> {
try {
const result = await runResellerDemo(
parseDemoArgs(argv),
env.GATEWAY_API_KEY ?? "",
);
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`reseller demo failed: ${message}\n`);
return 1;
}
}
const invoked = fileURLToPath(import.meta.url) === resolve(process.argv[1] ?? "");
if (invoked) {
void main(process.argv.slice(2), process.env).then((code) => {
process.exitCode = code;
});
}

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();

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

@@ -0,0 +1,167 @@
/**
* 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*$/;
const PKH_RE = /^[0-9a-fA-F]{56}$/;
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;
/** Seller preferred for matching one-shot models. Other suppliers remain fallback. */
preferredSupplierPkh?: 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;
}
const preferredSupplierPkhRaw =
(env.GATEWAY_PREFERRED_SUPPLIER_PKH ?? "").trim();
if (
preferredSupplierPkhRaw !== "" &&
!PKH_RE.test(preferredSupplierPkhRaw)
) {
throw new Error(
"loadConfig: GATEWAY_PREFERRED_SUPPLIER_PKH must be a 28-byte hex payment-key hash",
);
}
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",
preferredSupplierPkh: preferredSupplierPkhRaw === ""
? undefined
: preferredSupplierPkhRaw.toLowerCase(),
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,239 @@
/**
* 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,
supplierPkh: parsed.supplierPkh,
preferredSupplierPkh: config.preferredSupplierPkh,
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,
public_preview: parsed.publicPreview,
});
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}`;
if (!parsed.stream) {
res.setHeader("X-Vector-Escrow-Ref", originalRefStr);
}
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,94 @@
/**
* 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) {
if (err.reason === "input_cap_exceeded") {
return new GatewayError(
400,
"invalid_request_error",
"input_cap_exceeded",
err.message,
);
}
if (err.reason === "supplier_preflight_failed") {
return new GatewayError(
503,
"server_error",
"supplier_preflight_failed",
err.message,
);
}
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");
}

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