63 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
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
150 changed files with 18924 additions and 151 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
"

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

@@ -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",
@@ -125,6 +132,34 @@ const OPERATOR_SOURCES: OperatorSource[] = [
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

@@ -42,6 +42,13 @@
* 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}$/;
@@ -62,7 +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 {
@@ -148,10 +157,36 @@ 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,
@@ -167,6 +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,
@@ -134,6 +141,7 @@ export async function runMain(env: Record<string, string | undefined>): Promise<
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,11 +73,15 @@ 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 type { AdvertDatum } from "@marketplace/shared/cbor";
@@ -86,8 +90,11 @@ import type {
DiscoverSuppliersOptions,
SubmitPromptOptions,
SubmitPromptResult,
SupplierCapabilityView,
SubmitTtsOptions,
SubmitTtsResult,
SubmitOcrOptions,
SubmitOcrResult,
StartChatOptions,
StartChatResult,
ChatSettleMode,
@@ -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 ────────────
/**

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

View File

@@ -114,6 +114,9 @@ export interface AppDeps {
* `${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. */
@@ -317,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(

View File

@@ -4,6 +4,7 @@
* 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
@@ -16,6 +17,7 @@ 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() {
@@ -35,8 +37,9 @@ 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="/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> */}
@@ -49,6 +52,7 @@ export default function App() {
<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 />} />

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

@@ -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

@@ -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

@@ -34,6 +34,7 @@ services:
# 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

@@ -34,6 +34,8 @@ services:
# 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.

View File

@@ -49,6 +49,12 @@ services:
- "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

View File

@@ -49,6 +49,12 @@ services:
- "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

View File

@@ -49,6 +49,12 @@ services:
- "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

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

@@ -58,6 +58,12 @@ services:
- "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

View File

@@ -58,6 +58,12 @@ services:
- "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

View File

@@ -58,6 +58,12 @@ services:
- "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

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

@@ -51,6 +51,12 @@ services:
- "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

View File

@@ -51,6 +51,12 @@ services:
- "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

View File

@@ -51,6 +51,12 @@ services:
- "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

View File

@@ -62,6 +62,12 @@ services:
- "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

View File

@@ -62,6 +62,12 @@ services:
- "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

View File

@@ -74,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

@@ -75,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,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"
}

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`.

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

@@ -22,6 +22,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 gateway/package.json ./gateway/package.json

View File

@@ -8,7 +8,8 @@
"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"
"create-demo-key": "tsx src/cli/create-demo-key.ts",
"reseller-demo": "tsx src/cli/reseller-demo.ts"
},
"dependencies": {
"@marketplace/buyer": "workspace:*",

View File

@@ -19,6 +19,26 @@ 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);
}
@@ -74,8 +94,15 @@ export function makeAccountHandler(deps: GatewayDeps) {
if (r.ok) {
const rows = (await r.json()) as Array<Record<string, unknown>>;
if (Array.isArray(rows)) {
for (const row of rows) {
if (!ACTIVE_ESCROW_STATES.has(String(row.state))) continue;
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")) +

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

@@ -28,6 +28,7 @@
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;
@@ -53,6 +54,11 @@ export interface GatewayConfig {
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 {
@@ -112,6 +118,17 @@ export function loadConfig(env: Record<string, string | undefined>): GatewayConf
}
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,
@@ -138,6 +155,10 @@ export function loadConfig(env: Record<string, string | undefined>): GatewayConf
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(/\/+$/, ""))

View File

@@ -57,7 +57,10 @@ export function runMain(env: Record<string, string | undefined>): void {
const invokedDirectly =
typeof process !== "undefined" &&
process.argv?.[1] !== undefined &&
/gateway\/(src|dist)\/index\.(t|j)s$/.test(process.argv[1]);
// 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 {

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

@@ -2,14 +2,20 @@
* gateway/src/onchain/preflight.ts — balance + collateral pre-flight.
*
* The buyer wallet funds the FULL escrow at PostEscrow: price + buyer_bond +
* supplier_bond (postEscrow.ts:132). Accept/Reclaim are Plutus script-spends
* that also need a pure-AP3X collateral UTxO ≥ 5 AP3X (assertCollateralCandidate).
* So required = 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.
* 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;
@@ -32,20 +38,48 @@ export interface PreflightResult {
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: { priceLovelace: bigint; buyerBondLovelace: bigint; supplierBondLovelace: bigint },
cost: PreflightCost,
): Promise<PreflightResult> {
const utxos = await chain.queryUtxosByAddress(address);
const available = totalLovelace(utxos);
const collateralOk = hasCollateral(utxos);
const required =
cost.priceLovelace +
cost.buyerBondLovelace +
cost.supplierBondLovelace +
COLLATERAL_MIN_LOVELACE +
FEE_RESERVE_LOVELACE;
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,

View File

@@ -96,6 +96,8 @@ async function runOneShot(
indexerUrl: config.indexerUrl,
model: parsed.model,
capabilityId: CAPABILITY,
supplierPkh: parsed.supplierPkh,
preferredSupplierPkh: config.preferredSupplierPkh,
fetchFn,
});
if (candidates.length === 0) {
@@ -152,6 +154,7 @@ async function runOneShot(
messages: parsed.messages,
payment_lovelace: cand.priceLovelace,
max_output_tokens: parsed.maxTokens,
public_preview: parsed.publicPreview,
});
used = cand;
break;
@@ -167,6 +170,9 @@ async function runOneShot(
// 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,

View File

@@ -71,6 +71,22 @@ export function toGatewayError(err: unknown): GatewayError {
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);

View File

@@ -12,6 +12,8 @@ export interface ParsedChatRequest {
stream: boolean;
tools?: unknown[];
toolChoice?: unknown;
publicPreview: boolean;
supplierPkh?: string;
}
const ROLES = new Set(["system", "user", "assistant"]);
@@ -83,6 +85,46 @@ export function parseChatRequest(body: unknown, opts?: { allowTools?: boolean })
maxTokens = mt;
}
if (b.public_preview !== undefined && typeof b.public_preview !== "boolean") {
throw badRequest(
"invalid_public_preview",
"`public_preview` must be a boolean when provided",
);
}
const publicPreview = b.public_preview === true;
let supplierPkh: string | undefined;
if (b.x_vector !== undefined) {
if (
typeof b.x_vector !== "object" ||
b.x_vector === null ||
Array.isArray(b.x_vector)
) {
throw badRequest("invalid_x_vector", "`x_vector` must be an object");
}
const requested = (b.x_vector as Record<string, unknown>).supplier_pkh;
if (
requested !== undefined &&
(typeof requested !== "string" || !/^[0-9a-fA-F]{56}$/.test(requested))
) {
throw badRequest(
"invalid_supplier_pkh",
"`x_vector.supplier_pkh` must be a 28-byte hex payment-key hash",
);
}
if (typeof requested === "string") supplierPkh = requested.toLowerCase();
}
const stream = b.stream === true;
return { model, messages, maxTokens, stream, tools, toolChoice };
return {
model,
messages,
maxTokens,
stream,
tools,
toolChoice,
publicPreview,
supplierPkh,
};
}

View File

@@ -53,8 +53,15 @@ export function parseRef(ref: string): OutputReference | null {
export interface SelectSupplierOpts {
indexerUrl: string;
/** Exact model match; "" matches ANY model under the capability (used by
* single-model capabilities like the ocr.* ids, where the capability
* already pins the model). */
model: string;
capabilityId: string;
/** Optional exact seller identity pin for deterministic agent routing. */
supplierPkh?: string;
/** Preferred seller ordered first when it is eligible. */
preferredSupplierPkh?: string;
fetchFn?: typeof globalThis.fetch;
/** supplier_pkhs eligible regardless of polled status — used after the demo
* executor frees a supplier the indexer poller (~20 s cadence) still reports
@@ -82,7 +89,10 @@ export async function selectCandidates(opts: SelectSupplierOpts): Promise<Suppli
const candidates: SupplierCandidate[] = [];
for (const raw of body as SupplierView[]) {
if (raw.capability_id !== opts.capabilityId) continue;
if (raw.model !== opts.model) continue;
if (opts.model !== "" && raw.model !== opts.model) continue;
if (opts.supplierPkh !== undefined && raw.supplier_pkh !== opts.supplierPkh) {
continue;
}
if (raw.advert_status !== "Active") continue;
if (raw.status !== "free" && raw.status !== "unknown" && !opts.ignoreStatusFor?.has(raw.supplier_pkh)) {
continue;
@@ -103,8 +113,16 @@ export async function selectCandidates(opts: SelectSupplierOpts): Promise<Suppli
status: raw.status,
});
}
// Prefer known-free over unknown so we route to confirmed-available first.
candidates.sort((a, b) => (a.status === "free" ? 0 : 1) - (b.status === "free" ? 0 : 1));
// Prefer the configured seller, then known-free over unknown.
candidates.sort((left, right) => {
const leftRank =
(left.supplierPkh === opts.preferredSupplierPkh ? 0 : 2) +
(left.status === "free" ? 0 : 1);
const rightRank =
(right.supplierPkh === opts.preferredSupplierPkh ? 0 : 2) +
(right.status === "free" ? 0 : 1);
return leftRank - rightRank;
});
return candidates;
}

View File

@@ -15,19 +15,88 @@ import { open as unseal } from "../crypto/seal.js";
import { deriveWalletKey } from "../wallet.js";
import type { ApiKeyRow } from "../db/store.js";
/** Tiny promise-chain mutex: run(fn) executes fns strictly one at a time. */
/** Default per-run deadline. Chain reads and tx submits settle in seconds;
* anything past this is the 2026-08-12 freeze mode (a fetch that never
* settles), not a slow success. */
export const DEFAULT_MUTEX_TIMEOUT_MS = 180_000;
/** Default queue bound per mutex. A healthy key drains its queue in seconds;
* a queue this deep means the head is stuck and callers should fail fast. */
export const DEFAULT_MUTEX_MAX_QUEUE = 64;
/** A run() exceeded its deadline and was abandoned so queued work can proceed. */
export class MutexTimeoutError extends Error {
constructor(label: string, timeoutMs: number) {
super(`mutex run '${label}' exceeded ${timeoutMs}ms and was abandoned; queued work proceeds`);
this.name = "MutexTimeoutError";
}
}
/** run() was refused because the queue is at its bound (head likely stuck). */
export class MutexQueueFullError extends Error {
constructor(label: string, depth: number) {
super(`mutex queue full (${depth} pending) — refusing '${label}'; the head of the queue is likely stuck`);
this.name = "MutexQueueFullError";
}
}
function runWithDeadline<T>(fn: () => Promise<T>, timeoutMs: number, label: string): Promise<T> {
return new Promise<T>((resolvePromise, rejectPromise) => {
const timer = setTimeout(() => rejectPromise(new MutexTimeoutError(label, timeoutMs)), timeoutMs);
// Node-only nicety: don't hold the process open for a pending deadline.
timer.unref?.();
fn().then(
(v) => {
clearTimeout(timer);
resolvePromise(v);
},
(e) => {
clearTimeout(timer);
rejectPromise(e);
},
);
});
}
/**
* Tiny promise-chain mutex: run(fn) executes fns strictly one at a time —
* now with a per-run deadline and a queue bound (2026-08-12 freeze fix).
*
* On deadline the stuck fn is NOT cancelled (promises aren't); it is
* abandoned: its run() rejects with MutexTimeoutError and the chain advances.
* A zombie that settles later can at worst lose a UTxO-contention race with
* the run that replaced it — strictly better than the alternative, where one
* stuck chain call convoys every subsequent request on the key forever.
*/
export class Mutex {
private tail: Promise<unknown> = Promise.resolve();
private depth = 0;
run<T>(fn: () => Promise<T>): Promise<T> {
const result = this.tail.then(fn, fn);
// Keep the chain alive even if fn rejects; swallow here so the next
// run() isn't poisoned by a prior rejection.
this.tail = result.then(
constructor(private readonly opts: { timeoutMs?: number; maxQueue?: number } = {}) {}
run<T>(fn: () => Promise<T>, label = "op"): Promise<T> {
const timeoutMs = this.opts.timeoutMs ?? DEFAULT_MUTEX_TIMEOUT_MS;
const maxQueue = this.opts.maxQueue ?? DEFAULT_MUTEX_MAX_QUEUE;
if (this.depth >= maxQueue) {
return Promise.reject(new MutexQueueFullError(label, this.depth));
}
this.depth++;
// Keep the chain alive even if fn rejects or times out; swallow here so
// the next run() isn't poisoned by a prior rejection.
const result = this.tail.then(
() => runWithDeadline(fn, timeoutMs, label),
() => runWithDeadline(fn, timeoutMs, label),
);
const settled = result.finally(() => {
this.depth--;
});
this.tail = settled.then(
() => undefined,
() => undefined,
);
return result;
return settled;
}
}

View File

@@ -14,6 +14,7 @@ import { ipRateLimit, keyRateLimit, demoIpRateLimit } from "./middleware/rateLim
import { sendError } from "./middleware/http.js";
import { notFound } from "./openai/errors.js";
import { makeChatCompletionsHandler } from "./openai/chatCompletions.js";
import { makeOcrExtractHandler } from "./ocr/extract.js";
import { makeModelsHandler } from "./openai/models.js";
import {
makeOpenSessionHandler,
@@ -40,6 +41,7 @@ function corsMiddleware(allowOrigins: string[]) {
res.setHeader("Vary", "Origin");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Expose-Headers", "X-Vector-Escrow-Ref");
res.setHeader("Access-Control-Max-Age", "600");
}
if (req.method === "OPTIONS") {
@@ -56,7 +58,11 @@ export function createApp(deps: GatewayDeps): Express {
app.set("trust proxy", true);
// CORS before body-parsing so preflight OPTIONS short-circuits cheaply.
app.use(corsMiddleware(deps.config.corsOrigins));
app.use(express.json({ limit: "1mb" }));
// /v1/ocr carries a base64 page image and mounts its own larger parser on
// the route; every other path keeps the 1mb ceiling.
const jsonBody = express.json({ limit: "1mb" });
app.use((req, res, next) =>
req.path.startsWith("/v1/ocr/") ? next() : jsonBody(req, res, next));
app.get("/healthz", (_req: Request, res: Response) => {
res.json({ ok: true });
@@ -80,6 +86,12 @@ export function createApp(deps: GatewayDeps): Express {
app.post("/account/withdraw", auth, makeWithdrawHandler(deps));
app.post("/openai/v1/chat/completions", ...gated, makeChatCompletionsHandler(deps));
// Model-scoped OCR: 16mb parser covers MAX_OCR_IMAGE_B64_CHARS
// (~12M chars ≈ 9 MB binary) plus JSON envelope. Gates (auth + rate
// limits) run BEFORE the parser so an unauthenticated caller cannot
// buffer/JSON.parse up to 16mb before getting a 401, and rate limiting
// can actually throttle the request (2026-08 review finding C2).
app.post("/v1/ocr/extract", ...gated, express.json({ limit: "16mb" }), makeOcrExtractHandler(deps));
app.get("/openai/v1/models", auth, makeModelsHandler(deps));
app.post("/openai/v1/chat/sessions", ...gated, makeOpenSessionHandler(deps));
app.post("/openai/v1/chat/sessions/:id/messages", ...gated, makeSessionMessageHandler(deps));

View File

@@ -46,6 +46,7 @@ function makeDeps(
chatSettleMode: overrides?.chatSettleMode ?? "full",
walletHealthIntervalMs: 600_000,
sdkRegistryMax: 100,
ocrCapabilityId: "ocr.page.extract.chandra-ocr-2.v1",
corsOrigins: [],
};
const chain = overrides?.chain ?? ({ queryUtxosByAddress: async () => [] } as unknown as GatewayDeps["chain"]);

572
gateway/test/ocr.test.ts Normal file
View File

@@ -0,0 +1,572 @@
/**
* gateway/test/ocr.test.ts — POST /v1/ocr/extract route coverage.
*
* Focus: auth gating, demo-key rejection, body validation, capability-based
* supplier lookup, and the enlarged body limit. The settle path (submitOcr →
* resolveSubmittedRef → acceptAndConfirm) reuses the one-shot chat plumbing
* covered by http.test.ts + the SDK suites; end-to-end runs live on testnet.
*/
import { describe, it, expect, vi } from "vitest";
import { tmpdir } from "os";
import { join } from "path";
import { randomBytes, randomUUID } from "crypto";
import request from "supertest";
import { createApp } from "../src/server.js";
import { GatewayStore } from "../src/db/store.js";
import { SdkRegistry } from "../src/sdk/registry.js";
import { genPrivKeyHex, deriveWalletKey } from "../src/wallet.js";
import { seal } from "../src/crypto/seal.js";
import { hashApiKey } from "../src/middleware/apiKeyAuth.js";
import { venueAttribution } from "../src/ocr/extract.js";
import type { GatewayConfig } from "../src/config.js";
import type { GatewayDeps } from "../src/deps.js";
import { SupplierError } from "@marketplace/buyer/sdk";
// The one-shot settle step (resolveSubmittedRef → acceptAndConfirm) talks to
// the live indexer + chain; the happy-path route test below only needs to
// prove that a successful submitOcr reaches the response with the
// first-party-brokered disclosure label (I3), so it stubs just those two
// chain-touching steps and keeps every other export real.
vi.mock("../src/onchain/settle.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/onchain/settle.js")>();
return {
...actual,
resolveSubmittedRef: vi.fn(async () => ({ txHash: "e".repeat(64), index: 0 })),
acceptAndConfirm: vi.fn(async () => "f".repeat(64)),
};
});
const MASTER = "ab".repeat(32);
const IMG = Buffer.from("page bytes").toString("base64");
/** A funded wallet: covers price+bonds+collateral+fee and has pure-ADA ≥ 5. */
const fundedChain = {
queryUtxosByAddress: async () => [
{ ref: { txHash: "ff".repeat(32), index: 0 }, address: "a", lovelace: 20_000_000n, assets: {}, datumHex: null, scriptRef: null },
],
} as unknown as GatewayDeps["chain"];
const OCR_SUPPLIER = {
utxo_ref: `${"cc".repeat(32)}#0`,
supplier_pkh: "s3",
capability_id: "ocr.page.extract.chandra-ocr-2.v1",
model: "chandra-ocr-2",
max_output_tokens: 4096,
price_lovelace: "200000",
supplier_bond_lovelace: "1000000",
buyer_bond_lovelace: "1000000",
endpoint_url: "http://sup",
advert_status: "Active",
status: "free",
};
function makeDeps(fetchFn?: typeof globalThis.fetch, overrides?: { chain?: GatewayDeps["chain"] }): GatewayDeps {
const dbDir = join(tmpdir(), `gw-ocr-test-${randomUUID()}`);
const store = new GatewayStore(dbDir);
const config: GatewayConfig = {
masterKeyHex: MASTER,
indexerUrl: "http://ix",
ogmiosUrl: "http://og",
networkId: 0,
liveChain: true,
port: 0,
dbDir,
signupRate: { max: 1000, windowMs: 60_000 },
keyRate: { max: 1000, windowMs: 60_000 },
demoIpRate: { max: 1000, windowMs: 60_000 },
sweeperIntervalMs: 60_000,
demoSessionIdleMs: 180_000,
chatSettleMode: "full",
walletHealthIntervalMs: 600_000,
sdkRegistryMax: 100,
ocrCapabilityId: "ocr.page.extract.chandra-ocr-2.v1",
corsOrigins: [],
};
const chain = overrides?.chain ?? ({ queryUtxosByAddress: async () => [] } as unknown as GatewayDeps["chain"]);
const registry = new SdkRegistry({
chain,
indexerUrl: config.indexerUrl,
networkId: config.networkId,
masterKeyHex: config.masterKeyHex,
max: config.sdkRegistryMax,
});
const defaultFetch = (async () => new Response("[]", { status: 200 })) as unknown as typeof globalThis.fetch;
return { config, store, chain, registry, fetchFn: fetchFn ?? defaultFetch };
}
/** Insert a key row (demo or funded) and return the raw bearer token. */
function makeKey(deps: GatewayDeps, demo: 0 | 1): string {
const rawKey = `vk-${randomBytes(24).toString("hex")}`;
const privHex = genPrivKeyHex();
const walletKey = deriveWalletKey(privHex, 0);
const sealed = seal(privHex, MASTER);
deps.store.insertKey({
id: randomUUID(),
key_hash: hashApiKey(rawKey),
key_prefix: rawKey.slice(0, 12),
label: demo ? "demo" : "funded",
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,
});
return rawKey;
}
function validBody() {
return { image_b64: IMG, mime: "image/png", output_format: "markdown" };
}
describe("POST /v1/ocr/extract — auth + demo gating", () => {
it("401 without a bearer key, OpenAI error shape", async () => {
const res = await request(createApp(makeDeps()))
.post("/v1/ocr/extract").send(validBody());
expect(res.status).toBe(401);
expect(res.body.error?.type).toBe("authentication_error");
});
it("403 demo_key_unsupported for demo keys", async () => {
const deps = makeDeps();
const rawKey = makeKey(deps, 1);
const res = await request(createApp(deps))
.post("/v1/ocr/extract")
.set("Authorization", `Bearer ${rawKey}`)
.send(validBody());
expect(res.status).toBe(403);
expect(res.body.error?.code).toBe("demo_key_unsupported");
});
});
describe("POST /v1/ocr/extract — body validation", () => {
async function post(body: unknown) {
const deps = makeDeps();
const rawKey = makeKey(deps, 0);
return request(createApp(deps))
.post("/v1/ocr/extract")
.set("Authorization", `Bearer ${rawKey}`)
.send(body as object);
}
it("400 invalid_image on missing/empty/data-URL image_b64", async () => {
for (const image_b64 of [undefined, "", `data:image/png;base64,${IMG}`]) {
const res = await post({ ...validBody(), image_b64 });
expect(res.status).toBe(400);
expect(res.body.error?.code).toBe("invalid_image");
}
});
it("400 invalid_mime on unsupported mime", async () => {
const res = await post({ ...validBody(), mime: "image/gif" });
expect(res.status).toBe(400);
expect(res.body.error?.code).toBe("invalid_mime");
});
it("400 invalid_output_format on unsupported format", async () => {
const res = await post({ ...validBody(), output_format: "yaml" });
expect(res.status).toBe(400);
expect(res.body.error?.code).toBe("invalid_output_format");
});
it("output_format defaults to markdown (reaches supplier lookup)", async () => {
const res = await post({ image_b64: IMG, mime: "image/png" });
// Passes validation, then the empty indexer answer → 404 model_not_found.
expect(res.status).toBe(404);
expect(res.body.error?.code).toBe("model_not_found");
});
});
describe("POST /v1/ocr/extract — supplier lookup + body limit", () => {
it("404 model_not_found when no supplier advertises the capability", async () => {
const deps = makeDeps();
const rawKey = makeKey(deps, 0);
const res = await request(createApp(deps))
.post("/v1/ocr/extract")
.set("Authorization", `Bearer ${rawKey}`)
.send(validBody());
expect(res.status).toBe(404);
expect(res.body.error?.code).toBe("model_not_found");
});
it("queries the indexer with the configured ocr capability id", async () => {
let queried = "";
const fetchFn = (async (url: unknown) => {
queried = String(url);
return new Response("[]", { status: 200 });
}) as unknown as typeof globalThis.fetch;
const deps = makeDeps(fetchFn);
const rawKey = makeKey(deps, 0);
await request(createApp(deps))
.post("/v1/ocr/extract")
.set("Authorization", `Bearer ${rawKey}`)
.send(validBody());
expect(queried).toContain("capability_id=ocr.page.extract.chandra-ocr-2.v1");
});
it("accepts a 2MB body (over the 1mb global limit — OCR route has its own parser)", async () => {
const deps = makeDeps();
const rawKey = makeKey(deps, 0);
const big = "A".repeat(2_000_000);
const res = await request(createApp(deps))
.post("/v1/ocr/extract")
.set("Authorization", `Bearer ${rawKey}`)
.send({ ...validBody(), image_b64: big });
expect(res.status).not.toBe(413);
// Valid base64 + no suppliers → 404 model_not_found.
expect(res.status).toBe(404);
});
it("chat completions keep the 1mb ceiling (413 on a 2MB body)", async () => {
const deps = makeDeps();
const rawKey = makeKey(deps, 0);
const big = "A".repeat(2_000_000);
const res = await request(createApp(deps))
.post("/openai/v1/chat/completions")
.set("Authorization", `Bearer ${rawKey}`)
.send({ model: "qwen", messages: [{ role: "user", content: big }] });
expect(res.status).toBe(413);
});
});
// ─── venueAttribution — pure-function header sanitization (I3) ──────────────
//
// grep for "first-party-brokered" used to return only source lines: nothing
// exercised venueAttribution's regex gates or RECEIPT_LABELS_V0 reaching the
// wire. These two blocks close that gap.
function fakeReq(headers: Record<string, string>): Parameters<typeof venueAttribution>[0] {
return { header: (name: string) => headers[name] } as unknown as Parameters<typeof venueAttribution>[0];
}
describe("venueAttribution — header sanitization", () => {
it("passes through a valid venue slug and a valid lowercase hash", () => {
const hash = "a".repeat(64);
const out = venueAttribution(fakeReq({ "X-Venue": "apify", "X-Run-User-Hash": hash }));
expect(out).toEqual({ venue: "apify", runUserHash: hash });
});
it("lowercases a valid but uppercase hex hash", () => {
const out = venueAttribution(fakeReq({ "X-Venue": "apify", "X-Run-User-Hash": "A".repeat(64) }));
expect(out.runUserHash).toBe("a".repeat(64));
});
it("drops an invalid venue to empty string (uppercase, bad chars, oversized)", () => {
for (const bad of ["Apify", "apify!", "apify_venue", "a".repeat(33)]) {
const out = venueAttribution(fakeReq({ "X-Venue": bad, "X-Run-User-Hash": "a".repeat(64) }));
expect(out.venue).toBe("");
}
});
it("drops an invalid run-user-hash to empty string (wrong length, non-hex)", () => {
for (const bad of ["a".repeat(63), "a".repeat(65), "z".repeat(64), "not-hex-at-all"]) {
const out = venueAttribution(fakeReq({ "X-Venue": "apify", "X-Run-User-Hash": bad }));
expect(out.runUserHash).toBe("");
}
});
it("defaults both fields to empty string when headers are missing", () => {
const out = venueAttribution(fakeReq({}));
expect(out).toEqual({ venue: "", runUserHash: "" });
});
});
// ─── Happy path — receipt disclosure label reaches the response (I3) ────────
describe("POST /v1/ocr/extract — happy path", () => {
it("200 response x_vector.receipt_labels contains first-party-brokered", async () => {
const fetchFn = (async (url: unknown) => {
const u = String(url);
if (u.includes("/suppliers")) {
return new Response(JSON.stringify([OCR_SUPPLIER]), { status: 200 });
}
return new Response("[]", { status: 200 });
}) as unknown as typeof globalThis.fetch;
const deps = makeDeps(fetchFn, { chain: fundedChain });
const rawKey = makeKey(deps, 0);
const keyRow = deps.store.getKeyByHash(hashApiKey(rawKey))!;
// Stub only the chain-touching supplier call; escrow ref shape matches
// what resolveSubmittedRef's mock (module-level, top of file) expects.
const ctx = deps.registry.getContext(keyRow);
const submitOcr = vi.fn(async () => ({
output_format: "markdown",
content: "extracted text",
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
receipt: {
prompt_hash: "1b".repeat(32),
response_hash: "2c".repeat(32),
model: "chandra-ocr-2",
prompt_tokens: 10,
completion_tokens: 5,
wallclock_ms: Date.now(),
supplier_pkh: "s3",
escrow_ref: `${"d".repeat(64)}#0`,
},
receiptSignature: "sig".repeat(20),
escrowRef: { txHash: "d".repeat(64), index: 0 },
}));
(ctx.sdk as { submitOcr: unknown }).submitOcr = submitOcr;
const res = await request(createApp(deps))
.post("/v1/ocr/extract")
.set("Authorization", `Bearer ${rawKey}`)
.send(validBody());
expect(res.status).toBe(200);
expect(submitOcr).toHaveBeenCalledTimes(1);
expect(res.body.x_vector.receipt_labels).toContain("first-party-brokered");
// escrow_ref in the response comes from submitOcr's returned escrowRef
// (the ORIGINAL Open ref), not the mocked resolveSubmittedRef's answer —
// see settle.ts's docstring on why those two refs differ on-chain.
expect(res.body.x_vector.escrow_ref).toBe(`${"d".repeat(64)}#0`);
});
});
// ─── Failure path — escrow ref survives a post-escrow SupplierError (I2) ────
//
// submitOcr posts the escrow, THEN calls the supplier. A SupplierError
// (timeout, network failure, non-2xx, malformed response, receipt mismatch)
// means the escrow already landed on chain even though submitOcr rejects —
// per the OCR route's 120s supplier budget, this is likely the MOST common
// paid-but-failed shape. Before this fix, escrowRefStr was only assigned
// from submitOcr's *return value*, so this exact case recorded escrow_ref:
// null. The route now also listens for the SDK's "escrow_posted" progress
// event around each attempt, so the ref survives regardless of whether
// submitOcr goes on to resolve or reject.
describe("POST /v1/ocr/extract — escrow ref survives a post-escrow SupplierError", () => {
it("records the escrow_ref from the escrow_posted progress event, not null, when submitOcr posts an escrow then rejects", async () => {
const fetchFn = (async (url: unknown) => {
const u = String(url);
if (u.includes("/suppliers")) {
return new Response(JSON.stringify([OCR_SUPPLIER]), { status: 200 });
}
return new Response("[]", { status: 200 });
}) as unknown as typeof globalThis.fetch;
const deps = makeDeps(fetchFn, { chain: fundedChain });
const rawKey = makeKey(deps, 0);
const keyRow = deps.store.getKeyByHash(hashApiKey(rawKey))!;
// Same monkeypatch pattern as the happy-path test above: fetch the real
// per-key Marketplace instance from the registry BEFORE the request so
// its real EventEmitter (on/off/emit) stays live, then replace only
// submitOcr. The stub mirrors what Marketplace.submitOcr actually does
// on this failure shape: emit "escrow_posted" the instant the tx
// confirms, then throw once the supplier call fails.
const ctx = deps.registry.getContext(keyRow);
const knownEscrowRef = `${"9".repeat(64)}#0`;
const submitOcr = vi.fn(async () => {
ctx.sdk.emit("progress", { type: "escrow_posted", escrow_ref: knownEscrowRef });
throw new SupplierError("timeout", { message: "supplier did not respond within budget" });
});
(ctx.sdk as { submitOcr: unknown }).submitOcr = submitOcr;
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const res = await request(createApp(deps))
.post("/v1/ocr/extract")
.set("Authorization", `Bearer ${rawKey}`)
.send(validBody());
// Snapshot the recorded calls BEFORE restoring — mockRestore() also
// clears .mock.calls (it runs mockReset()/mockClear() first), so
// reading logSpy.mock.calls after restore always sees [].
const loggedLines = logSpy.mock.calls.map((args) => String(args[0]));
logSpy.mockRestore();
expect(submitOcr).toHaveBeenCalledTimes(1);
// toGatewayError maps SupplierError("timeout") to 504 supplier_timeout —
// confirms the request actually took the failure path this test targets.
expect(res.status).toBe(504);
expect(res.body.error?.code).toBe("supplier_timeout");
// The reconciliation spine: the usage row recorded in the catch block
// must carry the real ref, not null.
const rows = deps.store.listUsage(keyRow.id, 10);
expect(rows).toHaveLength(1);
expect(rows[0].status).toBe("failed");
expect(rows[0].escrow_ref).toBe(knownEscrowRef);
// The structured ocr_failed log line (wedge-ledger emitter input) must
// carry the same ref.
const failedLine = loggedLines
.filter((line) => line.includes('"evt":"ocr_failed"'))
.map((line) => JSON.parse(line) as { escrow_ref: string | null });
expect(failedLine).toHaveLength(1);
expect(failedLine[0].escrow_ref).toBe(knownEscrowRef);
});
});
// ─── Busy pre-check - never post an escrow to a candidate that is busy ──────
//
// runOcrOneShot now checks each candidate's live /status immediately before
// calling submitOcr for it (mirrors sessions.ts's startChat pre-check):
// "working" means busy and moves on to the next candidate WITHOUT posting an
// escrow; anything else (free, unreachable, malformed, slow) is treated as
// usable. submitOcr is the escrow-posting path in these tests (same
// monkeypatch pattern as the suites above), so asserting its call count is
// exactly how "no escrow was posted" is observed.
const OCR_SUPPLIER_A = {
...OCR_SUPPLIER,
utxo_ref: `${"aa".repeat(32)}#0`,
supplier_pkh: "sA",
endpoint_url: "http://sup-a",
};
const OCR_SUPPLIER_B = {
...OCR_SUPPLIER,
utxo_ref: `${"bb".repeat(32)}#0`,
supplier_pkh: "sB",
endpoint_url: "http://sup-b",
};
/** Indexer /suppliers lookup plus a per-endpoint /status responder, on one
* fetchFn - everything else falls back to the suite's usual empty array. */
function makeFetchWithStatus(
suppliers: Array<typeof OCR_SUPPLIER>,
statusByEndpoint: Record<string, () => Response>,
): typeof globalThis.fetch {
return (async (url: unknown) => {
const u = String(url);
if (u.includes("/suppliers")) {
return new Response(JSON.stringify(suppliers), { status: 200 });
}
for (const [endpoint, respond] of Object.entries(statusByEndpoint)) {
if (u === `${endpoint}/status`) return respond();
}
return new Response("[]", { status: 200 });
}) as unknown as typeof globalThis.fetch;
}
/** Same successful submitOcr shape as the happy-path suite above, reused
* across the busy pre-check tests. */
function makeSubmitOcrStub() {
return vi.fn(async (_opts: { advertRef: { txHash: string; index: number } }) => ({
output_format: "markdown",
content: "extracted text",
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
receipt: {
prompt_hash: "1b".repeat(32),
response_hash: "2c".repeat(32),
model: "chandra-ocr-2",
prompt_tokens: 10,
completion_tokens: 5,
wallclock_ms: Date.now(),
supplier_pkh: "s3",
escrow_ref: `${"d".repeat(64)}#0`,
},
receiptSignature: "sig".repeat(20),
escrowRef: { txHash: "d".repeat(64), index: 0 },
}));
}
describe("POST /v1/ocr/extract - busy pre-check", () => {
it("a busy supplier is never sent an escrow; the route returns the busy response", async () => {
const fetchFn = makeFetchWithStatus([OCR_SUPPLIER], {
"http://sup": () => new Response(JSON.stringify({ status: "working" }), { status: 200 }),
});
const deps = makeDeps(fetchFn, { chain: fundedChain });
const rawKey = makeKey(deps, 0);
const keyRow = deps.store.getKeyByHash(hashApiKey(rawKey))!;
const ctx = deps.registry.getContext(keyRow);
const submitOcr = makeSubmitOcrStub();
(ctx.sdk as { submitOcr: unknown }).submitOcr = submitOcr;
const res = await request(createApp(deps))
.post("/v1/ocr/extract")
.set("Authorization", `Bearer ${rawKey}`)
.send(validBody());
// The escrow-posting path was never invoked.
expect(submitOcr).not.toHaveBeenCalled();
// toGatewayError maps SupplierError("supplier_busy") to 503 overloaded -
// the same busy response a post-escrow 409 would have produced.
expect(res.status).toBe(503);
expect(res.body.error?.code).toBe("overloaded");
const rows = deps.store.listUsage(keyRow.id, 10);
expect(rows).toHaveLength(1);
expect(rows[0].status).toBe("failed");
expect(rows[0].escrow_ref).toBeNull();
});
it("a free supplier proceeds to post and settle exactly as before", async () => {
const fetchFn = makeFetchWithStatus([OCR_SUPPLIER], {
"http://sup": () => new Response(JSON.stringify({ status: "free" }), { status: 200 }),
});
const deps = makeDeps(fetchFn, { chain: fundedChain });
const rawKey = makeKey(deps, 0);
const keyRow = deps.store.getKeyByHash(hashApiKey(rawKey))!;
const ctx = deps.registry.getContext(keyRow);
const submitOcr = makeSubmitOcrStub();
(ctx.sdk as { submitOcr: unknown }).submitOcr = submitOcr;
const res = await request(createApp(deps))
.post("/v1/ocr/extract")
.set("Authorization", `Bearer ${rawKey}`)
.send(validBody());
expect(submitOcr).toHaveBeenCalledTimes(1);
expect(res.status).toBe(200);
expect(res.body.x_vector.receipt_labels).toContain("first-party-brokered");
});
it("falls back to the second candidate when the first is busy, posting exactly one escrow", async () => {
const fetchFn = makeFetchWithStatus([OCR_SUPPLIER_A, OCR_SUPPLIER_B], {
"http://sup-a": () => new Response(JSON.stringify({ status: "working" }), { status: 200 }),
"http://sup-b": () => new Response(JSON.stringify({ status: "free" }), { status: 200 }),
});
const deps = makeDeps(fetchFn, { chain: fundedChain });
const rawKey = makeKey(deps, 0);
const keyRow = deps.store.getKeyByHash(hashApiKey(rawKey))!;
const ctx = deps.registry.getContext(keyRow);
const submitOcr = makeSubmitOcrStub();
(ctx.sdk as { submitOcr: unknown }).submitOcr = submitOcr;
const res = await request(createApp(deps))
.post("/v1/ocr/extract")
.set("Authorization", `Bearer ${rawKey}`)
.send(validBody());
expect(res.status).toBe(200);
// Exactly one escrow posted, and it is the second (free) candidate's -
// the busy first candidate was skipped, not retried after failing.
expect(submitOcr).toHaveBeenCalledTimes(1);
expect(submitOcr.mock.calls[0][0].advertRef).toEqual({ txHash: "bb".repeat(32), index: 0 });
});
it("a status check that errors does not block the attempt", async () => {
const fetchFn = makeFetchWithStatus([OCR_SUPPLIER], {
"http://sup": () => {
// Simulates both a network error and an aborted (timed-out) request -
// isSupplierBusy's catch block treats every failure mode the same
// way: unknown, not busy, proceed. A real AbortSignal timeout is not
// exercised here to keep the suite fast; the outcome is identical.
throw new Error("simulated network failure reaching /status");
},
});
const deps = makeDeps(fetchFn, { chain: fundedChain });
const rawKey = makeKey(deps, 0);
const keyRow = deps.store.getKeyByHash(hashApiKey(rawKey))!;
const ctx = deps.registry.getContext(keyRow);
const submitOcr = makeSubmitOcrStub();
(ctx.sdk as { submitOcr: unknown }).submitOcr = submitOcr;
const res = await request(createApp(deps))
.post("/v1/ocr/extract")
.set("Authorization", `Bearer ${rawKey}`)
.send(validBody());
// The candidate was still tried despite the failed pre-check.
expect(submitOcr).toHaveBeenCalledTimes(1);
expect(res.status).toBe(200);
});
});

View File

@@ -45,6 +45,7 @@ function makeSweepDeps(opts: { chatSettleMode: "full" | "ticket"; escrows: Escro
chatSettleMode: opts.chatSettleMode,
walletHealthIntervalMs: 600_000,
sdkRegistryMax: 100,
ocrCapabilityId: "ocr.page.extract.chandra-ocr-2.v1",
corsOrigins: [],
};
const chain = { queryUtxo: async () => null } as unknown as GatewayDeps["chain"];

View File

@@ -70,6 +70,28 @@ describe("routing/selectSupplier", () => {
expect(got[0].priceLovelace).toBe(2n);
});
it("pins routing to an exact supplier when requested", async () => {
const got = await selectCandidates({
indexerUrl: "http://ix",
model: "m",
capabilityId: "llm.text.generate.v1",
supplierPkh: "s2",
fetchFn: jsonResponse(rows),
});
expect(got.map((candidate) => candidate.supplierPkh)).toEqual(["s2"]);
});
it("orders the preferred supplier first and keeps fallback candidates", async () => {
const got = await selectCandidates({
indexerUrl: "http://ix",
model: "m",
capabilityId: "llm.text.generate.v1",
preferredSupplierPkh: "s2",
fetchFn: jsonResponse(rows),
});
expect(got.map((candidate) => candidate.supplierPkh)).toEqual(["s2", "s1"]);
});
it("routes chat.v1 separately", async () => {
const got = await selectCandidates({
indexerUrl: "http://ix",
@@ -118,6 +140,20 @@ describe("openai/validate", () => {
expect(p.maxTokens).toBe(50);
expect(p.stream).toBe(false);
});
it("accepts and validates a Vector supplier pin", () => {
const supplierPkh = "a".repeat(56);
const parsed = parseChatRequest({
model: "m",
messages: [{ role: "user", content: "hi" }],
x_vector: { supplier_pkh: supplierPkh },
});
expect(parsed.supplierPkh).toBe(supplierPkh);
expect(() => parseChatRequest({
model: "m",
messages: [{ role: "user", content: "hi" }],
x_vector: { supplier_pkh: "bad" },
})).toThrow(GatewayError);
});
it("rejects tools/functions", () => {
expect(() => parseChatRequest({ model: "m", messages: [{ role: "user", content: "x" }], tools: [] })).toThrow(GatewayError);
});
@@ -208,8 +244,16 @@ describe("onchain/preflight", () => {
});
it("preflight requires balance AND collateral", async () => {
const cost = { priceLovelace: 2_000_000n, buyerBondLovelace: 1_000_000n, supplierBondLovelace: 1_000_000n };
// required = 2 + 1 + 1 + 5 (collateral) + 2 (fee) = 11 ADA
const cost = {
capabilityId: "llm.text.generate.v1",
priceLovelace: 2_000_000n,
buyerBondLovelace: 1_000_000n,
supplierBondLovelace: 1_000_000n,
};
// required = escrowLockFloor(2 + 1 + 1) + 5 (collateral) + 2 (fee) = 11 ADA
// — at this economic total (4 ADA) the Submitted-state min-ada floor for a
// short capability id (~2.2 ADA) stays well under the raw total, so the
// floor is a no-op here; tx-minada-floor.test.ts covers the lifted case.
const fakeChain = { queryUtxosByAddress: async () => [pure(20_000_000n)] } as any;
const ok = await preflight(fakeChain, "a", cost);
expect(ok.ok).toBe(true);

View File

@@ -29,6 +29,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 indexer/package.json ./indexer/package.json
COPY indexer-ui/package.json ./indexer-ui/package.json

View File

@@ -28,6 +28,8 @@ export interface PostAdvertParams {
walletKey: WalletKey;
advertDatum: AdvertDatum;
deposit_lovelace: bigint;
/** Durable journal hook invoked with signed CBOR before broadcast. */
beforeSubmit?: (built: PostAdvertBuildResult) => Promise<void>;
}
export async function buildPostAdvertTx(
@@ -75,6 +77,7 @@ export async function buildPostAdvertTx(
walletKey,
advertDatum,
deposit_lovelace,
beforeSubmit: params.beforeSubmit,
});
}
@@ -102,13 +105,15 @@ export async function buildPostAdvertTx(
const txCborHex = encodeTxBody(body);
const expectedTxHash = sha256Hex(txCborHex);
// Submit so the mock seeds the new UTxO + spends inputs (none here).
await chain.submitTx(txCborHex);
return {
const built: PostAdvertBuildResult = {
txCborHex,
expectedTxHash,
advertOutputRef: { txHash: expectedTxHash, index: 0 },
};
await params.beforeSubmit?.(built);
// Submit so the mock seeds the new UTxO + spends inputs (none here).
await chain.submitTx(txCborHex);
return built;
}

View File

@@ -0,0 +1,257 @@
/**
* tx/escrow/postOcrEscrow.ts — PostEscrow tx builder for model-scoped OCR
* capabilities (`ocr.page.extract.<model-slug>.v1`, one page per job).
*
* Mirrors `postTtsEscrow.ts` but commits an OCR-shaped prompt_hash over the
* canonicalised request envelope `{image_b64, mime, output_format}` — the
* SAME object the supplier hashes when validating the incoming
* POST /v1/ocr/extract body. Any mismatch on either side fails the
* supplier-side prompt_mismatch gate.
*
* The image travels off-chain (buyer → supplier HTTP body); the chain only
* ever carries prompt_hash. Never place image bytes or extracted text in a
* datum.
*
* Off-chain invariants (mirror TTS's, with OCR-shaped body validation):
* 1. advert UTxO exists at advertRef
* 2. advert datum.status === "Active"
* 3. payment_lovelace === advert.price_lovelace
* 4. buyerKey.pubKeyHash !== advert.supplier_pkh
* 5. body.image_b64 is a non-empty plain-base64 string within size bounds
* (no data-URL prefix — the mime rides in its own field)
* 6. body.mime ∈ ALLOWED_OCR_MIMES
* 7. body.output_format ∈ ALLOWED_OCR_OUTPUT_FORMATS
*/
import * as nodeCrypto from "crypto";
import type { ChainProvider, OutputReference } from "../../chain/ChainProvider.js";
import type { EscrowDatum } from "../../cbor/types.js";
import { decodeAdvertDatum } from "../../cbor/AdvertDatum.js";
import { encodeEscrowDatum } from "../../cbor/EscrowDatum.js";
import { canonicalize } from "../../cbor/canonical.js";
import type { WalletKey, PostEscrowBuildResult } from "../types.js";
import { TxConstructionError } from "../types.js";
import { loadBlueprint } from "../blueprint.js";
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js";
import { detectCborBackend } from "../internal/cborBackend.js";
import { escrowLockFloor } from "../internal/minAdaFloor.js";
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
/** OCR request envelope. The buyer-side hash and the supplier-side hash
* MUST agree on this exact set of keys, in this exact JCS-canonical
* encoding, or the supplier will reject with `prompt_mismatch`. */
export interface OcrRequest {
/** Plain base64 of the page image bytes. No `data:` prefix. */
image_b64: string;
/** Image content type. */
mime: string;
/** Requested extraction output shape. */
output_format: string;
}
export const ALLOWED_OCR_MIMES = new Set([
"image/png", "image/jpeg", "image/webp",
]);
export const ALLOWED_OCR_OUTPUT_FORMATS = new Set(["markdown", "html", "json"]);
/**
* Upper bound on the base64 payload, in characters (~9 MB of image bytes).
* A 300-DPI page scan lands around 0.5–3 MB binary; this cap protects the
* supplier's JSON body parser and the off-chain transport, not the chain
* (the chain only sees the 32-byte hash).
*/
export const MAX_OCR_IMAGE_B64_CHARS = 12_000_000;
/** Plain base64 (standard alphabet, optional padding). Rejects data URLs,
* whitespace and base64url — one canonical encoding, one hash. */
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
export interface PostOcrEscrowParams {
chain: ChainProvider;
buyerKey: WalletKey;
advertRef: OutputReference;
request: OcrRequest;
payment_lovelace: bigint;
}
function sha256Utf8Hex(s: string): string {
return nodeCrypto.createHash("sha256").update(s, "utf8").digest("hex");
}
/** Hash function shared between buyer (here) and supplier (server.ts).
* Exported so unit tests on either side can pin the canonicalisation. */
export function ocrPromptHash(req: OcrRequest): string {
return sha256Utf8Hex(canonicalize({
image_b64: req.image_b64,
mime: req.mime,
output_format: req.output_format,
}));
}
/** Body validation shared conceptually with the supplier route. Throws
* TxConstructionError with a machine-readable reason. */
export function validateOcrRequest(request: OcrRequest): void {
if (typeof request?.image_b64 !== "string" || request.image_b64.length === 0) {
throw new TxConstructionError("image required",
"request.image_b64 must be a non-empty base64 string");
}
if (request.image_b64.length > MAX_OCR_IMAGE_B64_CHARS) {
throw new TxConstructionError("image too large",
`request.image_b64 exceeds ${MAX_OCR_IMAGE_B64_CHARS} chars`);
}
if (!BASE64_RE.test(request.image_b64)) {
throw new TxConstructionError("image not base64",
"request.image_b64 must be plain base64 (no data: prefix, no whitespace)");
}
if (!ALLOWED_OCR_MIMES.has(request.mime)) {
throw new TxConstructionError("mime invalid",
`mime must be one of: ${[...ALLOWED_OCR_MIMES].join(", ")}`);
}
if (!ALLOWED_OCR_OUTPUT_FORMATS.has(request.output_format)) {
throw new TxConstructionError("output_format invalid",
`output_format must be one of: ${[...ALLOWED_OCR_OUTPUT_FORMATS].join(", ")}`);
}
}
export async function buildPostOcrEscrowTx(
params: PostOcrEscrowParams,
): Promise<PostEscrowBuildResult> {
const { chain, buyerKey, advertRef, request, payment_lovelace } = params;
// 5–7. Body validation before any chain access.
validateOcrRequest(request);
// 1. Advert UTxO must exist.
const advertUtxo = await chain.queryUtxo(advertRef);
if (advertUtxo === null) {
throw new TxConstructionError(
"advert ref not on chain",
`no UTxO at ${advertRef.txHash}#${advertRef.index}`,
);
}
if (!advertUtxo.datumHex) {
throw new TxConstructionError(
"advert datum missing",
`UTxO ${advertRef.txHash}#${advertRef.index} has no inline datum`,
);
}
const advertDatum = decodeAdvertDatum(advertUtxo.datumHex);
// 2. Advert must be Active.
if (advertDatum.status !== "Active") {
throw new TxConstructionError(
"advert is retired",
`advert.status is ${advertDatum.status}, expected Active`,
);
}
// 3. Payment must equal advertised price.
if (payment_lovelace !== advertDatum.price_lovelace) {
throw new TxConstructionError(
"payment must equal advertised price",
`payment ${payment_lovelace} != advert.price ${advertDatum.price_lovelace}`,
);
}
// 4. Buyer cannot be supplier.
if (buyerKey.pubKeyHash === advertDatum.supplier_pkh) {
throw new TxConstructionError(
"buyer cannot be supplier",
`buyerKey pkh ${buyerKey.pubKeyHash} equals advert.supplier_pkh`,
);
}
// Time source: same convention as chat/TTS.
const tipSlot = await chain.tip();
const isLive = detectCborBackend(chain) === "live";
const postedAt = isLive ? Date.now() : mockSlotToWallclockMs(tipSlot);
const deliverBy = postedAt + advertDatum.max_processing_ms + NETWORK_BUFFER_MS;
const requestSpecCanonical = canonicalize({
capability_id: advertDatum.capability_id,
max_output_tokens: advertDatum.max_output_tokens,
model: advertDatum.model,
});
const requestSpecHash = sha256Utf8Hex(requestSpecCanonical);
const promptHash = ocrPromptHash(request);
const economicTotal =
advertDatum.price_lovelace +
advertDatum.buyer_bond_lovelace +
advertDatum.supplier_bond_lovelace;
const escrowDatum: EscrowDatum = {
buyer_pkh: buyerKey.pubKeyHash,
supplier_pkh: advertDatum.supplier_pkh,
advert_ref: advertRef,
capability_id: advertDatum.capability_id,
request_spec_hash: requestSpecHash,
prompt_hash: promptHash,
payment_lovelace: advertDatum.price_lovelace,
buyer_bond_lovelace: advertDatum.buyer_bond_lovelace,
supplier_bond_lovelace: advertDatum.supplier_bond_lovelace,
deliver_by: deliverBy,
posted_at: postedAt,
submitted_at: null,
result_receipt_hash: null,
state: "Open",
};
// Lock enough that the escrow output satisfies min-ada in its LARGEST
// future state (Submitted) - value_equal on Claim/Submit forbids any
// later bump (2026-08-07 min-ada incident).
const totalLocked = escrowLockFloor(escrowDatum, economicTotal);
// Live path: reuse the capability-agnostic live builder (messages is
// void-marked there; the on-chain commitment is the escrowDatum, which
// carries our OCR prompt_hash).
if (isLive) {
const liveCborPath = "../internal/liveCbor.js";
const { buildLiveTxForEscrow } = await import(/* @vite-ignore */ liveCborPath);
return buildLiveTxForEscrow({
chain: chain as LiveOgmiosProvider,
buyerKey,
advertRef,
messages: [],
escrowDatum,
totalLocked,
deliverBy,
postedAt,
});
}
// Mock backend path (synthetic JSON-in-hex tx body).
const blueprint = loadBlueprint();
const escrowAddress = blueprint.escrowScriptAddress(0);
const body = {
type: "post-escrow",
inputs: [],
outputs: [
{
ref: { txHash: "$self", index: 0 },
address: escrowAddress,
lovelace: totalLocked,
assets: {},
datumHex: encodeEscrowDatum(escrowDatum),
scriptRef: null,
},
],
requiredSigners: [buyerKey.pubKeyHash],
validityRange: { lowerBoundMs: postedAt, upperBoundMs: deliverBy },
meta: { script_hash: blueprint.escrowScriptHash, advert_ref: advertRef },
};
const txCborHex = encodeTxBody(body);
const expectedTxHash = sha256Hex(txCborHex);
await chain.submitTx(txCborHex);
return {
txCborHex,
expectedTxHash,
escrowOutputRef: { txHash: expectedTxHash, index: 0 },
};
}

View File

@@ -17,6 +17,8 @@ import type { WalletKey, BuildResult } from "../types.js";
import { TxConstructionError } from "../types.js";
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
import { ACCEPT_WINDOW_MS, mockSlotToWallclockMs } from "../internal/constants.js";
import { detectCborBackend } from "../internal/cborBackend.js";
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
export interface ReleaseParams {
chain: ChainProvider;
@@ -65,8 +67,10 @@ export async function buildReleaseTx(
}
// 4. Tip must be at or past submitted_at + ACCEPT_WINDOW.
// Live backend uses real POSIX ms; mock backend keeps slot*1000.
const tipSlot = await chain.tip();
const tipMs = mockSlotToWallclockMs(tipSlot);
const isLive = detectCborBackend(chain) === "live";
const tipMs = isLive ? Date.now() : mockSlotToWallclockMs(tipSlot);
const threshold = datum.submitted_at + ACCEPT_WINDOW_MS;
if (tipMs < threshold) {
throw new TxConstructionError(
@@ -75,6 +79,21 @@ export async function buildReleaseTx(
);
}
// Live path: real Cardano CBOR via lucid-evolution.
if (isLive) {
const liveCborPath = "../internal/liveCbor.js";
const { buildLiveTxForRelease } = await import(/* @vite-ignore */ liveCborPath);
return buildLiveTxForRelease({
chain: chain as LiveOgmiosProvider,
supplierKey,
escrowRef,
escrowUtxo: utxo,
datum,
tipMs,
});
}
// Mock path: synthetic testTxBody (preserves existing test behaviour).
const supplierDue =
datum.payment_lovelace + datum.supplier_bond_lovelace + datum.buyer_bond_lovelace;

View File

@@ -26,6 +26,8 @@ export interface SubmitParams {
supplierKey: WalletKey;
escrowRef: OutputReference;
receiptHash: string;
/** Durable journal hook invoked with signed CBOR before broadcast. */
beforeSubmit?: (built: BuildResult) => Promise<void>;
}
function isHex32(s: string): boolean {
@@ -113,6 +115,7 @@ export async function buildSubmitTx(
deliverBy: datum.deliver_by,
tipMs,
receiptHash: receiptHash.toLowerCase(),
beforeSubmit: params.beforeSubmit,
});
}
@@ -138,6 +141,7 @@ export async function buildSubmitTx(
const txCborHex = encodeTxBody(body);
const expectedTxHash = sha256Hex(txCborHex);
await params.beforeSubmit?.({ txCborHex, expectedTxHash });
await chain.submitTx(txCborHex);
return { txCborHex, expectedTxHash };

View File

@@ -5,8 +5,13 @@
export type { ChatMessage, ToolCall, WalletKey, BuildResult, PostAdvertBuildResult, PostEscrowBuildResult } from "./types.js";
export { TxConstructionError } from "./types.js";
export { normalizeChatMessage, chatMessagesEquivalent, toolCallsEqual } from "./chatMessage.js";
export {
BOUNDED_INPUT_DETAIL_MARKER,
chatInputTokenUpperBound,
} from "./inputBound.js";
export { mockSlotToWallclockMs, mockWallclockMsToSlot, NETWORK_BUFFER_MS } from "./internal/constants.js";
export { detectCborBackend, type CborBackend } from "./internal/cborBackend.js";
export { escrowLockFloor, minAdaForEscrowDatum } from "./internal/minAdaFloor.js";
export type { Signer } from "./signer.js";
export { MockSigner } from "./signer.js";
export type { Blueprint } from "./blueprint.js";
@@ -37,6 +42,15 @@ export {
} from "./escrow/postTtsEscrow.js";
export type { PostChatEscrowParams } from "./escrow/postChatEscrow.js";
export { buildPostChatEscrowTx, chatSessionPromptHash } from "./escrow/postChatEscrow.js";
export type { PostOcrEscrowParams, OcrRequest } from "./escrow/postOcrEscrow.js";
export {
buildPostOcrEscrowTx,
ocrPromptHash,
validateOcrRequest,
ALLOWED_OCR_MIMES,
ALLOWED_OCR_OUTPUT_FORMATS,
MAX_OCR_IMAGE_B64_CHARS,
} from "./escrow/postOcrEscrow.js";
export type { ClaimParams } from "./escrow/claim.js";
export { buildClaimTx } from "./escrow/claim.js";
export type { SubmitParams } from "./escrow/submit.js";

View File

@@ -0,0 +1,26 @@
import type { ChatMessage } from "./types.js";
import { canonicalize } from "../cbor/canonical.js";
/** Advert detail-URI marker for suppliers that require live input-cap preflight. */
export const BOUNDED_INPUT_DETAIL_MARKER = "#vector-bounded-input-v1";
const CHAT_MESSAGE_OVERHEAD = 8;
const CHAT_REPLY_PRIMER = 3;
const encoder = new TextEncoder();
/**
* Conservative, tokenizer-independent upper bound for text chat input.
*
* Every UTF-8 byte counts as one token unit. The fixed per-message and reply
* overhead covers role separators and the provider chat template. Major
* byte-fallback tokenizers cannot emit more content tokens than input bytes.
*/
export function chatInputTokenUpperBound(
messages: readonly ChatMessage[],
): number {
return (
CHAT_REPLY_PRIMER +
CHAT_MESSAGE_OVERHEAD * messages.length +
encoder.encode(canonicalize(messages)).byteLength
);
}

View File

@@ -10,8 +10,8 @@
* for vkh enterprise). Input UTxOs may carry testnet-prefixed addresses — lucid
* does not validate address network for inputs (only for outputs).
*
* Deferred builders (postAdvert, updateAdvert, retireAdvert, reclaim, release)
* are NOT touched in M1-F-4. Those continue using the synthetic testTxBody path.
* Deferred builders: only updateAdvert remains on the synthetic testTxBody
* path. postAdvert, retireAdvert, reclaim, and release all have live builders.
*
* Catherine M1-F-4-green.
*/
@@ -61,6 +61,7 @@ const REDEEMER_CLAIM = 121;
const REDEEMER_SUBMIT = 122;
const REDEEMER_ACCEPT = 123;
const REDEEMER_RECLAIM = 124;
const REDEEMER_RELEASE = 125;
// AdvertRedeemer Constr indices per lib/marketplace/types.ak:
// PostAdvert → Constr0 (tag 121, creation event — never seen by spend)
@@ -100,6 +101,7 @@ export interface LiveSubmitParams {
deliverBy: number;
tipMs: number;
receiptHash: string;
beforeSubmit?: (built: BuildResult) => Promise<void>;
}
export interface LiveAcceptParams {
@@ -121,11 +123,21 @@ export interface LiveReclaimParams {
tipMs: number;
}
export interface LiveReleaseParams {
chain: LiveOgmiosProvider;
supplierKey: WalletKey;
escrowRef: OutputReference;
escrowUtxo: Utxo;
datum: EscrowDatum;
tipMs: number;
}
export interface LivePostAdvertParams {
chain: LiveOgmiosProvider;
walletKey: WalletKey;
advertDatum: AdvertDatum;
deposit_lovelace: bigint;
beforeSubmit?: (built: PostAdvertBuildResult) => Promise<void>;
}
export interface LiveRetireAdvertParams {
@@ -489,6 +501,7 @@ async function buildSpendOpenOrClaimedTx(opts: {
validFromMs: number;
validToMs: number;
signerPkh: string;
beforeSubmit?: (built: BuildResult) => Promise<void>;
}): Promise<BuildResult> {
const provider = buildLucidProvider(opts.chain);
const { lucid } = await createLucidContext(provider, opts.walletKey, {
@@ -575,6 +588,7 @@ async function buildSpendOpenOrClaimedTx(opts: {
const txCborHex = signed.toCBOR();
const expectedTxHash = signed.toHash();
await opts.beforeSubmit?.({ txCborHex, expectedTxHash });
await opts.chain.submitTx(txCborHex);
@@ -640,6 +654,7 @@ export async function buildLiveTxForSubmit(params: LiveSubmitParams): Promise<Bu
validFromMs: tipMs,
validToMs: stampMs,
signerPkh: params.supplierKey.pubKeyHash,
beforeSubmit: params.beforeSubmit,
});
}
@@ -778,6 +793,77 @@ export async function buildLiveTxForReclaim(params: LiveReclaimParams): Promise<
return { txCborHex, expectedTxHash };
}
// ─── Release ─────────────────────────────────────────────────────────
export async function buildLiveTxForRelease(params: LiveReleaseParams): Promise<BuildResult> {
const provider = buildLucidProvider(params.chain);
const { lucid } = await createLucidContext(provider, params.supplierKey, {
networkId: 1,
systemStartUnix: 0,
slotLengthMs: 1000,
}, { usePresetProtocolParameters: true });
const { script, address: escrowAddress } = loadEscrowScript();
const spendCtx = await resolveSpendCtx(lucid, "ESCROW_REF_UTXO", script);
const lucidInput = chainUtxoToLucidScriptInput(params.escrowUtxo, escrowAddress);
const releaseRedeemerHex = encodeNullaryConstrHex(REDEEMER_RELEASE);
if (params.datum.submitted_at === null) {
throw new TxConstructionError(
"submitted_at missing",
"Submitted-state escrow must carry a submitted_at timestamp",
);
}
// Release pays everything to the supplier: payment + supplier_bond + buyer_bond.
const supplierDue =
params.datum.payment_lovelace +
params.datum.supplier_bond_lovelace +
params.datum.buyer_bond_lovelace;
const supplierLovelace = supplierDue < MIN_UTXO_LOVELACE ? MIN_UTXO_LOVELACE : supplierDue;
const supplierAddress = pkhToEnterpriseAddress(params.datum.supplier_pkh, MAINNET_ID);
const realWalletUtxos = await lucid.wallet().getUtxos();
assertCollateralCandidate(realWalletUtxos);
const presetWalletInputs = realWalletUtxos;
// escrow.ak:handle_release checks `lower_bound >= submitted_at + ACCEPT_WINDOW`.
// Clamp validFrom to that threshold: the Reclaim-style tip-60s back-pad alone
// would violate the validator whenever the tip is less than 60s past the window.
const threshold = params.datum.submitted_at + ACCEPT_WINDOW_MS;
const validFromMs = Math.max(threshold, params.tipMs - 60_000);
let signed;
try {
const txBuilder = (
spendCtx.kind === "ref"
? lucid.newTx().readFrom([spendCtx.refUtxo])
: lucid.newTx().attach.SpendingValidator(spendCtx.script)
)
.collectFrom([lucidInput], releaseRedeemerHex)
.pay.ToAddress(supplierAddress, { lovelace: supplierLovelace })
.addSignerKey(params.supplierKey.pubKeyHash)
.validFrom(validFromMs)
.validTo(params.tipMs + 60_000)
.setMinFee(500_000n);
// Same UPLC eval delegation as Accept/Reclaim — lucid's CML evaluator
// falsely rejects valid Plutus V3 spends; route to Ogmios.
const completed = await txBuilder.complete({
presetWalletInputs,
localUPLCEval: false,
});
signed = await completed.sign.withWallet().complete();
} catch (err) {
rethrowAsTxError(err, "release build failed");
}
const txCborHex = signed.toCBOR();
const expectedTxHash = signed.toHash();
await params.chain.submitTx(txCborHex);
return { txCborHex, expectedTxHash };
}
// ─── PostAdvert ──────────────────────────────────────────────────────
export async function buildLiveTxForAdvert(
@@ -829,14 +915,16 @@ export async function buildLiveTxForAdvert(
const txCborHex = signed.toCBOR();
const expectedTxHash = signed.toHash();
await chain.submitTx(txCborHex);
return {
const built: PostAdvertBuildResult = {
txCborHex,
expectedTxHash,
advertOutputRef: { txHash: expectedTxHash, index: 0 },
};
await params.beforeSubmit?.(built);
await chain.submitTx(txCborHex);
return built;
}
// ─── RetireAdvert ────────────────────────────────────────────────────

View File

@@ -45,3 +45,16 @@ export type {
WithdrawWalletBuildResult,
} from "./wallet/withdrawWallet.js";
export { buildWithdrawTx } from "./wallet/withdrawWallet.js";
// ── Surplus Merkle-batch anchor (live-only) ────────────────────────────
export type {
AnchorMetadataParams,
SurplusSaleProofAnchorMetadata,
} from "./wallet/anchorMetadata.js";
export {
MAX_TRANSACTION_METADATA_TEXT_BYTES,
SURPLUS_SALE_PROOF_METADATA_LABEL,
SURPLUS_SALE_PROOF_PROTOCOL,
buildAnchorMetadataTx,
validateSurplusSaleProofAnchorMetadata,
} from "./wallet/anchorMetadata.js";

View File

@@ -0,0 +1,166 @@
/**
* Builds and submits the live wallet transaction that anchors one Surplus sale
* Merkle batch. The caller remains responsible for awaiting confirmation.
*/
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
import { OgmiosLucidProvider } from "../../chain/OgmiosLucidProvider.js";
import { createLucidContext } from "../internal/lucidContext.js";
import type { BuildResult, WalletKey } from "../types.js";
import { TxConstructionError } from "../types.js";
export const SURPLUS_SALE_PROOF_METADATA_LABEL = 674;
export const SURPLUS_SALE_PROOF_PROTOCOL = "surplus-sale-proof-v1";
export const MAX_TRANSACTION_METADATA_TEXT_BYTES = 64;
export interface SurplusSaleProofAnchorMetadata {
p: typeof SURPLUS_SALE_PROOF_PROTOCOL;
root: string;
count: number;
first: string;
last: string;
}
export interface AnchorMetadataParams {
chain: LiveOgmiosProvider;
walletKey: WalletKey;
metadata: SurplusSaleProofAnchorMetadata;
}
const ROOT_HEX_RE = /^[0-9a-f]{64}$/;
const PRINTABLE_ASCII_RE = /^[\x20-\x7e]+$/;
const METADATA_KEYS = ["p", "root", "count", "first", "last"] as const;
const textEncoder = new TextEncoder();
function metadataTextFits(value: string): boolean {
return textEncoder.encode(value).byteLength <= MAX_TRANSACTION_METADATA_TEXT_BYTES;
}
function validSaleId(value: unknown): value is string {
return (
typeof value === "string" &&
PRINTABLE_ASCII_RE.test(value) &&
metadataTextFits(value)
);
}
/** Validate the exact label-674 payload before Lucid or Ogmios is initialized. */
export function validateSurplusSaleProofAnchorMetadata(
value: unknown,
): asserts value is SurplusSaleProofAnchorMetadata {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new TxConstructionError(
"anchor_metadata_invalid",
"Surplus sale proof metadata must be an object",
);
}
const metadata = value as Record<string, unknown>;
const keys = Object.keys(metadata);
if (
keys.length !== METADATA_KEYS.length ||
METADATA_KEYS.some((key) => !Object.prototype.hasOwnProperty.call(metadata, key))
) {
throw new TxConstructionError(
"anchor_metadata_invalid",
"Surplus sale proof metadata must contain only p, root, count, first, and last",
);
}
if (metadata.p !== SURPLUS_SALE_PROOF_PROTOCOL) {
throw new TxConstructionError(
"anchor_metadata_invalid",
`metadata protocol must be ${SURPLUS_SALE_PROOF_PROTOCOL}`,
);
}
if (!metadataTextFits(SURPLUS_SALE_PROOF_PROTOCOL)) {
throw new TxConstructionError(
"anchor_metadata_oversized",
"metadata protocol exceeds the 64-byte transaction metadata text limit",
);
}
if (typeof metadata.root !== "string" || !ROOT_HEX_RE.test(metadata.root)) {
throw new TxConstructionError(
"anchor_metadata_invalid",
"metadata root must be 64 lowercase hexadecimal characters",
);
}
if (!metadataTextFits(metadata.root)) {
throw new TxConstructionError(
"anchor_metadata_oversized",
"metadata root exceeds the 64-byte transaction metadata text limit",
);
}
if (
typeof metadata.count !== "number" ||
!Number.isSafeInteger(metadata.count) ||
metadata.count <= 0
) {
throw new TxConstructionError(
"anchor_metadata_invalid",
"metadata count must be a positive safe integer",
);
}
for (const field of ["first", "last"] as const) {
const id = metadata[field];
if (!validSaleId(id)) {
const oversized =
typeof id === "string" &&
textEncoder.encode(id).byteLength > MAX_TRANSACTION_METADATA_TEXT_BYTES;
throw new TxConstructionError(
oversized ? "anchor_metadata_oversized" : "anchor_metadata_invalid",
`metadata ${field} must be a non-empty printable ASCII sale ID of at most 64 bytes`,
);
}
}
}
export async function buildAnchorMetadataTx(
params: AnchorMetadataParams,
): Promise<BuildResult> {
const { chain, walletKey, metadata } = params;
validateSurplusSaleProofAnchorMetadata(metadata);
const provider = new OgmiosLucidProvider({
ogmiosUrl: chain.url,
fetch: chain.fetchImpl,
});
const { lucid } = await createLucidContext(
provider,
walletKey,
{ networkId: 1, systemStartUnix: 0, slotLengthMs: 1000 },
{ usePresetProtocolParameters: true },
);
const realWalletUtxos = await lucid.wallet().getUtxos();
const anchorMetadata = {
p: metadata.p,
root: metadata.root,
count: metadata.count,
first: metadata.first,
last: metadata.last,
};
let signed;
try {
const txBuilder = lucid
.newTx()
.attachMetadata(SURPLUS_SALE_PROOF_METADATA_LABEL, anchorMetadata)
.addSignerKey(walletKey.pubKeyHash)
.setMinFee(500_000n);
const completed = await txBuilder.complete({
presetWalletInputs: realWalletUtxos,
localUPLCEval: false,
});
signed = await completed.sign.withWallet().complete();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new TxConstructionError("anchor_metadata_build_failed", message);
}
const txCborHex = signed.toCBOR();
const expectedTxHash = signed.toHash();
await chain.submitTx(txCborHex);
return { txCborHex, expectedTxHash };
}

View File

@@ -2,9 +2,22 @@
* planConsolidate.ts — pure pre-flight planner for the wallet-consolidate flow.
*
* Given a wallet's current UTxO set + a target collateral lovelace, decide
* whether to consolidate (many → 2), split (1 → 2), or skip (already at
* {≥collateral, working}). The same downstream tx-builder handles
* consolidate and split — only the "reason" tag differs for logging.
* whether the wallet needs reshaping or is already usable for script spends.
*
* Healthy is a STRUCTURAL predicate, not a UTxO count (F7, 2026-08-08):
* (a) some pure-lovelace UTxO >= collateralLovelace exists (the collateral
* candidate lucid's setCollateral needs), AND
* (b) at least MIN_WORKING_LOVELACE sits OUTSIDE that candidate (fee and
* spend money that is not the candidate), AND
* (c) fragmentation is bounded (utxos.length <= maxUtxos).
*
* The earlier revision returned `already-healthy` only for EXACTLY 2 UTxOs.
* The consolidate tx itself lands 3 (collateral + working + the fee-reserve
* remainder as change), so the shape consolidation produced could never
* satisfy its own health check — the wallet-health ticker re-consolidated
* every tick and burned 0.5 AP3X per pass (116 AP3X in one 24h soak).
* The health check also runs BEFORE the low-balance guard now: a wallet that
* can already transact must never be told "balance too low to consolidate".
*
* No lucid / WASM deps so this is unit-testable from a vanilla vitest.
*/
@@ -26,12 +39,24 @@ export interface PlannedConsolidate {
export const DEFAULT_COLLATERAL_LOVELACE = 5_000_000n;
export const DEFAULT_FEE_RESERVE = 2_000_000n;
/** Reshape only when fragmentation passes this; receipt/change UTxOs
* accumulating between jobs are fine as long as a candidate exists. */
export const DEFAULT_MAX_UTXOS = 6;
const MIN_WORKING_LOVELACE = 1_000_000n;
/** Lovelace of a UTxO iff it carries no native assets; null otherwise. */
function pureLovelace(u: UtxoLike): bigint | null {
for (const [unit, amount] of Object.entries(u.assets)) {
if (unit !== "lovelace" && (amount ?? 0n) > 0n) return null;
}
return u.assets.lovelace ?? 0n;
}
export function planConsolidate(
utxos: UtxoLike[],
collateralLovelace: bigint = DEFAULT_COLLATERAL_LOVELACE,
feeReserve: bigint = DEFAULT_FEE_RESERVE,
maxUtxos: number = DEFAULT_MAX_UTXOS,
): PlannedConsolidate {
if (utxos.length === 0) {
throw new Error("wallet has no UTxOs — fund the address first");
@@ -42,6 +67,32 @@ export function planConsolidate(
0n,
);
// Structural health check first — reshaping a usable wallet wastes a fee.
if (utxos.length <= maxUtxos) {
// Smallest qualifying candidate: leave larger UTxOs as working funds.
let candidate: bigint | null = null;
for (const u of utxos) {
const pure = pureLovelace(u);
if (pure !== null && pure >= collateralLovelace) {
if (candidate === null || pure < candidate) candidate = pure;
}
}
if (candidate !== null) {
const workingOutside = totalLovelaceIn - candidate;
if (workingOutside >= MIN_WORKING_LOVELACE) {
return {
inputCount: utxos.length,
totalLovelaceIn,
collateralOutput: candidate,
workingOutput: workingOutside,
alreadyHealthy: true,
reason: "already-healthy",
};
}
}
}
// Reshape needed — only an actual reshape has a funds threshold.
const required = collateralLovelace + feeReserve + MIN_WORKING_LOVELACE;
if (totalLovelaceIn < required) {
throw new Error(
@@ -51,23 +102,6 @@ export function planConsolidate(
);
}
if (utxos.length === 2) {
const lov0 = utxos[0].assets.lovelace ?? 0n;
const lov1 = utxos[1].assets.lovelace ?? 0n;
const smaller = lov0 < lov1 ? lov0 : lov1;
const larger = lov0 < lov1 ? lov1 : lov0;
if (smaller >= collateralLovelace) {
return {
inputCount: 2,
totalLovelaceIn,
collateralOutput: smaller,
workingOutput: larger,
alreadyHealthy: true,
reason: "already-healthy",
};
}
}
const workingOutput = totalLovelaceIn - collateralLovelace - feeReserve;
return {

116
pnpm-lock.yaml generated
View File

@@ -321,10 +321,16 @@ importers:
express:
specifier: ^4.21.2
version: 4.22.1
pg:
specifier: ^8.16.3
version: 8.23.0
devDependencies:
'@types/express':
specifier: ^5.0.3
version: 5.0.6
'@types/pg':
specifier: ^8.15.5
version: 8.23.1
'@types/supertest':
specifier: ^6.0.2
version: 6.0.3
@@ -1244,6 +1250,9 @@ packages:
'@types/pdf-parse@1.1.5':
resolution: {integrity: sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==}
'@types/pg@8.23.1':
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
'@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
@@ -2360,6 +2369,40 @@ packages:
resolution: {integrity: sha512-XRIRcLgk6ZnUbsHsYXExMw+krrPE81hJ6FQPLdBNhhBefqIQKXu/WeTgNBGSwPrfU0v+UCEwn7AoAUOsVKHFvQ==}
engines: {node: '>=6.8.1'}
pg-cloudflare@1.4.0:
resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==}
pg-connection-string@2.14.0:
resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==}
pg-int8@1.0.1:
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
engines: {node: '>=4.0.0'}
pg-pool@3.14.0:
resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==}
peerDependencies:
pg: '>=8.0'
pg-protocol@1.16.0:
resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==}
pg-types@2.2.0:
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
engines: {node: '>=4'}
pg@8.23.0:
resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==}
engines: {node: '>= 16.0.0'}
peerDependencies:
pg-native: '>=3.0.1'
peerDependenciesMeta:
pg-native:
optional: true
pgpass@1.0.5:
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -2430,6 +2473,22 @@ packages:
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
engines: {node: ^10 || ^12 || >=14}
postgres-array@2.0.0:
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
engines: {node: '>=4'}
postgres-bytea@1.0.1:
resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
engines: {node: '>=0.10.0'}
postgres-date@1.0.7:
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
engines: {node: '>=0.10.0'}
postgres-interval@1.2.0:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
@@ -2640,6 +2699,10 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
sprintf-js@1.1.3:
resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==}
@@ -3863,6 +3926,12 @@ snapshots:
dependencies:
'@types/node': 25.6.0
'@types/pg@8.23.1':
dependencies:
'@types/node': 25.6.0
pg-protocol: 1.16.0
pg-types: 2.2.0
'@types/prop-types@15.7.15': {}
'@types/qs@6.15.0': {}
@@ -4947,6 +5016,41 @@ snapshots:
dependencies:
node-ensure: 0.0.0
pg-cloudflare@1.4.0:
optional: true
pg-connection-string@2.14.0: {}
pg-int8@1.0.1: {}
pg-pool@3.14.0(pg@8.23.0):
dependencies:
pg: 8.23.0
pg-protocol@1.16.0: {}
pg-types@2.2.0:
dependencies:
pg-int8: 1.0.1
postgres-array: 2.0.0
postgres-bytea: 1.0.1
postgres-date: 1.0.7
postgres-interval: 1.2.0
pg@8.23.0:
dependencies:
pg-connection-string: 2.14.0
pg-pool: 3.14.0(pg@8.23.0)
pg-protocol: 1.16.0
pg-types: 2.2.0
pgpass: 1.0.5
optionalDependencies:
pg-cloudflare: 1.4.0
pgpass@1.0.5:
dependencies:
split2: 4.2.0
picocolors@1.1.1: {}
picomatch@2.3.2: {}
@@ -4997,6 +5101,16 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
postgres-array@2.0.0: {}
postgres-bytea@1.0.1: {}
postgres-date@1.0.7: {}
postgres-interval@1.2.0:
dependencies:
xtend: 4.0.2
prebuild-install@7.1.3:
dependencies:
detect-libc: 2.1.2
@@ -5282,6 +5396,8 @@ snapshots:
source-map-js@1.2.1: {}
split2@4.2.0: {}
sprintf-js@1.1.3: {}
stackback@0.0.2: {}

View File

@@ -0,0 +1,83 @@
# supplier/.env.chandra.example — Chandra OCR-2 supplier (ocr capability)
#
# Copy to supplier/.env.chandra on the host, chmod 600, fill the blanks.
# TESTNET FIRST: run the full lifecycle (advert → escrow → claim → submit →
# accept) on testnet before any mainnet value enters this file. Mainnet ops
# (funding, advert post, container start) are operator-executed.
#
# The upstream is any OpenAI-compatible VISION endpoint serving
# datalab-to/chandra-ocr-2 (vLLM recommended; `chandra_vllm` docker or
# `vllm serve datalab-to/chandra-ocr-2 --revision <sha>`).
# LICENSING: the model weights and the hosted API each carry their own
# terms. Confirm your own licensing position with the model provider before
# serving commercially.
# ── Identity / chain ──────────────────────────────────────────────────────
SUPPLIER_PRIV_KEY_HEX= # gen-keypair output; NEW wallet, working float only
NETWORK_ID= # 0 testnet | 1 mainnet — set explicitly, never default
OGMIOS_URL= # wss://ogmios.vector.testnet... | mainnet equivalent
ADVERT_REF= # <txHash>#<index> from tx:post-advert (below)
LIVE_CHAIN= # 1 only when submitting for real
VECTOR_ZERO_TIME_MS= # per deploy/README (mainnet 1756485600000)
# ── Capability ────────────────────────────────────────────────────────────
CAPABILITY_KIND=ocr
# ── Upstream selection ────────────────────────────────────────────────────
# "datalab" = Datalab hosted document API (v0 deployment, decided 2026-08-06)
# "openai-vision" = self-hosted vLLM (the attestation upgrade path)
OCR_UPSTREAM=datalab
# ── Datalab upstream (OCR_UPSTREAM=datalab) ───────────────────────────────
# TERMS: the upstream provider's end-user terms govern how you may use and
# redistribute output, and they vary by account and plan. Confirm your own
# position with the provider before serving third parties.
DATALAB_API_KEY= # provider account key; never commit
DATALAB_BASE_URL=https://www.datalab.to
DATALAB_MODE=accurate # fast | balanced | accurate
DATALAB_TIMEOUT_MS=120000 # submit + poll budget; keep < advert max_processing_ms
# ── OpenAI-compatible vision upstream (OCR_UPSTREAM=openai-vision) ────────
# No /v1 suffix — supplier/src/ocrVision.ts appends /v1/chat/completions.
# OPENAI_BASE_URL= # e.g. http://chandra-vllm:8000
# OPENAI_API_KEY= # empty for a local vLLM; token for hosted endpoints
OPENAI_TIMEOUT_MS=120000 # page inference is seconds; keep < advert max_processing_ms
# OPENAI_MAX_TOKENS=8192 # optional output ceiling (advert cap applies regardless)
# ── OCR extras ────────────────────────────────────────────────────────────
# Datalab upstream: the pre-Claim probe defaults to per_job liveness against
# {DATALAB_BASE_URL}/api/v1/health - no further config needed. Set
# REVISION_PROBE_MODE=off only if you want claims without the health gate.
#
# openai-vision upstream only (attestation upgrade path):
# OCR_PROMPT_OVERRIDE= # exact serving prompt, pinned at integration
# HF_MODEL_REVISION= # 40-hex checkpoint SHA; mismatch refuses Claim
# REVISION_PROBE_URL= # e.g. http://chandra-vllm:8000/info
# ── Service ───────────────────────────────────────────────────────────────
PORT=8080
# ── Advert posting (run once per network, testnet first) ──────────────────
# Model string convention: datalab upstream = "datalab/convert-accurate"
# (honest: hosted engine + mode); self-hosted = "datalab-to/chandra-ocr-2@<sha7>".
# Endpoint host: your deployed supplier's public HTTPS endpoint (deploy
# target tracked outside this repo, not in this file).
#
# pnpm --filter @marketplace/supplier tx:post-advert -- \
# --ogmios-url $OGMIOS_URL \
# --priv-key $SUPPLIER_PRIV_KEY_HEX \
# --capability-id ocr.page.extract.chandra-ocr-2.v1 \
# --model "datalab/convert-accurate" \
# --max-output-tokens 8192 \
# --max-processing-ms 150000 \
# --price-lovelace 200000 \
# --endpoint-url https://<your-supplier-host> \
# --detail-uri https://<your-supplier-host>/chandra-detail.json \
# --detail-hash <sha256 of the detail JSON>
#
# max_processing_ms 150000 covers DATALAB_TIMEOUT_MS (120s) + chain legs.
# Price 200000 (0.2 AP3X) is the fleet convention placeholder — a future
# pricing decision supersedes it. Bonds default to 1 AP3X each.
# The detail JSON template lives at supplier/chandra-detail.example.json.
# Testnet: DNS is not required — a direct http://<host-ip>:<port> endpoint
# works for the testnet advert (retire it after the drills).

View File

@@ -132,3 +132,51 @@ ADVERT_DETAIL_HASH=
# MAX_CHAT_SESSIONS=1 # concurrent session slots (default 1)
# CHAT_SETTLE_MODE=full # "full" (Claim/Submit) or "ticket" (no supplier
# # chain ops; buyer reclaims the Open escrow)
# ─── Autonomous Surplus seller (surplus:start) ────────────────────────────
# This service uses a seller-scoped Surplus key. It does not need a wallet
# private key. Keep both API keys in the host-only surplus-seller.env file.
SURPLUS_LIVE=
SURPLUS_API_URL=https://api.surplusintelligence.ai
SURPLUS_SELLER_API_KEY=si_seller_REPLACE_ME
SURPLUS_OPENROUTER_API_KEY=sk-or-v1-REPLACE_ME
SURPLUS_PROVIDER_BASE_URL=https://openrouter.ai/api/v1
OPENROUTER_CAPACITY_BASE_URL=https://openrouter.ai/api
SURPLUS_SELLER_WALLET=0x0000000000000000000000000000000000000000
SURPLUS_PAYOUT_ADDRESS=0x0000000000000000000000000000000000000000
# The controller discovers all fixed-price text models and selects one by
# current demand. It keeps only one active offer. At the default 490 bps
# recovery floor, a $0.05 seller cap bounds upstream spend near $1.02 per day.
SURPLUS_PER_OFFER_CAP_USD=0.05
SURPLUS_AGGREGATE_CAP_USD=0.05
SURPLUS_OPENROUTER_RESERVE_USD=1.00
SURPLUS_OPENROUTER_MAX_LIMIT_USD=20.00
SURPLUS_MIN_RECOVERY_BPS=490
SURPLUS_UNDERCUT_BPS=10
SURPLUS_MAX_CANDIDATE_ORDER_BOOKS=10
SURPLUS_REPRICE_INTERVAL_MS=300000
SURPLUS_REQUEST_TIMEOUT_MS=120000
# 0 keeps selling. 1 pauses after the first trade or one offer-cap of spend.
SURPLUS_STOP_AFTER_SETTLED_REQUESTS=0
SURPLUS_SETTLED_STATUSES=confirmed
SURPLUS_STATE_PATH=/var/lib/surplus-manager/state.json
# --- Vector proof publisher ------------------------------------------------
# When enabled, every settled Base settlement transaction gets one Vector
# counterpart: the sales it settled are Merkle-batched and anchored in a
# label-674 metadata transaction signed by the proof wallet below.
SURPLUS_PROOF_ENABLED=
# Required when enabled. Mainnet Ogmios endpoint and the 64-hex Ed25519 key
# of the AP3X proof wallet (the retired Vector supplier wallet).
OGMIOS_URL=
SURPLUS_PROOF_WALLET_PRIV_KEY_HEX=
SURPLUS_PROOF_LEDGER_PATH=/var/lib/surplus-manager/vector-proofs.json
# Anchoring pauses when the wallet cannot keep this reserve after one fee.
SURPLUS_PROOF_RESERVE_LOVELACE=5000000
SURPLUS_PROOF_FEE_BUDGET_LOVELACE=1000000
SURPLUS_PROOF_INTERVAL_MS=300000
SURPLUS_PROOF_CONFIRM_TIMEOUT_MS=300000
# Optional support-export backfill. The CSV must have the exact header:
# request_id,settled_at,model,input_tokens,output_tokens,seller_earned_usd,settlement_status,settlement_type,tx_hash
# The offer id is stamped into each export leaf because the export omits it.
SURPLUS_PROOF_HISTORY_CSV_PATH=
SURPLUS_PROOF_HISTORY_OFFER_ID=

View File

@@ -0,0 +1,105 @@
# supplier/.env.hetzner.example
#
# Preset for a supplier that uses Hetzner Inference
# (https://inference.hetzner.com) as its COMPUTE BACKEND to fulfil bonded
# inference work on Vector. Hetzner exposes an OpenAI-compatible API, so this
# reuses the existing LLM_BACKEND=openai path with NO code changes — config only.
#
# One template for all 8 Hetzner-backed suppliers (4 models x {one-off, chat}):
# 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)
#
# Copy to supplier/.env.<name> on the host (chmod 600) and fill in real values.
# .env.* is gitignored — NEVER commit a populated copy.
#
# Runbook: docs/HETZNER_SETUP.md
#
# Lines marked SECRET must never be shared, logged, or committed.
# ─── Wallet identity ──────────────────────────────────────────────────────
# Generate all four values in one shot (from repo root):
# pnpm --filter @marketplace/supplier tx:gen-keypair --network 1
#
# ALL FOUR are required. The supplier does NOT re-derive address/pkh/pubkey
# from the private key at boot — leaving them unset silently yields an empty
# wallet identity and every escrow is rejected 403 wrong_supplier (this took
# down the TTS supplier once; don't repeat it).
# 64-char hex Ed25519 private key seed for this supplier's wallet. SECRET.
SUPPLIER_PRIV_KEY_HEX=PASTE_64_HEX_CHARS_HERE
SUPPLIER_ADDRESS=PASTE_ADDR1_HERE
SUPPLIER_PKH=PASTE_28_BYTE_HEX_HERE
SUPPLIER_PUB_KEY_HEX=PASTE_32_BYTE_HEX_HERE
# ─── Chain plumbing (mainnet) ─────────────────────────────────────────────
OGMIOS_URL=https://ogmios.vector.mainnet.apexfusion.org
NETWORK_ID=1
VECTOR_ZERO_TIME_MS=1756485600000
# CIP-33 reference scripts (shared mainnet deployment).
ESCROW_REF_UTXO=c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#0
ADVERT_REF_UTXO=c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#1
# OutputReference of this supplier's on-chain advertisement UTxO.
# Post it first (see tx:post-advert below), then paste the printed "<txHash>#0".
ADVERT_REF=PASTE_TXHASH_HERE#0
# LIVE_CHAIN: only the literal "1" opts in to real Claim/Submit txs.
LIVE_CHAIN=1
# ─── Backend: Hetzner Inference (OpenAI-compatible) ───────────────────────
LLM_BACKEND=openai
# Hetzner base URL. The supplier appends /v1/chat/completions itself, so do
# NOT include the /api/v1 → only up to /api.
OPENAI_BASE_URL=https://inference.hetzner.com/api
# Hetzner Inference API key. Shared across all 8 Hetzner-backed suppliers —
# rate limits apply to the aggregate. SECRET.
OPENAI_API_KEY=PASTE_HETZNER_KEY_HERE
# All four models are reasoning-style; generations can be slow.
OPENAI_TIMEOUT_MS=300000
# Do NOT set OPENAI_REASONING — `reasoning:{enabled:false}` is an
# OpenRouter-only param.
# OPENAI_REASONING=
# OPENAI_MAX_TOKENS intentionally unset: no max_tokens is forwarded upstream,
# Hetzner grants context − prompt. The advert's max_output_tokens (= model
# context length) only caps what buyers may request.
# OPENAI_MAX_TOKENS=
PORT=8080
# ─── tx:post-advert — run once before first deploy ────────────────────────
# One-off (llm.text.generate.v1) suppliers, from repo root:
# pnpm --filter @marketplace/supplier tx:post-advert \
# --capability-id llm.text.generate.v1 \
# --model <EXACT Hetzner model id, see table above> \
# --max-output-tokens <262144 or 512000 per table> \
# --max-processing-ms 300000 \
# --price-lovelace 200000 \
# --endpoint-url https://mp-suppliers-<name>.vector.apexfusion.org
#
# Chat (llm.chat.v1) suppliers: same, but
# --capability-id llm.chat.v1 --max-processing-ms 1800000
# (the session spans the whole conversation; Claim+Submit must land before
# deliver_by — mirrors the kimi-chat fleet).
#
# CRITICAL: --model goes on-chain AND is sent verbatim to Hetzner as the API
# `model` param (jobRunner uses advert.model). It must be the EXACT id from
# GET https://inference.hetzner.com/api/v1/models — including the "Qwen/"
# prefix on Qwen/Qwen3.6-35B-A3B-FP8.
ADVERT_CAPABILITY_ID=llm.text.generate.v1
ADVERT_MODEL=GLM-5.2-NVFP4
ADVERT_MAX_OUTPUT_TOKENS=512000
ADVERT_MAX_PROCESSING_MS=300000
ADVERT_PRICE_LOVELACE=200000
ADVERT_SUPPLIER_BOND_LOVELACE=1000000
ADVERT_BUYER_BOND_LOVELACE=1000000
ADVERT_ENDPOINT_URL=https://mp-suppliers-glm.vector.apexfusion.org
ADVERT_DETAIL_URI=
ADVERT_DETAIL_HASH=

View File

@@ -0,0 +1,114 @@
# supplier/.env.huggingface-chat.example
#
# Preset for a chat supplier that uses the HuggingFace Inference Providers router
# (https://router.huggingface.co) as its COMPUTE BACKEND to fulfil bonded
# inference work commissioned on Vector. It is the OpenAI-compatible on-ramp:
# the router is OpenAI-compatible, so this reuses the existing LLM_BACKEND=openai
# path with NO code changes — it is config only.
#
# Copy to supplier/.env on the host (chmod 600) and fill in real values.
# .env is gitignored — NEVER commit a populated copy.
#
# Runbook: docs/HUGGINGFACE_ROUTER_SETUP.md
#
# To generate a fresh Ed25519 wallet seed: openssl rand -hex 32
# Lines marked SECRET must never be shared, logged, or committed.
# ─── Required ─────────────────────────────────────────────────────────────
# 64-char hex Ed25519 private key seed for this supplier's wallet. SECRET.
SUPPLIER_PRIV_KEY_HEX=PASTE_64_HEX_CHARS_HERE
# Ogmios WebSocket URL. Testnet shown; use the mainnet URL for NETWORK_ID=1:
# testnet: wss://ogmios.vector.testnet.apexfusion.org
# mainnet: wss://ogmios.vector.mainnet.apexfusion.org
OGMIOS_URL=wss://ogmios.vector.testnet.apexfusion.org
# OutputReference of this supplier's on-chain advertisement UTxO.
# Format "<64-hex-tx-hash>#<int>". Post it first (see tx:post-advert below),
# then paste the printed "<txHash>#0" here.
ADVERT_REF=PASTE_TXHASH_HERE#0
# Cardano network ID. "0" = testnet, "1" = mainnet. Keep "0" until you have
# validated the setup end-to-end on testnet.
NETWORK_ID=0
# ─── Backend: HuggingFace router (OpenAI-compatible) ──────────────────────
# Route the chat job runner at the HF Inference Providers router instead of a
# local Ollama. The supplier POSTs to ${OPENAI_BASE_URL}/v1/chat/completions.
LLM_BACKEND=openai
# HF router base URL. The supplier appends /v1/chat/completions itself, so do
# NOT include the /v1 suffix here.
OPENAI_BASE_URL=https://router.huggingface.co
# HuggingFace access token (starts with "hf_"). Create one at
# https://huggingface.co/settings/tokens with "Make calls to Inference
# Providers" permission. Forwarded as `Authorization: Bearer <token>`. SECRET.
OPENAI_API_KEY=PASTE_HF_TOKEN_HERE
# First-token latency across hosted providers can exceed local Ollama — keep a
# generous request timeout.
OPENAI_TIMEOUT_MS=180000
# ─── IMPORTANT: do NOT set OPENAI_REASONING here ──────────────────────────
# `reasoning:{enabled:false}` is an OpenRouter-only param. The HF router
# rejects it with HTTP 400 on every request. The supplier refuses to boot if
# OPENAI_REASONING is disabled while OPENAI_BASE_URL points at the HF router.
# (This is the #1 footgun when copying an OpenRouter env — leave it unset.)
# OPENAI_REASONING=
# Optional hard ceiling forwarded as `max_tokens`. Unset = provider grants the
# max available (context − prompt). Keep below the model's context window.
# OPENAI_MAX_TOKENS=
# ─── Enable real chain submissions ────────────────────────────────────────
# LIVE_CHAIN: only the literal "1" opts in to real Claim/Submit txs. Leave
# unset for a dry run (read-only, no chain writes) while testing.
LIVE_CHAIN=
# HTTP listen port inside the container. Compose maps this to the host.
PORT=8080
# ─── tx:post-advert — run once before first deploy ────────────────────────
# Execute (from repo root):
# 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://mp-supplier.vector.testnet.apexfusion.org
# Copy the printed "<txHash>#0" into ADVERT_REF above.
#
# NOTE on --model: HF uses PROVIDER-QUALIFIED model names. The bare id picks a
# default provider; a ":<hint>" suffix pins routing:
# deepseek-ai/DeepSeek-V3:fastest # cheapest/fastest provider for the model
# deepseek-ai/DeepSeek-V3:together # pin a specific provider
# openai/gpt-oss-120b # open-weight, HF-hosted
# Browse models + providers at https://huggingface.co/models?inference_provider=all
# The value lives on-chain in AdvertDatum.model — it is data, not code, so you
# can advertise any router-supported model without touching the supplier.
ADVERT_CAPABILITY_ID=llm.text.generate.v1
ADVERT_MODEL=deepseek-ai/DeepSeek-V3:fastest
ADVERT_MAX_OUTPUT_TOKENS=512
ADVERT_MAX_PROCESSING_MS=60000
# Flat AP3X amount committed in escrow per job (1 AP3X = 1_000_000 lovelace).
# The HF backend meters PER TOKEN while this committed amount is fixed per job,
# so size it for your worst-case prompt length and cap output tightly via
# ADVERT_MAX_OUTPUT_TOKENS. See docs/HUGGINGFACE_ROUTER_SETUP.md §4.
ADVERT_PRICE_LOVELACE=2000000
ADVERT_SUPPLIER_BOND_LOVELACE=1000000
ADVERT_BUYER_BOND_LOVELACE=1000000
# HTTPS endpoint buyers will call (must be publicly reachable).
ADVERT_ENDPOINT_URL=https://mp-supplier.vector.testnet.apexfusion.org
# Optional off-chain detail pointer + its sha256 (leave empty for v1).
ADVERT_DETAIL_URI=
ADVERT_DETAIL_HASH=
# ─── Optional: CIP-33 reference scripts (recommended on mainnet) ──────────
# ESCROW_REF_UTXO=<txhash>#0
# ADVERT_REF_UTXO=<txhash>#1

View File

@@ -27,6 +27,8 @@ RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
# Workspace manifests first to maximise layer cache reuse.
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 supplier/package.json ./supplier/package.json
# Buyer + indexer manifests are needed because pnpm-lock.yaml indexes the
@@ -68,6 +70,9 @@ COPY --from=builder /repo /repo
# Drop privileges. The official node image ships a non-root `node` user.
RUN chown -R node:node /repo
RUN mkdir -p /var/lib/surplus-manager \
&& chown node:node /var/lib/surplus-manager \
&& chmod 700 /var/lib/surplus-manager
USER node
ENV NODE_ENV=production

View File

@@ -0,0 +1,21 @@
{
"capability_id": "ocr.page.extract.chandra-ocr-2.v1",
"weights_source": "hf:datalab-to/chandra-ocr-2",
"model_revision": "<40-hex commit SHA; self-hosted (OCR_UPSTREAM=openai-vision) deployments only — the Datalab-hosted default (OCR_UPSTREAM=datalab) is a third-party API with no pinnable checkpoint, so this is unset there>",
"license": "modified OpenRAIL-M (weights); Apache-2.0 (code)",
"request_shape": {
"route": "POST /v1/ocr/extract",
"body": { "image_b64": "plain base64, no data: prefix", "mime": "image/png | image/jpeg | image/webp", "output_format": "markdown | html | json" },
"one_page_per_job": true
},
"receipt": {
"schema": "v1 (8 fields, Ed25519-signed)",
"response_hash": "sha256(canonical({content, output_format}))",
"verify": "GET /capability → pub_key_hex; verify signature over canonical(receipt)"
},
"revision_probe": {
"route": "<runtime info route reporting the loaded checkpoint; self-hosted (openai-vision) upstream only>",
"policy": "self-hosted (OCR_UPSTREAM=openai-vision) upstream: probe before every Claim, mismatch refuses the job. Datalab-hosted default (OCR_UPSTREAM=datalab): per-job liveness probe only, no checkpoint pin."
},
"operator_disclosure": "first-party (HAL8-operated supplier)"
}

View File

@@ -9,17 +9,21 @@
"tx:post-advert": "tsx src/cli/post-advert.ts",
"tx:retire-advert": "tsx src/cli/retire-advert.ts",
"tx:publish-reference-scripts": "tsx src/cli/publish-reference-scripts.ts",
"tx:consolidate-wallet": "tsx src/cli/consolidate-wallet.ts"
"tx:consolidate-wallet": "tsx src/cli/consolidate-wallet.ts",
"reseller:setup": "tsx src/cli/setup-reseller.ts",
"surplus:start": "tsx src/cli/surplus-seller.ts"
},
"dependencies": {
"@marketplace/shared": "workspace:*",
"@noble/ed25519": "^2.1.0",
"@noble/hashes": "^1.8.0",
"bech32": "^2.0.0",
"express": "^4.21.2"
"express": "^4.21.2",
"pg": "^8.16.3"
},
"devDependencies": {
"@types/express": "^5.0.3",
"@types/pg": "^8.15.5",
"@types/supertest": "^6.0.2",
"supertest": "^7.0.0",
"typescript": "^5.7.2"

View File

@@ -15,6 +15,7 @@ import {
detectCborBackend,
mockSlotToWallclockMs,
TxConstructionError,
type PostAdvertBuildResult,
type WalletKey,
} from "@marketplace/shared/tx";
@@ -34,6 +35,8 @@ export interface PostAdvertFlowParams {
* in that order. Defaults to console.log.
*/
log?: (line: string) => void;
/** Durable journal hook invoked with the signed transaction before broadcast. */
beforeSubmit?: (built: PostAdvertBuildResult) => Promise<void>;
}
export interface PostAdvertFlowResult {
@@ -110,6 +113,7 @@ export async function runPostAdvert(
walletKey,
advertDatum: datumToSubmit,
deposit_lovelace: datumToSubmit.supplier_bond_lovelace,
beforeSubmit: params.beforeSubmit,
});
writeLog(`submitted tx ${built.expectedTxHash}`);

View File

@@ -0,0 +1,486 @@
import { spawn } from "node:child_process";
import { createHash, randomBytes } from "node:crypto";
import { constants } from "node:fs";
import { chmod, mkdir, open, rename } from "node:fs/promises";
import { dirname, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { LiveOgmiosProvider, type OutputReference } from "@marketplace/shared/chain";
import { decodeAdvertDatum, type AdvertDatum } from "@marketplace/shared/cbor";
import {
BOUNDED_INPUT_DETAIL_MARKER,
loadBlueprint,
type WalletKey,
} from "@marketplace/shared/tx";
import { deriveKeypair } from "./gen-keypair.js";
import { runPostAdvert } from "./postAdvertFlow.js";
import { callOpenAi } from "../openai.js";
import { OpenRouterCapacityProvider } from "../reseller/openRouterCapacity.js";
import {
loadResellerSetupConfig,
type ResellerSetupConfig,
} from "../reseller/setupConfig.js";
interface CliOptions {
configPath: string;
startStack: boolean;
}
interface SetupState extends Record<string, string> {
SUPPLIER_PRIV_KEY_HEX: string;
SUPPLIER_PUB_KEY_HEX: string;
SUPPLIER_PKH: string;
SUPPLIER_ADDRESS: string;
POSTGRES_PASSWORD: string;
OPENAI_API_KEY: string;
ADVERT_REF: string;
}
interface SetupResult {
supplierAddress: string;
supplierPkh: string;
advertRef: string;
endpointUrl: string;
dashboardUrl: string;
marketplaceModel: string;
providerModel: string;
stackStarted: boolean;
}
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const OPENROUTER_BASE_URL = "https://openrouter.ai/api";
const SHA256_EMPTY = createHash("sha256").update("").digest("hex");
const MAINNET_ZERO_TIME_MS = "1756485600000";
const MAINNET_ESCROW_REF =
"c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#0";
const MAINNET_ADVERT_REF =
"c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#1";
const POSTGRES_STATE_FILE =
"/var/lib/agents-marketplace/openrouter-reseller-postgres.env";
const DEFAULT_STATE_FILE = "/var/lib/agents-marketplace/openrouter-reseller.env";
const RESELLER_HOSTNAME =
"mp-suppliers-openrouter-reseller.vector.apexfusion.org";
export function parseSetupArgs(argv: string[]): CliOptions {
let configPath = "";
let startStack = true;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--config") {
const value = argv[index + 1];
if (!value) throw new Error("--config requires a path");
configPath = value;
index += 1;
} else if (arg === "--no-start") {
startStack = false;
} else {
throw new Error(`unknown setup argument: ${arg}`);
}
}
if (configPath === "") throw new Error("--config is required");
return { configPath, startStack };
}
export async function runResellerSetup(
options: CliOptions,
env: Record<string, string | undefined>,
log: (line: string) => void = (line) => console.error(line),
): Promise<SetupResult> {
const config = await loadResellerSetupConfig(
resolve(REPO_ROOT, options.configPath),
);
if (new URL(config.endpointUrl).hostname !== RESELLER_HOSTNAME) {
throw new Error(`endpointUrl host must be ${RESELLER_HOSTNAME}`);
}
const statePath = resolveStatePath(env.RESELLER_STATE_FILE, options.startStack);
process.env.VECTOR_ZERO_TIME_MS = MAINNET_ZERO_TIME_MS;
process.env.ESCROW_REF_UTXO = MAINNET_ESCROW_REF;
process.env.ADVERT_REF_UTXO = MAINNET_ADVERT_REF;
const prior = await readState(statePath);
const privateKey = prior.SUPPLIER_PRIV_KEY_HEX ?? randomBytes(32).toString("hex");
const derived = deriveKeypair(privateKey, config.networkId);
const postgresPassword = prior.POSTGRES_PASSWORD ?? randomBytes(24).toString("hex");
const openRouterKey = env.OPENROUTER_API_KEY ?? prior.OPENAI_API_KEY;
if (!openRouterKey) {
throw new Error("OPENROUTER_API_KEY is required on the first setup run");
}
const runtimeState = buildRuntimeState(
config,
{
privateKeyHex: derived.privateKeyHex,
pubKeyHex: derived.publicKeyHex,
pubKeyHash: derived.pubKeyHash,
address: derived.address,
},
postgresPassword,
openRouterKey,
prior.ADVERT_REF ?? "",
);
await writeState(statePath, runtimeState);
await writeState(POSTGRES_STATE_FILE, {
POSTGRES_USER: runtimeState.POSTGRES_USER,
POSTGRES_DB: runtimeState.POSTGRES_DB,
POSTGRES_PASSWORD: runtimeState.POSTGRES_PASSWORD,
});
log("checking capped OpenRouter allowance and model pricing");
const capacity = new OpenRouterCapacityProvider({
baseUrl: OPENROUTER_BASE_URL,
apiKey: openRouterKey,
timeoutMs: config.providerTimeoutMs,
});
await capacity.readCapacity(config.openRouterModel);
log("running one-token OpenRouter inference probe");
await callOpenAi({
baseUrl: OPENROUTER_BASE_URL,
apiKey: openRouterKey,
model: config.openRouterModel,
messages: [{ role: "user", content: "Reply OK" }],
maxTokens: 1,
disableReasoning: true,
timeoutMs: config.inferenceTimeoutMs,
});
const chain = new LiveOgmiosProvider({ ogmiosUrl: config.ogmiosUrl });
log(`seller wallet ${derived.address}`);
await waitForFunding(
chain,
derived.address,
config.minimumWalletLovelace,
config.fundingTimeoutMs,
log,
);
const walletKey: WalletKey = {
privateKeyHex: derived.privateKeyHex,
pubKeyHex: derived.publicKeyHex,
pubKeyHash: derived.pubKeyHash,
address: derived.address,
};
const advert = buildAdvert(config, walletKey);
const advertRef = await findOrPostAdvert(
chain,
walletKey,
advert,
runtimeState.ADVERT_REF,
log,
async (pendingRef) => {
runtimeState.ADVERT_REF = `${pendingRef.txHash}#${pendingRef.index}`;
await writeState(statePath, runtimeState);
},
);
runtimeState.ADVERT_REF = `${advertRef.txHash}#${advertRef.index}`;
await writeState(statePath, runtimeState);
if (options.startStack) {
log("starting reseller and Postgres stack");
await runCompose(config.composeFile);
}
return {
supplierAddress: walletKey.address,
supplierPkh: walletKey.pubKeyHash,
advertRef: runtimeState.ADVERT_REF,
endpointUrl: config.endpointUrl,
dashboardUrl: `${config.endpointUrl}/reseller`,
marketplaceModel: config.marketplaceModel,
providerModel: config.openRouterModel,
stackStarted: options.startStack,
};
}
function buildRuntimeState(
config: ResellerSetupConfig,
wallet: WalletKey,
postgresPassword: string,
openRouterKey: string,
advertRef: string,
): SetupState {
const hostname = new URL(config.endpointUrl).hostname;
return {
SUPPLIER_PRIV_KEY_HEX: wallet.privateKeyHex,
SUPPLIER_PUB_KEY_HEX: wallet.pubKeyHex,
SUPPLIER_PKH: wallet.pubKeyHash,
SUPPLIER_ADDRESS: wallet.address,
POSTGRES_USER: "reseller",
POSTGRES_DB: "reseller",
POSTGRES_PASSWORD: postgresPassword,
OPENAI_API_KEY: openRouterKey,
ADVERT_REF: advertRef,
OGMIOS_URL: config.ogmiosUrl,
NETWORK_ID: String(config.networkId),
LIVE_CHAIN: "1",
CAPABILITY_KIND: "chat",
LLM_BACKEND: "openai",
OPENAI_BASE_URL: OPENROUTER_BASE_URL,
OPENAI_MODEL_OVERRIDE: config.openRouterModel,
OPENAI_MAX_TOKENS: String(config.maxOutputTokens),
OPENAI_REASONING: "off",
OPENAI_TIMEOUT_MS: String(config.inferenceTimeoutMs),
RESELLER_PROVIDER: "openrouter",
RESELLER_DATABASE_URL: `postgresql://reseller:${postgresPassword}@postgres:5432/reseller`,
RESELLER_RESERVE_USD: config.reserveUsd,
RESELLER_MAX_INPUT_TOKENS: String(config.maxInputTokens),
RESELLER_POLL_INTERVAL_MS: String(config.pollIntervalMs),
RESELLER_PROVIDER_TIMEOUT_MS: String(config.providerTimeoutMs),
RESELLER_PREVIEW_MAX_CHARS: String(config.previewMaxChars),
RESELLER_HOSTNAME: hostname,
RESELLER_ENDPOINT_URL: config.endpointUrl,
RESELLER_MARKETPLACE_MODEL: config.marketplaceModel,
RESELLER_PROVIDER_MODEL: config.openRouterModel,
PORT: "8080",
WALLET_HEALTH_INTERVAL_MS: "600000",
VECTOR_ZERO_TIME_MS: MAINNET_ZERO_TIME_MS,
ESCROW_REF_UTXO: MAINNET_ESCROW_REF,
ADVERT_REF_UTXO: MAINNET_ADVERT_REF,
};
}
function buildAdvert(config: ResellerSetupConfig, wallet: WalletKey): AdvertDatum {
return {
supplier_pkh: wallet.pubKeyHash,
capability_id: "llm.text.generate.v1",
model: config.marketplaceModel,
max_output_tokens: config.maxOutputTokens,
max_processing_ms: config.maxProcessingMs,
price_lovelace: config.priceLovelace,
supplier_bond_lovelace: config.supplierBondLovelace,
buyer_bond_lovelace: config.buyerBondLovelace,
endpoint_url: config.endpointUrl,
detail_uri: BOUNDED_INPUT_DETAIL_MARKER,
detail_hash: SHA256_EMPTY,
advertised_at: 0,
status: "Active",
};
}
async function findOrPostAdvert(
chain: LiveOgmiosProvider,
walletKey: WalletKey,
expected: AdvertDatum,
savedRef: string,
log: (line: string) => void,
journal: (ref: OutputReference) => Promise<void>,
): Promise<OutputReference> {
const parsedSaved = parseRef(savedRef);
if (parsedSaved) {
const utxo = await chain.queryUtxo(parsedSaved);
if (utxo?.datumHex) {
assertAdvertMatches(decodeAdvertDatum(utxo.datumHex), expected);
log(`reusing saved advert ${savedRef}`);
return parsedSaved;
}
try {
await chain.awaitTx(parsedSaved.txHash, 120_000);
const confirmed = await chain.queryUtxo(parsedSaved);
if (confirmed?.datumHex) {
assertAdvertMatches(decodeAdvertDatum(confirmed.datumHex), expected);
log(`reusing confirmed pending advert ${savedRef}`);
return parsedSaved;
}
} catch {
// The pending transaction is no longer viable; authoritative scan follows.
}
}
const advertAddress = loadBlueprint().advertScriptAddress(1);
const advertUtxos = await chain.queryUtxosByAddress(advertAddress);
const sameModel: Array<{ ref: OutputReference; datum: AdvertDatum }> = [];
for (const utxo of advertUtxos) {
if (!utxo.datumHex) continue;
try {
const datum = decodeAdvertDatum(utxo.datumHex);
if (datum.status === "Active" && datum.model === expected.model) {
sameModel.push({ ref: utxo.ref, datum });
}
} catch {
// Ignore unrelated or malformed script outputs.
}
}
const own = sameModel.filter(
(candidate) => candidate.datum.supplier_pkh === expected.supplier_pkh,
);
if (own.length > 1) {
throw new Error(`multiple active adverts already exist for ${expected.model}`);
}
if (own.length === 1) {
assertAdvertMatches(own[0].datum, expected);
log(`reusing discovered advert ${own[0].ref.txHash}#${own[0].ref.index}`);
return own[0].ref;
}
const result = await runPostAdvert({
chain,
walletKey,
advertDatum: expected,
log,
beforeSubmit: async (built) => journal(built.advertOutputRef),
});
return result.advertRef;
}
function assertAdvertMatches(actual: AdvertDatum, expected: AdvertDatum): void {
const fieldsMatch =
actual.status === "Active" &&
actual.supplier_pkh === expected.supplier_pkh &&
actual.capability_id === expected.capability_id &&
actual.model === expected.model &&
actual.max_output_tokens === expected.max_output_tokens &&
actual.max_processing_ms === expected.max_processing_ms &&
actual.price_lovelace === expected.price_lovelace &&
actual.supplier_bond_lovelace === expected.supplier_bond_lovelace &&
actual.buyer_bond_lovelace === expected.buyer_bond_lovelace &&
actual.endpoint_url === expected.endpoint_url &&
actual.detail_uri === expected.detail_uri &&
actual.detail_hash === expected.detail_hash;
if (!fieldsMatch) {
throw new Error("saved advert does not match the reseller config");
}
}
async function waitForFunding(
chain: LiveOgmiosProvider,
address: string,
minimum: bigint,
timeoutMs: number,
log: (line: string) => void,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastReported = -1n;
while (Date.now() < deadline) {
const utxos = await chain.queryUtxosByAddress(address);
const total = utxos.reduce((sum, utxo) => sum + utxo.lovelace, 0n);
if (total >= minimum) return;
if (total !== lastReported) {
log(`wallet funding ${total}/${minimum} lovelace`);
lastReported = total;
}
await new Promise<void>((resolveWait) => setTimeout(resolveWait, 5_000));
}
throw new Error(`wallet funding timed out for ${address}`);
}
function resolveStatePath(
configured: string | undefined,
startStack: boolean,
): string {
const statePath = resolve(configured ?? DEFAULT_STATE_FILE);
const repoRoot = REPO_ROOT;
const fromRepo = relative(repoRoot, statePath);
if (fromRepo === "" || (!fromRepo.startsWith("../") && fromRepo !== "..")) {
throw new Error("RESELLER_STATE_FILE must be outside the repository");
}
if (startStack && statePath !== DEFAULT_STATE_FILE) {
throw new Error(
`stack startup requires the fixed state file ${DEFAULT_STATE_FILE}`,
);
}
return statePath;
}
async function readState(path: string): Promise<Record<string, string>> {
let raw: string;
try {
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
try {
const info = await handle.stat();
if (!info.isFile()) {
throw new Error(`reseller state must be a regular file: ${path}`);
}
if (typeof process.getuid === "function" && info.uid !== process.getuid()) {
throw new Error(`reseller state is not owned by the current user: ${path}`);
}
if ((info.mode & 0o077) !== 0) {
throw new Error(`reseller state permissions must be 0600: ${path}`);
}
raw = await handle.readFile("utf8");
} finally {
await handle.close();
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
throw error;
}
const state: Record<string, string> = {};
for (const line of raw.split(/\r?\n/)) {
if (line === "" || line.startsWith("#")) continue;
const separator = line.indexOf("=");
if (separator <= 0) throw new Error(`invalid reseller state line: ${line}`);
state[line.slice(0, separator)] = line.slice(separator + 1);
}
return state;
}
async function writeState(path: string, state: Record<string, string>): Promise<void> {
for (const [name, value] of Object.entries(state)) {
if (/[\r\n]/.test(name) || /[\r\n]/.test(value)) {
throw new Error(`reseller state ${name} must be one line`);
}
}
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
await chmod(dirname(path), 0o700);
const temp = `${path}.${randomBytes(12).toString("hex")}.tmp`;
const body = Object.entries(state)
.sort(([left], [right]) => left.localeCompare(right))
.map(([name, value]) => `${name}=${value}`)
.join("\n") + "\n";
const handle = await open(temp, "wx", 0o600);
try {
await handle.writeFile(body, "utf8");
await handle.sync();
} finally {
await handle.close();
}
await rename(temp, path);
await chmod(path, 0o600);
}
function parseRef(value: string): OutputReference | null {
const match = /^([0-9a-fA-F]{64})#(0|[1-9]\d*)$/.exec(value);
if (!match) return null;
return { txHash: match[1], index: Number(match[2]) };
}
async function runCompose(composeFile: string): Promise<void> {
const args = [
"compose",
"-f",
resolve(REPO_ROOT, composeFile),
"up",
"-d",
"--build",
"--wait",
];
await new Promise<void>((resolveRun, rejectRun) => {
const child = spawn("docker", args, { stdio: "inherit" });
child.once("error", rejectRun);
child.once("exit", (code) => {
if (code === 0) resolveRun();
else rejectRun(new Error(`docker compose exited with code ${code}`));
});
});
}
export async function main(
argv: string[],
env: Record<string, string | undefined>,
): Promise<number> {
try {
const result = await runResellerSetup(parseSetupArgs(argv), env);
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 setup 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,209 @@
import {
createServer,
type IncomingMessage,
type Server,
type ServerResponse,
} from "node:http";
import { fileURLToPath } from "node:url";
import { resolve } from "node:path";
import { loadSurplusManagerConfig } from "../surplus/config.js";
import { SurplusClient, type SurplusSale } from "../surplus/client.js";
import { SurplusSellerController } from "../surplus/controller.js";
import { OpenRouterKeyClient } from "../surplus/openRouterKey.js";
import { FileSurplusStateStore } from "../surplus/state.js";
import {
SurplusDashboardService,
type SurplusDashboardSnapshot,
} from "../surplus/dashboard.js";
import { FileSurplusVectorProofLedger } from "../surplus/vectorProof.js";
import { loadHistoricalSalesCsv } from "../surplus/historyExport.js";
import { SurplusVectorProofPublisher } from "../surplus/vectorPublisher.js";
import { LiveOgmiosProvider } from "@marketplace/shared/chain";
import type { WalletKey } from "@marketplace/shared/tx";
import { buildAnchorMetadataTx } from "@marketplace/shared/tx/server";
import { deriveKeypair } from "./gen-keypair.js";
export async function runSurplusSeller(
env: Record<string, string | undefined>,
): Promise<{
server: Server;
controller: SurplusSellerController;
publisher: SurplusVectorProofPublisher | null;
}> {
const config = loadSurplusManagerConfig(env);
const client = new SurplusClient({
apiBaseUrl: config.apiBaseUrl,
sellerApiKey: config.sellerApiKey,
timeoutMs: config.requestTimeoutMs,
});
const allowance = new OpenRouterKeyClient({
baseUrl: config.capacityBaseUrl,
apiKey: config.providerApiKey,
timeoutMs: config.requestTimeoutMs,
});
const stateStore = new FileSurplusStateStore(config.statePath);
const controller = new SurplusSellerController({
config,
client,
allowance,
stateStore,
log: (message) => console.log(message),
});
let publisher: SurplusVectorProofPublisher | null = null;
let proofs: FileSurplusVectorProofLedger | undefined;
if (config.proof !== null) {
const proofConfig = config.proof;
const ledger = await FileSurplusVectorProofLedger.open(proofConfig.ledgerPath);
proofs = ledger;
const chain = new LiveOgmiosProvider({ ogmiosUrl: proofConfig.ogmiosUrl });
const derived = deriveKeypair(proofConfig.walletPrivateKeyHex, 1);
const walletKey: WalletKey = {
pubKeyHash: derived.pubKeyHash,
pubKeyHex: derived.publicKeyHex,
privateKeyHex: derived.privateKeyHex,
address: derived.address,
};
let historicalSales: (() => Promise<readonly SurplusSale[]>) | undefined;
if (proofConfig.historyCsvPath !== null && proofConfig.historyOfferId !== null) {
const history = await loadHistoricalSalesCsv(
proofConfig.historyCsvPath,
proofConfig.historyOfferId,
);
console.log(
`surplus-proof: loaded ${history.sales.length} settled export sale(s) from ` +
`${proofConfig.historyCsvPath} (${history.settledWithoutTx} without a Base tx, ` +
`${history.skippedUnsettled} unsettled rows skipped)`,
);
historicalSales = async () => history.sales;
}
publisher = new SurplusVectorProofPublisher({
ledger,
earnings: () => client.getEarnings(),
historicalSales,
anchor: async (metadata) => {
const built = await buildAnchorMetadataTx({ chain, walletKey, metadata });
return { expectedTxHash: built.expectedTxHash };
},
awaitTx: (txHash, timeoutMs) => chain.awaitTx(txHash, timeoutMs),
balanceLovelace: async () => {
const utxos = await chain.queryUtxosByAddress(walletKey.address);
return utxos.reduce((sum, utxo) => sum + utxo.lovelace, 0n);
},
reserveLovelace: proofConfig.reserveLovelace,
feeBudgetLovelace: proofConfig.feeBudgetLovelace,
settledStatuses: config.settledStatuses,
intervalMs: proofConfig.intervalMs,
confirmTimeoutMs: proofConfig.confirmTimeoutMs,
log: (message) => console.log(message),
});
console.log(
`surplus-proof: publisher enabled with wallet ${derived.address} and ledger ${proofConfig.ledgerPath}`,
);
}
const dashboard = new SurplusDashboardService({
controller,
client,
allowance,
config,
proofs,
});
const server = createSurplusServer(controller, dashboard);
await new Promise<void>((resolveListen, rejectListen) => {
server.once("error", rejectListen);
server.listen(config.port, "0.0.0.0", () => {
server.off("error", rejectListen);
resolveListen();
});
});
await controller.start();
publisher?.start();
return { server, controller, publisher };
}
interface DashboardSource {
getSnapshot(): Promise<SurplusDashboardSnapshot>;
}
export function createSurplusServer(
controller: Pick<SurplusSellerController, "snapshot" | "healthy">,
dashboard: DashboardSource,
): Server {
return createServer((request, response) => {
void handleRequest(controller, dashboard, request, response);
});
}
async function handleRequest(
controller: Pick<SurplusSellerController, "snapshot" | "healthy">,
dashboard: DashboardSource,
request: IncomingMessage,
response: ServerResponse,
): Promise<void> {
const path = request.url?.split("?")[0] ?? "/";
response.setHeader("cache-control", "no-store");
response.setHeader("x-content-type-options", "nosniff");
response.setHeader("x-frame-options", "DENY");
response.setHeader("referrer-policy", "no-referrer");
const status = controller.snapshot();
if (request.method === "GET" && path === "/healthz") {
const healthy = controller.healthy();
response.statusCode = healthy ? 200 : 503;
response.setHeader("content-type", "application/json");
response.end(JSON.stringify({ ok: healthy, phase: status.phase }));
return;
}
if (request.method === "GET" && path === "/status") {
response.statusCode = 200;
response.setHeader("content-type", "application/json");
response.end(JSON.stringify(status));
return;
}
if (request.method === "GET" && path === "/internal/resale-dashboard") {
try {
const snapshot = await dashboard.getSnapshot();
response.statusCode = 200;
response.setHeader("content-type", "application/json");
response.end(JSON.stringify(snapshot));
} catch {
response.statusCode = 503;
response.setHeader("content-type", "application/json");
response.end(JSON.stringify({ error: "dashboard_data_unavailable" }));
}
return;
}
response.statusCode = 404;
response.setHeader("content-type", "application/json");
response.end(JSON.stringify({ error: "not_found" }));
}
export async function main(
env: Record<string, string | undefined> = process.env,
): Promise<void> {
const { server, controller, publisher } = await runSurplusSeller(env);
console.log("Surplus seller controller listening");
let shuttingDown = false;
const shutdown = async (signal: string): Promise<void> => {
if (shuttingDown) return;
shuttingDown = true;
console.log(`Surplus seller controller shutting down (${signal})`);
await publisher?.stop();
await controller.stop();
await new Promise<void>((resolveClose, rejectClose) => {
server.close((error) => error ? rejectClose(error) : resolveClose());
});
};
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM"));
}
const invoked = fileURLToPath(import.meta.url) === resolve(process.argv[1] ?? "");
if (invoked) {
main().catch((error) => {
const detail = error instanceof Error ? error.message : String(error);
console.error(`Surplus seller controller failed: ${detail}`);
process.exitCode = 1;
});
}

View File

@@ -15,7 +15,10 @@
*
* Required for CAPABILITY_KIND="chat" + LLM_BACKEND="openai":
* OPENAI_BASE_URL — http(s):// URL for an OpenAI-compatible /v1/chat/completions
* host (e.g. ChatMock localhost proxy backed by Codex OAuth).
* host. Examples: ChatMock localhost proxy (Codex OAuth),
* OpenRouter (https://openrouter.ai/api), or the HuggingFace
* Inference Providers router (https://router.huggingface.co).
* See docs/HUGGINGFACE_ROUTER_SETUP.md for the HF preset.
*
* Required for CAPABILITY_KIND="tts":
* PIPER_URL — http(s):// URL for the openedai-speech-min PiperTTS host
@@ -33,6 +36,15 @@
* OLLAMA_TIMEOUT_MS — ollama request timeout in ms (default 120_000)
* OPENAI_TIMEOUT_MS — openai request timeout in ms (default 180_000 — GPT
* first-token latency is longer than local Ollama)
* OPENAI_SESSION_PASSTHROUGH
* — "1" switches chat-session turns to the stateful-
* upstream contract (OpenClaw): escrow ref as the
* OpenAI `user` field + delta-only messages per turn.
* Strict "1" only. Default off. Requires
* CAPABILITY_KIND="chat-session".
* OPENAI_MODEL_OVERRIDE — fixed upstream model id sent instead of the
* advert's buyer-facing model (OpenClaw accepts only
* "openclaw" / "openclaw/<agentId>"). Default unset.
* PIPER_TIMEOUT_MS — piper request timeout in ms (default 120_000)
* LIVE_CHAIN — "1" opts in to live-chain submit/await (default off).
* Any other value (including "true", "yes", "TRUE")
@@ -50,6 +62,34 @@ const HEX64_RE = /^[0-9a-fA-F]{64}$/;
const TX_HASH_RE = /^[0-9a-fA-F]{64}$/;
const NON_NEG_INT_RE = /^(?:0|[1-9]\d*)$/;
const POS_INT_RE = /^[1-9]\d*$/;
const USD_RE = /^(?:0|[1-9]\d*)(?:\.\d{1,9})?$/;
/** Lowercased hostname of a URL, or "" if it doesn't parse. */
function hostnameOf(url: string): string {
try {
return new URL(url).hostname.toLowerCase();
} catch {
return "";
}
}
function isOpenRouterApiBaseUrl(value: string): boolean {
try {
const url = new URL(value);
return (
url.protocol === "https:" &&
url.hostname === "openrouter.ai" &&
url.port === "" &&
url.username === "" &&
url.password === "" &&
(url.pathname === "/api" || url.pathname === "/api/") &&
url.search === "" &&
url.hash === ""
);
} catch {
return false;
}
}
export interface AdvertRef {
txHash: string;
@@ -60,8 +100,26 @@ export interface AdvertRef {
* "chat" — one-off `llm.text.generate.v1` (single prompt → single completion)
* "tts" — `audio.synthesize.piper.v1`
* "chat-session" — multi-turn `llm.chat.v1` (escrow bookends, off-chain turns,
* streaming). Requires LLM_BACKEND="openai". */
export type CapabilityKind = "chat" | "tts" | "chat-session";
* streaming). Requires LLM_BACKEND="openai".
* "ocr" — model-scoped `ocr.page.extract.<model-slug>.v1` (one page
* image → one extraction). Upstream is an OpenAI-compatible
* VISION endpoint (OPENAI_BASE_URL), e.g. vLLM serving
* datalab-to/chandra-ocr-2. */
export type CapabilityKind = "chat" | "tts" | "chat-session" | "ocr";
/** Served-checkpoint probe cadence for the ocr capability.
* "per_job" — probe the upstream before every Claim (default when
* REVISION_PROBE_URL is set, and always the default for the
* datalab upstream, whose health route is derived).
* "off" — no probing. */
export type RevisionProbeMode = "per_job" | "off";
/** Upstream flavor for the ocr capability.
* "openai-vision" — OpenAI-compatible vision endpoint (vLLM self-hosting;
* supports the revision-pin attestation probe).
* "datalab" — Datalab hosted document API (submit + poll; liveness
* probe only — hosted supply has no checkpoint to pin). */
export type OcrUpstream = "openai-vision" | "datalab";
/** LLM backend selected for the chat capability. */
export type LlmBackend = "ollama" | "openai";
@@ -73,6 +131,22 @@ export type LlmBackend = "ollama" | "openai";
* the buyer's sweeper Reclaims the Open escrow after deliver_by. */
export type ChatSettleMode = "full" | "ticket";
export type ResellerProvider = "openrouter";
export interface ResellerConfig {
provider: ResellerProvider;
databaseUrl: string;
/** Fixed protected amount in provider USD credits. Parsed exactly at runtime. */
reserveUsd: string;
/** Conservative off-chain input bound advertised by /capability. */
maxInputTokens: number;
pollIntervalMs: number;
staleAfterMs: number;
providerTimeoutMs: number;
previewMaxChars: number;
}
export interface SupplierConfig {
supplierPrivKeyHex: string;
ogmiosUrl: string;
@@ -99,6 +173,28 @@ export interface SupplierConfig {
* the model context or the provider 400s (e.g. kimi context = 262144).
*/
openaiMaxTokens: number;
/**
* When true, chat-session turns speak the STATEFUL-upstream contract
* (e.g. an OpenClaw gateway): each turn 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 sends ONLY the current
* turn's delta messages — the upstream session carries the history, and
* re-sending the full transcript would duplicate it into the upstream
* context every turn. The supplier still accumulates the full transcript
* locally for the receipt. Env: OPENAI_SESSION_PASSTHROUGH="1". Default
* off (stateless upstreams get the full transcript each turn). Requires
* capabilityKind="chat-session".
*/
openaiSessionPassthrough: boolean;
/**
* When non-empty, sent as the upstream `model` instead of the advert's
* model id. For upstreams that reject arbitrary model strings — OpenClaw's
* chat-completions endpoint 400s on anything but "openclaw" or
* "openclaw/<agentId>" — while the advert carries the buyer-facing id the
* gateway routes on. Env: OPENAI_MODEL_OVERRIDE. Default "" (advert model
* sent verbatim).
*/
openaiModelOverride: string;
/** Empty string when capabilityKind="chat". */
piperUrl: string;
advertRef: AdvertRef;
@@ -123,6 +219,37 @@ export interface SupplierConfig {
maxChatSessions: number;
/** Chain settlement mode for chat sessions. Default "full". */
chatSettleMode: ChatSettleMode;
/**
* Upstream flavor for the ocr capability. Env: OCR_UPSTREAM
* ("openai-vision" default | "datalab").
*/
ocrUpstream: OcrUpstream;
/** Datalab API key (X-API-Key). Required when ocrUpstream="datalab". */
datalabApiKey: string;
/** Datalab API origin. Env: DATALAB_BASE_URL, default https://www.datalab.to */
datalabBaseUrl: string;
/** Datalab quality mode: fast | balanced | accurate. Env: DATALAB_MODE. */
datalabMode: string;
/** Whole-call budget for submit+poll, ms. Env: DATALAB_TIMEOUT_MS. */
datalabTimeoutMs: number;
/**
* Overrides the built-in per-format OCR instruction prompt when non-empty
* (capabilityKind="ocr" + openai-vision upstream only). Env: OCR_PROMPT_OVERRIDE.
*/
ocrPromptOverride: string;
/**
* Expected served-checkpoint SHA (40-hex) for the ocr upstream. When set
* together with revisionProbeUrl, a probe mismatch refuses the Claim.
* Env: HF_MODEL_REVISION. "" = no pin.
*/
hfModelRevision: string;
/**
* Serving-runtime info route reporting the loaded checkpoint (TGI-style
* /info or vLLM equivalent). Env: REVISION_PROBE_URL. "" = probing off.
*/
revisionProbeUrl: string;
/** Probe cadence; see RevisionProbeMode. Env: REVISION_PROBE_MODE. */
revisionProbeMode: RevisionProbeMode;
/**
* When true, the supplier boots a LiveOgmiosProvider (real submitTx/awaitTx).
* Default false → ReadOnlyOgmiosProvider (safe; no chain writes).
@@ -134,6 +261,8 @@ export interface SupplierConfig {
* Default 600_000 (10 min). Only fires while liveChain=true.
*/
walletHealthIntervalMs: number;
/** Optional balance-aware provider resale mode. Null preserves legacy behavior. */
reseller: ResellerConfig | null;
}
function requireField(env: Record<string, string | undefined>, name: string): string {
@@ -174,8 +303,13 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
// CAPABILITY_KIND drives whether OLLAMA_URL or PIPER_URL is required.
// Default to "chat" so existing chat suppliers keep booting unchanged.
const capKindStr = env.CAPABILITY_KIND ?? "chat";
if (capKindStr !== "chat" && capKindStr !== "tts" && capKindStr !== "chat-session") {
throw new Error('loadConfig: CAPABILITY_KIND must be "chat", "tts", or "chat-session"');
if (
capKindStr !== "chat" &&
capKindStr !== "tts" &&
capKindStr !== "chat-session" &&
capKindStr !== "ocr"
) {
throw new Error('loadConfig: CAPABILITY_KIND must be "chat", "tts", "chat-session", or "ocr"');
}
const capabilityKind: CapabilityKind = capKindStr;
@@ -204,6 +338,19 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
openaiBaseUrl = requireField(env, "OPENAI_BASE_URL");
ollamaUrl = env.OLLAMA_URL ?? "";
piperUrl = env.PIPER_URL ?? "";
} else if (capabilityKind === "ocr") {
// Upstream is either an OpenAI-compatible vision endpoint (self-hosted
// vLLM; OPENAI_BASE_URL required) or the Datalab hosted API
// (DATALAB_API_KEY required, validated below). Ollama and piper are
// unused regardless of LLM_BACKEND.
const upstream = env.OCR_UPSTREAM ?? "openai-vision";
if (upstream === "openai-vision") {
openaiBaseUrl = requireField(env, "OPENAI_BASE_URL");
} else {
openaiBaseUrl = env.OPENAI_BASE_URL ?? "";
}
ollamaUrl = env.OLLAMA_URL ?? "";
piperUrl = env.PIPER_URL ?? "";
} else if (capabilityKind === "chat") {
if (llmBackend === "openai") {
openaiBaseUrl = requireField(env, "OPENAI_BASE_URL");
@@ -269,6 +416,24 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
reasoningRaw === "0" ||
reasoningRaw === "no";
// HuggingFace router guard. The `reasoning:{enabled:false}` param is
// OpenRouter-specific; the HF Inference Providers router
// (router.huggingface.co) rejects it with HTTP 400 on EVERY request. This is
// the easy footgun when copying an OpenRouter env to the HF preset, so fail
// fast at boot with a clear message instead of 400-ing every job.
// See docs/HUGGINGFACE_ROUTER_SETUP.md.
if (
llmBackend === "openai" &&
openaiReasoningDisabled &&
hostnameOf(openaiBaseUrl) === "router.huggingface.co"
) {
throw new Error(
"loadConfig: OPENAI_REASONING must be unset for the HuggingFace router " +
"(OPENAI_BASE_URL=https://router.huggingface.co) — it rejects " +
"reasoning:{enabled:false} with HTTP 400. Remove OPENAI_REASONING.",
);
}
// OPENAI_MAX_TOKENS — optional hard ceiling forwarded as `max_tokens`. Unset
// or non-positive omits it (provider grants max available = context − prompt).
// Keep it below the model context (kimi 262144) or the provider 400s when
@@ -282,6 +447,22 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
openaiMaxTokens = Number(maxTokStr);
}
// OPENAI_SESSION_PASSTHROUGH — "1" switches chat-session turns to the
// stateful-upstream contract (OpenClaw): escrow ref as the OpenAI `user`
// field + delta-only messages (the upstream session carries history).
// Strict "1" only, mirroring LIVE_CHAIN.
const openaiSessionPassthrough = env.OPENAI_SESSION_PASSTHROUGH === "1";
if (openaiSessionPassthrough && capabilityKind !== "chat-session") {
throw new Error(
'loadConfig: OPENAI_SESSION_PASSTHROUGH requires CAPABILITY_KIND="chat-session"',
);
}
// OPENAI_MODEL_OVERRIDE — fixed upstream model id sent instead of the
// advert's (buyer-facing) model. Needed when the upstream validates the
// model field (OpenClaw accepts only "openclaw" / "openclaw/<agentId>").
const openaiModelOverride = env.OPENAI_MODEL_OVERRIDE ?? "";
// CHAT_IDLE_TIMEOUT_MS — idle auto-end window for chat sessions (default 5 min).
const chatIdleStr = env.CHAT_IDLE_TIMEOUT_MS;
let chatIdleTimeoutMs = 300_000;
@@ -318,6 +499,52 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
}
const chatSettleMode: ChatSettleMode = settleModeStr;
// OCR-capability extras. All optional; harmless when set on other kinds
// (ignored by the chat/tts/chat-session paths).
const ocrUpstreamStr = env.OCR_UPSTREAM ?? "openai-vision";
if (ocrUpstreamStr !== "openai-vision" && ocrUpstreamStr !== "datalab") {
throw new Error('loadConfig: OCR_UPSTREAM must be "openai-vision" or "datalab"');
}
const ocrUpstream: OcrUpstream = ocrUpstreamStr;
const datalabApiKey = env.DATALAB_API_KEY ?? "";
if (capabilityKind === "ocr" && ocrUpstream === "datalab" && datalabApiKey === "") {
throw new Error('loadConfig: OCR_UPSTREAM="datalab" requires DATALAB_API_KEY');
}
const datalabBaseUrl = env.DATALAB_BASE_URL ?? "https://www.datalab.to";
const datalabMode = env.DATALAB_MODE ?? "accurate";
if (datalabMode !== "fast" && datalabMode !== "balanced" && datalabMode !== "accurate") {
throw new Error('loadConfig: DATALAB_MODE must be "fast", "balanced", or "accurate"');
}
const datalabTimeoutStr = env.DATALAB_TIMEOUT_MS;
let datalabTimeoutMs = 120_000;
if (datalabTimeoutStr !== undefined && datalabTimeoutStr !== "") {
if (!POS_INT_RE.test(datalabTimeoutStr)) {
throw new Error("loadConfig: DATALAB_TIMEOUT_MS must be a positive integer");
}
datalabTimeoutMs = Number(datalabTimeoutStr);
}
const ocrPromptOverride = env.OCR_PROMPT_OVERRIDE ?? "";
const hfModelRevision = env.HF_MODEL_REVISION ?? "";
if (hfModelRevision !== "" && !/^[0-9a-fA-F]{40}$/.test(hfModelRevision)) {
throw new Error("loadConfig: HF_MODEL_REVISION must be a 40-hex commit SHA when set");
}
const revisionProbeUrl = env.REVISION_PROBE_URL ?? "";
// Datalab upstream: the health route is derived from DATALAB_BASE_URL, so
// the liveness probe defaults ON without a REVISION_PROBE_URL. The
// openai-vision upstream keeps the explicit-URL requirement.
const probeDefault =
ocrUpstream === "datalab" || revisionProbeUrl !== "" ? "per_job" : "off";
const probeModeStr = env.REVISION_PROBE_MODE ?? probeDefault;
if (probeModeStr !== "per_job" && probeModeStr !== "off") {
throw new Error('loadConfig: REVISION_PROBE_MODE must be "per_job" or "off"');
}
if (probeModeStr === "per_job" && ocrUpstream === "openai-vision" && revisionProbeUrl === "" && capabilityKind === "ocr") {
throw new Error('loadConfig: REVISION_PROBE_MODE="per_job" requires REVISION_PROBE_URL for the openai-vision upstream');
}
const revisionProbeMode: RevisionProbeMode = probeModeStr;
const piperTimeoutStr = env.PIPER_TIMEOUT_MS;
let piperTimeoutMs = 120_000;
if (piperTimeoutStr !== undefined && piperTimeoutStr !== "") {
@@ -342,6 +569,121 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
walletHealthIntervalMs = Number(whIntervalStr);
}
const resellerFields = [
"RESELLER_DATABASE_URL",
"RESELLER_RESERVE_USD",
"RESELLER_MAX_INPUT_TOKENS",
"RESELLER_POLL_INTERVAL_MS",
"RESELLER_PROVIDER_TIMEOUT_MS",
"RESELLER_PREVIEW_MAX_CHARS",
] as const;
const resellerProviderRaw = (env.RESELLER_PROVIDER ?? "").trim();
const hasResellerFields = resellerFields.some(
(name) => env[name] !== undefined && env[name] !== "",
);
let reseller: ResellerConfig | null = null;
if (resellerProviderRaw === "") {
if (hasResellerFields) {
throw new Error(
"loadConfig: RESELLER_PROVIDER is required when reseller fields are set",
);
}
} else {
if (resellerProviderRaw !== "openrouter") {
throw new Error('loadConfig: RESELLER_PROVIDER must be "openrouter"');
}
if (capabilityKind !== "chat" || llmBackend !== "openai") {
throw new Error(
'loadConfig: RESELLER_PROVIDER requires CAPABILITY_KIND="chat" and LLM_BACKEND="openai"',
);
}
if (!isOpenRouterApiBaseUrl(openaiBaseUrl)) {
throw new Error(
"loadConfig: RESELLER_PROVIDER=openrouter requires OPENAI_BASE_URL=https://openrouter.ai/api",
);
}
if (openaiApiKey === "") {
throw new Error(
"loadConfig: RESELLER_PROVIDER=openrouter requires OPENAI_API_KEY",
);
}
if (openaiMaxTokens <= 0) {
throw new Error(
"loadConfig: RESELLER_PROVIDER requires a positive OPENAI_MAX_TOKENS",
);
}
const databaseUrl = requireField(env, "RESELLER_DATABASE_URL");
let databaseProtocol = "";
try {
databaseProtocol = new URL(databaseUrl).protocol;
} catch {
throw new Error(
"loadConfig: RESELLER_DATABASE_URL must be a valid PostgreSQL URL",
);
}
if (databaseProtocol !== "postgres:" && databaseProtocol !== "postgresql:") {
throw new Error(
"loadConfig: RESELLER_DATABASE_URL must use postgres:// or postgresql://",
);
}
const reserveUsd = requireField(env, "RESELLER_RESERVE_USD").trim();
if (!USD_RE.test(reserveUsd)) {
throw new Error(
"loadConfig: RESELLER_RESERVE_USD must be a non-negative decimal with at most 9 places",
);
}
const maxInputRaw = requireField(env, "RESELLER_MAX_INPUT_TOKENS");
if (!POS_INT_RE.test(maxInputRaw) || !Number.isSafeInteger(Number(maxInputRaw))) {
throw new Error(
"loadConfig: RESELLER_MAX_INPUT_TOKENS must be a positive safe integer",
);
}
const pollRaw = env.RESELLER_POLL_INTERVAL_MS ?? "30000";
if (!POS_INT_RE.test(pollRaw) || !Number.isSafeInteger(Number(pollRaw))) {
throw new Error(
"loadConfig: RESELLER_POLL_INTERVAL_MS must be a positive safe integer",
);
}
const pollIntervalMs = Number(pollRaw);
const providerTimeoutRaw = env.RESELLER_PROVIDER_TIMEOUT_MS ?? "10000";
if (
!POS_INT_RE.test(providerTimeoutRaw) ||
!Number.isSafeInteger(Number(providerTimeoutRaw))
) {
throw new Error(
"loadConfig: RESELLER_PROVIDER_TIMEOUT_MS must be a positive safe integer",
);
}
const previewMaxRaw = env.RESELLER_PREVIEW_MAX_CHARS ?? "160";
if (
!POS_INT_RE.test(previewMaxRaw) ||
!Number.isSafeInteger(Number(previewMaxRaw)) ||
Number(previewMaxRaw) > 1000
) {
throw new Error(
"loadConfig: RESELLER_PREVIEW_MAX_CHARS must be an integer from 1 to 1000",
);
}
reseller = {
provider: "openrouter",
databaseUrl,
reserveUsd,
maxInputTokens: Number(maxInputRaw),
pollIntervalMs,
staleAfterMs: Math.max(pollIntervalMs * 2, pollIntervalMs + 5000),
providerTimeoutMs: Number(providerTimeoutRaw),
previewMaxChars: Number(previewMaxRaw),
};
}
return {
supplierPrivKeyHex,
ogmiosUrl,
@@ -350,6 +692,8 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
openaiApiKey,
openaiReasoningDisabled,
openaiMaxTokens,
openaiSessionPassthrough,
openaiModelOverride,
piperUrl,
advertRef,
networkId,
@@ -362,7 +706,17 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
chatIdleTimeoutMs,
maxChatSessions,
chatSettleMode,
ocrUpstream,
datalabApiKey,
datalabBaseUrl,
datalabMode,
datalabTimeoutMs,
ocrPromptOverride,
hfModelRevision,
revisionProbeUrl,
revisionProbeMode,
liveChain,
walletHealthIntervalMs,
reseller,
};
}

249
supplier/src/datalabOcr.ts Normal file
View File

@@ -0,0 +1,249 @@
/**
* supplier/src/datalabOcr.ts — HTTP client for the Datalab hosted document
* API (https://www.datalab.to), used when OCR_UPSTREAM="datalab".
*
* Contract (verified against documentation.datalab.to, 2026-08-06):
* POST {base}/api/v1/convert multipart/form-data, X-API-Key
* fields: file (binary), output_format (markdown|html|json), mode
* → { success, error, request_id, request_check_url }
* GET {base}/api/v1/convert/{id} X-API-Key
* → { status: "complete" | ..., markdown|html|json, page_count, error }
*
* callDatalabOcr() submits ONE page image and polls until complete, then
* returns the SAME result shape as ocrVision.callOcrVision so runOcrJob's
* receipt construction is shared. Token counts are pinned to 0 — the
* Datalab API has no token semantics (mirrors the Piper/TTS convention);
* wallclock_ms brackets submit→complete locally.
*
* JSON output: when output_format="json" the result field can be an object;
* it is serialized with JSON.stringify to keep `content` a string. The
* receipt commits sha256(canonical({content, output_format})) over exactly
* the string the buyer receives, so determinism holds regardless.
*
* Error reasons (mirror the taxonomy of the other adapters):
* "datalab_failure" — non-2xx, network error, success:false, job error
* "datalab_timeout" — submit+poll exceeded timeoutMs
* "datalab_malformed" — response JSON missing the expected fields
*
* probeDatalabHealth() — pre-Claim liveness gate for the route (the
* revision-probe slot repurposed for hosted supply): GETs {base}/api/v1/health
* with the API key; a non-OK answer refuses the Claim so escrows are not
* stranded behind a dead upstream.
*/
import type { OcrRequest } from "@marketplace/shared/tx";
import type { OcrVisionResult } from "./ocrVision.js";
export interface CallDatalabOcrParams {
/** API origin, no trailing slash. Default deployment: https://www.datalab.to */
baseUrl: string;
apiKey: string;
request: OcrRequest;
/** Whole-call budget: submit + polling until complete. */
timeoutMs: number;
/** Datalab quality mode: fast | balanced | accurate. */
mode: string;
/** Poll cadence; overridable in tests. Default 2000. */
pollIntervalMs?: number;
}
export type DatalabErrorReason = "datalab_failure" | "datalab_timeout" | "datalab_malformed";
export class DatalabError extends Error {
public readonly reason: DatalabErrorReason;
constructor(reason: DatalabErrorReason, message?: string) {
super(message ?? reason);
this.name = "DatalabError";
this.reason = reason;
}
}
function isAbortError(err: unknown): boolean {
if (err instanceof Error) {
if (err.name === "AbortError") return true;
if ((err as { code?: string }).code === "ABORT_ERR") return true;
}
return false;
}
const EXT_BY_MIME: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
};
function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const t = setTimeout(resolve, ms);
signal.addEventListener("abort", () => {
clearTimeout(t);
const err = new Error("aborted");
err.name = "AbortError";
reject(err);
}, { once: true });
});
}
export async function callDatalabOcr(params: CallDatalabOcrParams): Promise<OcrVisionResult> {
const { baseUrl, apiKey, request, timeoutMs, mode } = params;
const pollIntervalMs = params.pollIntervalMs ?? 2_000;
const base = baseUrl.replace(/\/+$/, "");
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const startedAt = Date.now();
try {
// ── 1. Submit ─────────────────────────────────────────────────────
const form = new FormData();
const bytes = Buffer.from(request.image_b64, "base64");
const ext = EXT_BY_MIME[request.mime] ?? "png";
form.append("file", new Blob([bytes], { type: request.mime }), `page.${ext}`);
form.append("output_format", request.output_format);
form.append("mode", mode);
let submitRes: Response;
try {
submitRes = await fetch(`${base}/api/v1/convert`, {
method: "POST",
headers: { "X-API-Key": apiKey },
body: form,
signal: controller.signal,
});
} catch (err) {
if (isAbortError(err)) {
throw new DatalabError("datalab_timeout", `Datalab submit exceeded ${timeoutMs}ms`);
}
throw new DatalabError("datalab_failure",
`Datalab submit failed: ${(err as Error)?.message ?? String(err)}`);
}
if (!submitRes.ok) {
let detail = "";
try { detail = await submitRes.text(); } catch { /* body unavailable */ }
throw new DatalabError("datalab_failure",
`Datalab submit returned HTTP ${submitRes.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`);
}
let submitBody: Record<string, unknown>;
try {
submitBody = await submitRes.json() as Record<string, unknown>;
} catch {
throw new DatalabError("datalab_malformed", "Datalab submit response was not JSON");
}
if (submitBody.success !== true) {
throw new DatalabError("datalab_failure",
`Datalab submit success=false: ${String(submitBody.error ?? "no error detail")}`);
}
const requestId = submitBody.request_id;
if (typeof requestId !== "string" || requestId.length === 0) {
throw new DatalabError("datalab_malformed", "Datalab submit response missing request_id");
}
// ── 2. Poll until complete ────────────────────────────────────────
const checkUrl = typeof submitBody.request_check_url === "string" && submitBody.request_check_url.length > 0
? submitBody.request_check_url
: `${base}/api/v1/convert/${requestId}`;
for (;;) {
let pollRes: Response;
try {
pollRes = await fetch(checkUrl, {
headers: { "X-API-Key": apiKey },
signal: controller.signal,
});
} catch (err) {
if (isAbortError(err)) {
throw new DatalabError("datalab_timeout", `Datalab poll exceeded ${timeoutMs}ms budget`);
}
throw new DatalabError("datalab_failure",
`Datalab poll failed: ${(err as Error)?.message ?? String(err)}`);
}
if (!pollRes.ok) {
throw new DatalabError("datalab_failure", `Datalab poll returned HTTP ${pollRes.status}`);
}
let poll: Record<string, unknown>;
try {
poll = await pollRes.json() as Record<string, unknown>;
} catch {
throw new DatalabError("datalab_malformed", "Datalab poll response was not JSON");
}
const status = typeof poll.status === "string" ? poll.status.toLowerCase() : "";
if (status === "complete") {
if (poll.error && typeof poll.error === "string" && poll.error.length > 0) {
throw new DatalabError("datalab_failure", `Datalab conversion error: ${poll.error}`);
}
const raw = poll[request.output_format];
let content: string;
if (typeof raw === "string") {
content = raw;
} else if (raw !== null && raw !== undefined && typeof raw === "object") {
content = JSON.stringify(raw);
} else {
throw new DatalabError("datalab_malformed",
`Datalab result missing "${request.output_format}" field`);
}
if (content.length === 0) {
throw new DatalabError("datalab_malformed", "Datalab result content is empty");
}
return {
content,
prompt_tokens: 0,
completion_tokens: 0,
wallclock_ms: Date.now() - startedAt,
};
}
if (status === "failed" || status === "error") {
throw new DatalabError("datalab_failure",
`Datalab conversion status=${status}: ${String(poll.error ?? "no detail")}`);
}
try {
await sleep(pollIntervalMs, controller.signal);
} catch (err) {
if (isAbortError(err)) {
throw new DatalabError("datalab_timeout", `Datalab poll exceeded ${timeoutMs}ms budget`);
}
throw err;
}
}
} finally {
clearTimeout(timer);
}
}
// ─── Liveness probe (pre-Claim gate for hosted supply) ─────────────────────
export interface ProbeDatalabParams {
baseUrl: string;
apiKey: string;
timeoutMs: number;
}
export interface ProbeDatalabResult {
ok: boolean;
detail: string;
}
export async function probeDatalabHealth(params: ProbeDatalabParams): Promise<ProbeDatalabResult> {
const base = params.baseUrl.replace(/\/+$/, "");
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), params.timeoutMs);
try {
const res = await fetch(`${base}/api/v1/health`, {
headers: { "X-API-Key": params.apiKey },
signal: controller.signal,
});
if (!res.ok) {
return { ok: false, detail: `health HTTP ${res.status}` };
}
return { ok: true, detail: "health ok" };
} catch (err) {
const msg = isAbortError(err)
? `health probe exceeded ${params.timeoutMs}ms`
: `health probe failed: ${(err as Error)?.message ?? String(err)}`;
return { ok: false, detail: msg };
} finally {
clearTimeout(timer);
}
}

View File

@@ -22,6 +22,12 @@ import { JobStore } from "./jobs.js";
import { createApp, type SupplierDeps } from "./server.js";
import { buildChainProvider } from "./chain.js";
import { startWalletHealthTicker } from "./walletHealth.js";
import { decodeAdvertDatum } from "@marketplace/shared/cbor";
import { BOUNDED_INPUT_DETAIL_MARKER } from "@marketplace/shared/tx";
import { OpenRouterCapacityProvider } from "./reseller/openRouterCapacity.js";
import { CapacityGate } from "./reseller/capacityGate.js";
import { ResellerEvidenceStore } from "./reseller/evidenceStore.js";
import { ResellerRuntime } from "./reseller/runtime.js";
async function main(): Promise<void> {
const cfg = loadConfig(process.env);
@@ -45,8 +51,74 @@ async function main(): Promise<void> {
// chat-session kind serves N concurrent sessions; one-shot kinds stay 1.
const state = new SupplierState(cfg.capabilityKind === "chat-session" ? cfg.maxChatSessions : 1);
const jobs = new JobStore();
let reseller: ResellerRuntime | undefined;
if (cfg.reseller) {
const advertUtxo = await chain.queryUtxo(cfg.advertRef);
if (!advertUtxo?.datumHex) {
throw new Error("reseller boot: configured advert UTxO is unavailable");
}
const advert = decodeAdvertDatum(advertUtxo.datumHex);
if (advert.status !== "Active") {
throw new Error(`reseller boot: advert status is ${advert.status}`);
}
if (advert.capability_id !== "llm.text.generate.v1") {
throw new Error(
`reseller boot: advert capability must be llm.text.generate.v1, got ${advert.capability_id}`,
);
}
if (!advert.detail_uri.endsWith(BOUNDED_INPUT_DETAIL_MARKER)) {
throw new Error("reseller boot: advert is missing bounded-input metadata");
}
if (advert.max_output_tokens !== cfg.openaiMaxTokens) {
throw new Error(
"reseller boot: OPENAI_MAX_TOKENS must equal advert max_output_tokens",
);
}
if (advert.supplier_pkh !== supplierKey.pubKeyHash) {
throw new Error("reseller boot: advert supplier does not match SUPPLIER_PKH");
}
const deps: SupplierDeps = { chain, state, config: cfg, supplierKey, jobs };
const providerModel = cfg.openaiModelOverride || advert.model;
const provider = new OpenRouterCapacityProvider({
baseUrl: cfg.openaiBaseUrl,
apiKey: cfg.openaiApiKey,
timeoutMs: cfg.reseller.providerTimeoutMs,
});
const gate = new CapacityGate({
provider,
model: providerModel,
reserveUsd: cfg.reseller.reserveUsd,
maxInputTokens: cfg.reseller.maxInputTokens,
maxOutputTokens: advert.max_output_tokens,
staleAfterMs: cfg.reseller.staleAfterMs,
});
const store = new ResellerEvidenceStore(
cfg.reseller.databaseUrl,
cfg.reseller.previewMaxChars,
);
reseller = new ResellerRuntime({
chain,
state,
gate,
store,
advert,
advertRef: cfg.advertRef,
providerModel,
pollIntervalMs: cfg.reseller.pollIntervalMs,
maxInputTokens: cfg.reseller.maxInputTokens,
});
await reseller.initialize();
}
const deps: SupplierDeps = {
chain,
state,
config: cfg,
supplierKey,
jobs,
reseller,
};
const app = createApp(deps);
const server = app.listen(cfg.port, () => {
@@ -80,7 +152,9 @@ async function main(): Promise<void> {
clearInterval(evictInterval);
walletHealthTicker?.stop();
state.markOffline();
server.close(() => process.exit(0));
server.close(() => {
void (reseller?.close() ?? Promise.resolve()).finally(() => process.exit(0));
});
setTimeout(() => process.exit(1), 5_000).unref();
};
process.on("SIGINT", () => shutdown("SIGINT"));

View File

@@ -38,12 +38,18 @@ import type { SupplierConfig } from "./config.js";
import * as ollama from "./ollama.js";
import * as openai from "./openai.js";
import * as piper from "./piper.js";
import * as ocrVision from "./ocrVision.js";
import * as datalabOcr from "./datalabOcr.js";
import type { OcrRequest } from "@marketplace/shared/tx";
import type {
JobStore,
JobResponsePayload,
ChatJobResponsePayload,
TtsJobResponsePayload,
OcrJobResponsePayload,
} from "./jobs.js";
import type { ResellerRuntime } from "./reseller/runtime.js";
import { parseUsdNumberCeil } from "./reseller/money.js";
export interface RunChatJobDeps {
chain: ChainProvider;
@@ -51,6 +57,7 @@ export interface RunChatJobDeps {
config: SupplierConfig;
supplierKey: WalletKey;
jobs: JobStore;
reseller?: ResellerRuntime;
}
export interface RunChatJobParams {
@@ -67,6 +74,15 @@ function sha256Hex(s: string): string {
return createHash("sha256").update(s, "utf8").digest("hex");
}
async function recordResellerFailure(
reseller: ResellerRuntime | undefined,
escrowRef: string,
reason: string,
): Promise<void> {
if (!reseller) return;
await reseller.recordFailed(escrowRef, reason).catch(() => undefined);
}
/**
* Run Ollama → receipt → Submit in the background.
* Always resolves (never rejects). Terminal state written to jobs store.
@@ -79,16 +95,36 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
deps.jobs.setRunning(jobId);
try {
if (deps.reseller) {
try {
await deps.reseller.recordInferenceStarted(escrowRef);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
deps.jobs.fail(jobId, {
httpStatus: 503,
reason: "evidence_unavailable",
message,
});
return;
}
}
// ── 2. Call upstream LLM ───────────────────────────────────────────
// Both backends return the same { content, prompt_tokens,
// completion_tokens, wallclock_ms } shape so receipt construction
// downstream is identical.
let inference: { content: string; prompt_tokens: number; completion_tokens: number; wallclock_ms: number };
let inference: {
content: string;
prompt_tokens: number;
completion_tokens: number;
wallclock_ms: number;
cost_usd?: number;
};
try {
if (deps.config.llmBackend === "openai") {
inference = await openai.callOpenAi({
baseUrl: deps.config.openaiBaseUrl,
model: advert.model,
model: deps.config.openaiModelOverride || advert.model,
messages: requestBody.messages,
timeoutMs: deps.config.openaiTimeoutMs,
apiKey: deps.config.openaiApiKey,
@@ -123,6 +159,7 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
reason: collapsedReason,
message,
});
await recordResellerFailure(deps.reseller, escrowRef, collapsedReason);
return;
}
@@ -142,8 +179,39 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
});
const signed = signReceipt(receipt, deps.supplierKey.privateKeyHex);
const resultHash = receiptResultHash(signed);
if (deps.reseller) {
try {
await deps.reseller.recordOutput(escrowRef, {
response: assistantMessage,
receipt: {
receipt: signed.receipt,
signature: signed.signature,
},
promptTokens: inference.prompt_tokens,
completionTokens: inference.completion_tokens,
outputText: inference.content,
actualCostUsdNanos: inference.cost_usd === undefined
? null
: parseUsdNumberCeil(inference.cost_usd, "OpenRouter usage cost"),
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
deps.jobs.fail(jobId, {
httpStatus: 503,
reason: "evidence_unavailable",
message,
});
await recordResellerFailure(
deps.reseller,
escrowRef,
"output_persistence_failed",
);
return;
}
}
// ── 4+5. Submit tx: build + confirm as ONE wallet critical section ──
// ── 4+5. Broadcast, journal, then confirm Submit under one wallet lock ─
const submitOutcome = await deps.state.walletMutex.run(async () => {
let built: { txCborHex: string; expectedTxHash: string };
try {
@@ -152,19 +220,30 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
supplierKey: deps.supplierKey,
escrowRef: claimedRef,
receiptHash: resultHash,
beforeSubmit: deps.reseller
? async (candidate) => {
await deps.reseller!.recordSubmitting(
escrowRef,
`${candidate.expectedTxHash}#0`,
);
}
: undefined,
});
} catch (err) {
return { kind: "build_failed" as const, err };
}
const submittedRef = `${built.expectedTxHash}#0`;
try {
await deps.chain.awaitTx(built.expectedTxHash, 60_000);
} catch (err) {
return { kind: "await_failed" as const, err };
return { kind: "await_failed" as const, err, built, submittedRef };
}
return { kind: "ok" as const };
return { kind: "ok" as const, built, submittedRef };
});
if (submitOutcome.kind === "build_failed") {
const message = submitOutcome.err instanceof Error ? submitOutcome.err.message : String(submitOutcome.err);
const message = submitOutcome.err instanceof Error
? submitOutcome.err.message
: String(submitOutcome.err);
console.warn(
`[job_failed] jobId=${jobId} reason=submit_failed httpStatus=502 msg=${message}`,
);
@@ -173,20 +252,42 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
reason: "submit_failed",
message: `Submit tx failed: ${message}`,
});
await recordResellerFailure(deps.reseller, escrowRef, "submit_failed");
return;
}
if (submitOutcome.kind === "await_failed") {
const msg = submitOutcome.err instanceof Error ? submitOutcome.err.message : String(submitOutcome.err);
const message = submitOutcome.err instanceof Error
? submitOutcome.err.message
: String(submitOutcome.err);
console.warn(
`[job_failed] jobId=${jobId} reason=submit_timeout httpStatus=502 msg=${msg}`,
`[job_failed] jobId=${jobId} reason=submit_timeout httpStatus=502 msg=${message}`,
);
deps.jobs.fail(jobId, {
httpStatus: 502,
reason: "submit_timeout",
message: `Submit awaitTx failed: ${msg}`,
message: `Submit awaitTx failed: ${message}`,
});
deps.reseller?.watchSubmitting(escrowRef, submitOutcome.submittedRef);
return;
}
if (deps.reseller) {
try {
await deps.reseller.recordSubmitConfirmed(
escrowRef,
submitOutcome.submittedRef,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
deps.jobs.fail(jobId, {
httpStatus: 503,
reason: "evidence_unavailable",
message,
});
deps.reseller.watchSubmitting(escrowRef, submitOutcome.submittedRef);
return;
}
}
// ── 6. Mark complete ────────────────────────────────────────────────
const payload: ChatJobResponsePayload = {
@@ -208,6 +309,9 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
};
deps.jobs.complete(jobId, payload);
} finally {
if (deps.reseller) {
await deps.reseller.refreshCapacity().catch(() => undefined);
}
// ── 7. Always release this job's slot ───────────────────────────────
try {
deps.state.release(escrowRef);
@@ -364,3 +468,173 @@ export async function runTtsJob(params: RunTtsJobParams): Promise<void> {
}
}
}
// ─── runOcrJob ────────────────────────────────────────────────────────────
//
// Mirror of runTtsJob for model-scoped `ocr.page.extract.<model-slug>.v1`
// capabilities. Same 7-step shape (setRunning → upstream → receipt →
// buildSubmit → awaitTx → complete → finally release). Differences from the
// TTS path:
//
// - Upstream is `ocrVision.callOcrVision` against an OpenAI-compatible
// VISION endpoint (vLLM serving e.g. datalab-to/chandra-ocr-2); the
// one-page image rides as a data-URL content part.
// - response_hash = sha256(canonical({content, output_format})) — commits
// the extraction text AND the shape it was requested in.
// - prompt_tokens / completion_tokens come from upstream usage (vision
// tokens are real tokens, unlike Piper).
export interface RunOcrJobParams {
deps: RunChatJobDeps;
jobId: string;
escrowRef: string;
claimedRef: OutputReference;
advert: AdvertDatum;
escrowDatum: EscrowDatum;
/** The OCR request envelope. The PROMPT_HASH committed by the buyer in
* the escrow datum is sha256(canonicalize(this same object)), validated
* by the route before Claim. */
requestBody: OcrRequest;
}
export async function runOcrJob(params: RunOcrJobParams): Promise<void> {
const { deps, jobId, escrowRef, claimedRef, advert, escrowDatum, requestBody } = params;
deps.jobs.setRunning(jobId);
try {
// ── 2. Call the OCR upstream (openai-vision or datalab) ───────────
let inference: ocrVision.OcrVisionResult;
try {
if (deps.config.ocrUpstream === "datalab") {
inference = await datalabOcr.callDatalabOcr({
baseUrl: deps.config.datalabBaseUrl,
apiKey: deps.config.datalabApiKey,
request: requestBody,
timeoutMs: deps.config.datalabTimeoutMs,
mode: deps.config.datalabMode,
});
} else {
const configuredMax = deps.config.openaiMaxTokens;
const maxTokens =
configuredMax > 0
? Math.min(configuredMax, advert.max_output_tokens)
: advert.max_output_tokens;
inference = await ocrVision.callOcrVision({
baseUrl: deps.config.openaiBaseUrl,
model: advert.model,
request: requestBody,
timeoutMs: deps.config.openaiTimeoutMs,
apiKey: deps.config.openaiApiKey,
maxTokens,
promptOverride: deps.config.ocrPromptOverride,
});
}
} catch (err) {
const rawReason =
(err as ocrVision.OcrVisionError | datalabOcr.DatalabError)?.reason ??
(deps.config.ocrUpstream === "datalab" ? "datalab_failure" : "ocr_failure");
const message = err instanceof Error ? err.message : String(err);
// *_timeout collapses to the failure reason for jobs.fail, matching
// the chat/tts runner failure-code table (502 only).
const collapsedReason =
rawReason === "ocr_timeout" ? "ocr_failure"
: rawReason === "datalab_timeout" ? "datalab_failure"
: rawReason;
console.warn(
`[job_failed] jobId=${jobId} reason=${rawReason} httpStatus=502 msg=${message}`,
);
deps.jobs.fail(jobId, {
httpStatus: 502,
reason: collapsedReason,
message,
});
return;
}
// ── 3. Build + sign receipt ───────────────────────────────────────
const responseHash = sha256Hex(canonicalize({
content: inference.content,
output_format: requestBody.output_format,
}));
const receipt = buildReceipt({
prompt_hash: escrowDatum.prompt_hash,
response_hash: responseHash,
model: advert.model,
prompt_tokens: inference.prompt_tokens,
completion_tokens: inference.completion_tokens,
wallclock_ms: inference.wallclock_ms,
supplier_pkh: deps.supplierKey.pubKeyHash,
escrow_ref: escrowRef,
});
const signed = signReceipt(receipt, deps.supplierKey.privateKeyHex);
const resultHash = receiptResultHash(signed);
// ── 4+5. Submit tx: build + confirm as ONE wallet critical section ──
const submitOutcome = await deps.state.walletMutex.run(async () => {
let built: { txCborHex: string; expectedTxHash: string };
try {
built = await buildSubmitTx({
chain: deps.chain,
supplierKey: deps.supplierKey,
escrowRef: claimedRef,
receiptHash: resultHash,
});
} catch (err) {
return { kind: "build_failed" as const, err };
}
try {
await deps.chain.awaitTx(built.expectedTxHash, 60_000);
} catch (err) {
return { kind: "await_failed" as const, err };
}
return { kind: "ok" as const };
});
if (submitOutcome.kind === "build_failed") {
const message = submitOutcome.err instanceof Error ? submitOutcome.err.message : String(submitOutcome.err);
console.warn(
`[job_failed] jobId=${jobId} reason=submit_failed httpStatus=502 msg=${message}`,
);
deps.jobs.fail(jobId, {
httpStatus: 502,
reason: "submit_failed",
message: `Submit tx failed: ${message}`,
});
return;
}
if (submitOutcome.kind === "await_failed") {
const msg = submitOutcome.err instanceof Error ? submitOutcome.err.message : String(submitOutcome.err);
console.warn(
`[job_failed] jobId=${jobId} reason=submit_timeout httpStatus=502 msg=${msg}`,
);
deps.jobs.fail(jobId, {
httpStatus: 502,
reason: "submit_timeout",
message: `Submit awaitTx failed: ${msg}`,
});
return;
}
// ── 6. Mark complete ──────────────────────────────────────────────
const payload: OcrJobResponsePayload = {
kind: "ocr",
output_format: requestBody.output_format,
content: inference.content,
usage: {
prompt_tokens: inference.prompt_tokens,
completion_tokens: inference.completion_tokens,
total_tokens: inference.prompt_tokens + inference.completion_tokens,
},
receipt: signed.receipt as unknown as Record<string, unknown>,
receipt_signature: signed.signature,
};
deps.jobs.complete(jobId, payload);
} finally {
try {
deps.state.release(escrowRef);
} catch {
/* never let lock-release escape */
}
}
}

View File

@@ -63,13 +63,35 @@ export interface TtsJobResponsePayload {
receipt_signature: string;
}
/**
* OCR-flavoured terminal payload. Stored when a `runOcrJob` succeeds.
* `content` is the extraction in the requested output_format (markdown/
* html/json as text). The receipt's response_hash commits
* sha256(canonical({content, output_format})).
*/
export interface OcrJobResponsePayload {
kind: "ocr";
output_format: string; // "markdown" | "html" | "json"
content: string;
usage: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
receipt: Record<string, unknown>;
receipt_signature: string;
}
/**
* Discriminated union of every job-payload shape the store can hold.
* `kind` defaults to "chat" on the chat shape (older code paths don't
* set it; new `runChatJob` writes can set kind: "chat" explicitly to make
* the discriminator narrowing reliable).
*/
export type JobResponsePayload = ChatJobResponsePayload | TtsJobResponsePayload;
export type JobResponsePayload =
| ChatJobResponsePayload
| TtsJobResponsePayload
| OcrJobResponsePayload;
export interface JobFailure {
httpStatus: number;

271
supplier/src/ocrVision.ts Normal file
View File

@@ -0,0 +1,271 @@
/**
* supplier/src/ocrVision.ts — HTTP client for an OpenAI-compatible VISION
* /v1/chat/completions endpoint (vLLM serving an OCR VLM, e.g.
* datalab-to/chandra-ocr-2). Used by the `ocr` capability kind.
*
* callOcrVision({ baseUrl, model, request, timeoutMs, ... })
* POST ${baseUrl}/v1/chat/completions with a single user message whose
* content is OpenAI vision content parts:
* [{type:"image_url", image_url:{url:"data:<mime>;base64,<b64>"}},
* {type:"text", text:<instruction for the output format>}]
* Returns { content, prompt_tokens, completion_tokens, wallclock_ms }
* — the same shape as ollama.ts / openai.ts so receipt construction is
* identical downstream.
*
* The instruction text per output_format is a serving-side implementation
* detail (like temperature); the buyer's on-chain commitment covers the
* OcrRequest envelope, not the serving prompt. The exact Chandra prompt
* convention gets pinned against a live endpoint during integration —
* override per deployment via OCR_PROMPT_OVERRIDE without a code change.
*
* Error reasons:
* "ocr_failure" — non-2xx HTTP, network error, unexpected fetch failure
* "ocr_timeout" — request exceeded timeoutMs
* "ocr_malformed" — response missing choices[0].message.content (or empty)
*
* probeRevision() — best-effort served-checkpoint probe (light v0). GETs a
* runtime info route and extracts a revision/SHA field if one is present.
* The route handler refuses to Claim on a mismatch when a pin is
* configured. When the receipt schema is extended to carry it, the probed
* value feeds `model_revision`; until then it is a claim gate and
* a log line.
*/
import type { OcrRequest } from "@marketplace/shared/tx";
export interface CallOcrVisionParams {
baseUrl: string;
model: string;
request: OcrRequest;
timeoutMs: number;
/** Optional bearer token (hosted endpoints). "" sends no header. */
apiKey?: string;
/** Hard output-token ceiling forwarded as max_tokens when > 0. */
maxTokens?: number;
/** Overrides the built-in per-format instruction when non-empty. */
promptOverride?: string;
}
export interface OcrVisionResult {
content: string;
prompt_tokens: number;
completion_tokens: number;
wallclock_ms: number;
}
export type OcrVisionErrorReason = "ocr_failure" | "ocr_timeout" | "ocr_malformed";
export class OcrVisionError extends Error {
public readonly reason: OcrVisionErrorReason;
constructor(reason: OcrVisionErrorReason, message?: string) {
super(message ?? reason);
this.name = "OcrVisionError";
this.reason = reason;
}
}
function isAbortError(err: unknown): boolean {
if (err instanceof Error) {
if (err.name === "AbortError") return true;
if ((err as { code?: string }).code === "ABORT_ERR") return true;
}
return false;
}
/** Default extraction instructions per output_format. Placeholder wording
* pending the Chandra-convention pin (integration item); serving deployments
* override via OCR_PROMPT_OVERRIDE. */
export function defaultOcrInstruction(outputFormat: string): string {
switch (outputFormat) {
case "html":
return "Convert this document page to clean, semantic HTML. Preserve reading order, tables, form fields and layout structure. Output only the HTML.";
case "json":
return "Extract this document page as JSON with layout blocks (type, bbox, text) preserving reading order, tables and form fields. Output only the JSON.";
case "markdown":
default:
return "Convert this document page to clean markdown. Preserve reading order, tables and structure. Transcribe handwriting faithfully. Output only the markdown.";
}
}
export async function callOcrVision(params: CallOcrVisionParams): Promise<OcrVisionResult> {
const { baseUrl, model, request, timeoutMs, apiKey, maxTokens, promptOverride } = params;
const url = `${baseUrl.replace(/\/+$/, "")}/v1/chat/completions`;
const instruction =
promptOverride && promptOverride.length > 0
? promptOverride
: defaultOcrInstruction(request.output_format);
const payload: Record<string, unknown> = {
model,
messages: [
{
role: "user",
content: [
{
type: "image_url",
image_url: { url: `data:${request.mime};base64,${request.image_b64}` },
},
{ type: "text", text: instruction },
],
},
],
stream: false,
};
if (typeof maxTokens === "number" && maxTokens > 0) payload.max_tokens = maxTokens;
const body = JSON.stringify(payload);
const headers: Record<string, string> = { "content-type": "application/json" };
if (apiKey) {
headers.authorization = `Bearer ${apiKey}`;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const startedAt = Date.now();
let response: Response;
try {
response = await fetch(url, {
method: "POST",
headers,
body,
signal: controller.signal,
});
} catch (err) {
if (isAbortError(err)) {
throw new OcrVisionError("ocr_timeout", `OCR request exceeded ${timeoutMs}ms`);
}
throw new OcrVisionError(
"ocr_failure",
`OCR fetch failed: ${(err as Error)?.message ?? String(err)}`,
);
} finally {
clearTimeout(timer);
}
if (!response.ok) {
let detail = "";
try {
detail = await response.text();
} catch {
// ignore — body unavailable
}
throw new OcrVisionError(
"ocr_failure",
`OCR upstream returned HTTP ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`,
);
}
let parsed: unknown;
try {
parsed = await response.json();
} catch (err) {
throw new OcrVisionError(
"ocr_malformed",
`OCR response was not valid JSON: ${(err as Error)?.message ?? String(err)}`,
);
}
if (!parsed || typeof parsed !== "object") {
throw new OcrVisionError("ocr_malformed", "OCR response was not an object");
}
const obj = parsed as Record<string, unknown>;
const choicesRaw = obj.choices;
if (!Array.isArray(choicesRaw) || choicesRaw.length === 0) {
throw new OcrVisionError("ocr_malformed", "OCR response missing 'choices' array");
}
const firstChoice = choicesRaw[0];
if (!firstChoice || typeof firstChoice !== "object") {
throw new OcrVisionError("ocr_malformed", "OCR response choices[0] not an object");
}
const messageRaw = (firstChoice as Record<string, unknown>).message;
if (!messageRaw || typeof messageRaw !== "object") {
throw new OcrVisionError("ocr_malformed", "OCR response missing 'choices[0].message'");
}
const content = (messageRaw as Record<string, unknown>).content;
if (typeof content !== "string" || content.length === 0) {
throw new OcrVisionError(
"ocr_malformed",
"OCR response missing/empty choices[0].message.content",
);
}
const usageRaw = obj.usage;
let promptTokens = 0;
let completionTokens = 0;
if (usageRaw && typeof usageRaw === "object") {
const usage = usageRaw as Record<string, unknown>;
if (typeof usage.prompt_tokens === "number") promptTokens = usage.prompt_tokens;
if (typeof usage.completion_tokens === "number") completionTokens = usage.completion_tokens;
}
return {
content,
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
wallclock_ms: Date.now() - startedAt,
};
}
// ─── Revision probe (light v0) ──────────────────────────────────────────────
export interface ProbeRevisionParams {
probeUrl: string;
timeoutMs: number;
apiKey?: string;
}
export interface ProbeRevisionResult {
ok: boolean;
/** 40-hex checkpoint SHA when the runtime reports one; "" otherwise. */
servedSha: string;
/** Human-readable detail for logs/alerts. */
detail: string;
}
const SHA40_RE = /^[0-9a-f]{40}$/i;
/** Fields observed across serving runtimes that carry a checkpoint id
* (TGI /info: model_sha; vLLM variants: sha / revision / model_revision). */
const SHA_FIELDS = ["model_sha", "sha", "revision", "model_revision"];
export async function probeRevision(params: ProbeRevisionParams): Promise<ProbeRevisionResult> {
const { probeUrl, timeoutMs, apiKey } = params;
const headers: Record<string, string> = {};
if (apiKey) headers.authorization = `Bearer ${apiKey}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(probeUrl, { headers, signal: controller.signal });
if (!response.ok) {
return { ok: false, servedSha: "", detail: `probe HTTP ${response.status}` };
}
let parsed: unknown;
try {
parsed = await response.json();
} catch {
return { ok: false, servedSha: "", detail: "probe response not JSON" };
}
if (!parsed || typeof parsed !== "object") {
return { ok: false, servedSha: "", detail: "probe response not an object" };
}
const obj = parsed as Record<string, unknown>;
for (const field of SHA_FIELDS) {
const v = obj[field];
if (typeof v === "string" && SHA40_RE.test(v)) {
return { ok: true, servedSha: v.toLowerCase(), detail: `probe ${field}=${v}` };
}
}
return { ok: true, servedSha: "", detail: "probe answered without a revision field" };
} catch (err) {
const msg = isAbortError(err)
? `probe exceeded ${timeoutMs}ms`
: `probe failed: ${(err as Error)?.message ?? String(err)}`;
return { ok: false, servedSha: "", detail: msg };
} finally {
clearTimeout(timer);
}
}

View File

@@ -52,6 +52,13 @@ export interface CallOpenAiParams {
* context window (some providers 400 when it exceeds context).
*/
maxTokens?: number;
/**
* Forwarded as the OpenAI `user` field when non-empty. Stateful upstreams
* (e.g. an OpenClaw gateway) key their agent session off it, so passing the
* escrow ref pins one upstream session per marketplace chat session.
* Stateless providers ignore it.
*/
user?: string;
/**
* When true, sends `reasoning: { enabled: false }` to disable OpenRouter
* "thinking" tokens — faster/cheaper straight answers, and avoids reasoning
@@ -67,6 +74,8 @@ export interface OpenAiResult {
prompt_tokens: number;
completion_tokens: number;
wallclock_ms: number;
/** Provider-reported request cost in USD when present (OpenRouter extension). */
cost_usd?: number;
/** Present when the model requested tool calls (streaming path only). */
tool_calls?: ToolCall[];
/** Upstream finish_reason when reported (e.g. "stop", "tool_calls"). */
@@ -93,11 +102,12 @@ function isAbortError(err: unknown): boolean {
}
export async function callOpenAi(params: CallOpenAiParams): Promise<OpenAiResult> {
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning } = params;
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning, user } = params;
const url = `${baseUrl}/v1/chat/completions`;
const payload: Record<string, unknown> = { model, messages, stream: false };
if (typeof maxTokens === "number" && maxTokens > 0) payload.max_tokens = maxTokens;
if (disableReasoning) payload.reasoning = { enabled: false };
if (user) payload.user = user;
const body = JSON.stringify(payload);
const headers: Record<string, string> = { "content-type": "application/json" };
@@ -180,10 +190,18 @@ export async function callOpenAi(params: CallOpenAiParams): Promise<OpenAiResult
const usageRaw = obj.usage;
let promptTokens = 0;
let completionTokens = 0;
let costUsd: number | undefined;
if (usageRaw && typeof usageRaw === "object") {
const usage = usageRaw as Record<string, unknown>;
if (typeof usage.prompt_tokens === "number") promptTokens = usage.prompt_tokens;
if (typeof usage.completion_tokens === "number") completionTokens = usage.completion_tokens;
if (
typeof usage.cost === "number" &&
Number.isFinite(usage.cost) &&
usage.cost >= 0
) {
costUsd = usage.cost;
}
}
const wallclockMs = Date.now() - startedAt;
@@ -193,6 +211,7 @@ export async function callOpenAi(params: CallOpenAiParams): Promise<OpenAiResult
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
wallclock_ms: wallclockMs,
...(costUsd === undefined ? {} : { cost_usd: costUsd }),
};
}
@@ -215,7 +234,7 @@ export async function callOpenAiStream(
params: CallOpenAiParams,
onToken: (delta: string) => void,
): Promise<OpenAiResult> {
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning, tools, toolChoice } = params;
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning, tools, toolChoice, user } = params;
const url = `${baseUrl}/v1/chat/completions`;
const payload: Record<string, unknown> = {
model,
@@ -225,6 +244,7 @@ export async function callOpenAiStream(
};
if (typeof maxTokens === "number" && maxTokens > 0) payload.max_tokens = maxTokens;
if (disableReasoning) payload.reasoning = { enabled: false };
if (user) payload.user = user;
if (Array.isArray(tools) && tools.length > 0) {
payload.tools = tools;
if (toolChoice !== undefined) payload.tool_choice = toolChoice;

View File

@@ -0,0 +1,179 @@
import type { CapacityProvider } from "./provider.js";
import { parseUsdDecimal } from "./money.js";
export type CapacityReason =
| "starting"
| "ready"
| "below_reserve"
| "insufficient_for_job"
| "provider_unavailable"
| "database_unavailable"
| "stale";
export interface CapacitySnapshot {
provider: string;
model: string;
canServe: boolean;
reason: CapacityReason;
checkedAtMs: number | null;
lastSuccessAtMs: number | null;
keyLimitUsdNanos: bigint;
remainingAllowanceUsdNanos: bigint;
reserveUsdNanos: bigint;
sellableUsdNanos: bigint;
worstCaseJobUsdNanos: bigint;
promptPriceUsdNanosPerToken: bigint;
completionPriceUsdNanosPerToken: bigint;
requestPriceUsdNanos: bigint;
availableJobs: bigint | null;
limitReset: string | null;
error: string | null;
}
interface CapacityGateOptions {
provider: CapacityProvider;
model: string;
reserveUsd: string;
maxInputTokens: number;
maxOutputTokens: number;
staleAfterMs: number;
now?: () => number;
}
export class CapacityGate {
private readonly provider: CapacityProvider;
private readonly model: string;
private readonly reserveUsdNanos: bigint;
private readonly maxInputTokens: number;
private readonly maxOutputTokens: number;
private readonly staleAfterMs: number;
private readonly now: () => number;
private current: CapacitySnapshot;
private inFlight: Promise<CapacitySnapshot> | null = null;
constructor(options: CapacityGateOptions) {
this.provider = options.provider;
this.model = options.model;
this.reserveUsdNanos = parseUsdDecimal(options.reserveUsd, "ceil");
this.maxInputTokens = options.maxInputTokens;
this.maxOutputTokens = options.maxOutputTokens;
this.staleAfterMs = options.staleAfterMs;
this.now = options.now ?? Date.now;
this.current = {
provider: options.provider.id,
model: options.model,
canServe: false,
reason: "starting",
checkedAtMs: null,
lastSuccessAtMs: null,
keyLimitUsdNanos: 0n,
remainingAllowanceUsdNanos: 0n,
reserveUsdNanos: this.reserveUsdNanos,
sellableUsdNanos: 0n,
worstCaseJobUsdNanos: 0n,
promptPriceUsdNanosPerToken: 0n,
completionPriceUsdNanosPerToken: 0n,
requestPriceUsdNanos: 0n,
availableJobs: 0n,
limitReset: null,
error: null,
};
}
snapshot(): CapacitySnapshot {
const snapshot = this.current;
if (
snapshot.lastSuccessAtMs !== null &&
this.now() - snapshot.lastSuccessAtMs > this.staleAfterMs
) {
return {
...snapshot,
canServe: false,
reason: "stale",
error: "provider capacity data is stale",
};
}
return { ...snapshot };
}
async refresh(): Promise<CapacitySnapshot> {
if (this.inFlight) return this.inFlight;
this.inFlight = this.performRefresh();
try {
return await this.inFlight;
} finally {
this.inFlight = null;
}
}
forceUnavailable(
reason: Extract<CapacityReason, "database_unavailable" | "provider_unavailable">,
error: string,
): CapacitySnapshot {
this.current = {
...this.current,
canServe: false,
reason,
checkedAtMs: this.now(),
error,
};
return this.snapshot();
}
private async performRefresh(): Promise<CapacitySnapshot> {
try {
const reading = await this.provider.readCapacity(this.model);
const pricing = reading.pricing;
const worstCaseJobUsdNanos =
pricing.requestUsdNanos +
pricing.promptUsdNanosPerToken * BigInt(this.maxInputTokens) +
pricing.completionUsdNanosPerToken * BigInt(this.maxOutputTokens);
const remaining = reading.keyRemainingUsdNanos;
const sellable = remaining > this.reserveUsdNanos
? remaining - this.reserveUsdNanos
: 0n;
const enoughForOne =
worstCaseJobUsdNanos === 0n || sellable >= worstCaseJobUsdNanos;
const aboveReserve = remaining >= this.reserveUsdNanos;
const canServe = aboveReserve && enoughForOne;
const reason: CapacityReason = canServe
? "ready"
: !aboveReserve
? "below_reserve"
: "insufficient_for_job";
const availableJobs = worstCaseJobUsdNanos === 0n
? null
: sellable / worstCaseJobUsdNanos;
this.current = {
provider: reading.provider,
model: this.model,
canServe,
reason,
checkedAtMs: reading.checkedAtMs,
lastSuccessAtMs: reading.checkedAtMs,
keyLimitUsdNanos: reading.keyLimitUsdNanos,
remainingAllowanceUsdNanos: remaining,
reserveUsdNanos: this.reserveUsdNanos,
sellableUsdNanos: sellable,
worstCaseJobUsdNanos,
promptPriceUsdNanosPerToken: pricing.promptUsdNanosPerToken,
completionPriceUsdNanosPerToken: pricing.completionUsdNanosPerToken,
requestPriceUsdNanos: pricing.requestUsdNanos,
availableJobs,
limitReset: reading.limitReset,
error: null,
};
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
this.current = {
...this.current,
canServe: false,
reason: "provider_unavailable",
checkedAtMs: this.now(),
error: detail,
};
}
return this.snapshot();
}
}

View File

@@ -0,0 +1,101 @@
export function renderResellerDashboardPage(): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Reseller capacity</title>
<style>
:root{color-scheme:dark;--bg:#071014;--panel:#0d1a20;--line:#24363d;--text:#eef5f5;--muted:#98aeb5;--good:#50d890;--warn:#ffcb66;--bad:#ff7b72;--accent:#75bfff}
*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 15% 0,#12303a 0,transparent 34rem),var(--bg);color:var(--text);font:14px/1.5 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
main{width:min(1180px,calc(100% - 32px));margin:0 auto;padding:42px 0 64px}header{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;margin-bottom:24px}h1{font-size:clamp(28px,5vw,48px);line-height:1.05;margin:0;letter-spacing:-.04em}header p{margin:8px 0 0;color:var(--muted)}.stamp{color:var(--muted);font-variant-numeric:tabular-nums}.panel{background:color-mix(in srgb,var(--panel) 94%,transparent);border:1px solid var(--line);border-radius:16px;box-shadow:0 16px 48px #0005}.identity{display:grid;grid-template-columns:1.4fr 1fr 1fr;gap:1px;overflow:hidden;margin-bottom:16px}.identity>div{padding:18px 20px;background:var(--panel)}.label{display:block;color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.12em;margin-bottom:6px}.value{font-size:17px;font-weight:650;overflow-wrap:anywhere}.status{display:inline-flex;align-items:center;gap:8px}.dot{width:9px;height:9px;border-radius:50%;background:var(--muted);box-shadow:0 0 0 4px #fff1}.status.free .dot{background:var(--good)}.status.working .dot{background:var(--warn)}.status.offline .dot{background:var(--bad)}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:0 0 24px}.metric{padding:18px}.metric strong{display:block;font-size:24px;letter-spacing:-.03em;font-variant-numeric:tabular-nums}.metric small{color:var(--muted)}.section-head{display:flex;justify-content:space-between;align-items:center;margin:28px 0 12px}h2{font-size:18px;margin:0}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;min-width:940px}th,td{text-align:left;padding:13px 14px;border-bottom:1px solid var(--line);vertical-align:top}th{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.09em;font-weight:600}tbody tr:last-child td{border-bottom:0}.job-status{font-weight:700}.job-status.settled{color:var(--good)}.job-status.failed{color:var(--bad)}.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}.preview{max-width:250px;color:#c9d8dc;white-space:normal}.empty,.error{padding:30px;color:var(--muted)}.error{color:var(--bad)}
a.explorer-link{color:var(--accent);text-decoration:none;text-underline-offset:3px}a.explorer-link:hover,a.explorer-link:focus-visible{text-decoration:underline}
@media(max-width:820px){header{align-items:flex-start;flex-direction:column}.identity{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}}@media(max-width:480px){main{width:min(100% - 20px,1180px);padding-top:24px}.metrics{grid-template-columns:1fr}.metric strong{font-size:21px}}
</style>
</head>
<body>
<main>
<header><div><h1>Provider capacity resale</h1><p>Bounded upstream capacity settled through Vector escrow.</p></div><div class="stamp" id="updated">Loading current state</div></header>
<section class="panel identity" aria-label="Agent identity">
<div><span class="label">Route</span><span class="value" id="route">Loading</span></div>
<div><span class="label">Marketplace model</span><span class="value" id="market-model">Loading</span></div>
<div><span class="label">Supplier status</span><span class="status" id="status"><span class="dot"></span><span id="status-text">Loading</span></span><div class="stamp" id="reason"></div></div>
</section>
<section class="metrics" aria-label="Capacity and settlement metrics">
<div class="panel metric"><span class="label">Sellable allowance</span><strong id="sellable">--</strong><small id="allowance">Provider allowance --</small></div>
<div class="panel metric"><span class="label">Worst-case job</span><strong id="worst-job">--</strong><small id="available-jobs">-- jobs available</small></div>
<div class="panel metric"><span class="label">AP3X earned</span><strong id="earned">--</strong><small id="settled-count">-- settled jobs</small></div>
<div class="panel metric"><span class="label">Upstream spend</span><strong id="spend">--</strong><small id="failed-count">-- failed jobs</small></div>
</section>
<div class="section-head"><h2>Conversion timeline</h2><span class="stamp">Public previews require buyer consent</span></div>
<section class="panel table-wrap"><table><thead><tr><th>Time</th><th>Status</th><th>Model</th><th>Prompt preview</th><th>Output preview</th><th>Upstream</th><th>Payout</th><th>Escrow</th></tr></thead><tbody id="jobs"><tr><td colspan="8" class="empty">Loading jobs</td></tr></tbody></table></section>
</main>
<script>
(function(){
'use strict';
var byId=function(id){return document.getElementById(id)};
var text=function(id,value){byId(id).textContent=value==null?'--':String(value)};
var money=function(value){return '$'+String(value==null?'0':value)};
function renderStatus(state){
text('route',state.provider+' / '+state.provider_model);
text('market-model',state.marketplace_model);
var status=byId('status');status.className='status '+state.status;
text('status-text',state.status);text('reason',state.reason);
text('sellable',money(state.capacity.sellable_usd));
text('allowance','Allowance '+money(state.capacity.remaining_allowance_usd)+' · reserve '+money(state.capacity.protected_reserve_usd)+' · committed '+money(state.capacity.committed_usd));
text('worst-job',money(state.capacity.worst_case_job_usd));
text('available-jobs',state.capacity.available_jobs==null?'No balance limit':state.capacity.available_jobs+' jobs available');
text('earned',state.totals.ap3x_earned+' AP3X');
text('settled-count',state.totals.settled_jobs+' settled jobs');
text('spend',money(state.totals.upstream_spend_usd));
text('failed-count',state.totals.failed_jobs+' failed jobs');
text('updated','Updated '+new Date().toLocaleTimeString());
}
function cell(row,value,className){var td=document.createElement('td');if(className)td.className=className;td.textContent=value==null?'--':String(value);row.appendChild(td)}
function escrowCell(row,value){
var td=document.createElement('td');td.className='mono';
var ref=typeof value==='string'?value:'';
var txHash=ref.split('#')[0];
if(/^[0-9a-fA-F]{64}$/.test(txHash)){
var link=document.createElement('a');
link.className='explorer-link';
link.href='https://vector.apexscan.org/en/transaction/'+txHash+'/summary/';
link.target='_blank';link.rel='noopener noreferrer';
link.textContent=ref;
link.setAttribute('aria-label','View escrow transaction '+txHash+' on ApexScan');
td.appendChild(link);
}else{
td.textContent=ref||'--';
}
row.appendChild(td);
}
function renderJobs(rows){
var body=byId('jobs');body.replaceChildren();
if(!rows.length){var row=document.createElement('tr');cell(row,'No terminal jobs yet','empty');row.firstChild.colSpan=8;body.appendChild(row);return}
rows.forEach(function(job){
var row=document.createElement('tr');
cell(row,new Date(job.created_at).toLocaleString());
cell(row,job.status,'job-status '+job.status);
cell(row,job.marketplace_model,'mono');
cell(row,job.prompt_preview,'preview');
cell(row,job.output_preview,'preview');
cell(row,job.upstream_cost_usd==null?'--':money(job.upstream_cost_usd));
cell(row,job.ap3x_payout+' AP3X');
escrowCell(row,job.escrow_ref);
body.appendChild(row);
});
}
function showError(message){var body=byId('jobs');body.replaceChildren();var row=document.createElement('tr');cell(row,message,'error');row.firstChild.colSpan=8;body.appendChild(row);text('updated','Data unavailable')}
async function refresh(){
try{
var results=await Promise.all([fetch('/reseller/api/status',{cache:'no-store'}),fetch('/reseller/api/jobs?limit=50',{cache:'no-store'})]);
if(!results[0].ok||!results[1].ok)throw new Error('Seller evidence is unavailable');
var data=await Promise.all([results[0].json(),results[1].json()]);renderStatus(data[0]);renderJobs(data[1].jobs||[]);
}catch(error){showError(error instanceof Error?error.message:'Seller evidence is unavailable')}
}
refresh();setInterval(refresh,5000);
})();
</script>
</body>
</html>`;
}

View File

@@ -0,0 +1,446 @@
import { Pool } from "pg";
import type { ChatMessage } from "@marketplace/shared/tx";
import type { CapacitySnapshot } from "./capacityGate.js";
import { formatUsdNanos } from "./money.js";
const SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS reseller_jobs (
escrow_ref text PRIMARY KEY,
local_job_id text,
status text NOT NULL CHECK (status IN ('received','claimed','running','submitting','submitted','settled','failed')),
provider text NOT NULL,
provider_model text NOT NULL,
marketplace_model text NOT NULL,
request_json jsonb NOT NULL,
response_json jsonb,
receipt_json jsonb,
public_preview boolean NOT NULL DEFAULT false,
prompt_preview text,
output_preview text,
balance_before_nanos bigint NOT NULL,
balance_after_nanos bigint,
worst_case_cost_nanos bigint NOT NULL,
actual_cost_nanos bigint,
price_lovelace bigint NOT NULL,
prompt_tokens integer,
completion_tokens integer,
claim_tx_hash text,
submitted_ref text,
failure_reason text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
settled_at timestamptz
);
CREATE INDEX IF NOT EXISTS reseller_jobs_status_idx
ON reseller_jobs (status, updated_at DESC);
CREATE INDEX IF NOT EXISTS reseller_jobs_created_idx
ON reseller_jobs (created_at DESC);
CREATE TABLE IF NOT EXISTS reseller_capacity (
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
provider text NOT NULL,
model text NOT NULL,
can_serve boolean NOT NULL,
reason text NOT NULL,
checked_at timestamptz,
remaining_allowance_nanos bigint NOT NULL,
reserve_nanos bigint NOT NULL,
sellable_nanos bigint NOT NULL,
worst_case_job_nanos bigint NOT NULL,
available_jobs bigint,
limit_reset text,
updated_at timestamptz NOT NULL DEFAULT now()
);
`;
export interface ReceivedJob {
escrowRef: string;
provider: string;
providerModel: string;
marketplaceModel: string;
messages: ChatMessage[];
publicPreview: boolean;
balanceBeforeUsdNanos: bigint;
worstCaseCostUsdNanos: bigint;
priceLovelace: bigint;
}
export interface RecordedOutput {
response: Record<string, unknown>;
receipt: Record<string, unknown>;
promptTokens: number;
completionTokens: number;
actualCostUsdNanos: bigint | null;
outputText: string;
}
export interface SubmittedJobRow {
escrowRef: string;
submittedRef: string;
}
export interface PublicResellerJob {
escrow_ref: string;
status: "settled" | "failed";
provider: string;
provider_model: string;
marketplace_model: string;
prompt_preview: string | null;
output_preview: string | null;
upstream_cost_usd: string | null;
ap3x_payout: string;
claim_tx_hash: string | null;
submitted_ref: string | null;
failure_reason: string | null;
created_at: string;
settled_at: string | null;
}
export interface PublicResellerTotals {
settled_jobs: number;
failed_jobs: number;
upstream_spend_usd: string;
ap3x_earned: string;
}
export class ResellerEvidenceStore {
private readonly pool: Pool;
private readonly previewMaxChars: number;
constructor(databaseUrl: string, previewMaxChars: number) {
this.pool = new Pool({ connectionString: databaseUrl, max: 4 });
this.previewMaxChars = previewMaxChars;
}
async initialize(): Promise<void> {
const client = await this.pool.connect();
try {
await client.query("SELECT pg_advisory_lock(9042713861245)");
await client.query(SCHEMA_SQL);
} finally {
await client.query("SELECT pg_advisory_unlock(9042713861245)").catch(() => undefined);
client.release();
}
}
async ping(): Promise<void> {
await this.pool.query("SELECT 1");
}
async close(): Promise<void> {
await this.pool.end();
}
async markInterruptedJobs(): Promise<void> {
await this.pool.query(
`UPDATE reseller_jobs
SET status = 'failed', failure_reason = 'interrupted_on_restart', updated_at = now()
WHERE status IN ('received','claimed','running')`,
);
}
async saveCapacity(snapshot: CapacitySnapshot): Promise<void> {
await this.pool.query(
`INSERT INTO reseller_capacity (
singleton, provider, model, can_serve, reason, checked_at,
remaining_allowance_nanos, reserve_nanos, sellable_nanos,
worst_case_job_nanos, available_jobs, limit_reset, updated_at
) VALUES (true,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,now())
ON CONFLICT (singleton) DO UPDATE SET
provider=EXCLUDED.provider, model=EXCLUDED.model,
can_serve=EXCLUDED.can_serve, reason=EXCLUDED.reason,
checked_at=EXCLUDED.checked_at,
remaining_allowance_nanos=EXCLUDED.remaining_allowance_nanos,
reserve_nanos=EXCLUDED.reserve_nanos,
sellable_nanos=EXCLUDED.sellable_nanos,
worst_case_job_nanos=EXCLUDED.worst_case_job_nanos,
available_jobs=EXCLUDED.available_jobs,
limit_reset=EXCLUDED.limit_reset, updated_at=now()`,
[
snapshot.provider,
snapshot.model,
snapshot.canServe,
snapshot.reason,
snapshot.checkedAtMs === null ? null : new Date(snapshot.checkedAtMs),
snapshot.remainingAllowanceUsdNanos.toString(),
snapshot.reserveUsdNanos.toString(),
snapshot.sellableUsdNanos.toString(),
snapshot.worstCaseJobUsdNanos.toString(),
snapshot.availableJobs?.toString() ?? null,
snapshot.limitReset,
],
);
}
async recordReceived(job: ReceivedJob): Promise<void> {
const promptPreview = job.publicPreview
? redactPublicPreview(
job.messages.map((message) => `${message.role}: ${String(message.content)}`).join(" "),
this.previewMaxChars,
)
: null;
await this.pool.query(
`INSERT INTO reseller_jobs (
escrow_ref, status, provider, provider_model, marketplace_model,
request_json, public_preview, prompt_preview, balance_before_nanos,
worst_case_cost_nanos, price_lovelace
) VALUES ($1,'received',$2,$3,$4,$5::jsonb,$6,$7,$8,$9,$10)`,
[
job.escrowRef,
job.provider,
job.providerModel,
job.marketplaceModel,
JSON.stringify(job.messages),
job.publicPreview,
promptPreview,
job.balanceBeforeUsdNanos.toString(),
job.worstCaseCostUsdNanos.toString(),
job.priceLovelace.toString(),
],
);
}
async recordClaimed(
escrowRef: string,
claimTxHash: string,
localJobId: string,
): Promise<void> {
await this.transition(
escrowRef,
["received"],
`status='claimed', claim_tx_hash=$2, local_job_id=$3`,
[claimTxHash, localJobId],
);
}
async recordInferenceStarted(escrowRef: string): Promise<void> {
await this.transition(escrowRef, ["claimed"], `status='running'`, []);
}
async recordOutput(escrowRef: string, output: RecordedOutput): Promise<void> {
const outputPreview = await this.publicPreviewEnabled(escrowRef)
? redactPublicPreview(output.outputText, this.previewMaxChars)
: null;
await this.transition(
escrowRef,
["running"],
`response_json=$2::jsonb, receipt_json=$3::jsonb,
prompt_tokens=$4, completion_tokens=$5, actual_cost_nanos=$6,
output_preview=$7`,
[
JSON.stringify(output.response),
JSON.stringify(output.receipt),
output.promptTokens,
output.completionTokens,
output.actualCostUsdNanos?.toString() ?? null,
outputPreview,
],
false,
);
}
async recordSubmitting(escrowRef: string, submittedRef: string): Promise<void> {
await this.transition(
escrowRef,
["running"],
`status='submitting', submitted_ref=$2`,
[submittedRef],
);
}
async recordSubmitConfirmed(escrowRef: string): Promise<void> {
await this.transition(
escrowRef,
["submitting"],
`status='submitted'`,
[],
);
}
async recordSettled(
escrowRef: string,
balanceAfterUsdNanos: bigint | null,
): Promise<void> {
await this.transition(
escrowRef,
["submitted"],
`status='settled', balance_after_nanos=$2, settled_at=now()`,
[balanceAfterUsdNanos?.toString() ?? null],
);
}
async recordFailed(escrowRef: string, reason: string): Promise<void> {
await this.pool.query(
`UPDATE reseller_jobs
SET status='failed', failure_reason=$2, updated_at=now()
WHERE escrow_ref=$1 AND status <> 'settled'`,
[escrowRef, reason.slice(0, 1000)],
);
}
async listSubmitting(): Promise<SubmittedJobRow[]> {
const result = await this.pool.query<{
escrow_ref: string;
submitted_ref: string;
}>(
`SELECT escrow_ref, submitted_ref FROM reseller_jobs
WHERE status='submitting' AND submitted_ref IS NOT NULL`,
);
return result.rows.map((row) => ({
escrowRef: row.escrow_ref,
submittedRef: row.submitted_ref,
}));
}
async listSubmitted(): Promise<SubmittedJobRow[]> {
const result = await this.pool.query<{
escrow_ref: string;
submitted_ref: string;
}>(
`SELECT escrow_ref, submitted_ref FROM reseller_jobs
WHERE status='submitted' AND submitted_ref IS NOT NULL`,
);
return result.rows.map((row) => ({
escrowRef: row.escrow_ref,
submittedRef: row.submitted_ref,
}));
}
async listPublicJobs(limit: number): Promise<PublicResellerJob[]> {
const safeLimit = Math.max(1, Math.min(100, Math.trunc(limit)));
const result = await this.pool.query<{
escrow_ref: string;
status: "settled" | "failed";
provider: string;
provider_model: string;
marketplace_model: string;
prompt_preview: string | null;
output_preview: string | null;
actual_cost_nanos: string | null;
price_lovelace: string;
claim_tx_hash: string | null;
submitted_ref: string | null;
failure_reason: string | null;
created_at: Date;
settled_at: Date | null;
}>(
`SELECT escrow_ref,status,provider,provider_model,marketplace_model,
prompt_preview,output_preview,actual_cost_nanos,price_lovelace,
claim_tx_hash,submitted_ref,failure_reason,created_at,settled_at
FROM reseller_jobs WHERE status IN ('settled','failed')
ORDER BY created_at DESC LIMIT $1`,
[safeLimit],
);
return result.rows.map((row) => ({
escrow_ref: row.escrow_ref,
status: row.status,
provider: row.provider,
provider_model: row.provider_model,
marketplace_model: row.marketplace_model,
prompt_preview: row.prompt_preview,
output_preview: row.output_preview,
upstream_cost_usd: row.actual_cost_nanos === null
? null
: formatUsdNanos(BigInt(row.actual_cost_nanos)),
ap3x_payout: row.status === "settled"
? formatAp3x(BigInt(row.price_lovelace))
: "0.000000",
claim_tx_hash: row.claim_tx_hash,
submitted_ref: row.submitted_ref,
failure_reason: row.failure_reason,
created_at: row.created_at.toISOString(),
settled_at: row.settled_at?.toISOString() ?? null,
}));
}
async publicTotals(): Promise<PublicResellerTotals> {
const result = await this.pool.query<{
settled_jobs: string;
failed_jobs: string;
spend_nanos: string;
earned_lovelace: string;
}>(
`SELECT
count(*) FILTER (WHERE status='settled')::text AS settled_jobs,
count(*) FILTER (WHERE status='failed')::text AS failed_jobs,
coalesce(sum(actual_cost_nanos),0)::text AS spend_nanos,
coalesce(sum(price_lovelace) FILTER (WHERE status='settled'),0)::text AS earned_lovelace
FROM reseller_jobs`,
);
const row = result.rows[0];
return {
settled_jobs: Number(row?.settled_jobs ?? 0),
failed_jobs: Number(row?.failed_jobs ?? 0),
upstream_spend_usd: formatUsdNanos(BigInt(row?.spend_nanos ?? 0)),
ap3x_earned: formatAp3x(BigInt(row?.earned_lovelace ?? 0)),
};
}
private async publicPreviewEnabled(escrowRef: string): Promise<boolean> {
const result = await this.pool.query<{ public_preview: boolean }>(
"SELECT public_preview FROM reseller_jobs WHERE escrow_ref=$1",
[escrowRef],
);
return result.rows[0]?.public_preview === true;
}
private async transition(
escrowRef: string,
expected: string[],
assignment: string,
values: unknown[],
requireStatusChange = true,
): Promise<void> {
const parameters = [escrowRef, ...values];
const expectedPlaceholders = expected
.map((_, index) => `$${parameters.length + index + 1}`)
.join(",");
parameters.push(...expected);
const result = await this.pool.query(
`UPDATE reseller_jobs SET ${assignment}, updated_at=now()
WHERE escrow_ref=$1 AND status IN (${expectedPlaceholders})`,
parameters,
);
if (result.rowCount !== 1 && requireStatusChange) {
throw new Error(
`invalid reseller job transition for ${escrowRef}; expected ${expected.join("|")}`,
);
}
if (result.rowCount !== 1 && !requireStatusChange) {
throw new Error(`reseller job not running: ${escrowRef}`);
}
}
}
export function redactPublicPreview(value: string, maxChars: number): string {
const redacted = value
.replace(
/-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/g,
"[private material]",
)
.replace(
/["']?\b[A-Z0-9_]*(?:PASSWORD|PASSWD|SECRET|TOKEN|API[_-]?KEY|PRIVATE[_-]?KEY|PRIV[_-]?KEY)\b["']?\s*[:=]\s*(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s,}]+)/gi,
"[secret assignment]",
)
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[jwt]")
.replace(/\b(?:gh[opsu]_|github_pat_)[A-Za-z0-9_]{16,}\b/g, "[secret]")
.replace(/\b[0-9a-fA-F]{64}\b/g, "[64-hex secret]")
.replace(/\b(?:sk|key)-[A-Za-z0-9_-]{8,}\b/g, "[secret]")
.replace(/\bBearer\s+\S+/gi, "Bearer [secret]")
.replace(/\bBasic\s+[A-Za-z0-9+/=]+/gi, "Basic [secret]")
.replace(/\b(?:xox[baprs]-|AKIA)[A-Za-z0-9_-]{12,}\b/g, "[secret]")
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[email]")
.replace(/https?:\/\/\S+/gi, "[url]")
.replace(/\s+/g, " ")
.trim();
return redacted.length <= maxChars
? redacted
: `${redacted.slice(0, Math.max(0, maxChars - 1))}…`;
}
function formatAp3x(lovelace: bigint): string {
const whole = lovelace / 1_000_000n;
const fraction = (lovelace % 1_000_000n).toString().padStart(6, "0");
return `${whole}.${fraction}`;
}

View File

@@ -0,0 +1,54 @@
export const USD_NANOS = 1_000_000_000n;
export type DecimalRounding = "floor" | "ceil";
/** Parse a non-negative decimal USD string into integer nanodollars. */
export function parseUsdDecimal(
raw: string,
rounding: DecimalRounding,
): bigint {
const match = /^(?:0|[1-9]\d*)(?:\.(\d+))?$/.exec(raw);
if (!match) throw new Error(`invalid USD decimal: ${raw}`);
const dot = raw.indexOf(".");
const wholeRaw = dot === -1 ? raw : raw.slice(0, dot);
const fractionRaw = dot === -1 ? "" : raw.slice(dot + 1);
const kept = fractionRaw.slice(0, 9).padEnd(9, "0");
let nanos = BigInt(wholeRaw) * USD_NANOS + BigInt(kept || "0");
if (rounding === "ceil" && /[1-9]/.test(fractionRaw.slice(9))) {
nanos += 1n;
}
return nanos;
}
/**
* Convert a provider JSON number to a conservative remaining-balance value.
* One nanodollar is removed to protect against binary floating-point rounding.
*/
export function parseUsdNumberFloor(value: unknown, field: string): bigint {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
throw new Error(`${field} must be a non-negative finite number`);
}
const scaled = BigInt(Math.floor(value * Number(USD_NANOS)));
return scaled > 0n ? scaled - 1n : 0n;
}
/** Convert a provider JSON cost to nanodollars without rounding down. */
export function parseUsdNumberCeil(value: unknown, field: string): bigint {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
throw new Error(`${field} must be a non-negative finite number`);
}
if (value === 0) return 0n;
return BigInt(Math.ceil(value * Number(USD_NANOS))) + 1n;
}
export function formatUsdNanos(value: bigint, decimals = 6): string {
if (value < 0n) throw new Error("USD value must be non-negative");
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 9) {
throw new Error("USD display decimals must be from 0 to 9");
}
const whole = value / USD_NANOS;
if (decimals === 0) return whole.toString();
const fraction = (value % USD_NANOS).toString().padStart(9, "0");
return `${whole}.${fraction.slice(0, decimals)}`;
}

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