50 Commits

Author SHA1 Message Date
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
dcejtlin-apex
d5349057b0 Merge pull request #9 from Apex-Fusion/fix/escrow-minada-floor
fix(escrow): min-ada floor so long-capability Submits settle
2026-08-10 15:08:11 +02:00
david
678ee862ea fix(docker): COPY patches/ into every builder stage
The lockfile gained patchedDependencies (libsodium-wrappers-sumo) in
2aa1688, and the package manager hashes the patch file during install —
every image build since then fails with ENOENT on /repo/patches. No
image had been rebuilt since, so it surfaced only on the next deploy.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:12:45 +02:00
FilipBl4gojevic
1f587867c6 README: Dispute Resolution naming
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:06:14 +02:00
HalukG
0b253c73bb Merge pull request #3 from FilipBl4gojevic/docs-consolidation-readme
README: marketplace front door - status, flow, layout, links
2026-07-24 13:46:46 +03:00
FilipBl4gojevic
1648edeba7 README: vocabulary pass on Related line
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:26:58 +02:00
FilipBl4gojevic
4090e064cb README: note internal audit coverage
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:07:03 +02:00
FilipBl4gojevic
3fac04b00e README: marketplace front door - status, flow, layout, links
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 10:57:27 +02:00
128 changed files with 7434 additions and 1027 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

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

View File

@@ -35,6 +35,8 @@ RUN apk add --no-cache python3 make g++
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./ 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 packages/shared/package.json ./packages/shared/package.json
COPY buyer/package.json ./buyer/package.json COPY buyer/package.json ./buyer/package.json
COPY supplier/package.json ./supplier/package.json COPY supplier/package.json ./supplier/package.json

View File

@@ -41,6 +41,13 @@ interface OperatorSource {
} }
const OPERATOR_SOURCES: 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", label: "buyer@vector-marketplace",
host: "root@91.98.147.172", host: "root@91.98.147.172",
@@ -125,6 +132,20 @@ const OPERATOR_SOURCES: OperatorSource[] = [
keyVar: "SUPPLIER_PRIV_KEY_HEX", keyVar: "SUPPLIER_PRIV_KEY_HEX",
role: "supplier", 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", label: "buyer@open-webui",
host: "open-webui", host: "open-webui",

View File

@@ -73,8 +73,10 @@ import {
buildPostEscrowTx, buildPostEscrowTx,
buildPostTtsEscrowTx, buildPostTtsEscrowTx,
buildPostChatEscrowTx, buildPostChatEscrowTx,
buildPostOcrEscrowTx,
ttsPromptHash, ttsPromptHash,
chatSessionPromptHash, chatSessionPromptHash,
ocrPromptHash,
buildAcceptTx, buildAcceptTx,
buildReclaimTx, buildReclaimTx,
TxConstructionError, TxConstructionError,
@@ -88,6 +90,8 @@ import type {
SubmitPromptResult, SubmitPromptResult,
SubmitTtsOptions, SubmitTtsOptions,
SubmitTtsResult, SubmitTtsResult,
SubmitOcrOptions,
SubmitOcrResult,
StartChatOptions, StartChatOptions,
StartChatResult, StartChatResult,
ChatSettleMode, ChatSettleMode,
@@ -807,6 +811,278 @@ export class Marketplace extends EventEmitterBase {
}; };
} }
// ─── submitOcr — full lifecycle for ocr.page.extract.<model-slug>.v1 ──
async submitOcr(opts: SubmitOcrOptions): Promise<SubmitOcrResult> {
const { advertRef, image_b64, mime, output_format, payment_lovelace } = opts;
const request = { image_b64, mime, output_format };
let advertDatum: AdvertDatum | null = null;
let escrowOutputRef: OutputReference | null = null;
let escrowRefStr = "";
let postedAtMs = Date.now();
const previewStr = `[ocr:${mime} ${output_format} ${image_b64.length}b64]`;
const recordFailure = (reason: string): void => {
this.historyStore.save({
escrow_ref: escrowRefStr || `${"0".repeat(64)}#0`,
supplier_pkh: advertDatum?.supplier_pkh ?? "",
capability_id: advertDatum?.capability_id ?? "",
prompt_preview: previewStr,
posted_at: postedAtMs,
status: "failed",
failure_reason: reason,
});
};
// ── 1. Resolve advert + post escrow (OCR prompt_hash) ─────────────
let escrowResult;
try {
const utxo = await this.chain.queryUtxo(advertRef);
if (utxo && utxo.datumHex) {
try {
advertDatum = decodeAdvertDatum(utxo.datumHex);
} catch { /* builder will re-throw structurally */ }
}
escrowResult = await buildPostOcrEscrowTx({
chain: this.chain,
buyerKey: this.walletKey,
advertRef,
request,
payment_lovelace,
});
} catch (err) {
if (err instanceof TxConstructionError) {
recordFailure(err.reason);
throw err;
}
this.emitProgress({ type: "chain_submit_failed", detail: (err as Error).message });
recordFailure((err as Error).message);
throw err;
}
escrowOutputRef = escrowResult.escrowOutputRef;
escrowRefStr = refToString(escrowOutputRef);
this.emitProgress({ type: "escrow_posted", escrow_ref: escrowRefStr });
// ── 2. Wait for the escrow tx to confirm ──────────────────────────
try {
await this.chain.awaitTx(escrowResult.expectedTxHash, 120_000);
} catch { /* mock providers / tests */ }
// ── 3. Re-fetch escrow datum to capture posted_at ─────────────────
try {
const escrowUtxo = await this.chain.queryUtxo(escrowOutputRef);
if (escrowUtxo && escrowUtxo.datumHex) {
const ed = decodeEscrowDatum(escrowUtxo.datumHex);
postedAtMs = ed.posted_at;
}
} catch { /* metadata best-effort */ }
if (!advertDatum) {
const reason = "advert datum unavailable after escrow post";
recordFailure(reason);
throw new ReceiptVerificationError(reason);
}
// ── 4. Call supplier /v1/ocr/extract ──────────────────────────────
const supplierBaseUrl = advertDatum.endpoint_url;
const supplierHttp = new HttpClient({
baseUrl: supplierBaseUrl,
fetch: this.fetchImpl,
});
const ocrBody = { image_b64, mime, output_format };
let ocrResult;
try {
ocrResult = await supplierHttp.postJson("/v1/ocr/extract", ocrBody, {
headers: { "X-Escrow-Ref": escrowRefStr },
});
} catch (err) {
if (err instanceof HttpError) {
const reason = err.kind === "timeout" ? "timeout" : "network_error";
const sErr = new SupplierError(reason, { message: err.message });
recordFailure(reason);
throw sErr;
}
recordFailure((err as Error).message);
throw err;
}
// 202 + poll mirror of submitTts's async handling. OCR inference for a
// page runs seconds; the long tail is the Submit tx confirmation.
if (ocrResult.status === 202 && ocrResult.body && typeof ocrResult.body === "object") {
const jobId = (ocrResult.body as { job_id?: string }).job_id;
if (typeof jobId !== "string" || jobId.length === 0) {
const sErr = new SupplierError("malformed_response", {
status: ocrResult.status,
message: "supplier 202 response missing job_id",
});
recordFailure("malformed_response");
throw sErr;
}
const POLL_INTERVAL_MS = 2_000;
const POLL_TIMEOUT_MS = 180_000;
const pollDeadline = Date.now() + POLL_TIMEOUT_MS;
let polled = ocrResult;
let firstIter = true;
while (Date.now() < pollDeadline) {
if (!firstIter) {
await new Promise<void>((r) => setTimeout(r, POLL_INTERVAL_MS));
}
firstIter = false;
try {
polled = await supplierHttp.getJson(`/v1/ocr/extract/${jobId}`);
} catch (err) {
if (err instanceof HttpError) {
const reason = err.kind === "timeout" ? "timeout" : "network_error";
const sErr = new SupplierError(reason, { message: err.message });
recordFailure(reason);
throw sErr;
}
throw err;
}
if (polled.status === 200) break;
if (polled.status === 202) continue;
const failBody = (polled.body && typeof polled.body === "object")
? polled.body as { reason?: string; message?: string }
: {};
const sErr = new SupplierError(failBody.reason ?? "supplier_http_error", {
status: polled.status,
message: failBody.message ?? `supplier returned ${polled.status}`,
});
recordFailure(sErr.reason);
throw sErr;
}
if (polled.status !== 200) {
const sErr = new SupplierError("timeout", {
status: polled.status,
message: `supplier job ${jobId} not done within ${POLL_TIMEOUT_MS}ms`,
});
recordFailure("timeout");
throw sErr;
}
ocrResult = polled;
}
if (!ocrResult.ok) {
const bodyReason =
ocrResult.body && typeof ocrResult.body === "object"
? ((ocrResult.body as { reason?: string }).reason ?? "supplier_http_error")
: "supplier_http_error";
const sErr = new SupplierError(bodyReason, {
status: ocrResult.status,
message: `supplier returned ${ocrResult.status}`,
});
recordFailure(bodyReason);
throw sErr;
}
if (ocrResult.parseError || !ocrResult.body || typeof ocrResult.body !== "object") {
const sErr = new SupplierError("malformed_response", {
status: ocrResult.status,
message: "supplier body is not valid JSON",
});
recordFailure("malformed_response");
throw sErr;
}
const responseBody = ocrResult.body as {
output_format?: string;
content?: string;
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
receipt?: import("@marketplace/shared/receipt").Receipt;
receipt_signature?: string;
};
if (!responseBody.content || !responseBody.receipt || !responseBody.receipt_signature) {
const sErr = new SupplierError("malformed_response", {
status: ocrResult.status,
message: "supplier response is missing content / receipt / receipt_signature",
});
recordFailure("malformed_response");
throw sErr;
}
const receipt = responseBody.receipt;
const receiptSignature = responseBody.receipt_signature;
this.emitProgress({ type: "supplier_called", escrow_ref: escrowRefStr });
// ── 5. Receipt verification ───────────────────────────────────────
if (receipt.supplier_pkh !== advertDatum.supplier_pkh) {
recordFailure("wrong_supplier");
throw new ReceiptVerificationError("wrong_supplier");
}
if (receipt.escrow_ref !== escrowRefStr) {
recordFailure("wrong_escrow_ref");
throw new ReceiptVerificationError("wrong_escrow_ref");
}
// The OCR prompt commitment uses the SAME canonical hash the supplier
// validated against the escrow datum. Recompute from the envelope we
// sent; a mismatch means the supplier signed a different request.
const expectedPromptHash = ocrPromptHash(request);
if (receipt.prompt_hash !== expectedPromptHash) {
recordFailure("prompt_hash_mismatch");
throw new ReceiptVerificationError("prompt_hash_mismatch");
}
if (receipt.model !== advertDatum.model) {
recordFailure("request_spec_hash_mismatch");
throw new ReceiptVerificationError("request_spec_hash_mismatch");
}
// The receipt's response_hash must commit the content we actually
// received in the requested output shape.
const expectedResponseHash = nodeCrypto.createHash("sha256")
.update(canonicalize({
content: responseBody.content,
output_format: responseBody.output_format ?? output_format,
}), "utf8")
.digest("hex");
if (receipt.response_hash !== expectedResponseHash) {
recordFailure("response_hash_mismatch");
throw new ReceiptVerificationError("response_hash_mismatch");
}
if (typeof receiptSignature !== "string" || !SIG_RE.test(receiptSignature)) {
recordFailure("invalid_signature");
throw new ReceiptVerificationError("invalid_signature");
}
if (receiptSignature === ZERO_SIGNATURE) {
recordFailure("invalid_signature");
throw new ReceiptVerificationError("invalid_signature");
}
if (!HEX64_RE.test(receipt.prompt_hash) || !HEX64_RE.test(receipt.response_hash)) {
recordFailure("malformed_receipt");
throw new ReceiptVerificationError("malformed_receipt");
}
this.emitProgress({ type: "receipt_verified", escrow_ref: escrowRefStr });
this.historyStore.save({
escrow_ref: escrowRefStr,
supplier_pkh: advertDatum.supplier_pkh,
capability_id: advertDatum.capability_id,
prompt_preview: previewStr,
posted_at: postedAtMs,
status: "completed",
response: responseBody.content.length <= 200
? responseBody.content
: responseBody.content.slice(0, 200),
receipt,
receipt_signature: receiptSignature,
});
return {
output_format: responseBody.output_format ?? output_format,
content: responseBody.content,
usage: {
prompt_tokens: responseBody.usage?.prompt_tokens ?? 0,
completion_tokens: responseBody.usage?.completion_tokens ?? 0,
total_tokens: responseBody.usage?.total_tokens ?? 0,
},
receipt,
receiptSignature,
escrowRef: escrowOutputRef,
};
}
// ─── startChat / endChat — multi-turn llm.chat.v1 lifecycle ──────────── // ─── startChat / endChat — multi-turn llm.chat.v1 lifecycle ────────────
/** /**

View File

@@ -187,6 +187,35 @@ export interface SubmitTtsResult {
escrowRef: OutputReference; 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 * StartChatOptions / StartChatResult — Marketplace.startChat() for the
* `llm.chat.v1` capability. Opens the escrow (session-init prompt_hash) and * `llm.chat.v1` capability. Opens the escrow (session-init prompt_hash) and

View File

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

View File

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

View File

@@ -114,7 +114,7 @@ export default function ApiKeys() {
<h1 className="text-2xl font-semibold">API Keys</h1> <h1 className="text-2xl font-semibold">API Keys</h1>
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
Generate an OpenAI-compatible API key for the marketplace gateway. Each key has its own Generate an OpenAI-compatible API key for the marketplace gateway. Each key has its own
custodial wallet — fund its deposit address with AP3X, then point any OpenAI SDK at{" "} gateway-managed wallet (the gateway holds this wallet's key) - fund its deposit address with AP3X, then point any OpenAI SDK at{" "}
<code className="font-mono text-xs">{baseUrl}</code>. <code className="font-mono text-xs">{baseUrl}</code>.
</p> </p>
</div> </div>

View File

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

View File

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

View File

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

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`, joins `apex-dashboard_apex-net` and overrides `OGMIOS_URL=ws://ogmios:1337`,
so once the supplier code stops booting `ReadOnlyOgmiosProvider` and so once the supplier code stops booting `ReadOnlyOgmiosProvider` and
starts talking to a real Ogmios, no compose changes are needed. 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

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

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

View File

@@ -59,8 +59,18 @@ services:
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.docker.network=traefik-net" - "traefik.docker.network=traefik-net"
# This host is NOT Cloudflare-proxied: traefik terminates TLS itself with
# a Let's Encrypt cert (resolver defined in /root/traefik/traefik.yml on
# the box). The :80 router only redirects; ACME HTTP-01 challenges are
# answered by traefik before the redirect applies.
- "traefik.http.routers.mp-gateway-mainnet.entrypoints=web" - "traefik.http.routers.mp-gateway-mainnet.entrypoints=web"
- "traefik.http.routers.mp-gateway-mainnet.rule=Host(`api.marketplace.vector.apexfusion.org`)" - "traefik.http.routers.mp-gateway-mainnet.rule=Host(`api.marketplace.vector.apexfusion.org`)"
- "traefik.http.routers.mp-gateway-mainnet.middlewares=mp-gateway-mainnet-https"
- "traefik.http.middlewares.mp-gateway-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-gateway-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-gateway-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-gateway-mainnet-tls.rule=Host(`api.marketplace.vector.apexfusion.org`)"
- "traefik.http.routers.mp-gateway-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-gateway-mainnet.loadbalancer.server.port=8080" - "traefik.http.services.mp-gateway-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

@@ -49,6 +49,12 @@ services:
- "traefik.docker.network=traefik-net" - "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.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.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" - "traefik.http.services.mp-suppliers-deepseek-flash-1-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

@@ -49,6 +49,12 @@ services:
- "traefik.docker.network=traefik-net" - "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.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.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" - "traefik.http.services.mp-suppliers-deepseek-flash-2-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

@@ -49,6 +49,12 @@ services:
- "traefik.docker.network=traefik-net" - "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.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.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" - "traefik.http.services.mp-suppliers-deepseek-flash-3-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

@@ -65,6 +65,12 @@ services:
- "traefik.docker.network=traefik-net" - "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-deepseek-mainnet.entrypoints=web" - "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.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" - "traefik.http.services.mp-suppliers-deepseek-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -61,6 +61,12 @@ services:
- "traefik.docker.network=traefik-net" - "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-gpt-mainnet.entrypoints=web" - "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.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" - "traefik.http.services.mp-suppliers-gpt-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file 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.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet.entrypoints=web" - "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.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" - "traefik.http.services.mp-suppliers-kimi-2-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

@@ -51,6 +51,12 @@ services:
- "traefik.docker.network=traefik-net" - "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet.entrypoints=web" - "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.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" - "traefik.http.services.mp-suppliers-kimi-3-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

@@ -51,6 +51,12 @@ services:
- "traefik.docker.network=traefik-net" - "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet.entrypoints=web" - "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.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" - "traefik.http.services.mp-suppliers-kimi-4-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

@@ -62,6 +62,12 @@ services:
- "traefik.docker.network=traefik-net" - "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.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.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" - "traefik.http.services.mp-suppliers-kimi-chat-2-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

@@ -62,6 +62,12 @@ services:
- "traefik.docker.network=traefik-net" - "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.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.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" - "traefik.http.services.mp-suppliers-kimi-chat-3-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

@@ -74,6 +74,12 @@ services:
- "traefik.docker.network=traefik-net" - "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet.entrypoints=web" - "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.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" - "traefik.http.services.mp-suppliers-kimi-chat-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

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

View File

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

View File

@@ -75,6 +75,12 @@ services:
- "traefik.docker.network=traefik-net" - "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-kimi-mainnet.entrypoints=web" - "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.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" - "traefik.http.services.mp-suppliers-kimi-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

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

View File

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

View File

@@ -65,6 +65,12 @@ services:
- "traefik.docker.network=traefik-net" - "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-tts-mainnet.entrypoints=web" - "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.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" - "traefik.http.services.mp-suppliers-tts-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

@@ -60,6 +60,12 @@ services:
- "traefik.docker.network=traefik-net" - "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-mainnet.entrypoints=web" - "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.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" - "traefik.http.services.mp-suppliers-mainnet.loadbalancer.server.port=8080"
logging: logging:
driver: json-file driver: json-file

View File

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

View File

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

Binary file not shown.

Binary file not shown.

133
docs/HETZNER_SETUP.md Normal file
View File

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

View File

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

View File

@@ -24,8 +24,8 @@ The audit confirmed 39 holes in the first draft (10 distinct themes). This docum
| One-shot | `/openai/v1/chat/completions` → `llm.text.generate.v1`, 1 escrow/call, buffered (+ pseudo-stream) | | One-shot | `/openai/v1/chat/completions` → `llm.text.generate.v1`, 1 escrow/call, buffered (+ pseudo-stream) |
| Chat session | `/openai/v1/chat/sessions*` → `llm.chat.v1`, 1 escrow/**session**, real SSE per turn, settle at close | | Chat session | `/openai/v1/chat/sessions*` → `llm.chat.v1`, 1 escrow/**session**, real SSE per turn, settle at close |
| Routing | match `model` + required capability; pick an available supplier (status `free`\|`unknown`); **not cheapest**; fallback to next match on busy/offline | | Routing | match `model` + required capability; pick an available supplier (status `free`\|`unknown`); **not cheapest**; fallback to next match on busy/offline |
| Pricing | cost = matched supplier `price_lovelace`; **required balance = price + buyer_bond + supplier_bond + 5 ADA collateral + fee reserve** | | Pricing | cost = matched supplier `price_lovelace`; **required balance = price + buyer_bond + supplier_bond + 5 AP3X collateral + fee reserve** |
| Wallet health | gateway **auto-consolidates** each wallet to `{5-ADA collateral, working}` after settle / on a tick | | Wallet health | gateway **auto-consolidates** each wallet to `{5-AP3X collateral, working}` after settle / on a tick |
| Settlement | resolve the **Submitted** ref via indexer, `acceptResult`, **await confirm** before returning success; sweeper is backstop | | Settlement | resolve the **Submitted** ref via indexer, `acceptResult`, **await confirm** before returning success; sweeper is backstop |
| Sweeper | state-aware: Open/Claimed past `deliver_by` → reclaim; Submitted → retry Accept in-window; never blind-reclaim Submitted | | Sweeper | state-aware: Open/Claimed past `deliver_by` → reclaim; Submitted → retry Accept in-window; never blind-reclaim Submitted |
| Response | OpenAI object `{id, object, created, model, choices, usage}` + `x_vector:{receipt, receipt_signature, escrow_ref}`; full streaming chunk transform + final usage chunk | | Response | OpenAI object `{id, object, created, model, choices, usage}` + `x_vector:{receipt, receipt_signature, escrow_ref}`; full streaming chunk transform + final usage chunk |
@@ -41,7 +41,7 @@ New standalone pnpm package `gateway/` (add to `pnpm-workspace.yaml`). Boots **o
- SDK methods: `discoverSuppliers`, `submitPrompt` (`Marketplace.ts:242`, verifies receipt but **does not settle**), `startChat`/`endChat` (chat session; `endChat` returns `acceptedRef` and awaits Accept), `acceptResult`, `reclaim`. - SDK methods: `discoverSuppliers`, `submitPrompt` (`Marketplace.ts:242`, verifies receipt but **does not settle**), `startChat`/`endChat` (chat session; `endChat` returns `acceptedRef` and awaits Accept), `acceptResult`, `reclaim`.
- Escrow value/bonds: buyer funds `totalLocked = price + buyer_bond + supplier_bond` at PostEscrow — `packages/shared/src/tx/escrow/postEscrow.ts:132-200`. On Accept supplier gets `payment+supplier_bond`, buyer gets `buyer_bond` back. - Escrow value/bonds: buyer funds `totalLocked = price + buyer_bond + supplier_bond` at PostEscrow — `packages/shared/src/tx/escrow/postEscrow.ts:132-200`. On Accept supplier gets `payment+supplier_bond`, buyer gets `buyer_bond` back.
- Submitted-ref resolution: the buyer `/v1/accept` queries the indexer and matches the Submitted row by `posted_at` — `buyer/src/server.ts` `/v1/accept`. Gateway must mirror this. - Submitted-ref resolution: the buyer `/v1/accept` queries the indexer and matches the Submitted row by `posted_at` — `buyer/src/server.ts` `/v1/accept`. Gateway must mirror this.
- Wallet health: `planConsolidate` / `consolidateWallet` (`{collateral, working}` shape, `DEFAULT_COLLATERAL_LOVELACE = 5 ADA`) — `packages/shared/src/tx/wallet/`. Collateral assertion: `assertCollateralCandidate` — `packages/shared/src/tx/internal/liveCbor.ts`. - Wallet health: `planConsolidate` / `consolidateWallet` (`{collateral, working}` shape, `DEFAULT_COLLATERAL_LOVELACE = 5 AP3X`) — `packages/shared/src/tx/wallet/`. Collateral assertion: `assertCollateralCandidate` — `packages/shared/src/tx/internal/liveCbor.ts`.
- Supplier chat-session SSE `{type:"token"|"done"|"error"}` + headers — `supplier/src/server.ts` `makeChatSessionHandlers`; buyer relay in `buyer/src/server.ts` `/v1/chat/message` (note: **verbatim** passthrough — the gateway must transform to OpenAI chunks). - Supplier chat-session SSE `{type:"token"|"done"|"error"}` + headers — `supplier/src/server.ts` `makeChatSessionHandlers`; buyer relay in `buyer/src/server.ts` `/v1/chat/message` (note: **verbatim** passthrough — the gateway must transform to OpenAI chunks).
- SQLite pattern (`better-sqlite3`, WAL) — `buyer/src/db/archive.ts`. Auth helpers `timingSafeCompareStrings`, `createRateLimiter` — `buyer/src/auth.ts`. - SQLite pattern (`better-sqlite3`, WAL) — `buyer/src/db/archive.ts`. Auth helpers `timingSafeCompareStrings`, `createRateLimiter` — `buyer/src/auth.ts`.
- Indexer `SupplierView` (`model`, `capability_id`, `price_lovelace`, `buyer_bond_lovelace`, `supplier_bond_lovelace`, `status` free/working/offline/unknown, `advert_status`) — `indexer/src/routes/suppliers.ts`. - Indexer `SupplierView` (`model`, `capability_id`, `price_lovelace`, `buyer_bond_lovelace`, `supplier_bond_lovelace`, `status` free/working/offline/unknown, `advert_status`) — `indexer/src/routes/suppliers.ts`.
@@ -100,7 +100,7 @@ Raw API key never persisted (only `sha256(key)`); `key_prefix` for display. `mas
## Request lifecycle — one-shot `/chat/completions` ## Request lifecycle — one-shot `/chat/completions`
All on-chain steps run inside the per-key `mutex.run(...)`. All on-chain steps run inside the per-key `mutex.run(...)`.
1. **Route** (`selectSupplier`): indexer rows where `model` matches, `capability_id==="llm.text.generate.v1"`, `advert_status==="Active"`, live `status ∈ {free, unknown}`. Pick an available one (not cheapest). No match → 404 `model_not_found` (`type: invalid_request_error`). 1. **Route** (`selectSupplier`): indexer rows where `model` matches, `capability_id==="llm.text.generate.v1"`, `advert_status==="Active"`, live `status ∈ {free, unknown}`. Pick an available one (not cheapest). No match → 404 `model_not_found` (`type: invalid_request_error`).
2. **Pre-flight**: required = `price + buyer_bond + supplier_bond + 5_000_000 (collateral) + ~2 AP3X fee`. Verify total balance **and** a pure-ADA UTxO ≥5 ADA exists (`assertCollateralCandidate`-style). Else 402 `insufficient_funds` + `x_vector:{required_lovelace, available_lovelace, collateral_ok, deposit_address}`. 2. **Pre-flight**: required = `price + buyer_bond + supplier_bond + 5_000_000 (collateral) + ~2 AP3X fee`. Verify total balance **and** a pure-AP3X UTxO ≥5 AP3X exists (`assertCollateralCandidate`-style). Else 402 `insufficient_funds` + `x_vector:{required_lovelace, available_lovelace, collateral_ok, deposit_address}`.
3. **Execute**: `submitPrompt({advertRef, messages, payment_lovelace:price, max_output_tokens})` (posts escrow → supplier → verifies receipt). 3. **Execute**: `submitPrompt({advertRef, messages, payment_lovelace:price, max_output_tokens})` (posts escrow → supplier → verifies receipt).
4. **Settle**: resolve the **Submitted** escrow ref via the indexer (mirror buyer `/v1/accept`: match by buyer pkh + `posted_at`), `acceptResult(submittedRef)`, **await confirmation**. On 409/offline mid-route, retry next matching supplier (bounded). On any post-escrow failure, record + leave for the sweeper (don't block the response on reclaim). 4. **Settle**: resolve the **Submitted** escrow ref via the indexer (mirror buyer `/v1/accept`: match by buyer pkh + `posted_at`), `acceptResult(submittedRef)`, **await confirmation**. On 409/offline mid-route, retry next matching supplier (bounded). On any post-escrow failure, record + leave for the sweeper (don't block the response on reclaim).
5. **Respond**: `{id:"chatcmpl-…", object:"chat.completion", created:<sec>, model, choices:[{index:0,message:{role:"assistant",content},finish_reason:"stop"}], usage:{prompt_tokens,completion_tokens,total_tokens}, x_vector:{receipt,receipt_signature,escrow_ref}}`. Record `usage`. 5. **Respond**: `{id:"chatcmpl-…", object:"chat.completion", created:<sec>, model, choices:[{index:0,message:{role:"assistant",content},finish_reason:"stop"}], usage:{prompt_tokens,completion_tokens,total_tokens}, x_vector:{receipt,receipt_signature,escrow_ref}}`. Record `usage`.
@@ -121,7 +121,7 @@ All on-chain steps run inside the per-key `mutex.run(...)`.
## Sweeper & wallet-health ## Sweeper & wallet-health
- **Sweeper** (interval ~60s): per key, classify escrows. Open/Claimed past `deliver_by` → `reclaim`. Submitted **with a verified receipt** still in accept-window → retry `acceptResult`. Submitted past accept-window → leave for supplier Release (log). Never blind-`reclaim` Submitted. - **Sweeper** (interval ~60s): per key, classify escrows. Open/Claimed past `deliver_by` → `reclaim`. Submitted **with a verified receipt** still in accept-window → retry `acceptResult`. Submitted past accept-window → leave for supplier Release (log). Never blind-`reclaim` Submitted.
- **Wallet-health tick**: periodically + after each settle, `planConsolidate` → `consolidateWallet` so every wallet keeps a ≥5 ADA collateral UTxO. Skip if already healthy. - **Wallet-health tick**: periodically + after each settle, `planConsolidate` → `consolidateWallet` so every wallet keeps a ≥5 AP3X collateral UTxO. Skip if already healthy.
## Errors (OpenAI shape `{error:{message, type, code, param:null}}`) ## Errors (OpenAI shape `{error:{message, type, code, param:null}}`)
no supplier → 404 `invalid_request_error`/`model_not_found`; underfunded/no-collateral → 402 `invalid_request_error`/`insufficient_funds`; bad/missing key → 401 `authentication_error`/`invalid_api_key`; rate limit → 429 `rate_limit_error` + `Retry-After`; supplier timeout → 504 `server_error`; supplier busy race (after retries) → 503 `server_error`/`overloaded`; receipt verify → 502 `server_error`/`receipt_verification_failed`; tx build → 409/502 `server_error`/`escrow_failed`; unsupported `tools`/`functions` → 400 `invalid_request_error`/`unsupported_parameter`. no supplier → 404 `invalid_request_error`/`model_not_found`; underfunded/no-collateral → 402 `invalid_request_error`/`insufficient_funds`; bad/missing key → 401 `authentication_error`/`invalid_api_key`; rate limit → 429 `rate_limit_error` + `Retry-After`; supplier timeout → 504 `server_error`; supplier busy race (after retries) → 503 `server_error`/`overloaded`; receipt verify → 502 `server_error`/`receipt_verification_failed`; tx build → 409/502 `server_error`/`escrow_failed`; unsupported `tools`/`functions` → 400 `invalid_request_error`/`unsupported_parameter`.
@@ -145,7 +145,7 @@ Real billing/markup & fee hook; trustless/self-custody recovery; multi-funding-U
## Verification (testnet, end-to-end) ## Verification (testnet, end-to-end)
1. Indexer + Ogmios + ≥1 `llm.text.generate.v1` and ≥1 `llm.chat.v1` supplier for the same `model`, Active. 1. Indexer + Ogmios + ≥1 `llm.text.generate.v1` and ≥1 `llm.chat.v1` supplier for the same `model`, Active.
2. Boot gateway: `LIVE_CHAIN=1`, `GATEWAY_MASTER_KEY=$(openssl rand -hex 32)`, `OGMIOS_URL`, `INDEXER_URL`, `NETWORK_ID`, `GATEWAY_DB_DIR`, `GATEWAY_PORT`. 2. Boot gateway: `LIVE_CHAIN=1`, `GATEWAY_MASTER_KEY=$(openssl rand -hex 32)`, `OGMIOS_URL`, `INDEXER_URL`, `NETWORK_ID`, `GATEWAY_DB_DIR`, `GATEWAY_PORT`.
3. `POST /signup` → key + deposit address. Fund ≥ price + both bonds + 5 ADA collateral + ~2 ADA fees; confirm via `GET /account` (`collateral_ok:true`). 3. `POST /signup` → key + deposit address. Fund ≥ price + both bonds + 5 AP3X collateral + ~2 AP3X fees; confirm via `GET /account` (`collateral_ok:true`).
4. One-shot non-stream via curl and via the OpenAI Python/JS SDK (`base_url=…/openai/v1`) → valid `chat.completion` + `x_vector`; confirm the escrow **settled (Accepted)** and a `usage` row exists. 4. One-shot non-stream via curl and via the OpenAI Python/JS SDK (`base_url=…/openai/v1`) → valid `chat.completion` + `x_vector`; confirm the escrow **settled (Accepted)** and a `usage` row exists.
5. **Run a 2nd and 3rd request** → confirm wallet-health kept `collateral_ok` and they succeed (the audit's request-#2 failure must not reproduce). 5. **Run a 2nd and 3rd request** → confirm wallet-health kept `collateral_ok` and they succeed (the audit's request-#2 failure must not reproduce).
6. One-shot `stream:true` → chunks + final usage + `[DONE]`. 6. One-shot `stream:true` → chunks + final usage + `[DONE]`.

View File

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

Binary file not shown.

Binary file not shown.

View File

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

View File

@@ -22,6 +22,8 @@ RUN apk add --no-cache python3 make g++
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./ 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 packages/shared/package.json ./packages/shared/package.json
COPY buyer/package.json ./buyer/package.json COPY buyer/package.json ./buyer/package.json
COPY gateway/package.json ./gateway/package.json COPY gateway/package.json ./gateway/package.json

View File

@@ -19,6 +19,26 @@ import { badRequest, forbidden } from "../openai/errors.js";
const ACTIVE_ESCROW_STATES = new Set(["Open", "Claimed", "Submitted"]); 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 { function ap3x(lovelace: bigint): string {
return (Number(lovelace) / 1e6).toFixed(2); return (Number(lovelace) / 1e6).toFixed(2);
} }
@@ -74,8 +94,15 @@ export function makeAccountHandler(deps: GatewayDeps) {
if (r.ok) { if (r.ok) {
const rows = (await r.json()) as Array<Record<string, unknown>>; const rows = (await r.json()) as Array<Record<string, unknown>>;
if (Array.isArray(rows)) { if (Array.isArray(rows)) {
for (const row of rows) { const active = rows.filter((row) => ACTIVE_ESCROW_STATES.has(String(row.state)));
if (!ACTIVE_ESCROW_STATES.has(String(row.state))) continue; 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 += lockedInEscrow +=
BigInt(String(row.payment_lovelace ?? "0")) + BigInt(String(row.payment_lovelace ?? "0")) +
BigInt(String(row.buyer_bond_lovelace ?? "0")) + BigInt(String(row.buyer_bond_lovelace ?? "0")) +

View File

@@ -53,6 +53,9 @@ export interface GatewayConfig {
walletHealthIntervalMs: number; walletHealthIntervalMs: number;
sdkRegistryMax: number; sdkRegistryMax: number;
corsOrigins: string[]; corsOrigins: string[];
/** Capability id the /v1/ocr/extract route escrows against. Model-scoped
* per the marketplace convention (one model, one id). Env: OCR_CAPABILITY_ID. */
ocrCapabilityId: string;
} }
function requireField(env: Record<string, string | undefined>, name: string): string { function requireField(env: Record<string, string | undefined>, name: string): string {
@@ -138,6 +141,7 @@ export function loadConfig(env: Record<string, string | undefined>): GatewayConf
chatSettleMode: chatSettleMode(env), chatSettleMode: chatSettleMode(env),
walletHealthIntervalMs: posInt(env, "WALLET_HEALTH_INTERVAL_MS", 10 * 60 * 1000), walletHealthIntervalMs: posInt(env, "WALLET_HEALTH_INTERVAL_MS", 10 * 60 * 1000),
sdkRegistryMax: posInt(env, "SDK_REGISTRY_MAX", 500), sdkRegistryMax: posInt(env, "SDK_REGISTRY_MAX", 500),
ocrCapabilityId: env.OCR_CAPABILITY_ID ?? "ocr.page.extract.chandra-ocr-2.v1",
corsOrigins: (env.GATEWAY_CORS_ORIGINS ?? "") corsOrigins: (env.GATEWAY_CORS_ORIGINS ?? "")
.split(",") .split(",")
.map((o) => o.trim().replace(/\/+$/, "")) .map((o) => o.trim().replace(/\/+$/, ""))

View File

@@ -57,7 +57,10 @@ export function runMain(env: Record<string, string | undefined>): void {
const invokedDirectly = const invokedDirectly =
typeof process !== "undefined" && typeof process !== "undefined" &&
process.argv?.[1] !== 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) { if (invokedDirectly) {
try { 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. * gateway/src/onchain/preflight.ts — balance + collateral pre-flight.
* *
* The buyer wallet funds the FULL escrow at PostEscrow: price + buyer_bond + * The buyer wallet funds the FULL escrow at PostEscrow: price + buyer_bond +
* supplier_bond (postEscrow.ts:132). Accept/Reclaim are Plutus script-spends * supplier_bond, raised to the Submitted-state min-ada floor so long
* that also need a pure-ADA collateral UTxO ≥ 5 ADA (assertCollateralCandidate). * capability ids (e.g. the 33-byte OCR one) don't get bumped mid-flight —
* So required = price + buyer_bond + supplier_bond + collateral + fee reserve, * see escrowLockFloor (packages/shared/.../minAdaFloor.ts) and
* AND the wallet must currently hold a pure-ADA UTxO ≥ collateral. We check both * postOcrEscrow.ts:205, the SAME floor a POST actually locks. Accept/Reclaim
* up front and return a clean 402 instead of failing mid-flight. * 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 { 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 COLLATERAL_MIN_LOVELACE = 5_000_000n;
export const FEE_RESERVE_LOVELACE = 2_000_000n; export const FEE_RESERVE_LOVELACE = 2_000_000n;
@@ -18,7 +24,7 @@ export function totalLovelace(utxos: Utxo[]): bigint {
return utxos.reduce((sum, u) => sum + u.lovelace, 0n); return utxos.reduce((sum, u) => sum + u.lovelace, 0n);
} }
/** True iff some UTxO is pure-ADA (no native assets) and ≥ collateral floor. */ /** True iff some UTxO is pure-AP3X (no native assets) and ≥ collateral floor. */
export function hasCollateral(utxos: Utxo[]): boolean { export function hasCollateral(utxos: Utxo[]): boolean {
return utxos.some( return utxos.some(
(u) => u.lovelace >= COLLATERAL_MIN_LOVELACE && Object.keys(u.assets).length === 0, (u) => u.lovelace >= COLLATERAL_MIN_LOVELACE && Object.keys(u.assets).length === 0,
@@ -32,20 +38,48 @@ export interface PreflightResult {
collateralOk: boolean; 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( export async function preflight(
chain: ChainProvider, chain: ChainProvider,
address: string, address: string,
cost: { priceLovelace: bigint; buyerBondLovelace: bigint; supplierBondLovelace: bigint }, cost: PreflightCost,
): Promise<PreflightResult> { ): Promise<PreflightResult> {
const utxos = await chain.queryUtxosByAddress(address); const utxos = await chain.queryUtxosByAddress(address);
const available = totalLovelace(utxos); const available = totalLovelace(utxos);
const collateralOk = hasCollateral(utxos); const collateralOk = hasCollateral(utxos);
const required = const economicTotal = cost.priceLovelace + cost.buyerBondLovelace + cost.supplierBondLovelace;
cost.priceLovelace + const escrowFloor = escrowLockFloor(representativeEscrowDatum(cost), economicTotal);
cost.buyerBondLovelace + const required = escrowFloor + COLLATERAL_MIN_LOVELACE + FEE_RESERVE_LOVELACE;
cost.supplierBondLovelace +
COLLATERAL_MIN_LOVELACE +
FEE_RESERVE_LOVELACE;
return { return {
ok: available >= required && collateralOk, ok: available >= required && collateralOk,
availableLovelace: available, availableLovelace: available,

View File

@@ -108,7 +108,7 @@ async function runOneShot(
throw paymentRequired( throw paymentRequired(
pf.collateralOk pf.collateralOk
? "insufficient balance for this request" ? "insufficient balance for this request"
: "wallet has no pure-ADA UTxO ≥ 5 ADA for collateral; deposit a little more ADA", : "wallet has no pure-AP3X UTxO ≥ 5 AP3X for collateral; deposit a little more AP3X",
{ {
x_vector: { x_vector: {
required_lovelace: pf.requiredLovelace.toString(), required_lovelace: pf.requiredLovelace.toString(),

View File

@@ -93,7 +93,7 @@ export async function openSessionCore(
const pf = await preflight(deps.chain, ctx.walletKey.address, primary); const pf = await preflight(deps.chain, ctx.walletKey.address, primary);
if (!pf.ok) { if (!pf.ok) {
throw paymentRequired( throw paymentRequired(
pf.collateralOk ? "insufficient balance to open a session" : "wallet has no ≥5 ADA pure-ADA collateral UTxO", pf.collateralOk ? "insufficient balance to open a session" : "wallet has no ≥5 AP3X pure-AP3X collateral UTxO",
{ {
x_vector: { x_vector: {
required_lovelace: pf.requiredLovelace.toString(), required_lovelace: pf.requiredLovelace.toString(),

View File

@@ -53,6 +53,9 @@ export function parseRef(ref: string): OutputReference | null {
export interface SelectSupplierOpts { export interface SelectSupplierOpts {
indexerUrl: string; 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; model: string;
capabilityId: string; capabilityId: string;
fetchFn?: typeof globalThis.fetch; fetchFn?: typeof globalThis.fetch;
@@ -82,7 +85,7 @@ export async function selectCandidates(opts: SelectSupplierOpts): Promise<Suppli
const candidates: SupplierCandidate[] = []; const candidates: SupplierCandidate[] = [];
for (const raw of body as SupplierView[]) { for (const raw of body as SupplierView[]) {
if (raw.capability_id !== opts.capabilityId) continue; if (raw.capability_id !== opts.capabilityId) continue;
if (raw.model !== opts.model) continue; if (opts.model !== "" && raw.model !== opts.model) continue;
if (raw.advert_status !== "Active") continue; if (raw.advert_status !== "Active") continue;
if (raw.status !== "free" && raw.status !== "unknown" && !opts.ignoreStatusFor?.has(raw.supplier_pkh)) { if (raw.status !== "free" && raw.status !== "unknown" && !opts.ignoreStatusFor?.has(raw.supplier_pkh)) {
continue; continue;

View File

@@ -15,19 +15,88 @@ import { open as unseal } from "../crypto/seal.js";
import { deriveWalletKey } from "../wallet.js"; import { deriveWalletKey } from "../wallet.js";
import type { ApiKeyRow } from "../db/store.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 { export class Mutex {
private tail: Promise<unknown> = Promise.resolve(); private tail: Promise<unknown> = Promise.resolve();
private depth = 0;
run<T>(fn: () => Promise<T>): Promise<T> { constructor(private readonly opts: { timeoutMs?: number; maxQueue?: number } = {}) {}
const result = this.tail.then(fn, fn);
// Keep the chain alive even if fn rejects; swallow here so the next run<T>(fn: () => Promise<T>, label = "op"): Promise<T> {
// run() isn't poisoned by a prior rejection. const timeoutMs = this.opts.timeoutMs ?? DEFAULT_MUTEX_TIMEOUT_MS;
this.tail = result.then( 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,
() => 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 { sendError } from "./middleware/http.js";
import { notFound } from "./openai/errors.js"; import { notFound } from "./openai/errors.js";
import { makeChatCompletionsHandler } from "./openai/chatCompletions.js"; import { makeChatCompletionsHandler } from "./openai/chatCompletions.js";
import { makeOcrExtractHandler } from "./ocr/extract.js";
import { makeModelsHandler } from "./openai/models.js"; import { makeModelsHandler } from "./openai/models.js";
import { import {
makeOpenSessionHandler, makeOpenSessionHandler,
@@ -56,7 +57,11 @@ export function createApp(deps: GatewayDeps): Express {
app.set("trust proxy", true); app.set("trust proxy", true);
// CORS before body-parsing so preflight OPTIONS short-circuits cheaply. // CORS before body-parsing so preflight OPTIONS short-circuits cheaply.
app.use(corsMiddleware(deps.config.corsOrigins)); 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) => { app.get("/healthz", (_req: Request, res: Response) => {
res.json({ ok: true }); res.json({ ok: true });
@@ -80,6 +85,12 @@ export function createApp(deps: GatewayDeps): Express {
app.post("/account/withdraw", auth, makeWithdrawHandler(deps)); app.post("/account/withdraw", auth, makeWithdrawHandler(deps));
app.post("/openai/v1/chat/completions", ...gated, makeChatCompletionsHandler(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.get("/openai/v1/models", auth, makeModelsHandler(deps));
app.post("/openai/v1/chat/sessions", ...gated, makeOpenSessionHandler(deps)); app.post("/openai/v1/chat/sessions", ...gated, makeOpenSessionHandler(deps));
app.post("/openai/v1/chat/sessions/:id/messages", ...gated, makeSessionMessageHandler(deps)); app.post("/openai/v1/chat/sessions/:id/messages", ...gated, makeSessionMessageHandler(deps));

View File

@@ -8,7 +8,7 @@ export const INDEX_HTML = `<!doctype html>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Vector Marketplace — OpenAI Gateway</title> <title>Vector Marketplace - OpenAI Gateway</title>
<style> <style>
body { font-family: system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; } body { font-family: system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; }
h1 { font-size: 1.5rem; } h2 { font-size: 1.1rem; margin-top: 2rem; } h1 { font-size: 1.5rem; } h2 { font-size: 1.1rem; margin-top: 2rem; }
@@ -22,7 +22,7 @@ export const INDEX_HTML = `<!doctype html>
</style> </style>
</head> </head>
<body> <body>
<h1>Vector Marketplace — OpenAI-compatible Gateway</h1> <h1>Vector Marketplace - OpenAI-compatible Gateway</h1>
<p class="muted">Point any OpenAI SDK at <code id="base"></code> using your API key as the bearer token.</p> <p class="muted">Point any OpenAI SDK at <code id="base"></code> using your API key as the bearer token.</p>
<h2>1. Create an API key</h2> <h2>1. Create an API key</h2>
@@ -33,7 +33,7 @@ export const INDEX_HTML = `<!doctype html>
<div id="signupOut"></div> <div id="signupOut"></div>
<h2>2. Fund your wallet</h2> <h2>2. Fund your wallet</h2>
<p class="muted">Send AP3X to your deposit address (you need price + bonds + ~5 ADA collateral + fees).</p> <p class="muted">Send AP3X to your deposit address (you need price + bonds + ~5 AP3X collateral + fees).</p>
<h2>3. Account</h2> <h2>3. Account</h2>
<div class="row"> <div class="row">

View File

@@ -2,8 +2,8 @@
* gateway/src/walletHealth.ts — keep each custodial wallet usable. * gateway/src/walletHealth.ts — keep each custodial wallet usable.
* *
* After every settled request (and on a periodic tick) we re-shape the wallet to * After every settled request (and on a periodic tick) we re-shape the wallet to
* {≥5 ADA collateral UTxO, working UTxO} via the shared consolidate builder, so * {≥5 AP3X collateral UTxO, working UTxO} via the shared consolidate builder, so
* the NEXT request's Accept/Reclaim script-spend always finds a pure-ADA * the NEXT request's Accept/Reclaim script-spend always finds a pure-AP3X
* collateral candidate. No-op when the wallet is already healthy. * collateral candidate. No-op when the wallet is already healthy.
*/ */

View File

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

View File

@@ -208,8 +208,16 @@ describe("onchain/preflight", () => {
}); });
it("preflight requires balance AND collateral", async () => { it("preflight requires balance AND collateral", async () => {
const cost = { priceLovelace: 2_000_000n, buyerBondLovelace: 1_000_000n, supplierBondLovelace: 1_000_000n }; const cost = {
// required = 2 + 1 + 1 + 5 (collateral) + 2 (fee) = 11 ADA 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 fakeChain = { queryUtxosByAddress: async () => [pure(20_000_000n)] } as any;
const ok = await preflight(fakeChain, "a", cost); const ok = await preflight(fakeChain, "a", cost);
expect(ok.ok).toBe(true); 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 RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./ 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 packages/shared/package.json ./packages/shared/package.json
COPY indexer/package.json ./indexer/package.json COPY indexer/package.json ./indexer/package.json
COPY indexer-ui/package.json ./indexer-ui/package.json COPY indexer-ui/package.json ./indexer-ui/package.json

View File

@@ -37,5 +37,10 @@
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vitest": "^2.1.8" "vitest": "^2.1.8"
},
"pnpm": {
"patchedDependencies": {
"libsodium-wrappers-sumo@0.7.16": "patches/libsodium-wrappers-sumo@0.7.16.patch"
}
} }
} }

View File

@@ -42,6 +42,7 @@ import { loadBlueprint } from "../blueprint.js";
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js"; import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js"; import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js";
import { detectCborBackend } from "../internal/cborBackend.js"; import { detectCborBackend } from "../internal/cborBackend.js";
import { escrowLockFloor } from "../internal/minAdaFloor.js";
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js"; import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
export interface PostChatEscrowParams { export interface PostChatEscrowParams {
@@ -132,7 +133,7 @@ export async function buildPostChatEscrowTx(
const requestSpecHash = sha256Utf8Hex(requestSpecCanonical); const requestSpecHash = sha256Utf8Hex(requestSpecCanonical);
const promptHash = chatSessionPromptHash({ session_nonce }); const promptHash = chatSessionPromptHash({ session_nonce });
const totalLocked = const economicTotal =
advertDatum.price_lovelace + advertDatum.price_lovelace +
advertDatum.buyer_bond_lovelace + advertDatum.buyer_bond_lovelace +
advertDatum.supplier_bond_lovelace; advertDatum.supplier_bond_lovelace;
@@ -154,6 +155,11 @@ export async function buildPostChatEscrowTx(
state: "Open", 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 (min-ada floor).
const totalLocked = escrowLockFloor(escrowDatum, economicTotal);
// Live path: reuse the capability-agnostic chat builder (messages = []). // Live path: reuse the capability-agnostic chat builder (messages = []).
// The on-chain commitment is the escrowDatum (carrying our session-init // The on-chain commitment is the escrowDatum (carrying our session-init
// prompt_hash); messages are only used for the mock cbor body, which we omit. // prompt_hash); messages are only used for the mock cbor body, which we omit.

View File

@@ -38,6 +38,7 @@ import { loadBlueprint } from "../blueprint.js";
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js"; import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js"; import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js";
import { detectCborBackend } from "../internal/cborBackend.js"; import { detectCborBackend } from "../internal/cborBackend.js";
import { escrowLockFloor } from "../internal/minAdaFloor.js";
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js"; import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
export interface PostEscrowParams { export interface PostEscrowParams {
@@ -129,7 +130,7 @@ export async function buildPostEscrowTx(
// supplier-side validation in M1-C can verify role/content/system integrity. // supplier-side validation in M1-C can verify role/content/system integrity.
const promptHash = sha256Utf8Hex(canonicalize(messages)); const promptHash = sha256Utf8Hex(canonicalize(messages));
const totalLocked = const economicTotal =
advertDatum.price_lovelace + advertDatum.price_lovelace +
advertDatum.buyer_bond_lovelace + advertDatum.buyer_bond_lovelace +
advertDatum.supplier_bond_lovelace; advertDatum.supplier_bond_lovelace;
@@ -151,6 +152,11 @@ export async function buildPostEscrowTx(
state: "Open", 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 (min-ada floor).
const totalLocked = escrowLockFloor(escrowDatum, economicTotal);
// Live path: produce real Cardano CBOR via lucid-evolution. // Live path: produce real Cardano CBOR via lucid-evolution.
if (detectCborBackend(chain) === "live") { if (detectCborBackend(chain) === "live") {
const liveCborPath = "../internal/liveCbor.js"; const liveCborPath = "../internal/liveCbor.js";

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

@@ -31,6 +31,7 @@ import { loadBlueprint } from "../blueprint.js";
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js"; import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js"; import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js";
import { detectCborBackend } from "../internal/cborBackend.js"; import { detectCborBackend } from "../internal/cborBackend.js";
import { escrowLockFloor } from "../internal/minAdaFloor.js";
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js"; import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
/** TTS request envelope. The buyer-side hash and the supplier-side hash /** TTS request envelope. The buyer-side hash and the supplier-side hash
@@ -149,7 +150,7 @@ export async function buildPostTtsEscrowTx(
const requestSpecHash = sha256Utf8Hex(requestSpecCanonical); const requestSpecHash = sha256Utf8Hex(requestSpecCanonical);
const promptHash = ttsPromptHash(request); const promptHash = ttsPromptHash(request);
const totalLocked = const economicTotal =
advertDatum.price_lovelace + advertDatum.price_lovelace +
advertDatum.buyer_bond_lovelace + advertDatum.buyer_bond_lovelace +
advertDatum.supplier_bond_lovelace; advertDatum.supplier_bond_lovelace;
@@ -171,6 +172,11 @@ export async function buildPostTtsEscrowTx(
state: "Open", 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 (min-ada floor).
const totalLocked = escrowLockFloor(escrowDatum, economicTotal);
// Live path: reuse the chat builder. The live cbor path is already // Live path: reuse the chat builder. The live cbor path is already
// capability-agnostic — `messages` is `void`-marked there. We pass an // capability-agnostic — `messages` is `void`-marked there. We pass an
// empty array so the type checker is satisfied; the actual on-chain // empty array so the type checker is satisfied; the actual on-chain

View File

@@ -17,6 +17,8 @@ import type { WalletKey, BuildResult } from "../types.js";
import { TxConstructionError } from "../types.js"; import { TxConstructionError } from "../types.js";
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js"; import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
import { ACCEPT_WINDOW_MS, mockSlotToWallclockMs } from "../internal/constants.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 { export interface ReleaseParams {
chain: ChainProvider; chain: ChainProvider;
@@ -65,8 +67,10 @@ export async function buildReleaseTx(
} }
// 4. Tip must be at or past submitted_at + ACCEPT_WINDOW. // 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 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; const threshold = datum.submitted_at + ACCEPT_WINDOW_MS;
if (tipMs < threshold) { if (tipMs < threshold) {
throw new TxConstructionError( 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 = const supplierDue =
datum.payment_lovelace + datum.supplier_bond_lovelace + datum.buyer_bond_lovelace; datum.payment_lovelace + datum.supplier_bond_lovelace + datum.buyer_bond_lovelace;

View File

@@ -7,6 +7,7 @@ export { TxConstructionError } from "./types.js";
export { normalizeChatMessage, chatMessagesEquivalent, toolCallsEqual } from "./chatMessage.js"; export { normalizeChatMessage, chatMessagesEquivalent, toolCallsEqual } from "./chatMessage.js";
export { mockSlotToWallclockMs, mockWallclockMsToSlot, NETWORK_BUFFER_MS } from "./internal/constants.js"; export { mockSlotToWallclockMs, mockWallclockMsToSlot, NETWORK_BUFFER_MS } from "./internal/constants.js";
export { detectCborBackend, type CborBackend } from "./internal/cborBackend.js"; export { detectCborBackend, type CborBackend } from "./internal/cborBackend.js";
export { escrowLockFloor, minAdaForEscrowDatum } from "./internal/minAdaFloor.js";
export type { Signer } from "./signer.js"; export type { Signer } from "./signer.js";
export { MockSigner } from "./signer.js"; export { MockSigner } from "./signer.js";
export type { Blueprint } from "./blueprint.js"; export type { Blueprint } from "./blueprint.js";
@@ -37,6 +38,15 @@ export {
} from "./escrow/postTtsEscrow.js"; } from "./escrow/postTtsEscrow.js";
export type { PostChatEscrowParams } from "./escrow/postChatEscrow.js"; export type { PostChatEscrowParams } from "./escrow/postChatEscrow.js";
export { buildPostChatEscrowTx, chatSessionPromptHash } 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 type { ClaimParams } from "./escrow/claim.js";
export { buildClaimTx } from "./escrow/claim.js"; export { buildClaimTx } from "./escrow/claim.js";
export type { SubmitParams } from "./escrow/submit.js"; export type { SubmitParams } from "./escrow/submit.js";

View File

@@ -10,8 +10,8 @@
* for vkh enterprise). Input UTxOs may carry testnet-prefixed addresses — lucid * for vkh enterprise). Input UTxOs may carry testnet-prefixed addresses — lucid
* does not validate address network for inputs (only for outputs). * does not validate address network for inputs (only for outputs).
* *
* Deferred builders (postAdvert, updateAdvert, retireAdvert, reclaim, release) * Deferred builders: only updateAdvert remains on the synthetic testTxBody
* are NOT touched in M1-F-4. Those continue using the synthetic testTxBody path. * path. postAdvert, retireAdvert, reclaim, and release all have live builders.
* *
* Catherine M1-F-4-green. * Catherine M1-F-4-green.
*/ */
@@ -61,6 +61,7 @@ const REDEEMER_CLAIM = 121;
const REDEEMER_SUBMIT = 122; const REDEEMER_SUBMIT = 122;
const REDEEMER_ACCEPT = 123; const REDEEMER_ACCEPT = 123;
const REDEEMER_RECLAIM = 124; const REDEEMER_RECLAIM = 124;
const REDEEMER_RELEASE = 125;
// AdvertRedeemer Constr indices per lib/marketplace/types.ak: // AdvertRedeemer Constr indices per lib/marketplace/types.ak:
// PostAdvert → Constr0 (tag 121, creation event — never seen by spend) // PostAdvert → Constr0 (tag 121, creation event — never seen by spend)
@@ -121,6 +122,15 @@ export interface LiveReclaimParams {
tipMs: number; tipMs: number;
} }
export interface LiveReleaseParams {
chain: LiveOgmiosProvider;
supplierKey: WalletKey;
escrowRef: OutputReference;
escrowUtxo: Utxo;
datum: EscrowDatum;
tipMs: number;
}
export interface LivePostAdvertParams { export interface LivePostAdvertParams {
chain: LiveOgmiosProvider; chain: LiveOgmiosProvider;
walletKey: WalletKey; walletKey: WalletKey;
@@ -778,6 +788,77 @@ export async function buildLiveTxForReclaim(params: LiveReclaimParams): Promise<
return { txCborHex, expectedTxHash }; 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 ────────────────────────────────────────────────────── // ─── PostAdvert ──────────────────────────────────────────────────────
export async function buildLiveTxForAdvert( export async function buildLiveTxForAdvert(

View File

@@ -0,0 +1,67 @@
/**
* minAdaFloor.ts — min-ada floor for the escrow's LARGEST future datum.
*
* The escrow validator requires value_equal(continuing, in) on Claim and
* Submit, so the lovelace locked at POST time must already satisfy min-ada
* for every datum the output will ever carry. The largest is the Submitted
* state: submitted_at None -> Some(POSIX ms) and result_receipt_hash
* None -> Some(32 bytes) add ~43 bytes over Open/Claimed. If the posted
* lovelace sits below that state's min-ada, lucid bumps the Submit
* continuing output to min-ada, value_equal fails, and the validator
* errors with no traces (the 2026-08-07 mainnet incident: the 33-byte
* "ocr.page.extract.chandra-ocr-2.v1" capability_id pushed the Submitted
* output 11,030 lovelace over a 2,200,000 lock; the 20-byte chat
* capability stayed 49,310 under, which is why only OCR Submits failed).
*
* escrowLockFloor() returns the lovelace to lock: the economic total
* (payment + both bonds), raised to the Submitted-state min-ada plus a
* safety margin when the total alone is too small.
*/
import type { EscrowDatum } from "../../cbor/types.js";
import { encodeEscrowDatum } from "../../cbor/EscrowDatum.js";
/** Conway/Babbage utxoCostPerByte on Vector mainnet (protocol params). */
const UTXO_COST_PER_BYTE = 4310n;
/** Ledger constant: min-ada = (160 + serialized output bytes) * costPerByte. */
const OUTPUT_OVERHEAD_BYTES = 160n;
/** Covers CBOR framing drift between this estimate and the ledger's exact
* serialization (address, coin width, datum wrapper). Generous on purpose:
* 50k lovelace = 0.05 AP3X, dwarfed by per-leg tx fees. */
const SAFETY_MARGIN_LOVELACE = 50_000n;
/** Serialized-output byte estimate for an enterprise script address output
* carrying `lovelace` and an inline datum of `datumBytes`. Mirrors the
* arithmetic validated against mainnet on 2026-08-07 (minada-calc). */
function estimateOutputBytes(datumBytes: number, lovelace: bigint): bigint {
const addr = 1n + 29n;
const coin = 1n + (lovelace > 0xffffffffn ? 8n : 4n);
const datumField = 1n + 2n + 3n + BigInt(datumBytes);
const mapOverhead = 3n;
return addr + coin + datumField + mapOverhead;
}
/** Min-ada for the escrow output when it carries `datum`. */
export function minAdaForEscrowDatum(datum: EscrowDatum, lovelace: bigint): bigint {
const datumBytes = encodeEscrowDatum(datum).length / 2;
const size = estimateOutputBytes(datumBytes, lovelace);
return (OUTPUT_OVERHEAD_BYTES + size) * UTXO_COST_PER_BYTE;
}
/** The lovelace a POST must lock so the escrow output satisfies min-ada in
* every reachable state, including Submitted. `economicTotal` is
* payment + buyer_bond + supplier_bond. */
export function escrowLockFloor(openDatum: EscrowDatum, economicTotal: bigint): bigint {
const submittedShape: EscrowDatum = {
...openDatum,
// Representative worst-case Submit fields: a 13-digit POSIX ms stamp
// and a 32-byte receipt hash — sizes, not values, matter here.
submitted_at: openDatum.deliver_by,
result_receipt_hash: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
state: "Submitted",
};
const floor = minAdaForEscrowDatum(submittedShape, economicTotal) + SAFETY_MARGIN_LOVELACE;
return floor > economicTotal ? floor : economicTotal;
}

View File

@@ -2,9 +2,22 @@
* planConsolidate.ts — pure pre-flight planner for the wallet-consolidate flow. * planConsolidate.ts — pure pre-flight planner for the wallet-consolidate flow.
* *
* Given a wallet's current UTxO set + a target collateral lovelace, decide * Given a wallet's current UTxO set + a target collateral lovelace, decide
* whether to consolidate (many → 2), split (1 → 2), or skip (already at * whether the wallet needs reshaping or is already usable for script spends.
* {≥collateral, working}). The same downstream tx-builder handles *
* consolidate and split — only the "reason" tag differs for logging. * 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. * 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_COLLATERAL_LOVELACE = 5_000_000n;
export const DEFAULT_FEE_RESERVE = 2_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; 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( export function planConsolidate(
utxos: UtxoLike[], utxos: UtxoLike[],
collateralLovelace: bigint = DEFAULT_COLLATERAL_LOVELACE, collateralLovelace: bigint = DEFAULT_COLLATERAL_LOVELACE,
feeReserve: bigint = DEFAULT_FEE_RESERVE, feeReserve: bigint = DEFAULT_FEE_RESERVE,
maxUtxos: number = DEFAULT_MAX_UTXOS,
): PlannedConsolidate { ): PlannedConsolidate {
if (utxos.length === 0) { if (utxos.length === 0) {
throw new Error("wallet has no UTxOs — fund the address first"); throw new Error("wallet has no UTxOs — fund the address first");
@@ -42,6 +67,32 @@ export function planConsolidate(
0n, 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; const required = collateralLovelace + feeReserve + MIN_WORKING_LOVELACE;
if (totalLovelaceIn < required) { if (totalLovelaceIn < required) {
throw new Error( 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; const workingOutput = totalLovelaceIn - collateralLovelace - feeReserve;
return { return {

File diff suppressed because one or more lines are too long

9
pnpm-lock.yaml generated
View File

@@ -4,6 +4,11 @@ settings:
autoInstallPeers: true autoInstallPeers: true
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
patchedDependencies:
libsodium-wrappers-sumo@0.7.16:
hash: 761a204004f889b65fdf148159f4bd90733703d63a0ff00eb0671c92ea085096
path: patches/libsodium-wrappers-sumo@0.7.16.patch
importers: importers:
.: .:
@@ -3166,7 +3171,7 @@ snapshots:
'@cardano-sdk/util': 0.16.0 '@cardano-sdk/util': 0.16.0
blake2b: 2.1.4 blake2b: 2.1.4
i: 0.3.7 i: 0.3.7
libsodium-wrappers-sumo: 0.7.16 libsodium-wrappers-sumo: 0.7.16(patch_hash=761a204004f889b65fdf148159f4bd90733703d63a0ff00eb0671c92ea085096)
lodash: 4.18.1 lodash: 4.18.1
npm: 9.9.4 npm: 9.9.4
pbkdf2: 3.1.5 pbkdf2: 3.1.5
@@ -4743,7 +4748,7 @@ snapshots:
libsodium-sumo@0.7.16: {} libsodium-sumo@0.7.16: {}
libsodium-wrappers-sumo@0.7.16: libsodium-wrappers-sumo@0.7.16(patch_hash=761a204004f889b65fdf148159f4bd90733703d63a0ff00eb0671c92ea085096):
dependencies: dependencies:
libsodium-sumo: 0.7.16 libsodium-sumo: 0.7.16

View File

@@ -6,3 +6,6 @@ packages:
- "indexer" - "indexer"
- "indexer-ui" - "indexer-ui"
- "wallet-monitor" - "wallet-monitor"
onlyBuiltDependencies:
- better-sqlite3

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

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

@@ -27,6 +27,8 @@ RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
# Workspace manifests first to maximise layer cache reuse. # Workspace manifests first to maximise layer cache reuse.
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./ 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 packages/shared/package.json ./packages/shared/package.json
COPY supplier/package.json ./supplier/package.json COPY supplier/package.json ./supplier/package.json
# Buyer + indexer manifests are needed because pnpm-lock.yaml indexes the # Buyer + indexer manifests are needed because pnpm-lock.yaml indexes the

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

@@ -36,6 +36,15 @@
* OLLAMA_TIMEOUT_MS — ollama request timeout in ms (default 120_000) * OLLAMA_TIMEOUT_MS — ollama request timeout in ms (default 120_000)
* OPENAI_TIMEOUT_MS — openai request timeout in ms (default 180_000 — GPT * OPENAI_TIMEOUT_MS — openai request timeout in ms (default 180_000 — GPT
* first-token latency is longer than local Ollama) * 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) * PIPER_TIMEOUT_MS — piper request timeout in ms (default 120_000)
* LIVE_CHAIN — "1" opts in to live-chain submit/await (default off). * LIVE_CHAIN — "1" opts in to live-chain submit/await (default off).
* Any other value (including "true", "yes", "TRUE") * Any other value (including "true", "yes", "TRUE")
@@ -72,8 +81,26 @@ export interface AdvertRef {
* "chat" — one-off `llm.text.generate.v1` (single prompt → single completion) * "chat" — one-off `llm.text.generate.v1` (single prompt → single completion)
* "tts" — `audio.synthesize.piper.v1` * "tts" — `audio.synthesize.piper.v1`
* "chat-session" — multi-turn `llm.chat.v1` (escrow bookends, off-chain turns, * "chat-session" — multi-turn `llm.chat.v1` (escrow bookends, off-chain turns,
* streaming). Requires LLM_BACKEND="openai". */ * streaming). Requires LLM_BACKEND="openai".
export type CapabilityKind = "chat" | "tts" | "chat-session"; * "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. */ /** LLM backend selected for the chat capability. */
export type LlmBackend = "ollama" | "openai"; export type LlmBackend = "ollama" | "openai";
@@ -111,6 +138,28 @@ export interface SupplierConfig {
* the model context or the provider 400s (e.g. kimi context = 262144). * the model context or the provider 400s (e.g. kimi context = 262144).
*/ */
openaiMaxTokens: number; 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". */ /** Empty string when capabilityKind="chat". */
piperUrl: string; piperUrl: string;
advertRef: AdvertRef; advertRef: AdvertRef;
@@ -135,6 +184,37 @@ export interface SupplierConfig {
maxChatSessions: number; maxChatSessions: number;
/** Chain settlement mode for chat sessions. Default "full". */ /** Chain settlement mode for chat sessions. Default "full". */
chatSettleMode: ChatSettleMode; 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). * When true, the supplier boots a LiveOgmiosProvider (real submitTx/awaitTx).
* Default false → ReadOnlyOgmiosProvider (safe; no chain writes). * Default false → ReadOnlyOgmiosProvider (safe; no chain writes).
@@ -186,8 +266,13 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
// CAPABILITY_KIND drives whether OLLAMA_URL or PIPER_URL is required. // CAPABILITY_KIND drives whether OLLAMA_URL or PIPER_URL is required.
// Default to "chat" so existing chat suppliers keep booting unchanged. // Default to "chat" so existing chat suppliers keep booting unchanged.
const capKindStr = env.CAPABILITY_KIND ?? "chat"; const capKindStr = env.CAPABILITY_KIND ?? "chat";
if (capKindStr !== "chat" && capKindStr !== "tts" && capKindStr !== "chat-session") { if (
throw new Error('loadConfig: CAPABILITY_KIND must be "chat", "tts", or "chat-session"'); 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; const capabilityKind: CapabilityKind = capKindStr;
@@ -216,6 +301,19 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
openaiBaseUrl = requireField(env, "OPENAI_BASE_URL"); openaiBaseUrl = requireField(env, "OPENAI_BASE_URL");
ollamaUrl = env.OLLAMA_URL ?? ""; ollamaUrl = env.OLLAMA_URL ?? "";
piperUrl = env.PIPER_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") { } else if (capabilityKind === "chat") {
if (llmBackend === "openai") { if (llmBackend === "openai") {
openaiBaseUrl = requireField(env, "OPENAI_BASE_URL"); openaiBaseUrl = requireField(env, "OPENAI_BASE_URL");
@@ -312,6 +410,22 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
openaiMaxTokens = Number(maxTokStr); 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). // CHAT_IDLE_TIMEOUT_MS — idle auto-end window for chat sessions (default 5 min).
const chatIdleStr = env.CHAT_IDLE_TIMEOUT_MS; const chatIdleStr = env.CHAT_IDLE_TIMEOUT_MS;
let chatIdleTimeoutMs = 300_000; let chatIdleTimeoutMs = 300_000;
@@ -348,6 +462,52 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
} }
const chatSettleMode: ChatSettleMode = settleModeStr; 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; const piperTimeoutStr = env.PIPER_TIMEOUT_MS;
let piperTimeoutMs = 120_000; let piperTimeoutMs = 120_000;
if (piperTimeoutStr !== undefined && piperTimeoutStr !== "") { if (piperTimeoutStr !== undefined && piperTimeoutStr !== "") {
@@ -380,6 +540,8 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
openaiApiKey, openaiApiKey,
openaiReasoningDisabled, openaiReasoningDisabled,
openaiMaxTokens, openaiMaxTokens,
openaiSessionPassthrough,
openaiModelOverride,
piperUrl, piperUrl,
advertRef, advertRef,
networkId, networkId,
@@ -392,6 +554,15 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
chatIdleTimeoutMs, chatIdleTimeoutMs,
maxChatSessions, maxChatSessions,
chatSettleMode, chatSettleMode,
ocrUpstream,
datalabApiKey,
datalabBaseUrl,
datalabMode,
datalabTimeoutMs,
ocrPromptOverride,
hfModelRevision,
revisionProbeUrl,
revisionProbeMode,
liveChain, liveChain,
walletHealthIntervalMs, walletHealthIntervalMs,
}; };

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

@@ -38,11 +38,15 @@ import type { SupplierConfig } from "./config.js";
import * as ollama from "./ollama.js"; import * as ollama from "./ollama.js";
import * as openai from "./openai.js"; import * as openai from "./openai.js";
import * as piper from "./piper.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 { import type {
JobStore, JobStore,
JobResponsePayload, JobResponsePayload,
ChatJobResponsePayload, ChatJobResponsePayload,
TtsJobResponsePayload, TtsJobResponsePayload,
OcrJobResponsePayload,
} from "./jobs.js"; } from "./jobs.js";
export interface RunChatJobDeps { export interface RunChatJobDeps {
@@ -88,7 +92,7 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
if (deps.config.llmBackend === "openai") { if (deps.config.llmBackend === "openai") {
inference = await openai.callOpenAi({ inference = await openai.callOpenAi({
baseUrl: deps.config.openaiBaseUrl, baseUrl: deps.config.openaiBaseUrl,
model: advert.model, model: deps.config.openaiModelOverride || advert.model,
messages: requestBody.messages, messages: requestBody.messages,
timeoutMs: deps.config.openaiTimeoutMs, timeoutMs: deps.config.openaiTimeoutMs,
apiKey: deps.config.openaiApiKey, apiKey: deps.config.openaiApiKey,
@@ -364,3 +368,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; 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. * Discriminated union of every job-payload shape the store can hold.
* `kind` defaults to "chat" on the chat shape (older code paths don't * `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 * set it; new `runChatJob` writes can set kind: "chat" explicitly to make
* the discriminator narrowing reliable). * the discriminator narrowing reliable).
*/ */
export type JobResponsePayload = ChatJobResponsePayload | TtsJobResponsePayload; export type JobResponsePayload =
| ChatJobResponsePayload
| TtsJobResponsePayload
| OcrJobResponsePayload;
export interface JobFailure { export interface JobFailure {
httpStatus: number; 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). * context window (some providers 400 when it exceeds context).
*/ */
maxTokens?: number; 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 * When true, sends `reasoning: { enabled: false }` to disable OpenRouter
* "thinking" tokens — faster/cheaper straight answers, and avoids reasoning * "thinking" tokens — faster/cheaper straight answers, and avoids reasoning
@@ -93,11 +100,12 @@ function isAbortError(err: unknown): boolean {
} }
export async function callOpenAi(params: CallOpenAiParams): Promise<OpenAiResult> { 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 url = `${baseUrl}/v1/chat/completions`;
const payload: Record<string, unknown> = { model, messages, stream: false }; const payload: Record<string, unknown> = { model, messages, stream: false };
if (typeof maxTokens === "number" && maxTokens > 0) payload.max_tokens = maxTokens; if (typeof maxTokens === "number" && maxTokens > 0) payload.max_tokens = maxTokens;
if (disableReasoning) payload.reasoning = { enabled: false }; if (disableReasoning) payload.reasoning = { enabled: false };
if (user) payload.user = user;
const body = JSON.stringify(payload); const body = JSON.stringify(payload);
const headers: Record<string, string> = { "content-type": "application/json" }; const headers: Record<string, string> = { "content-type": "application/json" };
@@ -215,7 +223,7 @@ export async function callOpenAiStream(
params: CallOpenAiParams, params: CallOpenAiParams,
onToken: (delta: string) => void, onToken: (delta: string) => void,
): Promise<OpenAiResult> { ): 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 url = `${baseUrl}/v1/chat/completions`;
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
model, model,
@@ -225,6 +233,7 @@ export async function callOpenAiStream(
}; };
if (typeof maxTokens === "number" && maxTokens > 0) payload.max_tokens = maxTokens; if (typeof maxTokens === "number" && maxTokens > 0) payload.max_tokens = maxTokens;
if (disableReasoning) payload.reasoning = { enabled: false }; if (disableReasoning) payload.reasoning = { enabled: false };
if (user) payload.user = user;
if (Array.isArray(tools) && tools.length > 0) { if (Array.isArray(tools) && tools.length > 0) {
payload.tools = tools; payload.tools = tools;
if (toolChoice !== undefined) payload.tool_choice = toolChoice; if (toolChoice !== undefined) payload.tool_choice = toolChoice;

View File

@@ -44,12 +44,19 @@ import {
mockSlotToWallclockMs, mockSlotToWallclockMs,
detectCborBackend, detectCborBackend,
normalizeChatMessage, normalizeChatMessage,
ocrPromptHash,
ALLOWED_OCR_MIMES,
ALLOWED_OCR_OUTPUT_FORMATS,
MAX_OCR_IMAGE_B64_CHARS,
} from "@marketplace/shared/tx"; } from "@marketplace/shared/tx";
import type { OcrRequest } from "@marketplace/shared/tx";
import type { SupplierState } from "./state.js"; import type { SupplierState } from "./state.js";
import type { SupplierConfig } from "./config.js"; import type { SupplierConfig } from "./config.js";
import { JobStore } from "./jobs.js"; import { JobStore } from "./jobs.js";
import { runChatJob, runTtsJob } from "./jobRunner.js"; import { runChatJob, runTtsJob, runOcrJob } from "./jobRunner.js";
import { probeRevision } from "./ocrVision.js";
import { probeDatalabHealth } from "./datalabOcr.js";
import { ChatSessionStore, type ChatSessionRecord } from "./chatSession.js"; import { ChatSessionStore, type ChatSessionRecord } from "./chatSession.js";
import { endChatSession, type EndChatSessionDeps } from "./chatSessionRunner.js"; import { endChatSession, type EndChatSessionDeps } from "./chatSessionRunner.js";
import { callOpenAiStream } from "./openai.js"; import { callOpenAiStream } from "./openai.js";
@@ -427,9 +434,9 @@ function makeGetJobHandler(deps: ResolvedDeps) {
// Defensive narrowing: this poll route only knows how to render the // Defensive narrowing: this poll route only knows how to render the
// chat shape. A future shared JobStore could mix capabilities; we'd // chat shape. A future shared JobStore could mix capabilities; we'd
// want each kind's poll to be sure it's reading its own. // want each kind's poll to be sure it's reading its own.
if ("audio_b64" in payload) { if (payload.kind === "ocr" || "audio_b64" in payload) {
return jsonError(res, 500, "wrong_payload_kind", return jsonError(res, 500, "wrong_payload_kind",
"tts payload returned to chat poll route"); "non-chat payload returned to chat poll route");
} }
return res.status(200).json({ return res.status(200).json({
choices: payload.choices, choices: payload.choices,
@@ -696,6 +703,303 @@ function makeGetTtsJobHandler(deps: ResolvedDeps) {
}; };
} }
// ─── POST /v1/ocr/extract ───────────────────────────────────────────────────
//
// Mirror of makeTtsHandler for model-scoped `ocr.page.extract.<model-slug>.v1`
// capabilities. Same lifecycle (validate → resolve advert → resolve escrow →
// hash check → claim → fire-and-forget runner → 202). Differences:
// - body shape: { image_b64, mime, output_format } (one page per job)
// - prompt_hash is ocrPromptHash(envelope) — shared with the buyer-side
// builder in packages/shared/src/tx/escrow/postOcrEscrow.ts
// - optional served-revision probe BEFORE Claim: a mispinned or
// unreachable upstream refuses the job instead of stranding the escrow
// in Claimed.
/** Plain base64 (standard alphabet, optional padding). Shared shape with the
* buyer-side validator; kept local so route validation stays dependency-light. */
const OCR_BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
function makeOcrHandler(deps: ResolvedDeps) {
return async (req: Request, res: Response, next: NextFunction) => {
// Slot held by THIS request (null until acquired / after hand-off).
let acquiredRef: string | null = null;
try {
// ── 1. Header ───────────────────────────────────────────────────
const headerVal = req.header("X-Escrow-Ref");
if (!headerVal) {
return jsonError(res, 400, "escrow_ref_required", "X-Escrow-Ref header is required");
}
const escrowRef = parseEscrowRef(headerVal);
if (escrowRef === null) {
return jsonError(res, 400, "escrow_ref_malformed",
'X-Escrow-Ref must match "<64-hex>#<int>"');
}
const escrowRefStr = `${escrowRef.txHash}#${escrowRef.index}`;
// ── 2. Body ─────────────────────────────────────────────────────
const body = (req.body ?? {}) as Record<string, unknown>;
const imageB64 = typeof body.image_b64 === "string" ? body.image_b64 : "";
if (imageB64.length === 0) {
return jsonError(res, 400, "image_required",
"body.image_b64 must be a non-empty base64 string");
}
if (imageB64.length > MAX_OCR_IMAGE_B64_CHARS) {
return jsonError(res, 413, "image_too_large",
`body.image_b64 exceeds ${MAX_OCR_IMAGE_B64_CHARS} chars`);
}
if (!OCR_BASE64_RE.test(imageB64)) {
return jsonError(res, 400, "image_not_base64",
"body.image_b64 must be plain base64 (no data: prefix, no whitespace)");
}
const mime = typeof body.mime === "string" ? body.mime : "";
if (!ALLOWED_OCR_MIMES.has(mime)) {
return jsonError(res, 400, "mime_invalid",
`mime must be one of: ${[...ALLOWED_OCR_MIMES].join(", ")}`);
}
const outputFormat = typeof body.output_format === "string" ? body.output_format : "";
if (!ALLOWED_OCR_OUTPUT_FORMATS.has(outputFormat)) {
return jsonError(res, 400, "output_format_invalid",
`output_format must be one of: ${[...ALLOWED_OCR_OUTPUT_FORMATS].join(", ")}`);
}
const ocrRequest: OcrRequest = { image_b64: imageB64, mime, output_format: outputFormat };
// ── 3. Advert ───────────────────────────────────────────────────
const advertResult = await fetchActiveAdvert(deps);
if ("error" in advertResult) {
const e = advertResult.error;
return jsonError(res, e.status, e.reason, e.message);
}
const advert = advertResult.datum;
// ── 4. Escrow ───────────────────────────────────────────────────
const escrowUtxo = await deps.chain.queryUtxo(escrowRef);
if (escrowUtxo === null || !escrowUtxo.datumHex) {
return jsonError(res, 404, "escrow_not_found",
`escrow UTxO ${escrowRefStr} not found on chain`);
}
let escrowDatum: EscrowDatum;
try {
escrowDatum = decodeEscrowDatum(escrowUtxo.datumHex);
} catch (err) {
return jsonError(res, 404, "escrow_decode_failed", (err as Error).message);
}
// ── 5. State / identity / capability ────────────────────────────
if (escrowDatum.state !== "Open") {
return jsonError(res, 409, "escrow_not_claimable",
`escrow state is ${escrowDatum.state}, expected Open`);
}
if (escrowDatum.supplier_pkh !== deps.supplierKey.pubKeyHash) {
return jsonError(res, 403, "wrong_supplier",
"escrow supplier_pkh does not match this node");
}
if (escrowDatum.capability_id !== advert.capability_id) {
return jsonError(res, 409, "capability_mismatch",
`escrow capability ${escrowDatum.capability_id} != advert ${advert.capability_id}`);
}
// ── 6. Hash checks ──────────────────────────────────────────────
const expectedRequestSpecHash = sha256Hex(canonicalize({
capability_id: advert.capability_id,
max_output_tokens: advert.max_output_tokens,
model: advert.model,
}));
if (escrowDatum.request_spec_hash !== expectedRequestSpecHash) {
return jsonError(res, 409, "request_spec_mismatch",
"request_spec_hash in escrow does not match advert spec");
}
// The OCR prompt commitment is the canonicalisation of the full
// request envelope. The buyer SDK computes the same shape (shared
// ocrPromptHash) when building the escrow datum.
const expectedPromptHash = ocrPromptHash(ocrRequest);
if (escrowDatum.prompt_hash !== expectedPromptHash) {
return jsonError(res, 409, "prompt_mismatch",
"prompt_hash in escrow does not match request body");
}
// ── 7. Deadline ─────────────────────────────────────────────────
const tipSlot = await deps.chain.tip();
const isLive = detectCborBackend(deps.chain) === "live";
const nowMs = isLive
? Date.now()
: Math.max(mockSlotToWallclockMs(tipSlot), escrowDatum.posted_at);
if (nowMs >= escrowDatum.deliver_by) {
return jsonError(res, 408, "past_deliver_by",
`now ${nowMs} >= deliver_by ${escrowDatum.deliver_by}`);
}
// ── 7.5. Upstream probe (before Claim, when configured) ─────────
// A failure REFUSES the job here — claiming with a dead or mispinned
// upstream would strand the escrow (and, for self-hosted supply,
// corrupt the attestation story). 503 tells the buyer to retry/route
// elsewhere. Datalab upstream: liveness only (hosted supply has no
// checkpoint to pin). openai-vision upstream: liveness + optional
// served-revision match against HF_MODEL_REVISION.
if (deps.config.revisionProbeMode === "per_job") {
if (deps.config.ocrUpstream === "datalab") {
const probe = await probeDatalabHealth({
baseUrl: deps.config.datalabBaseUrl,
apiKey: deps.config.datalabApiKey,
timeoutMs: 10_000,
});
if (!probe.ok) {
console.warn(`[upstream_probe_failed] ${probe.detail}`);
return jsonError(res, 503, "upstream_probe_failed",
`upstream health probe failed: ${probe.detail}`);
}
} else if (deps.config.revisionProbeUrl !== "") {
const probe = await probeRevision({
probeUrl: deps.config.revisionProbeUrl,
timeoutMs: 10_000,
apiKey: deps.config.openaiApiKey,
});
if (!probe.ok) {
console.warn(`[revision_probe_failed] ${probe.detail}`);
return jsonError(res, 503, "revision_probe_failed",
`served-revision probe failed: ${probe.detail}`);
}
if (
deps.config.hfModelRevision !== "" &&
probe.servedSha !== "" &&
probe.servedSha !== deps.config.hfModelRevision.toLowerCase()
) {
console.warn(
`[revision_mismatch] expected=${deps.config.hfModelRevision} served=${probe.servedSha}`,
);
return jsonError(res, 503, "revision_mismatch",
"served checkpoint does not match the pinned revision");
}
}
}
// ── 8. Acquire session slot ─────────────────────────────────────
if (!deps.state.tryAcquire(escrowRefStr)) {
return jsonError(res, 409, "supplier_busy", "supplier is already working another job");
}
acquiredRef = escrowRefStr;
// ── 9. Claim tx: build + confirm as ONE wallet critical section ──
const claimOutcome = await deps.state.walletMutex.run(async () => {
let built;
try {
built = await buildClaimTx({
chain: deps.chain,
supplierKey: deps.supplierKey,
escrowRef,
});
} 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, built };
});
if (claimOutcome.kind === "build_failed") {
deps.state.release(escrowRefStr);
acquiredRef = null;
triggerOnFailureConsolidate({
chain: deps.chain,
state: deps.state,
supplierKey: deps.supplierKey,
});
return jsonError(res, 503, "chain_submit_failed",
`Claim tx submit failed: ${(claimOutcome.err as Error).message}`);
}
if (claimOutcome.kind === "await_failed") {
deps.state.release(escrowRefStr);
acquiredRef = null;
return jsonError(res, 504, "claim_timeout",
`Claim awaitTx failed: ${(claimOutcome.err as Error).message}`);
}
// ── 10. Spawn runner + 202 ──────────────────────────────────────
const claimedRef: OutputReference = {
txHash: claimOutcome.built.expectedTxHash,
index: 0,
};
const jobId = deps.jobs.create(escrowRefStr);
// Slot release now happens inside runOcrJob's finally.
acquiredRef = null;
void runOcrJob({
deps: {
chain: deps.chain,
state: deps.state,
config: deps.config,
supplierKey: deps.supplierKey,
jobs: deps.jobs,
},
jobId,
escrowRef: escrowRefStr,
claimedRef,
advert,
escrowDatum,
requestBody: ocrRequest,
});
return res.status(202).json({
job_id: jobId,
status: "accepted",
escrow_ref: escrowRefStr,
});
} catch (err) {
if (acquiredRef) {
try { deps.state.release(acquiredRef); } catch { /* ignore */ }
}
next(err);
return;
}
};
}
// ─── GET /v1/ocr/extract/:jobId ─────────────────────────────────────────────
function makeGetOcrJobHandler(deps: ResolvedDeps) {
return (req: Request, res: Response) => {
const rawJobId = req.params.jobId;
const jobId = typeof rawJobId === "string" ? rawJobId : "";
if (!UUID_V4_RE.test(jobId)) {
return jsonError(res, 400, "invalid_job_id", "jobId must be a UUIDv4 string");
}
const record = deps.jobs.get(jobId);
if (!record) {
return jsonError(res, 404, "job_not_found", `no job found with id ${jobId}`);
}
res.setHeader("Content-Type", "application/json");
if (record.status === "accepted" || record.status === "running") {
return res.status(202).json({
status: record.status,
escrow_ref: record.escrowRef,
});
}
if (record.status === "done") {
const payload = record.responsePayload!;
if (!("kind" in payload) || payload.kind !== "ocr") {
return jsonError(res, 500, "wrong_payload_kind",
"non-ocr payload returned to ocr poll route");
}
return res.status(200).json({
output_format: payload.output_format,
content: payload.content,
usage: payload.usage,
receipt: payload.receipt,
receipt_signature: payload.receipt_signature,
escrow_ref: record.escrowRef,
});
}
// failed
const f = record.failure!;
return res.status(f.httpStatus).json({
status: "failed",
reason: f.reason,
message: f.message,
escrow_ref: record.escrowRef,
});
};
}
// ─── /v1/chat/{start,message,end} — multi-turn chat session (llm.chat.v1) ─── // ─── /v1/chat/{start,message,end} — multi-turn chat session (llm.chat.v1) ───
// //
// Escrow bookends with off-chain turns: // Escrow bookends with off-chain turns:
@@ -968,17 +1272,23 @@ function makeChatSessionHandlers(deps: ResolvedDeps) {
}; };
try { try {
// Stateful upstreams (OpenClaw) key a persistent agent session off the
// OpenAI `user` field and carry the history themselves — send only this
// turn's delta or the transcript duplicates into the upstream context
// every turn. Stateless upstreams get the full transcript as before.
const stateful = deps.config.openaiSessionPassthrough;
const result = await callOpenAiStream( const result = await callOpenAiStream(
{ {
baseUrl: deps.config.openaiBaseUrl, baseUrl: deps.config.openaiBaseUrl,
model: record.advert.model, model: deps.config.openaiModelOverride || record.advert.model,
messages: record.transcript, messages: stateful ? delta : record.transcript,
timeoutMs: deps.config.openaiTimeoutMs, timeoutMs: deps.config.openaiTimeoutMs,
apiKey: deps.config.openaiApiKey, apiKey: deps.config.openaiApiKey,
maxTokens: deps.config.openaiMaxTokens, maxTokens: deps.config.openaiMaxTokens,
disableReasoning: deps.config.openaiReasoningDisabled, disableReasoning: deps.config.openaiReasoningDisabled,
tools, tools,
toolChoice, toolChoice,
user: stateful ? escrowRefStr : undefined,
}, },
(tok) => sse({ type: "token", value: tok }), (tok) => sse({ type: "token", value: tok }),
); );
@@ -1077,7 +1387,11 @@ export function createApp(deps: SupplierDeps): Application {
const app = express(); const app = express();
app.disable("x-powered-by"); app.disable("x-powered-by");
app.use(express.json({ limit: "1mb" })); // /v1/ocr carries a base64 page image and mounts its own larger parser
// below; every other route 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));
// /healthz is mounted FIRST and uses no deps — it must succeed regardless // /healthz is mounted FIRST and uses no deps — it must succeed regardless
// of chain/state/config (independent of /status free/working/offline). // of chain/state/config (independent of /status free/working/offline).
@@ -1095,6 +1409,11 @@ export function createApp(deps: SupplierDeps): Application {
if (resolved.config.capabilityKind === "tts") { if (resolved.config.capabilityKind === "tts") {
app.post("/v1/audio/synthesize", makeTtsHandler(resolved)); app.post("/v1/audio/synthesize", makeTtsHandler(resolved));
app.get("/v1/audio/synthesize/:jobId", makeGetTtsJobHandler(resolved)); app.get("/v1/audio/synthesize/:jobId", makeGetTtsJobHandler(resolved));
} else if (resolved.config.capabilityKind === "ocr") {
// MAX_OCR_IMAGE_B64_CHARS (~12M chars ≈ 9 MB binary) + JSON envelope
// fits comfortably under 16mb.
app.post("/v1/ocr/extract", express.json({ limit: "16mb" }), makeOcrHandler(resolved));
app.get("/v1/ocr/extract/:jobId", makeGetOcrJobHandler(resolved));
} else if (resolved.config.capabilityKind === "chat-session") { } else if (resolved.config.capabilityKind === "chat-session") {
const chat = makeChatSessionHandlers(resolved); const chat = makeChatSessionHandlers(resolved);
app.post("/v1/chat/start", chat.start); app.post("/v1/chat/start", chat.start);

View File

@@ -7,15 +7,26 @@
* and the lane stalls until an operator runs `tx:consolidate-wallet`. * and the lane stalls until an operator runs `tx:consolidate-wallet`.
* *
* Two layers of self-healing: * Two layers of self-healing:
* startWalletHealthTicker — periodic background tick (every N ms). When * startWalletHealthTicker — periodic background tick (every N ms). Runs
* the supplier is idle, runs runConsolidateWallet. planConsolidate * runConsolidateWallet; planConsolidate's structural health predicate
* returns `already-healthy` cheaply for clean 2-UTxO wallets, so this * short-circuits without a tx when a collateral candidate exists and
* is a no-op in steady state — no tx, no fee. * fragmentation is bounded. A tick on a healthy wallet costs one UTxO
* query and nothing else.
* triggerOnFailureConsolidate — fire-and-forget consolidate from the * triggerOnFailureConsolidate — fire-and-forget consolidate from the
* Claim-failure path. Debounced so back-to-back failures don't queue * Claim-failure path. Debounced so back-to-back failures don't queue
* multiple consolidates. Returns 503 to the buyer for THIS request; the * multiple consolidates. Returns 503 to the buyer for THIS request; the
* next buyer retry (~30-60s later) finds the wallet healthy. * next buyer retry (~30-60s later) finds the wallet healthy.
* *
* F7 guards (2026-08-08 incident: a non-idempotent health check made every
* tick consolidate, burning 0.5 AP3X per 45s until the wallet hit the
* floor — 116 AP3X in one soak). Two independent circuit breakers protect
* against ANY future non-convergence:
* - consecutive guard: 3 consolidations in a row without ever reaching
* `already-healthy` halts wallet-health for this process.
* - budget guard: more than 6 consolidations inside 24h halts it too.
* A halt logs loudly once; consolidate manually and restart the supplier
* to re-arm.
*
* Wallet-lock discipline: consolidation spends EVERY wallet UTxO, so it runs * Wallet-lock discipline: consolidation spends EVERY wallet UTxO, so it runs
* inside state.walletMutex — the same mutex that serializes Claims/Submits — * inside state.walletMutex — the same mutex that serializes Claims/Submits —
* queueing behind any in-flight chain op instead of skipping. Session slots * queueing behind any in-flight chain op instead of skipping. Session slots
@@ -63,6 +74,29 @@ export interface WalletHealthTicker {
const inFlight = new WeakSet<SupplierState>(); const inFlight = new WeakSet<SupplierState>();
const lastOnFailureAt = new WeakMap<SupplierState, number>(); const lastOnFailureAt = new WeakMap<SupplierState, number>();
// ── F7 circuit breakers ──────────────────────────────────────────────
const MAX_CONSECUTIVE_CONSOLIDATIONS = 3;
const MAX_CONSOLIDATIONS_PER_WINDOW = 6;
const BUDGET_WINDOW_MS = 24 * 60 * 60 * 1000;
interface GuardCounters {
consecutive: number;
windowStart: number;
windowCount: number;
halted: boolean;
}
const guardCounters = new WeakMap<SupplierState, GuardCounters>();
function guardsFor(state: SupplierState): GuardCounters {
let g = guardCounters.get(state);
if (!g) {
g = { consecutive: 0, windowStart: Date.now(), windowCount: 0, halted: false };
guardCounters.set(state, g);
}
return g;
}
function defaultLog(line: string): void { function defaultLog(line: string): void {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`[wallet-health] ${line}`); console.log(`[wallet-health] ${line}`);
@@ -73,6 +107,8 @@ async function consolidateOnce(
opts: { collateralLovelace: bigint; awaitTimeoutMs: number; log: (line: string) => void }, opts: { collateralLovelace: bigint; awaitTimeoutMs: number; log: (line: string) => void },
): Promise<void> { ): Promise<void> {
const { state } = deps; const { state } = deps;
const guards = guardsFor(state);
if (guards.halted) return; // the halt already logged loudly, once
if (inFlight.has(state)) { if (inFlight.has(state)) {
opts.log("skip: another consolidate already in flight"); opts.log("skip: another consolidate already in flight");
return; return;
@@ -92,11 +128,36 @@ async function consolidateOnce(
}), }),
); );
if (result.reason === "already-healthy") { if (result.reason === "already-healthy") {
guards.consecutive = 0;
opts.log("already-healthy"); opts.log("already-healthy");
} else { } else {
const now = Date.now();
if (now - guards.windowStart > BUDGET_WINDOW_MS) {
guards.windowStart = now;
guards.windowCount = 0;
}
guards.consecutive += 1;
guards.windowCount += 1;
opts.log(`consolidated: ${result.reason} txHash=${result.txHash}`); opts.log(`consolidated: ${result.reason} txHash=${result.txHash}`);
if (guards.consecutive >= MAX_CONSECUTIVE_CONSOLIDATIONS) {
guards.halted = true;
opts.log(
`HALT: ${guards.consecutive} consecutive consolidations without reaching a healthy shape — ` +
`wallet-health stopped to protect funds (F7 guard). Investigate planConsolidate vs the ` +
`builder's output shape, consolidate manually, restart the supplier to re-arm.`,
);
} else if (guards.windowCount >= MAX_CONSOLIDATIONS_PER_WINDOW) {
guards.halted = true;
opts.log(
`HALT: consolidation budget exhausted (${guards.windowCount} in 24h) — wallet-health ` +
`stopped to protect funds (F7 guard). Restart the supplier to re-arm.`,
);
}
} }
} catch (err) { } catch (err) {
// Neither healthy nor consolidated (e.g. balance too low): no fee was
// spent, so the guards don't move — but nothing converged either, so
// the consecutive counter is NOT reset.
opts.log(`consolidate failed: ${(err as Error).message}`); opts.log(`consolidate failed: ${(err as Error).message}`);
} finally { } finally {
inFlight.delete(state); inFlight.delete(state);
@@ -143,8 +204,9 @@ export function triggerOnFailureConsolidate(
}); });
} }
/** Test-only: reset the in-flight + debounce maps between tests. */ /** Test-only: reset the in-flight + debounce + guard maps between tests. */
export function _resetWalletHealthForTests(state: SupplierState): void { export function _resetWalletHealthForTests(state: SupplierState): void {
inFlight.delete(state); inFlight.delete(state);
lastOnFailureAt.delete(state); lastOnFailureAt.delete(state);
guardCounters.delete(state);
} }

View File

@@ -118,15 +118,17 @@ export function encodeSampleAdvertDatumHex(): string {
hexToBytes(datum.supplier_pkh), hexToBytes(datum.supplier_pkh),
utf8Bytes(datum.capability_id), utf8Bytes(datum.capability_id),
utf8Bytes(datum.model), utf8Bytes(datum.model),
datum.max_output_tokens, // BigInt so cbor-x emits uint64 (0x1b) like the production codec — plain
datum.max_processing_ms, // numbers encode minimally (or as float64 past 2^32) and break golden parity.
BigInt(datum.max_output_tokens),
BigInt(datum.max_processing_ms),
datum.price_lovelace, datum.price_lovelace,
datum.supplier_bond_lovelace, datum.supplier_bond_lovelace,
datum.buyer_bond_lovelace, datum.buyer_bond_lovelace,
utf8Bytes(datum.endpoint_url), utf8Bytes(datum.endpoint_url),
utf8Bytes(datum.detail_uri), utf8Bytes(datum.detail_uri),
hexToBytes(datum.detail_hash), hexToBytes(datum.detail_hash),
datum.advertised_at, BigInt(datum.advertised_at),
statusTag, statusTag,
]); ]);

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