9 Commits

Author SHA1 Message Date
FilipBl4gojevic
d5fdd1f1b7 feat(tx): live CBOR builder for Release - Submitted strays become resolvable
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
Release was the one escrow spend path still hardwired to the synthetic
testTxBody backend, so suppliers had no way to resolve Submitted escrows
past the accept window on mainnet (the sweeper parks them 'for supplier
Release'). Adds buildLiveTxForRelease mirroring the Reclaim builder
(supplier payout, Constr4/tag 125 redeemer, Ogmios UPLC eval) and the
detectCborBackend dispatch in buildReleaseTx, reclaim.ts-style.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,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")
@@ -129,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;
@@ -379,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;
@@ -493,6 +540,8 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
openaiApiKey,
openaiReasoningDisabled,
openaiMaxTokens,
openaiSessionPassthrough,
openaiModelOverride,
piperUrl,
advertRef,
networkId,

View File

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

View File

@@ -52,6 +52,13 @@ export interface CallOpenAiParams {
* context window (some providers 400 when it exceeds context).
*/
maxTokens?: number;
/**
* Forwarded as the OpenAI `user` field when non-empty. Stateful upstreams
* (e.g. an OpenClaw gateway) key their agent session off it, so passing the
* escrow ref pins one upstream session per marketplace chat session.
* Stateless providers ignore it.
*/
user?: string;
/**
* When true, sends `reasoning: { enabled: false }` to disable OpenRouter
* "thinking" tokens — faster/cheaper straight answers, and avoids reasoning
@@ -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;

View File

@@ -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 }),
);

View File

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

View File

@@ -294,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");
});
});

View File

@@ -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 }] },

View File

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

View 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);
});
});