Compare commits
57 Commits
feat/llm-c
...
fix/hetzne
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0c83ca985 | ||
|
|
bfcb0c46b8 | ||
|
|
88e549bd1d | ||
|
|
6c5a068fd9 | ||
|
|
83f81ab5fc | ||
|
|
6859486334 | ||
|
|
304bbc62ad | ||
|
|
312f3e730e | ||
|
|
6156900fc2 | ||
|
|
7d348e762f | ||
|
|
7bf3252cdb | ||
|
|
feabd12876 | ||
|
|
2b2295b7ed | ||
|
|
069edcb076 | ||
|
|
5377d379b7 | ||
|
|
d5349057b0 | ||
|
|
678ee862ea | ||
|
|
c391b98af8 | ||
|
|
6ca7f2927a | ||
|
|
4d94aa5040 | ||
|
|
36c5cfc6be | ||
|
|
a54fdcb24c | ||
|
|
926300dee5 | ||
|
|
73700f983a | ||
|
|
2aa1688a17 | ||
|
|
3ae2a827c4 | ||
|
|
fec0ac8d0a | ||
|
|
be9febffb1 | ||
|
|
e5793031f8 | ||
|
|
716a8bf2ef | ||
|
|
b10706cf69 | ||
|
|
1f587867c6 | ||
|
|
0b253c73bb | ||
|
|
1648edeba7 | ||
|
|
4090e064cb | ||
|
|
3fac04b00e | ||
|
|
2f7b0194a5 | ||
|
|
9a4f9f0699 | ||
|
|
3d2b4bb203 | ||
|
|
4ebe7c9683 | ||
|
|
b92bff56a7 | ||
|
|
ab649c55da | ||
|
|
99c44442e7 | ||
|
|
9b5e5b5710 | ||
|
|
f4f35866f5 | ||
|
|
4ba074020c | ||
|
|
6de62b8cf0 | ||
|
|
900b2259e0 | ||
|
|
ac6689275f | ||
|
|
06e7cd8800 | ||
|
|
3f2a37c157 | ||
|
|
b2866506fc | ||
|
|
599033bef1 | ||
|
|
a0f6ff4444 | ||
|
|
4434c787be | ||
|
|
5d2397d79b | ||
|
|
5ddc47f1f4 |
69
.github/workflows/deploy-mainnet.yml
vendored
Normal file
69
.github/workflows/deploy-mainnet.yml
vendored
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
name: Deploy mainnet
|
||||||
|
|
||||||
|
# CD for the vector-marketplace host (91.98.147.172).
|
||||||
|
#
|
||||||
|
# Trigger: automatically after the "CI" workflow succeeds on main, or manually
|
||||||
|
# via workflow_dispatch (Actions tab -> Deploy mainnet -> Run workflow).
|
||||||
|
#
|
||||||
|
# The runner only SSHes in and executes deploy/mainnet/deploy.sh AT THE NEW
|
||||||
|
# COMMIT (fetched server-side via `git show FETCH_HEAD:`), so deploy logic is
|
||||||
|
# versioned with the code it deploys. The script itself decides which compose
|
||||||
|
# projects to rebuild based on the old..new diff, drains busy suppliers, and
|
||||||
|
# health-gates every restart — see deploy/mainnet/deploy.sh.
|
||||||
|
#
|
||||||
|
# Secrets: DEPLOY_SSH_KEY — ed25519 private key whose public half is in
|
||||||
|
# root@vector-marketplace:~/.ssh/authorized_keys.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["CI"]
|
||||||
|
types: [completed]
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
force:
|
||||||
|
description: "Redeploy even if the server is already at origin/main"
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-mainnet
|
||||||
|
cancel-in-progress: false # never kill a deploy mid-rollout
|
||||||
|
|
||||||
|
env:
|
||||||
|
DEPLOY_HOST: 91.98.147.172
|
||||||
|
DEPLOY_USER: root
|
||||||
|
# Pinned host key (ssh-keyscan -t ed25519 91.98.147.172)
|
||||||
|
DEPLOY_HOSTKEY: "91.98.147.172 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJvjM8D+pNUb2Qr4yJbqVcRrUzbpbfyv0/tupUgD5Th0"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: deploy to vector-marketplace
|
||||||
|
runs-on: nixos
|
||||||
|
# workflow_run fires on failure too — only deploy green main.
|
||||||
|
if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
|
||||||
|
steps:
|
||||||
|
# Self-hosted runner: keep key material in the job's temp dir, not the
|
||||||
|
# runner user's ~/.ssh (which persists across jobs).
|
||||||
|
- name: Set up SSH
|
||||||
|
run: |
|
||||||
|
install -m 700 -d "$RUNNER_TEMP/ssh"
|
||||||
|
printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY }}" > "$RUNNER_TEMP/ssh/id_ed25519"
|
||||||
|
chmod 600 "$RUNNER_TEMP/ssh/id_ed25519"
|
||||||
|
printf '%s\n' "$DEPLOY_HOSTKEY" > "$RUNNER_TEMP/ssh/known_hosts"
|
||||||
|
|
||||||
|
- name: Run deploy script on server
|
||||||
|
run: |
|
||||||
|
FORCE=0
|
||||||
|
[ "${{ github.event.inputs.force }}" = "true" ] && FORCE=1
|
||||||
|
ssh -o BatchMode=yes \
|
||||||
|
-o IdentitiesOnly=yes \
|
||||||
|
-i "$RUNNER_TEMP/ssh/id_ed25519" \
|
||||||
|
-o UserKnownHostsFile="$RUNNER_TEMP/ssh/known_hosts" \
|
||||||
|
"$DEPLOY_USER@$DEPLOY_HOST" "
|
||||||
|
set -euo pipefail
|
||||||
|
cd /root/agents-marketplace
|
||||||
|
git fetch origin main
|
||||||
|
git show FETCH_HEAD:deploy/mainnet/deploy.sh > /run/marketplace-deploy.sh
|
||||||
|
FORCE=$FORCE bash /run/marketplace-deploy.sh
|
||||||
|
"
|
||||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -11,9 +11,21 @@ coverage
|
|||||||
!.env.*.example
|
!.env.*.example
|
||||||
|
|
||||||
# Local data / caches (matches at any depth — covers
|
# Local data / caches (matches at any depth — covers
|
||||||
# deploy/simulation/data/, indexer/data/, etc.)
|
# deploy/simulation/data/, indexer/data/, wallet-monitor/data/, etc.)
|
||||||
data/
|
data/
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# wallet-monitor: the real wallet list holds production mainnet addresses —
|
||||||
|
# keep it out of git; only wallets.example.json is committed.
|
||||||
|
wallet-monitor/wallets.json
|
||||||
|
|
||||||
|
# Generated wallet key dumps (e.g. tx:gen-keypair output, supplier/.deepseek-wallet.json)
|
||||||
|
# — these contain privateKeyHex. Never commit.
|
||||||
|
*-wallet.json
|
||||||
|
|
||||||
|
# Operator local file backups (e.g. docker-compose.buyer.yml.bak-20260609)
|
||||||
|
*.bak
|
||||||
|
*.bak-*
|
||||||
.vitest-cache
|
.vitest-cache
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
|||||||
37
README.md
37
README.md
@@ -1,3 +1,36 @@
|
|||||||
## Local Agents Marketplace
|
# Local Agents Marketplace
|
||||||
|
|
||||||
Local Agents Marketplace MVP. See `docs/ARCHITECTURE.md` for the architecture spec. Monorepo managed with pnpm workspaces. Work-in-progress; not production-ready.
|
Bonded-escrow coordination for AI agent work on **Vector**, the Apex Fusion eUTXO L2.
|
||||||
|
|
||||||
|
Buyers commission work. Suppliers claim it. Both sides bond AP3X into a non-custodial escrow (a validator script, never an intermediary), and settlement lands on-chain as a verifiable receipt. Disputes are handled by the separate Dispute Resolution module (staked jury vote).
|
||||||
|
|
||||||
|
**Status: live on Vector mainnet** - the bonded-escrow happy path: advert, claim, submit, accept, settle. The contracts have been through the internal audit pipeline (methodology published in [vector-ai-agents](https://github.com/Apex-Fusion/vector-ai-agents)); they have not yet undergone independent third-party audit.
|
||||||
|
|
||||||
|
## Flow
|
||||||
|
|
||||||
|
1. **Advert** - a buyer posts a job with escrow bonded
|
||||||
|
2. **Claim** - a supplier claims it, bonding their side
|
||||||
|
3. **Submit** - work is delivered
|
||||||
|
4. **Accept** - the buyer accepts the work
|
||||||
|
5. **Settle** - the escrow releases; the settlement is a signed on-chain receipt
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
| Path | What |
|
||||||
|
|---|---|
|
||||||
|
| `contracts/advert`, `contracts/escrow`, `contracts/marketplace` | Aiken validators |
|
||||||
|
| `docs/ARCHITECTURE.md` | Architecture spec |
|
||||||
|
| `docs/buyer/`, `docs/supplier/` | Role guides |
|
||||||
|
| `deploy/` | Deployment and simulation tooling |
|
||||||
|
|
||||||
|
Monorepo managed with pnpm workspaces.
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
Developer docs: [Vector AI documentation](https://apex-fusion.github.io/vector-ai-documentation/). For the contract-level picture, start with [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [mcp-server](https://github.com/Apex-Fusion/mcp-server) - hosted MCP server for Vector (testnet and mainnet)
|
||||||
|
- [agent-sdk-py](https://github.com/Apex-Fusion/agent-sdk-py) / [agent-sdk-ts](https://github.com/Apex-Fusion/agent-sdk-ts) - Python and TypeScript SDKs
|
||||||
|
- [vector-agent-modules](https://github.com/Apex-Fusion/vector-agent-modules) - dispute resolution, reputation staking, self-improvement
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ RUN apk add --no-cache python3 make g++
|
|||||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||||
|
|
||||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./
|
||||||
|
# pnpm-lock.yaml's patchedDependencies hash the patch files at install time.
|
||||||
|
COPY patches ./patches
|
||||||
COPY packages/shared/package.json ./packages/shared/package.json
|
COPY packages/shared/package.json ./packages/shared/package.json
|
||||||
COPY buyer/package.json ./buyer/package.json
|
COPY buyer/package.json ./buyer/package.json
|
||||||
COPY supplier/package.json ./supplier/package.json
|
COPY supplier/package.json ./supplier/package.json
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
"./sdk": "./src/sdk/index.ts"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"build:ui": "vite build --config vite.config.ts",
|
"build:ui": "vite build --config vite.config.ts",
|
||||||
|
|||||||
@@ -83,6 +83,48 @@ const OPERATOR_SOURCES: OperatorSource[] = [
|
|||||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||||
role: "supplier",
|
role: "supplier",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "supplier-kimi-2@vector-marketplace",
|
||||||
|
host: "root@91.98.147.172",
|
||||||
|
envPath: "/root/agents-marketplace/supplier/.env.kimi-2",
|
||||||
|
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||||
|
role: "supplier",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "supplier-kimi-3@vector-marketplace",
|
||||||
|
host: "root@91.98.147.172",
|
||||||
|
envPath: "/root/agents-marketplace/supplier/.env.kimi-3",
|
||||||
|
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||||
|
role: "supplier",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "supplier-kimi-4@vector-marketplace",
|
||||||
|
host: "root@91.98.147.172",
|
||||||
|
envPath: "/root/agents-marketplace/supplier/.env.kimi-4",
|
||||||
|
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||||
|
role: "supplier",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "supplier-deepseek-flash-1@vector-marketplace",
|
||||||
|
host: "root@91.98.147.172",
|
||||||
|
envPath: "/root/agents-marketplace/supplier/.env.deepseek-flash-1",
|
||||||
|
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||||
|
role: "supplier",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "supplier-deepseek-flash-2@vector-marketplace",
|
||||||
|
host: "root@91.98.147.172",
|
||||||
|
envPath: "/root/agents-marketplace/supplier/.env.deepseek-flash-2",
|
||||||
|
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||||
|
role: "supplier",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "supplier-deepseek-flash-3@vector-marketplace",
|
||||||
|
host: "root@91.98.147.172",
|
||||||
|
envPath: "/root/agents-marketplace/supplier/.env.deepseek-flash-3",
|
||||||
|
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||||
|
role: "supplier",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "buyer@open-webui",
|
label: "buyer@open-webui",
|
||||||
host: "open-webui",
|
host: "open-webui",
|
||||||
|
|||||||
@@ -36,6 +36,12 @@
|
|||||||
* Defaults to "./data/archive" relative to cwd.
|
* Defaults to "./data/archive" relative to cwd.
|
||||||
* When unset, archive is disabled (responses are
|
* When unset, archive is disabled (responses are
|
||||||
* not persisted; /v1/responses* return 503).
|
* not persisted; /v1/responses* return 503).
|
||||||
|
* GATEWAY_PUBLIC_URL — Public base URL of the OpenAI-compatible gateway
|
||||||
|
* (e.g. https://api.marketplace.vector.apexfusion.org).
|
||||||
|
* Injected into the SPA boot block so the "Generate
|
||||||
|
* API key" page knows where to POST /signup. When
|
||||||
|
* unset, the SPA falls back to deriving "api." +
|
||||||
|
* current host.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
|
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
|
||||||
@@ -56,6 +62,7 @@ export interface BuyerConfig {
|
|||||||
password: string;
|
password: string;
|
||||||
sessionSecret: string;
|
sessionSecret: string;
|
||||||
cookieSecure: boolean;
|
cookieSecure: boolean;
|
||||||
|
gatewayPublicUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireField(env: Record<string, string | undefined>, name: string): string {
|
function requireField(env: Record<string, string | undefined>, name: string): string {
|
||||||
@@ -141,6 +148,11 @@ export function loadConfig(env: Record<string, string | undefined>): BuyerConfig
|
|||||||
cookieSecure = cookieSecureStr === "1";
|
cookieSecure = cookieSecureStr === "1";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GATEWAY_PUBLIC_URL — optional. Public base URL of the gateway, surfaced to
|
||||||
|
// the SPA via the boot block (no trailing slash). Empty string means the SPA
|
||||||
|
// derives the URL from its own host ("api." + location.host).
|
||||||
|
const gatewayPublicUrl = (env.GATEWAY_PUBLIC_URL ?? "").trim().replace(/\/+$/, "");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
privKeyHex,
|
privKeyHex,
|
||||||
indexerUrl,
|
indexerUrl,
|
||||||
@@ -155,5 +167,6 @@ export function loadConfig(env: Record<string, string | undefined>): BuyerConfig
|
|||||||
password,
|
password,
|
||||||
sessionSecret,
|
sessionSecret,
|
||||||
cookieSecure,
|
cookieSecure,
|
||||||
|
gatewayPublicUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ export async function runMain(env: Record<string, string | undefined>): Promise<
|
|||||||
password: config.password,
|
password: config.password,
|
||||||
sessionSecret: config.sessionSecret,
|
sessionSecret: config.sessionSecret,
|
||||||
cookieSecure: config.cookieSecure,
|
cookieSecure: config.cookieSecure,
|
||||||
|
gatewayPublicUrl: config.gatewayPublicUrl,
|
||||||
jobStore,
|
jobStore,
|
||||||
pdfCaps,
|
pdfCaps,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -73,14 +73,15 @@ import {
|
|||||||
buildPostEscrowTx,
|
buildPostEscrowTx,
|
||||||
buildPostTtsEscrowTx,
|
buildPostTtsEscrowTx,
|
||||||
buildPostChatEscrowTx,
|
buildPostChatEscrowTx,
|
||||||
|
buildPostOcrEscrowTx,
|
||||||
ttsPromptHash,
|
ttsPromptHash,
|
||||||
chatSessionPromptHash,
|
chatSessionPromptHash,
|
||||||
|
ocrPromptHash,
|
||||||
buildAcceptTx,
|
buildAcceptTx,
|
||||||
buildReclaimTx,
|
buildReclaimTx,
|
||||||
TxConstructionError,
|
TxConstructionError,
|
||||||
} from "@marketplace/shared/tx";
|
} from "@marketplace/shared/tx";
|
||||||
import { decodeAdvertDatum, decodeEscrowDatum, canonicalize } from "@marketplace/shared/cbor";
|
import { decodeAdvertDatum, decodeEscrowDatum, canonicalize } from "@marketplace/shared/cbor";
|
||||||
import { verifyReceipt } from "@marketplace/shared/receipt";
|
|
||||||
import type { AdvertDatum } from "@marketplace/shared/cbor";
|
import type { AdvertDatum } from "@marketplace/shared/cbor";
|
||||||
import type {
|
import type {
|
||||||
SupplierView,
|
SupplierView,
|
||||||
@@ -89,8 +90,11 @@ import type {
|
|||||||
SubmitPromptResult,
|
SubmitPromptResult,
|
||||||
SubmitTtsOptions,
|
SubmitTtsOptions,
|
||||||
SubmitTtsResult,
|
SubmitTtsResult,
|
||||||
|
SubmitOcrOptions,
|
||||||
|
SubmitOcrResult,
|
||||||
StartChatOptions,
|
StartChatOptions,
|
||||||
StartChatResult,
|
StartChatResult,
|
||||||
|
ChatSettleMode,
|
||||||
EndChatOptions,
|
EndChatOptions,
|
||||||
EndChatResult,
|
EndChatResult,
|
||||||
AcceptResultOptions,
|
AcceptResultOptions,
|
||||||
@@ -807,6 +811,278 @@ export class Marketplace extends EventEmitterBase {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── submitOcr — full lifecycle for ocr.page.extract.<model-slug>.v1 ──
|
||||||
|
|
||||||
|
async submitOcr(opts: SubmitOcrOptions): Promise<SubmitOcrResult> {
|
||||||
|
const { advertRef, image_b64, mime, output_format, payment_lovelace } = opts;
|
||||||
|
const request = { image_b64, mime, output_format };
|
||||||
|
|
||||||
|
let advertDatum: AdvertDatum | null = null;
|
||||||
|
let escrowOutputRef: OutputReference | null = null;
|
||||||
|
let escrowRefStr = "";
|
||||||
|
let postedAtMs = Date.now();
|
||||||
|
|
||||||
|
const previewStr = `[ocr:${mime} ${output_format} ${image_b64.length}b64]`;
|
||||||
|
const recordFailure = (reason: string): void => {
|
||||||
|
this.historyStore.save({
|
||||||
|
escrow_ref: escrowRefStr || `${"0".repeat(64)}#0`,
|
||||||
|
supplier_pkh: advertDatum?.supplier_pkh ?? "",
|
||||||
|
capability_id: advertDatum?.capability_id ?? "",
|
||||||
|
prompt_preview: previewStr,
|
||||||
|
posted_at: postedAtMs,
|
||||||
|
status: "failed",
|
||||||
|
failure_reason: reason,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 1. Resolve advert + post escrow (OCR prompt_hash) ─────────────
|
||||||
|
let escrowResult;
|
||||||
|
try {
|
||||||
|
const utxo = await this.chain.queryUtxo(advertRef);
|
||||||
|
if (utxo && utxo.datumHex) {
|
||||||
|
try {
|
||||||
|
advertDatum = decodeAdvertDatum(utxo.datumHex);
|
||||||
|
} catch { /* builder will re-throw structurally */ }
|
||||||
|
}
|
||||||
|
escrowResult = await buildPostOcrEscrowTx({
|
||||||
|
chain: this.chain,
|
||||||
|
buyerKey: this.walletKey,
|
||||||
|
advertRef,
|
||||||
|
request,
|
||||||
|
payment_lovelace,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TxConstructionError) {
|
||||||
|
recordFailure(err.reason);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
this.emitProgress({ type: "chain_submit_failed", detail: (err as Error).message });
|
||||||
|
recordFailure((err as Error).message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
escrowOutputRef = escrowResult.escrowOutputRef;
|
||||||
|
escrowRefStr = refToString(escrowOutputRef);
|
||||||
|
|
||||||
|
this.emitProgress({ type: "escrow_posted", escrow_ref: escrowRefStr });
|
||||||
|
|
||||||
|
// ── 2. Wait for the escrow tx to confirm ──────────────────────────
|
||||||
|
try {
|
||||||
|
await this.chain.awaitTx(escrowResult.expectedTxHash, 120_000);
|
||||||
|
} catch { /* mock providers / tests */ }
|
||||||
|
|
||||||
|
// ── 3. Re-fetch escrow datum to capture posted_at ─────────────────
|
||||||
|
try {
|
||||||
|
const escrowUtxo = await this.chain.queryUtxo(escrowOutputRef);
|
||||||
|
if (escrowUtxo && escrowUtxo.datumHex) {
|
||||||
|
const ed = decodeEscrowDatum(escrowUtxo.datumHex);
|
||||||
|
postedAtMs = ed.posted_at;
|
||||||
|
}
|
||||||
|
} catch { /* metadata best-effort */ }
|
||||||
|
|
||||||
|
if (!advertDatum) {
|
||||||
|
const reason = "advert datum unavailable after escrow post";
|
||||||
|
recordFailure(reason);
|
||||||
|
throw new ReceiptVerificationError(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Call supplier /v1/ocr/extract ──────────────────────────────
|
||||||
|
const supplierBaseUrl = advertDatum.endpoint_url;
|
||||||
|
const supplierHttp = new HttpClient({
|
||||||
|
baseUrl: supplierBaseUrl,
|
||||||
|
fetch: this.fetchImpl,
|
||||||
|
});
|
||||||
|
const ocrBody = { image_b64, mime, output_format };
|
||||||
|
|
||||||
|
let ocrResult;
|
||||||
|
try {
|
||||||
|
ocrResult = await supplierHttp.postJson("/v1/ocr/extract", ocrBody, {
|
||||||
|
headers: { "X-Escrow-Ref": escrowRefStr },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof HttpError) {
|
||||||
|
const reason = err.kind === "timeout" ? "timeout" : "network_error";
|
||||||
|
const sErr = new SupplierError(reason, { message: err.message });
|
||||||
|
recordFailure(reason);
|
||||||
|
throw sErr;
|
||||||
|
}
|
||||||
|
recordFailure((err as Error).message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 202 + poll mirror of submitTts's async handling. OCR inference for a
|
||||||
|
// page runs seconds; the long tail is the Submit tx confirmation.
|
||||||
|
if (ocrResult.status === 202 && ocrResult.body && typeof ocrResult.body === "object") {
|
||||||
|
const jobId = (ocrResult.body as { job_id?: string }).job_id;
|
||||||
|
if (typeof jobId !== "string" || jobId.length === 0) {
|
||||||
|
const sErr = new SupplierError("malformed_response", {
|
||||||
|
status: ocrResult.status,
|
||||||
|
message: "supplier 202 response missing job_id",
|
||||||
|
});
|
||||||
|
recordFailure("malformed_response");
|
||||||
|
throw sErr;
|
||||||
|
}
|
||||||
|
const POLL_INTERVAL_MS = 2_000;
|
||||||
|
const POLL_TIMEOUT_MS = 180_000;
|
||||||
|
const pollDeadline = Date.now() + POLL_TIMEOUT_MS;
|
||||||
|
let polled = ocrResult;
|
||||||
|
let firstIter = true;
|
||||||
|
while (Date.now() < pollDeadline) {
|
||||||
|
if (!firstIter) {
|
||||||
|
await new Promise<void>((r) => setTimeout(r, POLL_INTERVAL_MS));
|
||||||
|
}
|
||||||
|
firstIter = false;
|
||||||
|
try {
|
||||||
|
polled = await supplierHttp.getJson(`/v1/ocr/extract/${jobId}`);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof HttpError) {
|
||||||
|
const reason = err.kind === "timeout" ? "timeout" : "network_error";
|
||||||
|
const sErr = new SupplierError(reason, { message: err.message });
|
||||||
|
recordFailure(reason);
|
||||||
|
throw sErr;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (polled.status === 200) break;
|
||||||
|
if (polled.status === 202) continue;
|
||||||
|
const failBody = (polled.body && typeof polled.body === "object")
|
||||||
|
? polled.body as { reason?: string; message?: string }
|
||||||
|
: {};
|
||||||
|
const sErr = new SupplierError(failBody.reason ?? "supplier_http_error", {
|
||||||
|
status: polled.status,
|
||||||
|
message: failBody.message ?? `supplier returned ${polled.status}`,
|
||||||
|
});
|
||||||
|
recordFailure(sErr.reason);
|
||||||
|
throw sErr;
|
||||||
|
}
|
||||||
|
if (polled.status !== 200) {
|
||||||
|
const sErr = new SupplierError("timeout", {
|
||||||
|
status: polled.status,
|
||||||
|
message: `supplier job ${jobId} not done within ${POLL_TIMEOUT_MS}ms`,
|
||||||
|
});
|
||||||
|
recordFailure("timeout");
|
||||||
|
throw sErr;
|
||||||
|
}
|
||||||
|
ocrResult = polled;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ocrResult.ok) {
|
||||||
|
const bodyReason =
|
||||||
|
ocrResult.body && typeof ocrResult.body === "object"
|
||||||
|
? ((ocrResult.body as { reason?: string }).reason ?? "supplier_http_error")
|
||||||
|
: "supplier_http_error";
|
||||||
|
const sErr = new SupplierError(bodyReason, {
|
||||||
|
status: ocrResult.status,
|
||||||
|
message: `supplier returned ${ocrResult.status}`,
|
||||||
|
});
|
||||||
|
recordFailure(bodyReason);
|
||||||
|
throw sErr;
|
||||||
|
}
|
||||||
|
if (ocrResult.parseError || !ocrResult.body || typeof ocrResult.body !== "object") {
|
||||||
|
const sErr = new SupplierError("malformed_response", {
|
||||||
|
status: ocrResult.status,
|
||||||
|
message: "supplier body is not valid JSON",
|
||||||
|
});
|
||||||
|
recordFailure("malformed_response");
|
||||||
|
throw sErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseBody = ocrResult.body as {
|
||||||
|
output_format?: string;
|
||||||
|
content?: string;
|
||||||
|
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
|
||||||
|
receipt?: import("@marketplace/shared/receipt").Receipt;
|
||||||
|
receipt_signature?: string;
|
||||||
|
};
|
||||||
|
if (!responseBody.content || !responseBody.receipt || !responseBody.receipt_signature) {
|
||||||
|
const sErr = new SupplierError("malformed_response", {
|
||||||
|
status: ocrResult.status,
|
||||||
|
message: "supplier response is missing content / receipt / receipt_signature",
|
||||||
|
});
|
||||||
|
recordFailure("malformed_response");
|
||||||
|
throw sErr;
|
||||||
|
}
|
||||||
|
const receipt = responseBody.receipt;
|
||||||
|
const receiptSignature = responseBody.receipt_signature;
|
||||||
|
|
||||||
|
this.emitProgress({ type: "supplier_called", escrow_ref: escrowRefStr });
|
||||||
|
|
||||||
|
// ── 5. Receipt verification ───────────────────────────────────────
|
||||||
|
if (receipt.supplier_pkh !== advertDatum.supplier_pkh) {
|
||||||
|
recordFailure("wrong_supplier");
|
||||||
|
throw new ReceiptVerificationError("wrong_supplier");
|
||||||
|
}
|
||||||
|
if (receipt.escrow_ref !== escrowRefStr) {
|
||||||
|
recordFailure("wrong_escrow_ref");
|
||||||
|
throw new ReceiptVerificationError("wrong_escrow_ref");
|
||||||
|
}
|
||||||
|
// The OCR prompt commitment uses the SAME canonical hash the supplier
|
||||||
|
// validated against the escrow datum. Recompute from the envelope we
|
||||||
|
// sent; a mismatch means the supplier signed a different request.
|
||||||
|
const expectedPromptHash = ocrPromptHash(request);
|
||||||
|
if (receipt.prompt_hash !== expectedPromptHash) {
|
||||||
|
recordFailure("prompt_hash_mismatch");
|
||||||
|
throw new ReceiptVerificationError("prompt_hash_mismatch");
|
||||||
|
}
|
||||||
|
if (receipt.model !== advertDatum.model) {
|
||||||
|
recordFailure("request_spec_hash_mismatch");
|
||||||
|
throw new ReceiptVerificationError("request_spec_hash_mismatch");
|
||||||
|
}
|
||||||
|
// The receipt's response_hash must commit the content we actually
|
||||||
|
// received in the requested output shape.
|
||||||
|
const expectedResponseHash = nodeCrypto.createHash("sha256")
|
||||||
|
.update(canonicalize({
|
||||||
|
content: responseBody.content,
|
||||||
|
output_format: responseBody.output_format ?? output_format,
|
||||||
|
}), "utf8")
|
||||||
|
.digest("hex");
|
||||||
|
if (receipt.response_hash !== expectedResponseHash) {
|
||||||
|
recordFailure("response_hash_mismatch");
|
||||||
|
throw new ReceiptVerificationError("response_hash_mismatch");
|
||||||
|
}
|
||||||
|
if (typeof receiptSignature !== "string" || !SIG_RE.test(receiptSignature)) {
|
||||||
|
recordFailure("invalid_signature");
|
||||||
|
throw new ReceiptVerificationError("invalid_signature");
|
||||||
|
}
|
||||||
|
if (receiptSignature === ZERO_SIGNATURE) {
|
||||||
|
recordFailure("invalid_signature");
|
||||||
|
throw new ReceiptVerificationError("invalid_signature");
|
||||||
|
}
|
||||||
|
if (!HEX64_RE.test(receipt.prompt_hash) || !HEX64_RE.test(receipt.response_hash)) {
|
||||||
|
recordFailure("malformed_receipt");
|
||||||
|
throw new ReceiptVerificationError("malformed_receipt");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emitProgress({ type: "receipt_verified", escrow_ref: escrowRefStr });
|
||||||
|
|
||||||
|
this.historyStore.save({
|
||||||
|
escrow_ref: escrowRefStr,
|
||||||
|
supplier_pkh: advertDatum.supplier_pkh,
|
||||||
|
capability_id: advertDatum.capability_id,
|
||||||
|
prompt_preview: previewStr,
|
||||||
|
posted_at: postedAtMs,
|
||||||
|
status: "completed",
|
||||||
|
response: responseBody.content.length <= 200
|
||||||
|
? responseBody.content
|
||||||
|
: responseBody.content.slice(0, 200),
|
||||||
|
receipt,
|
||||||
|
receipt_signature: receiptSignature,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
output_format: responseBody.output_format ?? output_format,
|
||||||
|
content: responseBody.content,
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: responseBody.usage?.prompt_tokens ?? 0,
|
||||||
|
completion_tokens: responseBody.usage?.completion_tokens ?? 0,
|
||||||
|
total_tokens: responseBody.usage?.total_tokens ?? 0,
|
||||||
|
},
|
||||||
|
receipt,
|
||||||
|
receiptSignature,
|
||||||
|
escrowRef: escrowOutputRef,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ─── startChat / endChat — multi-turn llm.chat.v1 lifecycle ────────────
|
// ─── startChat / endChat — multi-turn llm.chat.v1 lifecycle ────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -823,6 +1099,30 @@ export class Marketplace extends EventEmitterBase {
|
|||||||
}
|
}
|
||||||
const advertDatum = decodeAdvertDatum(advertUtxo.datumHex);
|
const advertDatum = decodeAdvertDatum(advertUtxo.datumHex);
|
||||||
|
|
||||||
|
const supplierHttp = new HttpClient({ baseUrl: advertDatum.endpoint_url, fetch: this.fetchImpl });
|
||||||
|
|
||||||
|
// Pre-flight: refuse to lock any funds if the supplier is already serving
|
||||||
|
// another chat. The supplier is single-slot, so a second concurrent escrow
|
||||||
|
// would only 409 at Claim and be left stranded Open with funds locked.
|
||||||
|
// Checking /status first keeps the buyer's funds unlocked. Best-effort: if
|
||||||
|
// /status is unreachable we proceed, and the supplier's Claim still guards
|
||||||
|
// the slot.
|
||||||
|
try {
|
||||||
|
const statusRes = await supplierHttp.getJson("/status");
|
||||||
|
const liveStatus = statusRes.body && typeof statusRes.body === "object"
|
||||||
|
? (statusRes.body as { status?: string }).status
|
||||||
|
: undefined;
|
||||||
|
if (liveStatus === "working") {
|
||||||
|
throw new SupplierError("supplier_busy", {
|
||||||
|
status: 409,
|
||||||
|
message: "supplier is busy with another chat session",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SupplierError) throw err;
|
||||||
|
/* /status unreachable — proceed; Claim still enforces the single slot */
|
||||||
|
}
|
||||||
|
|
||||||
const sessionNonce = randomNonce();
|
const sessionNonce = randomNonce();
|
||||||
let escrowResult;
|
let escrowResult;
|
||||||
try {
|
try {
|
||||||
@@ -851,7 +1151,6 @@ export class Marketplace extends EventEmitterBase {
|
|||||||
/* mock providers / tests */
|
/* mock providers / tests */
|
||||||
}
|
}
|
||||||
|
|
||||||
const supplierHttp = new HttpClient({ baseUrl: advertDatum.endpoint_url, fetch: this.fetchImpl });
|
|
||||||
let startRes;
|
let startRes;
|
||||||
try {
|
try {
|
||||||
startRes = await supplierHttp.postJson(
|
startRes = await supplierHttp.postJson(
|
||||||
@@ -877,7 +1176,13 @@ export class Marketplace extends EventEmitterBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.emitProgress({ type: "chat_started", escrow_ref: escrowRefStr });
|
this.emitProgress({ type: "chat_started", escrow_ref: escrowRefStr });
|
||||||
return { escrowRef: escrowOutputRef, sessionNonce, supplierBaseUrl: advertDatum.endpoint_url };
|
// Ticket-mode suppliers answer {status:"ticket"} (no Claim); anything else
|
||||||
|
// — "claimed" or an older supplier without the field — is full settlement.
|
||||||
|
const startStatus = startRes.body && typeof startRes.body === "object"
|
||||||
|
? (startRes.body as { status?: string }).status
|
||||||
|
: undefined;
|
||||||
|
const settleMode: ChatSettleMode = startStatus === "ticket" ? "ticket" : "full";
|
||||||
|
return { escrowRef: escrowOutputRef, sessionNonce, supplierBaseUrl: advertDatum.endpoint_url, settleMode };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -886,22 +1191,15 @@ export class Marketplace extends EventEmitterBase {
|
|||||||
* Submitted escrow — which is when the user is actually charged.
|
* Submitted escrow — which is when the user is actually charged.
|
||||||
*/
|
*/
|
||||||
async endChat(opts: EndChatOptions): Promise<EndChatResult> {
|
async endChat(opts: EndChatOptions): Promise<EndChatResult> {
|
||||||
const { escrowRef, sessionNonce, transcript } = opts;
|
const { escrowRef, sessionNonce, transcript, supplierBaseUrl } = opts;
|
||||||
const escrowRefStr = refToString(escrowRef);
|
const escrowRefStr = refToString(escrowRef);
|
||||||
|
|
||||||
// Resolve escrow → advert for endpoint_url, supplier_pkh, model, pub key.
|
// The original Open escrow UTxO has ALREADY been spent by the supplier's
|
||||||
const escrowUtxo = await this.chain.queryUtxo(escrowRef);
|
// Claim (Open→Claimed) by the time End runs, so we must NOT queryUtxo it
|
||||||
if (!escrowUtxo || !escrowUtxo.datumHex) {
|
// (that was the cause of the "escrow ref not on chain" 502). Use the
|
||||||
throw new TxConstructionError("escrow ref not on chain", `no escrow UTxO at ${escrowRefStr}`);
|
// supplier endpoint the buyer-app cached from startChat, and resolve
|
||||||
}
|
// supplier identity (pkh, model) from /capability for verification.
|
||||||
const escrowDatum = decodeEscrowDatum(escrowUtxo.datumHex);
|
const supplierHttp = new HttpClient({ baseUrl: supplierBaseUrl, fetch: this.fetchImpl });
|
||||||
const advertUtxo = await this.chain.queryUtxo(escrowDatum.advert_ref);
|
|
||||||
if (!advertUtxo || !advertUtxo.datumHex) {
|
|
||||||
throw new TxConstructionError("advert ref not on chain", "advert UTxO for this escrow is missing");
|
|
||||||
}
|
|
||||||
const advertDatum = decodeAdvertDatum(advertUtxo.datumHex);
|
|
||||||
|
|
||||||
const supplierHttp = new HttpClient({ baseUrl: advertDatum.endpoint_url, fetch: this.fetchImpl });
|
|
||||||
let endRes;
|
let endRes;
|
||||||
try {
|
try {
|
||||||
endRes = await supplierHttp.postJson("/v1/chat/end", {}, { headers: { "X-Escrow-Ref": escrowRefStr } });
|
endRes = await supplierHttp.postJson("/v1/chat/end", {}, { headers: { "X-Escrow-Ref": escrowRefStr } });
|
||||||
@@ -922,18 +1220,43 @@ export class Marketplace extends EventEmitterBase {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const endBody = endRes.body as {
|
const endBody = endRes.body as {
|
||||||
|
status?: string;
|
||||||
|
settle_mode?: string;
|
||||||
receipt?: import("@marketplace/shared/receipt").Receipt;
|
receipt?: import("@marketplace/shared/receipt").Receipt;
|
||||||
receipt_signature?: string;
|
receipt_signature?: string;
|
||||||
submitted_ref?: string;
|
submitted_ref?: string;
|
||||||
};
|
};
|
||||||
|
// Ticket sessions produce no receipt and nothing to Accept — the Open
|
||||||
|
// escrow returns via reclaim after deliver_by. Trusting the supplier's
|
||||||
|
// response is incentive-safe: lying in either direction only forfeits the
|
||||||
|
// supplier's own payout (the buyer's sweeper reclaims unsettled escrows).
|
||||||
|
if (endBody.settle_mode === "ticket") {
|
||||||
|
this.emitProgress({ type: "chat_ended", escrow_ref: escrowRefStr });
|
||||||
|
return { settleMode: "ticket", escrowRef };
|
||||||
|
}
|
||||||
if (!endBody.receipt || !endBody.receipt_signature) {
|
if (!endBody.receipt || !endBody.receipt_signature) {
|
||||||
throw new SupplierError("malformed_response", { message: "supplier end response missing receipt" });
|
throw new SupplierError("malformed_response", { message: "supplier end response missing receipt" });
|
||||||
}
|
}
|
||||||
const receipt = endBody.receipt;
|
const receipt = endBody.receipt;
|
||||||
const receiptSignature = endBody.receipt_signature;
|
const receiptSignature = endBody.receipt_signature;
|
||||||
|
|
||||||
|
// ── Resolve supplier identity from /capability for verification ───────
|
||||||
|
let capSupplierPkh = "";
|
||||||
|
let capModel = "";
|
||||||
|
try {
|
||||||
|
const capRes = await supplierHttp.getJson("/capability");
|
||||||
|
if (capRes.body && typeof capRes.body === "object") {
|
||||||
|
capSupplierPkh = (capRes.body as { supplier_pkh?: string }).supplier_pkh ?? "";
|
||||||
|
capModel = (capRes.body as { model?: string }).model ?? "";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* best-effort; the escrow_ref + prompt_hash + signature checks below,
|
||||||
|
plus the on-chain Accept (which routes funds per the Submitted datum),
|
||||||
|
still hold even if /capability is briefly unavailable */
|
||||||
|
}
|
||||||
|
|
||||||
// ── Verify receipt ───────────────────────────────────────────────────
|
// ── Verify receipt ───────────────────────────────────────────────────
|
||||||
if (receipt.supplier_pkh !== escrowDatum.supplier_pkh) {
|
if (capSupplierPkh && receipt.supplier_pkh !== capSupplierPkh) {
|
||||||
throw new ReceiptVerificationError("wrong_supplier");
|
throw new ReceiptVerificationError("wrong_supplier");
|
||||||
}
|
}
|
||||||
if (receipt.escrow_ref !== escrowRefStr) {
|
if (receipt.escrow_ref !== escrowRefStr) {
|
||||||
@@ -943,7 +1266,7 @@ export class Marketplace extends EventEmitterBase {
|
|||||||
if (receipt.prompt_hash !== chatSessionPromptHash({ session_nonce: sessionNonce })) {
|
if (receipt.prompt_hash !== chatSessionPromptHash({ session_nonce: sessionNonce })) {
|
||||||
throw new ReceiptVerificationError("prompt_hash_mismatch");
|
throw new ReceiptVerificationError("prompt_hash_mismatch");
|
||||||
}
|
}
|
||||||
if (receipt.model !== advertDatum.model) {
|
if (capModel && receipt.model !== capModel) {
|
||||||
throw new ReceiptVerificationError("request_spec_hash_mismatch");
|
throw new ReceiptVerificationError("request_spec_hash_mismatch");
|
||||||
}
|
}
|
||||||
if (typeof receiptSignature !== "string" || !SIG_RE.test(receiptSignature) || receiptSignature === ZERO_SIGNATURE) {
|
if (typeof receiptSignature !== "string" || !SIG_RE.test(receiptSignature) || receiptSignature === ZERO_SIGNATURE) {
|
||||||
@@ -961,21 +1284,13 @@ export class Marketplace extends EventEmitterBase {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Cryptographic verification via the supplier's published pub key (best-effort).
|
// NOTE: cryptographic Ed25519 verifyReceipt is intentionally NOT called
|
||||||
try {
|
// here. It lives in @marketplace/shared/receipt/sign.ts, whose top-level
|
||||||
const capRes = await supplierHttp.getJson("/capability");
|
// `import { createHash } from "crypto"` cannot be bundled into the browser
|
||||||
const pubKeyHex = capRes.body && typeof capRes.body === "object"
|
// SPA (Vite externalizes `crypto`). The one-off submitPrompt/submitTts
|
||||||
? (capRes.body as { pub_key_hex?: string }).pub_key_hex
|
// paths take the same structural-only stance (see the M1-E/M1-F note at the
|
||||||
: undefined;
|
// top of this file). The structural checks above + the on-chain
|
||||||
if (typeof pubKeyHex === "string" && /^[0-9a-fA-F]{64}$/.test(pubKeyHex)) {
|
// result_receipt_hash commitment are the v1 verification surface.
|
||||||
if (!verifyReceipt({ receipt, signature: receiptSignature }, pubKeyHex)) {
|
|
||||||
throw new ReceiptVerificationError("invalid_signature");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ReceiptVerificationError) throw err;
|
|
||||||
/* /capability unreachable — structural verification stands */
|
|
||||||
}
|
|
||||||
|
|
||||||
this.emitProgress({ type: "receipt_verified", escrow_ref: escrowRefStr });
|
this.emitProgress({ type: "receipt_verified", escrow_ref: escrowRefStr });
|
||||||
|
|
||||||
@@ -1000,7 +1315,7 @@ export class Marketplace extends EventEmitterBase {
|
|||||||
this.emitProgress({ type: "accept_submitted", escrow_ref: refToString(acceptedRef) });
|
this.emitProgress({ type: "accept_submitted", escrow_ref: refToString(acceptedRef) });
|
||||||
this.emitProgress({ type: "chat_ended", escrow_ref: escrowRefStr });
|
this.emitProgress({ type: "chat_ended", escrow_ref: escrowRefStr });
|
||||||
|
|
||||||
return { receipt, receiptSignature, escrowRef, acceptedRef };
|
return { settleMode: "full", receipt, receiptSignature, escrowRef, acceptedRef };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── acceptResult / reclaim ───────────────────────────────────────────
|
// ─── acceptResult / reclaim ───────────────────────────────────────────
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export type {
|
|||||||
AcceptResultOptions,
|
AcceptResultOptions,
|
||||||
ReclaimOptions,
|
ReclaimOptions,
|
||||||
GetTaskHistoryOptions,
|
GetTaskHistoryOptions,
|
||||||
|
ChatSettleMode,
|
||||||
|
StartChatResult,
|
||||||
|
EndChatResult,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
export { ReceiptVerificationError, IndexerError, SupplierError } from "./types.js";
|
export { ReceiptVerificationError, IndexerError, SupplierError } from "./types.js";
|
||||||
export type { TaskHistoryStore } from "./history.js";
|
export type { TaskHistoryStore } from "./history.js";
|
||||||
|
|||||||
@@ -187,6 +187,35 @@ export interface SubmitTtsResult {
|
|||||||
escrowRef: OutputReference;
|
escrowRef: OutputReference;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SubmitOcrOptions — arguments to Marketplace.submitOcr() for model-scoped
|
||||||
|
* `ocr.page.extract.<model-slug>.v1` capabilities. The same `{image_b64,
|
||||||
|
* mime, output_format}` envelope is hashed (sha256 ∘ canonical) into the
|
||||||
|
* escrow datum's `prompt_hash` AND posted to the supplier's /v1/ocr/extract
|
||||||
|
* body, so the supplier can verify the request matches the on-chain commit.
|
||||||
|
*/
|
||||||
|
export interface SubmitOcrOptions {
|
||||||
|
advertRef: OutputReference;
|
||||||
|
image_b64: string; // plain base64, no data: prefix
|
||||||
|
mime: string; // image/png | image/jpeg | image/webp
|
||||||
|
output_format: string; // markdown | html | json
|
||||||
|
payment_lovelace: bigint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SubmitOcrResult — returned from Marketplace.submitOcr() on success.
|
||||||
|
* `content` is the extraction in the requested output_format; the receipt's
|
||||||
|
* response_hash commits sha256(canonical({content, output_format})).
|
||||||
|
*/
|
||||||
|
export interface SubmitOcrResult {
|
||||||
|
output_format: string;
|
||||||
|
content: string;
|
||||||
|
usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
|
||||||
|
receipt: Receipt;
|
||||||
|
receiptSignature: string;
|
||||||
|
escrowRef: OutputReference;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* StartChatOptions / StartChatResult — Marketplace.startChat() for the
|
* StartChatOptions / StartChatResult — Marketplace.startChat() for the
|
||||||
* `llm.chat.v1` capability. Opens the escrow (session-init prompt_hash) and
|
* `llm.chat.v1` capability. Opens the escrow (session-init prompt_hash) and
|
||||||
@@ -197,6 +226,11 @@ export interface StartChatOptions {
|
|||||||
advertRef: OutputReference;
|
advertRef: OutputReference;
|
||||||
payment_lovelace: bigint;
|
payment_lovelace: bigint;
|
||||||
}
|
}
|
||||||
|
/** Chain settlement mode a chat session runs under (reported by the supplier
|
||||||
|
* at /v1/chat/start): "full" = Claim/Submit/Accept lifecycle; "ticket" = the
|
||||||
|
* buyer's escrow post is the only chain op (reclaimed after deliver_by). */
|
||||||
|
export type ChatSettleMode = "full" | "ticket";
|
||||||
|
|
||||||
export interface StartChatResult {
|
export interface StartChatResult {
|
||||||
escrowRef: OutputReference;
|
escrowRef: OutputReference;
|
||||||
/** Random nonce committed into the escrow's session-init prompt_hash. The
|
/** Random nonce committed into the escrow's session-init prompt_hash. The
|
||||||
@@ -204,6 +238,8 @@ export interface StartChatResult {
|
|||||||
sessionNonce: string;
|
sessionNonce: string;
|
||||||
/** Supplier endpoint_url, cached by the buyer-app for message routing. */
|
/** Supplier endpoint_url, cached by the buyer-app for message routing. */
|
||||||
supplierBaseUrl: string;
|
supplierBaseUrl: string;
|
||||||
|
/** How the supplier settles this session ("full" when it Claimed). */
|
||||||
|
settleMode: ChatSettleMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -213,16 +249,28 @@ export interface StartChatResult {
|
|||||||
export interface EndChatOptions {
|
export interface EndChatOptions {
|
||||||
escrowRef: OutputReference;
|
escrowRef: OutputReference;
|
||||||
sessionNonce: string;
|
sessionNonce: string;
|
||||||
|
/** Supplier endpoint_url cached by the buyer-app from startChat. Required
|
||||||
|
* because the Open escrow UTxO is already spent (Claimed) by End time, so we
|
||||||
|
* resolve the supplier via its cached URL + /capability rather than chain. */
|
||||||
|
supplierBaseUrl: string;
|
||||||
/** The browser's local transcript mirror, used to verify response_hash. */
|
/** The browser's local transcript mirror, used to verify response_hash. */
|
||||||
transcript?: import("@marketplace/shared/tx").ChatMessage[];
|
transcript?: import("@marketplace/shared/tx").ChatMessage[];
|
||||||
}
|
}
|
||||||
export interface EndChatResult {
|
export type EndChatResult =
|
||||||
receipt: Receipt;
|
| {
|
||||||
receiptSignature: string;
|
settleMode: "full";
|
||||||
escrowRef: OutputReference;
|
receipt: Receipt;
|
||||||
/** The Submitted escrow UTxO that was Accepted. */
|
receiptSignature: string;
|
||||||
acceptedRef: OutputReference;
|
escrowRef: OutputReference;
|
||||||
}
|
/** The Submitted escrow UTxO that was Accepted. */
|
||||||
|
acceptedRef: OutputReference;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/** Ticket session: nothing was Submitted/Accepted — no receipt exists.
|
||||||
|
* The Open escrow returns to the buyer via reclaim after deliver_by. */
|
||||||
|
settleMode: "ticket";
|
||||||
|
escrowRef: OutputReference;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AcceptResultOptions — arguments to Marketplace.acceptResult().
|
* AcceptResultOptions — arguments to Marketplace.acceptResult().
|
||||||
|
|||||||
@@ -109,6 +109,11 @@ export interface AppDeps {
|
|||||||
* brute-force rate-limit window. Defaults to Date.now. Tests use a fake
|
* brute-force rate-limit window. Defaults to Date.now. Tests use a fake
|
||||||
* clock to drive expiry and rolling-renewal assertions deterministically. */
|
* clock to drive expiry and rolling-renewal assertions deterministically. */
|
||||||
nowMs?: () => number;
|
nowMs?: () => number;
|
||||||
|
/** Public base URL of the OpenAI-compatible gateway, injected into the SPA
|
||||||
|
* boot block so the "Generate API key" page can POST cross-origin to
|
||||||
|
* `${gatewayUrl}/signup`. Public, non-secret value. When empty/omitted the
|
||||||
|
* SPA derives "api." + its own host. */
|
||||||
|
gatewayPublicUrl?: string;
|
||||||
/** PDF book summarizer job registry. When provided, the /v1/pdf-* routes
|
/** PDF book summarizer job registry. When provided, the /v1/pdf-* routes
|
||||||
* are live; when omitted they respond 503 (feature disabled). Built in
|
* are live; when omitted they respond 503 (feature disabled). Built in
|
||||||
* runMain from the same marketplace/chain/wallet/archive deps. */
|
* runMain from the same marketplace/chain/wallet/archive deps. */
|
||||||
@@ -379,6 +384,37 @@ export function createApp(deps: AppDeps): Express {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── GET /v1/wallet/balance — live buyer-wallet balance from Ogmios.
|
||||||
|
// Sums lovelace across all UTxOs at the buyer's address. The Wallet page
|
||||||
|
// renders this as AP3X (lovelace / 1e6).
|
||||||
|
app.get("/v1/wallet/balance", async (_req: Request, res: Response) => {
|
||||||
|
if (!resolved) {
|
||||||
|
return jsonError(
|
||||||
|
res,
|
||||||
|
503,
|
||||||
|
"service_unavailable",
|
||||||
|
"buyer-app booted without chain deps; /v1/wallet/balance disabled",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const utxos = await resolved.chain.queryUtxosByAddress(resolved.walletKey.address);
|
||||||
|
let lovelace = 0n;
|
||||||
|
for (const u of utxos) lovelace += u.lovelace;
|
||||||
|
return res.status(200).json({
|
||||||
|
address: resolved.walletKey.address,
|
||||||
|
lovelace: lovelace.toString(),
|
||||||
|
utxo_count: utxos.length,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return jsonError(
|
||||||
|
res,
|
||||||
|
502,
|
||||||
|
"chain_unreachable",
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── /v1/indexer/* — generic GET passthrough to the internal indexer.
|
// ── /v1/indexer/* — generic GET passthrough to the internal indexer.
|
||||||
// The SPA's SDK is configured with `indexerUrl = ${origin}/v1/indexer`,
|
// The SPA's SDK is configured with `indexerUrl = ${origin}/v1/indexer`,
|
||||||
// so calls like `/suppliers`, `/escrows?buyer=…`, `/capabilities` land
|
// so calls like `/suppliers`, `/escrows?buyer=…`, `/capabilities` land
|
||||||
@@ -649,9 +685,11 @@ export function createApp(deps: AppDeps): Express {
|
|||||||
const DEMO_CHAT_MODEL = "moonshotai/kimi-k2.6";
|
const DEMO_CHAT_MODEL = "moonshotai/kimi-k2.6";
|
||||||
|
|
||||||
function isChatMessageArray(v: unknown): v is ChatMessage[] {
|
function isChatMessageArray(v: unknown): v is ChatMessage[] {
|
||||||
|
// The free chat demo stays text-only: the `tool` role (added to
|
||||||
|
// ChatMessage for the gateway demo endpoint) is not accepted here.
|
||||||
return Array.isArray(v) && v.length > 0 && v.every(
|
return Array.isArray(v) && v.length > 0 && v.every(
|
||||||
(m) => m && typeof m === "object"
|
(m) => m && typeof m === "object"
|
||||||
&& (["system", "user", "assistant"] as const).includes((m as ChatMessage).role)
|
&& ["system", "user", "assistant"].includes((m as ChatMessage).role)
|
||||||
&& typeof (m as ChatMessage).content === "string",
|
&& typeof (m as ChatMessage).content === "string",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -881,11 +919,29 @@ export function createApp(deps: AppDeps): Express {
|
|||||||
const transcript = Array.isArray(body.transcript)
|
const transcript = Array.isArray(body.transcript)
|
||||||
? (body.transcript as ChatMessage[])
|
? (body.transcript as ChatMessage[])
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const state = chatRouteState.get(rawRef);
|
||||||
|
if (!state) {
|
||||||
|
return jsonError(res, 404, "chat_session_not_found",
|
||||||
|
"no active chat session for this escrow (the buyer-app may have restarted); please start a new chat");
|
||||||
|
}
|
||||||
const m = ESCROW_REF_RE.exec(rawRef)!;
|
const m = ESCROW_REF_RE.exec(rawRef)!;
|
||||||
const escrowRef = { txHash: m[1], index: Number(m[2]) };
|
const escrowRef = { txHash: m[1], index: Number(m[2]) };
|
||||||
try {
|
try {
|
||||||
const result = await deps.marketplace.endChat({ escrowRef, sessionNonce, transcript });
|
const result = await deps.marketplace.endChat({
|
||||||
|
escrowRef,
|
||||||
|
sessionNonce,
|
||||||
|
transcript,
|
||||||
|
supplierBaseUrl: state.supplierBaseUrl,
|
||||||
|
});
|
||||||
chatRouteState.delete(rawRef);
|
chatRouteState.delete(rawRef);
|
||||||
|
if (result.settleMode === "ticket") {
|
||||||
|
// Ticket session: no receipt/Accept — funds return via reclaim.
|
||||||
|
return res.status(200).json({
|
||||||
|
status: "ended",
|
||||||
|
escrow_ref: rawRef,
|
||||||
|
settle_mode: "ticket",
|
||||||
|
});
|
||||||
|
}
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
status: "accepted",
|
status: "accepted",
|
||||||
escrow_ref: rawRef,
|
escrow_ref: rawRef,
|
||||||
@@ -1269,6 +1325,9 @@ export function createApp(deps: AppDeps): Express {
|
|||||||
pubKeyHash: resolved.walletKey.pubKeyHash,
|
pubKeyHash: resolved.walletKey.pubKeyHash,
|
||||||
address: displayAddress,
|
address: displayAddress,
|
||||||
},
|
},
|
||||||
|
// Public gateway base URL for the "Generate API key" page. Empty
|
||||||
|
// string when unconfigured — the SPA then derives "api." + host.
|
||||||
|
gatewayUrl: deps.gatewayPublicUrl ?? "",
|
||||||
})};</script>`
|
})};</script>`
|
||||||
: "";
|
: "";
|
||||||
const indexWithBoot = cachedTemplate.replace(
|
const indexWithBoot = cachedTemplate.replace(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
* /tasks → <TaskHistory />
|
* /tasks → <TaskHistory />
|
||||||
* /pending → <PendingReceipts /> UX-1: buyer-side Accept button
|
* /pending → <PendingReceipts /> UX-1: buyer-side Accept button
|
||||||
* /wallet → <Wallet />
|
* /wallet → <Wallet />
|
||||||
|
* /api-keys → <ApiKeys /> self-serve gateway API key generation
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Routes, Route, Link } from "react-router-dom";
|
import { Routes, Route, Link } from "react-router-dom";
|
||||||
@@ -14,6 +15,7 @@ import TaskHistory from "./pages/TaskHistory.js";
|
|||||||
import Wallet from "./pages/Wallet.js";
|
import Wallet from "./pages/Wallet.js";
|
||||||
import PendingReceipts from "./pages/PendingReceipts.js";
|
import PendingReceipts from "./pages/PendingReceipts.js";
|
||||||
import BookSummarizer from "./pages/BookSummarizer.js";
|
import BookSummarizer from "./pages/BookSummarizer.js";
|
||||||
|
import ApiKeys from "./pages/ApiKeys.js";
|
||||||
import { RequireAuth, useAuth } from "./state/AuthContext.js";
|
import { RequireAuth, useAuth } from "./state/AuthContext.js";
|
||||||
|
|
||||||
function SignOutButton() {
|
function SignOutButton() {
|
||||||
@@ -35,10 +37,13 @@ export default function App() {
|
|||||||
<div className="min-h-screen bg-gray-50 text-gray-900">
|
<div className="min-h-screen bg-gray-50 text-gray-900">
|
||||||
<nav className="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-6">
|
<nav className="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-6">
|
||||||
<Link to="/" className="font-medium hover:underline">Dashboard</Link>
|
<Link to="/" className="font-medium hover:underline">Dashboard</Link>
|
||||||
<Link to="/book" className="font-medium hover:underline">Book Summarizer</Link>
|
{/* Book Summarizer tab hidden for now — the /book route and all PDF
|
||||||
|
summarize code remain intact; restore by re-adding this link:
|
||||||
|
<Link to="/book" className="font-medium hover:underline">Book Summarizer</Link> */}
|
||||||
<Link to="/tasks" className="font-medium hover:underline">Tasks</Link>
|
<Link to="/tasks" className="font-medium hover:underline">Tasks</Link>
|
||||||
<Link to="/pending" className="font-medium hover:underline">Pending</Link>
|
<Link to="/pending" className="font-medium hover:underline">Pending</Link>
|
||||||
<Link to="/wallet" className="font-medium hover:underline">Wallet</Link>
|
<Link to="/wallet" className="font-medium hover:underline">Wallet</Link>
|
||||||
|
<Link to="/api-keys" className="font-medium hover:underline">API Keys</Link>
|
||||||
<SignOutButton />
|
<SignOutButton />
|
||||||
</nav>
|
</nav>
|
||||||
<main className="p-6">
|
<main className="p-6">
|
||||||
@@ -48,6 +53,7 @@ export default function App() {
|
|||||||
<Route path="/tasks" element={<TaskHistory />} />
|
<Route path="/tasks" element={<TaskHistory />} />
|
||||||
<Route path="/pending" element={<PendingReceipts />} />
|
<Route path="/pending" element={<PendingReceipts />} />
|
||||||
<Route path="/wallet" element={<Wallet />} />
|
<Route path="/wallet" element={<Wallet />} />
|
||||||
|
<Route path="/api-keys" element={<ApiKeys />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
* data: {"type":"error","message":"…"}\n\n
|
* data: {"type":"error","message":"…"}\n\n
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { OutputReference } from "@marketplace/shared/chain";
|
import type { OutputReference } from "@marketplace/shared/chain";
|
||||||
|
|
||||||
export interface ChatFormProps {
|
export interface ChatFormProps {
|
||||||
@@ -102,8 +102,28 @@ async function streamChat(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Animated "Thinking …" placeholder shown while waiting for the model's
|
||||||
|
* first token (the assistant bubble exists but no content has streamed yet). */
|
||||||
|
function ThinkingDots(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 text-gray-500" data-testid="chat-thinking">
|
||||||
|
Thinking
|
||||||
|
<span className="inline-flex items-end gap-0.5 pb-0.5">
|
||||||
|
{[0, 150, 300].map((d) => (
|
||||||
|
<span
|
||||||
|
key={d}
|
||||||
|
className="h-1.5 w-1.5 animate-bounce rounded-full bg-gray-400"
|
||||||
|
style={{ animationDelay: `${d}ms` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ChatForm({ advertRef, payment_lovelace }: ChatFormProps = {}): JSX.Element {
|
export default function ChatForm({ advertRef, payment_lovelace }: ChatFormProps = {}): JSX.Element {
|
||||||
const isPaid = advertRef !== undefined && payment_lovelace !== undefined;
|
const isPaid = advertRef !== undefined && payment_lovelace !== undefined;
|
||||||
|
const advertRefStr = advertRef ? `${advertRef.txHash}#${advertRef.index}` : null;
|
||||||
|
|
||||||
const [turns, setTurns] = useState<ChatTurn[]>([]);
|
const [turns, setTurns] = useState<ChatTurn[]>([]);
|
||||||
const [streaming, setStreaming] = useState<string | null>(null); // in-progress assistant text
|
const [streaming, setStreaming] = useState<string | null>(null); // in-progress assistant text
|
||||||
@@ -116,11 +136,43 @@ export default function ChatForm({ advertRef, payment_lovelace }: ChatFormProps
|
|||||||
const [starting, setStarting] = useState(false);
|
const [starting, setStarting] = useState(false);
|
||||||
const [ending, setEnding] = useState(false);
|
const [ending, setEnding] = useState(false);
|
||||||
const [charged, setCharged] = useState<string | null>(null);
|
const [charged, setCharged] = useState<string | null>(null);
|
||||||
|
// Live supplier availability (paid mode): null = unknown/checking, true = free, false = busy/offline.
|
||||||
|
const [available, setAvailable] = useState<boolean | null>(null);
|
||||||
const sessionRef = useRef<Session | null>(null);
|
const sessionRef = useRef<Session | null>(null);
|
||||||
sessionRef.current = session;
|
sessionRef.current = session;
|
||||||
|
|
||||||
const active = isPaid ? session !== null && charged === null : true;
|
const active = isPaid ? session !== null && charged === null : true;
|
||||||
|
|
||||||
|
// Poll the supplier's status while the Start button is showing, so a second
|
||||||
|
// user can't start a chat while one is already active. The supplier is
|
||||||
|
// single-slot (status "working" between Claim and Submit); we disable Start
|
||||||
|
// until it's "free" again. (startChat also does an authoritative pre-flight
|
||||||
|
// /status check, so this is the UX layer over that guard.)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPaid || !advertRefStr || session !== null || charged !== null) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const check = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const r = await fetch("/v1/indexer/suppliers?capability_id=llm.chat.v1");
|
||||||
|
if (!r.ok) throw new Error(String(r.status));
|
||||||
|
const rows = (await r.json()) as Array<{ utxo_ref: string; status: string }>;
|
||||||
|
const row = rows.find((x) => x.utxo_ref === advertRefStr);
|
||||||
|
// Unknown (row missing / indexer hiccup) → null, which does NOT hard-block
|
||||||
|
// (the pre-flight check still guards funds); only an explicit non-free
|
||||||
|
// status disables the button.
|
||||||
|
if (!cancelled) setAvailable(row ? row.status === "free" : null);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setAvailable(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void check();
|
||||||
|
const id = setInterval(check, 8000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(id);
|
||||||
|
};
|
||||||
|
}, [isPaid, advertRefStr, session, charged]);
|
||||||
|
|
||||||
async function startChat(): Promise<void> {
|
async function startChat(): Promise<void> {
|
||||||
if (!advertRef || payment_lovelace === undefined) return;
|
if (!advertRef || payment_lovelace === undefined) return;
|
||||||
setStarting(true);
|
setStarting(true);
|
||||||
@@ -215,7 +267,18 @@ export default function ChatForm({ advertRef, payment_lovelace }: ChatFormProps
|
|||||||
} catch { /* keep fallback */ }
|
} catch { /* keep fallback */ }
|
||||||
throw new Error(msg);
|
throw new Error(msg);
|
||||||
}
|
}
|
||||||
setCharged(payment_lovelace !== undefined ? ap3x(payment_lovelace) : "0");
|
let endBody: { settle_mode?: string } = {};
|
||||||
|
try {
|
||||||
|
endBody = (await resp.json()) as { settle_mode?: string };
|
||||||
|
} catch { /* older buyer-app responses stay full-settle */ }
|
||||||
|
// Ticket sessions never charge — the escrow returns via reclaim.
|
||||||
|
setCharged(
|
||||||
|
endBody.settle_mode === "ticket"
|
||||||
|
? "0 (ticket session — escrow reclaimed)"
|
||||||
|
: payment_lovelace !== undefined
|
||||||
|
? ap3x(payment_lovelace)
|
||||||
|
: "0",
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -226,15 +289,26 @@ export default function ChatForm({ advertRef, payment_lovelace }: ChatFormProps
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4 rounded border border-gray-200 bg-white p-4" data-testid="chat-form">
|
<div className="space-y-4 rounded border border-gray-200 bg-white p-4" data-testid="chat-form">
|
||||||
{isPaid && session === null && (
|
{isPaid && session === null && (
|
||||||
<button
|
<div className="space-y-2">
|
||||||
type="button"
|
<button
|
||||||
onClick={startChat}
|
type="button"
|
||||||
disabled={starting}
|
onClick={startChat}
|
||||||
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:bg-gray-400"
|
disabled={starting || available === false}
|
||||||
data-testid="chat-start"
|
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:bg-gray-400"
|
||||||
>
|
data-testid="chat-start"
|
||||||
{starting ? "Opening escrow…" : "Start chat"}
|
>
|
||||||
</button>
|
{starting
|
||||||
|
? "Opening escrow…"
|
||||||
|
: available === false
|
||||||
|
? "Supplier busy"
|
||||||
|
: "Start chat"}
|
||||||
|
</button>
|
||||||
|
{available === false && (
|
||||||
|
<p className="text-sm text-amber-700" data-testid="chat-busy">
|
||||||
|
Another chat is in progress on this supplier. The button re-enables when it ends.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(active || streaming !== null || turns.length > 0) && (
|
{(active || streaming !== null || turns.length > 0) && (
|
||||||
@@ -257,7 +331,7 @@ export default function ChatForm({ advertRef, payment_lovelace }: ChatFormProps
|
|||||||
{streaming !== null && (
|
{streaming !== null && (
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<span className="inline-block max-w-[85%] whitespace-pre-wrap rounded bg-gray-100 px-3 py-2 text-sm text-gray-900">
|
<span className="inline-block max-w-[85%] whitespace-pre-wrap rounded bg-gray-100 px-3 py-2 text-sm text-gray-900">
|
||||||
{streaming.length > 0 ? streaming : "…"}
|
{streaming.length > 0 ? streaming : <ThinkingDots />}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export default function JobEstimate({
|
|||||||
<dd className="text-right">{est.mapCalls}</dd>
|
<dd className="text-right">{est.mapCalls}</dd>
|
||||||
<dt>Reduce calls</dt>
|
<dt>Reduce calls</dt>
|
||||||
<dd className="text-right">{est.reduceCalls}</dd>
|
<dd className="text-right">{est.reduceCalls}</dd>
|
||||||
<dt className="font-medium">Total paid calls</dt>
|
<dt className="font-medium">Total escrowed calls</dt>
|
||||||
<dd className="text-right font-medium">{est.totalCalls}</dd>
|
<dd className="text-right font-medium">{est.totalCalls}</dd>
|
||||||
<dt className="font-medium">Est. max cost</dt>
|
<dt className="font-medium">Est. max cost</dt>
|
||||||
<dd className="text-right font-medium">{est.totalAp3x} AP3X</dd>
|
<dd className="text-right font-medium">{est.totalAp3x} AP3X</dd>
|
||||||
@@ -126,7 +126,7 @@ export default function JobEstimate({
|
|||||||
disabled={blocked || starting}
|
disabled={blocked || starting}
|
||||||
className="rounded bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:bg-gray-400"
|
className="rounded bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:bg-gray-400"
|
||||||
>
|
>
|
||||||
{starting ? "Starting…" : `Proceed & pay (~${est.totalAp3x} AP3X)`}
|
{starting ? "Starting…" : `Proceed (locks ~${est.totalAp3x} AP3X in escrow)`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default function PdfUploadForm({ onUploaded }: { onUploaded: (info: Uploa
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
Upload a text-based PDF book. The buyer will chunk it and pay marketplace
|
Upload a text-based PDF book. The buyer will chunk it and commission marketplace
|
||||||
suppliers to summarize each chunk, then synthesize a full-book summary.
|
suppliers to summarize each chunk, then synthesize a full-book summary.
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
|
|||||||
32
buyer/src/ui/gateway.ts
Normal file
32
buyer/src/ui/gateway.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* buyer/src/ui/gateway.ts — resolve the OpenAI-compatible gateway base URL the
|
||||||
|
* SPA should call for the "Generate API key" page.
|
||||||
|
*
|
||||||
|
* Resolution order:
|
||||||
|
* 1. window.__BUYER_BOOT__.gatewayUrl — injected by the buyer server from the
|
||||||
|
* GATEWAY_PUBLIC_URL env (the configured value). Present on any page load
|
||||||
|
* made while the buyer_session cookie is valid.
|
||||||
|
* 2. Derived "api." + current host — the deploy convention is exactly
|
||||||
|
* api.<buyer-host> (marketplace.… → api.marketplace.…). This covers the
|
||||||
|
* gap right after a fresh login, when the SPA flips auth state WITHOUT a
|
||||||
|
* page reload so the boot block (which is only injected into
|
||||||
|
* server-rendered HTML) hasn't been re-fetched yet.
|
||||||
|
*
|
||||||
|
* The result never has a trailing slash.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface BuyerBootMaybeGateway {
|
||||||
|
gatewayUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripTrailingSlashes(url: string): string {
|
||||||
|
return url.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveGatewayUrl(): string {
|
||||||
|
const boot = (window as unknown as { __BUYER_BOOT__?: BuyerBootMaybeGateway })
|
||||||
|
.__BUYER_BOOT__;
|
||||||
|
const configured = boot?.gatewayUrl?.trim();
|
||||||
|
if (configured) return stripTrailingSlashes(configured);
|
||||||
|
return `${window.location.protocol}//api.${window.location.host}`;
|
||||||
|
}
|
||||||
205
buyer/src/ui/pages/ApiKeys.tsx
Normal file
205
buyer/src/ui/pages/ApiKeys.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* buyer/src/ui/pages/ApiKeys.tsx — self-serve "Generate API key" page.
|
||||||
|
*
|
||||||
|
* Mints a custodial gateway API key by POSTing directly to the gateway's
|
||||||
|
* public /signup endpoint (cross-origin; the gateway CORS-allows this origin).
|
||||||
|
* The raw key is shown ONCE — it is never stored server-side in retrievable
|
||||||
|
* form, so it cannot be listed or recovered later. The page also surfaces the
|
||||||
|
* funding deposit address and a ready-to-paste usage snippet.
|
||||||
|
*
|
||||||
|
* Access is gated by the SPA's existing operator login (this page only renders
|
||||||
|
* inside <RequireAuth>). The gateway /signup itself stays public + IP
|
||||||
|
* rate-limited; per-user accounts are a later feature.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { resolveGatewayUrl } from "../gateway.js";
|
||||||
|
|
||||||
|
interface SignupResult {
|
||||||
|
api_key: string;
|
||||||
|
key_prefix: string;
|
||||||
|
deposit_address: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull a human-readable message out of whatever error shape the gateway
|
||||||
|
* returns (OpenAI `{error:{message}}`, plain `{error|message}`, or neither). */
|
||||||
|
function errorMessage(body: unknown, status: number, statusText: string): string {
|
||||||
|
if (body && typeof body === "object") {
|
||||||
|
const b = body as Record<string, unknown>;
|
||||||
|
const err = b.error;
|
||||||
|
if (err && typeof err === "object" && typeof (err as Record<string, unknown>).message === "string") {
|
||||||
|
return (err as Record<string, string>).message;
|
||||||
|
}
|
||||||
|
if (typeof err === "string") return err;
|
||||||
|
if (typeof b.message === "string") return b.message;
|
||||||
|
}
|
||||||
|
return `${status} ${statusText}`.trim() || "request failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function CopyButton({ value, label }: { value: string; label?: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void navigator.clipboard
|
||||||
|
?.writeText(value)
|
||||||
|
.then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* clipboard blocked (insecure context / permissions) — no-op */
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="rounded border border-gray-300 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
{copied ? "Copied!" : (label ?? "Copy")}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ApiKeys() {
|
||||||
|
const [label, setLabel] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [result, setResult] = useState<SignupResult | null>(null);
|
||||||
|
|
||||||
|
const gatewayUrl = resolveGatewayUrl();
|
||||||
|
const baseUrl = `${gatewayUrl}/openai/v1`;
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${gatewayUrl}/signup`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(label.trim() ? { label: label.trim() } : {}),
|
||||||
|
});
|
||||||
|
const body = (await res.json().catch(() => null)) as unknown;
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(errorMessage(body, res.status, res.statusText));
|
||||||
|
}
|
||||||
|
const r = body as Partial<SignupResult> | null;
|
||||||
|
if (!r || typeof r.api_key !== "string" || typeof r.deposit_address !== "string") {
|
||||||
|
throw new Error("unexpected response from gateway");
|
||||||
|
}
|
||||||
|
setResult({
|
||||||
|
api_key: r.api_key,
|
||||||
|
key_prefix: r.key_prefix ?? r.api_key.slice(0, 12),
|
||||||
|
deposit_address: r.deposit_address,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "could not reach the gateway");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const curlSnippet =
|
||||||
|
result === null
|
||||||
|
? ""
|
||||||
|
: [
|
||||||
|
`curl ${baseUrl}/chat/completions \\`,
|
||||||
|
` -H "Authorization: Bearer ${result.api_key}" \\`,
|
||||||
|
` -H "Content-Type: application/json" \\`,
|
||||||
|
` -d '{"model": "<model>", "messages": [{"role": "user", "content": "Hello"}]}'`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl space-y-6">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-2xl font-semibold">API Keys</h1>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Generate an OpenAI-compatible API key for the marketplace gateway. Each key has its own
|
||||||
|
gateway-managed wallet (the gateway holds this wallet's key) - fund its deposit address with AP3X, then point any OpenAI SDK at{" "}
|
||||||
|
<code className="font-mono text-xs">{baseUrl}</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white p-4 space-y-3">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
Label <span className="font-normal text-gray-400">(optional)</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
placeholder="e.g. my-laptop"
|
||||||
|
maxLength={120}
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-gray-400 focus:outline-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void generate()}
|
||||||
|
disabled={loading}
|
||||||
|
className="rounded bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-50"
|
||||||
|
data-testid="generate-api-key"
|
||||||
|
>
|
||||||
|
{loading ? "Generating…" : "Generate API key"}
|
||||||
|
</button>
|
||||||
|
{error !== null && (
|
||||||
|
<p className="text-sm text-red-600" data-testid="api-key-error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result !== null && (
|
||||||
|
<div className="space-y-4 rounded-lg border border-amber-300 bg-amber-50 p-4" data-testid="api-key-result">
|
||||||
|
<div className="rounded border border-amber-400 bg-amber-100 px-3 py-2 text-sm text-amber-900">
|
||||||
|
Copy your API key now — it is shown <strong>only once</strong> and cannot be recovered. If you
|
||||||
|
lose it, generate a new one.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-gray-700">API key</span>
|
||||||
|
<CopyButton value={result.api_key} />
|
||||||
|
</div>
|
||||||
|
<code className="mt-1 block break-all rounded bg-white px-3 py-2 font-mono text-sm">
|
||||||
|
{result.api_key}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-gray-700">Deposit address (fund with AP3X)</span>
|
||||||
|
<CopyButton value={result.deposit_address} />
|
||||||
|
</div>
|
||||||
|
<code className="mt-1 block break-all rounded bg-white px-3 py-2 font-mono text-sm">
|
||||||
|
{result.deposit_address}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-gray-700">Base URL</span>
|
||||||
|
<CopyButton value={baseUrl} />
|
||||||
|
</div>
|
||||||
|
<code className="mt-1 block break-all rounded bg-white px-3 py-2 font-mono text-sm">
|
||||||
|
{baseUrl}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-gray-700">Quick start</span>
|
||||||
|
<CopyButton value={curlSnippet} />
|
||||||
|
</div>
|
||||||
|
<pre className="mt-1 overflow-x-auto rounded bg-gray-900 px-3 py-2 font-mono text-xs text-gray-100">
|
||||||
|
{curlSnippet}
|
||||||
|
</pre>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Replace <code className="font-mono"><model></code> with a model from{" "}
|
||||||
|
<code className="font-mono">{baseUrl}/models</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -152,7 +152,7 @@ export default function BookSummarizer() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">Book Summarizer</h1>
|
<h1 className="text-2xl font-semibold">Book Summarizer</h1>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
Upload a PDF → pay suppliers to summarize each chunk → get a full-book summary.
|
Upload a PDF → commission suppliers to summarize each chunk → get a full-book summary.
|
||||||
Jobs keep running if you navigate away; reopen them below.
|
Jobs keep running if you navigate away; reopen them below.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* buyer/src/ui/pages/PendingReceipts.tsx — list of currently-Submitted escrows
|
* buyer/src/ui/pages/PendingReceipts.tsx — list of currently-Submitted escrows
|
||||||
* for this buyer, with an "Accept & Pay" action per row.
|
* for this buyer, with an "Accept & Settle" action per row.
|
||||||
*
|
*
|
||||||
* Data flow:
|
* Data flow:
|
||||||
* GET /v1/pending-receipts → buyer-app server proxies indexer /escrows?buyer=<pkh>
|
* GET /v1/pending-receipts → buyer-app server proxies indexer /escrows?buyer=<pkh>
|
||||||
@@ -190,7 +190,7 @@ export default function PendingReceipts() {
|
|||||||
<th className="px-2 py-2">Submitted</th>
|
<th className="px-2 py-2">Submitted</th>
|
||||||
<th className="px-2 py-2">Supplier</th>
|
<th className="px-2 py-2">Supplier</th>
|
||||||
<th className="px-2 py-2">Capability</th>
|
<th className="px-2 py-2">Capability</th>
|
||||||
<th className="px-2 py-2">Payment</th>
|
<th className="px-2 py-2">Escrowed</th>
|
||||||
<th className="px-2 py-2">Window left</th>
|
<th className="px-2 py-2">Window left</th>
|
||||||
<th className="px-2 py-2">Action</th>
|
<th className="px-2 py-2">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -254,7 +254,7 @@ export default function PendingReceipts() {
|
|||||||
: "bg-indigo-600 text-white hover:bg-indigo-700")
|
: "bg-indigo-600 text-white hover:bg-indigo-700")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isAccepting ? "accepting…" : expired ? "expired" : "Accept & Pay"}
|
{isAccepting ? "accepting…" : expired ? "expired" : "Accept & Settle"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ function LifecycleCard({ lc }: { lc: Lifecycle }): JSX.Element {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<span className="text-gray-500">·</span>
|
<span className="text-gray-500">·</span>
|
||||||
<span>{fmtAda(lc.payment_lovelace)} payment</span>
|
<span>{fmtAda(lc.payment_lovelace)} escrowed</span>
|
||||||
<span className="text-gray-500">·</span>
|
<span className="text-gray-500">·</span>
|
||||||
<span>{fmtAda(lc.buyer_bond_lovelace)} bond</span>
|
<span>{fmtAda(lc.buyer_bond_lovelace)} bond</span>
|
||||||
<span className="text-gray-500">·</span>
|
<span className="text-gray-500">·</span>
|
||||||
|
|||||||
@@ -1,16 +1,89 @@
|
|||||||
/**
|
/**
|
||||||
* buyer/src/ui/pages/Wallet.tsx — wallet address + balance view.
|
* buyer/src/ui/pages/Wallet.tsx — wallet address + live balance view.
|
||||||
|
*
|
||||||
|
* Balance is fetched from /v1/wallet/balance, which sums the buyer wallet's
|
||||||
|
* UTxOs live from Ogmios, and rendered as AP3X (lovelace / 1e6).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { useMarketplace } from "../state/MarketplaceContext.js";
|
import { useMarketplace } from "../state/MarketplaceContext.js";
|
||||||
|
|
||||||
|
function formatAp3x(lovelace: string): string {
|
||||||
|
return (Number(lovelace) / 1e6).toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 6,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function Wallet() {
|
export default function Wallet() {
|
||||||
const marketplace = useMarketplace();
|
const marketplace = useMarketplace();
|
||||||
const walletKey = marketplace.getWalletKey();
|
const walletKey = marketplace.getWalletKey();
|
||||||
|
|
||||||
|
const [lovelace, setLovelace] = useState<string | null>(null);
|
||||||
|
const [utxoCount, setUtxoCount] = useState<number | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
fetch("/v1/wallet/balance")
|
||||||
|
.then(async (r) => {
|
||||||
|
if (!r.ok) {
|
||||||
|
const j = (await r.json().catch(() => ({}))) as { message?: string; error?: string };
|
||||||
|
throw new Error(j.message ?? j.error ?? `${r.status} ${r.statusText}`);
|
||||||
|
}
|
||||||
|
return r.json() as Promise<{ lovelace: string; utxo_count: number }>;
|
||||||
|
})
|
||||||
|
.then((j) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLovelace(j.lovelace);
|
||||||
|
setUtxoCount(j.utxo_count);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e: Error) => {
|
||||||
|
if (!cancelled) setError(e.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [reloadKey]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="text-2xl font-semibold">Wallet</h1>
|
<h1 className="text-2xl font-semibold">Wallet</h1>
|
||||||
<dl className="space-y-2">
|
<dl className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm text-gray-500">Balance</dt>
|
||||||
|
<dd className="flex items-baseline gap-3" data-testid="wallet-balance">
|
||||||
|
{loading ? (
|
||||||
|
<span className="text-gray-400">Loading…</span>
|
||||||
|
) : error ? (
|
||||||
|
<span className="text-sm text-red-600">{error}</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-2xl font-semibold">{formatAp3x(lovelace ?? "0")} AP3X</span>
|
||||||
|
{utxoCount !== null && (
|
||||||
|
<span className="text-xs text-gray-400">({utxoCount} UTxO{utxoCount === 1 ? "" : "s"})</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setReloadKey((k) => k + 1)}
|
||||||
|
disabled={loading}
|
||||||
|
className="ml-2 rounded border border-gray-300 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
data-testid="wallet-balance-refresh"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-gray-500">Address</dt>
|
<dt className="text-sm text-gray-500">Address</dt>
|
||||||
<dd className="font-mono text-sm break-all">{walletKey.address}</dd>
|
<dd className="font-mono text-sm break-all">{walletKey.address}</dd>
|
||||||
|
|||||||
@@ -289,3 +289,34 @@ For the M1-F-2 (LiveOgmiosProvider) handoff: the supplier compose already
|
|||||||
joins `apex-dashboard_apex-net` and overrides `OGMIOS_URL=ws://ogmios:1337`,
|
joins `apex-dashboard_apex-net` and overrides `OGMIOS_URL=ws://ogmios:1337`,
|
||||||
so once the supplier code stops booting `ReadOnlyOgmiosProvider` and
|
so once the supplier code stops booting `ReadOnlyOgmiosProvider` and
|
||||||
starts talking to a real Ogmios, no compose changes are needed.
|
starts talking to a real Ogmios, no compose changes are needed.
|
||||||
|
|
||||||
|
## 10. Continuous deployment (mainnet, vector-marketplace host)
|
||||||
|
|
||||||
|
Merges to `main` deploy automatically to the mainnet host once CI is green:
|
||||||
|
|
||||||
|
1. `.github/workflows/ci.yml` runs typecheck + vitest on the merge commit.
|
||||||
|
2. On success, `.github/workflows/deploy-mainnet.yml` SSHes into the host
|
||||||
|
(secret `DEPLOY_SSH_KEY`, host key pinned in the workflow) and executes
|
||||||
|
`deploy/mainnet/deploy.sh` **at the new commit** (`git show FETCH_HEAD:...`),
|
||||||
|
so deploy logic always matches the code being deployed.
|
||||||
|
3. The script hard-resets `/root/agents-marketplace` to `origin/main`, maps the
|
||||||
|
old..new diff to affected compose projects (changes under `packages/`,
|
||||||
|
`patches/`, `contracts/` or root manifests rebuild everything), builds all
|
||||||
|
affected images up front, then does a rolling `up -d` with a health gate per
|
||||||
|
project. Suppliers are drained first: `/status` is polled until not
|
||||||
|
`working` (up to `DRAIN_TIMEOUT_SECS`, default 600) so an in-flight job
|
||||||
|
isn't killed and its escrow bond forfeited.
|
||||||
|
|
||||||
|
Manual controls:
|
||||||
|
|
||||||
|
- **Manual deploy / redeploy**: Actions → "Deploy mainnet" → Run workflow
|
||||||
|
(check *force* to redeploy the same sha), or on the host:
|
||||||
|
`bash /root/agents-marketplace/deploy/mainnet/deploy.sh` (`FORCE=1` to
|
||||||
|
redeploy, `DRY_RUN=1` to preview which projects would restart).
|
||||||
|
- **Rollback**: `cd /root/agents-marketplace && git reset --hard <old-sha> &&
|
||||||
|
FORCE=1 bash deploy/mainnet/deploy.sh`. Every deploy appends
|
||||||
|
`old -> new (projects)` to `/var/log/marketplace-deploy.log`.
|
||||||
|
|
||||||
|
Projects with no running containers on the host (e.g. wallet-monitor) are
|
||||||
|
skipped; image-only projects (ollama, chatmock, tts-piper) redeploy only when
|
||||||
|
their own compose file changes.
|
||||||
|
|||||||
178
deploy/mainnet/deploy.sh
Executable file
178
deploy/mainnet/deploy.sh
Executable file
@@ -0,0 +1,178 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# deploy/mainnet/deploy.sh — pull-based CD for the vector-marketplace host.
|
||||||
|
#
|
||||||
|
# Runs ON the server (root@vector-marketplace, repo at /root/agents-marketplace).
|
||||||
|
# Invoked by .github/workflows/deploy-mainnet.yml over SSH, but safe to run by
|
||||||
|
# hand: `bash deploy/mainnet/deploy.sh`.
|
||||||
|
#
|
||||||
|
# What it does:
|
||||||
|
# 1. Fetches origin/main and hard-resets the checkout to it.
|
||||||
|
# 2. Diffs old..new to work out which compose projects actually need a
|
||||||
|
# rebuild (gateway vs suppliers vs buyer vs indexer ...). Changes under
|
||||||
|
# packages/, patches/, contracts/ or the root manifests rebuild everything,
|
||||||
|
# because every Dockerfile COPYs those into its build context.
|
||||||
|
# 3. Builds affected images first (all 17 supplier projects share one
|
||||||
|
# Dockerfile/context, so build #2..#17 are layer-cache hits), THEN does a
|
||||||
|
# rolling `up -d` so the downtime window is restart-only, not build+restart.
|
||||||
|
# 4. Suppliers are drained before recreate: /status is polled until the
|
||||||
|
# supplier is not "working" (a busy supplier killed mid-job forfeits its
|
||||||
|
# escrow bond). After DRAIN_TIMEOUT_SECS we recreate anyway, so a wedged
|
||||||
|
# supplier cannot block the deploy forever.
|
||||||
|
# 5. Every restarted container must report healthy (or plain running when it
|
||||||
|
# has no healthcheck) within HEALTH_TIMEOUT_SECS or the deploy fails.
|
||||||
|
#
|
||||||
|
# Env knobs:
|
||||||
|
# FORCE=1 redeploy even if already at origin/main
|
||||||
|
# DRY_RUN=1 print what would be rebuilt/restarted, change nothing
|
||||||
|
# DRAIN_TIMEOUT_SECS=N max wait per busy supplier (default 600, 0 = skip)
|
||||||
|
# HEALTH_TIMEOUT_SECS=N max wait for healthy (default 180)
|
||||||
|
#
|
||||||
|
# Rollback: git reset --hard <old-sha> && FORCE=1 bash deploy/mainnet/deploy.sh
|
||||||
|
# (old sha is printed below and appended to /var/log/marketplace-deploy.log).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO=/root/agents-marketplace
|
||||||
|
COMPOSE_DIR="$REPO/deploy/mainnet"
|
||||||
|
LOCK=/run/marketplace-deploy.lock
|
||||||
|
LOG=/var/log/marketplace-deploy.log
|
||||||
|
DRAIN_TIMEOUT_SECS="${DRAIN_TIMEOUT_SECS:-600}"
|
||||||
|
HEALTH_TIMEOUT_SECS="${HEALTH_TIMEOUT_SECS:-180}"
|
||||||
|
FORCE="${FORCE:-0}"
|
||||||
|
DRY_RUN="${DRY_RUN:-0}"
|
||||||
|
|
||||||
|
exec 9>"$LOCK"
|
||||||
|
flock -n 9 || { echo "another deploy is already running (lock: $LOCK)"; exit 1; }
|
||||||
|
|
||||||
|
cd "$REPO"
|
||||||
|
|
||||||
|
OLD=$(git rev-parse HEAD)
|
||||||
|
git fetch origin main
|
||||||
|
NEW=$(git rev-parse FETCH_HEAD)
|
||||||
|
|
||||||
|
if [ "$OLD" = "$NEW" ] && [ "$FORCE" != "1" ]; then
|
||||||
|
echo "already at origin/main ($NEW) — nothing to deploy (FORCE=1 to override)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "deploying $OLD -> $NEW"
|
||||||
|
if [ "$DRY_RUN" = "1" ]; then
|
||||||
|
echo "(dry run — checkout not reset, nothing rebuilt or restarted)"
|
||||||
|
else
|
||||||
|
git reset --hard FETCH_HEAD
|
||||||
|
fi
|
||||||
|
|
||||||
|
CHANGED=$(git diff --name-only "$OLD" "$NEW" || true)
|
||||||
|
[ "$FORCE" = "1" ] && [ -z "$CHANGED" ] && CHANGED="(forced)"
|
||||||
|
|
||||||
|
# Paths COPY'd into every image's build context (see */Dockerfile).
|
||||||
|
SHARED_RE='^(packages/|patches/|contracts/|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsconfig\.base\.json$|\.npmrc$)'
|
||||||
|
|
||||||
|
changed_matches() { # changed_matches <ere> -> 0 if any changed path matches
|
||||||
|
[ "$FORCE" = "1" ] && return 0
|
||||||
|
echo "$CHANGED" | grep -qE "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Compose project -> the source prefixes that require rebuilding its image.
|
||||||
|
# Image-only projects (ollama, chatmock, tts-piper) redeploy only when their
|
||||||
|
# own compose file changes.
|
||||||
|
project_re() {
|
||||||
|
case "$1" in
|
||||||
|
docker-compose.indexer.yml) echo "^indexer/|^indexer-ui/|$SHARED_RE" ;;
|
||||||
|
docker-compose.buyer.yml) echo "^buyer/|$SHARED_RE" ;;
|
||||||
|
docker-compose.gateway.yml) echo "^gateway/|$SHARED_RE" ;;
|
||||||
|
docker-compose.wallet-monitor.yml) echo "^wallet-monitor/|$SHARED_RE" ;;
|
||||||
|
docker-compose.supplier*.yml) echo "^supplier/|$SHARED_RE" ;;
|
||||||
|
*) echo "" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Restart order: infra first, suppliers last (each supplier drains first).
|
||||||
|
ORDERED_NON_SUPPLIER="docker-compose.indexer.yml docker-compose.buyer.yml docker-compose.gateway.yml docker-compose.wallet-monitor.yml docker-compose.ollama.yml docker-compose.chatmock.yml docker-compose.tts-piper.yml"
|
||||||
|
SUPPLIERS=$(cd "$COMPOSE_DIR" && ls docker-compose.supplier*.yml)
|
||||||
|
|
||||||
|
AFFECTED=()
|
||||||
|
for f in $ORDERED_NON_SUPPLIER $SUPPLIERS; do
|
||||||
|
[ -f "$COMPOSE_DIR/$f" ] || continue
|
||||||
|
re=$(project_re "$f")
|
||||||
|
hit=0
|
||||||
|
[ -n "$re" ] && changed_matches "$re" && hit=1
|
||||||
|
changed_matches "^deploy/mainnet/$f\$" && hit=1
|
||||||
|
[ "$hit" = "1" ] || continue
|
||||||
|
if [ -z "$(docker compose -f "$COMPOSE_DIR/$f" ps -q 2>/dev/null)" ]; then
|
||||||
|
echo "skip $f — affected but no running containers on this host"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
AFFECTED+=("$f")
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "${#AFFECTED[@]}" -eq 0 ]; then
|
||||||
|
echo "no running compose project is affected by $OLD..$NEW — done"
|
||||||
|
echo "$(date -Is) $OLD -> $NEW (no services affected)" >> "$LOG"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "affected projects: ${AFFECTED[*]}"
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" = "1" ]; then
|
||||||
|
echo "(dry run — stopping before build/rollout)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== build phase =="
|
||||||
|
for f in "${AFFECTED[@]}"; do
|
||||||
|
echo "-- building $f"
|
||||||
|
docker compose -f "$COMPOSE_DIR/$f" build
|
||||||
|
done
|
||||||
|
|
||||||
|
drain_supplier() { # drain_supplier <compose-file>
|
||||||
|
[ "$DRAIN_TIMEOUT_SECS" = "0" ] && return 0
|
||||||
|
local cid deadline status
|
||||||
|
cid=$(docker compose -f "$COMPOSE_DIR/$1" ps -q | head -1)
|
||||||
|
[ -n "$cid" ] || return 0
|
||||||
|
deadline=$(( $(date +%s) + DRAIN_TIMEOUT_SECS ))
|
||||||
|
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||||
|
status=$(docker exec "$cid" wget -qO- http://localhost:8080/status 2>/dev/null \
|
||||||
|
| grep -o '"status":"[^"]*"' | head -1 | cut -d'"' -f4 || true)
|
||||||
|
if [ "$status" != "working" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo " supplier busy (status=working) — waiting for job to finish..."
|
||||||
|
sleep 15
|
||||||
|
done
|
||||||
|
echo " WARNING: still busy after ${DRAIN_TIMEOUT_SECS}s — recreating anyway"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_healthy() { # wait_healthy <compose-file>
|
||||||
|
local deadline cid state
|
||||||
|
deadline=$(( $(date +%s) + HEALTH_TIMEOUT_SECS ))
|
||||||
|
for cid in $(docker compose -f "$COMPOSE_DIR/$1" ps -q); do
|
||||||
|
while :; do
|
||||||
|
state=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$cid")
|
||||||
|
case "$state" in
|
||||||
|
healthy|running) break ;;
|
||||||
|
starting|unhealthy|created|restarting)
|
||||||
|
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||||||
|
echo " FAILED: $(docker inspect -f '{{.Name}}' "$cid") is '$state' after ${HEALTH_TIMEOUT_SECS}s; last logs:"
|
||||||
|
docker logs --tail 50 "$cid" 2>&1 | sed 's/^/ | /'
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
sleep 5 ;;
|
||||||
|
*) echo " FAILED: container state '$state'"; docker logs --tail 50 "$cid" 2>&1 | sed 's/^/ | /'; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "== rollout phase =="
|
||||||
|
for f in "${AFFECTED[@]}"; do
|
||||||
|
echo "-- rolling out $f"
|
||||||
|
case "$f" in docker-compose.supplier*) drain_supplier "$f" ;; esac
|
||||||
|
docker compose -f "$COMPOSE_DIR/$f" up -d
|
||||||
|
wait_healthy "$f"
|
||||||
|
echo " ok"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "$(date -Is) $OLD -> $NEW (${AFFECTED[*]})" >> "$LOG"
|
||||||
|
echo "== deploy complete: $NEW =="
|
||||||
|
echo "rollback: cd $REPO && git reset --hard $OLD && FORCE=1 bash deploy/mainnet/deploy.sh"
|
||||||
@@ -21,10 +21,19 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
INDEXER_URL: http://marketplace-mainnet-indexer:8090
|
INDEXER_URL: http://marketplace-mainnet-indexer:8090
|
||||||
BUYER_PORT: "8070"
|
BUYER_PORT: "8070"
|
||||||
|
# PiperTTS demo (Path B): direct-proxy target for /v1/synth-speech. Piper is
|
||||||
|
# private on marketplace-mainnet-net (no public host), so use internal DNS.
|
||||||
|
# Without this the demo TTS tile 503s ("booted without TTS_PIPER_BASE_URL").
|
||||||
|
TTS_PIPER_BASE_URL: http://marketplace-mainnet-tts-piper:8000
|
||||||
# Pin the archive dir so it lands on the persistent volume below — keeps
|
# Pin the archive dir so it lands on the persistent volume below — keeps
|
||||||
# the response archive + PDF job store (past summaries) across container
|
# the response archive + PDF job store (past summaries) across container
|
||||||
# recreation/redeploys.
|
# recreation/redeploys.
|
||||||
ARCHIVE_DIR: /repo/data/archive
|
ARCHIVE_DIR: /repo/data/archive
|
||||||
|
# Public base URL of the OpenAI gateway, surfaced to the SPA boot block so
|
||||||
|
# the "API Keys" page can POST cross-origin to ${gatewayUrl}/signup. The
|
||||||
|
# gateway must CORS-allow https://marketplace.vector.apexfusion.org (see
|
||||||
|
# GATEWAY_CORS_ORIGINS in docker-compose.gateway.yml).
|
||||||
|
GATEWAY_PUBLIC_URL: https://api.marketplace.vector.apexfusion.org
|
||||||
# NETWORK_ID intentionally not pinned — see supplier compose comment.
|
# NETWORK_ID intentionally not pinned — see supplier compose comment.
|
||||||
volumes:
|
volumes:
|
||||||
- buyer-mainnet-archive:/repo/data/archive
|
- buyer-mainnet-archive:/repo/data/archive
|
||||||
|
|||||||
90
deploy/mainnet/docker-compose.gateway.yml
Normal file
90
deploy/mainnet/docker-compose.gateway.yml
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# deploy/mainnet/docker-compose.gateway.yml — OpenAI-compatible Gateway — MAINNET
|
||||||
|
#
|
||||||
|
# Multi-tenant, custodial OpenAI gateway. Each API key is its own buyer wallet;
|
||||||
|
# every request posts an on-chain escrow from that user's wallet. See docs/gateway.md.
|
||||||
|
#
|
||||||
|
# Secrets live in gateway/.env (NOT committed):
|
||||||
|
# GATEWAY_MASTER_KEY=<openssl rand -hex 32> # losing this locks ALL user funds
|
||||||
|
# Back up BOTH gateway/.env (the master key) AND the gateway-mainnet-data volume
|
||||||
|
# (the gateway.db holds every user's encrypted wallet key + usage).
|
||||||
|
|
||||||
|
name: marketplace-mainnet-gateway
|
||||||
|
|
||||||
|
services:
|
||||||
|
gateway:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: gateway/Dockerfile
|
||||||
|
container_name: marketplace-mainnet-gateway
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- path: ../../gateway/.env
|
||||||
|
required: true
|
||||||
|
environment:
|
||||||
|
INDEXER_URL: http://marketplace-mainnet-indexer:8090
|
||||||
|
OGMIOS_URL: https://ogmios.vector.mainnet.apexfusion.org
|
||||||
|
LIVE_CHAIN: "1"
|
||||||
|
NETWORK_ID: "1"
|
||||||
|
GATEWAY_PORT: "8080"
|
||||||
|
GATEWAY_DB_DIR: /repo/data/gateway
|
||||||
|
# Mainnet chain genesis (slot↔wallclock). MUST match buyer/supplier or
|
||||||
|
# PostEscrow validity bounds land ~51 days off and Ogmios rejects (code 3118).
|
||||||
|
VECTOR_ZERO_TIME_MS: "1756485600000"
|
||||||
|
# On-chain reference-script UTxOs (escrow spending validator) used by
|
||||||
|
# Accept/Reclaim/Submit. Same values the buyer/suppliers use on mainnet.
|
||||||
|
ESCROW_REF_UTXO: c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#0
|
||||||
|
ADVERT_REF_UTXO: c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#1
|
||||||
|
# Browser Origins allowed to call the gateway cross-origin. The buyer
|
||||||
|
# frontend's "API Keys" page (served from marketplace.vector…) POSTs to
|
||||||
|
# /signup here. Comma-separated; empty disables CORS entirely.
|
||||||
|
GATEWAY_CORS_ORIGINS: https://marketplace.vector.apexfusion.org
|
||||||
|
# Ticket mode: the escrow post at session open is the only chain op;
|
||||||
|
# the sweeper reclaims Open escrows after deliver_by. Must match the
|
||||||
|
# kimi-chat suppliers' CHAT_SETTLE_MODE.
|
||||||
|
CHAT_SETTLE_MODE: ticket
|
||||||
|
# Demo janitor closes idle sessions BEFORE the suppliers' 15 min
|
||||||
|
# CHAT_IDLE_TIMEOUT_MS backstop.
|
||||||
|
DEMO_SESSION_IDLE_MS: "600000"
|
||||||
|
volumes:
|
||||||
|
- gateway-mainnet-data:/repo/data/gateway
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||||
|
interval: 20s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
networks:
|
||||||
|
- marketplace-mainnet-net
|
||||||
|
- traefik-net
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=traefik-net"
|
||||||
|
# This host is NOT Cloudflare-proxied: traefik terminates TLS itself with
|
||||||
|
# a Let's Encrypt cert (resolver defined in /root/traefik/traefik.yml on
|
||||||
|
# the box). The :80 router only redirects; ACME HTTP-01 challenges are
|
||||||
|
# answered by traefik before the redirect applies.
|
||||||
|
- "traefik.http.routers.mp-gateway-mainnet.entrypoints=web"
|
||||||
|
- "traefik.http.routers.mp-gateway-mainnet.rule=Host(`api.marketplace.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-gateway-mainnet.middlewares=mp-gateway-mainnet-https"
|
||||||
|
- "traefik.http.middlewares.mp-gateway-mainnet-https.redirectscheme.scheme=https"
|
||||||
|
- "traefik.http.middlewares.mp-gateway-mainnet-https.redirectscheme.permanent=true"
|
||||||
|
- "traefik.http.routers.mp-gateway-mainnet-tls.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.mp-gateway-mainnet-tls.rule=Host(`api.marketplace.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-gateway-mainnet-tls.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.mp-gateway-mainnet.loadbalancer.server.port=8080"
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
marketplace-mainnet-net:
|
||||||
|
external: true
|
||||||
|
name: marketplace-mainnet-net
|
||||||
|
traefik-net:
|
||||||
|
external: true
|
||||||
|
name: traefik-net
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
gateway-mainnet-data:
|
||||||
71
deploy/mainnet/docker-compose.supplier-deepseek-flash-1.yml
Normal file
71
deploy/mainnet/docker-compose.supplier-deepseek-flash-1.yml
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# deploy/mainnet/docker-compose.supplier-deepseek-flash-1.yml — Supplier — MAINNET (OpenRouter / DeepSeek V4 Flash #1)
|
||||||
|
#
|
||||||
|
# Parallel-capacity supplier for deepseek/deepseek-v4-flash via OpenRouter's
|
||||||
|
# OpenAI-compatible API. Same shape as the kimi replicas; distinct only in
|
||||||
|
# instance name, container, env file, Traefik host/router, and advert model.
|
||||||
|
# Shares the same OpenRouter API key as the kimi suppliers (accepted ceiling;
|
||||||
|
# supplier has no 429/backoff). Advert caps: max_output_tokens=262144,
|
||||||
|
# max_processing_ms=3600000; OPENAI_TIMEOUT_MS matched.
|
||||||
|
#
|
||||||
|
# Pre-flight:
|
||||||
|
# 1. DNS (proxied): mp-suppliers-deepseek-flash-1.vector.apexfusion.org → host
|
||||||
|
# 2. supplier/.env.deepseek-flash-1 on host (chmod 600): SUPPLIER_PRIV_KEY_HEX,
|
||||||
|
# OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1, VECTOR_ZERO_TIME_MS,
|
||||||
|
# ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF, LIVE_CHAIN=1
|
||||||
|
# 3. Networks marketplace-mainnet-net + traefik-net already exist.
|
||||||
|
|
||||||
|
name: marketplace-mainnet-supplier-deepseek-flash-1
|
||||||
|
|
||||||
|
services:
|
||||||
|
supplier:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: supplier/Dockerfile
|
||||||
|
container_name: marketplace-mainnet-supplier-deepseek-flash-1
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- path: ../../supplier/.env.deepseek-flash-1
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
LLM_BACKEND: openai
|
||||||
|
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||||
|
OPENAI_BASE_URL: https://openrouter.ai/api
|
||||||
|
# Matches the advert's max_processing_ms=3600000 (60 min).
|
||||||
|
OPENAI_TIMEOUT_MS: "3600000"
|
||||||
|
PORT: "8080"
|
||||||
|
LIVE_CHAIN: "1"
|
||||||
|
# NETWORK_ID and OGMIOS_URL come from supplier/.env.deepseek-flash-1.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||||
|
interval: 20s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
networks:
|
||||||
|
- marketplace-mainnet-net
|
||||||
|
- traefik-net
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=traefik-net"
|
||||||
|
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet.entrypoints=web"
|
||||||
|
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet.rule=Host(`mp-suppliers-deepseek-flash-1.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet.middlewares=mp-suppliers-deepseek-flash-1-mainnet-https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-1-mainnet-https.redirectscheme.scheme=https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-1-mainnet-https.redirectscheme.permanent=true"
|
||||||
|
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet-tls.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet-tls.rule=Host(`mp-suppliers-deepseek-flash-1.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet-tls.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.mp-suppliers-deepseek-flash-1-mainnet.loadbalancer.server.port=8080"
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
marketplace-mainnet-net:
|
||||||
|
external: true
|
||||||
|
name: marketplace-mainnet-net
|
||||||
|
traefik-net:
|
||||||
|
external: true
|
||||||
|
name: traefik-net
|
||||||
71
deploy/mainnet/docker-compose.supplier-deepseek-flash-2.yml
Normal file
71
deploy/mainnet/docker-compose.supplier-deepseek-flash-2.yml
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# deploy/mainnet/docker-compose.supplier-deepseek-flash-2.yml — Supplier — MAINNET (OpenRouter / DeepSeek V4 Flash #2)
|
||||||
|
#
|
||||||
|
# Parallel-capacity supplier for deepseek/deepseek-v4-flash via OpenRouter's
|
||||||
|
# OpenAI-compatible API. Same shape as the kimi replicas; distinct only in
|
||||||
|
# instance name, container, env file, Traefik host/router, and advert model.
|
||||||
|
# Shares the same OpenRouter API key as the kimi suppliers (accepted ceiling;
|
||||||
|
# supplier has no 429/backoff). Advert caps: max_output_tokens=262144,
|
||||||
|
# max_processing_ms=3600000; OPENAI_TIMEOUT_MS matched.
|
||||||
|
#
|
||||||
|
# Pre-flight:
|
||||||
|
# 1. DNS (proxied): mp-suppliers-deepseek-flash-2.vector.apexfusion.org → host
|
||||||
|
# 2. supplier/.env.deepseek-flash-2 on host (chmod 600): SUPPLIER_PRIV_KEY_HEX,
|
||||||
|
# OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1, VECTOR_ZERO_TIME_MS,
|
||||||
|
# ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF, LIVE_CHAIN=1
|
||||||
|
# 3. Networks marketplace-mainnet-net + traefik-net already exist.
|
||||||
|
|
||||||
|
name: marketplace-mainnet-supplier-deepseek-flash-2
|
||||||
|
|
||||||
|
services:
|
||||||
|
supplier:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: supplier/Dockerfile
|
||||||
|
container_name: marketplace-mainnet-supplier-deepseek-flash-2
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- path: ../../supplier/.env.deepseek-flash-2
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
LLM_BACKEND: openai
|
||||||
|
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||||
|
OPENAI_BASE_URL: https://openrouter.ai/api
|
||||||
|
# Matches the advert's max_processing_ms=3600000 (60 min).
|
||||||
|
OPENAI_TIMEOUT_MS: "3600000"
|
||||||
|
PORT: "8080"
|
||||||
|
LIVE_CHAIN: "1"
|
||||||
|
# NETWORK_ID and OGMIOS_URL come from supplier/.env.deepseek-flash-2.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||||
|
interval: 20s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
networks:
|
||||||
|
- marketplace-mainnet-net
|
||||||
|
- traefik-net
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=traefik-net"
|
||||||
|
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet.entrypoints=web"
|
||||||
|
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet.rule=Host(`mp-suppliers-deepseek-flash-2.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet.middlewares=mp-suppliers-deepseek-flash-2-mainnet-https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-2-mainnet-https.redirectscheme.scheme=https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-2-mainnet-https.redirectscheme.permanent=true"
|
||||||
|
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet-tls.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet-tls.rule=Host(`mp-suppliers-deepseek-flash-2.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet-tls.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.mp-suppliers-deepseek-flash-2-mainnet.loadbalancer.server.port=8080"
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
marketplace-mainnet-net:
|
||||||
|
external: true
|
||||||
|
name: marketplace-mainnet-net
|
||||||
|
traefik-net:
|
||||||
|
external: true
|
||||||
|
name: traefik-net
|
||||||
71
deploy/mainnet/docker-compose.supplier-deepseek-flash-3.yml
Normal file
71
deploy/mainnet/docker-compose.supplier-deepseek-flash-3.yml
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# deploy/mainnet/docker-compose.supplier-deepseek-flash-3.yml — Supplier — MAINNET (OpenRouter / DeepSeek V4 Flash #3)
|
||||||
|
#
|
||||||
|
# Parallel-capacity supplier for deepseek/deepseek-v4-flash via OpenRouter's
|
||||||
|
# OpenAI-compatible API. Same shape as the kimi replicas; distinct only in
|
||||||
|
# instance name, container, env file, Traefik host/router, and advert model.
|
||||||
|
# Shares the same OpenRouter API key as the kimi suppliers (accepted ceiling;
|
||||||
|
# supplier has no 429/backoff). Advert caps: max_output_tokens=262144,
|
||||||
|
# max_processing_ms=3600000; OPENAI_TIMEOUT_MS matched.
|
||||||
|
#
|
||||||
|
# Pre-flight:
|
||||||
|
# 1. DNS (proxied): mp-suppliers-deepseek-flash-3.vector.apexfusion.org → host
|
||||||
|
# 2. supplier/.env.deepseek-flash-3 on host (chmod 600): SUPPLIER_PRIV_KEY_HEX,
|
||||||
|
# OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1, VECTOR_ZERO_TIME_MS,
|
||||||
|
# ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF, LIVE_CHAIN=1
|
||||||
|
# 3. Networks marketplace-mainnet-net + traefik-net already exist.
|
||||||
|
|
||||||
|
name: marketplace-mainnet-supplier-deepseek-flash-3
|
||||||
|
|
||||||
|
services:
|
||||||
|
supplier:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: supplier/Dockerfile
|
||||||
|
container_name: marketplace-mainnet-supplier-deepseek-flash-3
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- path: ../../supplier/.env.deepseek-flash-3
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
LLM_BACKEND: openai
|
||||||
|
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||||
|
OPENAI_BASE_URL: https://openrouter.ai/api
|
||||||
|
# Matches the advert's max_processing_ms=3600000 (60 min).
|
||||||
|
OPENAI_TIMEOUT_MS: "3600000"
|
||||||
|
PORT: "8080"
|
||||||
|
LIVE_CHAIN: "1"
|
||||||
|
# NETWORK_ID and OGMIOS_URL come from supplier/.env.deepseek-flash-3.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||||
|
interval: 20s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
networks:
|
||||||
|
- marketplace-mainnet-net
|
||||||
|
- traefik-net
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=traefik-net"
|
||||||
|
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet.entrypoints=web"
|
||||||
|
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet.rule=Host(`mp-suppliers-deepseek-flash-3.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet.middlewares=mp-suppliers-deepseek-flash-3-mainnet-https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-3-mainnet-https.redirectscheme.scheme=https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-3-mainnet-https.redirectscheme.permanent=true"
|
||||||
|
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet-tls.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet-tls.rule=Host(`mp-suppliers-deepseek-flash-3.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet-tls.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.mp-suppliers-deepseek-flash-3-mainnet.loadbalancer.server.port=8080"
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
marketplace-mainnet-net:
|
||||||
|
external: true
|
||||||
|
name: marketplace-mainnet-net
|
||||||
|
traefik-net:
|
||||||
|
external: true
|
||||||
|
name: traefik-net
|
||||||
@@ -65,6 +65,12 @@ services:
|
|||||||
- "traefik.docker.network=traefik-net"
|
- "traefik.docker.network=traefik-net"
|
||||||
- "traefik.http.routers.mp-suppliers-deepseek-mainnet.entrypoints=web"
|
- "traefik.http.routers.mp-suppliers-deepseek-mainnet.entrypoints=web"
|
||||||
- "traefik.http.routers.mp-suppliers-deepseek-mainnet.rule=Host(`mp-suppliers-deepseek.vector.apexfusion.org`)"
|
- "traefik.http.routers.mp-suppliers-deepseek-mainnet.rule=Host(`mp-suppliers-deepseek.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-deepseek-mainnet.middlewares=mp-suppliers-deepseek-mainnet-https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-deepseek-mainnet-https.redirectscheme.scheme=https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-deepseek-mainnet-https.redirectscheme.permanent=true"
|
||||||
|
- "traefik.http.routers.mp-suppliers-deepseek-mainnet-tls.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.mp-suppliers-deepseek-mainnet-tls.rule=Host(`mp-suppliers-deepseek.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-deepseek-mainnet-tls.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.services.mp-suppliers-deepseek-mainnet.loadbalancer.server.port=8080"
|
- "traefik.http.services.mp-suppliers-deepseek-mainnet.loadbalancer.server.port=8080"
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
|
|||||||
80
deploy/mainnet/docker-compose.supplier-ds-flash-chat-1.yml
Normal file
80
deploy/mainnet/docker-compose.supplier-ds-flash-chat-1.yml
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# deploy/mainnet/docker-compose.supplier-ds-flash-chat-1.yml — Supplier — MAINNET
|
||||||
|
# (OpenRouter / deepseek-v4-flash — MULTI-TURN CHAT capability `llm.chat.v1`, REPLICA 1)
|
||||||
|
#
|
||||||
|
# Chat-session sibling of the deepseek-flash one-off suppliers. Same OpenRouter
|
||||||
|
# backend (deepseek/deepseek-v4-flash) but CAPABILITY_KIND=chat-session so it
|
||||||
|
# mounts /v1/chat/{start,message,end}. Ticket settle mode (matches the mainnet
|
||||||
|
# gateway): the buyer's Open escrow post is the only chain op; the supplier
|
||||||
|
# never Claims/Submits; the gateway sweeper reclaims after deliver_by.
|
||||||
|
#
|
||||||
|
# Pre-flight:
|
||||||
|
# 1. DNS A-record: mp-suppliers-ds-flash-chat-1.vector.apexfusion.org -> host IP
|
||||||
|
# 2. New supplier wallet (gen-keypair), funded (advert post + fee float).
|
||||||
|
# 3. Post a fresh llm.chat.v1 advert for this wallet:
|
||||||
|
# --capability-id llm.chat.v1 --model deepseek/deepseek-v4-flash
|
||||||
|
# --max-processing-ms 1800000 --price-lovelace 200000
|
||||||
|
# 4. supplier/.env.ds-flash-chat-1 written on host (chmod 600) with at least:
|
||||||
|
# SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
|
||||||
|
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
|
||||||
|
# LIVE_CHAIN=1, OPENAI_REASONING=off, OPENAI_MAX_TOKENS=131072
|
||||||
|
|
||||||
|
name: marketplace-mainnet-supplier-ds-flash-chat-1
|
||||||
|
|
||||||
|
services:
|
||||||
|
supplier:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: supplier/Dockerfile
|
||||||
|
container_name: marketplace-mainnet-supplier-ds-flash-chat-1
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- path: ../../supplier/.env.ds-flash-chat-1
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
|
||||||
|
CAPABILITY_KIND: chat-session
|
||||||
|
LLM_BACKEND: openai
|
||||||
|
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||||
|
OPENAI_BASE_URL: https://openrouter.ai/api
|
||||||
|
OPENAI_TIMEOUT_MS: "300000"
|
||||||
|
CHAT_IDLE_TIMEOUT_MS: "900000"
|
||||||
|
CHAT_SETTLE_MODE: ticket
|
||||||
|
MAX_CHAT_SESSIONS: "8"
|
||||||
|
PORT: "8080"
|
||||||
|
# Enable LiveOgmiosProvider so wallet-health/consolidate can broadcast.
|
||||||
|
LIVE_CHAIN: "1"
|
||||||
|
# NETWORK_ID and OGMIOS_URL come from supplier/.env.ds-flash-chat-1.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||||
|
interval: 20s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
networks:
|
||||||
|
- marketplace-mainnet-net
|
||||||
|
- traefik-net
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=traefik-net"
|
||||||
|
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet.entrypoints=web"
|
||||||
|
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet.rule=Host(`mp-suppliers-ds-flash-chat-1.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet.middlewares=mp-suppliers-ds-flash-chat-1-mainnet-https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-1-mainnet-https.redirectscheme.scheme=https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-1-mainnet-https.redirectscheme.permanent=true"
|
||||||
|
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet-tls.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet-tls.rule=Host(`mp-suppliers-ds-flash-chat-1.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet-tls.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.mp-suppliers-ds-flash-chat-1-mainnet.loadbalancer.server.port=8080"
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
marketplace-mainnet-net:
|
||||||
|
external: true
|
||||||
|
name: marketplace-mainnet-net
|
||||||
|
traefik-net:
|
||||||
|
external: true
|
||||||
|
name: traefik-net
|
||||||
80
deploy/mainnet/docker-compose.supplier-ds-flash-chat-2.yml
Normal file
80
deploy/mainnet/docker-compose.supplier-ds-flash-chat-2.yml
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# deploy/mainnet/docker-compose.supplier-ds-flash-chat-2.yml — Supplier — MAINNET
|
||||||
|
# (OpenRouter / deepseek-v4-flash — MULTI-TURN CHAT capability `llm.chat.v1`, REPLICA 2)
|
||||||
|
#
|
||||||
|
# Chat-session sibling of the deepseek-flash one-off suppliers. Same OpenRouter
|
||||||
|
# backend (deepseek/deepseek-v4-flash) but CAPABILITY_KIND=chat-session so it
|
||||||
|
# mounts /v1/chat/{start,message,end}. Ticket settle mode (matches the mainnet
|
||||||
|
# gateway): the buyer's Open escrow post is the only chain op; the supplier
|
||||||
|
# never Claims/Submits; the gateway sweeper reclaims after deliver_by.
|
||||||
|
#
|
||||||
|
# Pre-flight:
|
||||||
|
# 1. DNS A-record: mp-suppliers-ds-flash-chat-2.vector.apexfusion.org -> host IP
|
||||||
|
# 2. New supplier wallet (gen-keypair), funded (advert post + fee float).
|
||||||
|
# 3. Post a fresh llm.chat.v1 advert for this wallet:
|
||||||
|
# --capability-id llm.chat.v1 --model deepseek/deepseek-v4-flash
|
||||||
|
# --max-processing-ms 1800000 --price-lovelace 200000
|
||||||
|
# 4. supplier/.env.ds-flash-chat-2 written on host (chmod 600) with at least:
|
||||||
|
# SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
|
||||||
|
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
|
||||||
|
# LIVE_CHAIN=1, OPENAI_REASONING=off, OPENAI_MAX_TOKENS=131072
|
||||||
|
|
||||||
|
name: marketplace-mainnet-supplier-ds-flash-chat-2
|
||||||
|
|
||||||
|
services:
|
||||||
|
supplier:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: supplier/Dockerfile
|
||||||
|
container_name: marketplace-mainnet-supplier-ds-flash-chat-2
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- path: ../../supplier/.env.ds-flash-chat-2
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
|
||||||
|
CAPABILITY_KIND: chat-session
|
||||||
|
LLM_BACKEND: openai
|
||||||
|
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||||
|
OPENAI_BASE_URL: https://openrouter.ai/api
|
||||||
|
OPENAI_TIMEOUT_MS: "300000"
|
||||||
|
CHAT_IDLE_TIMEOUT_MS: "900000"
|
||||||
|
CHAT_SETTLE_MODE: ticket
|
||||||
|
MAX_CHAT_SESSIONS: "8"
|
||||||
|
PORT: "8080"
|
||||||
|
# Enable LiveOgmiosProvider so wallet-health/consolidate can broadcast.
|
||||||
|
LIVE_CHAIN: "1"
|
||||||
|
# NETWORK_ID and OGMIOS_URL come from supplier/.env.ds-flash-chat-2.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||||
|
interval: 20s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
networks:
|
||||||
|
- marketplace-mainnet-net
|
||||||
|
- traefik-net
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=traefik-net"
|
||||||
|
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet.entrypoints=web"
|
||||||
|
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet.rule=Host(`mp-suppliers-ds-flash-chat-2.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet.middlewares=mp-suppliers-ds-flash-chat-2-mainnet-https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-2-mainnet-https.redirectscheme.scheme=https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-2-mainnet-https.redirectscheme.permanent=true"
|
||||||
|
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet-tls.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet-tls.rule=Host(`mp-suppliers-ds-flash-chat-2.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet-tls.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.mp-suppliers-ds-flash-chat-2-mainnet.loadbalancer.server.port=8080"
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
marketplace-mainnet-net:
|
||||||
|
external: true
|
||||||
|
name: marketplace-mainnet-net
|
||||||
|
traefik-net:
|
||||||
|
external: true
|
||||||
|
name: traefik-net
|
||||||
80
deploy/mainnet/docker-compose.supplier-ds-flash-chat-3.yml
Normal file
80
deploy/mainnet/docker-compose.supplier-ds-flash-chat-3.yml
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# deploy/mainnet/docker-compose.supplier-ds-flash-chat-3.yml — Supplier — MAINNET
|
||||||
|
# (OpenRouter / deepseek-v4-flash — MULTI-TURN CHAT capability `llm.chat.v1`, REPLICA 3)
|
||||||
|
#
|
||||||
|
# Chat-session sibling of the deepseek-flash one-off suppliers. Same OpenRouter
|
||||||
|
# backend (deepseek/deepseek-v4-flash) but CAPABILITY_KIND=chat-session so it
|
||||||
|
# mounts /v1/chat/{start,message,end}. Ticket settle mode (matches the mainnet
|
||||||
|
# gateway): the buyer's Open escrow post is the only chain op; the supplier
|
||||||
|
# never Claims/Submits; the gateway sweeper reclaims after deliver_by.
|
||||||
|
#
|
||||||
|
# Pre-flight:
|
||||||
|
# 1. DNS A-record: mp-suppliers-ds-flash-chat-3.vector.apexfusion.org -> host IP
|
||||||
|
# 2. New supplier wallet (gen-keypair), funded (advert post + fee float).
|
||||||
|
# 3. Post a fresh llm.chat.v1 advert for this wallet:
|
||||||
|
# --capability-id llm.chat.v1 --model deepseek/deepseek-v4-flash
|
||||||
|
# --max-processing-ms 1800000 --price-lovelace 200000
|
||||||
|
# 4. supplier/.env.ds-flash-chat-3 written on host (chmod 600) with at least:
|
||||||
|
# SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
|
||||||
|
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
|
||||||
|
# LIVE_CHAIN=1, OPENAI_REASONING=off, OPENAI_MAX_TOKENS=131072
|
||||||
|
|
||||||
|
name: marketplace-mainnet-supplier-ds-flash-chat-3
|
||||||
|
|
||||||
|
services:
|
||||||
|
supplier:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: supplier/Dockerfile
|
||||||
|
container_name: marketplace-mainnet-supplier-ds-flash-chat-3
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- path: ../../supplier/.env.ds-flash-chat-3
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
|
||||||
|
CAPABILITY_KIND: chat-session
|
||||||
|
LLM_BACKEND: openai
|
||||||
|
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||||
|
OPENAI_BASE_URL: https://openrouter.ai/api
|
||||||
|
OPENAI_TIMEOUT_MS: "300000"
|
||||||
|
CHAT_IDLE_TIMEOUT_MS: "900000"
|
||||||
|
CHAT_SETTLE_MODE: ticket
|
||||||
|
MAX_CHAT_SESSIONS: "8"
|
||||||
|
PORT: "8080"
|
||||||
|
# Enable LiveOgmiosProvider so wallet-health/consolidate can broadcast.
|
||||||
|
LIVE_CHAIN: "1"
|
||||||
|
# NETWORK_ID and OGMIOS_URL come from supplier/.env.ds-flash-chat-3.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||||
|
interval: 20s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
networks:
|
||||||
|
- marketplace-mainnet-net
|
||||||
|
- traefik-net
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=traefik-net"
|
||||||
|
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet.entrypoints=web"
|
||||||
|
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet.rule=Host(`mp-suppliers-ds-flash-chat-3.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet.middlewares=mp-suppliers-ds-flash-chat-3-mainnet-https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-3-mainnet-https.redirectscheme.scheme=https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-3-mainnet-https.redirectscheme.permanent=true"
|
||||||
|
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet-tls.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet-tls.rule=Host(`mp-suppliers-ds-flash-chat-3.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet-tls.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.mp-suppliers-ds-flash-chat-3-mainnet.loadbalancer.server.port=8080"
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
marketplace-mainnet-net:
|
||||||
|
external: true
|
||||||
|
name: marketplace-mainnet-net
|
||||||
|
traefik-net:
|
||||||
|
external: true
|
||||||
|
name: traefik-net
|
||||||
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
|
||||||
@@ -61,6 +61,12 @@ services:
|
|||||||
- "traefik.docker.network=traefik-net"
|
- "traefik.docker.network=traefik-net"
|
||||||
- "traefik.http.routers.mp-suppliers-gpt-mainnet.entrypoints=web"
|
- "traefik.http.routers.mp-suppliers-gpt-mainnet.entrypoints=web"
|
||||||
- "traefik.http.routers.mp-suppliers-gpt-mainnet.rule=Host(`mp-suppliers-gpt.vector.apexfusion.org`)"
|
- "traefik.http.routers.mp-suppliers-gpt-mainnet.rule=Host(`mp-suppliers-gpt.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-gpt-mainnet.middlewares=mp-suppliers-gpt-mainnet-https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-gpt-mainnet-https.redirectscheme.scheme=https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-gpt-mainnet-https.redirectscheme.permanent=true"
|
||||||
|
- "traefik.http.routers.mp-suppliers-gpt-mainnet-tls.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.mp-suppliers-gpt-mainnet-tls.rule=Host(`mp-suppliers-gpt.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-gpt-mainnet-tls.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.services.mp-suppliers-gpt-mainnet.loadbalancer.server.port=8080"
|
- "traefik.http.services.mp-suppliers-gpt-mainnet.loadbalancer.server.port=8080"
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
|
|||||||
73
deploy/mainnet/docker-compose.supplier-kimi-2.yml
Normal file
73
deploy/mainnet/docker-compose.supplier-kimi-2.yml
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# deploy/mainnet/docker-compose.supplier-kimi-2.yml — Supplier — MAINNET (OpenRouter / Kimi K2.6 replica #2)
|
||||||
|
#
|
||||||
|
# Parallel-capacity replica of supplier-kimi (OpenRouter, moonshotai/kimi-k2.6).
|
||||||
|
# Identical to docker-compose.supplier-kimi.yml except for the instance name,
|
||||||
|
# container name, env file, Traefik host/router, and OPENAI_TIMEOUT_MS (raised
|
||||||
|
# to 3600000 to match this advert's max_processing_ms=3600000, max_output_tokens=262144).
|
||||||
|
# Shares the same OpenRouter API key as the other kimi suppliers (accepted
|
||||||
|
# concurrency ceiling; supplier has no 429/backoff).
|
||||||
|
#
|
||||||
|
# Pre-flight:
|
||||||
|
# 1. DNS (proxied): mp-suppliers-kimi-2.vector.apexfusion.org → host
|
||||||
|
# 2. supplier/.env.kimi-2 on host (chmod 600): SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY,
|
||||||
|
# OGMIOS_URL, NETWORK_ID=1, VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO,
|
||||||
|
# ADVERT_REF, LIVE_CHAIN=1
|
||||||
|
# 3. Networks marketplace-mainnet-net + traefik-net already exist.
|
||||||
|
|
||||||
|
name: marketplace-mainnet-supplier-kimi-2
|
||||||
|
|
||||||
|
services:
|
||||||
|
supplier:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: supplier/Dockerfile
|
||||||
|
container_name: marketplace-mainnet-supplier-kimi-2
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- path: ../../supplier/.env.kimi-2
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
LLM_BACKEND: openai
|
||||||
|
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||||
|
OPENAI_BASE_URL: https://openrouter.ai/api
|
||||||
|
# Raised to match the advert's max_processing_ms=3600000 (60 min) so the
|
||||||
|
# upstream call isn't aborted before the advertised processing deadline.
|
||||||
|
OPENAI_TIMEOUT_MS: "3600000"
|
||||||
|
PORT: "8080"
|
||||||
|
LIVE_CHAIN: "1"
|
||||||
|
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||||
|
# come from supplier/.env.kimi-2 on the mainnet host (footgun avoidance).
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||||
|
interval: 20s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
networks:
|
||||||
|
- marketplace-mainnet-net
|
||||||
|
- traefik-net
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=traefik-net"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet.entrypoints=web"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet.rule=Host(`mp-suppliers-kimi-2.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet.middlewares=mp-suppliers-kimi-2-mainnet-https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-kimi-2-mainnet-https.redirectscheme.scheme=https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-kimi-2-mainnet-https.redirectscheme.permanent=true"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet-tls.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet-tls.rule=Host(`mp-suppliers-kimi-2.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet-tls.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.mp-suppliers-kimi-2-mainnet.loadbalancer.server.port=8080"
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
marketplace-mainnet-net:
|
||||||
|
external: true
|
||||||
|
name: marketplace-mainnet-net
|
||||||
|
traefik-net:
|
||||||
|
external: true
|
||||||
|
name: traefik-net
|
||||||
73
deploy/mainnet/docker-compose.supplier-kimi-3.yml
Normal file
73
deploy/mainnet/docker-compose.supplier-kimi-3.yml
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# deploy/mainnet/docker-compose.supplier-kimi-3.yml — Supplier — MAINNET (OpenRouter / Kimi K2.6 replica #3)
|
||||||
|
#
|
||||||
|
# Parallel-capacity replica of supplier-kimi (OpenRouter, moonshotai/kimi-k2.6).
|
||||||
|
# Identical to docker-compose.supplier-kimi.yml except for the instance name,
|
||||||
|
# container name, env file, Traefik host/router, and OPENAI_TIMEOUT_MS (raised
|
||||||
|
# to 3600000 to match this advert's max_processing_ms=3600000, max_output_tokens=262144).
|
||||||
|
# Shares the same OpenRouter API key as the other kimi suppliers (accepted
|
||||||
|
# concurrency ceiling; supplier has no 429/backoff).
|
||||||
|
#
|
||||||
|
# Pre-flight:
|
||||||
|
# 1. DNS (proxied): mp-suppliers-kimi-3.vector.apexfusion.org → host
|
||||||
|
# 2. supplier/.env.kimi-3 on host (chmod 600): SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY,
|
||||||
|
# OGMIOS_URL, NETWORK_ID=1, VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO,
|
||||||
|
# ADVERT_REF, LIVE_CHAIN=1
|
||||||
|
# 3. Networks marketplace-mainnet-net + traefik-net already exist.
|
||||||
|
|
||||||
|
name: marketplace-mainnet-supplier-kimi-3
|
||||||
|
|
||||||
|
services:
|
||||||
|
supplier:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: supplier/Dockerfile
|
||||||
|
container_name: marketplace-mainnet-supplier-kimi-3
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- path: ../../supplier/.env.kimi-3
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
LLM_BACKEND: openai
|
||||||
|
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||||
|
OPENAI_BASE_URL: https://openrouter.ai/api
|
||||||
|
# Raised to match the advert's max_processing_ms=3600000 (60 min) so the
|
||||||
|
# upstream call isn't aborted before the advertised processing deadline.
|
||||||
|
OPENAI_TIMEOUT_MS: "3600000"
|
||||||
|
PORT: "8080"
|
||||||
|
LIVE_CHAIN: "1"
|
||||||
|
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||||
|
# come from supplier/.env.kimi-3 on the mainnet host (footgun avoidance).
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||||
|
interval: 20s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
networks:
|
||||||
|
- marketplace-mainnet-net
|
||||||
|
- traefik-net
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=traefik-net"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet.entrypoints=web"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet.rule=Host(`mp-suppliers-kimi-3.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet.middlewares=mp-suppliers-kimi-3-mainnet-https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-kimi-3-mainnet-https.redirectscheme.scheme=https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-kimi-3-mainnet-https.redirectscheme.permanent=true"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet-tls.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet-tls.rule=Host(`mp-suppliers-kimi-3.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet-tls.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.mp-suppliers-kimi-3-mainnet.loadbalancer.server.port=8080"
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
marketplace-mainnet-net:
|
||||||
|
external: true
|
||||||
|
name: marketplace-mainnet-net
|
||||||
|
traefik-net:
|
||||||
|
external: true
|
||||||
|
name: traefik-net
|
||||||
73
deploy/mainnet/docker-compose.supplier-kimi-4.yml
Normal file
73
deploy/mainnet/docker-compose.supplier-kimi-4.yml
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# deploy/mainnet/docker-compose.supplier-kimi-4.yml — Supplier — MAINNET (OpenRouter / Kimi K2.6 replica #4)
|
||||||
|
#
|
||||||
|
# Parallel-capacity replica of supplier-kimi (OpenRouter, moonshotai/kimi-k2.6).
|
||||||
|
# Identical to docker-compose.supplier-kimi.yml except for the instance name,
|
||||||
|
# container name, env file, Traefik host/router, and OPENAI_TIMEOUT_MS (raised
|
||||||
|
# to 3600000 to match this advert's max_processing_ms=3600000, max_output_tokens=262144).
|
||||||
|
# Shares the same OpenRouter API key as the other kimi suppliers (accepted
|
||||||
|
# concurrency ceiling; supplier has no 429/backoff).
|
||||||
|
#
|
||||||
|
# Pre-flight:
|
||||||
|
# 1. DNS (proxied): mp-suppliers-kimi-4.vector.apexfusion.org → host
|
||||||
|
# 2. supplier/.env.kimi-4 on host (chmod 600): SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY,
|
||||||
|
# OGMIOS_URL, NETWORK_ID=1, VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO,
|
||||||
|
# ADVERT_REF, LIVE_CHAIN=1
|
||||||
|
# 3. Networks marketplace-mainnet-net + traefik-net already exist.
|
||||||
|
|
||||||
|
name: marketplace-mainnet-supplier-kimi-4
|
||||||
|
|
||||||
|
services:
|
||||||
|
supplier:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: supplier/Dockerfile
|
||||||
|
container_name: marketplace-mainnet-supplier-kimi-4
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- path: ../../supplier/.env.kimi-4
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
LLM_BACKEND: openai
|
||||||
|
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||||
|
OPENAI_BASE_URL: https://openrouter.ai/api
|
||||||
|
# Raised to match the advert's max_processing_ms=3600000 (60 min) so the
|
||||||
|
# upstream call isn't aborted before the advertised processing deadline.
|
||||||
|
OPENAI_TIMEOUT_MS: "3600000"
|
||||||
|
PORT: "8080"
|
||||||
|
LIVE_CHAIN: "1"
|
||||||
|
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||||
|
# come from supplier/.env.kimi-4 on the mainnet host (footgun avoidance).
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||||
|
interval: 20s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
networks:
|
||||||
|
- marketplace-mainnet-net
|
||||||
|
- traefik-net
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=traefik-net"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet.entrypoints=web"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet.rule=Host(`mp-suppliers-kimi-4.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet.middlewares=mp-suppliers-kimi-4-mainnet-https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-kimi-4-mainnet-https.redirectscheme.scheme=https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-kimi-4-mainnet-https.redirectscheme.permanent=true"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet-tls.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet-tls.rule=Host(`mp-suppliers-kimi-4.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet-tls.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.mp-suppliers-kimi-4-mainnet.loadbalancer.server.port=8080"
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
marketplace-mainnet-net:
|
||||||
|
external: true
|
||||||
|
name: marketplace-mainnet-net
|
||||||
|
traefik-net:
|
||||||
|
external: true
|
||||||
|
name: traefik-net
|
||||||
84
deploy/mainnet/docker-compose.supplier-kimi-chat-2.yml
Normal file
84
deploy/mainnet/docker-compose.supplier-kimi-chat-2.yml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# deploy/mainnet/docker-compose.supplier-kimi-chat-2.yml — Supplier — MAINNET
|
||||||
|
# (OpenRouter / Kimi K2.6 — MULTI-TURN CHAT capability `llm.chat.v1`, REPLICA 2)
|
||||||
|
#
|
||||||
|
# Second llm.chat.v1 replica: a chat session holds a supplier's single slot for
|
||||||
|
# the whole conversation, so concurrent chat users = number of chat suppliers.
|
||||||
|
# Deployed for the shared-demo-key endpoint (session-backed /chat/completions).
|
||||||
|
# Identical to docker-compose.supplier-kimi-chat.yml except for its own wallet,
|
||||||
|
# advert, env file and hostname.
|
||||||
|
#
|
||||||
|
# Pre-flight:
|
||||||
|
# 1. DNS A-record: mp-suppliers-kimi-chat-2.vector.apexfusion.org → host IP
|
||||||
|
# 2. New supplier wallet (gen-keypair), funded with advert bond + fee float.
|
||||||
|
# 3. Post a fresh llm.chat.v1 advert for this wallet:
|
||||||
|
# --capability-id llm.chat.v1 --model moonshotai/kimi-k2.6
|
||||||
|
# --max-processing-ms 1800000 (same price as the primary chat advert)
|
||||||
|
# 4. supplier/.env.kimi-chat-2 written on host (chmod 600) with at minimum:
|
||||||
|
# SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
|
||||||
|
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
|
||||||
|
# LIVE_CHAIN=1
|
||||||
|
|
||||||
|
name: marketplace-mainnet-supplier-kimi-chat-2
|
||||||
|
|
||||||
|
services:
|
||||||
|
supplier:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: supplier/Dockerfile
|
||||||
|
container_name: marketplace-mainnet-supplier-kimi-chat-2
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- path: ../../supplier/.env.kimi-chat-2
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
|
||||||
|
CAPABILITY_KIND: chat-session
|
||||||
|
LLM_BACKEND: openai
|
||||||
|
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||||
|
OPENAI_BASE_URL: https://openrouter.ai/api
|
||||||
|
# Kimi K2.6 generations can be slow; mirror the one-off Kimi compose.
|
||||||
|
OPENAI_TIMEOUT_MS: "300000"
|
||||||
|
# 2.5 min idle end: single-slot suppliers must free up quickly for the
|
||||||
|
# shared demo endpoint (the gateway transparently reopens on next turn).
|
||||||
|
CHAT_IDLE_TIMEOUT_MS: "900000"
|
||||||
|
CHAT_SETTLE_MODE: ticket
|
||||||
|
MAX_CHAT_SESSIONS: "8"
|
||||||
|
PORT: "8080"
|
||||||
|
# Enable LiveOgmiosProvider so Claim/Submit actually broadcast.
|
||||||
|
LIVE_CHAIN: "1"
|
||||||
|
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||||
|
# come from the operator-provided supplier/.env.kimi-chat-2 on a mainnet host.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||||
|
interval: 20s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
networks:
|
||||||
|
- marketplace-mainnet-net
|
||||||
|
- traefik-net
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=traefik-net"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet.entrypoints=web"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet.rule=Host(`mp-suppliers-kimi-chat-2.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet.middlewares=mp-suppliers-kimi-chat-2-mainnet-https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-kimi-chat-2-mainnet-https.redirectscheme.scheme=https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-kimi-chat-2-mainnet-https.redirectscheme.permanent=true"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet-tls.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet-tls.rule=Host(`mp-suppliers-kimi-chat-2.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet-tls.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.mp-suppliers-kimi-chat-2-mainnet.loadbalancer.server.port=8080"
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
marketplace-mainnet-net:
|
||||||
|
external: true
|
||||||
|
name: marketplace-mainnet-net
|
||||||
|
traefik-net:
|
||||||
|
external: true
|
||||||
|
name: traefik-net
|
||||||
84
deploy/mainnet/docker-compose.supplier-kimi-chat-3.yml
Normal file
84
deploy/mainnet/docker-compose.supplier-kimi-chat-3.yml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# deploy/mainnet/docker-compose.supplier-kimi-chat-3.yml — Supplier — MAINNET
|
||||||
|
# (OpenRouter / Kimi K2.6 — MULTI-TURN CHAT capability `llm.chat.v1`, REPLICA 3)
|
||||||
|
#
|
||||||
|
# Third llm.chat.v1 replica: a chat session holds a supplier's single slot for
|
||||||
|
# the whole conversation, so concurrent chat users = number of chat suppliers.
|
||||||
|
# Deployed for the shared-demo-key endpoint (session-backed /chat/completions).
|
||||||
|
# Identical to docker-compose.supplier-kimi-chat.yml except for its own wallet,
|
||||||
|
# advert, env file and hostname.
|
||||||
|
#
|
||||||
|
# Pre-flight:
|
||||||
|
# 1. DNS A-record: mp-suppliers-kimi-chat-3.vector.apexfusion.org → host IP
|
||||||
|
# 2. New supplier wallet (gen-keypair), funded with advert bond + fee float.
|
||||||
|
# 3. Post a fresh llm.chat.v1 advert for this wallet:
|
||||||
|
# --capability-id llm.chat.v1 --model moonshotai/kimi-k2.6
|
||||||
|
# --max-processing-ms 1800000 (same price as the primary chat advert)
|
||||||
|
# 4. supplier/.env.kimi-chat-3 written on host (chmod 600) with at minimum:
|
||||||
|
# SUPPLIER_PRIV_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
|
||||||
|
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
|
||||||
|
# LIVE_CHAIN=1
|
||||||
|
|
||||||
|
name: marketplace-mainnet-supplier-kimi-chat-3
|
||||||
|
|
||||||
|
services:
|
||||||
|
supplier:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: supplier/Dockerfile
|
||||||
|
container_name: marketplace-mainnet-supplier-kimi-chat-3
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- path: ../../supplier/.env.kimi-chat-3
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
|
||||||
|
CAPABILITY_KIND: chat-session
|
||||||
|
LLM_BACKEND: openai
|
||||||
|
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||||
|
OPENAI_BASE_URL: https://openrouter.ai/api
|
||||||
|
# Kimi K2.6 generations can be slow; mirror the one-off Kimi compose.
|
||||||
|
OPENAI_TIMEOUT_MS: "300000"
|
||||||
|
# 2.5 min idle end: single-slot suppliers must free up quickly for the
|
||||||
|
# shared demo endpoint (the gateway transparently reopens on next turn).
|
||||||
|
CHAT_IDLE_TIMEOUT_MS: "900000"
|
||||||
|
CHAT_SETTLE_MODE: ticket
|
||||||
|
MAX_CHAT_SESSIONS: "8"
|
||||||
|
PORT: "8080"
|
||||||
|
# Enable LiveOgmiosProvider so Claim/Submit actually broadcast.
|
||||||
|
LIVE_CHAIN: "1"
|
||||||
|
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||||
|
# come from the operator-provided supplier/.env.kimi-chat-3 on a mainnet host.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||||
|
interval: 20s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
networks:
|
||||||
|
- marketplace-mainnet-net
|
||||||
|
- traefik-net
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=traefik-net"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet.entrypoints=web"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet.rule=Host(`mp-suppliers-kimi-chat-3.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet.middlewares=mp-suppliers-kimi-chat-3-mainnet-https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-kimi-chat-3-mainnet-https.redirectscheme.scheme=https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-kimi-chat-3-mainnet-https.redirectscheme.permanent=true"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet-tls.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet-tls.rule=Host(`mp-suppliers-kimi-chat-3.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet-tls.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.mp-suppliers-kimi-chat-3-mainnet.loadbalancer.server.port=8080"
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
marketplace-mainnet-net:
|
||||||
|
external: true
|
||||||
|
name: marketplace-mainnet-net
|
||||||
|
traefik-net:
|
||||||
|
external: true
|
||||||
|
name: traefik-net
|
||||||
@@ -49,8 +49,12 @@ services:
|
|||||||
OPENAI_BASE_URL: https://openrouter.ai/api
|
OPENAI_BASE_URL: https://openrouter.ai/api
|
||||||
# Kimi K2.6 generations can be slow; mirror the one-off Kimi compose.
|
# Kimi K2.6 generations can be slow; mirror the one-off Kimi compose.
|
||||||
OPENAI_TIMEOUT_MS: "300000"
|
OPENAI_TIMEOUT_MS: "300000"
|
||||||
# Auto-end an idle session after 5 min (supplier Submits the transcript).
|
# 2.5 min idle end (supplier Submits the transcript): single-slot
|
||||||
CHAT_IDLE_TIMEOUT_MS: "300000"
|
# suppliers must free up quickly for the shared demo endpoint (the
|
||||||
|
# gateway transparently reopens a session on the next turn).
|
||||||
|
CHAT_IDLE_TIMEOUT_MS: "900000"
|
||||||
|
CHAT_SETTLE_MODE: ticket
|
||||||
|
MAX_CHAT_SESSIONS: "8"
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
# Enable LiveOgmiosProvider so Claim/Submit actually broadcast.
|
# Enable LiveOgmiosProvider so Claim/Submit actually broadcast.
|
||||||
LIVE_CHAIN: "1"
|
LIVE_CHAIN: "1"
|
||||||
@@ -70,6 +74,12 @@ services:
|
|||||||
- "traefik.docker.network=traefik-net"
|
- "traefik.docker.network=traefik-net"
|
||||||
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet.entrypoints=web"
|
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet.entrypoints=web"
|
||||||
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet.rule=Host(`mp-suppliers-kimi-chat.vector.apexfusion.org`)"
|
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet.rule=Host(`mp-suppliers-kimi-chat.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet.middlewares=mp-suppliers-kimi-chat-mainnet-https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-kimi-chat-mainnet-https.redirectscheme.scheme=https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-kimi-chat-mainnet-https.redirectscheme.permanent=true"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet-tls.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet-tls.rule=Host(`mp-suppliers-kimi-chat.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet-tls.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.services.mp-suppliers-kimi-chat-mainnet.loadbalancer.server.port=8080"
|
- "traefik.http.services.mp-suppliers-kimi-chat-mainnet.loadbalancer.server.port=8080"
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
|
|||||||
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
|
||||||
@@ -48,8 +48,10 @@ services:
|
|||||||
LLM_BACKEND: openai
|
LLM_BACKEND: openai
|
||||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||||
OPENAI_BASE_URL: https://openrouter.ai/api
|
OPENAI_BASE_URL: https://openrouter.ai/api
|
||||||
# Kimi K2.6 generations can be slow; mirror the DeepSeek/GPT composes.
|
# Raised to match the advert's max_processing_ms=3600000 (60 min) so the
|
||||||
OPENAI_TIMEOUT_MS: "300000"
|
# upstream call isn't aborted before the advertised processing deadline
|
||||||
|
# (matches the kimi-2/3/4 replicas).
|
||||||
|
OPENAI_TIMEOUT_MS: "3600000"
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
# Enable LiveOgmiosProvider so submitTx/awaitTx actually broadcast.
|
# Enable LiveOgmiosProvider so submitTx/awaitTx actually broadcast.
|
||||||
# Without this the supplier silently falls back to ReadOnlyOgmiosProvider
|
# Without this the supplier silently falls back to ReadOnlyOgmiosProvider
|
||||||
@@ -73,6 +75,12 @@ services:
|
|||||||
- "traefik.docker.network=traefik-net"
|
- "traefik.docker.network=traefik-net"
|
||||||
- "traefik.http.routers.mp-suppliers-kimi-mainnet.entrypoints=web"
|
- "traefik.http.routers.mp-suppliers-kimi-mainnet.entrypoints=web"
|
||||||
- "traefik.http.routers.mp-suppliers-kimi-mainnet.rule=Host(`mp-suppliers-kimi.vector.apexfusion.org`)"
|
- "traefik.http.routers.mp-suppliers-kimi-mainnet.rule=Host(`mp-suppliers-kimi.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-mainnet.middlewares=mp-suppliers-kimi-mainnet-https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-kimi-mainnet-https.redirectscheme.scheme=https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-kimi-mainnet-https.redirectscheme.permanent=true"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-mainnet-tls.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-mainnet-tls.rule=Host(`mp-suppliers-kimi.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-kimi-mainnet-tls.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.services.mp-suppliers-kimi-mainnet.loadbalancer.server.port=8080"
|
- "traefik.http.services.mp-suppliers-kimi-mainnet.loadbalancer.server.port=8080"
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
|
|||||||
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
|
||||||
@@ -65,6 +65,12 @@ services:
|
|||||||
- "traefik.docker.network=traefik-net"
|
- "traefik.docker.network=traefik-net"
|
||||||
- "traefik.http.routers.mp-suppliers-tts-mainnet.entrypoints=web"
|
- "traefik.http.routers.mp-suppliers-tts-mainnet.entrypoints=web"
|
||||||
- "traefik.http.routers.mp-suppliers-tts-mainnet.rule=Host(`mp-suppliers-tts.vector.apexfusion.org`)"
|
- "traefik.http.routers.mp-suppliers-tts-mainnet.rule=Host(`mp-suppliers-tts.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-tts-mainnet.middlewares=mp-suppliers-tts-mainnet-https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-tts-mainnet-https.redirectscheme.scheme=https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-tts-mainnet-https.redirectscheme.permanent=true"
|
||||||
|
- "traefik.http.routers.mp-suppliers-tts-mainnet-tls.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.mp-suppliers-tts-mainnet-tls.rule=Host(`mp-suppliers-tts.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-tts-mainnet-tls.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.services.mp-suppliers-tts-mainnet.loadbalancer.server.port=8080"
|
- "traefik.http.services.mp-suppliers-tts-mainnet.loadbalancer.server.port=8080"
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ services:
|
|||||||
- "traefik.docker.network=traefik-net"
|
- "traefik.docker.network=traefik-net"
|
||||||
- "traefik.http.routers.mp-suppliers-mainnet.entrypoints=web"
|
- "traefik.http.routers.mp-suppliers-mainnet.entrypoints=web"
|
||||||
- "traefik.http.routers.mp-suppliers-mainnet.rule=Host(`mp-suppliers.vector.apexfusion.org`)"
|
- "traefik.http.routers.mp-suppliers-mainnet.rule=Host(`mp-suppliers.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-mainnet.middlewares=mp-suppliers-mainnet-https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-mainnet-https.redirectscheme.scheme=https"
|
||||||
|
- "traefik.http.middlewares.mp-suppliers-mainnet-https.redirectscheme.permanent=true"
|
||||||
|
- "traefik.http.routers.mp-suppliers-mainnet-tls.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.mp-suppliers-mainnet-tls.rule=Host(`mp-suppliers.vector.apexfusion.org`)"
|
||||||
|
- "traefik.http.routers.mp-suppliers-mainnet-tls.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.services.mp-suppliers-mainnet.loadbalancer.server.port=8080"
|
- "traefik.http.services.mp-suppliers-mainnet.loadbalancer.server.port=8080"
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
|
|||||||
40
deploy/mainnet/docker-compose.wallet-monitor.yml
Normal file
40
deploy/mainnet/docker-compose.wallet-monitor.yml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# deploy/mainnet/docker-compose.wallet-monitor.yml — Wallet-balance Slack alerter — MAINNET
|
||||||
|
#
|
||||||
|
# A ONE-SHOT, not a long-running server: it checks every wallet listed in
|
||||||
|
# wallet-monitor/wallets.json, posts a Slack alert for any below threshold,
|
||||||
|
# then exits. There is no port, healthcheck, or restart policy.
|
||||||
|
#
|
||||||
|
# Invoked hourly by cron (see /etc/cron.d/wallet-monitor):
|
||||||
|
# cd /root/agents-marketplace && \
|
||||||
|
# docker compose -f deploy/mainnet/docker-compose.wallet-monitor.yml run --rm wallet-monitor
|
||||||
|
#
|
||||||
|
# One-time setup on the box:
|
||||||
|
# 1) populate wallet-monitor/wallets.json (the 7 addresses — see the package README)
|
||||||
|
# 2) create wallet-monitor/.env with SLACK_WEBHOOK_URL=...
|
||||||
|
# 3) docker compose -f deploy/mainnet/docker-compose.wallet-monitor.yml build
|
||||||
|
# 4) smoke test: docker compose -f ... run --rm -e TEST=1 wallet-monitor
|
||||||
|
|
||||||
|
name: marketplace-mainnet-wallet-monitor
|
||||||
|
|
||||||
|
services:
|
||||||
|
wallet-monitor:
|
||||||
|
build:
|
||||||
|
context: ../..
|
||||||
|
dockerfile: wallet-monitor/Dockerfile
|
||||||
|
env_file:
|
||||||
|
- path: ../../wallet-monitor/.env
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
# Mainnet-only service; pin the public Ogmios endpoint explicitly.
|
||||||
|
# (config.ts also defaults to this, but this compose is mainnet-specific.)
|
||||||
|
OGMIOS_URL: https://ogmios.vector.mainnet.apexfusion.org
|
||||||
|
volumes:
|
||||||
|
# The wallet list is read-only. The dedup state dir is writable and
|
||||||
|
# persists across runs (data/ is gitignored).
|
||||||
|
- ../../wallet-monitor/wallets.json:/repo/wallet-monitor/wallets.json:ro
|
||||||
|
- ../../wallet-monitor/data:/repo/wallet-monitor/data
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
@@ -8,13 +8,13 @@
|
|||||||
|
|
||||||
## 1. Goal and scope
|
## 1. Goal and scope
|
||||||
|
|
||||||
**What this is.** A two-sided marketplace where buyers (agents or humans) submit prompts to suppliers running inference on their own hardware, pay with AP3X via on-chain bonded escrow on Vector L2, and receive signed-receipt responses. The primary validation goal is **technical feasibility + lifecycle** ("can it be done end-to-end"), not demand modelling or economic stress testing.
|
**What this is.** A two-sided marketplace where buyers (agents or humans) submit prompts to suppliers running inference on their own hardware, with each job settled in AP3X through on-chain bonded escrow on Vector L2, and receive signed-receipt responses. The primary validation goal is **technical feasibility + lifecycle** ("can it be done end-to-end"), not demand modelling or economic stress testing.
|
||||||
|
|
||||||
**What v1 is NOT.**
|
**What v1 is NOT.**
|
||||||
- Not agentic on the supplier side. Suppliers answer single prompts. Buyer agents drive multiple prompts.
|
- Not agentic on the supplier side. Suppliers answer single prompts. Buyer agents drive multiple prompts.
|
||||||
- Not LLM-only in design. Capability model is open from day 1 (whisper / kokoro / image generation are planned siblings), but the first supplier is an LLM.
|
- Not LLM-only in design. Capability model is open from day 1 (whisper / kokoro / image generation are planned siblings), but the first supplier is an LLM.
|
||||||
- Not multi-slot. Single-slot per supplier process. Multi-capability operators run multiple processes.
|
- Not multi-slot. Single-slot per supplier process. Multi-capability operators run multiple processes.
|
||||||
- Not multi-chain. Vector testnet first, mainnet later. Masumi / Sokosumi / USDM is target #2 (adapter, not fork).
|
- Not multi-chain. Vector only (testnet and mainnet).
|
||||||
- Not disputed. M1 ships happy-path escrow only. Module-1 disputes wire in M2.
|
- Not disputed. M1 ships happy-path escrow only. Module-1 disputes wire in M2.
|
||||||
- Not streaming. `stream: true` is rejected by suppliers.
|
- Not streaming. `stream: true` is rejected by suppliers.
|
||||||
- Not tool-calling. `tools` / `tool_choice` / `functions` are rejected by suppliers.
|
- Not tool-calling. `tools` / `tool_choice` / `functions` are rejected by suppliers.
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
| # | Decision |
|
| # | Decision |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 1 | Payment token: **AP3X** (Vector testnet, later mainnet). Masumi = USDM, target #2. |
|
| 1 | Settlement asset: **AP3X**, bonded in escrow; jobs settle in AP3X on accepted work. |
|
||||||
| 2 | v1 scope: **Option C** — happy-path escrow only, no disputes. Module-1 wiring is milestone 2. |
|
| 2 | v1 scope: **Option C** — happy-path escrow only, no disputes. Module-1 wiring is milestone 2. |
|
||||||
| 3 | Module-1 contract source: TBD — to be located or restored before M2. Not blocking M0–M1. |
|
| 3 | Module-1 contract source: TBD — to be located or restored before M2. Not blocking M0–M1. |
|
||||||
| 4 | Evidence: **signed receipt** `{prompt_hash, response_hash, model, tokens, wallclock}` + Ed25519 supplier signature. Hash on-chain, plaintext off-chain. |
|
| 4 | Evidence: **signed receipt** `{prompt_hash, response_hash, model, tokens, wallclock}` + Ed25519 supplier signature. Hash on-chain, plaintext off-chain. |
|
||||||
@@ -283,7 +283,6 @@ Malformed datum · escrow to wrong script · supplier claims escrow addressed to
|
|||||||
| **M1** | Happy-path end-to-end, Hetzner CPU supplier | 2–3 wks | `qwen2.5:0.5b` on Hetzner, buyer posts → supplier claims → submits → buyer accepts; full Vector testnet lifecycle; indexer serves discovery |
|
| **M1** | Happy-path end-to-end, Hetzner CPU supplier | 2–3 wks | `qwen2.5:0.5b` on Hetzner, buyer posts → supplier claims → submits → buyer accepts; full Vector testnet lifecycle; indexer serves discovery |
|
||||||
| **M2** | Module-1 dispute wiring | 2–3 wks post-M1 | Contract source located; supplier=claimer / buyer=auditor mapping; Dispute state; evidence URI; mainnet-dry-run lifecycle |
|
| **M2** | Module-1 dispute wiring | 2–3 wks post-M1 | Contract source located; supplier=claimer / buyer=auditor mapping; Dispute state; evidence URI; mainnet-dry-run lifecycle |
|
||||||
| **M3** | Capability expansion + local lab | 2–4 wks (may overlap M2) | `speech.transcribe.v1` via Whisper live; local-lab GPU supplier with larger model; heterogeneous discovery validated |
|
| **M3** | Capability expansion + local lab | 2–4 wks (may overlap M2) | `speech.transcribe.v1` via Whisper live; local-lab GPU supplier with larger model; heterogeneous discovery validated |
|
||||||
| **M4** | Masumi / Sokosumi adapter | later | MIP-003 HTTP shim; MIP-002 NFT registration; USDM payment path; Sokosumi listing |
|
|
||||||
|
|
||||||
## 9. Open follow-ups (not blocking M0 / M1)
|
## 9. Open follow-ups (not blocking M0 / M1)
|
||||||
|
|
||||||
@@ -310,5 +309,3 @@ Malformed datum · escrow to wrong script · supplier claims escrow addressed to
|
|||||||
- Apex-dashboard CBOR decoders: `apex-dashboard/server/cbor-decoder.ts`
|
- Apex-dashboard CBOR decoders: `apex-dashboard/server/cbor-decoder.ts`
|
||||||
- Apex-dashboard test discipline: `apex-dashboard/tests/`
|
- Apex-dashboard test discipline: `apex-dashboard/tests/`
|
||||||
- Prior Python reference (AP3X buyer/supplier): `./docs/buyer/`, `./docs/supplier/`
|
- Prior Python reference (AP3X buyer/supplier): `./docs/buyer/`, `./docs/supplier/`
|
||||||
- Masumi docs: https://docs.masumi.network/
|
|
||||||
- MIP-003 spec: https://docs.masumi.network/mips/_mip-003
|
|
||||||
|
|||||||
@@ -1,412 +0,0 @@
|
|||||||
---
|
|
||||||
marp: true
|
|
||||||
paginate: true
|
|
||||||
size: 16:9
|
|
||||||
---
|
|
||||||
|
|
||||||
<!--
|
|
||||||
EXECUTIVE_SUMMARY.md — slide-style overview of the Local Agents Marketplace.
|
|
||||||
Build PDF + PPTX with: bash docs/build-deck.sh
|
|
||||||
Edit this file; mermaid blocks are pre-rendered to docs/img/diagram-*.png.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* ── Local Agents Marketplace — unified dark theme ────────────────────
|
|
||||||
* Every slide uses the same navy→indigo gradient as the title slide so
|
|
||||||
* the deck reads as one continuous document. Title and closing slides
|
|
||||||
* keep the centered hero treatment; content slides stay top-aligned but
|
|
||||||
* share the same palette and accent rules. */
|
|
||||||
:root {
|
|
||||||
--ink: #f1f5f9; /* primary text on dark gradient */
|
|
||||||
--muted: #94a3b8;
|
|
||||||
--accent: #818cf8; /* indigo-400, readable on the dark gradient */
|
|
||||||
--accent-deep: #4f46e5; /* used for solid accents (h2 bar, table head) */
|
|
||||||
--accent-soft: rgba(129,140,248,0.12);
|
|
||||||
--gradient: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #312e81 100%);
|
|
||||||
--row-even: rgba(255,255,255,0.04);
|
|
||||||
--row-odd: rgba(255,255,255,0.02);
|
|
||||||
--border: rgba(255,255,255,0.10);
|
|
||||||
}
|
|
||||||
section {
|
|
||||||
background: var(--gradient);
|
|
||||||
color: var(--ink);
|
|
||||||
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
|
|
||||||
font-size: 22px;
|
|
||||||
padding: 40px 60px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
section::after {
|
|
||||||
content: attr(data-marpit-pagination) ' / ' attr(data-marpit-pagination-total);
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
font-size: 48px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #ffffff;
|
|
||||||
border-bottom: 4px solid var(--accent);
|
|
||||||
padding-bottom: 10px;
|
|
||||||
margin: 0 0 18px 0;
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
font-size: 34px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #ffffff;
|
|
||||||
margin: 0 0 14px 0;
|
|
||||||
}
|
|
||||||
h2::before {
|
|
||||||
content: '';
|
|
||||||
display: inline-block;
|
|
||||||
width: 6px;
|
|
||||||
height: 28px;
|
|
||||||
background: var(--accent);
|
|
||||||
margin-right: 14px;
|
|
||||||
vertical-align: middle;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
h3 { color: #c7d2fe; font-weight: 600; font-size: 24px; margin: 8px 0; }
|
|
||||||
p, li { margin: 6px 0; line-height: 1.45; }
|
|
||||||
ul, ol { margin: 8px 0 12px 0; padding-left: 28px; }
|
|
||||||
strong { color: #ffffff; }
|
|
||||||
a { color: #a5b4fc; }
|
|
||||||
blockquote {
|
|
||||||
border-left: 4px solid var(--accent);
|
|
||||||
background: rgba(255,255,255,0.06);
|
|
||||||
padding: 10px 18px;
|
|
||||||
color: #e0e7ff;
|
|
||||||
border-radius: 0 8px 8px 0;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
code {
|
|
||||||
background: rgba(255,255,255,0.10);
|
|
||||||
color: #e0e7ff;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
/* Constrain mermaid images so they always fit on a 720px-tall slide
|
|
||||||
* (16:9 @ 1280×720 default). Soft white card behind the diagram so the
|
|
||||||
* mermaid SVG (which renders on a transparent canvas with dark ink)
|
|
||||||
* stays readable on the navy gradient. */
|
|
||||||
img {
|
|
||||||
display: block;
|
|
||||||
margin: 8px auto;
|
|
||||||
max-width: 92%;
|
|
||||||
max-height: 420px;
|
|
||||||
object-fit: contain;
|
|
||||||
background: #ffffff;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 10px;
|
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
|
|
||||||
}
|
|
||||||
/* Tile grid for the local-agent versatility slide. */
|
|
||||||
.backends {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 14px;
|
|
||||||
margin: 14px 0 12px 0;
|
|
||||||
}
|
|
||||||
.tile {
|
|
||||||
background: var(--accent-soft);
|
|
||||||
border: 2px solid var(--accent);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
text-align: center;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
.tile small {
|
|
||||||
display: block;
|
|
||||||
margin-top: 4px;
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.8em;
|
|
||||||
}
|
|
||||||
.tile.dots {
|
|
||||||
background: rgba(255,255,255,0.04);
|
|
||||||
border-style: dashed;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 28px;
|
|
||||||
}
|
|
||||||
.pipeline {
|
|
||||||
text-align: center;
|
|
||||||
background: rgba(255,255,255,0.08);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: #f8fafc;
|
|
||||||
padding: 12px 18px;
|
|
||||||
border-radius: 10px;
|
|
||||||
margin: 6px auto 14px auto;
|
|
||||||
max-width: 760px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.pipeline span {
|
|
||||||
display: inline-block;
|
|
||||||
margin: 0 10px;
|
|
||||||
}
|
|
||||||
/* Tables sit on a solid white card so cell text reads cleanly against
|
|
||||||
* the dark gradient backdrop. Rounded outer corners + shadow match the
|
|
||||||
* mermaid card styling. `display:table` + `table-layout:fixed` are
|
|
||||||
* needed so the table actually consumes the full slide width — Marp's
|
|
||||||
* default theme leaves tables at content-natural width, which left
|
|
||||||
* blank gutters on either side. */
|
|
||||||
table {
|
|
||||||
display: table !important;
|
|
||||||
width: 100% !important;
|
|
||||||
table-layout: fixed;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin: 12px 0;
|
|
||||||
font-size: 0.82em;
|
|
||||||
color: #0f172a;
|
|
||||||
background: #ffffff;
|
|
||||||
border-radius: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
|
|
||||||
}
|
|
||||||
td, th { word-wrap: break-word; overflow-wrap: anywhere; }
|
|
||||||
th {
|
|
||||||
background: var(--accent-deep);
|
|
||||||
color: #ffffff;
|
|
||||||
text-align: left;
|
|
||||||
padding: 9px 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
td {
|
|
||||||
padding: 7px 12px;
|
|
||||||
border-bottom: 1px solid #e2e8f0;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
tr:nth-child(even) td { background: #ffffff; }
|
|
||||||
tr:nth-child(odd) td { background: #f8fafc; }
|
|
||||||
/* code spans inside table cells need a light pill, not the dark one
|
|
||||||
* defined globally — otherwise they'd disappear into the white row. */
|
|
||||||
td code {
|
|
||||||
background: #eef2ff;
|
|
||||||
color: #1e293b;
|
|
||||||
}
|
|
||||||
/* Bold/strong inside cells must stay dark — the global rule paints
|
|
||||||
* <strong> white for the dark-gradient body text, which would render
|
|
||||||
* invisible against the white table card. */
|
|
||||||
td strong, th strong {
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
th strong {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
/* Title slide — centered hero treatment on the same gradient. */
|
|
||||||
section.title {
|
|
||||||
text-align: center;
|
|
||||||
padding: 100px 80px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
section.title h1 {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 64px;
|
|
||||||
margin: 0 auto 20px auto;
|
|
||||||
}
|
|
||||||
section.title blockquote {
|
|
||||||
margin: 28px auto;
|
|
||||||
max-width: 900px;
|
|
||||||
}
|
|
||||||
/* Closing slide — same centered treatment. */
|
|
||||||
section.closing {
|
|
||||||
text-align: center;
|
|
||||||
padding: 100px 80px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
section.closing h2::before { display: none; }
|
|
||||||
section.closing blockquote { font-size: 26px; max-width: 900px; }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<!-- _class: title -->
|
|
||||||
|
|
||||||
# Local Agents Marketplace
|
|
||||||
|
|
||||||
### A trust-minimised, on-chain marketplace for LLM inference
|
|
||||||
|
|
||||||
Buy and sell prompt → response with **bonded escrow** on **Vector** (UTxO).
|
|
||||||
No platform fee. No central rate-limiter. Verifiable receipts on chain.
|
|
||||||
|
|
||||||
> *Status: M1 lifecycle proven end-to-end on Vector testnet — 2026-05-05.*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The problem
|
|
||||||
|
|
||||||
* AI inference is **centralised** — a handful of providers, opaque pricing, account-level rate limits.
|
|
||||||
* Local-AI hardware is **idle most of the time** — GPUs and CPUs sit unused between user prompts.
|
|
||||||
* Buyers and sellers can't transact directly without a **trusted intermediary** taking a cut.
|
|
||||||
* "Verifiable" claims about which model ran are usually nothing more than the provider's word.
|
|
||||||
|
|
||||||
We need a **two-sided marketplace** where:
|
|
||||||
1. Sellers monetise spare compute,
|
|
||||||
2. Buyers get a **cryptographic receipt** of what was actually computed,
|
|
||||||
3. **Neither side has to trust the other** — bonds + on-chain settlement enforce honesty.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How it works — three actors, one chain
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
subgraph CHAIN[Vector — UTxO]
|
|
||||||
ADV[Advert UTxO<br/>capability, price, bonds]
|
|
||||||
ESC[Escrow UTxO<br/>locked funds + state]
|
|
||||||
end
|
|
||||||
|
|
||||||
SUP[🧠 Supplier<br/>local LLM node]
|
|
||||||
BUY[💼 Buyer<br/>app or agent]
|
|
||||||
IDX[🔭 Indexer<br/>read-only bulletin board]
|
|
||||||
|
|
||||||
SUP -->|publish advert| ADV
|
|
||||||
BUY -->|discover| IDX
|
|
||||||
IDX -.->|index events| CHAIN
|
|
||||||
BUY -->|post escrow| ESC
|
|
||||||
BUY -->|prompt + escrow_ref| SUP
|
|
||||||
SUP -->|signed receipt| BUY
|
|
||||||
SUP -->|claim & submit| ESC
|
|
||||||
BUY -->|accept & pay| ESC
|
|
||||||
```
|
|
||||||
|
|
||||||
* **Supplier** runs the inference and publishes a price.
|
|
||||||
* **Buyer** posts the work as a bonded escrow, sends the prompt, accepts the receipt.
|
|
||||||
* **Indexer** is a public mirror of chain state — anyone can run one. No platform middleman.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The bonded-escrow lifecycle
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
autonumber
|
|
||||||
participant B as Buyer
|
|
||||||
participant Chain as Vector
|
|
||||||
participant S as Supplier
|
|
||||||
participant LLM as Local LLM<br/>(OpenAI-compatible)
|
|
||||||
|
|
||||||
S->>Chain: PostAdvert (locks supplier_bond)
|
|
||||||
B->>Chain: PostEscrow → Open<br/>(locks payment + bonds)
|
|
||||||
B->>S: POST /v1/chat/completions
|
|
||||||
S->>Chain: ClaimEscrow → Claimed
|
|
||||||
S->>LLM: run inference
|
|
||||||
LLM-->>S: assistant message
|
|
||||||
S->>Chain: SubmitEscrow → Submitted
|
|
||||||
S-->>B: 200 OK + signed receipt
|
|
||||||
B->>Chain: AcceptEscrow — supplier paid
|
|
||||||
```
|
|
||||||
|
|
||||||
Every transition is on-chain, validated by an Aiken Plutus V3 script. The receipt is signed by the supplier's advert key; prompt and response hashes are bound on chain.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What if someone misbehaves?
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
OPEN[Open]:::norm --> CLAIMED[Claimed]:::norm --> SUBMITTED[Submitted]:::norm --> ACCEPTED[Accepted ✓]:::good
|
|
||||||
OPEN -->|deliver_by passed| RECLAIM[Reclaimed<br/>buyer recovers funds]:::warn
|
|
||||||
CLAIMED -->|deliver_by passed| RECLAIM
|
|
||||||
SUBMITTED -->|accept_window passed| RELEASE[Released<br/>supplier collects]:::warn
|
|
||||||
|
|
||||||
classDef norm fill:#e0e7ff,stroke:#4f46e5
|
|
||||||
classDef good fill:#dcfce7,stroke:#16a34a
|
|
||||||
classDef warn fill:#fef3c7,stroke:#d97706
|
|
||||||
```
|
|
||||||
|
|
||||||
| Failure mode | Recovery | Loss bearer |
|
|
||||||
|---|---|---|
|
|
||||||
| Supplier never claims/submits | Buyer **Reclaim** after `deliver_by` | Supplier — forfeits supplier_bond |
|
|
||||||
| Buyer disappears after Submit | Supplier **Release** after `submitted_at + 10 min` | Buyer — forfeits buyer_bond |
|
|
||||||
|
|
||||||
Bonds make honesty cheaper than running away. Both sides have skin in the game; no third-party arbiter is needed for the M1 happy/timeout paths.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Local-agent versatility — any OpenAI-compatible engine
|
|
||||||
|
|
||||||
<div class="pipeline">
|
|
||||||
<span>💼 Buyer</span> →
|
|
||||||
<span>🧠 Supplier (OpenAI-compatible HTTP wrapper)</span> →
|
|
||||||
<span>backend ↓</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="backends">
|
|
||||||
<div class="tile">Ollama<br/><small>local model runner</small></div>
|
|
||||||
<div class="tile">vLLM<br/><small>high-throughput GPU</small></div>
|
|
||||||
<div class="tile">llama.cpp<br/><small>CPU · Metal · ROCm</small></div>
|
|
||||||
<div class="tile">OpenRouter<br/><small>cloud API mux</small></div>
|
|
||||||
<div class="tile">openclaw / custom<br/><small>arbitrary stack</small></div>
|
|
||||||
<div class="tile dots">…<br/><small>any OpenAI-shaped API</small></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
The supplier service is a thin HTTP wrapper — it forwards `/v1/chat/completions` to whatever backend is configured. Each supplier picks its own model, hardware, and engine, then advertises a `capability_id`. Buyers shop on capability + model + price.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Capability + model = task selection
|
|
||||||
|
|
||||||
| Capability ID | Example models | Use case |
|
|
||||||
|---|---|---|
|
|
||||||
| `llm.text.generate.v1` | Llama 3 · Qwen 2.5 · Mistral · GPT-4 | Q&A, drafts, summarisation |
|
|
||||||
| `code.completion.v1` | Codestral · DeepSeek-Coder · StarCoder | IDE tab-complete, refactor |
|
|
||||||
| `vision.describe.v1` | LLaVA · Qwen-VL · Pixtral | Image captioning, OCR |
|
|
||||||
| `embedding.text.v1` | nomic-embed · BGE · ada-002 | RAG indexing |
|
|
||||||
| `audio.transcribe.v1` | whisper-large-v3 · distil-whisper | Voice-to-text |
|
|
||||||
| `…` | … | … |
|
|
||||||
|
|
||||||
* New capabilities = **just publish a new advert**. No marketplace governance gate.
|
|
||||||
* Today's deploy uses Ollama + Qwen 2.5; switching to vLLM with Llama 3 70B is a config change, not a code change.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## OpenClaw — agentic teams on your existing OpenAI seat
|
|
||||||
|
|
||||||
<div class="pipeline">
|
|
||||||
<span>🧠 Supplier wrapper</span> →
|
|
||||||
<span>🦾 OpenClaw</span> →
|
|
||||||
<span>🪪 OpenAI ChatGPT seat (flat-rate)</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
* **Cost-efficient by default.** OpenClaw exposes your existing **OpenAI ChatGPT subscription** as an OpenAI-compatible API. The marketplace supplier dispatches its inference against it, so an entire **agentic team** (coders, QA automation testers, release curators…) draws from the **flat-rate seat you already pay for** — no per-token metering, no surprise invoices.
|
|
||||||
* **Reliability layer (optional, marginal cost).** OpenClaw can fall back to the **Anthropic API** or **OpenRouter** model pool when the primary seat is rate-limited, the prompt requires a different model, or you want a **second-layer hardening** of responses (e.g. cross-provider consensus on critical outputs). You only pay metered tokens for the requests that actually fall through.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Proven on Vector testnet — 2026-05-05
|
|
||||||
|
|
||||||
| Step | Tx hash |
|
|
||||||
|---|---|
|
|
||||||
| Buyer posts escrow | `af762561…c2f7bc5` |
|
|
||||||
| Supplier claims | (lifecycle tx) |
|
|
||||||
| Supplier submits receipt | (lifecycle tx) |
|
|
||||||
| Buyer accepts | `125e3cfe…7dc100d` |
|
|
||||||
|
|
||||||
Funds flowed correctly: supplier received `payment + supplier_bond` (3 ADA); buyer got `buyer_bond` back.
|
|
||||||
|
|
||||||
* **Browser demo:** https://mp-buyers.vector.testnet.apexfusion.org
|
|
||||||
* **Live event stream:** https://mp-indexer.vector.testnet.apexfusion.org
|
|
||||||
* **Test suite:** 1098 unit tests passing, full lifecycle exercised in CI.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Why UTxO + Vector
|
|
||||||
|
|
||||||
* **Bonded escrow is native** — Plutus validators enforce the state machine without a custom token or oracle.
|
|
||||||
* **Vector settles in seconds** — the buyer→claim→submit→accept loop fits inside a single chat-completion's wall clock.
|
|
||||||
* **AP3X token** for fees and bonds — fungible, transparent, on-chain.
|
|
||||||
* **No custom rollup, no bridge** — runs on a production chain today.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<!-- _class: closing -->
|
|
||||||
|
|
||||||
## The pitch in one sentence
|
|
||||||
|
|
||||||
> Local Agents Marketplace turns any OpenAI-compatible inference endpoint into a **trustlessly billable service** — buyer and supplier exchange prompt, response, and payment with on-chain proof that what was paid for is what was delivered.
|
|
||||||
|
|
||||||
### Try it
|
|
||||||
|
|
||||||
* **Indexer (live event stream):** https://mp-indexer.vector.testnet.apexfusion.org
|
|
||||||
* **Buyer:** https://mp-buyers.vector.testnet.apexfusion.org
|
|
||||||
* **Supplier:** https://mp-suppliers.vector.testnet.apexfusion.org/capability
|
|
||||||
Binary file not shown.
Binary file not shown.
133
docs/HETZNER_SETUP.md
Normal file
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.
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
# Vector Buyer App
|
# Vector Buyer App
|
||||||
|
|
||||||
A self-contained app for purchasing AI compute on the Vector L2 testnet (Cardano). You submit prompts, pay with AP3X tokens via on-chain escrow, and receive LLM responses from supplier nodes.
|
A self-contained app for commissioning AI compute on the Vector L2 testnet. You submit prompts, bond AP3X in on-chain escrow, and receive LLM responses from supplier nodes; the escrow settles in AP3X on accepted work.
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
1. You type a **prompt** and set a **payment** amount in the web UI
|
1. You type a **prompt** and set a **price** in the web UI
|
||||||
2. The app posts an **escrow transaction** on-chain, locking your payment + bond
|
2. The app posts an **escrow transaction** on-chain, locking price + bond
|
||||||
3. The app sends the task to a **supplier node** via HTTP
|
3. The app sends the task to a **supplier node** via HTTP
|
||||||
4. The supplier calls an LLM, **claims** the escrow, and **submits** the result hash on-chain
|
4. The supplier calls an LLM, **claims** the escrow, and **submits** the result hash on-chain
|
||||||
5. You see the response in the UI and can **accept** to release payment, or **dispute**
|
5. You see the response in the UI and can **accept** to release payment, or **dispute**
|
||||||
@@ -193,8 +193,8 @@ If you accept the result, you get your 1 AP3X bond back. Total cost = payment +
|
|||||||
|
|
||||||
- **Never share your .env file** - it contains your wallet seed phrase
|
- **Never share your .env file** - it contains your wallet seed phrase
|
||||||
- **Never share your data/ folder** - it contains task history
|
- **Never share your data/ folder** - it contains task history
|
||||||
- The seed phrase controls your wallet funds. Back it up securely.
|
- The seed phrase controls your wallet. Back it up securely.
|
||||||
- Escrow protects you: the supplier cannot take your funds without delivering a result
|
- Escrow protects you: the supplier cannot take your escrowed value without delivering a result
|
||||||
- You have a 10-minute dispute window after receiving a result
|
- You have a 10-minute dispute window after receiving a result
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|||||||
155
docs/gateway.md
Normal file
155
docs/gateway.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
# OpenAI-Compatible API Gateway for the Vector Marketplace
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Today the only way to send a prompt to the marketplace is the single-operator **buyer** web app (cookie auth, one shared wallet). We want external developers to use **any OpenAI SDK** against the marketplace. Design (locked through interview + a 5-lens adversarial audit of an earlier draft):
|
||||||
|
|
||||||
|
- **Public, multi-tenant, custodial gateway.** Each API key is its **own buyer wallet** (own Ed25519 key, held AES-GCM-encrypted by the gateway). The user funds their own deposit address with AP3X; each request posts an on-chain escrow **from that user's wallet**. Not the deployed operator buyer.
|
||||||
|
- **Two surfaces, two escrow models:**
|
||||||
|
- **One-shot completions** — `POST /openai/v1/chat/completions` (stateless, OpenAI-standard). Routes to an `llm.text.generate.v1` supplier. **One escrow per call.** `stream:true` → buffered pseudo-stream.
|
||||||
|
- **Chat sessions** — a Vector REST extension. Routes to an `llm.chat.v1` supplier. **One escrow per session**: fixed price locked at open, **real token streaming** per turn (no per-turn charge), settled at session close.
|
||||||
|
- **Routing matches capability + model, not cheapest.** Per-prompt cost = the matched supplier's advertised price.
|
||||||
|
- **Gateway auto-maintains each user's wallet** (collateral + consolidation) so requests past #1 keep working.
|
||||||
|
- Onboarding is **self-serve via a small web UI**; the response is a **spec-clean OpenAI body + `x_vector` receipt extension**; custody is protected with **settle-on-success + a state-aware reclaim sweeper + a withdrawal path**.
|
||||||
|
|
||||||
|
The audit confirmed 39 holes in the first draft (10 distinct themes). This document incorporates every fix; the "Audit-driven corrections" section maps each theme to its resolution.
|
||||||
|
|
||||||
|
## Locked decisions
|
||||||
|
|
||||||
|
| Area | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Scope | Public multi-tenant OpenAI-compatible gateway; new `gateway/` pnpm package reusing the buyer `Marketplace` SDK |
|
||||||
|
| Identity | Each API key = its own custodial buyer wallet |
|
||||||
|
| Custody | AES-256-GCM, `GATEWAY_MASTER_KEY` (env), per-row nonce; decrypt in-memory at request time; **rotation CLI**; **withdrawal endpoint** is the user exit |
|
||||||
|
| One-shot | `/openai/v1/chat/completions` → `llm.text.generate.v1`, 1 escrow/call, buffered (+ pseudo-stream) |
|
||||||
|
| Chat session | `/openai/v1/chat/sessions*` → `llm.chat.v1`, 1 escrow/**session**, real SSE per turn, settle at close |
|
||||||
|
| Routing | match `model` + required capability; pick an available supplier (status `free`\|`unknown`); **not cheapest**; fallback to next match on busy/offline |
|
||||||
|
| Pricing | cost = matched supplier `price_lovelace`; **required balance = price + buyer_bond + supplier_bond + 5 AP3X collateral + fee reserve** |
|
||||||
|
| Wallet health | gateway **auto-consolidates** each wallet to `{5-AP3X collateral, working}` after settle / on a tick |
|
||||||
|
| Settlement | resolve the **Submitted** ref via indexer, `acceptResult`, **await confirm** before returning success; sweeper is backstop |
|
||||||
|
| Sweeper | state-aware: Open/Claimed past `deliver_by` → reclaim; Submitted → retry Accept in-window; never blind-reclaim Submitted |
|
||||||
|
| Response | OpenAI object `{id, object, created, model, choices, usage}` + `x_vector:{receipt, receipt_signature, escrow_ref}`; full streaming chunk transform + final usage chunk |
|
||||||
|
| Hardening | `/signup` IP rate-limit, per-key request rate-limit, `SdkRegistry` LRU cap, structured logging + audit trail |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
New standalone pnpm package `gateway/` (add to `pnpm-workspace.yaml`). Boots **one** shared `ChainProvider` (Ogmios, `LIVE_CHAIN=1`) + indexer URL, and an `SdkRegistry` that lazily builds **one `Marketplace` per API key** bound to that user's `WalletKey` (LRU-capped, evicted by idle). All on-chain work for a key is serialized by a per-key promise-chain mutex.
|
||||||
|
|
||||||
|
### Verified reuse points (do not re-implement)
|
||||||
|
- `Marketplace` ctor `MarketplaceOpts{chain, indexerUrl, walletKey, networkParams, historyStore?}` — `buyer/src/sdk/Marketplace.ts:117`. Per-user field is only `walletKey`.
|
||||||
|
- `deriveWalletKey` + ed25519 hook — `buyer/src/index.ts:24-64`.
|
||||||
|
- SDK methods: `discoverSuppliers`, `submitPrompt` (`Marketplace.ts:242`, verifies receipt but **does not settle**), `startChat`/`endChat` (chat session; `endChat` returns `acceptedRef` and awaits Accept), `acceptResult`, `reclaim`.
|
||||||
|
- Escrow value/bonds: buyer funds `totalLocked = price + buyer_bond + supplier_bond` at PostEscrow — `packages/shared/src/tx/escrow/postEscrow.ts:132-200`. On Accept supplier gets `payment+supplier_bond`, buyer gets `buyer_bond` back.
|
||||||
|
- Submitted-ref resolution: the buyer `/v1/accept` queries the indexer and matches the Submitted row by `posted_at` — `buyer/src/server.ts` `/v1/accept`. Gateway must mirror this.
|
||||||
|
- Wallet health: `planConsolidate` / `consolidateWallet` (`{collateral, working}` shape, `DEFAULT_COLLATERAL_LOVELACE = 5 AP3X`) — `packages/shared/src/tx/wallet/`. Collateral assertion: `assertCollateralCandidate` — `packages/shared/src/tx/internal/liveCbor.ts`.
|
||||||
|
- Supplier chat-session SSE `{type:"token"|"done"|"error"}` + headers — `supplier/src/server.ts` `makeChatSessionHandlers`; buyer relay in `buyer/src/server.ts` `/v1/chat/message` (note: **verbatim** passthrough — the gateway must transform to OpenAI chunks).
|
||||||
|
- SQLite pattern (`better-sqlite3`, WAL) — `buyer/src/db/archive.ts`. Auth helpers `timingSafeCompareStrings`, `createRateLimiter` — `buyer/src/auth.ts`.
|
||||||
|
- Indexer `SupplierView` (`model`, `capability_id`, `price_lovelace`, `buyer_bond_lovelace`, `supplier_bond_lovelace`, `status` free/working/offline/unknown, `advert_status`) — `indexer/src/routes/suppliers.ts`.
|
||||||
|
|
||||||
|
## Package layout (`gateway/`)
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
index.ts # boot: config → shared chain/indexer → GatewayStore → SdkRegistry → app → listen + sweeper + wallet-health tick
|
||||||
|
config.ts # GATEWAY_MASTER_KEY(64hex), INDEXER_URL, OGMIOS_URL, LIVE_CHAIN, NETWORK_ID, GATEWAY_PORT, GATEWAY_DB_DIR, rate-limit knobs
|
||||||
|
wallet.ts # deriveWalletKey + ed25519 hook + genPrivKey
|
||||||
|
crypto/seal.ts # aes-256-gcm seal()/open(), per-row nonce
|
||||||
|
db/{schema.ts,store.ts} # api_keys, usage, sessions
|
||||||
|
sdk/registry.ts # per-key Marketplace cache (LRU) + per-key Mutex
|
||||||
|
routing/selectSupplier.ts # indexer query → capability+model match → available → fallback
|
||||||
|
openai/{shapes.ts,errors.ts,chatCompletions.ts,sessions.ts,models.ts}
|
||||||
|
account/routes.ts # POST /signup, GET /account, POST /account/withdraw
|
||||||
|
middleware/apiKeyAuth.ts, middleware/rateLimit.ts
|
||||||
|
walletHealth.ts # post-settle + ticker consolidation (reuse planConsolidate)
|
||||||
|
sweeper.ts # state-aware reclaim/accept-retry
|
||||||
|
ui/index.html
|
||||||
|
cli/rotate-master-key.ts # re-encrypt all rows old→new master key
|
||||||
|
```
|
||||||
|
Build wiring: add `"exports": { "./sdk": "./dist/sdk/index.js" }` to `buyer/package.json`, depend `@marketplace/buyer: workspace:*` + `@marketplace/shared: workspace:*`.
|
||||||
|
|
||||||
|
## SQLite schema
|
||||||
|
```sql
|
||||||
|
CREATE TABLE api_keys ( id TEXT PRIMARY KEY, key_hash TEXT UNIQUE NOT NULL, key_prefix TEXT NOT NULL, label TEXT,
|
||||||
|
wallet_pkh TEXT NOT NULL, deposit_address TEXT NOT NULL,
|
||||||
|
enc_priv_nonce TEXT NOT NULL, enc_priv_ct TEXT NOT NULL, enc_priv_tag TEXT NOT NULL,
|
||||||
|
master_key_version INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, disabled INTEGER NOT NULL DEFAULT 0 );
|
||||||
|
CREATE TABLE usage ( id TEXT PRIMARY KEY, key_id TEXT NOT NULL, created_at INTEGER NOT NULL, kind TEXT NOT NULL,
|
||||||
|
model TEXT, capability_id TEXT, supplier_pkh TEXT, escrow_ref TEXT, cost_lovelace TEXT,
|
||||||
|
prompt_tokens INTEGER DEFAULT 0, completion_tokens INTEGER DEFAULT 0, status TEXT NOT NULL, failure_reason TEXT );
|
||||||
|
CREATE TABLE sessions ( id TEXT PRIMARY KEY, key_id TEXT NOT NULL, escrow_ref TEXT NOT NULL, session_nonce TEXT NOT NULL,
|
||||||
|
supplier_base_url TEXT NOT NULL, model TEXT NOT NULL, state TEXT NOT NULL, opened_at INTEGER NOT NULL, closed_at INTEGER );
|
||||||
|
```
|
||||||
|
Raw API key never persisted (only `sha256(key)`); `key_prefix` for display. `master_key_version` enables rotation.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
**One-shot (OpenAI standard):**
|
||||||
|
- `POST /openai/v1/chat/completions` — Bearer. Parse `{model, messages, max_tokens?, stream?}`; **reject** `tools`/`tool_choice`/`functions` with 400; **ignore** `temperature/n/stop/top_p/...` (documented). Lifecycle below.
|
||||||
|
- `GET /openai/v1/models` — distinct `model` over Active suppliers → OpenAI list shape.
|
||||||
|
|
||||||
|
**Chat sessions (Vector extension — documented as non-standard):**
|
||||||
|
- `POST /openai/v1/chat/sessions {model}` — open: route to `llm.chat.v1`, `startChat` (lock fixed price), persist session row → `{session_id, model}`.
|
||||||
|
- `POST /openai/v1/chat/sessions/{id}/messages {messages|content, stream}` — real SSE turn; transform supplier `{type:token}` → `chat.completion.chunk`; final usage chunk + `[DONE]`. No new escrow.
|
||||||
|
- `POST /openai/v1/chat/sessions/{id}/close` — settle: `endChat` (Submit+Accept, awaits), record usage, then run wallet-health.
|
||||||
|
|
||||||
|
**Account / UI:**
|
||||||
|
- `POST /signup {label?}` — IP rate-limited. Generate priv → derive → seal → store → `{api_key (once), deposit_address, key_prefix}`.
|
||||||
|
- `GET /account` — Bearer. `{deposit_address, balance:{available_lovelace, locked_in_escrow_lovelace, ap3x}, collateral_ok:bool, spend:{total_cost_lovelace, request_count}, recent_usage[]}`.
|
||||||
|
- `POST /account/withdraw {to_address, amount_lovelace?}` — Bearer. Build a wallet→address transfer (new `buildWithdrawTx` in packages/shared; repo has no send tx today) sending available funds out. **The custodial exit path.**
|
||||||
|
- `GET /` — self-serve UI (create key, copy deposit address, live balances, usage, withdraw).
|
||||||
|
|
||||||
|
## Request lifecycle — one-shot `/chat/completions`
|
||||||
|
All on-chain steps run inside the per-key `mutex.run(...)`.
|
||||||
|
1. **Route** (`selectSupplier`): indexer rows where `model` matches, `capability_id==="llm.text.generate.v1"`, `advert_status==="Active"`, live `status ∈ {free, unknown}`. Pick an available one (not cheapest). No match → 404 `model_not_found` (`type: invalid_request_error`).
|
||||||
|
2. **Pre-flight**: required = `price + buyer_bond + supplier_bond + 5_000_000 (collateral) + ~2 AP3X fee`. Verify total balance **and** a pure-AP3X UTxO ≥5 AP3X exists (`assertCollateralCandidate`-style). Else 402 `insufficient_funds` + `x_vector:{required_lovelace, available_lovelace, collateral_ok, deposit_address}`.
|
||||||
|
3. **Execute**: `submitPrompt({advertRef, messages, payment_lovelace:price, max_output_tokens})` (posts escrow → supplier → verifies receipt).
|
||||||
|
4. **Settle**: resolve the **Submitted** escrow ref via the indexer (mirror buyer `/v1/accept`: match by buyer pkh + `posted_at`), `acceptResult(submittedRef)`, **await confirmation**. On 409/offline mid-route, retry next matching supplier (bounded). On any post-escrow failure, record + leave for the sweeper (don't block the response on reclaim).
|
||||||
|
5. **Respond**: `{id:"chatcmpl-…", object:"chat.completion", created:<sec>, model, choices:[{index:0,message:{role:"assistant",content},finish_reason:"stop"}], usage:{prompt_tokens,completion_tokens,total_tokens}, x_vector:{receipt,receipt_signature,escrow_ref}}`. Record `usage`.
|
||||||
|
6. **Wallet health**: after settle, consolidate the wallet back to `{collateral, working}` (inside the mutex or queued).
|
||||||
|
7. `stream:true`: same flow; emit one (or chunked) `chat.completion.chunk` delta + final `finish_reason:"stop"` chunk **with `usage`** + `data: [DONE]`. Emit `: keepalive` SSE comments during the escrow-confirm gap.
|
||||||
|
|
||||||
|
## Chat-session lifecycle
|
||||||
|
- **open** → `startChat` (escrow at fixed price), store `{escrow_ref, session_nonce, supplier_base_url}`.
|
||||||
|
- **messages** → POST the turn to `${supplier_base_url}/v1/chat/message` with `X-Escrow-Ref`; transform each `{type:token,value}` → `chat.completion.chunk`; `{type:done}` → final chunk + usage + `[DONE]`. (System messages: fold into the first turn's content; documented limitation since chat.v1 has no system channel.)
|
||||||
|
- **close** → `endChat` (Submit + Accept, awaits) → settle + bond refund; record usage; wallet-health.
|
||||||
|
- Sessions are reclaimable by the sweeper if abandoned before close.
|
||||||
|
|
||||||
|
### Settle modes (`CHAT_SETTLE_MODE`, gateway + supplier, default `full`)
|
||||||
|
- **full** — the lifecycle above: supplier Claims at start, Submits a transcript receipt at end, gateway Accepts (buyer charged).
|
||||||
|
- **ticket** — the buyer's escrow post at open is the ONLY chain op. The supplier verifies the Open escrow as an entry ticket (`/v1/chat/start` → `{status:"ticket"}`) but never Claims/Submits; `/v1/chat/end` → `{status:"ended", settle_mode:"ticket"}` with no receipt; the sweeper Reclaims the Open escrow after `deliver_by`, returning all locked funds (net cost ≈ tx fees). Usage rows record `cost_lovelace: 0`. Sessions may outlive `deliver_by` (no hard-cap) — a live session whose ticket got reclaimed keeps running; only the idle janitor/close ends it. Close behavior is driven per-session by the supplier's `/end` response, so mixed-mode rollout windows settle correctly.
|
||||||
|
- Ticket caveats (accepted, demo-grade): a supplier restart forgets used tickets, so an unexpired un-reclaimed escrow ref could reopen a session (bounded by `deliver_by` + the sweep); `/account.locked_in_escrow_lovelace` over-reports because the indexer never updates spent rows.
|
||||||
|
- Suppliers additionally take `MAX_CHAT_SESSIONS` (default 1) — concurrent session slots; `/status` reports `active_sessions`/`max_sessions` and stays `free` until all slots are taken.
|
||||||
|
|
||||||
|
## Sweeper & wallet-health
|
||||||
|
- **Sweeper** (interval ~60s): per key, classify escrows. Open/Claimed past `deliver_by` → `reclaim`. Submitted **with a verified receipt** still in accept-window → retry `acceptResult`. Submitted past accept-window → leave for supplier Release (log). Never blind-`reclaim` Submitted.
|
||||||
|
- **Wallet-health tick**: periodically + after each settle, `planConsolidate` → `consolidateWallet` so every wallet keeps a ≥5 AP3X collateral UTxO. Skip if already healthy.
|
||||||
|
|
||||||
|
## Errors (OpenAI shape `{error:{message, type, code, param:null}}`)
|
||||||
|
no supplier → 404 `invalid_request_error`/`model_not_found`; underfunded/no-collateral → 402 `invalid_request_error`/`insufficient_funds`; bad/missing key → 401 `authentication_error`/`invalid_api_key`; rate limit → 429 `rate_limit_error` + `Retry-After`; supplier timeout → 504 `server_error`; supplier busy race (after retries) → 503 `server_error`/`overloaded`; receipt verify → 502 `server_error`/`receipt_verification_failed`; tx build → 409/502 `server_error`/`escrow_failed`; unsupported `tools`/`functions` → 400 `invalid_request_error`/`unsupported_parameter`.
|
||||||
|
|
||||||
|
## Audit-driven corrections (themes → resolution)
|
||||||
|
1. **Settlement/Submitted-ref + fire-and-forget Accept** → lifecycle step 4 (indexer resolution + awaited Accept); sweeper backstop.
|
||||||
|
2. **No withdrawal** → `POST /account/withdraw` + new `buildWithdrawTx`.
|
||||||
|
3. **Master-key SPOF / offline-stranding** → documented backup, `rotate-master-key` CLI (`master_key_version`), withdrawal as exit; trustless recovery (pre-signed reclaim / multisig) explicitly **out of scope**, documented as residual risk.
|
||||||
|
4. **Stateless↔stateful chat** → sessions are explicit (open/messages/close), one escrow/session; system-message folding documented.
|
||||||
|
5. **Collateral/fragmentation** → corrected required-balance formula + pre-flight collateral check + auto wallet-health consolidation.
|
||||||
|
6. **~0.5 req/min throughput** (awaitTx in mutex) → documented per-key SLA; "move awaitTx out of the lock" noted as fast-follow.
|
||||||
|
7. **Sweeper can't reclaim Submitted** → state-aware sweeper.
|
||||||
|
8. **OpenAI wire format** → full response object + chunk transform + final usage chunk + keepalive + error taxonomy + param policy.
|
||||||
|
9. **Hardening** → `/signup` IP limit, per-key request limit, `SdkRegistry` LRU cap, `status ∈ {free,unknown}` + freshness + fallback retry.
|
||||||
|
10. **Economics/observability** → v1 = **no on-chain margin** (operator subsidizes infra; a fee needs a separate transfer or off-chain credit — folded into the deferred "billing" phase); add structured per-request logging + audit (request_id, key_id, model, supplier_pkh, escrow_ref, status, latency) + basic metrics/alerts (indexer stale, Ogmios slow, sweeper idle).
|
||||||
|
- **Doc fix**: clarify the **off-chain tx builder** enforces `payment==advert.price`; the validator only enforces the state machine, not pricing.
|
||||||
|
|
||||||
|
## Out of scope for v1 (documented)
|
||||||
|
Real billing/markup & fee hook; trustless/self-custody recovery; multi-funding-UTxO optimization beyond the consolidation loop; moving `awaitTx` out of the per-key lock; real chat.v1 system-role support (needs a supplier API change).
|
||||||
|
|
||||||
|
## Verification (testnet, end-to-end)
|
||||||
|
1. Indexer + Ogmios + ≥1 `llm.text.generate.v1` and ≥1 `llm.chat.v1` supplier for the same `model`, Active.
|
||||||
|
2. Boot gateway: `LIVE_CHAIN=1`, `GATEWAY_MASTER_KEY=$(openssl rand -hex 32)`, `OGMIOS_URL`, `INDEXER_URL`, `NETWORK_ID`, `GATEWAY_DB_DIR`, `GATEWAY_PORT`.
|
||||||
|
3. `POST /signup` → key + deposit address. Fund ≥ price + both bonds + 5 AP3X collateral + ~2 AP3X fees; confirm via `GET /account` (`collateral_ok:true`).
|
||||||
|
4. One-shot non-stream via curl and via the OpenAI Python/JS SDK (`base_url=…/openai/v1`) → valid `chat.completion` + `x_vector`; confirm the escrow **settled (Accepted)** and a `usage` row exists.
|
||||||
|
5. **Run a 2nd and 3rd request** → confirm wallet-health kept `collateral_ok` and they succeed (the audit's request-#2 failure must not reproduce).
|
||||||
|
6. One-shot `stream:true` → chunks + final usage + `[DONE]`.
|
||||||
|
7. Chat session: open → stream 2–3 turns → close; confirm **one** escrow for the whole session, settled at close.
|
||||||
|
8. `POST /account/withdraw` → unspent AP3X returns to an external address.
|
||||||
|
9. Underfunded / no-collateral key → 402 with guidance. Unknown model → 404. `tools:[…]` → 400.
|
||||||
|
10. Kill gateway mid-request → restart → sweeper reclaims Open/Claimed and retries Accept on Submitted; balances recover.
|
||||||
@@ -1,412 +0,0 @@
|
|||||||
---
|
|
||||||
marp: true
|
|
||||||
paginate: true
|
|
||||||
size: 16:9
|
|
||||||
---
|
|
||||||
|
|
||||||
<!--
|
|
||||||
EXECUTIVE_SUMMARY.md — slide-style overview of the Local Agents Marketplace.
|
|
||||||
Build PDF + PPTX with: bash docs/build-deck.sh
|
|
||||||
Edit this file; mermaid blocks are pre-rendered to docs/img/diagram-*.png.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* ── Local Agents Marketplace — unified dark theme ────────────────────
|
|
||||||
* Every slide uses the same navy→indigo gradient as the title slide so
|
|
||||||
* the deck reads as one continuous document. Title and closing slides
|
|
||||||
* keep the centered hero treatment; content slides stay top-aligned but
|
|
||||||
* share the same palette and accent rules. */
|
|
||||||
:root {
|
|
||||||
--ink: #f1f5f9; /* primary text on dark gradient */
|
|
||||||
--muted: #94a3b8;
|
|
||||||
--accent: #818cf8; /* indigo-400, readable on the dark gradient */
|
|
||||||
--accent-deep: #4f46e5; /* used for solid accents (h2 bar, table head) */
|
|
||||||
--accent-soft: rgba(129,140,248,0.12);
|
|
||||||
--gradient: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #312e81 100%);
|
|
||||||
--row-even: rgba(255,255,255,0.04);
|
|
||||||
--row-odd: rgba(255,255,255,0.02);
|
|
||||||
--border: rgba(255,255,255,0.10);
|
|
||||||
}
|
|
||||||
section {
|
|
||||||
background: var(--gradient);
|
|
||||||
color: var(--ink);
|
|
||||||
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
|
|
||||||
font-size: 22px;
|
|
||||||
padding: 40px 60px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
section::after {
|
|
||||||
content: attr(data-marpit-pagination) ' / ' attr(data-marpit-pagination-total);
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
font-size: 48px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #ffffff;
|
|
||||||
border-bottom: 4px solid var(--accent);
|
|
||||||
padding-bottom: 10px;
|
|
||||||
margin: 0 0 18px 0;
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
font-size: 34px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #ffffff;
|
|
||||||
margin: 0 0 14px 0;
|
|
||||||
}
|
|
||||||
h2::before {
|
|
||||||
content: '';
|
|
||||||
display: inline-block;
|
|
||||||
width: 6px;
|
|
||||||
height: 28px;
|
|
||||||
background: var(--accent);
|
|
||||||
margin-right: 14px;
|
|
||||||
vertical-align: middle;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
h3 { color: #c7d2fe; font-weight: 600; font-size: 24px; margin: 8px 0; }
|
|
||||||
p, li { margin: 6px 0; line-height: 1.45; }
|
|
||||||
ul, ol { margin: 8px 0 12px 0; padding-left: 28px; }
|
|
||||||
strong { color: #ffffff; }
|
|
||||||
a { color: #a5b4fc; }
|
|
||||||
blockquote {
|
|
||||||
border-left: 4px solid var(--accent);
|
|
||||||
background: rgba(255,255,255,0.06);
|
|
||||||
padding: 10px 18px;
|
|
||||||
color: #e0e7ff;
|
|
||||||
border-radius: 0 8px 8px 0;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
code {
|
|
||||||
background: rgba(255,255,255,0.10);
|
|
||||||
color: #e0e7ff;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
/* Constrain mermaid images so they always fit on a 720px-tall slide
|
|
||||||
* (16:9 @ 1280×720 default). Soft white card behind the diagram so the
|
|
||||||
* mermaid SVG (which renders on a transparent canvas with dark ink)
|
|
||||||
* stays readable on the navy gradient. */
|
|
||||||
img {
|
|
||||||
display: block;
|
|
||||||
margin: 8px auto;
|
|
||||||
max-width: 92%;
|
|
||||||
max-height: 420px;
|
|
||||||
object-fit: contain;
|
|
||||||
background: #ffffff;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 10px;
|
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
|
|
||||||
}
|
|
||||||
/* Tile grid for the local-agent versatility slide. */
|
|
||||||
.backends {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 14px;
|
|
||||||
margin: 14px 0 12px 0;
|
|
||||||
}
|
|
||||||
.tile {
|
|
||||||
background: var(--accent-soft);
|
|
||||||
border: 2px solid var(--accent);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
text-align: center;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
.tile small {
|
|
||||||
display: block;
|
|
||||||
margin-top: 4px;
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.8em;
|
|
||||||
}
|
|
||||||
.tile.dots {
|
|
||||||
background: rgba(255,255,255,0.04);
|
|
||||||
border-style: dashed;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 28px;
|
|
||||||
}
|
|
||||||
.pipeline {
|
|
||||||
text-align: center;
|
|
||||||
background: rgba(255,255,255,0.08);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: #f8fafc;
|
|
||||||
padding: 12px 18px;
|
|
||||||
border-radius: 10px;
|
|
||||||
margin: 6px auto 14px auto;
|
|
||||||
max-width: 760px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.pipeline span {
|
|
||||||
display: inline-block;
|
|
||||||
margin: 0 10px;
|
|
||||||
}
|
|
||||||
/* Tables sit on a solid white card so cell text reads cleanly against
|
|
||||||
* the dark gradient backdrop. Rounded outer corners + shadow match the
|
|
||||||
* mermaid card styling. `display:table` + `table-layout:fixed` are
|
|
||||||
* needed so the table actually consumes the full slide width — Marp's
|
|
||||||
* default theme leaves tables at content-natural width, which left
|
|
||||||
* blank gutters on either side. */
|
|
||||||
table {
|
|
||||||
display: table !important;
|
|
||||||
width: 100% !important;
|
|
||||||
table-layout: fixed;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin: 12px 0;
|
|
||||||
font-size: 0.82em;
|
|
||||||
color: #0f172a;
|
|
||||||
background: #ffffff;
|
|
||||||
border-radius: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
|
|
||||||
}
|
|
||||||
td, th { word-wrap: break-word; overflow-wrap: anywhere; }
|
|
||||||
th {
|
|
||||||
background: var(--accent-deep);
|
|
||||||
color: #ffffff;
|
|
||||||
text-align: left;
|
|
||||||
padding: 9px 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
td {
|
|
||||||
padding: 7px 12px;
|
|
||||||
border-bottom: 1px solid #e2e8f0;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
tr:nth-child(even) td { background: #ffffff; }
|
|
||||||
tr:nth-child(odd) td { background: #f8fafc; }
|
|
||||||
/* code spans inside table cells need a light pill, not the dark one
|
|
||||||
* defined globally — otherwise they'd disappear into the white row. */
|
|
||||||
td code {
|
|
||||||
background: #eef2ff;
|
|
||||||
color: #1e293b;
|
|
||||||
}
|
|
||||||
/* Bold/strong inside cells must stay dark — the global rule paints
|
|
||||||
* <strong> white for the dark-gradient body text, which would render
|
|
||||||
* invisible against the white table card. */
|
|
||||||
td strong, th strong {
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
th strong {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
/* Title slide — centered hero treatment on the same gradient. */
|
|
||||||
section.title {
|
|
||||||
text-align: center;
|
|
||||||
padding: 100px 80px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
section.title h1 {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 64px;
|
|
||||||
margin: 0 auto 20px auto;
|
|
||||||
}
|
|
||||||
section.title blockquote {
|
|
||||||
margin: 28px auto;
|
|
||||||
max-width: 900px;
|
|
||||||
}
|
|
||||||
/* Closing slide — same centered treatment. */
|
|
||||||
section.closing {
|
|
||||||
text-align: center;
|
|
||||||
padding: 100px 80px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
section.closing h2::before { display: none; }
|
|
||||||
section.closing blockquote { font-size: 26px; max-width: 900px; }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<!-- _class: title -->
|
|
||||||
|
|
||||||
# Local Agents Marketplace
|
|
||||||
|
|
||||||
### A trust-minimised, on-chain marketplace for LLM inference
|
|
||||||
|
|
||||||
Buy and sell prompt → response with **bonded escrow** on **Vector** (UTxO).
|
|
||||||
No platform fee. No central rate-limiter. Verifiable receipts on chain.
|
|
||||||
|
|
||||||
> *Status: M1 lifecycle proven end-to-end on Vector testnet — 2026-05-05.*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The problem
|
|
||||||
|
|
||||||
* AI inference is **centralised** — a handful of providers, opaque pricing, account-level rate limits.
|
|
||||||
* Local-AI hardware is **idle most of the time** — GPUs and CPUs sit unused between user prompts.
|
|
||||||
* Buyers and sellers can't transact directly without a **trusted intermediary** taking a cut.
|
|
||||||
* "Verifiable" claims about which model ran are usually nothing more than the provider's word.
|
|
||||||
|
|
||||||
We need a **two-sided marketplace** where:
|
|
||||||
1. Sellers monetise spare compute,
|
|
||||||
2. Buyers get a **cryptographic receipt** of what was actually computed,
|
|
||||||
3. **Neither side has to trust the other** — bonds + on-chain settlement enforce honesty.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How it works — three actors, one chain
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
subgraph CHAIN[Vector — UTxO]
|
|
||||||
ADV[Advert UTxO<br/>capability, price, bonds]
|
|
||||||
ESC[Escrow UTxO<br/>locked funds + state]
|
|
||||||
end
|
|
||||||
|
|
||||||
SUP[🧠 Supplier<br/>local LLM node]
|
|
||||||
BUY[💼 Buyer<br/>app or agent]
|
|
||||||
IDX[🔭 Indexer<br/>read-only bulletin board]
|
|
||||||
|
|
||||||
SUP -->|publish advert| ADV
|
|
||||||
BUY -->|discover| IDX
|
|
||||||
IDX -.->|index events| CHAIN
|
|
||||||
BUY -->|post escrow| ESC
|
|
||||||
BUY -->|prompt + escrow_ref| SUP
|
|
||||||
SUP -->|signed receipt| BUY
|
|
||||||
SUP -->|claim & submit| ESC
|
|
||||||
BUY -->|accept & pay| ESC
|
|
||||||
```
|
|
||||||
|
|
||||||
* **Supplier** runs the inference and publishes a price.
|
|
||||||
* **Buyer** posts the work as a bonded escrow, sends the prompt, accepts the receipt.
|
|
||||||
* **Indexer** is a public mirror of chain state — anyone can run one. No platform middleman.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The bonded-escrow lifecycle
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
autonumber
|
|
||||||
participant B as Buyer
|
|
||||||
participant Chain as Vector
|
|
||||||
participant S as Supplier
|
|
||||||
participant LLM as Local LLM<br/>(OpenAI-compatible)
|
|
||||||
|
|
||||||
S->>Chain: PostAdvert (locks supplier_bond)
|
|
||||||
B->>Chain: PostEscrow → Open<br/>(locks payment + bonds)
|
|
||||||
B->>S: POST /v1/chat/completions
|
|
||||||
S->>Chain: ClaimEscrow → Claimed
|
|
||||||
S->>LLM: run inference
|
|
||||||
LLM-->>S: assistant message
|
|
||||||
S->>Chain: SubmitEscrow → Submitted
|
|
||||||
S-->>B: 200 OK + signed receipt
|
|
||||||
B->>Chain: AcceptEscrow — supplier paid
|
|
||||||
```
|
|
||||||
|
|
||||||
Every transition is on-chain, validated by an Aiken Plutus V3 script. The receipt is signed by the supplier's advert key; prompt and response hashes are bound on chain.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What if someone misbehaves?
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
OPEN[Open]:::norm --> CLAIMED[Claimed]:::norm --> SUBMITTED[Submitted]:::norm --> ACCEPTED[Accepted ✓]:::good
|
|
||||||
OPEN -->|deliver_by passed| RECLAIM[Reclaimed<br/>buyer recovers funds]:::warn
|
|
||||||
CLAIMED -->|deliver_by passed| RECLAIM
|
|
||||||
SUBMITTED -->|accept_window passed| RELEASE[Released<br/>supplier collects]:::warn
|
|
||||||
|
|
||||||
classDef norm fill:#e0e7ff,stroke:#4f46e5
|
|
||||||
classDef good fill:#dcfce7,stroke:#16a34a
|
|
||||||
classDef warn fill:#fef3c7,stroke:#d97706
|
|
||||||
```
|
|
||||||
|
|
||||||
| Failure mode | Recovery | Loss bearer |
|
|
||||||
|---|---|---|
|
|
||||||
| Supplier never claims/submits | Buyer **Reclaim** after `deliver_by` | Supplier — forfeits supplier_bond |
|
|
||||||
| Buyer disappears after Submit | Supplier **Release** after `submitted_at + 10 min` | Buyer — forfeits buyer_bond |
|
|
||||||
|
|
||||||
Bonds make honesty cheaper than running away. Both sides have skin in the game; no third-party arbiter is needed for the M1 happy/timeout paths.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Local-agent versatility — any OpenAI-compatible engine
|
|
||||||
|
|
||||||
<div class="pipeline">
|
|
||||||
<span>💼 Buyer</span> →
|
|
||||||
<span>🧠 Supplier (OpenAI-compatible HTTP wrapper)</span> →
|
|
||||||
<span>backend ↓</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="backends">
|
|
||||||
<div class="tile">Ollama<br/><small>local model runner</small></div>
|
|
||||||
<div class="tile">vLLM<br/><small>high-throughput GPU</small></div>
|
|
||||||
<div class="tile">llama.cpp<br/><small>CPU · Metal · ROCm</small></div>
|
|
||||||
<div class="tile">OpenRouter<br/><small>cloud API mux</small></div>
|
|
||||||
<div class="tile">openclaw / custom<br/><small>arbitrary stack</small></div>
|
|
||||||
<div class="tile dots">…<br/><small>any OpenAI-shaped API</small></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
The supplier service is a thin HTTP wrapper — it forwards `/v1/chat/completions` to whatever backend is configured. Each supplier picks its own model, hardware, and engine, then advertises a `capability_id`. Buyers shop on capability + model + price.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Capability + model = task selection
|
|
||||||
|
|
||||||
| Capability ID | Example models | Use case |
|
|
||||||
|---|---|---|
|
|
||||||
| `llm.text.generate.v1` | Llama 3 · Qwen 2.5 · Mistral · GPT-4 | Q&A, drafts, summarisation |
|
|
||||||
| `code.completion.v1` | Codestral · DeepSeek-Coder · StarCoder | IDE tab-complete, refactor |
|
|
||||||
| `vision.describe.v1` | LLaVA · Qwen-VL · Pixtral | Image captioning, OCR |
|
|
||||||
| `embedding.text.v1` | nomic-embed · BGE · ada-002 | RAG indexing |
|
|
||||||
| `audio.transcribe.v1` | whisper-large-v3 · distil-whisper | Voice-to-text |
|
|
||||||
| `…` | … | … |
|
|
||||||
|
|
||||||
* New capabilities = **just publish a new advert**. No marketplace governance gate.
|
|
||||||
* Today's deploy uses Ollama + Qwen 2.5; switching to vLLM with Llama 3 70B is a config change, not a code change.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## OpenClaw — agentic teams on your existing OpenAI seat
|
|
||||||
|
|
||||||
<div class="pipeline">
|
|
||||||
<span>🧠 Supplier wrapper</span> →
|
|
||||||
<span>🦾 OpenClaw</span> →
|
|
||||||
<span>🪪 OpenAI ChatGPT seat (flat-rate)</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
* **Cost-efficient by default.** OpenClaw exposes your existing **OpenAI ChatGPT subscription** as an OpenAI-compatible API. The marketplace supplier dispatches its inference against it, so an entire **agentic team** (coders, QA automation testers, release curators…) draws from the **flat-rate seat you already pay for** — no per-token metering, no surprise invoices.
|
|
||||||
* **Reliability layer (optional, marginal cost).** OpenClaw can fall back to the **Anthropic API** or **OpenRouter** model pool when the primary seat is rate-limited, the prompt requires a different model, or you want a **second-layer hardening** of responses (e.g. cross-provider consensus on critical outputs). You only pay metered tokens for the requests that actually fall through.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Proven on Vector testnet — 2026-05-05
|
|
||||||
|
|
||||||
| Step | Tx hash |
|
|
||||||
|---|---|
|
|
||||||
| Buyer posts escrow | `af762561…c2f7bc5` |
|
|
||||||
| Supplier claims | (lifecycle tx) |
|
|
||||||
| Supplier submits receipt | (lifecycle tx) |
|
|
||||||
| Buyer accepts | `125e3cfe…7dc100d` |
|
|
||||||
|
|
||||||
Funds flowed correctly: supplier received `payment + supplier_bond` (3 ADA); buyer got `buyer_bond` back.
|
|
||||||
|
|
||||||
* **Browser demo:** https://mp-buyers.vector.testnet.apexfusion.org
|
|
||||||
* **Live event stream:** https://mp-indexer.vector.testnet.apexfusion.org
|
|
||||||
* **Test suite:** 1098 unit tests passing, full lifecycle exercised in CI.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Why UTxO + Vector
|
|
||||||
|
|
||||||
* **Bonded escrow is native** — Plutus validators enforce the state machine without a custom token or oracle.
|
|
||||||
* **Vector settles in seconds** — the buyer→claim→submit→accept loop fits inside a single chat-completion's wall clock.
|
|
||||||
* **AP3X token** for fees and bonds — fungible, transparent, on-chain.
|
|
||||||
* **No custom rollup, no bridge** — runs on a production chain today.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<!-- _class: closing -->
|
|
||||||
|
|
||||||
## The pitch in one sentence
|
|
||||||
|
|
||||||
> Local Agents Marketplace turns any OpenAI-compatible inference endpoint into a **trustlessly billable service** — buyer and supplier exchange prompt, response, and payment with on-chain proof that what was paid for is what was delivered.
|
|
||||||
|
|
||||||
### Try it
|
|
||||||
|
|
||||||
* **Indexer (live event stream):** https://mp-indexer.vector.testnet.apexfusion.org
|
|
||||||
* **Buyer:** https://mp-buyers.vector.testnet.apexfusion.org
|
|
||||||
* **Supplier:** https://mp-suppliers.vector.testnet.apexfusion.org/capability
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -164,8 +164,8 @@ If the buyer does NOT accept within the dispute window (10 minutes), you can col
|
|||||||
|
|
||||||
- **Never share your .env file** - it contains your wallet seed phrase
|
- **Never share your .env file** - it contains your wallet seed phrase
|
||||||
- **Never share your data/ folder** - it contains task history
|
- **Never share your data/ folder** - it contains task history
|
||||||
- The seed phrase controls your wallet funds. Back it up securely.
|
- The seed phrase controls your wallet. Back it up securely.
|
||||||
- The node only responds to buyers who have already locked funds on-chain, so you cannot be scammed out of compute.
|
- The node only responds to buyers who have already locked value on-chain, so you cannot be scammed out of compute.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
36
gateway/.env.example
Normal file
36
gateway/.env.example
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# gateway/.env.example — copy to .env and fill in.
|
||||||
|
|
||||||
|
# 32-byte (64 hex char) master key used to AES-256-GCM-encrypt every user's
|
||||||
|
# custodial wallet private key at rest. Generate with: openssl rand -hex 32
|
||||||
|
# LOSING THIS PERMANENTLY LOCKS ALL USER FUNDS. Back it up out-of-band.
|
||||||
|
GATEWAY_MASTER_KEY=
|
||||||
|
|
||||||
|
# Internal indexer base URL (e.g. http://marketplace-indexer:8090).
|
||||||
|
INDEXER_URL=
|
||||||
|
|
||||||
|
# Ogmios HTTP endpoint. Required (the gateway posts/settles escrows on chain).
|
||||||
|
OGMIOS_URL=
|
||||||
|
|
||||||
|
# Must be "1" — the gateway signs and submits real transactions.
|
||||||
|
LIVE_CHAIN=1
|
||||||
|
|
||||||
|
# "0" testnet (addr_test…), "1" mainnet (addr…). Default "0".
|
||||||
|
NETWORK_ID=0
|
||||||
|
|
||||||
|
# HTTP port. Default 3010.
|
||||||
|
GATEWAY_PORT=3010
|
||||||
|
|
||||||
|
# Directory holding gateway.db (custodial keys + usage + sessions). BACK IT UP.
|
||||||
|
# Default ./data/gateway
|
||||||
|
GATEWAY_DB_DIR=./data/gateway
|
||||||
|
|
||||||
|
# Optional tuning (defaults shown):
|
||||||
|
# SIGNUP_RATE_MAX=5 # signups per IP per window
|
||||||
|
# SIGNUP_RATE_WINDOW_MS=3600000
|
||||||
|
# KEY_RATE_MAX=60 # requests per API key per window
|
||||||
|
# KEY_RATE_WINDOW_MS=60000
|
||||||
|
# SWEEPER_INTERVAL_MS=60000
|
||||||
|
# DEMO_SESSION_IDLE_MS=180000 # close demo chat sessions idle this long (frees the supplier slot)
|
||||||
|
# CHAT_SETTLE_MODE=full # "full" (Claim/Submit/Accept) or "ticket" (escrow post only; sweeper reclaims) — must match suppliers
|
||||||
|
# WALLET_HEALTH_INTERVAL_MS=600000
|
||||||
|
# SDK_REGISTRY_MAX=500 # max cached per-key Marketplace instances
|
||||||
72
gateway/Dockerfile
Normal file
72
gateway/Dockerfile
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
#
|
||||||
|
# gateway/Dockerfile — multi-stage image for the OpenAI-compatible gateway.
|
||||||
|
#
|
||||||
|
# Mirrors buyer/Dockerfile: we do NOT run `tsc` for the server because
|
||||||
|
# @marketplace/shared and @marketplace/buyer export .ts directly via
|
||||||
|
# package.json#exports — the tsx loader handles them at runtime. The gateway
|
||||||
|
# reuses the buyer's Marketplace SDK + lucid (buildAcceptTx / buildWithdrawTx),
|
||||||
|
# so it needs the same better-sqlite3 native rebuild + libsodium patch and the
|
||||||
|
# contracts/marketplace/plutus.json blueprint.
|
||||||
|
#
|
||||||
|
# Build:
|
||||||
|
# docker build -f gateway/Dockerfile -t marketplace-gateway .
|
||||||
|
|
||||||
|
ARG NODE_VERSION=20.19.0
|
||||||
|
|
||||||
|
# ─── builder ──────────────────────────────────────────────────────────────
|
||||||
|
FROM node:${NODE_VERSION}-alpine AS builder
|
||||||
|
WORKDIR /repo
|
||||||
|
|
||||||
|
RUN apk add --no-cache python3 make g++
|
||||||
|
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||||
|
|
||||||
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./
|
||||||
|
# pnpm-lock.yaml's patchedDependencies hash the patch files at install time.
|
||||||
|
COPY patches ./patches
|
||||||
|
COPY packages/shared/package.json ./packages/shared/package.json
|
||||||
|
COPY buyer/package.json ./buyer/package.json
|
||||||
|
COPY gateway/package.json ./gateway/package.json
|
||||||
|
|
||||||
|
# Install the gateway and its workspace deps (buyer + shared) with their deps.
|
||||||
|
RUN pnpm install --frozen-lockfile --filter '@marketplace/gateway...'
|
||||||
|
|
||||||
|
# better-sqlite3 native binding (same step as buyer/indexer images).
|
||||||
|
RUN BSQ_DIR=$(ls -d node_modules/.pnpm/better-sqlite3@*/node_modules/better-sqlite3 2>/dev/null | head -1) \
|
||||||
|
&& [ -n "$BSQ_DIR" ] || (echo "FATAL: better-sqlite3 not in pnpm store" && exit 1) \
|
||||||
|
&& (cd "$BSQ_DIR" && (npx --yes prebuild-install --runtime=node || npm install --build-from-source --no-save)) \
|
||||||
|
&& ls -la "$BSQ_DIR/build/Release/better_sqlite3.node"
|
||||||
|
|
||||||
|
# libsodium wrapper patch (lucid/CML dep) — same fix as the buyer image.
|
||||||
|
RUN set -e \
|
||||||
|
&& SRC=$(find node_modules/.pnpm -name "libsodium-sumo.mjs" -path "*/libsodium-sumo/dist/*" 2>/dev/null | head -1) \
|
||||||
|
&& DST=$(find node_modules/.pnpm -type d -path "*libsodium-wrappers-sumo@*/node_modules/libsodium-wrappers-sumo/dist/modules-sumo-esm" 2>/dev/null | head -1) \
|
||||||
|
&& [ -n "$SRC" ] && [ -n "$DST" ] || (echo "FATAL: libsodium-sumo paths not resolved" && exit 1) \
|
||||||
|
&& cp "$SRC" "$DST/"
|
||||||
|
|
||||||
|
COPY packages/shared ./packages/shared
|
||||||
|
COPY buyer ./buyer
|
||||||
|
COPY gateway ./gateway
|
||||||
|
# loadBlueprint reads contracts/marketplace/plutus.json at runtime (Accept/Withdraw).
|
||||||
|
COPY contracts/marketplace/plutus.json ./contracts/marketplace/plutus.json
|
||||||
|
|
||||||
|
# ─── runtime ──────────────────────────────────────────────────────────────
|
||||||
|
FROM node:${NODE_VERSION}-alpine AS runtime
|
||||||
|
WORKDIR /repo
|
||||||
|
|
||||||
|
RUN apk add --no-cache tini
|
||||||
|
COPY --from=builder /repo /repo
|
||||||
|
|
||||||
|
# Seed the gateway DB dir owned by node (so a fresh named volume inherits it).
|
||||||
|
RUN mkdir -p /repo/data/gateway && chown -R node:node /repo
|
||||||
|
USER node
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV GATEWAY_PORT=8080
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=20s --timeout=5s --start-period=15s --retries=3 \
|
||||||
|
CMD wget -qO- http://127.0.0.1:${GATEWAY_PORT}/healthz >/dev/null || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/sbin/tini", "--"]
|
||||||
|
CMD ["node_modules/.bin/tsx", "gateway/src/index.ts"]
|
||||||
29
gateway/package.json
Normal file
29
gateway/package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "@marketplace/gateway",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"start": "tsx src/index.ts",
|
||||||
|
"dev": "tsx src/index.ts",
|
||||||
|
"rotate-master-key": "tsx src/cli/rotate-master-key.ts",
|
||||||
|
"create-demo-key": "tsx src/cli/create-demo-key.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@marketplace/buyer": "workspace:*",
|
||||||
|
"@marketplace/shared": "workspace:*",
|
||||||
|
"@noble/ed25519": "^2.3.0",
|
||||||
|
"@noble/hashes": "^1.8.0",
|
||||||
|
"bech32": "^2.0.0",
|
||||||
|
"better-sqlite3": "^11.9.1",
|
||||||
|
"express": "^4.21.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
|
"@types/express": "^5.0.3",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
163
gateway/src/account/routes.ts
Normal file
163
gateway/src/account/routes.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/account/routes.ts — signup, account, withdraw.
|
||||||
|
*
|
||||||
|
* POST /signup → mint a custodial wallet + API key (key shown once)
|
||||||
|
* GET /account → balance, collateral readiness, spend, recent usage
|
||||||
|
* POST /account/withdraw → move unspent AP3X out to an external address (exit)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomBytes, randomUUID } from "crypto";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { buildWithdrawTx } from "@marketplace/shared/tx/server";
|
||||||
|
import type { GatewayDeps } from "../deps.js";
|
||||||
|
import { genPrivKeyHex, deriveWalletKey } from "../wallet.js";
|
||||||
|
import { seal } from "../crypto/seal.js";
|
||||||
|
import { hashApiKey, requireKey } from "../middleware/apiKeyAuth.js";
|
||||||
|
import { asyncHandler } from "../middleware/http.js";
|
||||||
|
import { totalLovelace, hasCollateral } from "../onchain/preflight.js";
|
||||||
|
import { badRequest, forbidden } from "../openai/errors.js";
|
||||||
|
|
||||||
|
const ACTIVE_ESCROW_STATES = new Set(["Open", "Claimed", "Submitted"]);
|
||||||
|
|
||||||
|
function ap3x(lovelace: bigint): string {
|
||||||
|
return (Number(lovelace) / 1e6).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── signup ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function makeSignupHandler(deps: GatewayDeps) {
|
||||||
|
return asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||||
|
const label = typeof body.label === "string" ? body.label.slice(0, 120) : null;
|
||||||
|
|
||||||
|
const privHex = genPrivKeyHex();
|
||||||
|
const walletKey = deriveWalletKey(privHex, deps.config.networkId);
|
||||||
|
const sealed = seal(privHex, deps.config.masterKeyHex);
|
||||||
|
|
||||||
|
const rawKey = `vk_${deps.config.networkId === 1 ? "live" : "test"}_${randomBytes(24).toString("hex")}`;
|
||||||
|
const keyPrefix = rawKey.slice(0, 12);
|
||||||
|
|
||||||
|
deps.store.insertKey({
|
||||||
|
id: randomUUID(),
|
||||||
|
key_hash: hashApiKey(rawKey),
|
||||||
|
key_prefix: keyPrefix,
|
||||||
|
label,
|
||||||
|
wallet_pkh: walletKey.pubKeyHash,
|
||||||
|
deposit_address: walletKey.address,
|
||||||
|
enc_priv_nonce: sealed.nonce,
|
||||||
|
enc_priv_ct: sealed.ct,
|
||||||
|
enc_priv_tag: sealed.tag,
|
||||||
|
master_key_version: 1,
|
||||||
|
created_at: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
api_key: rawKey,
|
||||||
|
key_prefix: keyPrefix,
|
||||||
|
deposit_address: walletKey.address,
|
||||||
|
note: "Save api_key now — it is shown only once. Fund deposit_address with AP3X to use the gateway.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── account ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function makeAccountHandler(deps: GatewayDeps) {
|
||||||
|
return asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const keyRow = requireKey(req);
|
||||||
|
const utxos = await deps.chain.queryUtxosByAddress(keyRow.deposit_address);
|
||||||
|
const available = totalLovelace(utxos);
|
||||||
|
|
||||||
|
let lockedInEscrow = 0n;
|
||||||
|
try {
|
||||||
|
const r = await deps.fetchFn(`${deps.config.indexerUrl}/escrows?buyer=${keyRow.wallet_pkh}`);
|
||||||
|
if (r.ok) {
|
||||||
|
const rows = (await r.json()) as Array<Record<string, unknown>>;
|
||||||
|
if (Array.isArray(rows)) {
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!ACTIVE_ESCROW_STATES.has(String(row.state))) continue;
|
||||||
|
lockedInEscrow +=
|
||||||
|
BigInt(String(row.payment_lovelace ?? "0")) +
|
||||||
|
BigInt(String(row.buyer_bond_lovelace ?? "0")) +
|
||||||
|
BigInt(String(row.supplier_bond_lovelace ?? "0"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* best-effort; report 0 locked on indexer hiccup */
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
key_prefix: keyRow.key_prefix,
|
||||||
|
// The shared demo key's wallet is operator-funded; hiding the deposit
|
||||||
|
// address keeps strangers from confusing it with their own wallet.
|
||||||
|
deposit_address: keyRow.demo ? null : keyRow.deposit_address,
|
||||||
|
...(keyRow.demo ? { demo: true } : {}),
|
||||||
|
balance: {
|
||||||
|
available_lovelace: available.toString(),
|
||||||
|
locked_in_escrow_lovelace: lockedInEscrow.toString(),
|
||||||
|
ap3x: ap3x(available),
|
||||||
|
},
|
||||||
|
collateral_ok: hasCollateral(utxos),
|
||||||
|
spend: {
|
||||||
|
total_cost_lovelace: deps.store.sumCostLovelace(keyRow.id).toString(),
|
||||||
|
request_count: deps.store.countUsage(keyRow.id),
|
||||||
|
},
|
||||||
|
recent_usage: deps.store.listUsage(keyRow.id, 20).map((u) => ({
|
||||||
|
created_at: u.created_at,
|
||||||
|
kind: u.kind,
|
||||||
|
model: u.model,
|
||||||
|
status: u.status,
|
||||||
|
cost_lovelace: u.cost_lovelace,
|
||||||
|
escrow_ref: u.escrow_ref,
|
||||||
|
failure_reason: u.failure_reason,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── withdraw ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function makeWithdrawHandler(deps: GatewayDeps) {
|
||||||
|
return asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const keyRow = requireKey(req);
|
||||||
|
if (keyRow.demo) {
|
||||||
|
// The demo key is shared publicly; without this, anyone holding it
|
||||||
|
// could drain the operator-funded wallet to their own address.
|
||||||
|
throw forbidden("demo_key_restricted", "withdrawals are disabled for the shared demo key");
|
||||||
|
}
|
||||||
|
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const toAddress = body.to_address;
|
||||||
|
if (typeof toAddress !== "string" || !/^addr(_test)?1[0-9a-z]+$/.test(toAddress)) {
|
||||||
|
throw badRequest("invalid_address", "`to_address` must be a bech32 Cardano address");
|
||||||
|
}
|
||||||
|
|
||||||
|
let amountLovelace: bigint | undefined;
|
||||||
|
if (body.amount_lovelace !== undefined && body.amount_lovelace !== null) {
|
||||||
|
try {
|
||||||
|
amountLovelace = BigInt(String(body.amount_lovelace));
|
||||||
|
} catch {
|
||||||
|
throw badRequest("invalid_amount", "`amount_lovelace` must be an integer (string or number)");
|
||||||
|
}
|
||||||
|
if (amountLovelace <= 0n) throw badRequest("invalid_amount", "`amount_lovelace` must be positive");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = deps.registry.getContext(keyRow);
|
||||||
|
await ctx.mutex.run(async () => {
|
||||||
|
const built = await buildWithdrawTx({
|
||||||
|
chain: deps.chain,
|
||||||
|
walletKey: ctx.walletKey,
|
||||||
|
toAddress,
|
||||||
|
amountLovelace,
|
||||||
|
});
|
||||||
|
await deps.chain.awaitTx(built.expectedTxHash, 120_000);
|
||||||
|
res.status(200).json({
|
||||||
|
status: "submitted",
|
||||||
|
tx_hash: built.expectedTxHash,
|
||||||
|
amount_lovelace: built.amountLovelace.toString(),
|
||||||
|
to_address: built.toAddress,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
16
gateway/src/chain.ts
Normal file
16
gateway/src/chain.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/chain.ts — the single shared LiveOgmiosProvider.
|
||||||
|
*
|
||||||
|
* The gateway always submits real transactions, so it always uses a Live
|
||||||
|
* provider (config enforces LIVE_CHAIN=1). One provider is shared across all
|
||||||
|
* per-key Marketplace instances; ChainProvider reads are concurrency-safe.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { LiveOgmiosProvider } from "@marketplace/shared/chain";
|
||||||
|
import type { GatewayConfig } from "./config.js";
|
||||||
|
|
||||||
|
export function buildChainProvider(config: GatewayConfig): LiveOgmiosProvider {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log("[gateway] chain: LiveOgmiosProvider");
|
||||||
|
return new LiveOgmiosProvider({ ogmiosUrl: config.ogmiosUrl });
|
||||||
|
}
|
||||||
76
gateway/src/cli/create-demo-key.ts
Normal file
76
gateway/src/cli/create-demo-key.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/cli/create-demo-key.ts — mint the shared demo API key.
|
||||||
|
*
|
||||||
|
* Same custodial machinery as POST /signup, but sets demo=1: withdraw is
|
||||||
|
* blocked, /account hides the deposit address, chat completions run through
|
||||||
|
* the session-backed demo executor, and per-IP rate limits apply. Public
|
||||||
|
* signup can never mint a demo key — this CLI is the only path.
|
||||||
|
*
|
||||||
|
* Run inside the gateway container (hits the volume-mounted DB):
|
||||||
|
* docker exec <gateway> pnpm --filter @marketplace/gateway create-demo-key
|
||||||
|
* Or locally:
|
||||||
|
* GATEWAY_MASTER_KEY=<64hex> GATEWAY_DB_DIR=./data/gateway NETWORK_ID=1 \
|
||||||
|
* pnpm --filter @marketplace/gateway create-demo-key [--label shared-demo]
|
||||||
|
*
|
||||||
|
* The raw key is printed ONCE and never stored (only its sha256).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomBytes, randomUUID } from "crypto";
|
||||||
|
import { GatewayStore } from "../db/store.js";
|
||||||
|
import { genPrivKeyHex, deriveWalletKey } from "../wallet.js";
|
||||||
|
import { seal } from "../crypto/seal.js";
|
||||||
|
import { hashApiKey } from "../middleware/apiKeyAuth.js";
|
||||||
|
|
||||||
|
const HEX64 = /^[0-9a-fA-F]{64}$/;
|
||||||
|
|
||||||
|
function arg(name: string): string | undefined {
|
||||||
|
const i = process.argv.indexOf(`--${name}`);
|
||||||
|
return i >= 0 ? process.argv[i + 1] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main(): void {
|
||||||
|
const masterKey = process.env.GATEWAY_MASTER_KEY;
|
||||||
|
if (!masterKey || !HEX64.test(masterKey)) {
|
||||||
|
throw new Error("create-demo-key: GATEWAY_MASTER_KEY must be 64 hex chars");
|
||||||
|
}
|
||||||
|
const dbDir = arg("db") ?? process.env.GATEWAY_DB_DIR ?? "./data/gateway";
|
||||||
|
const networkId: 0 | 1 = process.env.NETWORK_ID === "1" ? 1 : 0;
|
||||||
|
const label = arg("label") ?? "shared-demo";
|
||||||
|
|
||||||
|
const privHex = genPrivKeyHex();
|
||||||
|
const walletKey = deriveWalletKey(privHex, networkId);
|
||||||
|
const sealed = seal(privHex, masterKey);
|
||||||
|
|
||||||
|
const rawKey = `vk_${networkId === 1 ? "live" : "test"}_${randomBytes(24).toString("hex")}`;
|
||||||
|
|
||||||
|
const store = new GatewayStore(dbDir);
|
||||||
|
store.insertKey({
|
||||||
|
id: randomUUID(),
|
||||||
|
key_hash: hashApiKey(rawKey),
|
||||||
|
key_prefix: rawKey.slice(0, 12),
|
||||||
|
label,
|
||||||
|
wallet_pkh: walletKey.pubKeyHash,
|
||||||
|
deposit_address: walletKey.address,
|
||||||
|
enc_priv_nonce: sealed.nonce,
|
||||||
|
enc_priv_ct: sealed.ct,
|
||||||
|
enc_priv_tag: sealed.tag,
|
||||||
|
master_key_version: 1,
|
||||||
|
created_at: Date.now(),
|
||||||
|
demo: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
|
[
|
||||||
|
`[gateway] demo key created (label=${label}, network=${networkId === 1 ? "mainnet" : "testnet"})`,
|
||||||
|
``,
|
||||||
|
` api_key: ${rawKey}`,
|
||||||
|
` deposit_address: ${walletKey.address}`,
|
||||||
|
``,
|
||||||
|
`Save the api_key now — it is shown only once. Fund the deposit address`,
|
||||||
|
`with the demo float (withdrawals are disabled for this key).`,
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
52
gateway/src/cli/rotate-master-key.ts
Normal file
52
gateway/src/cli/rotate-master-key.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/cli/rotate-master-key.ts — re-encrypt all custodial keys.
|
||||||
|
*
|
||||||
|
* Decrypts every api_keys row with the OLD master key and re-encrypts with the
|
||||||
|
* NEW one, bumping master_key_version. Run with the gateway stopped.
|
||||||
|
*
|
||||||
|
* GATEWAY_DB_DIR=./data/gateway \
|
||||||
|
* pnpm --filter @marketplace/gateway rotate-master-key \
|
||||||
|
* --old <64hex-old> --new <64hex-new>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { GatewayStore } from "../db/store.js";
|
||||||
|
import { seal, open as unseal } from "../crypto/seal.js";
|
||||||
|
|
||||||
|
const HEX64 = /^[0-9a-fA-F]{64}$/;
|
||||||
|
|
||||||
|
function arg(name: string): string | undefined {
|
||||||
|
const i = process.argv.indexOf(`--${name}`);
|
||||||
|
return i >= 0 ? process.argv[i + 1] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main(): void {
|
||||||
|
const oldKey = arg("old") ?? process.env.GATEWAY_MASTER_KEY_OLD;
|
||||||
|
const newKey = arg("new") ?? process.env.GATEWAY_MASTER_KEY;
|
||||||
|
const dbDir = arg("db") ?? process.env.GATEWAY_DB_DIR ?? "./data/gateway";
|
||||||
|
|
||||||
|
if (!oldKey || !HEX64.test(oldKey)) throw new Error("rotate: --old must be 64 hex chars");
|
||||||
|
if (!newKey || !HEX64.test(newKey)) throw new Error("rotate: --new must be 64 hex chars");
|
||||||
|
if (oldKey === newKey) throw new Error("rotate: --old and --new are identical");
|
||||||
|
|
||||||
|
const store = new GatewayStore(dbDir);
|
||||||
|
const rows = store.listAllKeys();
|
||||||
|
let rotated = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
const priv = unseal(
|
||||||
|
{ nonce: row.enc_priv_nonce, ct: row.enc_priv_ct, tag: row.enc_priv_tag },
|
||||||
|
oldKey,
|
||||||
|
);
|
||||||
|
const sealed = seal(priv, newKey);
|
||||||
|
store.updateKeyEncryption(row.id, {
|
||||||
|
enc_priv_nonce: sealed.nonce,
|
||||||
|
enc_priv_ct: sealed.ct,
|
||||||
|
enc_priv_tag: sealed.tag,
|
||||||
|
master_key_version: row.master_key_version + 1,
|
||||||
|
});
|
||||||
|
rotated += 1;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[gateway] rotated ${rotated} key(s) at ${dbDir}; restart the gateway with the NEW GATEWAY_MASTER_KEY.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
150
gateway/src/config.ts
Normal file
150
gateway/src/config.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/config.ts — env loading for the OpenAI-compatible gateway.
|
||||||
|
*
|
||||||
|
* Mirrors buyer/src/config.ts conventions (requireField, HEX64_RE, LIVE_CHAIN
|
||||||
|
* semantics) but for a multi-tenant, custodial service. See docs/gateway.md.
|
||||||
|
*
|
||||||
|
* Required:
|
||||||
|
* GATEWAY_MASTER_KEY — 64-char hex (32 bytes). AES-256-GCM key wrapping every
|
||||||
|
* user's custodial wallet private key at rest. Losing it
|
||||||
|
* permanently locks all user funds.
|
||||||
|
* INDEXER_URL — internal indexer base URL.
|
||||||
|
* OGMIOS_URL — Ogmios HTTP endpoint (required; the gateway submits txs).
|
||||||
|
*
|
||||||
|
* Optional:
|
||||||
|
* LIVE_CHAIN — must be "1" (the gateway always needs a live provider).
|
||||||
|
* NETWORK_ID — "0" testnet (default) | "1" mainnet.
|
||||||
|
* GATEWAY_PORT — default 3010.
|
||||||
|
* GATEWAY_DB_DIR — default "./data/gateway".
|
||||||
|
* SIGNUP_RATE_MAX / SIGNUP_RATE_WINDOW_MS — per-IP signup limiter.
|
||||||
|
* KEY_RATE_MAX / KEY_RATE_WINDOW_MS — per-key request limiter.
|
||||||
|
* SWEEPER_INTERVAL_MS / WALLET_HEALTH_INTERVAL_MS — background tick cadences.
|
||||||
|
* SDK_REGISTRY_MAX — max cached per-key Marketplace instances (LRU).
|
||||||
|
* GATEWAY_CORS_ORIGINS — comma-separated allowlist of browser Origins that may
|
||||||
|
* call the gateway cross-origin (e.g. the marketplace
|
||||||
|
* frontend's "Generate API key" page). Default empty =
|
||||||
|
* CORS off (no Access-Control-* headers emitted).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
|
||||||
|
const POS_INT_RE = /^[1-9]\d*$/;
|
||||||
|
|
||||||
|
export interface GatewayConfig {
|
||||||
|
masterKeyHex: string;
|
||||||
|
indexerUrl: string;
|
||||||
|
ogmiosUrl: string;
|
||||||
|
networkId: 0 | 1;
|
||||||
|
liveChain: boolean;
|
||||||
|
port: number;
|
||||||
|
dbDir: string;
|
||||||
|
signupRate: { max: number; windowMs: number };
|
||||||
|
keyRate: { max: number; windowMs: number };
|
||||||
|
demoIpRate: { max: number; windowMs: number };
|
||||||
|
sweeperIntervalMs: number;
|
||||||
|
/** Close demo chat sessions idle this long (frees the supplier slot).
|
||||||
|
* Keep below the supplier's CHAT_IDLE_TIMEOUT_MS so the gateway closes
|
||||||
|
* first and records usage. */
|
||||||
|
demoSessionIdleMs: number;
|
||||||
|
/** Chat-session settlement mode this deployment runs. "full" = suppliers
|
||||||
|
* Claim/Submit and the gateway Accepts; "ticket" = the escrow post at open
|
||||||
|
* is the only chain op and the sweeper Reclaims it after deliver_by.
|
||||||
|
* Must agree with the suppliers' CHAT_SETTLE_MODE. */
|
||||||
|
chatSettleMode: "full" | "ticket";
|
||||||
|
walletHealthIntervalMs: number;
|
||||||
|
sdkRegistryMax: number;
|
||||||
|
corsOrigins: string[];
|
||||||
|
/** Capability id the /v1/ocr/extract route escrows against. Model-scoped
|
||||||
|
* per the marketplace convention (one model, one id). Env: OCR_CAPABILITY_ID. */
|
||||||
|
ocrCapabilityId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireField(env: Record<string, string | undefined>, name: string): string {
|
||||||
|
const v = env[name];
|
||||||
|
if (v === undefined || v === null || v === "") {
|
||||||
|
throw new Error(`loadConfig: missing required env var ${name}`);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatSettleMode(env: Record<string, string | undefined>): "full" | "ticket" {
|
||||||
|
const v = env.CHAT_SETTLE_MODE ?? "full";
|
||||||
|
if (v !== "full" && v !== "ticket") {
|
||||||
|
throw new Error('loadConfig: CHAT_SETTLE_MODE must be "full" or "ticket"');
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function posInt(
|
||||||
|
env: Record<string, string | undefined>,
|
||||||
|
name: string,
|
||||||
|
fallback: number,
|
||||||
|
): number {
|
||||||
|
const v = env[name];
|
||||||
|
if (v === undefined || v === "") return fallback;
|
||||||
|
if (!POS_INT_RE.test(v)) {
|
||||||
|
throw new Error(`loadConfig: ${name} must be a positive integer`);
|
||||||
|
}
|
||||||
|
return Number(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig(env: Record<string, string | undefined>): GatewayConfig {
|
||||||
|
const masterKeyHex = requireField(env, "GATEWAY_MASTER_KEY");
|
||||||
|
if (!HEX64_RE.test(masterKeyHex)) {
|
||||||
|
throw new Error(
|
||||||
|
"loadConfig: GATEWAY_MASTER_KEY must be 64 hex chars (32 bytes); generate with: openssl rand -hex 32",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexerUrl = requireField(env, "INDEXER_URL").replace(/\/+$/, "");
|
||||||
|
|
||||||
|
// The gateway always posts/settles escrows, so a live provider is mandatory.
|
||||||
|
const liveChain = env.LIVE_CHAIN === "1";
|
||||||
|
const ogmiosUrl = env.OGMIOS_URL ?? "";
|
||||||
|
if (!liveChain) {
|
||||||
|
throw new Error("loadConfig: LIVE_CHAIN=1 is required (the gateway submits transactions)");
|
||||||
|
}
|
||||||
|
if (ogmiosUrl === "") {
|
||||||
|
throw new Error("loadConfig: OGMIOS_URL is required when LIVE_CHAIN=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
let networkId: 0 | 1 = 0;
|
||||||
|
const networkIdStr = env.NETWORK_ID;
|
||||||
|
if (networkIdStr !== undefined && networkIdStr !== "") {
|
||||||
|
if (networkIdStr !== "0" && networkIdStr !== "1") {
|
||||||
|
throw new Error('loadConfig: NETWORK_ID must be "0" (testnet) or "1" (mainnet)');
|
||||||
|
}
|
||||||
|
networkId = networkIdStr === "1" ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
masterKeyHex,
|
||||||
|
indexerUrl,
|
||||||
|
ogmiosUrl,
|
||||||
|
networkId,
|
||||||
|
liveChain,
|
||||||
|
port: posInt(env, "GATEWAY_PORT", 3010),
|
||||||
|
dbDir: env.GATEWAY_DB_DIR ?? "./data/gateway",
|
||||||
|
signupRate: {
|
||||||
|
max: posInt(env, "SIGNUP_RATE_MAX", 5),
|
||||||
|
windowMs: posInt(env, "SIGNUP_RATE_WINDOW_MS", 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
keyRate: {
|
||||||
|
max: posInt(env, "KEY_RATE_MAX", 60),
|
||||||
|
windowMs: posInt(env, "KEY_RATE_WINDOW_MS", 60 * 1000),
|
||||||
|
},
|
||||||
|
demoIpRate: {
|
||||||
|
max: posInt(env, "DEMO_IP_RATE_MAX", 20),
|
||||||
|
windowMs: posInt(env, "DEMO_IP_RATE_WINDOW_MS", 60 * 1000),
|
||||||
|
},
|
||||||
|
sweeperIntervalMs: posInt(env, "SWEEPER_INTERVAL_MS", 60 * 1000),
|
||||||
|
demoSessionIdleMs: posInt(env, "DEMO_SESSION_IDLE_MS", 3 * 60 * 1000),
|
||||||
|
chatSettleMode: chatSettleMode(env),
|
||||||
|
walletHealthIntervalMs: posInt(env, "WALLET_HEALTH_INTERVAL_MS", 10 * 60 * 1000),
|
||||||
|
sdkRegistryMax: posInt(env, "SDK_REGISTRY_MAX", 500),
|
||||||
|
ocrCapabilityId: env.OCR_CAPABILITY_ID ?? "ocr.page.extract.chandra-ocr-2.v1",
|
||||||
|
corsOrigins: (env.GATEWAY_CORS_ORIGINS ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((o) => o.trim().replace(/\/+$/, ""))
|
||||||
|
.filter((o) => o !== ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
46
gateway/src/crypto/seal.ts
Normal file
46
gateway/src/crypto/seal.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/crypto/seal.ts — AES-256-GCM sealing for custodial private keys.
|
||||||
|
*
|
||||||
|
* Each user's wallet private key (64-char hex) is encrypted at rest with the
|
||||||
|
* GATEWAY_MASTER_KEY (32 bytes). A fresh 12-byte nonce per row; the 16-byte GCM
|
||||||
|
* auth tag is stored alongside. Plaintext is produced only in-memory at request
|
||||||
|
* time (open()) and never persisted or logged.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
|
||||||
|
|
||||||
|
export interface Sealed {
|
||||||
|
/** 12-byte GCM nonce, hex. */
|
||||||
|
nonce: string;
|
||||||
|
/** ciphertext, hex. */
|
||||||
|
ct: string;
|
||||||
|
/** 16-byte GCM auth tag, hex. */
|
||||||
|
tag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function masterKeyBuffer(masterKeyHex: string): Buffer {
|
||||||
|
const buf = Buffer.from(masterKeyHex, "hex");
|
||||||
|
if (buf.length !== 32) {
|
||||||
|
throw new Error("seal: master key must decode to exactly 32 bytes");
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encrypt `plaintext` (e.g. a wallet privKey hex) under the master key. */
|
||||||
|
export function seal(plaintext: string, masterKeyHex: string): Sealed {
|
||||||
|
const key = masterKeyBuffer(masterKeyHex);
|
||||||
|
const nonce = randomBytes(12);
|
||||||
|
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
||||||
|
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
return { nonce: nonce.toString("hex"), ct: ct.toString("hex"), tag: tag.toString("hex") };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decrypt a Sealed blob under the master key. Throws on tamper / wrong key. */
|
||||||
|
export function open(sealed: Sealed, masterKeyHex: string): string {
|
||||||
|
const key = masterKeyBuffer(masterKeyHex);
|
||||||
|
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(sealed.nonce, "hex"));
|
||||||
|
decipher.setAuthTag(Buffer.from(sealed.tag, "hex"));
|
||||||
|
const pt = Buffer.concat([decipher.update(Buffer.from(sealed.ct, "hex")), decipher.final()]);
|
||||||
|
return pt.toString("utf8");
|
||||||
|
}
|
||||||
62
gateway/src/db/schema.ts
Normal file
62
gateway/src/db/schema.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/db/schema.ts — SQLite schema for the gateway.
|
||||||
|
*
|
||||||
|
* Three tables:
|
||||||
|
* api_keys — one custodial wallet per key; the wallet priv is AES-GCM sealed
|
||||||
|
* (enc_priv_*). The raw API key is NEVER stored — only sha256(key).
|
||||||
|
* usage — one row per billable unit (a one-shot completion or a closed chat
|
||||||
|
* session), for /account spend reporting and audit.
|
||||||
|
* sessions — open chat sessions (one escrow per session), for streaming turns
|
||||||
|
* and sweeper recovery of abandoned sessions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const CREATE_TABLES_SQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
key_hash TEXT NOT NULL UNIQUE,
|
||||||
|
key_prefix TEXT NOT NULL,
|
||||||
|
label TEXT,
|
||||||
|
wallet_pkh TEXT NOT NULL,
|
||||||
|
deposit_address TEXT NOT NULL,
|
||||||
|
enc_priv_nonce TEXT NOT NULL,
|
||||||
|
enc_priv_ct TEXT NOT NULL,
|
||||||
|
enc_priv_tag TEXT NOT NULL,
|
||||||
|
master_key_version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
disabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
demo INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_keys_hash ON api_keys(key_hash);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS usage (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
key_id TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
model TEXT,
|
||||||
|
capability_id TEXT,
|
||||||
|
supplier_pkh TEXT,
|
||||||
|
escrow_ref TEXT,
|
||||||
|
cost_lovelace TEXT,
|
||||||
|
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
failure_reason TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_key ON usage(key_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
key_id TEXT NOT NULL,
|
||||||
|
escrow_ref TEXT NOT NULL,
|
||||||
|
session_nonce TEXT NOT NULL,
|
||||||
|
supplier_base_url TEXT NOT NULL,
|
||||||
|
supplier_pkh TEXT NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
price_lovelace TEXT NOT NULL,
|
||||||
|
state TEXT NOT NULL,
|
||||||
|
opened_at INTEGER NOT NULL,
|
||||||
|
closed_at INTEGER
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_key ON sessions(key_id, opened_at DESC);
|
||||||
|
`;
|
||||||
209
gateway/src/db/store.ts
Normal file
209
gateway/src/db/store.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/db/store.ts — synchronous SQLite store (mirrors buyer/src/db/archive.ts).
|
||||||
|
*
|
||||||
|
* better-sqlite3 + WAL + prepared statements. Low write volume (≤1 row per
|
||||||
|
* request) so synchronous is fine and simpler than async.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { mkdirSync } from "fs";
|
||||||
|
import { join, resolve } from "path";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import type { Database as BetterDatabase } from "better-sqlite3";
|
||||||
|
import { CREATE_TABLES_SQL } from "./schema.js";
|
||||||
|
|
||||||
|
export interface ApiKeyRow {
|
||||||
|
id: string;
|
||||||
|
key_hash: string;
|
||||||
|
key_prefix: string;
|
||||||
|
label: string | null;
|
||||||
|
wallet_pkh: string;
|
||||||
|
deposit_address: string;
|
||||||
|
enc_priv_nonce: string;
|
||||||
|
enc_priv_ct: string;
|
||||||
|
enc_priv_tag: string;
|
||||||
|
master_key_version: number;
|
||||||
|
created_at: number;
|
||||||
|
disabled: number;
|
||||||
|
/** 1 = shared demo key: withdraw blocked, session-backed completions, per-IP limits. */
|
||||||
|
demo: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageRow {
|
||||||
|
id: string;
|
||||||
|
key_id: string;
|
||||||
|
created_at: number;
|
||||||
|
kind: string;
|
||||||
|
model: string | null;
|
||||||
|
capability_id: string | null;
|
||||||
|
supplier_pkh: string | null;
|
||||||
|
escrow_ref: string | null;
|
||||||
|
cost_lovelace: string | null;
|
||||||
|
prompt_tokens: number;
|
||||||
|
completion_tokens: number;
|
||||||
|
status: string;
|
||||||
|
failure_reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionRow {
|
||||||
|
id: string;
|
||||||
|
key_id: string;
|
||||||
|
escrow_ref: string;
|
||||||
|
session_nonce: string;
|
||||||
|
supplier_base_url: string;
|
||||||
|
supplier_pkh: string;
|
||||||
|
model: string;
|
||||||
|
price_lovelace: string;
|
||||||
|
state: string;
|
||||||
|
opened_at: number;
|
||||||
|
closed_at: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GatewayStore {
|
||||||
|
private readonly db: BetterDatabase;
|
||||||
|
|
||||||
|
private readonly sInsertKey;
|
||||||
|
private readonly sGetKeyByHash;
|
||||||
|
private readonly sGetKeyById;
|
||||||
|
private readonly sListKeys;
|
||||||
|
private readonly sListAllKeys;
|
||||||
|
private readonly sUpdateKeyEncryption;
|
||||||
|
private readonly sInsertUsage;
|
||||||
|
private readonly sSumCost;
|
||||||
|
private readonly sCountByKey;
|
||||||
|
private readonly sListUsage;
|
||||||
|
private readonly sInsertSession;
|
||||||
|
private readonly sGetSession;
|
||||||
|
private readonly sSetSessionState;
|
||||||
|
private readonly sListOpenSessions;
|
||||||
|
|
||||||
|
constructor(dbDir: string) {
|
||||||
|
const dir = resolve(dbDir);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
this.db = new Database(join(dir, "gateway.db"));
|
||||||
|
this.db.pragma("journal_mode = WAL");
|
||||||
|
this.db.exec(CREATE_TABLES_SQL);
|
||||||
|
|
||||||
|
// Additive migration for DBs created before the demo flag existed
|
||||||
|
// (CREATE TABLE IF NOT EXISTS won't alter an existing table).
|
||||||
|
const keyCols = this.db.pragma("table_info(api_keys)") as Array<{ name: string }>;
|
||||||
|
if (!keyCols.some((c) => c.name === "demo")) {
|
||||||
|
this.db.exec("ALTER TABLE api_keys ADD COLUMN demo INTEGER NOT NULL DEFAULT 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sInsertKey = this.db.prepare(`
|
||||||
|
INSERT INTO api_keys (
|
||||||
|
id, key_hash, key_prefix, label, wallet_pkh, deposit_address,
|
||||||
|
enc_priv_nonce, enc_priv_ct, enc_priv_tag, master_key_version, created_at, disabled, demo
|
||||||
|
) VALUES (
|
||||||
|
@id, @key_hash, @key_prefix, @label, @wallet_pkh, @deposit_address,
|
||||||
|
@enc_priv_nonce, @enc_priv_ct, @enc_priv_tag, @master_key_version, @created_at, 0, @demo
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
this.sGetKeyByHash = this.db.prepare(`SELECT * FROM api_keys WHERE key_hash = @key_hash`);
|
||||||
|
this.sGetKeyById = this.db.prepare(`SELECT * FROM api_keys WHERE id = @id`);
|
||||||
|
this.sListKeys = this.db.prepare(`SELECT * FROM api_keys WHERE disabled = 0`);
|
||||||
|
this.sListAllKeys = this.db.prepare(`SELECT * FROM api_keys`);
|
||||||
|
this.sUpdateKeyEncryption = this.db.prepare(`
|
||||||
|
UPDATE api_keys SET enc_priv_nonce = @enc_priv_nonce, enc_priv_ct = @enc_priv_ct,
|
||||||
|
enc_priv_tag = @enc_priv_tag, master_key_version = @master_key_version WHERE id = @id
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.sInsertUsage = this.db.prepare(`
|
||||||
|
INSERT INTO usage (
|
||||||
|
id, key_id, created_at, kind, model, capability_id, supplier_pkh, escrow_ref,
|
||||||
|
cost_lovelace, prompt_tokens, completion_tokens, status, failure_reason
|
||||||
|
) VALUES (
|
||||||
|
@id, @key_id, @created_at, @kind, @model, @capability_id, @supplier_pkh, @escrow_ref,
|
||||||
|
@cost_lovelace, @prompt_tokens, @completion_tokens, @status, @failure_reason
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
this.sSumCost = this.db.prepare(`
|
||||||
|
SELECT COALESCE(SUM(CAST(cost_lovelace AS INTEGER)), 0) AS total
|
||||||
|
FROM usage WHERE key_id = @key_id AND status = 'completed'
|
||||||
|
`);
|
||||||
|
this.sCountByKey = this.db.prepare(`SELECT COUNT(*) AS n FROM usage WHERE key_id = @key_id`);
|
||||||
|
this.sListUsage = this.db.prepare(`
|
||||||
|
SELECT * FROM usage WHERE key_id = @key_id ORDER BY created_at DESC LIMIT @limit
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.sInsertSession = this.db.prepare(`
|
||||||
|
INSERT INTO sessions (
|
||||||
|
id, key_id, escrow_ref, session_nonce, supplier_base_url, supplier_pkh,
|
||||||
|
model, price_lovelace, state, opened_at, closed_at
|
||||||
|
) VALUES (
|
||||||
|
@id, @key_id, @escrow_ref, @session_nonce, @supplier_base_url, @supplier_pkh,
|
||||||
|
@model, @price_lovelace, @state, @opened_at, NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
this.sGetSession = this.db.prepare(`SELECT * FROM sessions WHERE id = @id`);
|
||||||
|
this.sSetSessionState = this.db.prepare(`
|
||||||
|
UPDATE sessions SET state = @state, closed_at = @closed_at WHERE id = @id
|
||||||
|
`);
|
||||||
|
this.sListOpenSessions = this.db.prepare(`SELECT * FROM sessions WHERE state = 'open'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
insertKey(row: Omit<ApiKeyRow, "disabled" | "demo"> & { demo?: number }): void {
|
||||||
|
this.sInsertKey.run({ ...row, label: row.label ?? null, demo: row.demo ?? 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
getKeyByHash(keyHash: string): ApiKeyRow | undefined {
|
||||||
|
return this.sGetKeyByHash.get({ key_hash: keyHash }) as ApiKeyRow | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
getKeyById(id: string): ApiKeyRow | undefined {
|
||||||
|
return this.sGetKeyById.get({ id }) as ApiKeyRow | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
listKeys(): ApiKeyRow[] {
|
||||||
|
return this.sListKeys.all() as ApiKeyRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
listAllKeys(): ApiKeyRow[] {
|
||||||
|
return this.sListAllKeys.all() as ApiKeyRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
updateKeyEncryption(
|
||||||
|
id: string,
|
||||||
|
enc: { enc_priv_nonce: string; enc_priv_ct: string; enc_priv_tag: string; master_key_version: number },
|
||||||
|
): void {
|
||||||
|
this.sUpdateKeyEncryption.run({ id, ...enc });
|
||||||
|
}
|
||||||
|
|
||||||
|
insertUsage(row: UsageRow): void {
|
||||||
|
this.sInsertUsage.run(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
sumCostLovelace(keyId: string): bigint {
|
||||||
|
const r = this.sSumCost.get({ key_id: keyId }) as { total: number } | undefined;
|
||||||
|
return BigInt(r?.total ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
countUsage(keyId: string): number {
|
||||||
|
const r = this.sCountByKey.get({ key_id: keyId }) as { n: number } | undefined;
|
||||||
|
return r?.n ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
listUsage(keyId: string, limit: number): UsageRow[] {
|
||||||
|
return this.sListUsage.all({ key_id: keyId, limit }) as UsageRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
insertSession(row: Omit<SessionRow, "closed_at">): void {
|
||||||
|
this.sInsertSession.run(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSession(id: string): SessionRow | undefined {
|
||||||
|
return this.sGetSession.get({ id }) as SessionRow | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSessionState(id: string, state: string, closedAt: number | null): void {
|
||||||
|
this.sSetSessionState.run({ id, state, closed_at: closedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
listOpenSessions(): SessionRow[] {
|
||||||
|
return this.sListOpenSessions.all() as SessionRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
listOpenSessionsByKey(keyId: string): SessionRow[] {
|
||||||
|
return this.listOpenSessions().filter((s) => s.key_id === keyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
gateway/src/deps.ts
Normal file
16
gateway/src/deps.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/deps.ts — shared dependency bundle passed to route factories.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { LiveOgmiosProvider } from "@marketplace/shared/chain";
|
||||||
|
import type { GatewayConfig } from "./config.js";
|
||||||
|
import type { GatewayStore } from "./db/store.js";
|
||||||
|
import type { SdkRegistry } from "./sdk/registry.js";
|
||||||
|
|
||||||
|
export interface GatewayDeps {
|
||||||
|
config: GatewayConfig;
|
||||||
|
store: GatewayStore;
|
||||||
|
chain: LiveOgmiosProvider;
|
||||||
|
registry: SdkRegistry;
|
||||||
|
fetchFn: typeof globalThis.fetch;
|
||||||
|
}
|
||||||
73
gateway/src/index.ts
Normal file
73
gateway/src/index.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/index.ts — entry point.
|
||||||
|
*
|
||||||
|
* Wires config → shared chain + store → SdkRegistry → Express app, and starts
|
||||||
|
* the reclaim sweeper + wallet-health ticker. Boot is wrapped in runMain() so
|
||||||
|
* importing this module (tests) does not start the server.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { loadConfig } from "./config.js";
|
||||||
|
import { buildChainProvider } from "./chain.js";
|
||||||
|
import { GatewayStore } from "./db/store.js";
|
||||||
|
import { SdkRegistry } from "./sdk/registry.js";
|
||||||
|
import { createApp } from "./server.js";
|
||||||
|
import { startSweeper } from "./sweeper.js";
|
||||||
|
import { startWalletHealthTicker } from "./walletHealth.js";
|
||||||
|
import type { GatewayDeps } from "./deps.js";
|
||||||
|
|
||||||
|
export function buildDeps(env: Record<string, string | undefined>): GatewayDeps {
|
||||||
|
const config = loadConfig(env);
|
||||||
|
const store = new GatewayStore(config.dbDir);
|
||||||
|
const chain = buildChainProvider(config);
|
||||||
|
const registry = new SdkRegistry({
|
||||||
|
chain,
|
||||||
|
indexerUrl: config.indexerUrl,
|
||||||
|
networkId: config.networkId,
|
||||||
|
masterKeyHex: config.masterKeyHex,
|
||||||
|
max: config.sdkRegistryMax,
|
||||||
|
});
|
||||||
|
return { config, store, chain, registry, fetchFn: globalThis.fetch };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runMain(env: Record<string, string | undefined>): void {
|
||||||
|
const deps = buildDeps(env);
|
||||||
|
const app = createApp(deps);
|
||||||
|
|
||||||
|
const stopSweeper = startSweeper(deps);
|
||||||
|
const stopHealth = startWalletHealthTicker(deps);
|
||||||
|
|
||||||
|
const server = app.listen(deps.config.port, () => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
|
`[gateway] listening on :${deps.config.port} (network=${deps.config.networkId}, indexer=${deps.config.indexerUrl})`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const shutdown = (): void => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log("[gateway] shutting down...");
|
||||||
|
stopSweeper();
|
||||||
|
stopHealth();
|
||||||
|
server.close(() => process.exit(0));
|
||||||
|
};
|
||||||
|
process.once("SIGINT", shutdown);
|
||||||
|
process.once("SIGTERM", shutdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
const invokedDirectly =
|
||||||
|
typeof process !== "undefined" &&
|
||||||
|
process.argv?.[1] !== undefined &&
|
||||||
|
// Accept both path separators: on Windows process.argv[1] arrives with
|
||||||
|
// backslashes, so a forward-slash-only pattern silently never matches and
|
||||||
|
// the process exits 0 without ever starting the server.
|
||||||
|
/gateway[\\/](src|dist)[\\/]index\.(t|j)s$/.test(process.argv[1]);
|
||||||
|
|
||||||
|
if (invokedDirectly) {
|
||||||
|
try {
|
||||||
|
runMain(process.env);
|
||||||
|
} catch (err) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("[gateway] boot failed:", err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
gateway/src/middleware/apiKeyAuth.ts
Normal file
45
gateway/src/middleware/apiKeyAuth.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/middleware/apiKeyAuth.ts — Bearer API-key authentication.
|
||||||
|
*
|
||||||
|
* Looks up sha256(rawKey) in api_keys (unique index). The raw key is never
|
||||||
|
* stored. Attaches the matched row to req.gatewayKey for downstream handlers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
import type { Request, Response, NextFunction } from "express";
|
||||||
|
import type { GatewayStore, ApiKeyRow } from "../db/store.js";
|
||||||
|
import { unauthorized } from "../openai/errors.js";
|
||||||
|
import { sendError } from "./http.js";
|
||||||
|
|
||||||
|
export interface GatewayRequest extends Request {
|
||||||
|
gatewayKey?: ApiKeyRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashApiKey(raw: string): string {
|
||||||
|
return createHash("sha256").update(raw, "utf8").digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeApiKeyAuth(store: GatewayStore) {
|
||||||
|
return (req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
const header = req.header("authorization") ?? "";
|
||||||
|
const m = /^Bearer\s+(.+)$/i.exec(header.trim());
|
||||||
|
if (!m) {
|
||||||
|
sendError(res, unauthorized("missing or malformed Authorization: Bearer header"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = store.getKeyByHash(hashApiKey(m[1].trim()));
|
||||||
|
if (!row || row.disabled) {
|
||||||
|
sendError(res, unauthorized());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(req as GatewayRequest).gatewayKey = row;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the authenticated key off the request (throws if middleware missing). */
|
||||||
|
export function requireKey(req: Request): ApiKeyRow {
|
||||||
|
const row = (req as GatewayRequest).gatewayKey;
|
||||||
|
if (!row) throw unauthorized();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
25
gateway/src/middleware/http.ts
Normal file
25
gateway/src/middleware/http.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/middleware/http.ts — Express response/error helpers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { GatewayError, toGatewayError, toErrorBody } from "../openai/errors.js";
|
||||||
|
|
||||||
|
/** Send a GatewayError (or any error) as an OpenAI-shaped JSON error response. */
|
||||||
|
export function sendError(res: Response, err: unknown): void {
|
||||||
|
const ge = err instanceof GatewayError ? err : toGatewayError(err);
|
||||||
|
if (res.headersSent) {
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(ge.httpStatus).json(toErrorBody(ge));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap an async handler so rejections are mapped to error responses. */
|
||||||
|
export function asyncHandler(
|
||||||
|
fn: (req: Request, res: Response) => Promise<void>,
|
||||||
|
): (req: Request, res: Response) => void {
|
||||||
|
return (req, res) => {
|
||||||
|
fn(req, res).catch((err) => sendError(res, err));
|
||||||
|
};
|
||||||
|
}
|
||||||
87
gateway/src/middleware/rateLimit.ts
Normal file
87
gateway/src/middleware/rateLimit.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/middleware/rateLimit.ts — per-IP and per-key limiters.
|
||||||
|
*
|
||||||
|
* Small sliding-window limiter (same shape as buyer/src/auth.ts createRateLimiter,
|
||||||
|
* inlined to avoid coupling the operator-auth module into the gateway). Used to
|
||||||
|
* bound public /signup abuse (per IP) and per-key request volume (429 + Retry-After).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response, NextFunction } from "express";
|
||||||
|
import { rateLimited } from "../openai/errors.js";
|
||||||
|
import { sendError } from "./http.js";
|
||||||
|
import { requireKey } from "./apiKeyAuth.js";
|
||||||
|
|
||||||
|
interface Bucket {
|
||||||
|
count: number;
|
||||||
|
windowStart: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Limiter {
|
||||||
|
attempt(key: string): { allowed: boolean; retryAfterMs: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLimiter(opts: { max: number; windowMs: number }): Limiter {
|
||||||
|
const buckets = new Map<string, Bucket>();
|
||||||
|
return {
|
||||||
|
attempt(key: string) {
|
||||||
|
const t = Date.now();
|
||||||
|
const existing = buckets.get(key);
|
||||||
|
if (!existing || t - existing.windowStart >= opts.windowMs) {
|
||||||
|
buckets.set(key, { count: 1, windowStart: t });
|
||||||
|
return { allowed: true, retryAfterMs: 0 };
|
||||||
|
}
|
||||||
|
if (existing.count >= opts.max) {
|
||||||
|
return { allowed: false, retryAfterMs: Math.max(0, opts.windowMs - (t - existing.windowStart)) };
|
||||||
|
}
|
||||||
|
existing.count += 1;
|
||||||
|
return { allowed: true, retryAfterMs: 0 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type Keyer = (req: Request) => string;
|
||||||
|
|
||||||
|
function makeLimiter(opts: { max: number; windowMs: number }, keyOf: Keyer) {
|
||||||
|
const limiter = createLimiter(opts);
|
||||||
|
return (req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
const decision = limiter.attempt(keyOf(req));
|
||||||
|
if (!decision.allowed) {
|
||||||
|
const retrySec = Math.ceil(decision.retryAfterMs / 1000);
|
||||||
|
res.setHeader("Retry-After", retrySec.toString());
|
||||||
|
sendError(res, rateLimited(`rate limit exceeded; retry in ${retrySec}s`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-IP limiter (for the public /signup endpoint). */
|
||||||
|
export function ipRateLimit(opts: { max: number; windowMs: number }) {
|
||||||
|
return makeLimiter(opts, (req) => req.ip ?? "unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-API-key limiter (apply AFTER apiKeyAuth). Demo keys are exempt: the
|
||||||
|
* shared demo key would let one abusive IP exhaust everyone's budget — the
|
||||||
|
* per-IP demoIpRateLimit is the demo control instead. */
|
||||||
|
export function keyRateLimit(opts: { max: number; windowMs: number }) {
|
||||||
|
const limit = makeLimiter(opts, (req) => requireKey(req).id);
|
||||||
|
return (req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
if (requireKey(req).demo) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
limit(req, res, next);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-IP limiter that applies ONLY to demo-key requests (after apiKeyAuth). */
|
||||||
|
export function demoIpRateLimit(opts: { max: number; windowMs: number }) {
|
||||||
|
const limit = makeLimiter(opts, (req) => req.ip ?? "unknown");
|
||||||
|
return (req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
if (!requireKey(req).demo) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
limit(req, res, next);
|
||||||
|
};
|
||||||
|
}
|
||||||
402
gateway/src/ocr/extract.ts
Normal file
402
gateway/src/ocr/extract.ts
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/ocr/extract.ts — POST /v1/ocr/extract.
|
||||||
|
*
|
||||||
|
* One escrow per page against a model-scoped `ocr.page.extract.<model>.v1`
|
||||||
|
* supplier (config.ocrCapabilityId). Lifecycle mirrors the one-shot chat
|
||||||
|
* path, all inside the per-key mutex: parse → select supplier → preflight →
|
||||||
|
* per-candidate live busy pre-check → submitOcr (escrow → supplier call →
|
||||||
|
* receipt verify) → resolve Submitted ref → Accept (awaited) → record usage
|
||||||
|
* → wallet-health → respond.
|
||||||
|
*
|
||||||
|
* Response (200):
|
||||||
|
* {
|
||||||
|
* output_format, content,
|
||||||
|
* usage: { prompt_tokens, completion_tokens, total_tokens },
|
||||||
|
* x_vector: {
|
||||||
|
* receipt, receipt_signature, escrow_ref,
|
||||||
|
* receipt_labels: ["first-party-brokered"]
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Venue attribution: the caller MAY send
|
||||||
|
* X-Venue: venue slug (e.g. "apify"), ≤ 32 chars
|
||||||
|
* X-Run-User-Hash: sha256 of the venue-side runner id, 64 hex
|
||||||
|
* Both are logged on a structured line per settled job. The wedge-ledger
|
||||||
|
* emitter consumes these lines; a usage-table venue
|
||||||
|
* column is deliberately deferred to later work so this route stays
|
||||||
|
* schema-additive. payer_class is derived at ledger time from the hash
|
||||||
|
* (known first-party hashes vs the rest), never asserted here.
|
||||||
|
*
|
||||||
|
* Demo keys are rejected: the demo executor is chat-session-shaped and its
|
||||||
|
* wallet holds one escrow per session; OCR needs a funded per-key wallet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { TxConstructionError } from "@marketplace/shared/tx";
|
||||||
|
import {
|
||||||
|
ALLOWED_OCR_MIMES,
|
||||||
|
ALLOWED_OCR_OUTPUT_FORMATS,
|
||||||
|
MAX_OCR_IMAGE_B64_CHARS,
|
||||||
|
} from "@marketplace/shared/tx";
|
||||||
|
import { SupplierError, type ProgressEvent } from "@marketplace/buyer/sdk";
|
||||||
|
import type { GatewayDeps } from "../deps.js";
|
||||||
|
import type { GatewayStore } from "../db/store.js";
|
||||||
|
import { requireKey } from "../middleware/apiKeyAuth.js";
|
||||||
|
import { asyncHandler } from "../middleware/http.js";
|
||||||
|
import { selectCandidates, type SupplierCandidate } from "../routing/selectSupplier.js";
|
||||||
|
import { preflight } from "../onchain/preflight.js";
|
||||||
|
import { resolveSubmittedRef, acceptAndConfirm } from "../onchain/settle.js";
|
||||||
|
import { ensureWalletHealthy } from "../walletHealth.js";
|
||||||
|
import { badRequest, forbidden, notFound, paymentRequired, toGatewayError } from "../openai/errors.js";
|
||||||
|
|
||||||
|
/** Operator-model v0: HAL8 operates both the custodial buyer and the
|
||||||
|
* supplier fleet, so every receipt this route returns is first-party
|
||||||
|
* brokered. Becomes per-supplier metadata when
|
||||||
|
* control-independent supply exists. */
|
||||||
|
const RECEIPT_LABELS_V0 = ["first-party-brokered"] as const;
|
||||||
|
|
||||||
|
const B64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||||
|
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
|
||||||
|
const VENUE_RE = /^[a-z0-9-]{1,32}$/;
|
||||||
|
|
||||||
|
/** Bound on the live busy pre-check below - cheap enough that a stalled
|
||||||
|
* supplier never meaningfully delays the request. */
|
||||||
|
const SUPPLIER_STATUS_CHECK_TIMEOUT_MS = 2_000;
|
||||||
|
|
||||||
|
interface ParsedOcrRequest {
|
||||||
|
image_b64: string;
|
||||||
|
mime: string;
|
||||||
|
output_format: string;
|
||||||
|
/** "" = any model under the capability (single-model ids). */
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOcrRequest(body: unknown): ParsedOcrRequest {
|
||||||
|
if (typeof body !== "object" || body === null) {
|
||||||
|
throw badRequest("invalid_body", "request body must be a JSON object");
|
||||||
|
}
|
||||||
|
const b = body as Record<string, unknown>;
|
||||||
|
|
||||||
|
const image = b.image_b64;
|
||||||
|
if (typeof image !== "string" || image.length === 0) {
|
||||||
|
throw badRequest("invalid_image", "`image_b64` is required and must be a non-empty base64 string");
|
||||||
|
}
|
||||||
|
if (image.length > MAX_OCR_IMAGE_B64_CHARS) {
|
||||||
|
throw badRequest("image_too_large", `\`image_b64\` exceeds ${MAX_OCR_IMAGE_B64_CHARS} characters`);
|
||||||
|
}
|
||||||
|
if (!B64_RE.test(image)) {
|
||||||
|
throw badRequest("invalid_image", "`image_b64` must be plain base64 (no data: prefix, no whitespace)");
|
||||||
|
}
|
||||||
|
|
||||||
|
const mime = b.mime;
|
||||||
|
if (typeof mime !== "string" || !ALLOWED_OCR_MIMES.has(mime)) {
|
||||||
|
throw badRequest("invalid_mime", `\`mime\` must be one of: ${[...ALLOWED_OCR_MIMES].join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawFormat = b.output_format ?? "markdown";
|
||||||
|
if (typeof rawFormat !== "string" || !ALLOWED_OCR_OUTPUT_FORMATS.has(rawFormat)) {
|
||||||
|
throw badRequest(
|
||||||
|
"invalid_output_format",
|
||||||
|
`\`output_format\` must be one of: ${[...ALLOWED_OCR_OUTPUT_FORMATS].join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let model = "";
|
||||||
|
if (b.model !== undefined && b.model !== null) {
|
||||||
|
if (typeof b.model !== "string" || b.model === "") {
|
||||||
|
throw badRequest("invalid_model", "`model` must be a non-empty string when provided");
|
||||||
|
}
|
||||||
|
model = b.model;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { image_b64: image, mime, output_format: rawFormat, model };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sanitized venue-attribution headers. Invalid values drop to "".
|
||||||
|
* Exported for direct unit testing (I3 review finding) — it is the only
|
||||||
|
* gate on what lands in the ocr_settled/ocr_failed structured log lines the
|
||||||
|
* wedge-ledger emitter reads, so it needs coverage independent of the HTTP
|
||||||
|
* route. */
|
||||||
|
export function venueAttribution(req: Request): { venue: string; runUserHash: string } {
|
||||||
|
const venueRaw = req.header("X-Venue") ?? "";
|
||||||
|
const hashRaw = req.header("X-Run-User-Hash") ?? "";
|
||||||
|
return {
|
||||||
|
venue: VENUE_RE.test(venueRaw) ? venueRaw : "",
|
||||||
|
runUserHash: HEX64_RE.test(hashRaw) ? hashRaw.toLowerCase() : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live busy pre-check, run immediately before a candidate's escrow posts.
|
||||||
|
* Mirrors sessions.ts's startChat pre-check: same signal ("working" on the
|
||||||
|
* supplier's own /status), same best-effort posture. Posting an escrow only
|
||||||
|
* to have the supplier reject as busy strands the escrow until reclaim and
|
||||||
|
* burns the tx fee, so this is worth a bounded, cheap HTTP round trip.
|
||||||
|
*
|
||||||
|
* Never a gate: the indexer already filters candidates on advert status, so
|
||||||
|
* a timeout, network error, non-2xx, or malformed body all resolve to "not
|
||||||
|
* busy" (unknown, not blocking) - a briefly-unreachable /status must never
|
||||||
|
* stop the candidate from being attempted.
|
||||||
|
*/
|
||||||
|
async function isSupplierBusy(
|
||||||
|
endpointUrl: string,
|
||||||
|
fetchFn: typeof globalThis.fetch,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await fetchFn(`${endpointUrl.replace(/\/+$/, "")}/status`, {
|
||||||
|
signal: AbortSignal.timeout(SUPPLIER_STATUS_CHECK_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (!res.ok) return false;
|
||||||
|
const body = (await res.json()) as unknown;
|
||||||
|
const status = body && typeof body === "object" ? (body as { status?: unknown }).status : undefined;
|
||||||
|
return status === "working";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordOcrUsage(
|
||||||
|
store: GatewayStore,
|
||||||
|
p: {
|
||||||
|
keyId: string;
|
||||||
|
capabilityId: string;
|
||||||
|
model: string;
|
||||||
|
supplierPkh: string | null;
|
||||||
|
escrowRef: string | null;
|
||||||
|
costLovelace: bigint | null;
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
status: "completed" | "failed";
|
||||||
|
failureReason?: string;
|
||||||
|
},
|
||||||
|
): void {
|
||||||
|
store.insertUsage({
|
||||||
|
id: randomUUID(),
|
||||||
|
key_id: p.keyId,
|
||||||
|
created_at: Date.now(),
|
||||||
|
kind: "ocr",
|
||||||
|
model: p.model,
|
||||||
|
capability_id: p.capabilityId,
|
||||||
|
supplier_pkh: p.supplierPkh,
|
||||||
|
escrow_ref: p.escrowRef,
|
||||||
|
cost_lovelace: p.costLovelace !== null ? p.costLovelace.toString() : null,
|
||||||
|
prompt_tokens: p.promptTokens,
|
||||||
|
completion_tokens: p.completionTokens,
|
||||||
|
status: p.status,
|
||||||
|
failure_reason: p.failureReason ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeOcrExtractHandler(deps: GatewayDeps) {
|
||||||
|
return asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const keyRow = requireKey(req);
|
||||||
|
if (keyRow.demo) {
|
||||||
|
throw forbidden("demo_key_unsupported", "demo keys cannot use /v1/ocr/extract; create a funded API key");
|
||||||
|
}
|
||||||
|
const ctx = deps.registry.getContext(keyRow);
|
||||||
|
const parsed = parseOcrRequest(req.body);
|
||||||
|
const attribution = venueAttribution(req);
|
||||||
|
await ctx.mutex.run(() => runOcrOneShot(deps, keyRow.id, ctx, parsed, attribution, res));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runOcrOneShot(
|
||||||
|
deps: GatewayDeps,
|
||||||
|
keyId: string,
|
||||||
|
ctx: ReturnType<GatewayDeps["registry"]["getContext"]>,
|
||||||
|
parsed: ParsedOcrRequest,
|
||||||
|
attribution: { venue: string; runUserHash: string },
|
||||||
|
res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const { config, store, chain, fetchFn } = deps;
|
||||||
|
const capabilityId = config.ocrCapabilityId;
|
||||||
|
|
||||||
|
// Hoisted above the try so the catch block can report whatever is known
|
||||||
|
// even when the failure happens AFTER an escrow was posted (e.g. the
|
||||||
|
// supplier call or settle step fails) — these fields are the
|
||||||
|
// reconciliation spine that joins on-chain settlement to platform
|
||||||
|
// billing, and losing them makes paid-but-failed jobs unreconcilable
|
||||||
|
// (I2 review finding).
|
||||||
|
let result: Awaited<ReturnType<typeof ctx.sdk.submitOcr>> | undefined;
|
||||||
|
let used: SupplierCandidate | undefined;
|
||||||
|
let escrowRefStr: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const candidates = await selectCandidates({
|
||||||
|
indexerUrl: config.indexerUrl,
|
||||||
|
model: parsed.model,
|
||||||
|
capabilityId,
|
||||||
|
fetchFn,
|
||||||
|
});
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
throw notFound(
|
||||||
|
"model_not_found",
|
||||||
|
parsed.model === ""
|
||||||
|
? `no available supplier for capability "${capabilityId}"`
|
||||||
|
: `no available supplier for model "${parsed.model}" under "${capabilityId}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const primary = candidates[0];
|
||||||
|
const pf = await preflight(chain, ctx.walletKey.address, primary);
|
||||||
|
if (!pf.ok) {
|
||||||
|
throw paymentRequired(
|
||||||
|
pf.collateralOk
|
||||||
|
? "insufficient balance for this request"
|
||||||
|
: "wallet has no pure-AP3X UTxO ≥ 5 AP3X for collateral; deposit a little more AP3X",
|
||||||
|
{
|
||||||
|
x_vector: {
|
||||||
|
required_lovelace: pf.requiredLovelace.toString(),
|
||||||
|
available_lovelace: pf.availableLovelace.toString(),
|
||||||
|
collateral_ok: pf.collateralOk,
|
||||||
|
deposit_address: ctx.walletKey.address,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit. Fall back to the next supplier ONLY on a pre-post
|
||||||
|
// TxConstructionError (no escrow was posted). A SupplierError means the
|
||||||
|
// escrow is already posted — do not retry (the sweeper recovers it).
|
||||||
|
//
|
||||||
|
// submitOcr posts the escrow internally and only returns escrowRef when
|
||||||
|
// it resolves. When the supplier call AFTER the post throws
|
||||||
|
// (SupplierError: timeout, network failure, non-2xx, malformed
|
||||||
|
// response, receipt mismatch) the escrow already landed on chain but
|
||||||
|
// escrowRefStr below would otherwise stay null — the OCR upstream has a
|
||||||
|
// 120s budget, so this is likely the most common paid-but-failed shape.
|
||||||
|
// The SDK emits a "progress" event of type "escrow_posted" (carrying
|
||||||
|
// escrow_ref) the instant the tx confirms, so listen for it around each
|
||||||
|
// attempt and capture the ref from the event rather than only from
|
||||||
|
// submitOcr's return value (I2 follow-up).
|
||||||
|
let lastErr: unknown;
|
||||||
|
for (const cand of candidates) {
|
||||||
|
// Live pre-check, run right here (not once at the top of the request):
|
||||||
|
// skip a candidate that is busy right now instead of paying to find
|
||||||
|
// out. See isSupplierBusy's docstring for the exact signal + posture.
|
||||||
|
if (await isSupplierBusy(cand.endpointUrl, fetchFn)) {
|
||||||
|
lastErr = new SupplierError("supplier_busy", {
|
||||||
|
status: 409,
|
||||||
|
message: "supplier is busy with another job",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onProgress = (ev: unknown): void => {
|
||||||
|
const e = ev as ProgressEvent;
|
||||||
|
if (e.type === "escrow_posted" && e.escrow_ref) escrowRefStr = e.escrow_ref;
|
||||||
|
};
|
||||||
|
ctx.sdk.on("progress", onProgress);
|
||||||
|
try {
|
||||||
|
result = await ctx.sdk.submitOcr({
|
||||||
|
advertRef: cand.advertRef,
|
||||||
|
image_b64: parsed.image_b64,
|
||||||
|
mime: parsed.mime,
|
||||||
|
output_format: parsed.output_format,
|
||||||
|
payment_lovelace: cand.priceLovelace,
|
||||||
|
});
|
||||||
|
used = cand;
|
||||||
|
break;
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err;
|
||||||
|
if (err instanceof TxConstructionError) continue;
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
ctx.sdk.off("progress", onProgress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!result || !used) {
|
||||||
|
throw toGatewayError(lastErr ?? new Error("no supplier could be engaged"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settle: resolve the live Submitted ref, Accept, await confirmation.
|
||||||
|
escrowRefStr = `${result.escrowRef.txHash}#${result.escrowRef.index}`;
|
||||||
|
const submittedRef = await resolveSubmittedRef({
|
||||||
|
indexerUrl: config.indexerUrl,
|
||||||
|
buyerPkh: ctx.walletKey.pubKeyHash,
|
||||||
|
originalRefStr: escrowRefStr,
|
||||||
|
fetchFn,
|
||||||
|
});
|
||||||
|
await acceptAndConfirm(chain, ctx.walletKey, submittedRef);
|
||||||
|
|
||||||
|
recordOcrUsage(store, {
|
||||||
|
keyId,
|
||||||
|
capabilityId,
|
||||||
|
model: used.model,
|
||||||
|
supplierPkh: used.supplierPkh,
|
||||||
|
escrowRef: escrowRefStr,
|
||||||
|
costLovelace: used.priceLovelace,
|
||||||
|
promptTokens: result.usage.prompt_tokens,
|
||||||
|
completionTokens: result.usage.completion_tokens,
|
||||||
|
status: "completed",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Structured settle line for the wedge-ledger emitter. One line per
|
||||||
|
// settled job; venue/run_user_hash empty when the
|
||||||
|
// caller sent none.
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
evt: "ocr_settled",
|
||||||
|
escrow_ref: escrowRefStr,
|
||||||
|
capability_id: capabilityId,
|
||||||
|
model: used.model,
|
||||||
|
supplier_pkh: used.supplierPkh,
|
||||||
|
cost_lovelace: used.priceLovelace.toString(),
|
||||||
|
receipt_labels: RECEIPT_LABELS_V0,
|
||||||
|
venue: attribution.venue,
|
||||||
|
run_user_hash: attribution.runUserHash,
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Re-shape the wallet ({collateral, working}) so the next request keeps
|
||||||
|
// a valid collateral UTxO. Best-effort; serialized by the mutex.
|
||||||
|
await ensureWalletHealthy(chain, ctx.walletKey).catch((e) =>
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("[gateway] wallet-health after ocr:", e instanceof Error ? e.message : e),
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
output_format: result.output_format,
|
||||||
|
content: result.content,
|
||||||
|
usage: result.usage,
|
||||||
|
x_vector: {
|
||||||
|
receipt: result.receipt,
|
||||||
|
receipt_signature: result.receiptSignature,
|
||||||
|
escrow_ref: escrowRefStr,
|
||||||
|
receipt_labels: RECEIPT_LABELS_V0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
recordOcrUsage(store, {
|
||||||
|
keyId,
|
||||||
|
capabilityId,
|
||||||
|
model: used?.model ?? parsed.model,
|
||||||
|
supplierPkh: used?.supplierPkh ?? null,
|
||||||
|
escrowRef: escrowRefStr,
|
||||||
|
costLovelace: used?.priceLovelace ?? null,
|
||||||
|
promptTokens: result?.usage.prompt_tokens ?? 0,
|
||||||
|
completionTokens: result?.usage.completion_tokens ?? 0,
|
||||||
|
status: "failed",
|
||||||
|
failureReason: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Structured failure line mirroring ocr_settled's shape (same venue /
|
||||||
|
// run_user_hash / escrow_ref fields) so paid-but-failed jobs — an
|
||||||
|
// escrow posted on-chain, then a later step (supplier call, settle)
|
||||||
|
// failed — can still be reconciled against on-chain state instead of
|
||||||
|
// vanishing from the wedge-ledger emitter's input (I2 review finding).
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
evt: "ocr_failed",
|
||||||
|
escrow_ref: escrowRefStr,
|
||||||
|
capability_id: capabilityId,
|
||||||
|
model: used?.model ?? parsed.model,
|
||||||
|
supplier_pkh: used?.supplierPkh ?? null,
|
||||||
|
venue: attribution.venue,
|
||||||
|
run_user_hash: attribution.runUserHash,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
89
gateway/src/onchain/preflight.ts
Normal file
89
gateway/src/onchain/preflight.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/onchain/preflight.ts — balance + collateral pre-flight.
|
||||||
|
*
|
||||||
|
* The buyer wallet funds the FULL escrow at PostEscrow: price + buyer_bond +
|
||||||
|
* supplier_bond, raised to the Submitted-state min-ada floor so long
|
||||||
|
* capability ids (e.g. the 33-byte OCR one) don't get bumped mid-flight —
|
||||||
|
* see escrowLockFloor (packages/shared/.../minAdaFloor.ts) and
|
||||||
|
* postOcrEscrow.ts:205, the SAME floor a POST actually locks. Accept/Reclaim
|
||||||
|
* are Plutus script-spends that also need a pure-AP3X collateral UTxO ≥ 5
|
||||||
|
* AP3X (assertCollateralCandidate). So required = escrowLockFloor(price +
|
||||||
|
* buyer_bond + supplier_bond) + collateral + fee reserve, AND the wallet
|
||||||
|
* must currently hold a pure-AP3X UTxO ≥ collateral. We check both up front
|
||||||
|
* and return a clean 402 instead of failing mid-flight.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ChainProvider, Utxo } from "@marketplace/shared/chain";
|
||||||
|
import type { EscrowDatum } from "@marketplace/shared/cbor";
|
||||||
|
import { escrowLockFloor } from "@marketplace/shared/tx";
|
||||||
|
|
||||||
|
export const COLLATERAL_MIN_LOVELACE = 5_000_000n;
|
||||||
|
export const FEE_RESERVE_LOVELACE = 2_000_000n;
|
||||||
|
|
||||||
|
export function totalLovelace(utxos: Utxo[]): bigint {
|
||||||
|
return utxos.reduce((sum, u) => sum + u.lovelace, 0n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True iff some UTxO is pure-AP3X (no native assets) and ≥ collateral floor. */
|
||||||
|
export function hasCollateral(utxos: Utxo[]): boolean {
|
||||||
|
return utxos.some(
|
||||||
|
(u) => u.lovelace >= COLLATERAL_MIN_LOVELACE && Object.keys(u.assets).length === 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreflightResult {
|
||||||
|
ok: boolean;
|
||||||
|
availableLovelace: bigint;
|
||||||
|
requiredLovelace: bigint;
|
||||||
|
collateralOk: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreflightCost {
|
||||||
|
capabilityId: string;
|
||||||
|
priceLovelace: bigint;
|
||||||
|
buyerBondLovelace: bigint;
|
||||||
|
supplierBondLovelace: bigint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Placeholder datum used only to size-estimate the Submitted-state min-ada
|
||||||
|
* floor. Every field except capability_id and the cost amounts is a
|
||||||
|
* fixed-size stand-in (real hash/pkh byte widths, a realistic 13-digit
|
||||||
|
* POSIX-ms timestamp) — capability_id's actual length is what drives the
|
||||||
|
* estimate, exactly as in escrowLockFloor's own contract. */
|
||||||
|
function representativeEscrowDatum(cost: PreflightCost): EscrowDatum {
|
||||||
|
return {
|
||||||
|
buyer_pkh: "3b2fdaed0398485ff35887fb7d0ad2025348864407854f7831dc6a55",
|
||||||
|
supplier_pkh: "401af418a7e6fb106277292881fcdeaa1cc15b5b112e9c8bdb2d46c9",
|
||||||
|
advert_ref: { txHash: "a6".repeat(32), index: 0 },
|
||||||
|
capability_id: cost.capabilityId,
|
||||||
|
request_spec_hash: "1b".repeat(32),
|
||||||
|
prompt_hash: "23".repeat(32),
|
||||||
|
payment_lovelace: cost.priceLovelace,
|
||||||
|
buyer_bond_lovelace: cost.buyerBondLovelace,
|
||||||
|
supplier_bond_lovelace: cost.supplierBondLovelace,
|
||||||
|
deliver_by: 1_786_105_955_257,
|
||||||
|
posted_at: 1_786_105_625_257,
|
||||||
|
submitted_at: null,
|
||||||
|
result_receipt_hash: null,
|
||||||
|
state: "Open",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function preflight(
|
||||||
|
chain: ChainProvider,
|
||||||
|
address: string,
|
||||||
|
cost: PreflightCost,
|
||||||
|
): Promise<PreflightResult> {
|
||||||
|
const utxos = await chain.queryUtxosByAddress(address);
|
||||||
|
const available = totalLovelace(utxos);
|
||||||
|
const collateralOk = hasCollateral(utxos);
|
||||||
|
const economicTotal = cost.priceLovelace + cost.buyerBondLovelace + cost.supplierBondLovelace;
|
||||||
|
const escrowFloor = escrowLockFloor(representativeEscrowDatum(cost), economicTotal);
|
||||||
|
const required = escrowFloor + COLLATERAL_MIN_LOVELACE + FEE_RESERVE_LOVELACE;
|
||||||
|
return {
|
||||||
|
ok: available >= required && collateralOk,
|
||||||
|
availableLovelace: available,
|
||||||
|
requiredLovelace: required,
|
||||||
|
collateralOk,
|
||||||
|
};
|
||||||
|
}
|
||||||
125
gateway/src/onchain/settle.ts
Normal file
125
gateway/src/onchain/settle.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/onchain/settle.ts — settlement + recovery helpers.
|
||||||
|
*
|
||||||
|
* submitPrompt returns the ORIGINAL Open escrow ref, but the supplier's
|
||||||
|
* Claim→Submit moves the live escrow to a NEW ref. To Accept (settle) we must
|
||||||
|
* resolve the current Submitted ref via the indexer (mirrors buyer /v1/accept:
|
||||||
|
* match the lineage by posted_at). Because the per-key mutex serializes a
|
||||||
|
* wallet's escrows, at settle time the wallet has at most one in-flight escrow,
|
||||||
|
* so a lone Submitted row is unambiguously ours.
|
||||||
|
*
|
||||||
|
* acceptResult/reclaim on the SDK are fire-and-forget; here we buildAcceptTx /
|
||||||
|
* buildReclaimTx and AWAIT confirmation so a returned success means settled.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ChainProvider, OutputReference } from "@marketplace/shared/chain";
|
||||||
|
import type { WalletKey } from "@marketplace/shared/tx";
|
||||||
|
import { buildAcceptTx, buildReclaimTx } from "@marketplace/shared/tx";
|
||||||
|
import { parseRef } from "../routing/selectSupplier.js";
|
||||||
|
import { GatewayError } from "../openai/errors.js";
|
||||||
|
|
||||||
|
export interface EscrowRow {
|
||||||
|
utxo_ref: string;
|
||||||
|
state: string;
|
||||||
|
posted_at: number;
|
||||||
|
/** Validator reclaim floor (indexer serves it; absent on very old rows). */
|
||||||
|
deliver_by?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEscrows(
|
||||||
|
indexerUrl: string,
|
||||||
|
buyerPkh: string,
|
||||||
|
fetchFn: typeof globalThis.fetch,
|
||||||
|
): Promise<EscrowRow[]> {
|
||||||
|
const res = await fetchFn(`${indexerUrl}/escrows?buyer=${buyerPkh}`);
|
||||||
|
if (!res.ok) throw new Error(`indexer /escrows returned ${res.status}`);
|
||||||
|
const body = (await res.json()) as unknown;
|
||||||
|
if (!Array.isArray(body)) throw new Error("indexer /escrows did not return an array");
|
||||||
|
return body as EscrowRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Poll the indexer for the current Submitted UTxO of our in-flight escrow. */
|
||||||
|
export async function resolveSubmittedRef(opts: {
|
||||||
|
indexerUrl: string;
|
||||||
|
buyerPkh: string;
|
||||||
|
originalRefStr: string;
|
||||||
|
fetchFn?: typeof globalThis.fetch;
|
||||||
|
timeoutMs?: number;
|
||||||
|
intervalMs?: number;
|
||||||
|
}): Promise<OutputReference> {
|
||||||
|
const fetchFn = opts.fetchFn ?? globalThis.fetch;
|
||||||
|
const timeoutMs = opts.timeoutMs ?? 60_000;
|
||||||
|
const intervalMs = opts.intervalMs ?? 2_000;
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
|
||||||
|
let lastErr: unknown;
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
const rows = await fetchEscrows(opts.indexerUrl, opts.buyerPkh, fetchFn);
|
||||||
|
|
||||||
|
// 1. Direct: the original ref already shows Submitted.
|
||||||
|
const direct = rows.find((r) => r.utxo_ref === opts.originalRefStr && r.state === "Submitted");
|
||||||
|
if (direct) return refOrThrow(direct.utxo_ref);
|
||||||
|
|
||||||
|
// 2. Lineage: find the original row to read posted_at, then the Submitted
|
||||||
|
// row sharing it (the validator preserves posted_at across states).
|
||||||
|
const lineage = rows.find((r) => r.utxo_ref === opts.originalRefStr);
|
||||||
|
if (lineage) {
|
||||||
|
const submitted = rows.find((r) => r.posted_at === lineage.posted_at && r.state === "Submitted");
|
||||||
|
if (submitted) return refOrThrow(submitted.utxo_ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback: the mutex guarantees ≤1 in-flight escrow for this wallet,
|
||||||
|
// so a single Submitted row is ours.
|
||||||
|
const submittedRows = rows.filter((r) => r.state === "Submitted");
|
||||||
|
if (submittedRows.length === 1) return refOrThrow(submittedRows[0].utxo_ref);
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err;
|
||||||
|
}
|
||||||
|
if (Date.now() >= deadline) {
|
||||||
|
throw new GatewayError(
|
||||||
|
502,
|
||||||
|
"server_error",
|
||||||
|
"escrow_settle_failed",
|
||||||
|
`could not resolve Submitted escrow within ${timeoutMs}ms${lastErr ? `: ${String(lastErr)}` : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await sleep(intervalMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refOrThrow(refStr: string): OutputReference {
|
||||||
|
const ref = parseRef(refStr);
|
||||||
|
if (!ref) {
|
||||||
|
throw new GatewayError(502, "server_error", "escrow_settle_failed", `bad escrow ref ${refStr}`);
|
||||||
|
}
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build + submit + await the Accept tx (Submitted → Accepted, terminal). */
|
||||||
|
export async function acceptAndConfirm(
|
||||||
|
chain: ChainProvider,
|
||||||
|
walletKey: WalletKey,
|
||||||
|
escrowRef: OutputReference,
|
||||||
|
awaitTimeoutMs = 120_000,
|
||||||
|
): Promise<string> {
|
||||||
|
const built = await buildAcceptTx({ chain, buyerKey: walletKey, escrowRef });
|
||||||
|
await chain.awaitTx(built.expectedTxHash, awaitTimeoutMs);
|
||||||
|
return built.expectedTxHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build + submit + await a Reclaim tx (Open/Claimed → Reclaimed). */
|
||||||
|
export async function reclaimAndConfirm(
|
||||||
|
chain: ChainProvider,
|
||||||
|
walletKey: WalletKey,
|
||||||
|
escrowRef: OutputReference,
|
||||||
|
awaitTimeoutMs = 120_000,
|
||||||
|
): Promise<string> {
|
||||||
|
const built = await buildReclaimTx({ chain, buyerKey: walletKey, escrowRef });
|
||||||
|
await chain.awaitTx(built.expectedTxHash, awaitTimeoutMs);
|
||||||
|
return built.expectedTxHash;
|
||||||
|
}
|
||||||
116
gateway/src/openai/affinity.ts
Normal file
116
gateway/src/openai/affinity.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/openai/affinity.ts — transcript-prefix session matching.
|
||||||
|
*
|
||||||
|
* Stateless OpenAI clients resend the FULL message history each call; sessions
|
||||||
|
* are stateful. An incoming request belongs to an open session when that
|
||||||
|
* session's mirrored transcript is a prefix of the incoming messages — the
|
||||||
|
* suffix (delta) is the next turn. Client-authored messages (user/system/tool)
|
||||||
|
* must match byte-exactly, but clients routinely reshape ASSISTANT text when
|
||||||
|
* echoing it back (strip <think> reasoning, re-render whitespace), so
|
||||||
|
* assistant content is compared normalized. Strict (byte-exact) matches are
|
||||||
|
* preferred over lenient ones to disambiguate concurrent threads that share a
|
||||||
|
* user-message prefix. The mirror itself is NEVER rewritten with client text:
|
||||||
|
* the delta is forwarded verbatim and mirrored only after a successful turn,
|
||||||
|
* so the gateway mirror and the supplier transcript stay hash-identical for
|
||||||
|
* the receipt response_hash.
|
||||||
|
*
|
||||||
|
* Pure module — no I/O — so the matching rules are unit-testable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { chatMessagesEquivalent, toolCallsEqual, type ChatMessage } from "@marketplace/shared/tx";
|
||||||
|
|
||||||
|
export interface AffinityCandidate {
|
||||||
|
sessionId: string;
|
||||||
|
model: string;
|
||||||
|
mirror: ChatMessage[];
|
||||||
|
lastUsedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AffinityMatch {
|
||||||
|
sessionId: string;
|
||||||
|
/** Messages the session has not seen yet — forwarded as the next turn. */
|
||||||
|
delta: ChatMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canonicalize assistant text the way clients mangle it when re-echoing
|
||||||
|
* history: drop <think>…</think> reasoning blocks, collapse whitespace runs,
|
||||||
|
* trim. Exported for tests. */
|
||||||
|
export function normalizeAssistantContent(content: string): string {
|
||||||
|
return content
|
||||||
|
.replace(/<think>[\s\S]*?<\/think>/gi, "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lenient equality: strict for client-authored roles (the client sends the
|
||||||
|
* same bytes both times); assistant content is compared normalized while
|
||||||
|
* assistant tool_calls stay strict (ids anchor tool-result turns). */
|
||||||
|
function chatMessagesLenientEquivalent(a: ChatMessage, b: ChatMessage): boolean {
|
||||||
|
if (a.role !== b.role) return false;
|
||||||
|
if (a.role !== "assistant") return chatMessagesEquivalent(a, b);
|
||||||
|
return (
|
||||||
|
normalizeAssistantContent(a.content) === normalizeAssistantContent(b.content) &&
|
||||||
|
toolCallsEqual(a.tool_calls, b.tool_calls)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type PrefixQuality = "strict" | "lenient";
|
||||||
|
|
||||||
|
function classifyPrefix(mirror: ChatMessage[], incoming: ChatMessage[]): PrefixQuality | null {
|
||||||
|
if (mirror.length >= incoming.length) return null;
|
||||||
|
let strict = true;
|
||||||
|
for (let i = 0; i < mirror.length; i++) {
|
||||||
|
if (chatMessagesEquivalent(mirror[i], incoming[i])) continue;
|
||||||
|
if (!chatMessagesLenientEquivalent(mirror[i], incoming[i])) return null;
|
||||||
|
strict = false;
|
||||||
|
}
|
||||||
|
return strict ? "strict" : "lenient";
|
||||||
|
}
|
||||||
|
|
||||||
|
function deltaRespondable(delta: ChatMessage[]): boolean {
|
||||||
|
const last = delta[delta.length - 1];
|
||||||
|
return last !== undefined && (last.role === "user" || last.role === "tool");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop trailing assistant/system messages so the history ends with a
|
||||||
|
* respondable message (regenerate semantics for clients that send a trailing
|
||||||
|
* assistant turn). Empty result = nothing to answer. */
|
||||||
|
export function trimToRespondable(messages: ChatMessage[]): ChatMessage[] {
|
||||||
|
let end = messages.length;
|
||||||
|
while (end > 0 && messages[end - 1].role !== "user" && messages[end - 1].role !== "tool") end--;
|
||||||
|
return messages.slice(0, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the open session whose mirror best matches the incoming history.
|
||||||
|
* Strict prefixes beat lenient ones; then longest mirror wins (most
|
||||||
|
* conversation reuse); ties go to the most recently used session. Returns
|
||||||
|
* null when nothing matches — caller opens a new session and replays the full
|
||||||
|
* history. Note `incoming.length <= mirror.length` never matches (a client
|
||||||
|
* retry of an already-answered request is a miss by design: the session
|
||||||
|
* transcript must stay append-only).
|
||||||
|
*/
|
||||||
|
export function matchSessionPrefix(
|
||||||
|
candidates: AffinityCandidate[],
|
||||||
|
incoming: ChatMessage[],
|
||||||
|
model: string,
|
||||||
|
): AffinityMatch | null {
|
||||||
|
let best: { cand: AffinityCandidate; quality: PrefixQuality } | null = null;
|
||||||
|
for (const cand of candidates) {
|
||||||
|
if (cand.model !== model) continue;
|
||||||
|
const quality = classifyPrefix(cand.mirror, incoming);
|
||||||
|
if (quality === null) continue;
|
||||||
|
if (!deltaRespondable(incoming.slice(cand.mirror.length))) continue;
|
||||||
|
if (
|
||||||
|
best === null ||
|
||||||
|
(quality === "strict" && best.quality === "lenient") ||
|
||||||
|
(quality === best.quality &&
|
||||||
|
(cand.mirror.length > best.cand.mirror.length ||
|
||||||
|
(cand.mirror.length === best.cand.mirror.length && cand.lastUsedAt > best.cand.lastUsedAt)))
|
||||||
|
) {
|
||||||
|
best = { cand, quality };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!best) return null;
|
||||||
|
return { sessionId: best.cand.sessionId, delta: incoming.slice(best.cand.mirror.length) };
|
||||||
|
}
|
||||||
233
gateway/src/openai/chatCompletions.ts
Normal file
233
gateway/src/openai/chatCompletions.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/openai/chatCompletions.ts — POST /openai/v1/chat/completions.
|
||||||
|
*
|
||||||
|
* One escrow per call against an llm.text.generate.v1 supplier. Lifecycle (all
|
||||||
|
* inside the per-key mutex): route → preflight → submitPrompt → resolve the
|
||||||
|
* Submitted ref + Accept (awaited) → record usage → wallet-health → respond.
|
||||||
|
* stream:true returns the completion as a buffered pseudo-stream (one delta +
|
||||||
|
* final usage chunk + [DONE]) with keepalive comments during the escrow wait.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { TxConstructionError } from "@marketplace/shared/tx";
|
||||||
|
import type { GatewayDeps } from "../deps.js";
|
||||||
|
import type { ApiKeyRow, GatewayStore } from "../db/store.js";
|
||||||
|
import type { KeyContext } from "../sdk/registry.js";
|
||||||
|
import { requireKey } from "../middleware/apiKeyAuth.js";
|
||||||
|
import { asyncHandler } from "../middleware/http.js";
|
||||||
|
import { selectCandidates } from "../routing/selectSupplier.js";
|
||||||
|
import { preflight } from "../onchain/preflight.js";
|
||||||
|
import { resolveSubmittedRef, acceptAndConfirm } from "../onchain/settle.js";
|
||||||
|
import { ensureWalletHealthy } from "../walletHealth.js";
|
||||||
|
import { parseChatRequest, type ParsedChatRequest } from "./validate.js";
|
||||||
|
import { runDemoChat } from "./demoChat.js";
|
||||||
|
import {
|
||||||
|
genId,
|
||||||
|
buildChatCompletion,
|
||||||
|
buildChunk,
|
||||||
|
usageFromReceipt,
|
||||||
|
sseData,
|
||||||
|
SSE_DONE,
|
||||||
|
type Usage,
|
||||||
|
} from "./shapes.js";
|
||||||
|
import { notFound, paymentRequired, toGatewayError, toErrorBody } from "./errors.js";
|
||||||
|
|
||||||
|
const CAPABILITY = "llm.text.generate.v1";
|
||||||
|
|
||||||
|
function recordUsage(
|
||||||
|
store: GatewayStore,
|
||||||
|
p: {
|
||||||
|
keyId: string;
|
||||||
|
model: string;
|
||||||
|
supplierPkh: string | null;
|
||||||
|
escrowRef: string | null;
|
||||||
|
costLovelace: bigint | null;
|
||||||
|
usage: Usage | null;
|
||||||
|
status: "completed" | "failed";
|
||||||
|
failureReason?: string;
|
||||||
|
},
|
||||||
|
): void {
|
||||||
|
store.insertUsage({
|
||||||
|
id: randomUUID(),
|
||||||
|
key_id: p.keyId,
|
||||||
|
created_at: Date.now(),
|
||||||
|
kind: "completion",
|
||||||
|
model: p.model,
|
||||||
|
capability_id: CAPABILITY,
|
||||||
|
supplier_pkh: p.supplierPkh,
|
||||||
|
escrow_ref: p.escrowRef,
|
||||||
|
cost_lovelace: p.costLovelace !== null ? p.costLovelace.toString() : null,
|
||||||
|
prompt_tokens: p.usage?.prompt_tokens ?? 0,
|
||||||
|
completion_tokens: p.usage?.completion_tokens ?? 0,
|
||||||
|
status: p.status,
|
||||||
|
failure_reason: p.failureReason ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeChatCompletionsHandler(deps: GatewayDeps) {
|
||||||
|
return asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const keyRow = requireKey(req);
|
||||||
|
const ctx = deps.registry.getContext(keyRow);
|
||||||
|
if (keyRow.demo) {
|
||||||
|
// Demo keys run session-backed with tools passthrough, and never fall
|
||||||
|
// back to one-shot (its settle path assumes ≤1 in-flight escrow per
|
||||||
|
// wallet; the demo wallet holds one escrow per concurrent session).
|
||||||
|
// Not under the key mutex — runDemoChat takes it only for session opens.
|
||||||
|
const parsed = parseChatRequest(req.body, { allowTools: true });
|
||||||
|
await runDemoChat(deps, keyRow, ctx, parsed, res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = parseChatRequest(req.body);
|
||||||
|
await ctx.mutex.run(() => runOneShot(deps, keyRow, ctx, parsed, res));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runOneShot(
|
||||||
|
deps: GatewayDeps,
|
||||||
|
keyRow: ApiKeyRow,
|
||||||
|
ctx: KeyContext,
|
||||||
|
parsed: ParsedChatRequest,
|
||||||
|
res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const { config, store, chain, fetchFn } = deps;
|
||||||
|
|
||||||
|
const candidates = await selectCandidates({
|
||||||
|
indexerUrl: config.indexerUrl,
|
||||||
|
model: parsed.model,
|
||||||
|
capabilityId: CAPABILITY,
|
||||||
|
fetchFn,
|
||||||
|
});
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
throw notFound("model_not_found", `no available supplier for model "${parsed.model}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const primary = candidates[0];
|
||||||
|
const pf = await preflight(chain, ctx.walletKey.address, primary);
|
||||||
|
if (!pf.ok) {
|
||||||
|
throw paymentRequired(
|
||||||
|
pf.collateralOk
|
||||||
|
? "insufficient balance for this request"
|
||||||
|
: "wallet has no pure-AP3X UTxO ≥ 5 AP3X for collateral; deposit a little more AP3X",
|
||||||
|
{
|
||||||
|
x_vector: {
|
||||||
|
required_lovelace: pf.requiredLovelace.toString(),
|
||||||
|
available_lovelace: pf.availableLovelace.toString(),
|
||||||
|
collateral_ok: pf.collateralOk,
|
||||||
|
deposit_address: ctx.walletKey.address,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = genId();
|
||||||
|
let keepalive: ReturnType<typeof setInterval> | undefined;
|
||||||
|
if (parsed.stream) {
|
||||||
|
res.status(200);
|
||||||
|
res.setHeader("Content-Type", "text/event-stream");
|
||||||
|
res.setHeader("Cache-Control", "no-cache, no-transform");
|
||||||
|
res.setHeader("Connection", "keep-alive");
|
||||||
|
res.setHeader("X-Accel-Buffering", "no");
|
||||||
|
res.write(": connected\n\n");
|
||||||
|
keepalive = setInterval(() => {
|
||||||
|
try {
|
||||||
|
res.write(": keepalive\n\n");
|
||||||
|
} catch {
|
||||||
|
/* client gone */
|
||||||
|
}
|
||||||
|
}, 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Submit. Fall back to the next supplier ONLY on a pre-post
|
||||||
|
// TxConstructionError (no escrow was posted). A SupplierError means the
|
||||||
|
// escrow is already posted — do not retry (the sweeper recovers it).
|
||||||
|
let result: Awaited<ReturnType<typeof ctx.sdk.submitPrompt>> | undefined;
|
||||||
|
let used: (typeof candidates)[number] | undefined;
|
||||||
|
let lastErr: unknown;
|
||||||
|
for (const cand of candidates) {
|
||||||
|
try {
|
||||||
|
result = await ctx.sdk.submitPrompt({
|
||||||
|
advertRef: cand.advertRef,
|
||||||
|
messages: parsed.messages,
|
||||||
|
payment_lovelace: cand.priceLovelace,
|
||||||
|
max_output_tokens: parsed.maxTokens,
|
||||||
|
});
|
||||||
|
used = cand;
|
||||||
|
break;
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err;
|
||||||
|
if (err instanceof TxConstructionError) continue;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!result || !used) {
|
||||||
|
throw toGatewayError(lastErr ?? new Error("no supplier could be engaged"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settle: resolve the live Submitted ref, Accept, await confirmation.
|
||||||
|
const originalRefStr = `${result.escrowRef.txHash}#${result.escrowRef.index}`;
|
||||||
|
const submittedRef = await resolveSubmittedRef({
|
||||||
|
indexerUrl: config.indexerUrl,
|
||||||
|
buyerPkh: ctx.walletKey.pubKeyHash,
|
||||||
|
originalRefStr,
|
||||||
|
fetchFn,
|
||||||
|
});
|
||||||
|
await acceptAndConfirm(chain, ctx.walletKey, submittedRef);
|
||||||
|
|
||||||
|
const usage = usageFromReceipt(result.receipt);
|
||||||
|
const vector = {
|
||||||
|
receipt: result.receipt,
|
||||||
|
receipt_signature: result.receiptSignature,
|
||||||
|
escrow_ref: originalRefStr,
|
||||||
|
};
|
||||||
|
recordUsage(store, {
|
||||||
|
keyId: keyRow.id,
|
||||||
|
model: parsed.model,
|
||||||
|
supplierPkh: used.supplierPkh,
|
||||||
|
escrowRef: originalRefStr,
|
||||||
|
costLovelace: used.priceLovelace,
|
||||||
|
usage,
|
||||||
|
status: "completed",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-shape the wallet ({collateral, working}) so the next request keeps a
|
||||||
|
// valid collateral UTxO. Best-effort; serialized by the mutex.
|
||||||
|
await ensureWalletHealthy(chain, ctx.walletKey).catch((e) =>
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("[gateway] wallet-health after completion:", e instanceof Error ? e.message : e),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parsed.stream) {
|
||||||
|
if (keepalive) clearInterval(keepalive);
|
||||||
|
res.write(sseData(buildChunk({ id, model: parsed.model, delta: { role: "assistant", content: result.response }, finishReason: null })));
|
||||||
|
res.write(sseData(buildChunk({ id, model: parsed.model, delta: {}, finishReason: "stop", usage, vector })));
|
||||||
|
res.write(SSE_DONE);
|
||||||
|
res.end();
|
||||||
|
} else {
|
||||||
|
res.status(200).json(buildChatCompletion({ id, model: parsed.model, content: result.response, usage, vector }));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (keepalive) clearInterval(keepalive);
|
||||||
|
recordUsage(store, {
|
||||||
|
keyId: keyRow.id,
|
||||||
|
model: parsed.model,
|
||||||
|
supplierPkh: null,
|
||||||
|
escrowRef: null,
|
||||||
|
costLovelace: null,
|
||||||
|
usage: null,
|
||||||
|
status: "failed",
|
||||||
|
failureReason: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
if (parsed.stream && res.headersSent) {
|
||||||
|
try {
|
||||||
|
res.write(sseData(toErrorBody(toGatewayError(err))));
|
||||||
|
} catch {
|
||||||
|
/* client gone */
|
||||||
|
}
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
413
gateway/src/openai/demoChat.ts
Normal file
413
gateway/src/openai/demoChat.ts
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/openai/demoChat.ts — session-backed /chat/completions for the
|
||||||
|
* shared demo key.
|
||||||
|
*
|
||||||
|
* Demo requests keep the standard OpenAI wire shape (tools included) but run
|
||||||
|
* against real escrow-per-session marketplace conversations: transcript-prefix
|
||||||
|
* affinity maps each stateless request onto an open session and forwards only
|
||||||
|
* the unseen delta; a miss transparently opens a new session (escrow post —
|
||||||
|
* the only chain work, under the key mutex) and replays the full history as
|
||||||
|
* the first turn. Supplier idle-closes are healed by reopening; the sweeper
|
||||||
|
* settles the abandoned escrow.
|
||||||
|
*
|
||||||
|
* Demo keys NEVER fall back to the one-shot path: resolveSubmittedRef's
|
||||||
|
* lone-Submitted-row fallback assumes ≤1 in-flight escrow per wallet, and the
|
||||||
|
* demo wallet holds one escrow per concurrent session.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import type { Response } from "express";
|
||||||
|
import type { ChatMessage, ToolCall } from "@marketplace/shared/tx";
|
||||||
|
import type { GatewayDeps } from "../deps.js";
|
||||||
|
import type { ApiKeyRow, SessionRow } from "../db/store.js";
|
||||||
|
import type { KeyContext } from "../sdk/registry.js";
|
||||||
|
import { transcripts, getSessionLock, dropSessionState } from "./transcripts.js";
|
||||||
|
import { matchSessionPrefix, trimToRespondable, type AffinityCandidate } from "./affinity.js";
|
||||||
|
import {
|
||||||
|
CAPABILITY,
|
||||||
|
SessionGoneError,
|
||||||
|
openSessionCore,
|
||||||
|
streamSupplierTurn,
|
||||||
|
type TurnResult,
|
||||||
|
} from "./sessions.js";
|
||||||
|
import type { ParsedChatRequest } from "./validate.js";
|
||||||
|
import { genId, buildChatCompletion, buildChunk, sseData, SSE_DONE, type Usage } from "./shapes.js";
|
||||||
|
import { GatewayError, badRequest, toGatewayError, toErrorBody } from "./errors.js";
|
||||||
|
|
||||||
|
interface DemoSessionMeta {
|
||||||
|
keyId: string;
|
||||||
|
model: string;
|
||||||
|
lastUsedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open sessions owned by the demo executor (sessionId → meta). Mirrors live
|
||||||
|
* in transcripts.ts; this map scopes affinity to demo-opened sessions. */
|
||||||
|
const demoSessions = new Map<string, DemoSessionMeta>();
|
||||||
|
|
||||||
|
/** Forget a session (closed/settled elsewhere, e.g. by the sweeper). */
|
||||||
|
export function dropDemoSession(sessionId: string): void {
|
||||||
|
demoSessions.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function candidatesFor(keyId: string, model: string): AffinityCandidate[] {
|
||||||
|
const out: AffinityCandidate[] = [];
|
||||||
|
for (const [sessionId, meta] of demoSessions) {
|
||||||
|
if (meta.keyId !== keyId || meta.model !== model) continue;
|
||||||
|
const mirror = transcripts.get(sessionId);
|
||||||
|
if (!mirror) continue;
|
||||||
|
out.push({ sessionId, model: meta.model, mirror, lastUsedAt: meta.lastUsedAt });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record the session's cost when the demo executor closes its row. Full
|
||||||
|
* mode: the flat session fee (supplier Submitted; the sweeper Accepts the
|
||||||
|
* escrow afterwards). Ticket mode: 0 — nothing settles, the sweeper reclaims
|
||||||
|
* the escrow and the wallet is made whole minus fees. */
|
||||||
|
function recordSessionCost(deps: GatewayDeps, session: SessionRow): void {
|
||||||
|
deps.store.insertUsage({
|
||||||
|
id: randomUUID(),
|
||||||
|
key_id: session.key_id,
|
||||||
|
created_at: Date.now(),
|
||||||
|
kind: "chat_session",
|
||||||
|
model: session.model,
|
||||||
|
capability_id: CAPABILITY,
|
||||||
|
supplier_pkh: session.supplier_pkh,
|
||||||
|
escrow_ref: session.escrow_ref,
|
||||||
|
cost_lovelace: deps.config.chatSettleMode === "ticket" ? "0" : session.price_lovelace,
|
||||||
|
prompt_tokens: 0,
|
||||||
|
completion_tokens: 0,
|
||||||
|
status: "completed",
|
||||||
|
failure_reason: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function markSessionGone(deps: GatewayDeps, session: SessionRow): void {
|
||||||
|
deps.store.setSessionState(session.id, "closed", Date.now());
|
||||||
|
dropSessionState(session.id);
|
||||||
|
dropDemoSession(session.id);
|
||||||
|
recordSessionCost(deps, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ask the supplier to settle (Submit) the session. NO gateway chain work —
|
||||||
|
* the sweeper Accepts the resulting Submitted escrow. The supplier releases
|
||||||
|
* its single slot as part of ending, so a truthy return means the supplier is
|
||||||
|
* free again. Best-effort: on failure the sweeper reclaims later. */
|
||||||
|
async function requestSupplierEnd(deps: GatewayDeps, session: SessionRow): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await deps.fetchFn(`${session.supplier_base_url.replace(/\/+$/, "")}/v1/chat/end`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", "X-Escrow-Ref": session.escrow_ref },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
signal: AbortSignal.timeout(90_000),
|
||||||
|
});
|
||||||
|
await res.text().catch(() => "");
|
||||||
|
return res.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close one demo session (evicted for capacity, or idle-janitored). Runs
|
||||||
|
* under the session lock so an in-flight turn finishes first; takes NO key
|
||||||
|
* mutex (no chain work here — see requestSupplierEnd). Returns the freed
|
||||||
|
* supplier_pkh, or null when the session was already gone or (janitor) was
|
||||||
|
* used again while we waited for the lock. */
|
||||||
|
async function closeDemoSession(
|
||||||
|
deps: GatewayDeps,
|
||||||
|
sessionId: string,
|
||||||
|
opts: { reason: "evicted" | "idle"; idleCutoffMs?: number; nowMs?: number },
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!demoSessions.has(sessionId)) return null;
|
||||||
|
return getSessionLock(sessionId).run(async () => {
|
||||||
|
const meta = demoSessions.get(sessionId);
|
||||||
|
if (!meta) return null; // lost a race with another closer
|
||||||
|
// Re-check idleness inside the lock: a turn that won the lock first has
|
||||||
|
// bumped lastUsedAt past the sweep's reference clock.
|
||||||
|
if (opts.idleCutoffMs !== undefined && (opts.nowMs ?? Date.now()) - meta.lastUsedAt < opts.idleCutoffMs) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const session = deps.store.getSession(sessionId);
|
||||||
|
if (!session || session.state !== "open") {
|
||||||
|
dropDemoSession(sessionId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await requestSupplierEnd(deps, session);
|
||||||
|
markSessionGone(deps, session);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[gateway:demo] closed session ${sessionId} (${opts.reason}), freed supplier ${session.supplier_pkh}`);
|
||||||
|
return session.supplier_pkh;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Least-recently-used open demo session for (key, model), excluding prior
|
||||||
|
* victims. Same-model scoping doubles as a guard: a bogus model name has no
|
||||||
|
* demo sessions, so eviction can never loop on a genuine model_not_found. */
|
||||||
|
function pickLruDemoSession(keyId: string, model: string, exclude: ReadonlySet<string>): string | null {
|
||||||
|
let lruId: string | null = null;
|
||||||
|
let lruAt = Infinity;
|
||||||
|
for (const [sessionId, meta] of demoSessions) {
|
||||||
|
if (meta.keyId !== keyId || meta.model !== model || exclude.has(sessionId)) continue;
|
||||||
|
if (meta.lastUsedAt < lruAt) {
|
||||||
|
lruAt = meta.lastUsedAt;
|
||||||
|
lruId = sessionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lruId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close demo sessions idle past deps.config.demoSessionIdleMs so abandoned
|
||||||
|
* conversations (client gone, title-gen one-offs) release their single-slot
|
||||||
|
* suppliers. Called from the sweeper tick; sequential to keep supplier
|
||||||
|
* /v1/chat/end calls orderly. */
|
||||||
|
export async function sweepIdleDemoSessions(deps: GatewayDeps, nowMs = Date.now()): Promise<void> {
|
||||||
|
const idleMs = deps.config.demoSessionIdleMs;
|
||||||
|
for (const [sessionId, meta] of [...demoSessions]) {
|
||||||
|
if (nowMs - meta.lastUsedAt < idleMs) continue;
|
||||||
|
try {
|
||||||
|
await closeDemoSession(deps, sessionId, { reason: "idle", idleCutoffMs: idleMs, nowMs });
|
||||||
|
} catch (err) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`[gateway:demo] idle close of ${sessionId} failed:`, err instanceof Error ? err.message : err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_EVICTIONS = 2;
|
||||||
|
|
||||||
|
function isNoSupplierError(err: unknown): boolean {
|
||||||
|
return err instanceof GatewayError && (err.code === "model_not_found" || err.code === "overloaded");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a session, evicting up to MAX_EVICTIONS LRU demo sessions when every
|
||||||
|
* supplier is pinned. Eviction frees the supplier immediately (its slot
|
||||||
|
* releases at Submit) but the indexer's status poll lags, so the retry passes
|
||||||
|
* the freed pkhs through selectCandidates' ignoreStatusFor. Lock ordering is
|
||||||
|
* safe: eviction holds only the session lock, the open holds only the key
|
||||||
|
* mutex — never nested. */
|
||||||
|
async function openSessionWithEviction(
|
||||||
|
deps: GatewayDeps,
|
||||||
|
keyRow: ApiKeyRow,
|
||||||
|
ctx: KeyContext,
|
||||||
|
model: string,
|
||||||
|
): Promise<SessionRow> {
|
||||||
|
const freedPkhs = new Set<string>();
|
||||||
|
const evicted = new Set<string>();
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
try {
|
||||||
|
return await ctx.mutex.run(() =>
|
||||||
|
openSessionCore(deps, keyRow, ctx, model, freedPkhs.size > 0 ? { ignoreStatusFor: freedPkhs } : undefined),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (attempt >= MAX_EVICTIONS || !isNoSupplierError(err)) throw err;
|
||||||
|
const victimId = pickLruDemoSession(keyRow.id, model, evicted);
|
||||||
|
if (!victimId) throw err;
|
||||||
|
evicted.add(victimId);
|
||||||
|
const pkh = await closeDemoSession(deps, victimId, { reason: "evicted" });
|
||||||
|
if (!pkh) throw err; // nothing actually freed — don't loop
|
||||||
|
freedPkhs.add(pkh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type LockedTurnOutcome =
|
||||||
|
| { kind: "done"; result: TurnResult }
|
||||||
|
| { kind: "retry" } // candidate no longer valid — re-run the match
|
||||||
|
| { kind: "gone" }; // supplier lost the session — open a fresh one
|
||||||
|
|
||||||
|
/** Streaming sink shared across turn attempts on one response. */
|
||||||
|
interface StreamSink {
|
||||||
|
onToken: (token: string) => void;
|
||||||
|
emitToolCalls: (toolCalls: ToolCall[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runDemoChat(
|
||||||
|
deps: GatewayDeps,
|
||||||
|
keyRow: ApiKeyRow,
|
||||||
|
ctx: KeyContext,
|
||||||
|
parsed: ParsedChatRequest,
|
||||||
|
res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
// Clients sometimes send histories ending in an assistant (continue/retry
|
||||||
|
// flows) or system message; the chat.v1 supplier can only respond to a
|
||||||
|
// user/tool-terminated turn. Trim trailing unrespondable messages
|
||||||
|
// (regenerate semantics) and reject — before any chain work — only when
|
||||||
|
// nothing respondable remains.
|
||||||
|
const trimmed = trimToRespondable(parsed.messages);
|
||||||
|
if (trimmed.length === 0) {
|
||||||
|
throw badRequest("invalid_messages", "no user or tool message to respond to");
|
||||||
|
}
|
||||||
|
const effective: ParsedChatRequest =
|
||||||
|
trimmed.length === parsed.messages.length ? parsed : { ...parsed, messages: trimmed };
|
||||||
|
|
||||||
|
const id = genId();
|
||||||
|
let keepalive: ReturnType<typeof setInterval> | undefined;
|
||||||
|
let roleSent = false;
|
||||||
|
if (parsed.stream) {
|
||||||
|
res.status(200);
|
||||||
|
res.setHeader("Content-Type", "text/event-stream");
|
||||||
|
res.setHeader("Cache-Control", "no-cache, no-transform");
|
||||||
|
res.setHeader("Connection", "keep-alive");
|
||||||
|
res.setHeader("X-Accel-Buffering", "no");
|
||||||
|
res.write(": connected\n\n");
|
||||||
|
keepalive = setInterval(() => {
|
||||||
|
try {
|
||||||
|
res.write(": keepalive\n\n");
|
||||||
|
} catch {
|
||||||
|
/* client gone */
|
||||||
|
}
|
||||||
|
}, 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureRole = (): void => {
|
||||||
|
if (roleSent) return;
|
||||||
|
roleSent = true;
|
||||||
|
res.write(sseData(buildChunk({ id, model: parsed.model, delta: { role: "assistant" }, finishReason: null })));
|
||||||
|
};
|
||||||
|
const sink: StreamSink = {
|
||||||
|
onToken: (token) => {
|
||||||
|
if (!parsed.stream) return;
|
||||||
|
ensureRole();
|
||||||
|
res.write(sseData(buildChunk({ id, model: parsed.model, delta: { content: token }, finishReason: null })));
|
||||||
|
},
|
||||||
|
emitToolCalls: (toolCalls) => {
|
||||||
|
if (!parsed.stream) return;
|
||||||
|
ensureRole();
|
||||||
|
res.write(
|
||||||
|
sseData(
|
||||||
|
buildChunk({
|
||||||
|
id,
|
||||||
|
model: parsed.model,
|
||||||
|
delta: { tool_calls: toolCalls.map((tc, index) => ({ index, ...tc })) },
|
||||||
|
finishReason: null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runWithAffinity(deps, keyRow, ctx, effective, sink);
|
||||||
|
if (keepalive) clearInterval(keepalive);
|
||||||
|
|
||||||
|
const usage: Usage = {
|
||||||
|
prompt_tokens: result.usage.prompt_tokens,
|
||||||
|
completion_tokens: result.usage.completion_tokens,
|
||||||
|
total_tokens: result.usage.prompt_tokens + result.usage.completion_tokens,
|
||||||
|
};
|
||||||
|
if (parsed.stream) {
|
||||||
|
if (result.assistantMessage.tool_calls?.length) sink.emitToolCalls(result.assistantMessage.tool_calls);
|
||||||
|
ensureRole();
|
||||||
|
res.write(sseData(buildChunk({ id, model: parsed.model, delta: {}, finishReason: result.finishReason, usage })));
|
||||||
|
res.write(SSE_DONE);
|
||||||
|
res.end();
|
||||||
|
} else {
|
||||||
|
res.status(200).json(
|
||||||
|
buildChatCompletion({
|
||||||
|
id,
|
||||||
|
model: parsed.model,
|
||||||
|
content: result.assistantMessage.content,
|
||||||
|
toolCalls: result.assistantMessage.tool_calls,
|
||||||
|
finishReason: result.finishReason,
|
||||||
|
usage,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (keepalive) clearInterval(keepalive);
|
||||||
|
if (parsed.stream && res.headersSent) {
|
||||||
|
try {
|
||||||
|
res.write(sseData(toErrorBody(toGatewayError(err))));
|
||||||
|
} catch {
|
||||||
|
/* client gone */
|
||||||
|
}
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runWithAffinity(
|
||||||
|
deps: GatewayDeps,
|
||||||
|
keyRow: ApiKeyRow,
|
||||||
|
ctx: KeyContext,
|
||||||
|
parsed: ParsedChatRequest,
|
||||||
|
sink: StreamSink,
|
||||||
|
): Promise<TurnResult> {
|
||||||
|
// Affinity: try matching sessions until one serves the turn. The match runs
|
||||||
|
// synchronously (no await between match and lock acquire, so the candidate
|
||||||
|
// set can't shift underneath us); the prefix is re-checked inside the lock
|
||||||
|
// because a concurrent request may have advanced the session first.
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
const match = matchSessionPrefix(candidatesFor(keyRow.id, parsed.model), parsed.messages, parsed.model);
|
||||||
|
if (!match) break;
|
||||||
|
|
||||||
|
const outcome: LockedTurnOutcome = await getSessionLock(match.sessionId).run(async () => {
|
||||||
|
const meta = demoSessions.get(match.sessionId);
|
||||||
|
const mirror = transcripts.get(match.sessionId);
|
||||||
|
const session = deps.store.getSession(match.sessionId);
|
||||||
|
if (!meta || !mirror || !session || session.state !== "open") {
|
||||||
|
dropDemoSession(match.sessionId);
|
||||||
|
return { kind: "retry" };
|
||||||
|
}
|
||||||
|
const recheck = matchSessionPrefix(
|
||||||
|
[{ sessionId: session.id, model: meta.model, mirror, lastUsedAt: meta.lastUsedAt }],
|
||||||
|
parsed.messages,
|
||||||
|
parsed.model,
|
||||||
|
);
|
||||||
|
if (!recheck) return { kind: "retry" };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await streamSupplierTurn(
|
||||||
|
deps,
|
||||||
|
session,
|
||||||
|
{ messages: recheck.delta, tools: parsed.tools, tool_choice: parsed.toolChoice },
|
||||||
|
sink.onToken,
|
||||||
|
);
|
||||||
|
meta.lastUsedAt = Date.now();
|
||||||
|
return { kind: "done", result };
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SessionGoneError) {
|
||||||
|
// Supplier idle-closed (or restarted). Close our row, record the
|
||||||
|
// fee (the escrow settles via the sweeper) and reopen fresh.
|
||||||
|
markSessionGone(deps, session);
|
||||||
|
return { kind: "gone" };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (outcome.kind === "done") return outcome.result;
|
||||||
|
if (outcome.kind === "gone") break;
|
||||||
|
// retry: candidate vanished/advanced — re-match against current state.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Miss: open a new session (chain work → key mutex; evicts an LRU demo
|
||||||
|
// session when every supplier is pinned) and replay the full history as its
|
||||||
|
// first turn.
|
||||||
|
const session = await openSessionWithEviction(deps, keyRow, ctx, parsed.model);
|
||||||
|
demoSessions.set(session.id, { keyId: keyRow.id, model: parsed.model, lastUsedAt: Date.now() });
|
||||||
|
return getSessionLock(session.id).run(async () => {
|
||||||
|
try {
|
||||||
|
const result = await streamSupplierTurn(
|
||||||
|
deps,
|
||||||
|
session,
|
||||||
|
{ messages: parsed.messages, tools: parsed.tools, tool_choice: parsed.toolChoice },
|
||||||
|
sink.onToken,
|
||||||
|
);
|
||||||
|
// Refresh recency after the (possibly long) first stream so this live
|
||||||
|
// thread isn't the LRU/janitor's first victim.
|
||||||
|
const meta = demoSessions.get(session.id);
|
||||||
|
if (meta) meta.lastUsedAt = Date.now();
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SessionGoneError) {
|
||||||
|
// Freshly opened yet already gone (supplier crashed mid-claim).
|
||||||
|
// Surface as an upstream error rather than looping.
|
||||||
|
markSessionGone(deps, session);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
78
gateway/src/openai/errors.ts
Normal file
78
gateway/src/openai/errors.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/openai/errors.ts — OpenAI-shaped error taxonomy.
|
||||||
|
*
|
||||||
|
* OpenAI SDKs parse error.type to drive retry/severity, so every gateway error
|
||||||
|
* is mapped to a concrete {message, type, code} triple and the documented HTTP
|
||||||
|
* status. Marketplace SDK errors (SupplierError/IndexerError/ReceiptVerification
|
||||||
|
* Error) and TxConstructionError are translated here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ReceiptVerificationError, IndexerError, SupplierError } from "@marketplace/buyer/sdk";
|
||||||
|
import { TxConstructionError } from "@marketplace/shared/tx";
|
||||||
|
|
||||||
|
export type OpenAiErrorType =
|
||||||
|
| "invalid_request_error"
|
||||||
|
| "authentication_error"
|
||||||
|
| "rate_limit_error"
|
||||||
|
| "server_error";
|
||||||
|
|
||||||
|
export class GatewayError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly httpStatus: number,
|
||||||
|
public readonly type: OpenAiErrorType,
|
||||||
|
public readonly code: string,
|
||||||
|
message: string,
|
||||||
|
/** Optional vendor extension fields merged alongside `error` in the body. */
|
||||||
|
public readonly extra?: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "GatewayError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const badRequest = (code: string, message: string): GatewayError =>
|
||||||
|
new GatewayError(400, "invalid_request_error", code, message);
|
||||||
|
export const unauthorized = (message = "invalid API key"): GatewayError =>
|
||||||
|
new GatewayError(401, "authentication_error", "invalid_api_key", message);
|
||||||
|
export const paymentRequired = (message: string, extra?: Record<string, unknown>): GatewayError =>
|
||||||
|
new GatewayError(402, "invalid_request_error", "insufficient_funds", message, extra);
|
||||||
|
export const forbidden = (code: string, message: string): GatewayError =>
|
||||||
|
new GatewayError(403, "invalid_request_error", code, message);
|
||||||
|
export const notFound = (code: string, message: string): GatewayError =>
|
||||||
|
new GatewayError(404, "invalid_request_error", code, message);
|
||||||
|
export const rateLimited = (message: string): GatewayError =>
|
||||||
|
new GatewayError(429, "rate_limit_error", "rate_limit_exceeded", message);
|
||||||
|
|
||||||
|
/** Render the body OpenAI clients expect: {error:{message,type,code,param}}. */
|
||||||
|
export function toErrorBody(err: GatewayError): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
error: { message: err.message, type: err.type, code: err.code, param: null },
|
||||||
|
...(err.extra ?? {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map any thrown value to a GatewayError. */
|
||||||
|
export function toGatewayError(err: unknown): GatewayError {
|
||||||
|
if (err instanceof GatewayError) return err;
|
||||||
|
|
||||||
|
if (err instanceof SupplierError) {
|
||||||
|
if (err.reason === "timeout") {
|
||||||
|
return new GatewayError(504, "server_error", "supplier_timeout", "supplier timed out");
|
||||||
|
}
|
||||||
|
if (err.reason === "supplier_busy") {
|
||||||
|
return new GatewayError(503, "server_error", "overloaded", "all matching suppliers are busy");
|
||||||
|
}
|
||||||
|
return new GatewayError(502, "server_error", "upstream_error", `supplier error: ${err.reason}`);
|
||||||
|
}
|
||||||
|
if (err instanceof ReceiptVerificationError) {
|
||||||
|
return new GatewayError(502, "server_error", "receipt_verification_failed", `receipt invalid: ${err.reason}`);
|
||||||
|
}
|
||||||
|
if (err instanceof IndexerError) {
|
||||||
|
return new GatewayError(502, "server_error", "indexer_error", `indexer error: ${err.reason}`);
|
||||||
|
}
|
||||||
|
if (err instanceof TxConstructionError) {
|
||||||
|
return new GatewayError(502, "server_error", "escrow_failed", `escrow build failed: ${err.reason}`);
|
||||||
|
}
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return new GatewayError(500, "server_error", "internal_error", message);
|
||||||
|
}
|
||||||
26
gateway/src/openai/models.ts
Normal file
26
gateway/src/openai/models.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/openai/models.ts — GET /openai/v1/models.
|
||||||
|
*
|
||||||
|
* Distinct models advertised by Active suppliers, in the OpenAI list shape.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import type { GatewayDeps } from "../deps.js";
|
||||||
|
import { asyncHandler } from "../middleware/http.js";
|
||||||
|
import { requireKey } from "../middleware/apiKeyAuth.js";
|
||||||
|
import { listModels } from "../routing/selectSupplier.js";
|
||||||
|
import { buildModelsList } from "./shapes.js";
|
||||||
|
import { CAPABILITY as CHAT_CAPABILITY } from "./sessions.js";
|
||||||
|
|
||||||
|
export function makeModelsHandler(deps: GatewayDeps) {
|
||||||
|
return asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const keyRow = requireKey(req);
|
||||||
|
// Demo keys only reach chat-capable suppliers (session-backed executor).
|
||||||
|
const models = await listModels({
|
||||||
|
indexerUrl: deps.config.indexerUrl,
|
||||||
|
capabilityId: keyRow.demo ? CHAT_CAPABILITY : undefined,
|
||||||
|
fetchFn: deps.fetchFn,
|
||||||
|
});
|
||||||
|
res.status(200).json(buildModelsList(models));
|
||||||
|
});
|
||||||
|
}
|
||||||
479
gateway/src/openai/sessions.ts
Normal file
479
gateway/src/openai/sessions.ts
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/openai/sessions.ts — chat sessions (Vector extension).
|
||||||
|
*
|
||||||
|
* One escrow per SESSION (fixed price locked at open, settled at close). Real
|
||||||
|
* token streaming per turn against an llm.chat.v1 supplier. Endpoints:
|
||||||
|
* POST /openai/v1/chat/sessions → open (startChat)
|
||||||
|
* POST /openai/v1/chat/sessions/:id/messages → one streamed turn
|
||||||
|
* POST /openai/v1/chat/sessions/:id/close → settle (endChat: Submit+Accept)
|
||||||
|
*
|
||||||
|
* The chat.v1 supplier has no system-role channel and accumulates the transcript
|
||||||
|
* server-side, so we send one user `content` per turn and mirror the transcript
|
||||||
|
* in-memory (for endChat's response_hash verification). A process restart loses
|
||||||
|
* the mirror; an abandoned session is reclaimed by the sweeper.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { TxConstructionError, normalizeChatMessage, type ChatMessage } from "@marketplace/shared/tx";
|
||||||
|
import { SupplierError } from "@marketplace/buyer/sdk";
|
||||||
|
import { transcripts, getSessionLock, dropSessionState } from "./transcripts.js";
|
||||||
|
import type { GatewayDeps } from "../deps.js";
|
||||||
|
import type { ApiKeyRow, GatewayStore, SessionRow } from "../db/store.js";
|
||||||
|
import type { KeyContext } from "../sdk/registry.js";
|
||||||
|
import { requireKey } from "../middleware/apiKeyAuth.js";
|
||||||
|
import { asyncHandler } from "../middleware/http.js";
|
||||||
|
import { selectCandidates, parseRef } from "../routing/selectSupplier.js";
|
||||||
|
import { preflight } from "../onchain/preflight.js";
|
||||||
|
import { ensureWalletHealthy } from "../walletHealth.js";
|
||||||
|
import { badRequest, notFound, paymentRequired, toGatewayError, toErrorBody } from "./errors.js";
|
||||||
|
import {
|
||||||
|
genId,
|
||||||
|
nowSec,
|
||||||
|
buildChatCompletion,
|
||||||
|
buildChunk,
|
||||||
|
sseData,
|
||||||
|
SSE_DONE,
|
||||||
|
type Usage,
|
||||||
|
} from "./shapes.js";
|
||||||
|
|
||||||
|
export const CAPABILITY = "llm.chat.v1";
|
||||||
|
|
||||||
|
/** Thrown when the supplier no longer knows the session (idle-closed/restart). */
|
||||||
|
export class SessionGoneError extends Error {
|
||||||
|
constructor(sessionId: string) {
|
||||||
|
super(`supplier no longer has session ${sessionId}`);
|
||||||
|
this.name = "SessionGoneError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refStr(ref: { txHash: string; index: number }): string {
|
||||||
|
return `${ref.txHash}#${ref.index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── open ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function makeOpenSessionHandler(deps: GatewayDeps) {
|
||||||
|
return asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const keyRow = requireKey(req);
|
||||||
|
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||||
|
const model = body.model;
|
||||||
|
if (typeof model !== "string" || model === "") {
|
||||||
|
throw badRequest("invalid_model", "`model` is required");
|
||||||
|
}
|
||||||
|
const ctx = deps.registry.getContext(keyRow);
|
||||||
|
const session = await ctx.mutex.run(() => openSessionCore(deps, keyRow, ctx, model));
|
||||||
|
res.status(200).json({ id: session.id, object: "chat.session", model, created: nowSec() });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a supplier, preflight the wallet, post+claim the session escrow and
|
||||||
|
* persist the SessionRow. Chain work — the CALLER must hold the key mutex
|
||||||
|
* (concurrent PostEscrow from one wallet double-spends the same UTxO).
|
||||||
|
*/
|
||||||
|
export async function openSessionCore(
|
||||||
|
deps: GatewayDeps,
|
||||||
|
keyRow: ApiKeyRow,
|
||||||
|
ctx: KeyContext,
|
||||||
|
model: string,
|
||||||
|
opts?: { ignoreStatusFor?: ReadonlySet<string> },
|
||||||
|
): Promise<SessionRow> {
|
||||||
|
const candidates = await selectCandidates({
|
||||||
|
indexerUrl: deps.config.indexerUrl,
|
||||||
|
model,
|
||||||
|
capabilityId: CAPABILITY,
|
||||||
|
fetchFn: deps.fetchFn,
|
||||||
|
ignoreStatusFor: opts?.ignoreStatusFor,
|
||||||
|
});
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
throw notFound("model_not_found", `no available chat (llm.chat.v1) supplier for model "${model}"`);
|
||||||
|
}
|
||||||
|
const primary = candidates[0];
|
||||||
|
const pf = await preflight(deps.chain, ctx.walletKey.address, primary);
|
||||||
|
if (!pf.ok) {
|
||||||
|
throw paymentRequired(
|
||||||
|
pf.collateralOk ? "insufficient balance to open a session" : "wallet has no ≥5 AP3X pure-AP3X collateral UTxO",
|
||||||
|
{
|
||||||
|
x_vector: {
|
||||||
|
required_lovelace: pf.requiredLovelace.toString(),
|
||||||
|
available_lovelace: pf.availableLovelace.toString(),
|
||||||
|
collateral_ok: pf.collateralOk,
|
||||||
|
deposit_address: ctx.walletKey.address,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// startChat posts the escrow + supplier Claim. Fall back to the next supplier
|
||||||
|
// only on a pre-post error (TxConstructionError, or a busy supplier caught by
|
||||||
|
// startChat's /status pre-check before any funds are locked).
|
||||||
|
let started: Awaited<ReturnType<typeof ctx.sdk.startChat>> | undefined;
|
||||||
|
let used: (typeof candidates)[number] | undefined;
|
||||||
|
let lastErr: unknown;
|
||||||
|
for (const cand of candidates) {
|
||||||
|
try {
|
||||||
|
started = await ctx.sdk.startChat({ advertRef: cand.advertRef, payment_lovelace: cand.priceLovelace });
|
||||||
|
used = cand;
|
||||||
|
break;
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err;
|
||||||
|
if (err instanceof TxConstructionError) continue;
|
||||||
|
if (err instanceof SupplierError && err.reason === "supplier_busy") continue;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!started || !used) throw toGatewayError(lastErr ?? new Error("no chat supplier could be engaged"));
|
||||||
|
|
||||||
|
// Deployment sanity: supplier and gateway must agree on the settle mode.
|
||||||
|
// Close behavior is driven per-session by the supplier's /end response, so
|
||||||
|
// a mismatch (rollout window) still settles correctly — just warn.
|
||||||
|
if ((started.settleMode === "ticket") !== (deps.config.chatSettleMode === "ticket")) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(
|
||||||
|
`[gateway] settle-mode mismatch: supplier ${used.supplierPkh.slice(0, 8)} ran "${started.settleMode}" but gateway is configured "${deps.config.chatSettleMode}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = randomUUID();
|
||||||
|
deps.store.insertSession({
|
||||||
|
id: sessionId,
|
||||||
|
key_id: keyRow.id,
|
||||||
|
escrow_ref: refStr(started.escrowRef),
|
||||||
|
session_nonce: started.sessionNonce,
|
||||||
|
supplier_base_url: started.supplierBaseUrl,
|
||||||
|
supplier_pkh: used.supplierPkh,
|
||||||
|
model,
|
||||||
|
price_lovelace: used.priceLovelace.toString(),
|
||||||
|
state: "open",
|
||||||
|
opened_at: Date.now(),
|
||||||
|
});
|
||||||
|
transcripts.set(sessionId, []);
|
||||||
|
|
||||||
|
const session = deps.store.getSession(sessionId);
|
||||||
|
if (!session) throw new Error(`session ${sessionId} vanished after insert`);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── messages (one streamed turn) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function makeSessionMessageHandler(deps: GatewayDeps) {
|
||||||
|
return asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const keyRow = requireKey(req);
|
||||||
|
const session = loadOwnedSession(deps.store, req, keyRow);
|
||||||
|
if (session.state !== "open") {
|
||||||
|
throw badRequest("session_closed", `session ${session.id} is ${session.state}`);
|
||||||
|
}
|
||||||
|
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||||
|
const stream = body.stream !== false; // sessions default to streaming
|
||||||
|
const content = resolveTurnContent(session.id, body);
|
||||||
|
if (content === "") throw badRequest("content_required", "no user content in this turn");
|
||||||
|
|
||||||
|
// Turns are chain-free (one HTTP POST to the supplier + SSE relay), so the
|
||||||
|
// per-key mutex — which exists to serialize wallet UTxO spends — is NOT
|
||||||
|
// held here. What needs serialization is this session's transcript:
|
||||||
|
// concurrent turns would interleave supplier appends and corrupt both the
|
||||||
|
// mirror and the receipt hash. Hence a per-session lock.
|
||||||
|
await getSessionLock(session.id).run(() => streamTurn(deps, session, content, stream, res));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTurnContent(sessionId: string, body: Record<string, unknown>): string {
|
||||||
|
if (typeof body.content === "string") {
|
||||||
|
return foldSystem(sessionId, body.content, body);
|
||||||
|
}
|
||||||
|
if (Array.isArray(body.messages)) {
|
||||||
|
const msgs = body.messages as Array<{ role?: unknown; content?: unknown }>;
|
||||||
|
const lastUser = [...msgs].reverse().find((m) => m.role === "user" && typeof m.content === "string");
|
||||||
|
const userContent = typeof lastUser?.content === "string" ? lastUser.content : "";
|
||||||
|
return foldSystem(sessionId, userContent, body);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On the first turn only, prepend any system message (chat.v1 has no system channel). */
|
||||||
|
function foldSystem(sessionId: string, content: string, body: Record<string, unknown>): string {
|
||||||
|
const mirror = transcripts.get(sessionId) ?? [];
|
||||||
|
if (mirror.length > 0) return content;
|
||||||
|
if (Array.isArray(body.messages)) {
|
||||||
|
const sys = (body.messages as Array<{ role?: unknown; content?: unknown }>)
|
||||||
|
.filter((m) => m.role === "system" && typeof m.content === "string")
|
||||||
|
.map((m) => m.content as string)
|
||||||
|
.join("\n\n");
|
||||||
|
if (sys) return `System: ${sys}\n\n${content}`;
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One turn's payload to the supplier: legacy single user string, or a
|
||||||
|
* multi-message delta (e.g. tool results) with optional tools passthrough. */
|
||||||
|
export type TurnPayload =
|
||||||
|
| { content: string }
|
||||||
|
| { messages: ChatMessage[]; tools?: unknown[]; tool_choice?: unknown };
|
||||||
|
|
||||||
|
export interface TurnResult {
|
||||||
|
assistantMessage: ChatMessage;
|
||||||
|
finishReason: "stop" | "tool_calls";
|
||||||
|
usage: { prompt_tokens: number; completion_tokens: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive one off-chain turn against the session's supplier and mirror the
|
||||||
|
* result into the session transcript. Caller must hold the session lock.
|
||||||
|
* Throws SessionGoneError when the supplier no longer knows the session
|
||||||
|
* (idle-closed or restarted) so callers can transparently reopen.
|
||||||
|
*/
|
||||||
|
export async function streamSupplierTurn(
|
||||||
|
deps: GatewayDeps,
|
||||||
|
session: SessionRow,
|
||||||
|
payload: TurnPayload,
|
||||||
|
onToken?: (token: string) => void,
|
||||||
|
): Promise<TurnResult> {
|
||||||
|
const upstream = await deps.fetchFn(`${session.supplier_base_url.replace(/\/+$/, "")}/v1/chat/message`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", accept: "text/event-stream", "X-Escrow-Ref": session.escrow_ref },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (upstream.status === 404) {
|
||||||
|
await upstream.text().catch(() => "");
|
||||||
|
throw new SessionGoneError(session.id);
|
||||||
|
}
|
||||||
|
if (!upstream.ok || !upstream.body) {
|
||||||
|
const detail = await upstream.text().catch(() => "");
|
||||||
|
throw toGatewayError(new SupplierError("supplier_http_error", { status: upstream.status, message: detail.slice(0, 200) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
let assistant = "";
|
||||||
|
let errored: string | undefined;
|
||||||
|
let doneMessage: ChatMessage | null = null;
|
||||||
|
let doneFinish: string | undefined;
|
||||||
|
let usage = { prompt_tokens: 0, completion_tokens: 0 };
|
||||||
|
await readSupplierSse(upstream.body as ReadableStream<Uint8Array>, (frame) => {
|
||||||
|
if (frame.type === "token" && typeof frame.value === "string") {
|
||||||
|
assistant += frame.value;
|
||||||
|
onToken?.(frame.value);
|
||||||
|
} else if (frame.type === "done") {
|
||||||
|
// Upgraded suppliers echo the final assistant message verbatim; mirror
|
||||||
|
// that exact object so both transcripts stay hash-identical.
|
||||||
|
doneMessage = normalizeChatMessage(frame.message);
|
||||||
|
if (typeof frame.finish_reason === "string") doneFinish = frame.finish_reason;
|
||||||
|
if (frame.usage && typeof frame.usage === "object") {
|
||||||
|
const u = frame.usage as Record<string, unknown>;
|
||||||
|
usage = {
|
||||||
|
prompt_tokens: typeof u.prompt_tokens === "number" ? u.prompt_tokens : 0,
|
||||||
|
completion_tokens: typeof u.completion_tokens === "number" ? u.completion_tokens : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (frame.type === "error") {
|
||||||
|
errored = typeof frame.message === "string" ? frame.message : "supplier stream error";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (errored !== undefined) {
|
||||||
|
throw toGatewayError(new SupplierError("upstream_error", { message: errored }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistantMessage: ChatMessage = doneMessage ?? { role: "assistant", content: assistant };
|
||||||
|
const finishReason: TurnResult["finishReason"] =
|
||||||
|
doneFinish === "tool_calls" || (assistantMessage.tool_calls?.length ?? 0) > 0 ? "tool_calls" : "stop";
|
||||||
|
|
||||||
|
// Mirror the turn for endChat's response_hash verification / affinity matching.
|
||||||
|
const mirror = transcripts.get(session.id) ?? [];
|
||||||
|
if ("content" in payload) mirror.push({ role: "user", content: payload.content });
|
||||||
|
else mirror.push(...payload.messages);
|
||||||
|
mirror.push(assistantMessage);
|
||||||
|
transcripts.set(session.id, mirror);
|
||||||
|
|
||||||
|
return { assistantMessage, finishReason, usage };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function streamTurn(
|
||||||
|
deps: GatewayDeps,
|
||||||
|
session: SessionRow,
|
||||||
|
content: string,
|
||||||
|
stream: boolean,
|
||||||
|
res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const id = genId();
|
||||||
|
|
||||||
|
if (stream) {
|
||||||
|
res.status(200);
|
||||||
|
res.setHeader("Content-Type", "text/event-stream");
|
||||||
|
res.setHeader("Cache-Control", "no-cache, no-transform");
|
||||||
|
res.setHeader("Connection", "keep-alive");
|
||||||
|
res.setHeader("X-Accel-Buffering", "no");
|
||||||
|
res.write(sseData(buildChunk({ id, model: session.model, delta: { role: "assistant" }, finishReason: null })));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: TurnResult;
|
||||||
|
try {
|
||||||
|
result = await streamSupplierTurn(deps, session, { content }, (token) => {
|
||||||
|
if (stream) res.write(sseData(buildChunk({ id, model: session.model, delta: { content: token }, finishReason: null })));
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SessionGoneError) {
|
||||||
|
err = toGatewayError(new SupplierError("supplier_http_error", { status: 404, message: err.message }));
|
||||||
|
}
|
||||||
|
if (stream) {
|
||||||
|
res.write(sseData(toErrorBody(toGatewayError(err))));
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw toGatewayError(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stream) {
|
||||||
|
res.write(sseData(buildChunk({ id, model: session.model, delta: {}, finishReason: result.finishReason })));
|
||||||
|
res.write(SSE_DONE);
|
||||||
|
res.end();
|
||||||
|
} else {
|
||||||
|
const usage: Usage = {
|
||||||
|
prompt_tokens: result.usage.prompt_tokens,
|
||||||
|
completion_tokens: result.usage.completion_tokens,
|
||||||
|
total_tokens: result.usage.prompt_tokens + result.usage.completion_tokens,
|
||||||
|
};
|
||||||
|
res.status(200).json(buildChatCompletion({ id, model: session.model, content: result.assistantMessage.content, usage }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SupplierFrame {
|
||||||
|
type?: string;
|
||||||
|
value?: string;
|
||||||
|
message?: unknown;
|
||||||
|
finish_reason?: unknown;
|
||||||
|
usage?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a supplier SSE stream of {type:token|done|error} frames. */
|
||||||
|
async function readSupplierSse(
|
||||||
|
body: ReadableStream<Uint8Array>,
|
||||||
|
onFrame: (frame: SupplierFrame) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
const reader = body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buf = "";
|
||||||
|
for (;;) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buf += decoder.decode(value, { stream: true });
|
||||||
|
let sep: number;
|
||||||
|
while ((sep = buf.indexOf("\n\n")) !== -1) {
|
||||||
|
const raw = buf.slice(0, sep);
|
||||||
|
buf = buf.slice(sep + 2);
|
||||||
|
for (const line of raw.split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed.startsWith("data:")) continue;
|
||||||
|
const data = trimmed.slice(5).trim();
|
||||||
|
if (data === "" || data === "[DONE]") continue;
|
||||||
|
try {
|
||||||
|
onFrame(JSON.parse(data) as SupplierFrame);
|
||||||
|
} catch {
|
||||||
|
/* ignore non-JSON keepalive/comment frames */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── close (settle) ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function makeCloseSessionHandler(deps: GatewayDeps) {
|
||||||
|
return asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const keyRow = requireKey(req);
|
||||||
|
const session = loadOwnedSession(deps.store, req, keyRow);
|
||||||
|
if (session.state !== "open") {
|
||||||
|
throw badRequest("session_closed", `session ${session.id} is already ${session.state}`);
|
||||||
|
}
|
||||||
|
const ctx = deps.registry.getContext(keyRow);
|
||||||
|
await ctx.mutex.run(() => closeSession(deps, keyRow, ctx, session, res));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeSession(
|
||||||
|
deps: GatewayDeps,
|
||||||
|
keyRow: ApiKeyRow,
|
||||||
|
ctx: KeyContext,
|
||||||
|
session: SessionRow,
|
||||||
|
res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const escrowRef = parseRef(session.escrow_ref);
|
||||||
|
if (!escrowRef) throw badRequest("bad_session", "session has an invalid escrow ref");
|
||||||
|
|
||||||
|
const result = await ctx.sdk.endChat({
|
||||||
|
escrowRef,
|
||||||
|
sessionNonce: session.session_nonce,
|
||||||
|
supplierBaseUrl: session.supplier_base_url,
|
||||||
|
transcript: transcripts.get(session.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
deps.store.setSessionState(session.id, "closed", Date.now());
|
||||||
|
dropSessionState(session.id);
|
||||||
|
|
||||||
|
if (result.settleMode === "ticket") {
|
||||||
|
// Nothing was Submitted/Accepted: no receipt, no charge (the escrow
|
||||||
|
// returns via the sweeper's reclaim), and no wallet churn to tidy up.
|
||||||
|
deps.store.insertUsage({
|
||||||
|
id: randomUUID(),
|
||||||
|
key_id: keyRow.id,
|
||||||
|
created_at: Date.now(),
|
||||||
|
kind: "chat_session",
|
||||||
|
model: session.model,
|
||||||
|
capability_id: CAPABILITY,
|
||||||
|
supplier_pkh: session.supplier_pkh,
|
||||||
|
escrow_ref: session.escrow_ref,
|
||||||
|
cost_lovelace: "0",
|
||||||
|
prompt_tokens: 0,
|
||||||
|
completion_tokens: 0,
|
||||||
|
status: "completed",
|
||||||
|
failure_reason: null,
|
||||||
|
});
|
||||||
|
res.status(200).json({
|
||||||
|
status: "ended",
|
||||||
|
escrow_ref: session.escrow_ref,
|
||||||
|
settle_mode: "ticket",
|
||||||
|
model: session.model,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = result.receipt.prompt_tokens ?? 0;
|
||||||
|
const completion = result.receipt.completion_tokens ?? 0;
|
||||||
|
deps.store.insertUsage({
|
||||||
|
id: randomUUID(),
|
||||||
|
key_id: keyRow.id,
|
||||||
|
created_at: Date.now(),
|
||||||
|
kind: "chat_session",
|
||||||
|
model: session.model,
|
||||||
|
capability_id: CAPABILITY,
|
||||||
|
supplier_pkh: session.supplier_pkh,
|
||||||
|
escrow_ref: session.escrow_ref,
|
||||||
|
cost_lovelace: session.price_lovelace,
|
||||||
|
prompt_tokens: prompt,
|
||||||
|
completion_tokens: completion,
|
||||||
|
status: "completed",
|
||||||
|
failure_reason: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ensureWalletHealthy(deps.chain, ctx.walletKey).catch((e) =>
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("[gateway] wallet-health after session close:", e instanceof Error ? e.message : e),
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
status: "closed",
|
||||||
|
escrow_ref: session.escrow_ref,
|
||||||
|
accepted_ref: refStr(result.acceptedRef),
|
||||||
|
model: session.model,
|
||||||
|
x_vector: { receipt: result.receipt, receipt_signature: result.receiptSignature, escrow_ref: session.escrow_ref },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadOwnedSession(store: GatewayStore, req: Request, keyRow: ApiKeyRow): SessionRow {
|
||||||
|
const raw = (req.params as Record<string, string | string[] | undefined>).id;
|
||||||
|
const id = typeof raw === "string" ? raw : undefined;
|
||||||
|
const session = id ? store.getSession(id) : undefined;
|
||||||
|
if (!session || session.key_id !== keyRow.id) {
|
||||||
|
throw notFound("session_not_found", `no chat session ${id ?? "(none)"}`);
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
115
gateway/src/openai/shapes.ts
Normal file
115
gateway/src/openai/shapes.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/openai/shapes.ts — OpenAI response object builders.
|
||||||
|
*
|
||||||
|
* Produces spec-clean ChatCompletion / ChatCompletionChunk / model-list objects
|
||||||
|
* plus the `x_vector` receipt extension. SSE helpers frame chunks for streaming.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomBytes } from "crypto";
|
||||||
|
import type { Receipt } from "@marketplace/shared/receipt";
|
||||||
|
import type { ToolCall } from "@marketplace/shared/tx";
|
||||||
|
|
||||||
|
export function genId(): string {
|
||||||
|
return `chatcmpl-${randomBytes(16).toString("hex")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nowSec(): number {
|
||||||
|
return Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VectorReceipt {
|
||||||
|
receipt: Receipt;
|
||||||
|
receipt_signature: string;
|
||||||
|
escrow_ref: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Usage {
|
||||||
|
prompt_tokens: number;
|
||||||
|
completion_tokens: number;
|
||||||
|
total_tokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usageFromReceipt(receipt: Receipt): Usage {
|
||||||
|
const prompt = receipt.prompt_tokens ?? 0;
|
||||||
|
const completion = receipt.completion_tokens ?? 0;
|
||||||
|
return { prompt_tokens: prompt, completion_tokens: completion, total_tokens: prompt + completion };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Non-streaming chat.completion object. */
|
||||||
|
export function buildChatCompletion(args: {
|
||||||
|
id: string;
|
||||||
|
model: string;
|
||||||
|
content: string;
|
||||||
|
usage: Usage;
|
||||||
|
toolCalls?: ToolCall[];
|
||||||
|
finishReason?: "stop" | "tool_calls";
|
||||||
|
vector?: VectorReceipt;
|
||||||
|
}): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
id: args.id,
|
||||||
|
object: "chat.completion",
|
||||||
|
created: nowSec(),
|
||||||
|
model: args.model,
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: args.content,
|
||||||
|
...(args.toolCalls?.length ? { tool_calls: args.toolCalls } : {}),
|
||||||
|
},
|
||||||
|
finish_reason: args.finishReason ?? "stop",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: args.usage,
|
||||||
|
...(args.vector ? { x_vector: args.vector } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A streaming chat.completion.chunk. `delta` is the incremental content (or {}). */
|
||||||
|
export function buildChunk(args: {
|
||||||
|
id: string;
|
||||||
|
model: string;
|
||||||
|
delta:
|
||||||
|
| { role?: string; content?: string; tool_calls?: Array<{ index: number } & ToolCall> }
|
||||||
|
| Record<string, never>;
|
||||||
|
finishReason: "stop" | "tool_calls" | null;
|
||||||
|
usage?: Usage;
|
||||||
|
vector?: VectorReceipt;
|
||||||
|
}): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
id: args.id,
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
created: nowSec(),
|
||||||
|
model: args.model,
|
||||||
|
choices: [{ index: 0, delta: args.delta, finish_reason: args.finishReason }],
|
||||||
|
...(args.usage ? { usage: args.usage } : {}),
|
||||||
|
...(args.vector ? { x_vector: args.vector } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Frame any object as an SSE `data:` event. */
|
||||||
|
export function sseData(obj: unknown): string {
|
||||||
|
return `data: ${JSON.stringify(obj)}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SSE_DONE = "data: [DONE]\n\n";
|
||||||
|
|
||||||
|
export function buildModelsList(models: string[]): Record<string, unknown> {
|
||||||
|
const created = nowSec();
|
||||||
|
return {
|
||||||
|
object: "list",
|
||||||
|
data: models.map((id) => ({ id, object: "model", created, owned_by: "vector-marketplace" })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render OpenAI messages[] into a single prompt string for the chat.v1 session
|
||||||
|
* supplier (which has no system-role channel — see docs/gateway.md). */
|
||||||
|
export function renderMessages(messages: Array<{ role: string; content: string }>): string {
|
||||||
|
return messages
|
||||||
|
.map((m) => {
|
||||||
|
const role = m.role === "assistant" ? "Assistant" : m.role === "system" ? "System" : "User";
|
||||||
|
return `${role}: ${m.content}`;
|
||||||
|
})
|
||||||
|
.join("\n\n");
|
||||||
|
}
|
||||||
35
gateway/src/openai/transcripts.ts
Normal file
35
gateway/src/openai/transcripts.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/openai/transcripts.ts — in-memory per-session state shared by
|
||||||
|
* the session routes (sessions.ts), the demo affinity executor (demoChat.ts)
|
||||||
|
* and the sweeper's housekeeping.
|
||||||
|
*
|
||||||
|
* transcripts: mirror of each open session's transcript (sessionId → messages).
|
||||||
|
* Kept hash-identical to the supplier's authoritative transcript (endChat's
|
||||||
|
* response_hash check). Lost on restart — abandoned sessions are recovered
|
||||||
|
* by the supplier idle watchdog + gateway sweeper.
|
||||||
|
* sessionLocks: per-session Mutex serializing turns on ONE session (concurrent
|
||||||
|
* turns would interleave supplier transcript appends). Distinct from the
|
||||||
|
* per-key Mutex, which only serializes on-chain wallet work.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ChatMessage } from "@marketplace/shared/tx";
|
||||||
|
import { Mutex } from "../sdk/registry.js";
|
||||||
|
|
||||||
|
export const transcripts = new Map<string, ChatMessage[]>();
|
||||||
|
|
||||||
|
const sessionLocks = new Map<string, Mutex>();
|
||||||
|
|
||||||
|
export function getSessionLock(sessionId: string): Mutex {
|
||||||
|
let lock = sessionLocks.get(sessionId);
|
||||||
|
if (!lock) {
|
||||||
|
lock = new Mutex();
|
||||||
|
sessionLocks.set(sessionId, lock);
|
||||||
|
}
|
||||||
|
return lock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forget all in-memory state for a session (closed/gone). */
|
||||||
|
export function dropSessionState(sessionId: string): void {
|
||||||
|
transcripts.delete(sessionId);
|
||||||
|
sessionLocks.delete(sessionId);
|
||||||
|
}
|
||||||
88
gateway/src/openai/validate.ts
Normal file
88
gateway/src/openai/validate.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/openai/validate.ts — request body validation for chat endpoints.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { normalizeChatMessage, type ChatMessage } from "@marketplace/shared/tx";
|
||||||
|
import { badRequest } from "./errors.js";
|
||||||
|
|
||||||
|
export interface ParsedChatRequest {
|
||||||
|
model: string;
|
||||||
|
messages: ChatMessage[];
|
||||||
|
maxTokens?: number;
|
||||||
|
stream: boolean;
|
||||||
|
tools?: unknown[];
|
||||||
|
toolChoice?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLES = new Set(["system", "user", "assistant"]);
|
||||||
|
|
||||||
|
/** Validate an OpenAI chat.completions-style body. Default path rejects
|
||||||
|
* tools/functions; `allowTools` (demo keys) accepts tools/tool_choice, the
|
||||||
|
* `tool` role, null content and assistant tool_calls, normalizing every
|
||||||
|
* message into canonical mirror shape. Silently ignores temperature/n/stop/
|
||||||
|
* top_p/etc (documented). */
|
||||||
|
export function parseChatRequest(body: unknown, opts?: { allowTools?: boolean }): ParsedChatRequest {
|
||||||
|
const allowTools = opts?.allowTools === true;
|
||||||
|
if (typeof body !== "object" || body === null) {
|
||||||
|
throw badRequest("invalid_body", "request body must be a JSON object");
|
||||||
|
}
|
||||||
|
const b = body as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (!allowTools && ("tools" in b || "tool_choice" in b || "functions" in b || "function_call" in b)) {
|
||||||
|
throw badRequest("unsupported_parameter", "tools and function-calling are not supported by this gateway");
|
||||||
|
}
|
||||||
|
if (allowTools && ("functions" in b || "function_call" in b)) {
|
||||||
|
throw badRequest("unsupported_parameter", "legacy functions/function_call are not supported; use tools");
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = b.model;
|
||||||
|
if (typeof model !== "string" || model === "") {
|
||||||
|
throw badRequest("invalid_model", "`model` is required and must be a non-empty string");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawMessages = b.messages;
|
||||||
|
if (!Array.isArray(rawMessages) || rawMessages.length === 0) {
|
||||||
|
throw badRequest("invalid_messages", "`messages` is required and must be a non-empty array");
|
||||||
|
}
|
||||||
|
const messages: ChatMessage[] = [];
|
||||||
|
for (const m of rawMessages) {
|
||||||
|
if (allowTools) {
|
||||||
|
const msg = normalizeChatMessage(m);
|
||||||
|
if (!msg) {
|
||||||
|
throw badRequest("invalid_messages", "each message must be OpenAI-shaped {role, content, tool_calls?, tool_call_id?}");
|
||||||
|
}
|
||||||
|
messages.push(msg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof m !== "object" ||
|
||||||
|
m === null ||
|
||||||
|
typeof (m as { role?: unknown }).role !== "string" ||
|
||||||
|
typeof (m as { content?: unknown }).content !== "string" ||
|
||||||
|
!ROLES.has((m as { role: string }).role)
|
||||||
|
) {
|
||||||
|
throw badRequest("invalid_messages", "each message must be {role: system|user|assistant, content: string}");
|
||||||
|
}
|
||||||
|
const mm = m as { role: "system" | "user" | "assistant"; content: string };
|
||||||
|
messages.push({ role: mm.role, content: mm.content });
|
||||||
|
}
|
||||||
|
|
||||||
|
let tools: unknown[] | undefined;
|
||||||
|
let toolChoice: unknown;
|
||||||
|
if (allowTools && Array.isArray(b.tools) && b.tools.length > 0) {
|
||||||
|
tools = b.tools;
|
||||||
|
toolChoice = b.tool_choice;
|
||||||
|
}
|
||||||
|
|
||||||
|
let maxTokens: number | undefined;
|
||||||
|
const mt = b.max_tokens ?? b.max_completion_tokens;
|
||||||
|
if (mt !== undefined && mt !== null) {
|
||||||
|
if (typeof mt !== "number" || !Number.isInteger(mt) || mt <= 0) {
|
||||||
|
throw badRequest("invalid_max_tokens", "`max_tokens` must be a positive integer");
|
||||||
|
}
|
||||||
|
maxTokens = mt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = b.stream === true;
|
||||||
|
return { model, messages, maxTokens, stream, tools, toolChoice };
|
||||||
|
}
|
||||||
137
gateway/src/routing/selectSupplier.ts
Normal file
137
gateway/src/routing/selectSupplier.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/routing/selectSupplier.ts — capability + model routing.
|
||||||
|
*
|
||||||
|
* Queries the indexer for suppliers advertising the requested model under the
|
||||||
|
* required capability. Per the design: match by capability + model, pick an
|
||||||
|
* AVAILABLE supplier (status free|unknown), NOT the cheapest. We return all
|
||||||
|
* eligible candidates so the caller can fall back to the next one when the
|
||||||
|
* chosen supplier turns out to be busy/offline at escrow-post time.
|
||||||
|
*
|
||||||
|
* `status==="unknown"` is treated as eligible: the indexer poller (~20s) leaves
|
||||||
|
* freshly-advertised suppliers as "unknown" until first polled, and excluding
|
||||||
|
* them would create artificial availability gaps.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { OutputReference } from "@marketplace/shared/chain";
|
||||||
|
|
||||||
|
/** Subset of the indexer's SupplierView we depend on. */
|
||||||
|
interface SupplierView {
|
||||||
|
utxo_ref: string;
|
||||||
|
supplier_pkh: string;
|
||||||
|
capability_id: string;
|
||||||
|
model: string;
|
||||||
|
max_output_tokens: number;
|
||||||
|
price_lovelace: string;
|
||||||
|
supplier_bond_lovelace: string;
|
||||||
|
buyer_bond_lovelace: string;
|
||||||
|
endpoint_url: string;
|
||||||
|
advert_status: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupplierCandidate {
|
||||||
|
advertRef: OutputReference;
|
||||||
|
utxoRef: string;
|
||||||
|
supplierPkh: string;
|
||||||
|
model: string;
|
||||||
|
capabilityId: string;
|
||||||
|
endpointUrl: string;
|
||||||
|
priceLovelace: bigint;
|
||||||
|
buyerBondLovelace: bigint;
|
||||||
|
supplierBondLovelace: bigint;
|
||||||
|
maxOutputTokens: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ESCROW_REF_RE = /^([0-9a-fA-F]{64})#(\d+)$/;
|
||||||
|
|
||||||
|
export function parseRef(ref: string): OutputReference | null {
|
||||||
|
const m = ESCROW_REF_RE.exec(ref);
|
||||||
|
if (!m) return null;
|
||||||
|
return { txHash: m[1], index: Number(m[2]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectSupplierOpts {
|
||||||
|
indexerUrl: string;
|
||||||
|
/** Exact model match; "" matches ANY model under the capability (used by
|
||||||
|
* single-model capabilities like the ocr.* ids, where the capability
|
||||||
|
* already pins the model). */
|
||||||
|
model: string;
|
||||||
|
capabilityId: string;
|
||||||
|
fetchFn?: typeof globalThis.fetch;
|
||||||
|
/** supplier_pkhs eligible regardless of polled status — used after the demo
|
||||||
|
* executor frees a supplier the indexer poller (~20 s cadence) still reports
|
||||||
|
* busy. sdk.startChat's live /status pre-check still guards genuinely-busy
|
||||||
|
* suppliers. */
|
||||||
|
ignoreStatusFor?: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return eligible candidates for (model, capability), ordered for fallback.
|
||||||
|
* Eligible = advert Active, model matches, live status ∈ {free, unknown}.
|
||||||
|
*/
|
||||||
|
export async function selectCandidates(opts: SelectSupplierOpts): Promise<SupplierCandidate[]> {
|
||||||
|
const fetchFn = opts.fetchFn ?? globalThis.fetch;
|
||||||
|
const url = `${opts.indexerUrl}/suppliers?capability_id=${encodeURIComponent(opts.capabilityId)}`;
|
||||||
|
const res = await fetchFn(url);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`indexer /suppliers returned ${res.status}`);
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as unknown;
|
||||||
|
if (!Array.isArray(body)) {
|
||||||
|
throw new Error("indexer /suppliers did not return an array");
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates: SupplierCandidate[] = [];
|
||||||
|
for (const raw of body as SupplierView[]) {
|
||||||
|
if (raw.capability_id !== opts.capabilityId) continue;
|
||||||
|
if (opts.model !== "" && raw.model !== opts.model) continue;
|
||||||
|
if (raw.advert_status !== "Active") continue;
|
||||||
|
if (raw.status !== "free" && raw.status !== "unknown" && !opts.ignoreStatusFor?.has(raw.supplier_pkh)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const advertRef = parseRef(raw.utxo_ref);
|
||||||
|
if (!advertRef) continue;
|
||||||
|
candidates.push({
|
||||||
|
advertRef,
|
||||||
|
utxoRef: raw.utxo_ref,
|
||||||
|
supplierPkh: raw.supplier_pkh,
|
||||||
|
model: raw.model,
|
||||||
|
capabilityId: raw.capability_id,
|
||||||
|
endpointUrl: raw.endpoint_url,
|
||||||
|
priceLovelace: BigInt(raw.price_lovelace),
|
||||||
|
buyerBondLovelace: BigInt(raw.buyer_bond_lovelace),
|
||||||
|
supplierBondLovelace: BigInt(raw.supplier_bond_lovelace),
|
||||||
|
maxOutputTokens: raw.max_output_tokens,
|
||||||
|
status: raw.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Prefer known-free over unknown so we route to confirmed-available first.
|
||||||
|
candidates.sort((a, b) => (a.status === "free" ? 0 : 1) - (b.status === "free" ? 0 : 1));
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distinct models across Active suppliers (for GET /openai/v1/models).
|
||||||
|
* Pass capabilityId to list only models under one capability (demo keys see
|
||||||
|
* only llm.chat.v1 models — the session-backed executor's routing target). */
|
||||||
|
export async function listModels(opts: {
|
||||||
|
indexerUrl: string;
|
||||||
|
capabilityId?: string;
|
||||||
|
fetchFn?: typeof globalThis.fetch;
|
||||||
|
}): Promise<string[]> {
|
||||||
|
const fetchFn = opts.fetchFn ?? globalThis.fetch;
|
||||||
|
const url = opts.capabilityId
|
||||||
|
? `${opts.indexerUrl}/suppliers?capability_id=${encodeURIComponent(opts.capabilityId)}`
|
||||||
|
: `${opts.indexerUrl}/suppliers`;
|
||||||
|
const res = await fetchFn(url);
|
||||||
|
if (!res.ok) throw new Error(`indexer /suppliers returned ${res.status}`);
|
||||||
|
const body = (await res.json()) as unknown;
|
||||||
|
if (!Array.isArray(body)) throw new Error("indexer /suppliers did not return an array");
|
||||||
|
const models = new Set<string>();
|
||||||
|
for (const raw of body as SupplierView[]) {
|
||||||
|
if (raw.advert_status !== "Active" || typeof raw.model !== "string") continue;
|
||||||
|
if (opts.capabilityId !== undefined && raw.capability_id !== opts.capabilityId) continue;
|
||||||
|
models.add(raw.model);
|
||||||
|
}
|
||||||
|
return [...models].sort();
|
||||||
|
}
|
||||||
89
gateway/src/sdk/registry.ts
Normal file
89
gateway/src/sdk/registry.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/sdk/registry.ts — per-API-key Marketplace SDK cache + serializer.
|
||||||
|
*
|
||||||
|
* Each API key is its own custodial wallet, so each gets its own Marketplace
|
||||||
|
* instance bound to that wallet's WalletKey. The shared ChainProvider and
|
||||||
|
* indexer URL are reused across all keys. A per-key Mutex serializes all
|
||||||
|
* on-chain work for one wallet (a single wallet cannot post two escrows from
|
||||||
|
* the same UTxO concurrently). The cache is LRU-bounded to cap memory.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Marketplace, MemoryTaskHistoryStore } from "@marketplace/buyer/sdk";
|
||||||
|
import type { LiveOgmiosProvider } from "@marketplace/shared/chain";
|
||||||
|
import type { WalletKey } from "@marketplace/shared/tx";
|
||||||
|
import { open as unseal } from "../crypto/seal.js";
|
||||||
|
import { deriveWalletKey } from "../wallet.js";
|
||||||
|
import type { ApiKeyRow } from "../db/store.js";
|
||||||
|
|
||||||
|
/** Tiny promise-chain mutex: run(fn) executes fns strictly one at a time. */
|
||||||
|
export class Mutex {
|
||||||
|
private tail: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
|
run<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
const result = this.tail.then(fn, fn);
|
||||||
|
// Keep the chain alive even if fn rejects; swallow here so the next
|
||||||
|
// run() isn't poisoned by a prior rejection.
|
||||||
|
this.tail = result.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyContext {
|
||||||
|
sdk: Marketplace;
|
||||||
|
mutex: Mutex;
|
||||||
|
walletKey: WalletKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SdkRegistryDeps {
|
||||||
|
chain: LiveOgmiosProvider;
|
||||||
|
indexerUrl: string;
|
||||||
|
networkId: 0 | 1;
|
||||||
|
masterKeyHex: string;
|
||||||
|
max: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SdkRegistry {
|
||||||
|
private readonly cache = new Map<string, KeyContext>();
|
||||||
|
|
||||||
|
constructor(private readonly deps: SdkRegistryDeps) {}
|
||||||
|
|
||||||
|
/** Build (or fetch from cache) the per-key SDK context. Decrypts the wallet
|
||||||
|
* key in-memory only when first constructing the instance. */
|
||||||
|
getContext(keyRow: ApiKeyRow): KeyContext {
|
||||||
|
const existing = this.cache.get(keyRow.id);
|
||||||
|
if (existing) {
|
||||||
|
// LRU bump: re-insert to move to the end of iteration order.
|
||||||
|
this.cache.delete(keyRow.id);
|
||||||
|
this.cache.set(keyRow.id, existing);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const privHex = unseal(
|
||||||
|
{ nonce: keyRow.enc_priv_nonce, ct: keyRow.enc_priv_ct, tag: keyRow.enc_priv_tag },
|
||||||
|
this.deps.masterKeyHex,
|
||||||
|
);
|
||||||
|
const walletKey = deriveWalletKey(privHex, this.deps.networkId);
|
||||||
|
const sdk = new Marketplace({
|
||||||
|
chain: this.deps.chain,
|
||||||
|
indexerUrl: this.deps.indexerUrl,
|
||||||
|
walletKey,
|
||||||
|
networkParams: { networkId: this.deps.networkId },
|
||||||
|
historyStore: new MemoryTaskHistoryStore(),
|
||||||
|
});
|
||||||
|
const ctx: KeyContext = { sdk, mutex: new Mutex(), walletKey };
|
||||||
|
this.cache.set(keyRow.id, ctx);
|
||||||
|
this.evictIfNeeded();
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
private evictIfNeeded(): void {
|
||||||
|
while (this.cache.size > this.deps.max) {
|
||||||
|
const oldest = this.cache.keys().next().value as string | undefined;
|
||||||
|
if (oldest === undefined) break;
|
||||||
|
this.cache.delete(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
105
gateway/src/server.ts
Normal file
105
gateway/src/server.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/server.ts — Express app wiring.
|
||||||
|
*
|
||||||
|
* Public: GET /healthz, GET /, POST /signup (IP rate-limited).
|
||||||
|
* Bearer: GET /account, POST /account/withdraw,
|
||||||
|
* POST /openai/v1/chat/completions, GET /openai/v1/models,
|
||||||
|
* POST /openai/v1/chat/sessions[/:id/messages|/close].
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express, { type Express, type NextFunction, type Request, type Response } from "express";
|
||||||
|
import type { GatewayDeps } from "./deps.js";
|
||||||
|
import { makeApiKeyAuth } from "./middleware/apiKeyAuth.js";
|
||||||
|
import { ipRateLimit, keyRateLimit, demoIpRateLimit } from "./middleware/rateLimit.js";
|
||||||
|
import { sendError } from "./middleware/http.js";
|
||||||
|
import { notFound } from "./openai/errors.js";
|
||||||
|
import { makeChatCompletionsHandler } from "./openai/chatCompletions.js";
|
||||||
|
import { makeOcrExtractHandler } from "./ocr/extract.js";
|
||||||
|
import { makeModelsHandler } from "./openai/models.js";
|
||||||
|
import {
|
||||||
|
makeOpenSessionHandler,
|
||||||
|
makeSessionMessageHandler,
|
||||||
|
makeCloseSessionHandler,
|
||||||
|
} from "./openai/sessions.js";
|
||||||
|
import { makeSignupHandler, makeAccountHandler, makeWithdrawHandler } from "./account/routes.js";
|
||||||
|
import { INDEX_HTML } from "./ui/page.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CORS for browser clients on an allowlisted Origin (e.g. the marketplace
|
||||||
|
* frontend's "Generate API key" page, served from a different host than the
|
||||||
|
* gateway). Only echoes Access-Control-* headers when the request's Origin is
|
||||||
|
* in the configured allowlist; an empty allowlist disables CORS entirely.
|
||||||
|
* No credentials are allowed — the signup call is a plain bearer-less POST, so
|
||||||
|
* cookies never ride along cross-origin. Preflight OPTIONS is answered 204.
|
||||||
|
*/
|
||||||
|
function corsMiddleware(allowOrigins: string[]) {
|
||||||
|
const allow = new Set(allowOrigins);
|
||||||
|
return (req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
const origin = req.headers.origin;
|
||||||
|
if (typeof origin === "string" && allow.has(origin)) {
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
||||||
|
res.setHeader("Vary", "Origin");
|
||||||
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||||
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||||
|
res.setHeader("Access-Control-Max-Age", "600");
|
||||||
|
}
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
res.status(204).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApp(deps: GatewayDeps): Express {
|
||||||
|
const app = express();
|
||||||
|
// Behind traefik/Cloudflare — trust the proxy chain so req.ip is the client.
|
||||||
|
app.set("trust proxy", true);
|
||||||
|
// CORS before body-parsing so preflight OPTIONS short-circuits cheaply.
|
||||||
|
app.use(corsMiddleware(deps.config.corsOrigins));
|
||||||
|
// /v1/ocr carries a base64 page image and mounts its own larger parser on
|
||||||
|
// the route; every other path keeps the 1mb ceiling.
|
||||||
|
const jsonBody = express.json({ limit: "1mb" });
|
||||||
|
app.use((req, res, next) =>
|
||||||
|
req.path.startsWith("/v1/ocr/") ? next() : jsonBody(req, res, next));
|
||||||
|
|
||||||
|
app.get("/healthz", (_req: Request, res: Response) => {
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/", (_req: Request, res: Response) => {
|
||||||
|
res.status(200).type("html").send(INDEX_HTML);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Public signup, rate-limited per IP.
|
||||||
|
app.post("/signup", ipRateLimit(deps.config.signupRate), makeSignupHandler(deps));
|
||||||
|
|
||||||
|
// Bearer-gated routes: auth → per-key rate limit (skips demo keys) →
|
||||||
|
// per-IP demo limit (demo keys only) → handler.
|
||||||
|
const auth = makeApiKeyAuth(deps.store);
|
||||||
|
const krl = keyRateLimit(deps.config.keyRate);
|
||||||
|
const dirl = demoIpRateLimit(deps.config.demoIpRate);
|
||||||
|
const gated = [auth, krl, dirl] as const;
|
||||||
|
|
||||||
|
app.get("/account", auth, makeAccountHandler(deps));
|
||||||
|
app.post("/account/withdraw", auth, makeWithdrawHandler(deps));
|
||||||
|
|
||||||
|
app.post("/openai/v1/chat/completions", ...gated, makeChatCompletionsHandler(deps));
|
||||||
|
// Model-scoped OCR: 16mb parser covers MAX_OCR_IMAGE_B64_CHARS
|
||||||
|
// (~12M chars ≈ 9 MB binary) plus JSON envelope. Gates (auth + rate
|
||||||
|
// limits) run BEFORE the parser so an unauthenticated caller cannot
|
||||||
|
// buffer/JSON.parse up to 16mb before getting a 401, and rate limiting
|
||||||
|
// can actually throttle the request (2026-08 review finding C2).
|
||||||
|
app.post("/v1/ocr/extract", ...gated, express.json({ limit: "16mb" }), makeOcrExtractHandler(deps));
|
||||||
|
app.get("/openai/v1/models", auth, makeModelsHandler(deps));
|
||||||
|
app.post("/openai/v1/chat/sessions", ...gated, makeOpenSessionHandler(deps));
|
||||||
|
app.post("/openai/v1/chat/sessions/:id/messages", ...gated, makeSessionMessageHandler(deps));
|
||||||
|
app.post("/openai/v1/chat/sessions/:id/close", ...gated, makeCloseSessionHandler(deps));
|
||||||
|
|
||||||
|
// OpenAI-shaped 404 for anything else.
|
||||||
|
app.use((_req: Request, res: Response) => {
|
||||||
|
sendError(res, notFound("not_found", "unknown route"));
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
224
gateway/src/sweeper.ts
Normal file
224
gateway/src/sweeper.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/sweeper.ts — state-aware custody safety net.
|
||||||
|
*
|
||||||
|
* Per key, classifies on-chain escrows and recovers stranded funds:
|
||||||
|
* Open|Claimed past the deliver_by deadline → Reclaim (returns funds + bond).
|
||||||
|
* Submitted (verified, in accept-window) → retry Accept (settle + refund).
|
||||||
|
* Submitted past the accept-window → leave for supplier Release (log).
|
||||||
|
*
|
||||||
|
* reclaim only works on Open/Claimed; Accept only on Submitted — so we branch by
|
||||||
|
* state and never blind-reclaim a Submitted escrow. The on-chain validator is
|
||||||
|
* the source of truth for deadlines, so premature/late attempts simply fail and
|
||||||
|
* are caught here; the age thresholds just avoid racing in-flight requests.
|
||||||
|
*
|
||||||
|
* Chat sessions get special handling:
|
||||||
|
* - Their deliver_by is ~30 min (vs ~10 min one-shot), so rows belonging to
|
||||||
|
* an OPEN session use CHAT_RECLAIM_AFTER_MS — otherwise every open session
|
||||||
|
* older than 11 min would trigger a doomed Reclaim attempt per sweep while
|
||||||
|
* holding the key's mutex.
|
||||||
|
* - When a session's escrow reaches a terminal state (Accepted here, spent
|
||||||
|
* elsewhere, or Reclaimed), the session row is closed, its in-memory state
|
||||||
|
* dropped, and a usage row recorded — abandoned demo sessions (nobody calls
|
||||||
|
* /close) settle and get billed through this path.
|
||||||
|
*
|
||||||
|
* Escrow↔session lineage: the indexer keeps one row per utxo_ref per state
|
||||||
|
* (terminal spends update nothing), and the validator preserves posted_at
|
||||||
|
* across states — so a session's lineage is "rows sharing the posted_at of the
|
||||||
|
* session's original Open ref row".
|
||||||
|
*
|
||||||
|
* Because terminal spends never update indexer rows, every settled escrow
|
||||||
|
* would be retried (one doomed chain query each) on every sweep forever. The
|
||||||
|
* in-process `settledRefs` memory skips refs this process has already settled
|
||||||
|
* or found spent; a restart just causes one harmless retry burst.
|
||||||
|
*
|
||||||
|
* Ticket mode (config.chatSettleMode === "ticket"): session escrows are never
|
||||||
|
* Claimed — they sit Open until reclaimed here after deliver_by. Reclaiming
|
||||||
|
* an Open ticket does NOT close a still-open session row: nothing re-checks
|
||||||
|
* the escrow after /v1/chat/start, so the conversation (and its affinity
|
||||||
|
* mirror) lives on, bounded only by the idle janitor.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import type { GatewayDeps } from "./deps.js";
|
||||||
|
import type { SessionRow } from "./db/store.js";
|
||||||
|
import { fetchEscrows, acceptAndConfirm, reclaimAndConfirm, type EscrowRow } from "./onchain/settle.js";
|
||||||
|
import { parseRef } from "./routing/selectSupplier.js";
|
||||||
|
import { dropSessionState } from "./openai/transcripts.js";
|
||||||
|
import { dropDemoSession, sweepIdleDemoSessions } from "./openai/demoChat.js";
|
||||||
|
import { CAPABILITY as CHAT_CAPABILITY } from "./openai/sessions.js";
|
||||||
|
|
||||||
|
// Don't touch escrows newer than this — an in-flight request is settling them.
|
||||||
|
const MIN_AGE_MS = 90_000;
|
||||||
|
// Reclaim Open/Claimed escrows older than this (past deliver_by ~10m + buffer).
|
||||||
|
const RECLAIM_AFTER_MS = 11 * 60 * 1000;
|
||||||
|
// Chat-session escrows: deliver_by ≈ posted_at + 30 min, so wait longer.
|
||||||
|
const CHAT_RECLAIM_AFTER_MS = 35 * 60 * 1000;
|
||||||
|
// Reclaim only fires once the validator allows it (validity lower bound >=
|
||||||
|
// deliver_by); attempting earlier is a guaranteed-doomed tx build.
|
||||||
|
const DELIVER_BY_BUFFER_MS = 60_000;
|
||||||
|
|
||||||
|
/** Refs this process already settled (Accepted/Reclaimed) or found spent.
|
||||||
|
* The indexer never updates terminal rows, so without this every historical
|
||||||
|
* escrow is retried each sweep. Bounded FIFO; restart = one retry burst. */
|
||||||
|
const settledRefs = new Set<string>();
|
||||||
|
const SETTLED_REFS_MAX = 5_000;
|
||||||
|
|
||||||
|
function markSettled(ref: string): void {
|
||||||
|
settledRefs.add(ref);
|
||||||
|
if (settledRefs.size > SETTLED_REFS_MAX) {
|
||||||
|
const first = settledRefs.values().next().value;
|
||||||
|
if (first !== undefined) settledRefs.delete(first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(msg: string): void {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[gateway:sweeper] ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSessionRow(
|
||||||
|
deps: GatewayDeps,
|
||||||
|
session: SessionRow,
|
||||||
|
outcome: { status: "completed" | "reclaimed"; costLovelace: string | null },
|
||||||
|
): void {
|
||||||
|
// Re-read: an in-flight close (route handler or demo executor) may have won.
|
||||||
|
const current = deps.store.getSession(session.id);
|
||||||
|
if (!current || current.state !== "open") return;
|
||||||
|
deps.store.setSessionState(session.id, "closed", Date.now());
|
||||||
|
dropSessionState(session.id);
|
||||||
|
dropDemoSession(session.id);
|
||||||
|
deps.store.insertUsage({
|
||||||
|
id: randomUUID(),
|
||||||
|
key_id: session.key_id,
|
||||||
|
created_at: Date.now(),
|
||||||
|
kind: "chat_session",
|
||||||
|
model: session.model,
|
||||||
|
capability_id: CHAT_CAPABILITY,
|
||||||
|
supplier_pkh: session.supplier_pkh,
|
||||||
|
escrow_ref: session.escrow_ref,
|
||||||
|
cost_lovelace: outcome.costLovelace,
|
||||||
|
prompt_tokens: 0,
|
||||||
|
completion_tokens: 0,
|
||||||
|
status: outcome.status,
|
||||||
|
failure_reason: null,
|
||||||
|
});
|
||||||
|
log(`closed abandoned session ${session.id} (${outcome.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chain-settle functions, injectable for tests. */
|
||||||
|
export interface SweepSettleFns {
|
||||||
|
accept: typeof acceptAndConfirm;
|
||||||
|
reclaim: typeof reclaimAndConfirm;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runSweepOnce(
|
||||||
|
deps: GatewayDeps,
|
||||||
|
settle: SweepSettleFns = { accept: acceptAndConfirm, reclaim: reclaimAndConfirm },
|
||||||
|
): Promise<void> {
|
||||||
|
// Idle demo sessions first: closing them makes the supplier Submit, so this
|
||||||
|
// sweep (or the next) can Accept the escrow right after.
|
||||||
|
await sweepIdleDemoSessions(deps).catch((err) =>
|
||||||
|
log(`demo idle janitor: ${err instanceof Error ? err.message : String(err)}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
for (const keyRow of deps.store.listKeys()) {
|
||||||
|
let rows: EscrowRow[];
|
||||||
|
try {
|
||||||
|
rows = await fetchEscrows(deps.config.indexerUrl, keyRow.wallet_pkh, deps.fetchFn);
|
||||||
|
} catch (err) {
|
||||||
|
log(`indexer error for ${keyRow.key_prefix}: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lineage-match open sessions: the session stores its ORIGINAL Open ref;
|
||||||
|
// rows sharing that row's posted_at are the same escrow in later states.
|
||||||
|
const sessionByPostedAt = new Map<number, SessionRow>();
|
||||||
|
for (const session of deps.store.listOpenSessionsByKey(keyRow.id)) {
|
||||||
|
const origin = rows.find((r) => r.utxo_ref === session.escrow_ref);
|
||||||
|
if (origin) sessionByPostedAt.set(origin.posted_at, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = deps.registry.getContext(keyRow);
|
||||||
|
for (const row of rows) {
|
||||||
|
const ref = parseRef(row.utxo_ref);
|
||||||
|
if (!ref) continue;
|
||||||
|
if (settledRefs.has(row.utxo_ref)) continue;
|
||||||
|
const ageMs = now - (row.posted_at ?? 0);
|
||||||
|
if (ageMs < MIN_AGE_MS) continue;
|
||||||
|
const chatSession = sessionByPostedAt.get(row.posted_at);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (row.state === "Submitted") {
|
||||||
|
try {
|
||||||
|
await ctx.mutex.run(() => settle.accept(deps.chain, ctx.walletKey, ref));
|
||||||
|
log(`accepted stranded Submitted escrow ${row.utxo_ref}`);
|
||||||
|
markSettled(row.utxo_ref);
|
||||||
|
if (chatSession) {
|
||||||
|
closeSessionRow(deps, chatSession, { status: "completed", costLovelace: chatSession.price_lovelace });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Accept fails routinely when the UTxO is already spent (settled
|
||||||
|
// by an in-flight request, an earlier sweep, or supplier Release).
|
||||||
|
// For session lineages, confirm the spend on-chain before closing
|
||||||
|
// the row — an Accept that failed for another reason (timeout)
|
||||||
|
// must leave the session open for the next sweep.
|
||||||
|
if ((await deps.chain.queryUtxo(ref)) === null) {
|
||||||
|
markSettled(row.utxo_ref);
|
||||||
|
if (chatSession) {
|
||||||
|
closeSessionRow(deps, chatSession, { status: "completed", costLovelace: chatSession.price_lovelace });
|
||||||
|
}
|
||||||
|
} else if (!chatSession) {
|
||||||
|
// Preserve the historical quiet-skip for one-shot escrows.
|
||||||
|
void err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (row.state === "Open" || row.state === "Claimed") {
|
||||||
|
const reclaimAfterMs = chatSession ? CHAT_RECLAIM_AFTER_MS : RECLAIM_AFTER_MS;
|
||||||
|
// The validator only allows Reclaim at/after deliver_by — skip
|
||||||
|
// attempts that cannot succeed yet (early-closed ticket sessions).
|
||||||
|
const pastDeliverBy =
|
||||||
|
typeof row.deliver_by === "number" ? now > row.deliver_by + DELIVER_BY_BUFFER_MS : true;
|
||||||
|
if (ageMs > reclaimAfterMs && pastDeliverBy) {
|
||||||
|
await ctx.mutex.run(() => settle.reclaim(deps.chain, ctx.walletKey, ref));
|
||||||
|
log(`reclaimed stranded ${row.state} escrow ${row.utxo_ref}`);
|
||||||
|
markSettled(row.utxo_ref);
|
||||||
|
if (chatSession) {
|
||||||
|
const ticketOpen = deps.config.chatSettleMode === "ticket" && row.state === "Open";
|
||||||
|
if (ticketOpen) {
|
||||||
|
// Ticket escrow reclaimed out from under a LIVE session —
|
||||||
|
// by design. Keep the session row + mirror; only the idle
|
||||||
|
// janitor / close paths end ticket sessions.
|
||||||
|
log(`ticket mode: escrow reclaimed, session ${chatSession.id} stays open`);
|
||||||
|
} else {
|
||||||
|
// Funds returned — the session never settled; record no cost.
|
||||||
|
closeSessionRow(deps, chatSession, { status: "reclaimed", costLovelace: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Expected when the validator rejects (wrong window/state) or the UTxO
|
||||||
|
// was already spent by an in-flight request; skip quietly.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start the periodic sweeper. Returns a stop() function. */
|
||||||
|
export function startSweeper(deps: GatewayDeps): () => void {
|
||||||
|
let running = false;
|
||||||
|
const tick = async (): Promise<void> => {
|
||||||
|
if (running) return;
|
||||||
|
running = true;
|
||||||
|
try {
|
||||||
|
await runSweepOnce(deps);
|
||||||
|
} catch (err) {
|
||||||
|
log(`sweep failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
} finally {
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handle = setInterval(() => void tick(), deps.config.sweeperIntervalMs);
|
||||||
|
return () => clearInterval(handle);
|
||||||
|
}
|
||||||
81
gateway/src/ui/page.ts
Normal file
81
gateway/src/ui/page.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/ui/page.ts — self-serve web UI, inlined as a string so it ships
|
||||||
|
* in dist/ (tsc does not copy .html). Plain HTML + fetch, no build step.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const INDEX_HTML = `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Vector Marketplace - OpenAI Gateway</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; max-width: 760px; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; }
|
||||||
|
h1 { font-size: 1.5rem; } h2 { font-size: 1.1rem; margin-top: 2rem; }
|
||||||
|
code, pre { background: #f4f4f5; border-radius: 4px; padding: .15rem .35rem; }
|
||||||
|
pre { padding: .75rem; overflow-x: auto; }
|
||||||
|
input, button { font-size: 1rem; padding: .45rem .6rem; border-radius: 6px; border: 1px solid #ccc; }
|
||||||
|
button { background: #111; color: #fff; cursor: pointer; border: none; }
|
||||||
|
.row { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; margin: .5rem 0; }
|
||||||
|
.key { background: #fffbe6; border: 1px solid #f5d90a; padding: .75rem; border-radius: 6px; word-break: break-all; }
|
||||||
|
.muted { color: #666; font-size: .9rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Vector Marketplace - OpenAI-compatible Gateway</h1>
|
||||||
|
<p class="muted">Point any OpenAI SDK at <code id="base"></code> using your API key as the bearer token.</p>
|
||||||
|
|
||||||
|
<h2>1. Create an API key</h2>
|
||||||
|
<div class="row">
|
||||||
|
<input id="label" placeholder="label (optional)" />
|
||||||
|
<button onclick="signup()">Create key</button>
|
||||||
|
</div>
|
||||||
|
<div id="signupOut"></div>
|
||||||
|
|
||||||
|
<h2>2. Fund your wallet</h2>
|
||||||
|
<p class="muted">Send AP3X to your deposit address (you need price + bonds + ~5 AP3X collateral + fees).</p>
|
||||||
|
|
||||||
|
<h2>3. Account</h2>
|
||||||
|
<div class="row">
|
||||||
|
<input id="apiKey" placeholder="vk_..." style="flex:1; min-width: 320px" />
|
||||||
|
<button onclick="account()">Check account</button>
|
||||||
|
</div>
|
||||||
|
<div id="acctOut"></div>
|
||||||
|
|
||||||
|
<h2>4. Withdraw</h2>
|
||||||
|
<div class="row">
|
||||||
|
<input id="toAddr" placeholder="addr... (destination)" style="flex:1; min-width: 320px" />
|
||||||
|
<input id="amt" placeholder="amount lovelace (blank = all)" />
|
||||||
|
<button onclick="withdraw()">Withdraw</button>
|
||||||
|
</div>
|
||||||
|
<div id="wOut"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('base').textContent = location.origin + '/openai/v1';
|
||||||
|
function pre(id, obj){ document.getElementById(id).innerHTML = '<pre>'+JSON.stringify(obj,null,2)+'</pre>'; }
|
||||||
|
async function signup(){
|
||||||
|
const label = document.getElementById('label').value;
|
||||||
|
const r = await fetch('/signup', {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({label})});
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.api_key) {
|
||||||
|
document.getElementById('signupOut').innerHTML =
|
||||||
|
'<div class="key"><b>API key (shown once):</b><br>'+j.api_key+'<br><br><b>Deposit address:</b><br>'+j.deposit_address+'</div>';
|
||||||
|
document.getElementById('apiKey').value = j.api_key;
|
||||||
|
document.getElementById('toAddr').value = '';
|
||||||
|
} else pre('signupOut', j);
|
||||||
|
}
|
||||||
|
function authHeaders(){ return {'authorization':'Bearer '+document.getElementById('apiKey').value, 'content-type':'application/json'}; }
|
||||||
|
async function account(){
|
||||||
|
const r = await fetch('/account', {headers: authHeaders()});
|
||||||
|
pre('acctOut', await r.json());
|
||||||
|
}
|
||||||
|
async function withdraw(){
|
||||||
|
const to_address = document.getElementById('toAddr').value;
|
||||||
|
const amt = document.getElementById('amt').value.trim();
|
||||||
|
const body = amt ? {to_address, amount_lovelace: amt} : {to_address};
|
||||||
|
const r = await fetch('/account/withdraw', {method:'POST', headers: authHeaders(), body: JSON.stringify(body)});
|
||||||
|
pre('wOut', await r.json());
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
60
gateway/src/wallet.ts
Normal file
60
gateway/src/wallet.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/wallet.ts — per-user wallet key derivation.
|
||||||
|
*
|
||||||
|
* Copied from buyer/src/index.ts:24-64 (deriveWalletKey + ed25519 sha512 hook)
|
||||||
|
* so the gateway does not have to import the buyer entrypoint (which boots an
|
||||||
|
* Express server). genPrivKeyHex mints a fresh custodial wallet at signup.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHash, randomBytes } from "crypto";
|
||||||
|
import * as ed from "@noble/ed25519";
|
||||||
|
import { blake2b } from "@noble/hashes/blake2b";
|
||||||
|
import { bech32 } from "bech32";
|
||||||
|
import type { WalletKey } from "@marketplace/shared/tx";
|
||||||
|
|
||||||
|
// Wire ed25519 sha512 hook (idempotent — same as buyer/src/index.ts + receipt/sign.ts).
|
||||||
|
ed.etc.sha512Sync = (...messages: Uint8Array[]): Uint8Array => {
|
||||||
|
const h = createHash("sha512");
|
||||||
|
for (const m of messages) h.update(m);
|
||||||
|
return new Uint8Array(h.digest());
|
||||||
|
};
|
||||||
|
|
||||||
|
function hexToBytes(hex: string): Uint8Array {
|
||||||
|
const bytes = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < hex.length; i += 2) {
|
||||||
|
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToHex(b: Uint8Array): string {
|
||||||
|
let out = "";
|
||||||
|
for (let i = 0; i < b.length; i++) out += b[i].toString(16).padStart(2, "0");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mint a fresh 32-byte Ed25519 private key as 64-char hex. */
|
||||||
|
export function genPrivKeyHex(): string {
|
||||||
|
return bytesToHex(new Uint8Array(randomBytes(32)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveWalletKey(privHex: string, networkId: 0 | 1): WalletKey {
|
||||||
|
const priv = hexToBytes(privHex);
|
||||||
|
const pub = ed.getPublicKey(priv);
|
||||||
|
const pubHex = bytesToHex(pub);
|
||||||
|
const pkh = blake2b(pub, { dkLen: 28 });
|
||||||
|
const pkhHex = bytesToHex(pkh);
|
||||||
|
const header = networkId === 0 ? 0x60 : 0x61;
|
||||||
|
const payload = new Uint8Array(29);
|
||||||
|
payload[0] = header;
|
||||||
|
payload.set(pkh, 1);
|
||||||
|
const words = bech32.toWords(payload);
|
||||||
|
const hrp = networkId === 0 ? "addr_test" : "addr";
|
||||||
|
const addr = bech32.encode(hrp, words, 1023);
|
||||||
|
return {
|
||||||
|
pubKeyHash: pkhHex,
|
||||||
|
pubKeyHex: pubHex,
|
||||||
|
privateKeyHex: privHex,
|
||||||
|
address: addr,
|
||||||
|
};
|
||||||
|
}
|
||||||
49
gateway/src/walletHealth.ts
Normal file
49
gateway/src/walletHealth.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* gateway/src/walletHealth.ts — keep each custodial wallet usable.
|
||||||
|
*
|
||||||
|
* After every settled request (and on a periodic tick) we re-shape the wallet to
|
||||||
|
* {≥5 AP3X collateral UTxO, working UTxO} via the shared consolidate builder, so
|
||||||
|
* the NEXT request's Accept/Reclaim script-spend always finds a pure-AP3X
|
||||||
|
* collateral candidate. No-op when the wallet is already healthy.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { LiveOgmiosProvider } from "@marketplace/shared/chain";
|
||||||
|
import type { WalletKey } from "@marketplace/shared/tx";
|
||||||
|
import { runConsolidateWallet, DEFAULT_COLLATERAL_LOVELACE } from "@marketplace/shared/tx/server";
|
||||||
|
import type { GatewayDeps } from "./deps.js";
|
||||||
|
|
||||||
|
export async function ensureWalletHealthy(
|
||||||
|
chain: LiveOgmiosProvider,
|
||||||
|
walletKey: WalletKey,
|
||||||
|
): Promise<void> {
|
||||||
|
await runConsolidateWallet({
|
||||||
|
chain,
|
||||||
|
walletKey,
|
||||||
|
collateralLovelace: DEFAULT_COLLATERAL_LOVELACE,
|
||||||
|
log: () => {
|
||||||
|
/* quiet; callers log failures */
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Periodically re-shape every wallet so a collateral UTxO is always available.
|
||||||
|
* Serialized per key via the registry mutex; no-op for already-healthy wallets. */
|
||||||
|
export function startWalletHealthTicker(deps: GatewayDeps): () => void {
|
||||||
|
let running = false;
|
||||||
|
const tick = async (): Promise<void> => {
|
||||||
|
if (running) return;
|
||||||
|
running = true;
|
||||||
|
try {
|
||||||
|
for (const keyRow of deps.store.listKeys()) {
|
||||||
|
const ctx = deps.registry.getContext(keyRow);
|
||||||
|
await ctx.mutex.run(() => ensureWalletHealthy(deps.chain, ctx.walletKey)).catch(() => {
|
||||||
|
/* skip wallets that can't consolidate (e.g. drained) */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handle = setInterval(() => void tick(), deps.config.walletHealthIntervalMs);
|
||||||
|
return () => clearInterval(handle);
|
||||||
|
}
|
||||||
143
gateway/test/affinity.test.ts
Normal file
143
gateway/test/affinity.test.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* affinity.test.ts — transcript-prefix session matching rules.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
matchSessionPrefix,
|
||||||
|
normalizeAssistantContent,
|
||||||
|
trimToRespondable,
|
||||||
|
type AffinityCandidate,
|
||||||
|
} from "../src/openai/affinity.js";
|
||||||
|
import type { ChatMessage } from "@marketplace/shared/tx";
|
||||||
|
|
||||||
|
const u = (content: string): ChatMessage => ({ role: "user", content });
|
||||||
|
const a = (content: string): ChatMessage => ({ role: "assistant", content });
|
||||||
|
const TOOL_CALL = { id: "call_1", type: "function" as const, function: { name: "get_time", arguments: "{}" } };
|
||||||
|
|
||||||
|
function cand(sessionId: string, mirror: ChatMessage[], lastUsedAt = 0, model = "m"): AffinityCandidate {
|
||||||
|
return { sessionId, model, mirror, lastUsedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("matchSessionPrefix", () => {
|
||||||
|
it("matches an exact prefix and returns the delta", () => {
|
||||||
|
const got = matchSessionPrefix([cand("s1", [u("hi"), a("hello")])], [u("hi"), a("hello"), u("more")], "m");
|
||||||
|
expect(got).toEqual({ sessionId: "s1", delta: [u("more")] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a multi-message tool delta ending with a tool result", () => {
|
||||||
|
const mirror = [u("hi"), { role: "assistant" as const, content: "", tool_calls: [TOOL_CALL] }];
|
||||||
|
const incoming = [...mirror, { role: "tool" as const, content: "3pm", tool_call_id: "call_1" }];
|
||||||
|
const got = matchSessionPrefix([cand("s1", mirror)], incoming, "m");
|
||||||
|
expect(got?.sessionId).toBe("s1");
|
||||||
|
expect(got?.delta).toHaveLength(1);
|
||||||
|
expect(got?.delta[0].role).toBe("tool");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects deltas that end with a non-respondable role", () => {
|
||||||
|
const got = matchSessionPrefix([cand("s1", [u("hi")])], [u("hi"), a("stray assistant")], "m");
|
||||||
|
expect(got).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never matches when incoming is not longer than the mirror (retry of answered request)", () => {
|
||||||
|
const mirror = [u("hi"), a("hello")];
|
||||||
|
expect(matchSessionPrefix([cand("s1", mirror)], mirror, "m")).toBeNull();
|
||||||
|
expect(matchSessionPrefix([cand("s1", mirror)], [u("hi")], "m")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects diverged histories (prefix mismatch)", () => {
|
||||||
|
const got = matchSessionPrefix([cand("s1", [u("hi"), a("hello")])], [u("hi"), a("DIFFERENT"), u("more")], "m");
|
||||||
|
expect(got).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the model to match", () => {
|
||||||
|
expect(matchSessionPrefix([cand("s1", [u("hi")], 0, "other")], [u("hi"), u("more")], "m")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the longest mirror, then most recently used", () => {
|
||||||
|
const incoming = [u("hi"), a("hello"), u("more")];
|
||||||
|
const longest = matchSessionPrefix(
|
||||||
|
[cand("short", [u("hi")], 99), cand("long", [u("hi"), a("hello")], 0)],
|
||||||
|
incoming,
|
||||||
|
"m",
|
||||||
|
);
|
||||||
|
expect(longest?.sessionId).toBe("long");
|
||||||
|
|
||||||
|
const tie = matchSessionPrefix(
|
||||||
|
[cand("old", [u("hi"), a("hello")], 1), cand("recent", [u("hi"), a("hello")], 2)],
|
||||||
|
incoming,
|
||||||
|
"m",
|
||||||
|
);
|
||||||
|
expect(tie?.sessionId).toBe("recent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches an empty mirror (fresh session) against any respondable history", () => {
|
||||||
|
const incoming = [{ role: "system" as const, content: "be nice" }, u("hi")];
|
||||||
|
const got = matchSessionPrefix([cand("fresh", [])], incoming, "m");
|
||||||
|
expect(got).toEqual({ sessionId: "fresh", delta: incoming });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats content null-normalization as equivalent (tool-call assistant echo)", () => {
|
||||||
|
const mirror = [u("hi"), { role: "assistant" as const, content: "", tool_calls: [TOOL_CALL] }];
|
||||||
|
// Client echoes the assistant message back with content:"" (normalized from null upstream).
|
||||||
|
const incoming = [u("hi"), { role: "assistant" as const, content: "", tool_calls: [{ ...TOOL_CALL }] }, { role: "tool" as const, content: "x", tool_call_id: "call_1" }];
|
||||||
|
expect(matchSessionPrefix([cand("s1", mirror)], incoming, "m")?.sessionId).toBe("s1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches leniently when the client reshapes the assistant echo (think-strip + whitespace)", () => {
|
||||||
|
const mirror = [u("hi"), a("<think>plan it</think>Hello there\n")];
|
||||||
|
const got = matchSessionPrefix([cand("s1", mirror)], [u("hi"), a("Hello there"), u("more")], "m");
|
||||||
|
expect(got).toEqual({ sessionId: "s1", delta: [u("more")] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays strict on user/tool divergence even with lenient assistant matching", () => {
|
||||||
|
const mirror = [u("hi "), a("Hello")];
|
||||||
|
// User content differs by whitespace — client-authored roles must be byte-exact.
|
||||||
|
expect(matchSessionPrefix([cand("s1", mirror)], [u("hi"), a("Hello"), u("more")], "m")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects assistant tool_calls divergence under lenient matching", () => {
|
||||||
|
const mirror = [u("hi"), { role: "assistant" as const, content: "x", tool_calls: [TOOL_CALL] }];
|
||||||
|
const changed = { ...TOOL_CALL, function: { name: "get_time", arguments: '{"tz":"utc"}' } };
|
||||||
|
const incoming = [u("hi"), { role: "assistant" as const, content: "x ", tool_calls: [changed] }, { role: "tool" as const, content: "t", tool_call_id: "call_1" }];
|
||||||
|
expect(matchSessionPrefix([cand("s1", mirror)], incoming, "m")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers a strict match over a lenient one regardless of recency", () => {
|
||||||
|
const incoming = [u("hi"), a("Hello"), u("more")];
|
||||||
|
const got = matchSessionPrefix(
|
||||||
|
[
|
||||||
|
cand("lenient", [u("hi"), a("Hello ")], 99),
|
||||||
|
cand("strict", [u("hi"), a("Hello")], 1),
|
||||||
|
],
|
||||||
|
incoming,
|
||||||
|
"m",
|
||||||
|
);
|
||||||
|
expect(got?.sessionId).toBe("strict");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeAssistantContent", () => {
|
||||||
|
it("strips think blocks, collapses whitespace and trims", () => {
|
||||||
|
expect(normalizeAssistantContent("<think>a\nb</think> Hi\n\nthere friend ")).toBe("Hi there friend");
|
||||||
|
expect(normalizeAssistantContent("<THINK>x</THINK>ok")).toBe("ok");
|
||||||
|
expect(normalizeAssistantContent("plain")).toBe("plain");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("trimToRespondable", () => {
|
||||||
|
it("drops trailing assistant/system messages", () => {
|
||||||
|
expect(trimToRespondable([u("hi"), a("partial")])).toEqual([u("hi")]);
|
||||||
|
expect(trimToRespondable([u("hi"), a("x"), { role: "system" as const, content: "nudge" }])).toEqual([u("hi")]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty when nothing respondable remains", () => {
|
||||||
|
expect(trimToRespondable([a("hello")])).toEqual([]);
|
||||||
|
expect(trimToRespondable([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps already-respondable histories unchanged", () => {
|
||||||
|
const msgs = [u("hi"), a("x"), u("more")];
|
||||||
|
expect(trimToRespondable(msgs)).toEqual(msgs);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user