Compare commits
16 Commits
feat/mainn
...
fix/releas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5fdd1f1b7 | ||
|
|
019766ab8d | ||
|
|
5c9da7639d | ||
|
|
843ac7cbdd | ||
|
|
48ad639d12 | ||
|
|
4047be04a3 | ||
|
|
23157d62a3 | ||
|
|
3ee09f1a9a | ||
|
|
44abbdffdc | ||
|
|
b0c83ca985 | ||
|
|
bfcb0c46b8 | ||
|
|
88e549bd1d | ||
|
|
6c5a068fd9 | ||
|
|
83f81ab5fc | ||
|
|
6859486334 | ||
|
|
5377d379b7 |
@@ -125,6 +125,20 @@ const OPERATOR_SOURCES: OperatorSource[] = [
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "supplier-local@inference-proxy",
|
||||
host: "inference-proxy",
|
||||
envPath: "/root/agents-marketplace/supplier/.env.local",
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "supplier-openclaw@inference-proxy",
|
||||
host: "inference-proxy",
|
||||
envPath: "/root/agents-marketplace/supplier/.env.openclaw",
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "buyer@open-webui",
|
||||
host: "open-webui",
|
||||
|
||||
261
deploy/inference-proxy/README.md
Normal file
261
deploy/inference-proxy/README.md
Normal 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`).
|
||||
99
deploy/inference-proxy/docker-compose.supplier-local.yml
Normal file
99
deploy/inference-proxy/docker-compose.supplier-local.yml
Normal 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
|
||||
134
deploy/inference-proxy/docker-compose.supplier-openclaw.yml
Normal file
134
deploy/inference-proxy/docker-compose.supplier-openclaw.yml
Normal 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
|
||||
65
deploy/inference-proxy/docker-compose.traefik.yml
Normal file
65
deploy/inference-proxy/docker-compose.traefik.yml
Normal 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
|
||||
101
deploy/mainnet/docker-compose.supplier-ds-flash-htz-chat.yml
Normal file
101
deploy/mainnet/docker-compose.supplier-ds-flash-htz-chat.yml
Normal 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
|
||||
89
deploy/mainnet/docker-compose.supplier-ds-flash-htz.yml
Normal file
89
deploy/mainnet/docker-compose.supplier-ds-flash-htz.yml
Normal 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
|
||||
101
deploy/mainnet/docker-compose.supplier-glm-chat.yml
Normal file
101
deploy/mainnet/docker-compose.supplier-glm-chat.yml
Normal 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
|
||||
89
deploy/mainnet/docker-compose.supplier-glm.yml
Normal file
89
deploy/mainnet/docker-compose.supplier-glm.yml
Normal 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
|
||||
101
deploy/mainnet/docker-compose.supplier-kimi-code-chat.yml
Normal file
101
deploy/mainnet/docker-compose.supplier-kimi-code-chat.yml
Normal 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
|
||||
89
deploy/mainnet/docker-compose.supplier-kimi-code.yml
Normal file
89
deploy/mainnet/docker-compose.supplier-kimi-code.yml
Normal 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
|
||||
101
deploy/mainnet/docker-compose.supplier-qwen35b-chat.yml
Normal file
101
deploy/mainnet/docker-compose.supplier-qwen35b-chat.yml
Normal 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
|
||||
89
deploy/mainnet/docker-compose.supplier-qwen35b.yml
Normal file
89
deploy/mainnet/docker-compose.supplier-qwen35b.yml
Normal 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
|
||||
133
docs/HETZNER_SETUP.md
Normal file
133
docs/HETZNER_SETUP.md
Normal 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`.
|
||||
168
docs/HUGGINGFACE_ROUTER_SETUP.md
Normal file
168
docs/HUGGINGFACE_ROUTER_SETUP.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# HuggingFace Router Supplier — Setup Runbook (V1 / W1)
|
||||
|
||||
> **Status:** shipped. Configures a Vector supplier node to use the HuggingFace
|
||||
> **Inference Providers router** as its **compute backend** for bonded inference
|
||||
> work commissioned on Vector. Config only — no code changes.
|
||||
|
||||
---
|
||||
|
||||
## 1. What this is
|
||||
|
||||
The HuggingFace **Inference Providers router** (`https://router.huggingface.co`)
|
||||
is an OpenAI-compatible endpoint that fronts hundreds of hosted models across
|
||||
partner providers (Together, Fireworks, DeepInfra, Groq, …). It is the direct
|
||||
analogue of the OpenRouter backend the supplier already supports.
|
||||
|
||||
Because the supplier's upstream client (`supplier/src/openai.ts`) already speaks
|
||||
plain `POST /v1/chat/completions` with a Bearer token, pointing it at HuggingFace
|
||||
is **pure configuration**: set `LLM_BACKEND=openai` and point `OPENAI_BASE_URL` at
|
||||
the router. It is the same as adding any OpenAI-compatible compute backend, with
|
||||
the HuggingFace endpoint and token substituted in.
|
||||
|
||||
```
|
||||
buyer commissions bonded work ──▶ supplier node ──▶ HuggingFace router
|
||||
(AP3X escrow on Vector) │ (compute backend, hf_ token)
|
||||
▼
|
||||
supplier fulfils the work; the escrow settles on-chain
|
||||
```
|
||||
|
||||
The supplier node runs the inference to **fulfil work a buyer has commissioned
|
||||
under stake**; HuggingFace is simply the compute it uses to produce the result.
|
||||
|
||||
**What this does NOT do:** it does not add creator/model-author work-sharing,
|
||||
change protocol economics, or change how an advert's committed amount is sized.
|
||||
Those are separate, larger pieces of work tracked in the team's internal planning
|
||||
notes. This preset is the fast, low-risk on-ramp that proves the path.
|
||||
|
||||
---
|
||||
|
||||
## 2. Prerequisites
|
||||
|
||||
1. A HuggingFace account with **Inference Providers access** (a free monthly
|
||||
credit allowance, then usage-based per token — this is HuggingFace's billing
|
||||
to you as the node operator).
|
||||
2. An access token from <https://huggingface.co/settings/tokens> with the
|
||||
**"Make calls to Inference Providers"** permission. It starts with `hf_`.
|
||||
Treat it like a password.
|
||||
3. A funded supplier wallet (~5 AP3X for tx fees + bonds). Generate a seed with
|
||||
`openssl rand -hex 32`.
|
||||
4. The usual supplier deploy prerequisites (Ogmios URL, a publicly reachable
|
||||
HTTPS endpoint for buyers). See `deploy/README.md`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Steps
|
||||
|
||||
### 3.1 Create the env file
|
||||
|
||||
```bash
|
||||
cp supplier/.env.huggingface-chat.example supplier/.env
|
||||
chmod 600 supplier/.env
|
||||
```
|
||||
|
||||
Fill in `SUPPLIER_PRIV_KEY_HEX`, `OPENAI_API_KEY` (your `hf_…` token),
|
||||
`OGMIOS_URL`, and the `ADVERT_*` values. Key settings for this preset:
|
||||
|
||||
| Var | Value | Why |
|
||||
|-----|-------|-----|
|
||||
| `LLM_BACKEND` | `openai` | routes chat through the OpenAI-compatible client |
|
||||
| `OPENAI_BASE_URL` | `https://router.huggingface.co` | HF router; the `/v1/chat/completions` suffix is appended by the client — do **not** add it |
|
||||
| `OPENAI_API_KEY` | `hf_…` | your HF token (Bearer) |
|
||||
| `OPENAI_REASONING` | **unset** | HF 400s on the OpenRouter-only `reasoning` param (see §5) |
|
||||
| `ADVERT_MODEL` | provider-qualified id, e.g. `deepseek-ai/DeepSeek-V3:fastest` | see §4 |
|
||||
|
||||
### 3.2 Choose a model
|
||||
|
||||
HF uses **provider-qualified** model names. The value goes on-chain in
|
||||
`AdvertDatum.model` and is forwarded verbatim to the router:
|
||||
|
||||
- `deepseek-ai/DeepSeek-V3:fastest` — bare id + `:fastest` picks the fastest provider
|
||||
- `deepseek-ai/DeepSeek-V3:together` — pin a specific provider
|
||||
- `openai/gpt-oss-120b` — open-weight, HF-hosted
|
||||
|
||||
Browse models and their providers at
|
||||
<https://huggingface.co/models?inference_provider=all>.
|
||||
|
||||
Because the model is on-chain data, you can advertise any router-supported chat
|
||||
model without changing the supplier code — just post a new advert.
|
||||
|
||||
### 3.3 Smoke-test the token before going on-chain
|
||||
|
||||
Confirm your token + model + base URL work end to end, independent of the chain:
|
||||
|
||||
```bash
|
||||
curl https://router.huggingface.co/v1/chat/completions \
|
||||
-H "Authorization: Bearer $HF_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"deepseek-ai/DeepSeek-V3:fastest",
|
||||
"messages":[{"role":"user","content":"say hi in 3 words"}],
|
||||
"stream":false}'
|
||||
```
|
||||
|
||||
A `choices[0].message.content` in the response means the compute backend is
|
||||
reachable. (Note: **no** `reasoning` field — see §5.)
|
||||
|
||||
### 3.4 Post the advert
|
||||
|
||||
```bash
|
||||
pnpm --filter @marketplace/supplier tx:post-advert \
|
||||
--capability-id llm.text.generate.v1 \
|
||||
--model deepseek-ai/DeepSeek-V3:fastest \
|
||||
--max-output-tokens 512 \
|
||||
--max-processing-ms 60000 \
|
||||
--price-lovelace 2000000 \
|
||||
--endpoint-url https://your-supplier.example.org
|
||||
```
|
||||
|
||||
Copy the printed `<txHash>#0` into `ADVERT_REF` in `supplier/.env`. Use
|
||||
`--dry-run` first to inspect the tx without submitting.
|
||||
|
||||
### 3.5 Boot
|
||||
|
||||
Start read-only first (`LIVE_CHAIN` unset) to confirm the node boots and can
|
||||
reach both Ogmios and the HF router, then set `LIVE_CHAIN=1` and restart to
|
||||
enable real Claim/Submit. Deploy via the supplier compose file as in
|
||||
`deploy/README.md`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Sizing the advert (read before you post one)
|
||||
|
||||
`ADVERT_PRICE_LOVELACE` is the **flat AP3X amount committed in escrow per job**,
|
||||
but the HuggingFace backend meters **per token**. The committed amount is fixed
|
||||
per job while upstream token usage varies, so:
|
||||
|
||||
- Size it for your **worst-case** prompt length, not the average.
|
||||
- Cap output tightly with `--max-output-tokens` — it is your main lever on
|
||||
upstream token usage.
|
||||
- Prefer **small** models here; large models have wide token-usage variance and
|
||||
are harder to size a fixed per-job amount around.
|
||||
- Buyer **input** tokens are not capped on-chain today. Until an input-token
|
||||
ceiling lands, stick to small models or add your own request-size guard.
|
||||
|
||||
This is a pre-existing protocol design point (fixed per-job advert amount vs.
|
||||
variable upstream token usage), not specific to HuggingFace. It is the main thing
|
||||
to account for when sizing an advert.
|
||||
|
||||
---
|
||||
|
||||
## 5. Footgun: `OPENAI_REASONING` must stay unset
|
||||
|
||||
`reasoning:{enabled:false}` is an **OpenRouter-specific** parameter. The HF
|
||||
router **rejects it with HTTP 400 on every request**. When copying an existing
|
||||
OpenRouter env, it is easy to carry `OPENAI_REASONING=off` over by accident.
|
||||
|
||||
The supplier guards against this: `loadConfig` **refuses to boot** if
|
||||
`OPENAI_REASONING` is disabled while `OPENAI_BASE_URL` resolves to
|
||||
`router.huggingface.co`, with a clear error naming the fix
|
||||
(`supplier/src/config.ts`). Just leave `OPENAI_REASONING` unset for HF.
|
||||
|
||||
---
|
||||
|
||||
## 6. Where this sits
|
||||
|
||||
This preset is the multi-model on-ramp that uses the HuggingFace router as a
|
||||
compute backend. Further work items (additional task adapters, advert-sizing
|
||||
refinements, and protocol-economics changes) are tracked separately in the team's
|
||||
internal planning notes, along with the upstream-terms check every operator must
|
||||
complete before pointing a node at a third-party compute backend.
|
||||
@@ -17,6 +17,8 @@ import type { WalletKey, BuildResult } from "../types.js";
|
||||
import { TxConstructionError } from "../types.js";
|
||||
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
|
||||
import { ACCEPT_WINDOW_MS, mockSlotToWallclockMs } from "../internal/constants.js";
|
||||
import { detectCborBackend } from "../internal/cborBackend.js";
|
||||
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
||||
|
||||
export interface ReleaseParams {
|
||||
chain: ChainProvider;
|
||||
@@ -65,8 +67,10 @@ export async function buildReleaseTx(
|
||||
}
|
||||
|
||||
// 4. Tip must be at or past submitted_at + ACCEPT_WINDOW.
|
||||
// Live backend uses real POSIX ms; mock backend keeps slot*1000.
|
||||
const tipSlot = await chain.tip();
|
||||
const tipMs = mockSlotToWallclockMs(tipSlot);
|
||||
const isLive = detectCborBackend(chain) === "live";
|
||||
const tipMs = isLive ? Date.now() : mockSlotToWallclockMs(tipSlot);
|
||||
const threshold = datum.submitted_at + ACCEPT_WINDOW_MS;
|
||||
if (tipMs < threshold) {
|
||||
throw new TxConstructionError(
|
||||
@@ -75,6 +79,21 @@ export async function buildReleaseTx(
|
||||
);
|
||||
}
|
||||
|
||||
// Live path: real Cardano CBOR via lucid-evolution.
|
||||
if (isLive) {
|
||||
const liveCborPath = "../internal/liveCbor.js";
|
||||
const { buildLiveTxForRelease } = await import(/* @vite-ignore */ liveCborPath);
|
||||
return buildLiveTxForRelease({
|
||||
chain: chain as LiveOgmiosProvider,
|
||||
supplierKey,
|
||||
escrowRef,
|
||||
escrowUtxo: utxo,
|
||||
datum,
|
||||
tipMs,
|
||||
});
|
||||
}
|
||||
|
||||
// Mock path: synthetic testTxBody (preserves existing test behaviour).
|
||||
const supplierDue =
|
||||
datum.payment_lovelace + datum.supplier_bond_lovelace + datum.buyer_bond_lovelace;
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
* for vkh enterprise). Input UTxOs may carry testnet-prefixed addresses — lucid
|
||||
* does not validate address network for inputs (only for outputs).
|
||||
*
|
||||
* Deferred builders (postAdvert, updateAdvert, retireAdvert, reclaim, release)
|
||||
* are NOT touched in M1-F-4. Those continue using the synthetic testTxBody path.
|
||||
* Deferred builders: only updateAdvert remains on the synthetic testTxBody
|
||||
* path. postAdvert, retireAdvert, reclaim, and release all have live builders.
|
||||
*
|
||||
* Catherine M1-F-4-green.
|
||||
*/
|
||||
@@ -61,6 +61,7 @@ const REDEEMER_CLAIM = 121;
|
||||
const REDEEMER_SUBMIT = 122;
|
||||
const REDEEMER_ACCEPT = 123;
|
||||
const REDEEMER_RECLAIM = 124;
|
||||
const REDEEMER_RELEASE = 125;
|
||||
|
||||
// AdvertRedeemer Constr indices per lib/marketplace/types.ak:
|
||||
// PostAdvert → Constr0 (tag 121, creation event — never seen by spend)
|
||||
@@ -121,6 +122,15 @@ export interface LiveReclaimParams {
|
||||
tipMs: number;
|
||||
}
|
||||
|
||||
export interface LiveReleaseParams {
|
||||
chain: LiveOgmiosProvider;
|
||||
supplierKey: WalletKey;
|
||||
escrowRef: OutputReference;
|
||||
escrowUtxo: Utxo;
|
||||
datum: EscrowDatum;
|
||||
tipMs: number;
|
||||
}
|
||||
|
||||
export interface LivePostAdvertParams {
|
||||
chain: LiveOgmiosProvider;
|
||||
walletKey: WalletKey;
|
||||
@@ -778,6 +788,77 @@ export async function buildLiveTxForReclaim(params: LiveReclaimParams): Promise<
|
||||
return { txCborHex, expectedTxHash };
|
||||
}
|
||||
|
||||
// ─── Release ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function buildLiveTxForRelease(params: LiveReleaseParams): Promise<BuildResult> {
|
||||
const provider = buildLucidProvider(params.chain);
|
||||
const { lucid } = await createLucidContext(provider, params.supplierKey, {
|
||||
networkId: 1,
|
||||
systemStartUnix: 0,
|
||||
slotLengthMs: 1000,
|
||||
}, { usePresetProtocolParameters: true });
|
||||
|
||||
const { script, address: escrowAddress } = loadEscrowScript();
|
||||
const spendCtx = await resolveSpendCtx(lucid, "ESCROW_REF_UTXO", script);
|
||||
const lucidInput = chainUtxoToLucidScriptInput(params.escrowUtxo, escrowAddress);
|
||||
const releaseRedeemerHex = encodeNullaryConstrHex(REDEEMER_RELEASE);
|
||||
|
||||
if (params.datum.submitted_at === null) {
|
||||
throw new TxConstructionError(
|
||||
"submitted_at missing",
|
||||
"Submitted-state escrow must carry a submitted_at timestamp",
|
||||
);
|
||||
}
|
||||
|
||||
// Release pays everything to the supplier: payment + supplier_bond + buyer_bond.
|
||||
const supplierDue =
|
||||
params.datum.payment_lovelace +
|
||||
params.datum.supplier_bond_lovelace +
|
||||
params.datum.buyer_bond_lovelace;
|
||||
const supplierLovelace = supplierDue < MIN_UTXO_LOVELACE ? MIN_UTXO_LOVELACE : supplierDue;
|
||||
const supplierAddress = pkhToEnterpriseAddress(params.datum.supplier_pkh, MAINNET_ID);
|
||||
|
||||
const realWalletUtxos = await lucid.wallet().getUtxos();
|
||||
assertCollateralCandidate(realWalletUtxos);
|
||||
const presetWalletInputs = realWalletUtxos;
|
||||
|
||||
// escrow.ak:handle_release checks `lower_bound >= submitted_at + ACCEPT_WINDOW`.
|
||||
// Clamp validFrom to that threshold: the Reclaim-style tip-60s back-pad alone
|
||||
// would violate the validator whenever the tip is less than 60s past the window.
|
||||
const threshold = params.datum.submitted_at + ACCEPT_WINDOW_MS;
|
||||
const validFromMs = Math.max(threshold, params.tipMs - 60_000);
|
||||
|
||||
let signed;
|
||||
try {
|
||||
const txBuilder = (
|
||||
spendCtx.kind === "ref"
|
||||
? lucid.newTx().readFrom([spendCtx.refUtxo])
|
||||
: lucid.newTx().attach.SpendingValidator(spendCtx.script)
|
||||
)
|
||||
.collectFrom([lucidInput], releaseRedeemerHex)
|
||||
.pay.ToAddress(supplierAddress, { lovelace: supplierLovelace })
|
||||
.addSignerKey(params.supplierKey.pubKeyHash)
|
||||
.validFrom(validFromMs)
|
||||
.validTo(params.tipMs + 60_000)
|
||||
.setMinFee(500_000n);
|
||||
|
||||
// Same UPLC eval delegation as Accept/Reclaim — lucid's CML evaluator
|
||||
// falsely rejects valid Plutus V3 spends; route to Ogmios.
|
||||
const completed = await txBuilder.complete({
|
||||
presetWalletInputs,
|
||||
localUPLCEval: false,
|
||||
});
|
||||
signed = await completed.sign.withWallet().complete();
|
||||
} catch (err) {
|
||||
rethrowAsTxError(err, "release build failed");
|
||||
}
|
||||
|
||||
const txCborHex = signed.toCBOR();
|
||||
const expectedTxHash = signed.toHash();
|
||||
await params.chain.submitTx(txCborHex);
|
||||
return { txCborHex, expectedTxHash };
|
||||
}
|
||||
|
||||
// ─── PostAdvert ──────────────────────────────────────────────────────
|
||||
|
||||
export async function buildLiveTxForAdvert(
|
||||
|
||||
105
supplier/.env.hetzner.example
Normal file
105
supplier/.env.hetzner.example
Normal 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=
|
||||
114
supplier/.env.huggingface-chat.example
Normal file
114
supplier/.env.huggingface-chat.example
Normal file
@@ -0,0 +1,114 @@
|
||||
# supplier/.env.huggingface-chat.example
|
||||
#
|
||||
# Preset for a chat supplier that uses the HuggingFace Inference Providers router
|
||||
# (https://router.huggingface.co) as its COMPUTE BACKEND to fulfil bonded
|
||||
# inference work commissioned on Vector. It is the OpenAI-compatible on-ramp:
|
||||
# the router is OpenAI-compatible, so this reuses the existing LLM_BACKEND=openai
|
||||
# path with NO code changes — it is config only.
|
||||
#
|
||||
# Copy to supplier/.env on the host (chmod 600) and fill in real values.
|
||||
# .env is gitignored — NEVER commit a populated copy.
|
||||
#
|
||||
# Runbook: docs/HUGGINGFACE_ROUTER_SETUP.md
|
||||
#
|
||||
# To generate a fresh Ed25519 wallet seed: openssl rand -hex 32
|
||||
# Lines marked SECRET must never be shared, logged, or committed.
|
||||
|
||||
# ─── Required ─────────────────────────────────────────────────────────────
|
||||
|
||||
# 64-char hex Ed25519 private key seed for this supplier's wallet. SECRET.
|
||||
SUPPLIER_PRIV_KEY_HEX=PASTE_64_HEX_CHARS_HERE
|
||||
|
||||
# Ogmios WebSocket URL. Testnet shown; use the mainnet URL for NETWORK_ID=1:
|
||||
# testnet: wss://ogmios.vector.testnet.apexfusion.org
|
||||
# mainnet: wss://ogmios.vector.mainnet.apexfusion.org
|
||||
OGMIOS_URL=wss://ogmios.vector.testnet.apexfusion.org
|
||||
|
||||
# OutputReference of this supplier's on-chain advertisement UTxO.
|
||||
# Format "<64-hex-tx-hash>#<int>". Post it first (see tx:post-advert below),
|
||||
# then paste the printed "<txHash>#0" here.
|
||||
ADVERT_REF=PASTE_TXHASH_HERE#0
|
||||
|
||||
# Cardano network ID. "0" = testnet, "1" = mainnet. Keep "0" until you have
|
||||
# validated the setup end-to-end on testnet.
|
||||
NETWORK_ID=0
|
||||
|
||||
# ─── Backend: HuggingFace router (OpenAI-compatible) ──────────────────────
|
||||
# Route the chat job runner at the HF Inference Providers router instead of a
|
||||
# local Ollama. The supplier POSTs to ${OPENAI_BASE_URL}/v1/chat/completions.
|
||||
LLM_BACKEND=openai
|
||||
|
||||
# HF router base URL. The supplier appends /v1/chat/completions itself, so do
|
||||
# NOT include the /v1 suffix here.
|
||||
OPENAI_BASE_URL=https://router.huggingface.co
|
||||
|
||||
# HuggingFace access token (starts with "hf_"). Create one at
|
||||
# https://huggingface.co/settings/tokens with "Make calls to Inference
|
||||
# Providers" permission. Forwarded as `Authorization: Bearer <token>`. SECRET.
|
||||
OPENAI_API_KEY=PASTE_HF_TOKEN_HERE
|
||||
|
||||
# First-token latency across hosted providers can exceed local Ollama — keep a
|
||||
# generous request timeout.
|
||||
OPENAI_TIMEOUT_MS=180000
|
||||
|
||||
# ─── IMPORTANT: do NOT set OPENAI_REASONING here ──────────────────────────
|
||||
# `reasoning:{enabled:false}` is an OpenRouter-only param. The HF router
|
||||
# rejects it with HTTP 400 on every request. The supplier refuses to boot if
|
||||
# OPENAI_REASONING is disabled while OPENAI_BASE_URL points at the HF router.
|
||||
# (This is the #1 footgun when copying an OpenRouter env — leave it unset.)
|
||||
# OPENAI_REASONING=
|
||||
|
||||
# Optional hard ceiling forwarded as `max_tokens`. Unset = provider grants the
|
||||
# max available (context − prompt). Keep below the model's context window.
|
||||
# OPENAI_MAX_TOKENS=
|
||||
|
||||
# ─── Enable real chain submissions ────────────────────────────────────────
|
||||
# LIVE_CHAIN: only the literal "1" opts in to real Claim/Submit txs. Leave
|
||||
# unset for a dry run (read-only, no chain writes) while testing.
|
||||
LIVE_CHAIN=
|
||||
|
||||
# HTTP listen port inside the container. Compose maps this to the host.
|
||||
PORT=8080
|
||||
|
||||
# ─── tx:post-advert — run once before first deploy ────────────────────────
|
||||
# Execute (from repo root):
|
||||
# pnpm --filter @marketplace/supplier tx:post-advert \
|
||||
# --capability-id llm.text.generate.v1 \
|
||||
# --model deepseek-ai/DeepSeek-V3:fastest \
|
||||
# --max-output-tokens 512 \
|
||||
# --max-processing-ms 60000 \
|
||||
# --price-lovelace 2000000 \
|
||||
# --endpoint-url https://mp-supplier.vector.testnet.apexfusion.org
|
||||
# Copy the printed "<txHash>#0" into ADVERT_REF above.
|
||||
#
|
||||
# NOTE on --model: HF uses PROVIDER-QUALIFIED model names. The bare id picks a
|
||||
# default provider; a ":<hint>" suffix pins routing:
|
||||
# deepseek-ai/DeepSeek-V3:fastest # cheapest/fastest provider for the model
|
||||
# deepseek-ai/DeepSeek-V3:together # pin a specific provider
|
||||
# openai/gpt-oss-120b # open-weight, HF-hosted
|
||||
# Browse models + providers at https://huggingface.co/models?inference_provider=all
|
||||
# The value lives on-chain in AdvertDatum.model — it is data, not code, so you
|
||||
# can advertise any router-supported model without touching the supplier.
|
||||
ADVERT_CAPABILITY_ID=llm.text.generate.v1
|
||||
ADVERT_MODEL=deepseek-ai/DeepSeek-V3:fastest
|
||||
ADVERT_MAX_OUTPUT_TOKENS=512
|
||||
ADVERT_MAX_PROCESSING_MS=60000
|
||||
|
||||
# Flat AP3X amount committed in escrow per job (1 AP3X = 1_000_000 lovelace).
|
||||
# The HF backend meters PER TOKEN while this committed amount is fixed per job,
|
||||
# so size it for your worst-case prompt length and cap output tightly via
|
||||
# ADVERT_MAX_OUTPUT_TOKENS. See docs/HUGGINGFACE_ROUTER_SETUP.md §4.
|
||||
ADVERT_PRICE_LOVELACE=2000000
|
||||
ADVERT_SUPPLIER_BOND_LOVELACE=1000000
|
||||
ADVERT_BUYER_BOND_LOVELACE=1000000
|
||||
|
||||
# HTTPS endpoint buyers will call (must be publicly reachable).
|
||||
ADVERT_ENDPOINT_URL=https://mp-supplier.vector.testnet.apexfusion.org
|
||||
|
||||
# Optional off-chain detail pointer + its sha256 (leave empty for v1).
|
||||
ADVERT_DETAIL_URI=
|
||||
ADVERT_DETAIL_HASH=
|
||||
|
||||
# ─── Optional: CIP-33 reference scripts (recommended on mainnet) ──────────
|
||||
# ESCROW_REF_UTXO=<txhash>#0
|
||||
# ADVERT_REF_UTXO=<txhash>#1
|
||||
@@ -15,7 +15,10 @@
|
||||
*
|
||||
* Required for CAPABILITY_KIND="chat" + LLM_BACKEND="openai":
|
||||
* OPENAI_BASE_URL — http(s):// URL for an OpenAI-compatible /v1/chat/completions
|
||||
* host (e.g. ChatMock localhost proxy backed by Codex OAuth).
|
||||
* host. Examples: ChatMock localhost proxy (Codex OAuth),
|
||||
* OpenRouter (https://openrouter.ai/api), or the HuggingFace
|
||||
* Inference Providers router (https://router.huggingface.co).
|
||||
* See docs/HUGGINGFACE_ROUTER_SETUP.md for the HF preset.
|
||||
*
|
||||
* Required for CAPABILITY_KIND="tts":
|
||||
* PIPER_URL — http(s):// URL for the openedai-speech-min PiperTTS host
|
||||
@@ -33,6 +36,15 @@
|
||||
* OLLAMA_TIMEOUT_MS — ollama request timeout in ms (default 120_000)
|
||||
* OPENAI_TIMEOUT_MS — openai request timeout in ms (default 180_000 — GPT
|
||||
* first-token latency is longer than local Ollama)
|
||||
* OPENAI_SESSION_PASSTHROUGH
|
||||
* — "1" switches chat-session turns to the stateful-
|
||||
* upstream contract (OpenClaw): escrow ref as the
|
||||
* OpenAI `user` field + delta-only messages per turn.
|
||||
* Strict "1" only. Default off. Requires
|
||||
* CAPABILITY_KIND="chat-session".
|
||||
* OPENAI_MODEL_OVERRIDE — fixed upstream model id sent instead of the
|
||||
* advert's buyer-facing model (OpenClaw accepts only
|
||||
* "openclaw" / "openclaw/<agentId>"). Default unset.
|
||||
* PIPER_TIMEOUT_MS — piper request timeout in ms (default 120_000)
|
||||
* LIVE_CHAIN — "1" opts in to live-chain submit/await (default off).
|
||||
* Any other value (including "true", "yes", "TRUE")
|
||||
@@ -51,6 +63,15 @@ const TX_HASH_RE = /^[0-9a-fA-F]{64}$/;
|
||||
const NON_NEG_INT_RE = /^(?:0|[1-9]\d*)$/;
|
||||
const POS_INT_RE = /^[1-9]\d*$/;
|
||||
|
||||
/** Lowercased hostname of a URL, or "" if it doesn't parse. */
|
||||
function hostnameOf(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.toLowerCase();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdvertRef {
|
||||
txHash: string;
|
||||
index: number;
|
||||
@@ -117,6 +138,28 @@ export interface SupplierConfig {
|
||||
* the model context or the provider 400s (e.g. kimi context = 262144).
|
||||
*/
|
||||
openaiMaxTokens: number;
|
||||
/**
|
||||
* When true, chat-session turns speak the STATEFUL-upstream contract
|
||||
* (e.g. an OpenClaw gateway): each turn sends the escrow ref as the OpenAI
|
||||
* `user` field (OpenClaw keys one persistent agent session per `user`, so
|
||||
* browser/tool state survives across turns) and sends ONLY the current
|
||||
* turn's delta messages — the upstream session carries the history, and
|
||||
* re-sending the full transcript would duplicate it into the upstream
|
||||
* context every turn. The supplier still accumulates the full transcript
|
||||
* locally for the receipt. Env: OPENAI_SESSION_PASSTHROUGH="1". Default
|
||||
* off (stateless upstreams get the full transcript each turn). Requires
|
||||
* capabilityKind="chat-session".
|
||||
*/
|
||||
openaiSessionPassthrough: boolean;
|
||||
/**
|
||||
* When non-empty, sent as the upstream `model` instead of the advert's
|
||||
* model id. For upstreams that reject arbitrary model strings — OpenClaw's
|
||||
* chat-completions endpoint 400s on anything but "openclaw" or
|
||||
* "openclaw/<agentId>" — while the advert carries the buyer-facing id the
|
||||
* gateway routes on. Env: OPENAI_MODEL_OVERRIDE. Default "" (advert model
|
||||
* sent verbatim).
|
||||
*/
|
||||
openaiModelOverride: string;
|
||||
/** Empty string when capabilityKind="chat". */
|
||||
piperUrl: string;
|
||||
advertRef: AdvertRef;
|
||||
@@ -336,6 +379,24 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
|
||||
reasoningRaw === "0" ||
|
||||
reasoningRaw === "no";
|
||||
|
||||
// HuggingFace router guard. The `reasoning:{enabled:false}` param is
|
||||
// OpenRouter-specific; the HF Inference Providers router
|
||||
// (router.huggingface.co) rejects it with HTTP 400 on EVERY request. This is
|
||||
// the easy footgun when copying an OpenRouter env to the HF preset, so fail
|
||||
// fast at boot with a clear message instead of 400-ing every job.
|
||||
// See docs/HUGGINGFACE_ROUTER_SETUP.md.
|
||||
if (
|
||||
llmBackend === "openai" &&
|
||||
openaiReasoningDisabled &&
|
||||
hostnameOf(openaiBaseUrl) === "router.huggingface.co"
|
||||
) {
|
||||
throw new Error(
|
||||
"loadConfig: OPENAI_REASONING must be unset for the HuggingFace router " +
|
||||
"(OPENAI_BASE_URL=https://router.huggingface.co) — it rejects " +
|
||||
"reasoning:{enabled:false} with HTTP 400. Remove OPENAI_REASONING.",
|
||||
);
|
||||
}
|
||||
|
||||
// OPENAI_MAX_TOKENS — optional hard ceiling forwarded as `max_tokens`. Unset
|
||||
// or non-positive omits it (provider grants max available = context − prompt).
|
||||
// Keep it below the model context (kimi 262144) or the provider 400s when
|
||||
@@ -349,6 +410,22 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
|
||||
openaiMaxTokens = Number(maxTokStr);
|
||||
}
|
||||
|
||||
// OPENAI_SESSION_PASSTHROUGH — "1" switches chat-session turns to the
|
||||
// stateful-upstream contract (OpenClaw): escrow ref as the OpenAI `user`
|
||||
// field + delta-only messages (the upstream session carries history).
|
||||
// Strict "1" only, mirroring LIVE_CHAIN.
|
||||
const openaiSessionPassthrough = env.OPENAI_SESSION_PASSTHROUGH === "1";
|
||||
if (openaiSessionPassthrough && capabilityKind !== "chat-session") {
|
||||
throw new Error(
|
||||
'loadConfig: OPENAI_SESSION_PASSTHROUGH requires CAPABILITY_KIND="chat-session"',
|
||||
);
|
||||
}
|
||||
|
||||
// OPENAI_MODEL_OVERRIDE — fixed upstream model id sent instead of the
|
||||
// advert's (buyer-facing) model. Needed when the upstream validates the
|
||||
// model field (OpenClaw accepts only "openclaw" / "openclaw/<agentId>").
|
||||
const openaiModelOverride = env.OPENAI_MODEL_OVERRIDE ?? "";
|
||||
|
||||
// CHAT_IDLE_TIMEOUT_MS — idle auto-end window for chat sessions (default 5 min).
|
||||
const chatIdleStr = env.CHAT_IDLE_TIMEOUT_MS;
|
||||
let chatIdleTimeoutMs = 300_000;
|
||||
@@ -463,6 +540,8 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
|
||||
openaiApiKey,
|
||||
openaiReasoningDisabled,
|
||||
openaiMaxTokens,
|
||||
openaiSessionPassthrough,
|
||||
openaiModelOverride,
|
||||
piperUrl,
|
||||
advertRef,
|
||||
networkId,
|
||||
|
||||
@@ -92,7 +92,7 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
|
||||
if (deps.config.llmBackend === "openai") {
|
||||
inference = await openai.callOpenAi({
|
||||
baseUrl: deps.config.openaiBaseUrl,
|
||||
model: advert.model,
|
||||
model: deps.config.openaiModelOverride || advert.model,
|
||||
messages: requestBody.messages,
|
||||
timeoutMs: deps.config.openaiTimeoutMs,
|
||||
apiKey: deps.config.openaiApiKey,
|
||||
|
||||
@@ -52,6 +52,13 @@ export interface CallOpenAiParams {
|
||||
* context window (some providers 400 when it exceeds context).
|
||||
*/
|
||||
maxTokens?: number;
|
||||
/**
|
||||
* Forwarded as the OpenAI `user` field when non-empty. Stateful upstreams
|
||||
* (e.g. an OpenClaw gateway) key their agent session off it, so passing the
|
||||
* escrow ref pins one upstream session per marketplace chat session.
|
||||
* Stateless providers ignore it.
|
||||
*/
|
||||
user?: string;
|
||||
/**
|
||||
* When true, sends `reasoning: { enabled: false }` to disable OpenRouter
|
||||
* "thinking" tokens — faster/cheaper straight answers, and avoids reasoning
|
||||
@@ -93,11 +100,12 @@ function isAbortError(err: unknown): boolean {
|
||||
}
|
||||
|
||||
export async function callOpenAi(params: CallOpenAiParams): Promise<OpenAiResult> {
|
||||
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning } = params;
|
||||
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning, user } = params;
|
||||
const url = `${baseUrl}/v1/chat/completions`;
|
||||
const payload: Record<string, unknown> = { model, messages, stream: false };
|
||||
if (typeof maxTokens === "number" && maxTokens > 0) payload.max_tokens = maxTokens;
|
||||
if (disableReasoning) payload.reasoning = { enabled: false };
|
||||
if (user) payload.user = user;
|
||||
const body = JSON.stringify(payload);
|
||||
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
@@ -215,7 +223,7 @@ export async function callOpenAiStream(
|
||||
params: CallOpenAiParams,
|
||||
onToken: (delta: string) => void,
|
||||
): Promise<OpenAiResult> {
|
||||
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning, tools, toolChoice } = params;
|
||||
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning, tools, toolChoice, user } = params;
|
||||
const url = `${baseUrl}/v1/chat/completions`;
|
||||
const payload: Record<string, unknown> = {
|
||||
model,
|
||||
@@ -225,6 +233,7 @@ export async function callOpenAiStream(
|
||||
};
|
||||
if (typeof maxTokens === "number" && maxTokens > 0) payload.max_tokens = maxTokens;
|
||||
if (disableReasoning) payload.reasoning = { enabled: false };
|
||||
if (user) payload.user = user;
|
||||
if (Array.isArray(tools) && tools.length > 0) {
|
||||
payload.tools = tools;
|
||||
if (toolChoice !== undefined) payload.tool_choice = toolChoice;
|
||||
|
||||
@@ -1272,17 +1272,23 @@ function makeChatSessionHandlers(deps: ResolvedDeps) {
|
||||
};
|
||||
|
||||
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(
|
||||
{
|
||||
baseUrl: deps.config.openaiBaseUrl,
|
||||
model: record.advert.model,
|
||||
messages: record.transcript,
|
||||
model: deps.config.openaiModelOverride || record.advert.model,
|
||||
messages: stateful ? delta : record.transcript,
|
||||
timeoutMs: deps.config.openaiTimeoutMs,
|
||||
apiKey: deps.config.openaiApiKey,
|
||||
maxTokens: deps.config.openaiMaxTokens,
|
||||
disableReasoning: deps.config.openaiReasoningDisabled,
|
||||
tools,
|
||||
toolChoice,
|
||||
user: stateful ? escrowRefStr : undefined,
|
||||
},
|
||||
(tok) => sse({ type: "token", value: tok }),
|
||||
);
|
||||
|
||||
@@ -37,7 +37,7 @@ function chatSessionConfig(): SupplierConfig {
|
||||
} as SupplierConfig;
|
||||
}
|
||||
|
||||
function makeAppWithSession() {
|
||||
function makeAppWithSession(configOverrides?: Partial<SupplierConfig>) {
|
||||
const chatSessions = new ChatSessionStore();
|
||||
const record = chatSessions.create({
|
||||
escrowRef: ESCROW_REF,
|
||||
@@ -48,7 +48,7 @@ function makeAppWithSession() {
|
||||
const app = createApp({
|
||||
chain: new MockChainProvider(),
|
||||
state: new SupplierState(),
|
||||
config: chatSessionConfig(),
|
||||
config: { ...chatSessionConfig(), ...configOverrides } as SupplierConfig,
|
||||
supplierKey: buildSupplierWalletKey(),
|
||||
jobs: new JobStore(),
|
||||
chatSessions,
|
||||
@@ -91,6 +91,54 @@ describe("ChatSessionStore.truncateTranscript", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /v1/chat/message — stateful upstream mode (OPENAI_SESSION_PASSTHROUGH)", () => {
|
||||
it("sends delta-only messages + user=escrowRef + overridden model; transcript still accumulates fully", async () => {
|
||||
const { app, record } = makeAppWithSession({
|
||||
openaiSessionPassthrough: true,
|
||||
openaiModelOverride: "openclaw",
|
||||
});
|
||||
const okFetch = () =>
|
||||
vi.fn().mockResolvedValue(new Response(OK_STREAM, { status: 200, headers: { "content-type": "text/event-stream" } }));
|
||||
|
||||
const fetch1 = okFetch();
|
||||
vi.stubGlobal("fetch", fetch1);
|
||||
await request(app).post("/v1/chat/message").set("X-Escrow-Ref", ESCROW_REF).send({ content: "first" });
|
||||
|
||||
const fetch2 = okFetch();
|
||||
vi.stubGlobal("fetch", fetch2);
|
||||
await request(app).post("/v1/chat/message").set("X-Escrow-Ref", ESCROW_REF).send({ content: "second" });
|
||||
|
||||
// Turn 2 upstream call: only the new delta, keyed to the escrow ref, with
|
||||
// the fixed upstream model — NOT the accumulated transcript / advert model.
|
||||
const body2 = JSON.parse(fetch2.mock.calls[0][1].body as string);
|
||||
expect(body2.messages).toEqual([{ role: "user", content: "second" }]);
|
||||
expect(body2.user).toBe(ESCROW_REF);
|
||||
expect(body2.model).toBe("openclaw");
|
||||
|
||||
// The local transcript (receipt source of truth) still has every turn.
|
||||
expect(record.transcript).toEqual([
|
||||
{ role: "user", content: "first" },
|
||||
{ role: "assistant", content: "Hello" },
|
||||
{ role: "user", content: "second" },
|
||||
{ role: "assistant", content: "Hello" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps full-transcript + advert-model behavior when passthrough is off", async () => {
|
||||
const { app } = makeAppWithSession();
|
||||
const fetch1 = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response(OK_STREAM, { status: 200, headers: { "content-type": "text/event-stream" } }));
|
||||
vi.stubGlobal("fetch", fetch1);
|
||||
await request(app).post("/v1/chat/message").set("X-Escrow-Ref", ESCROW_REF).send({ content: "first" });
|
||||
|
||||
const body = JSON.parse(fetch1.mock.calls[0][1].body as string);
|
||||
expect(body.messages).toEqual([{ role: "user", content: "first" }]);
|
||||
expect("user" in body).toBe(false);
|
||||
expect(body.model).toBe("kimi");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /v1/chat/message — failed-turn rollback", () => {
|
||||
it("truncates the delta on backend failure, then a retry appends exactly once", async () => {
|
||||
const { app, record } = makeAppWithSession();
|
||||
|
||||
@@ -184,6 +184,46 @@ describe("loadConfig() — LIVE_CHAIN parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── loadConfig — HuggingFace router preset (W1) ────────────────────────────
|
||||
// The HF Inference Providers router (router.huggingface.co) is OpenAI-compatible
|
||||
// and reuses LLM_BACKEND=openai, but rejects the OpenRouter-specific
|
||||
// reasoning:{enabled:false} param with HTTP 400 — so OPENAI_REASONING must stay
|
||||
// unset for it. loadConfig fails fast at boot to catch the copy-from-OpenRouter
|
||||
// footgun. See docs/HUGGINGFACE_ROUTER_SETUP.md.
|
||||
|
||||
describe("loadConfig() — HuggingFace router guard", () => {
|
||||
const hfEnv = (over: Record<string, string> = {}) => ({
|
||||
...buildSampleEnv(),
|
||||
LLM_BACKEND: "openai",
|
||||
OPENAI_BASE_URL: "https://router.huggingface.co",
|
||||
OPENAI_API_KEY: "hf_sampletoken",
|
||||
...over,
|
||||
});
|
||||
|
||||
it("accepts the HF router with OPENAI_REASONING unset", () => {
|
||||
const cfg = loadConfig(hfEnv());
|
||||
expect(cfg.openaiBaseUrl).toBe("https://router.huggingface.co");
|
||||
expect(cfg.llmBackend).toBe("openai");
|
||||
expect(cfg.openaiReasoningDisabled).toBe(false);
|
||||
});
|
||||
|
||||
it("throws when OPENAI_REASONING=off is set against the HF router", () => {
|
||||
expect(() => loadConfig(hfEnv({ OPENAI_REASONING: "off" }))).toThrow(
|
||||
/OPENAI_REASONING must be unset for the HuggingFace router/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("still allows OPENAI_REASONING=off for non-HF openai backends (e.g. OpenRouter)", () => {
|
||||
const cfg = loadConfig({
|
||||
...buildSampleEnv(),
|
||||
LLM_BACKEND: "openai",
|
||||
OPENAI_BASE_URL: "https://openrouter.ai/api",
|
||||
OPENAI_REASONING: "off",
|
||||
});
|
||||
expect(cfg.openaiReasoningDisabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── parseAdvertRef ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("parseAdvertRef()", () => {
|
||||
@@ -254,3 +294,40 @@ describe("loadConfig() — MAX_CHAT_SESSIONS / CHAT_SETTLE_MODE", () => {
|
||||
expect(() => loadConfig({ ...chatSessionEnv(), CHAT_SETTLE_MODE: "nope" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── loadConfig — OPENAI_SESSION_PASSTHROUGH ─────────────────────────────────
|
||||
|
||||
describe("loadConfig() — OPENAI_SESSION_PASSTHROUGH", () => {
|
||||
const chatSessionEnv = () => ({
|
||||
...buildSampleEnv(),
|
||||
CAPABILITY_KIND: "chat-session",
|
||||
LLM_BACKEND: "openai",
|
||||
OPENAI_BASE_URL: "http://up",
|
||||
});
|
||||
|
||||
it("defaults off", () => {
|
||||
expect(loadConfig(chatSessionEnv()).openaiSessionPassthrough).toBe(false);
|
||||
});
|
||||
|
||||
it('parses "1" for chat-session kind', () => {
|
||||
const cfg = loadConfig({ ...chatSessionEnv(), OPENAI_SESSION_PASSTHROUGH: "1" });
|
||||
expect(cfg.openaiSessionPassthrough).toBe(true);
|
||||
});
|
||||
|
||||
it('treats values other than "1" as off (mirrors LIVE_CHAIN strictness)', () => {
|
||||
const cfg = loadConfig({ ...chatSessionEnv(), OPENAI_SESSION_PASSTHROUGH: "true" });
|
||||
expect(cfg.openaiSessionPassthrough).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects it for non-chat-session kinds", () => {
|
||||
expect(() => loadConfig({ ...buildSampleEnv(), OPENAI_SESSION_PASSTHROUGH: "1" })).toThrow(
|
||||
/chat-session/,
|
||||
);
|
||||
});
|
||||
|
||||
it("parses OPENAI_MODEL_OVERRIDE (default empty = advert model verbatim)", () => {
|
||||
expect(loadConfig(chatSessionEnv()).openaiModelOverride).toBe("");
|
||||
const cfg = loadConfig({ ...chatSessionEnv(), OPENAI_MODEL_OVERRIDE: "openclaw" });
|
||||
expect(cfg.openaiModelOverride).toBe("openclaw");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,6 +52,22 @@ describe("callOpenAiStream — tools passthrough", () => {
|
||||
expect("tool_choice" in body).toBe(false);
|
||||
});
|
||||
|
||||
it("includes user in the payload when provided (session passthrough)", async () => {
|
||||
const fetchFn = makeStreamFetch(CONTENT_FRAMES);
|
||||
vi.stubGlobal("fetch", fetchFn);
|
||||
await callOpenAiStream({ ...BASE, user: `${"f".repeat(64)}#0` }, () => {});
|
||||
const body = JSON.parse(fetchFn.mock.calls[0][1].body as string);
|
||||
expect(body.user).toBe(`${"f".repeat(64)}#0`);
|
||||
});
|
||||
|
||||
it("omits user from the payload when not provided", async () => {
|
||||
const fetchFn = makeStreamFetch(CONTENT_FRAMES);
|
||||
vi.stubGlobal("fetch", fetchFn);
|
||||
await callOpenAiStream(BASE, () => {});
|
||||
const body = JSON.parse(fetchFn.mock.calls[0][1].body as string);
|
||||
expect("user" in body).toBe(false);
|
||||
});
|
||||
|
||||
it("accumulates fragmented delta.tool_calls across frames", async () => {
|
||||
const frames = sse([
|
||||
{ choices: [{ delta: { tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "get_time", arguments: "" } }] }, finish_reason: null }] },
|
||||
|
||||
@@ -103,6 +103,20 @@ describe("callOpenAi() — request shape", () => {
|
||||
expect(body.stream).toBe(false);
|
||||
});
|
||||
|
||||
it("includes user in the payload when provided (session passthrough)", async () => {
|
||||
await callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS, user: `${"f".repeat(64)}#0` });
|
||||
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body.user).toBe(`${"f".repeat(64)}#0`);
|
||||
});
|
||||
|
||||
it("omits user from the payload when not provided", async () => {
|
||||
await callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS });
|
||||
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect("user" in body).toBe(false);
|
||||
});
|
||||
|
||||
it("omits authorization header when apiKey is not provided (ChatMock path)", async () => {
|
||||
await callOpenAi({ baseUrl: BASE_URL, model: MODEL, messages: MESSAGES, timeoutMs: TIMEOUT_MS });
|
||||
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
|
||||
|
||||
83
tests/unit/tx-release-live-dispatch.test.ts
Normal file
83
tests/unit/tx-release-live-dispatch.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* tx-release-live-dispatch.test.ts — buildReleaseTx() must route to the live
|
||||
* CBOR builder when the chain is a LiveOgmiosProvider, mirroring reclaim.ts.
|
||||
*
|
||||
* Parity gap under test: Release is the one escrow spend path still hardwired
|
||||
* to the synthetic testTxBody backend, which leaves Submitted-past-accept-window
|
||||
* escrows unresolvable on mainnet (sweeper parks them "for supplier Release").
|
||||
*
|
||||
* The live builder itself needs a real wallet + Ogmios and is exercised by the
|
||||
* mainnet fleet scripts like the other six builders; here it is mocked at the
|
||||
* module boundary and only the dispatch contract is asserted.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
vi.mock("../../packages/shared/src/tx/internal/liveCbor.js", () => ({
|
||||
buildLiveTxForRelease: vi.fn(async () => ({
|
||||
txCborHex: "live-cbor-sentinel",
|
||||
expectedTxHash: "live-hash-sentinel",
|
||||
})),
|
||||
}));
|
||||
|
||||
import { buildReleaseTx } from "../../packages/shared/src/tx/escrow/release.js";
|
||||
import { LiveOgmiosProvider } from "../../packages/shared/src/chain/LiveOgmiosProvider.js";
|
||||
import { ACCEPT_WINDOW_MS } from "../../packages/shared/src/tx/escrow/accept.js";
|
||||
import * as liveCbor from "../../packages/shared/src/tx/internal/liveCbor.js";
|
||||
import { buildSupplierWalletKey } from "../fixtures/supplier-side/wallet-keys.js";
|
||||
import {
|
||||
buildSubmittedEscrowUtxo,
|
||||
SUBMITTED_AT,
|
||||
} from "../fixtures/buyer-side/sample-escrow-utxos.js";
|
||||
|
||||
const RELEASE_THRESHOLD = SUBMITTED_AT + ACCEPT_WINDOW_MS;
|
||||
|
||||
/**
|
||||
* A stub that passes the `instanceof LiveOgmiosProvider` backend check without
|
||||
* opening any connection. Only the methods buildReleaseTx touches pre-dispatch
|
||||
* are provided. Tip slot is set past the accept window so that, while the
|
||||
* dispatch gap exists, the mock path completes and returns synthetic CBOR
|
||||
* (a clean assertion failure) instead of erroring on the window check.
|
||||
*/
|
||||
function fakeLiveChain(): LiveOgmiosProvider {
|
||||
const utxo = buildSubmittedEscrowUtxo();
|
||||
const fake = Object.create(LiveOgmiosProvider.prototype) as LiveOgmiosProvider;
|
||||
Object.assign(fake, {
|
||||
queryUtxo: async () => utxo,
|
||||
tip: async () => Math.floor((RELEASE_THRESHOLD + 60_000) / 1000),
|
||||
submitTx: async () => undefined,
|
||||
});
|
||||
return fake;
|
||||
}
|
||||
|
||||
describe("buildReleaseTx() — live backend dispatch", () => {
|
||||
it("routes to buildLiveTxForRelease when chain is a LiveOgmiosProvider", async () => {
|
||||
const chain = fakeLiveChain();
|
||||
const supplier = buildSupplierWalletKey();
|
||||
const utxo = buildSubmittedEscrowUtxo();
|
||||
|
||||
const result = await buildReleaseTx({ chain, supplierKey: supplier, escrowRef: utxo.ref });
|
||||
|
||||
expect(result.txCborHex).toBe("live-cbor-sentinel");
|
||||
expect(result.expectedTxHash).toBe("live-hash-sentinel");
|
||||
expect(liveCbor.buildLiveTxForRelease).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes the escrow UTxO, decoded datum, and a real-clock tipMs to the live builder", async () => {
|
||||
const chain = fakeLiveChain();
|
||||
const supplier = buildSupplierWalletKey();
|
||||
const utxo = buildSubmittedEscrowUtxo();
|
||||
const before = Date.now();
|
||||
|
||||
await buildReleaseTx({ chain, supplierKey: supplier, escrowRef: utxo.ref });
|
||||
|
||||
const mockFn = liveCbor.buildLiveTxForRelease as unknown as ReturnType<typeof vi.fn>;
|
||||
const args = mockFn.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
||||
expect(args.escrowUtxo).toEqual(utxo);
|
||||
expect((args.datum as { state: string }).state).toBe("Submitted");
|
||||
expect(args.supplierKey).toBe(supplier);
|
||||
// Live path derives tipMs from the real clock (reclaim.ts parity), not
|
||||
// from the mock slot→ms mapping.
|
||||
expect(args.tipMs as number).toBeGreaterThanOrEqual(before);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user