Compare commits
63 Commits
fix/escrow
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5cb4b03e1 | ||
|
|
a6eabe6b98 | ||
|
|
0ed594f6ba | ||
|
|
59cac438f2 | ||
|
|
af405cf378 | ||
|
|
9463bfae85 | ||
|
|
53607738f8 | ||
|
|
cd3ea734e3 | ||
|
|
41820b11bb | ||
|
|
3393748972 | ||
|
|
799c7d8256 | ||
|
|
ced400f928 | ||
|
|
f79a33e7c3 | ||
|
|
ed6dbf5730 | ||
|
|
23ae561c16 | ||
|
|
2603820106 | ||
|
|
97ae2d8110 | ||
|
|
b714c09aca | ||
|
|
732de817aa | ||
|
|
2b8409a499 | ||
|
|
6b9f0f2284 | ||
|
|
27d23dd7a4 | ||
|
|
e54eb55f2b | ||
|
|
8baf127c77 | ||
|
|
e8261d9500 | ||
|
|
bb18578ae9 | ||
|
|
5221815a71 | ||
|
|
c531e4678f | ||
|
|
47b3c4efcd | ||
|
|
7db4de1f75 | ||
|
|
133102bc31 | ||
|
|
6fbf0d0f45 | ||
|
|
85ca58e4b2 | ||
|
|
b3b7d6b5e1 | ||
|
|
d5fdd1f1b7 | ||
|
|
019766ab8d | ||
|
|
5c9da7639d | ||
|
|
843ac7cbdd | ||
|
|
48ad639d12 | ||
|
|
4047be04a3 | ||
|
|
23157d62a3 | ||
|
|
3ee09f1a9a | ||
|
|
44abbdffdc | ||
|
|
b0c83ca985 | ||
|
|
bfcb0c46b8 | ||
|
|
88e549bd1d | ||
|
|
6c5a068fd9 | ||
|
|
83f81ab5fc | ||
|
|
6859486334 | ||
|
|
304bbc62ad | ||
|
|
312f3e730e | ||
|
|
6156900fc2 | ||
|
|
7d348e762f | ||
|
|
7bf3252cdb | ||
|
|
feabd12876 | ||
|
|
2b2295b7ed | ||
|
|
069edcb076 | ||
|
|
5377d379b7 | ||
|
|
d5349057b0 | ||
|
|
678ee862ea | ||
|
|
6ca7f2927a | ||
|
|
4d94aa5040 | ||
|
|
36c5cfc6be |
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
|
||||
"
|
||||
@@ -38,10 +38,15 @@ NETWORK_ID=0
|
||||
# along over loopback. Production behind TLS should leave this unset / "1".
|
||||
# COOKIE_SECURE=1
|
||||
|
||||
# Internal, redacted data source for the authenticated /dashboard page.
|
||||
# SURPLUS_DASHBOARD_URL=http://surplus-seller:8080/internal/resale-dashboard
|
||||
|
||||
# ─── Optional: PDF Book Summarizer (/book page, /v1/pdf-* routes) ─────────
|
||||
# Upload a PDF → chunk it → pay suppliers to summarize each chunk (one escrow
|
||||
# per map + reduce call) → full-book summary. All knobs are optional; defaults
|
||||
# shown. Tune the caps to bound spend/abuse on mainnet.
|
||||
# Kill switch: "0" disables the feature entirely (every /v1/pdf-* route 503s):
|
||||
# PDF_ENABLED=1
|
||||
#
|
||||
# Hard caps (a job is refused if it would exceed these):
|
||||
# PDF_MAX_PAGES=400 # reject books larger than this
|
||||
|
||||
@@ -35,6 +35,8 @@ RUN apk add --no-cache python3 make g++
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./
|
||||
# pnpm-lock.yaml's patchedDependencies hash the patch files at install time.
|
||||
COPY patches ./patches
|
||||
COPY packages/shared/package.json ./packages/shared/package.json
|
||||
COPY buyer/package.json ./buyer/package.json
|
||||
COPY supplier/package.json ./supplier/package.json
|
||||
|
||||
@@ -41,6 +41,13 @@ interface OperatorSource {
|
||||
}
|
||||
|
||||
const OPERATOR_SOURCES: OperatorSource[] = [
|
||||
{
|
||||
label: "supplier-hf-llama33@vector-marketplace",
|
||||
host: "root@91.98.147.172",
|
||||
envPath: "/root/agents-marketplace/supplier/.env.hf-llama33",
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "buyer@vector-marketplace",
|
||||
host: "root@91.98.147.172",
|
||||
@@ -125,6 +132,34 @@ const OPERATOR_SOURCES: OperatorSource[] = [
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "supplier-qwen38@vector-marketplace",
|
||||
host: "root@91.98.147.172",
|
||||
envPath: "/root/agents-marketplace/supplier/.env.qwen38",
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "supplier-qwen38-chat@vector-marketplace",
|
||||
host: "root@91.98.147.172",
|
||||
envPath: "/root/agents-marketplace/supplier/.env.qwen38-chat",
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "supplier-local@inference-proxy",
|
||||
host: "inference-proxy",
|
||||
envPath: "/root/agents-marketplace/supplier/.env.local",
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "supplier-openclaw@inference-proxy",
|
||||
host: "inference-proxy",
|
||||
envPath: "/root/agents-marketplace/supplier/.env.openclaw",
|
||||
keyVar: "SUPPLIER_PRIV_KEY_HEX",
|
||||
role: "supplier",
|
||||
},
|
||||
{
|
||||
label: "buyer@open-webui",
|
||||
host: "open-webui",
|
||||
|
||||
@@ -42,6 +42,13 @@
|
||||
* API key" page knows where to POST /signup. When
|
||||
* unset, the SPA falls back to deriving "api." +
|
||||
* current host.
|
||||
* SURPLUS_DASHBOARD_URL — Internal URL of the private Surplus dashboard
|
||||
* snapshot endpoint. When unset, /v1/resale-dashboard
|
||||
* responds 503 while the rest of the buyer stays live.
|
||||
* PDF_ENABLED — "1" (default) serves the PDF book summarizer
|
||||
* (/v1/pdf-* routes). Set "0" to disable the feature
|
||||
* entirely: no PDF job store is opened and every
|
||||
* /v1/pdf-* route responds 503.
|
||||
*/
|
||||
|
||||
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
|
||||
@@ -62,7 +69,9 @@ export interface BuyerConfig {
|
||||
password: string;
|
||||
sessionSecret: string;
|
||||
cookieSecure: boolean;
|
||||
pdfEnabled: boolean;
|
||||
gatewayPublicUrl: string;
|
||||
resaleDashboardUrl: string;
|
||||
}
|
||||
|
||||
function requireField(env: Record<string, string | undefined>, name: string): string {
|
||||
@@ -148,10 +157,36 @@ export function loadConfig(env: Record<string, string | undefined>): BuyerConfig
|
||||
cookieSecure = cookieSecureStr === "1";
|
||||
}
|
||||
|
||||
// PDF_ENABLED: defaults to true. Set "0" to disable the PDF book summarizer
|
||||
// entirely — runMain skips the JobStore wiring and every /v1/pdf-* route
|
||||
// responds 503. Accept only "0" / "1" to keep boot-time misconfig loud.
|
||||
let pdfEnabled = true;
|
||||
const pdfEnabledStr = env.PDF_ENABLED;
|
||||
if (pdfEnabledStr !== undefined && pdfEnabledStr !== "") {
|
||||
if (pdfEnabledStr !== "0" && pdfEnabledStr !== "1") {
|
||||
throw new Error('loadConfig: PDF_ENABLED must be "0" or "1"');
|
||||
}
|
||||
pdfEnabled = pdfEnabledStr === "1";
|
||||
}
|
||||
|
||||
// GATEWAY_PUBLIC_URL — optional. Public base URL of the gateway, surfaced to
|
||||
// the SPA via the boot block (no trailing slash). Empty string means the SPA
|
||||
// derives the URL from its own host ("api." + location.host).
|
||||
const gatewayPublicUrl = (env.GATEWAY_PUBLIC_URL ?? "").trim().replace(/\/+$/, "");
|
||||
const resaleDashboardUrl = (env.SURPLUS_DASHBOARD_URL ?? "")
|
||||
.trim()
|
||||
.replace(/\/+$/, "");
|
||||
if (resaleDashboardUrl !== "") {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(resaleDashboardUrl);
|
||||
} catch {
|
||||
throw new Error("loadConfig: SURPLUS_DASHBOARD_URL must be a valid URL");
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error("loadConfig: SURPLUS_DASHBOARD_URL must use http or https");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
privKeyHex,
|
||||
@@ -167,6 +202,8 @@ export function loadConfig(env: Record<string, string | undefined>): BuyerConfig
|
||||
password,
|
||||
sessionSecret,
|
||||
cookieSecure,
|
||||
pdfEnabled,
|
||||
gatewayPublicUrl,
|
||||
resaleDashboardUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,29 +96,36 @@ export async function runMain(env: Record<string, string | undefined>): Promise<
|
||||
|
||||
// PDF book summarizer: one job registry, sharing the live marketplace SDK,
|
||||
// chain, wallet, and (optional) archive. Caps come from PDF_* env vars.
|
||||
// PDF_ENABLED=0 skips the whole wiring; createApp then registers every
|
||||
// /v1/pdf-* route as a 503 stub (feature disabled).
|
||||
const pdfCaps = loadPdfCaps(env);
|
||||
// Durable job store (SQLite under ARCHIVE_DIR) so past summaries survive
|
||||
// restarts and navigation. Best-effort: if it can't open, jobs fall back to
|
||||
// in-memory only (lost on restart) but the feature still works.
|
||||
let pdfJobDb: PdfJobDb | undefined;
|
||||
try {
|
||||
pdfJobDb = new PdfJobDb(config.archiveDir);
|
||||
console.log(`[buyer] pdf job store at ${config.archiveDir}/pdf-jobs.db`);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[buyer] failed to open pdf job store at ${config.archiveDir}:`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
let jobStore: JobStore | undefined;
|
||||
if (config.pdfEnabled) {
|
||||
// Durable job store (SQLite under ARCHIVE_DIR) so past summaries survive
|
||||
// restarts and navigation. Best-effort: if it can't open, jobs fall back to
|
||||
// in-memory only (lost on restart) but the feature still works.
|
||||
let pdfJobDb: PdfJobDb | undefined;
|
||||
try {
|
||||
pdfJobDb = new PdfJobDb(config.archiveDir);
|
||||
console.log(`[buyer] pdf job store at ${config.archiveDir}/pdf-jobs.db`);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[buyer] failed to open pdf job store at ${config.archiveDir}:`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
jobStore = new JobStore({
|
||||
marketplace,
|
||||
chain,
|
||||
walletKey,
|
||||
indexerUrl: config.indexerUrl,
|
||||
archive,
|
||||
caps: pdfCaps,
|
||||
db: pdfJobDb,
|
||||
});
|
||||
} else {
|
||||
console.log("[buyer] pdf summarizer disabled (PDF_ENABLED=0)");
|
||||
}
|
||||
const jobStore = new JobStore({
|
||||
marketplace,
|
||||
chain,
|
||||
walletKey,
|
||||
indexerUrl: config.indexerUrl,
|
||||
archive,
|
||||
caps: pdfCaps,
|
||||
db: pdfJobDb,
|
||||
});
|
||||
|
||||
const app = createApp({
|
||||
distPath,
|
||||
@@ -134,6 +141,7 @@ export async function runMain(env: Record<string, string | undefined>): Promise<
|
||||
sessionSecret: config.sessionSecret,
|
||||
cookieSecure: config.cookieSecure,
|
||||
gatewayPublicUrl: config.gatewayPublicUrl,
|
||||
resaleDashboardUrl: config.resaleDashboardUrl,
|
||||
jobStore,
|
||||
pdfCaps,
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ export function registerPdfRoutes(
|
||||
res,
|
||||
503,
|
||||
"service_unavailable",
|
||||
"PDF summarizer disabled (buyer booted without chain/marketplace/archive deps)",
|
||||
"PDF summarizer disabled",
|
||||
);
|
||||
app.post("/v1/pdf-upload", disabled);
|
||||
app.all(/^\/v1\/pdf-jobs(\/.*)?$/, disabled);
|
||||
|
||||
@@ -73,11 +73,15 @@ import {
|
||||
buildPostEscrowTx,
|
||||
buildPostTtsEscrowTx,
|
||||
buildPostChatEscrowTx,
|
||||
buildPostOcrEscrowTx,
|
||||
ttsPromptHash,
|
||||
chatSessionPromptHash,
|
||||
ocrPromptHash,
|
||||
buildAcceptTx,
|
||||
buildReclaimTx,
|
||||
TxConstructionError,
|
||||
chatInputTokenUpperBound,
|
||||
BOUNDED_INPUT_DETAIL_MARKER,
|
||||
} from "@marketplace/shared/tx";
|
||||
import { decodeAdvertDatum, decodeEscrowDatum, canonicalize } from "@marketplace/shared/cbor";
|
||||
import type { AdvertDatum } from "@marketplace/shared/cbor";
|
||||
@@ -86,8 +90,11 @@ import type {
|
||||
DiscoverSuppliersOptions,
|
||||
SubmitPromptOptions,
|
||||
SubmitPromptResult,
|
||||
SupplierCapabilityView,
|
||||
SubmitTtsOptions,
|
||||
SubmitTtsResult,
|
||||
SubmitOcrOptions,
|
||||
SubmitOcrResult,
|
||||
StartChatOptions,
|
||||
StartChatResult,
|
||||
ChatSettleMode,
|
||||
@@ -106,6 +113,50 @@ import { MemoryTaskHistoryStore } from "./history.js";
|
||||
import { HttpClient, HttpError } from "./httpClient.js";
|
||||
|
||||
const ZERO_SIGNATURE = "0".repeat(128);
|
||||
|
||||
function validateSupplierCapability(
|
||||
body: unknown,
|
||||
advert: AdvertDatum,
|
||||
advertRef: OutputReference,
|
||||
): SupplierCapabilityView {
|
||||
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
||||
throw new TxConstructionError(
|
||||
"supplier_preflight_failed",
|
||||
"supplier /capability response must be an object",
|
||||
);
|
||||
}
|
||||
const capability = body as Record<string, unknown>;
|
||||
const expectedRef = `${advertRef.txHash}#${advertRef.index}`;
|
||||
if (
|
||||
capability.advert_ref !== expectedRef ||
|
||||
capability.capability_id !== advert.capability_id ||
|
||||
capability.model !== advert.model ||
|
||||
capability.max_output_tokens !== advert.max_output_tokens ||
|
||||
capability.max_processing_ms !== advert.max_processing_ms ||
|
||||
capability.price_lovelace !== advert.price_lovelace.toString() ||
|
||||
capability.supplier_pkh !== advert.supplier_pkh ||
|
||||
typeof capability.pub_key_hex !== "string"
|
||||
) {
|
||||
throw new TxConstructionError(
|
||||
"supplier_preflight_failed",
|
||||
"supplier /capability response does not match the on-chain advert",
|
||||
);
|
||||
}
|
||||
if (
|
||||
capability.max_input_tokens !== undefined &&
|
||||
(
|
||||
typeof capability.max_input_tokens !== "number" ||
|
||||
!Number.isSafeInteger(capability.max_input_tokens) ||
|
||||
capability.max_input_tokens <= 0
|
||||
)
|
||||
) {
|
||||
throw new TxConstructionError(
|
||||
"supplier_preflight_failed",
|
||||
"supplier max_input_tokens must be a positive safe integer",
|
||||
);
|
||||
}
|
||||
return capability as unknown as SupplierCapabilityView;
|
||||
}
|
||||
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
|
||||
const SIG_RE = /^[0-9a-fA-F]{128}$/;
|
||||
|
||||
@@ -237,16 +288,23 @@ export class Marketplace extends EventEmitterBase {
|
||||
}
|
||||
return result.body as SupplierView[];
|
||||
}
|
||||
|
||||
// ─── submitPrompt — happy path + every adversarial branch ──────────────
|
||||
|
||||
async submitPrompt(opts: SubmitPromptOptions): Promise<SubmitPromptResult> {
|
||||
const { advertRef, messages, payment_lovelace, max_output_tokens } = opts;
|
||||
const {
|
||||
advertRef,
|
||||
messages,
|
||||
payment_lovelace,
|
||||
max_output_tokens,
|
||||
public_preview,
|
||||
} = opts;
|
||||
|
||||
let supplierHttp: HttpClient | null = null;
|
||||
let advertDatum: AdvertDatum | null = null;
|
||||
let escrowOutputRef: OutputReference | null = null;
|
||||
let escrowRefStr = "";
|
||||
let postedAtMs = Date.now();
|
||||
let deliverByMs = 0;
|
||||
|
||||
const recordFailure = (reason: string): void => {
|
||||
this.historyStore.save({
|
||||
@@ -275,6 +333,46 @@ export class Marketplace extends EventEmitterBase {
|
||||
}
|
||||
}
|
||||
|
||||
if (advertDatum) {
|
||||
supplierHttp = new HttpClient({
|
||||
baseUrl: advertDatum.endpoint_url,
|
||||
fetch: this.fetchImpl,
|
||||
});
|
||||
if (advertDatum.detail_uri.endsWith(BOUNDED_INPUT_DETAIL_MARKER)) {
|
||||
let capabilityResult;
|
||||
try {
|
||||
capabilityResult = await supplierHttp.getJson("/capability");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new TxConstructionError("supplier_preflight_failed", message);
|
||||
}
|
||||
if (!capabilityResult.ok || capabilityResult.parseError) {
|
||||
throw new TxConstructionError(
|
||||
"supplier_preflight_failed",
|
||||
`supplier /capability returned HTTP ${capabilityResult.status}`,
|
||||
);
|
||||
}
|
||||
const capability = validateSupplierCapability(
|
||||
capabilityResult.body,
|
||||
advertDatum,
|
||||
advertRef,
|
||||
);
|
||||
if (capability.max_input_tokens === undefined) {
|
||||
throw new TxConstructionError(
|
||||
"supplier_preflight_failed",
|
||||
"bounded-input supplier omitted max_input_tokens",
|
||||
);
|
||||
}
|
||||
const inputUnits = chatInputTokenUpperBound(messages);
|
||||
if (inputUnits > capability.max_input_tokens) {
|
||||
throw new TxConstructionError(
|
||||
"input_cap_exceeded",
|
||||
`input bound ${inputUnits} exceeds supplier cap ${capability.max_input_tokens}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
escrowResult = await buildPostEscrowTx({
|
||||
chain: this.chain,
|
||||
buyerKey: this.walletKey,
|
||||
@@ -317,24 +415,33 @@ export class Marketplace extends EventEmitterBase {
|
||||
if (escrowUtxo && escrowUtxo.datumHex) {
|
||||
const ed = decodeEscrowDatum(escrowUtxo.datumHex);
|
||||
postedAtMs = ed.posted_at;
|
||||
deliverByMs = ed.deliver_by;
|
||||
}
|
||||
} catch {
|
||||
/* posted_at is best-effort metadata */
|
||||
}
|
||||
|
||||
if (!advertDatum) {
|
||||
// Should never happen — buildPostEscrowTx throws before this on missing/bad advert.
|
||||
// Should never happen — buildPostEscrowTx rejects missing or bad adverts.
|
||||
const reason = "advert datum unavailable after escrow post";
|
||||
recordFailure(reason);
|
||||
throw new ReceiptVerificationError(reason);
|
||||
}
|
||||
if (!supplierHttp) {
|
||||
const reason = "supplier client unavailable after escrow post";
|
||||
recordFailure(reason);
|
||||
throw new ReceiptVerificationError(reason);
|
||||
}
|
||||
if (deliverByMs === 0) {
|
||||
deliverByMs =
|
||||
postedAtMs + advertDatum.max_processing_ms + 30_000;
|
||||
}
|
||||
const supplierCallTimeoutMs = Math.max(
|
||||
30_000,
|
||||
deliverByMs - Date.now() + 90_000,
|
||||
);
|
||||
|
||||
// ── 4. Call supplier /v1/chat/completions ─────────────────────────
|
||||
const supplierBaseUrl = advertDatum.endpoint_url;
|
||||
const supplierHttp = new HttpClient({
|
||||
baseUrl: supplierBaseUrl,
|
||||
fetch: this.fetchImpl,
|
||||
});
|
||||
|
||||
const requestedMax = max_output_tokens ?? advertDatum.max_output_tokens;
|
||||
const cappedMax = Math.min(requestedMax, advertDatum.max_output_tokens);
|
||||
@@ -343,11 +450,14 @@ export class Marketplace extends EventEmitterBase {
|
||||
messages,
|
||||
max_tokens: cappedMax,
|
||||
};
|
||||
|
||||
let chatResult;
|
||||
try {
|
||||
chatResult = await supplierHttp.postJson("/v1/chat/completions", chatBody, {
|
||||
headers: { "X-Escrow-Ref": escrowRefStr },
|
||||
headers: {
|
||||
"X-Escrow-Ref": escrowRefStr,
|
||||
...(public_preview ? { "X-Vector-Public-Preview": "1" } : {}),
|
||||
},
|
||||
timeoutMs: supplierCallTimeoutMs,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
@@ -381,8 +491,11 @@ export class Marketplace extends EventEmitterBase {
|
||||
throw sErr;
|
||||
}
|
||||
const POLL_INTERVAL_MS = 2_000;
|
||||
const POLL_TIMEOUT_MS = 180_000;
|
||||
const pollDeadline = Date.now() + POLL_TIMEOUT_MS;
|
||||
const pollBudgetMs = Math.max(
|
||||
30_000,
|
||||
deliverByMs + 90_000 - Date.now(),
|
||||
);
|
||||
const pollDeadline = Date.now() + pollBudgetMs;
|
||||
let polled = chatResult;
|
||||
// First poll runs immediately so a quickly-completed job doesn't pay
|
||||
// the leading delay; subsequent polls wait POLL_INTERVAL_MS.
|
||||
@@ -419,7 +532,7 @@ export class Marketplace extends EventEmitterBase {
|
||||
if (polled.status !== 200) {
|
||||
const sErr = new SupplierError("timeout", {
|
||||
status: polled.status,
|
||||
message: `supplier job ${jobId} not done within ${POLL_TIMEOUT_MS}ms`,
|
||||
message: `supplier job ${jobId} not done within ${pollBudgetMs}ms`,
|
||||
});
|
||||
recordFailure("timeout");
|
||||
throw sErr;
|
||||
@@ -807,6 +920,278 @@ export class Marketplace extends EventEmitterBase {
|
||||
};
|
||||
}
|
||||
|
||||
// ─── submitOcr — full lifecycle for ocr.page.extract.<model-slug>.v1 ──
|
||||
|
||||
async submitOcr(opts: SubmitOcrOptions): Promise<SubmitOcrResult> {
|
||||
const { advertRef, image_b64, mime, output_format, payment_lovelace } = opts;
|
||||
const request = { image_b64, mime, output_format };
|
||||
|
||||
let advertDatum: AdvertDatum | null = null;
|
||||
let escrowOutputRef: OutputReference | null = null;
|
||||
let escrowRefStr = "";
|
||||
let postedAtMs = Date.now();
|
||||
|
||||
const previewStr = `[ocr:${mime} ${output_format} ${image_b64.length}b64]`;
|
||||
const recordFailure = (reason: string): void => {
|
||||
this.historyStore.save({
|
||||
escrow_ref: escrowRefStr || `${"0".repeat(64)}#0`,
|
||||
supplier_pkh: advertDatum?.supplier_pkh ?? "",
|
||||
capability_id: advertDatum?.capability_id ?? "",
|
||||
prompt_preview: previewStr,
|
||||
posted_at: postedAtMs,
|
||||
status: "failed",
|
||||
failure_reason: reason,
|
||||
});
|
||||
};
|
||||
|
||||
// ── 1. Resolve advert + post escrow (OCR prompt_hash) ─────────────
|
||||
let escrowResult;
|
||||
try {
|
||||
const utxo = await this.chain.queryUtxo(advertRef);
|
||||
if (utxo && utxo.datumHex) {
|
||||
try {
|
||||
advertDatum = decodeAdvertDatum(utxo.datumHex);
|
||||
} catch { /* builder will re-throw structurally */ }
|
||||
}
|
||||
escrowResult = await buildPostOcrEscrowTx({
|
||||
chain: this.chain,
|
||||
buyerKey: this.walletKey,
|
||||
advertRef,
|
||||
request,
|
||||
payment_lovelace,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof TxConstructionError) {
|
||||
recordFailure(err.reason);
|
||||
throw err;
|
||||
}
|
||||
this.emitProgress({ type: "chain_submit_failed", detail: (err as Error).message });
|
||||
recordFailure((err as Error).message);
|
||||
throw err;
|
||||
}
|
||||
|
||||
escrowOutputRef = escrowResult.escrowOutputRef;
|
||||
escrowRefStr = refToString(escrowOutputRef);
|
||||
|
||||
this.emitProgress({ type: "escrow_posted", escrow_ref: escrowRefStr });
|
||||
|
||||
// ── 2. Wait for the escrow tx to confirm ──────────────────────────
|
||||
try {
|
||||
await this.chain.awaitTx(escrowResult.expectedTxHash, 120_000);
|
||||
} catch { /* mock providers / tests */ }
|
||||
|
||||
// ── 3. Re-fetch escrow datum to capture posted_at ─────────────────
|
||||
try {
|
||||
const escrowUtxo = await this.chain.queryUtxo(escrowOutputRef);
|
||||
if (escrowUtxo && escrowUtxo.datumHex) {
|
||||
const ed = decodeEscrowDatum(escrowUtxo.datumHex);
|
||||
postedAtMs = ed.posted_at;
|
||||
}
|
||||
} catch { /* metadata best-effort */ }
|
||||
|
||||
if (!advertDatum) {
|
||||
const reason = "advert datum unavailable after escrow post";
|
||||
recordFailure(reason);
|
||||
throw new ReceiptVerificationError(reason);
|
||||
}
|
||||
|
||||
// ── 4. Call supplier /v1/ocr/extract ──────────────────────────────
|
||||
const supplierBaseUrl = advertDatum.endpoint_url;
|
||||
const supplierHttp = new HttpClient({
|
||||
baseUrl: supplierBaseUrl,
|
||||
fetch: this.fetchImpl,
|
||||
});
|
||||
const ocrBody = { image_b64, mime, output_format };
|
||||
|
||||
let ocrResult;
|
||||
try {
|
||||
ocrResult = await supplierHttp.postJson("/v1/ocr/extract", ocrBody, {
|
||||
headers: { "X-Escrow-Ref": escrowRefStr },
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
const reason = err.kind === "timeout" ? "timeout" : "network_error";
|
||||
const sErr = new SupplierError(reason, { message: err.message });
|
||||
recordFailure(reason);
|
||||
throw sErr;
|
||||
}
|
||||
recordFailure((err as Error).message);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 202 + poll mirror of submitTts's async handling. OCR inference for a
|
||||
// page runs seconds; the long tail is the Submit tx confirmation.
|
||||
if (ocrResult.status === 202 && ocrResult.body && typeof ocrResult.body === "object") {
|
||||
const jobId = (ocrResult.body as { job_id?: string }).job_id;
|
||||
if (typeof jobId !== "string" || jobId.length === 0) {
|
||||
const sErr = new SupplierError("malformed_response", {
|
||||
status: ocrResult.status,
|
||||
message: "supplier 202 response missing job_id",
|
||||
});
|
||||
recordFailure("malformed_response");
|
||||
throw sErr;
|
||||
}
|
||||
const POLL_INTERVAL_MS = 2_000;
|
||||
const POLL_TIMEOUT_MS = 180_000;
|
||||
const pollDeadline = Date.now() + POLL_TIMEOUT_MS;
|
||||
let polled = ocrResult;
|
||||
let firstIter = true;
|
||||
while (Date.now() < pollDeadline) {
|
||||
if (!firstIter) {
|
||||
await new Promise<void>((r) => setTimeout(r, POLL_INTERVAL_MS));
|
||||
}
|
||||
firstIter = false;
|
||||
try {
|
||||
polled = await supplierHttp.getJson(`/v1/ocr/extract/${jobId}`);
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
const reason = err.kind === "timeout" ? "timeout" : "network_error";
|
||||
const sErr = new SupplierError(reason, { message: err.message });
|
||||
recordFailure(reason);
|
||||
throw sErr;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (polled.status === 200) break;
|
||||
if (polled.status === 202) continue;
|
||||
const failBody = (polled.body && typeof polled.body === "object")
|
||||
? polled.body as { reason?: string; message?: string }
|
||||
: {};
|
||||
const sErr = new SupplierError(failBody.reason ?? "supplier_http_error", {
|
||||
status: polled.status,
|
||||
message: failBody.message ?? `supplier returned ${polled.status}`,
|
||||
});
|
||||
recordFailure(sErr.reason);
|
||||
throw sErr;
|
||||
}
|
||||
if (polled.status !== 200) {
|
||||
const sErr = new SupplierError("timeout", {
|
||||
status: polled.status,
|
||||
message: `supplier job ${jobId} not done within ${POLL_TIMEOUT_MS}ms`,
|
||||
});
|
||||
recordFailure("timeout");
|
||||
throw sErr;
|
||||
}
|
||||
ocrResult = polled;
|
||||
}
|
||||
|
||||
if (!ocrResult.ok) {
|
||||
const bodyReason =
|
||||
ocrResult.body && typeof ocrResult.body === "object"
|
||||
? ((ocrResult.body as { reason?: string }).reason ?? "supplier_http_error")
|
||||
: "supplier_http_error";
|
||||
const sErr = new SupplierError(bodyReason, {
|
||||
status: ocrResult.status,
|
||||
message: `supplier returned ${ocrResult.status}`,
|
||||
});
|
||||
recordFailure(bodyReason);
|
||||
throw sErr;
|
||||
}
|
||||
if (ocrResult.parseError || !ocrResult.body || typeof ocrResult.body !== "object") {
|
||||
const sErr = new SupplierError("malformed_response", {
|
||||
status: ocrResult.status,
|
||||
message: "supplier body is not valid JSON",
|
||||
});
|
||||
recordFailure("malformed_response");
|
||||
throw sErr;
|
||||
}
|
||||
|
||||
const responseBody = ocrResult.body as {
|
||||
output_format?: string;
|
||||
content?: string;
|
||||
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
|
||||
receipt?: import("@marketplace/shared/receipt").Receipt;
|
||||
receipt_signature?: string;
|
||||
};
|
||||
if (!responseBody.content || !responseBody.receipt || !responseBody.receipt_signature) {
|
||||
const sErr = new SupplierError("malformed_response", {
|
||||
status: ocrResult.status,
|
||||
message: "supplier response is missing content / receipt / receipt_signature",
|
||||
});
|
||||
recordFailure("malformed_response");
|
||||
throw sErr;
|
||||
}
|
||||
const receipt = responseBody.receipt;
|
||||
const receiptSignature = responseBody.receipt_signature;
|
||||
|
||||
this.emitProgress({ type: "supplier_called", escrow_ref: escrowRefStr });
|
||||
|
||||
// ── 5. Receipt verification ───────────────────────────────────────
|
||||
if (receipt.supplier_pkh !== advertDatum.supplier_pkh) {
|
||||
recordFailure("wrong_supplier");
|
||||
throw new ReceiptVerificationError("wrong_supplier");
|
||||
}
|
||||
if (receipt.escrow_ref !== escrowRefStr) {
|
||||
recordFailure("wrong_escrow_ref");
|
||||
throw new ReceiptVerificationError("wrong_escrow_ref");
|
||||
}
|
||||
// The OCR prompt commitment uses the SAME canonical hash the supplier
|
||||
// validated against the escrow datum. Recompute from the envelope we
|
||||
// sent; a mismatch means the supplier signed a different request.
|
||||
const expectedPromptHash = ocrPromptHash(request);
|
||||
if (receipt.prompt_hash !== expectedPromptHash) {
|
||||
recordFailure("prompt_hash_mismatch");
|
||||
throw new ReceiptVerificationError("prompt_hash_mismatch");
|
||||
}
|
||||
if (receipt.model !== advertDatum.model) {
|
||||
recordFailure("request_spec_hash_mismatch");
|
||||
throw new ReceiptVerificationError("request_spec_hash_mismatch");
|
||||
}
|
||||
// The receipt's response_hash must commit the content we actually
|
||||
// received in the requested output shape.
|
||||
const expectedResponseHash = nodeCrypto.createHash("sha256")
|
||||
.update(canonicalize({
|
||||
content: responseBody.content,
|
||||
output_format: responseBody.output_format ?? output_format,
|
||||
}), "utf8")
|
||||
.digest("hex");
|
||||
if (receipt.response_hash !== expectedResponseHash) {
|
||||
recordFailure("response_hash_mismatch");
|
||||
throw new ReceiptVerificationError("response_hash_mismatch");
|
||||
}
|
||||
if (typeof receiptSignature !== "string" || !SIG_RE.test(receiptSignature)) {
|
||||
recordFailure("invalid_signature");
|
||||
throw new ReceiptVerificationError("invalid_signature");
|
||||
}
|
||||
if (receiptSignature === ZERO_SIGNATURE) {
|
||||
recordFailure("invalid_signature");
|
||||
throw new ReceiptVerificationError("invalid_signature");
|
||||
}
|
||||
if (!HEX64_RE.test(receipt.prompt_hash) || !HEX64_RE.test(receipt.response_hash)) {
|
||||
recordFailure("malformed_receipt");
|
||||
throw new ReceiptVerificationError("malformed_receipt");
|
||||
}
|
||||
|
||||
this.emitProgress({ type: "receipt_verified", escrow_ref: escrowRefStr });
|
||||
|
||||
this.historyStore.save({
|
||||
escrow_ref: escrowRefStr,
|
||||
supplier_pkh: advertDatum.supplier_pkh,
|
||||
capability_id: advertDatum.capability_id,
|
||||
prompt_preview: previewStr,
|
||||
posted_at: postedAtMs,
|
||||
status: "completed",
|
||||
response: responseBody.content.length <= 200
|
||||
? responseBody.content
|
||||
: responseBody.content.slice(0, 200),
|
||||
receipt,
|
||||
receipt_signature: receiptSignature,
|
||||
});
|
||||
|
||||
return {
|
||||
output_format: responseBody.output_format ?? output_format,
|
||||
content: responseBody.content,
|
||||
usage: {
|
||||
prompt_tokens: responseBody.usage?.prompt_tokens ?? 0,
|
||||
completion_tokens: responseBody.usage?.completion_tokens ?? 0,
|
||||
total_tokens: responseBody.usage?.total_tokens ?? 0,
|
||||
},
|
||||
receipt,
|
||||
receiptSignature,
|
||||
escrowRef: escrowOutputRef,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── startChat / endChat — multi-turn llm.chat.v1 lifecycle ────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -138,6 +138,19 @@ export interface SupplierView {
|
||||
created_slot: number;
|
||||
}
|
||||
|
||||
/** Live off-chain capability metadata published by a supplier endpoint. */
|
||||
export interface SupplierCapabilityView {
|
||||
capability_id: string;
|
||||
model: string;
|
||||
max_output_tokens: number;
|
||||
max_processing_ms: number;
|
||||
price_lovelace: string;
|
||||
advert_ref: string;
|
||||
supplier_pkh: string;
|
||||
pub_key_hex: string;
|
||||
max_input_tokens?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DiscoverSuppliersOptions — optional filters for discoverSuppliers().
|
||||
*/
|
||||
@@ -154,6 +167,8 @@ export interface SubmitPromptOptions {
|
||||
messages: import("@marketplace/shared/tx").ChatMessage[];
|
||||
payment_lovelace: bigint;
|
||||
max_output_tokens?: number;
|
||||
/** Explicit consent for the seller's public redacted demo preview. */
|
||||
public_preview?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,6 +202,35 @@ export interface SubmitTtsResult {
|
||||
escrowRef: OutputReference;
|
||||
}
|
||||
|
||||
/**
|
||||
* SubmitOcrOptions — arguments to Marketplace.submitOcr() for model-scoped
|
||||
* `ocr.page.extract.<model-slug>.v1` capabilities. The same `{image_b64,
|
||||
* mime, output_format}` envelope is hashed (sha256 ∘ canonical) into the
|
||||
* escrow datum's `prompt_hash` AND posted to the supplier's /v1/ocr/extract
|
||||
* body, so the supplier can verify the request matches the on-chain commit.
|
||||
*/
|
||||
export interface SubmitOcrOptions {
|
||||
advertRef: OutputReference;
|
||||
image_b64: string; // plain base64, no data: prefix
|
||||
mime: string; // image/png | image/jpeg | image/webp
|
||||
output_format: string; // markdown | html | json
|
||||
payment_lovelace: bigint;
|
||||
}
|
||||
|
||||
/**
|
||||
* SubmitOcrResult — returned from Marketplace.submitOcr() on success.
|
||||
* `content` is the extraction in the requested output_format; the receipt's
|
||||
* response_hash commits sha256(canonical({content, output_format})).
|
||||
*/
|
||||
export interface SubmitOcrResult {
|
||||
output_format: string;
|
||||
content: string;
|
||||
usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
|
||||
receipt: Receipt;
|
||||
receiptSignature: string;
|
||||
escrowRef: OutputReference;
|
||||
}
|
||||
|
||||
/**
|
||||
* StartChatOptions / StartChatResult — Marketplace.startChat() for the
|
||||
* `llm.chat.v1` capability. Opens the escrow (session-init prompt_hash) and
|
||||
|
||||
@@ -114,6 +114,9 @@ export interface AppDeps {
|
||||
* `${gatewayUrl}/signup`. Public, non-secret value. When empty/omitted the
|
||||
* SPA derives "api." + its own host. */
|
||||
gatewayPublicUrl?: string;
|
||||
/** Private supplier-network endpoint used to populate the resale dashboard.
|
||||
* The browser only sees the redacted same-origin /v1/resale-dashboard proxy. */
|
||||
resaleDashboardUrl?: string;
|
||||
/** PDF book summarizer job registry. When provided, the /v1/pdf-* routes
|
||||
* are live; when omitted they respond 503 (feature disabled). Built in
|
||||
* runMain from the same marketplace/chain/wallet/archive deps. */
|
||||
@@ -317,6 +320,66 @@ export function createApp(deps: AppDeps): Express {
|
||||
}
|
||||
: null;
|
||||
|
||||
app.get("/v1/resale-dashboard", async (_req: Request, res: Response) => {
|
||||
const dashboardUrl = (deps.resaleDashboardUrl ?? "").replace(/\/+$/, "");
|
||||
if (dashboardUrl === "") {
|
||||
return jsonError(
|
||||
res,
|
||||
503,
|
||||
"service_unavailable",
|
||||
"buyer-app booted without SURPLUS_DASHBOARD_URL",
|
||||
);
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 10_000);
|
||||
try {
|
||||
const upstream = await (deps.fetchImpl ?? globalThis.fetch)(dashboardUrl, {
|
||||
headers: { accept: "application/json" },
|
||||
redirect: "error",
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await upstream.text();
|
||||
if (Buffer.byteLength(text, "utf8") > 1024 * 1024) {
|
||||
return jsonError(
|
||||
res,
|
||||
502,
|
||||
"dashboard_response_too_large",
|
||||
"resale dashboard response exceeded 1 MiB",
|
||||
);
|
||||
}
|
||||
if (!upstream.ok) {
|
||||
return jsonError(
|
||||
res,
|
||||
502,
|
||||
"dashboard_upstream_error",
|
||||
`resale dashboard upstream returned ${upstream.status}`,
|
||||
);
|
||||
}
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
return jsonError(
|
||||
res,
|
||||
502,
|
||||
"dashboard_invalid_json",
|
||||
"resale dashboard upstream did not return JSON",
|
||||
);
|
||||
}
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
return res.status(200).json(payload);
|
||||
} catch {
|
||||
return jsonError(
|
||||
res,
|
||||
502,
|
||||
"dashboard_unreachable",
|
||||
"resale dashboard upstream is unavailable",
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/v1/pending-receipts", async (_req: Request, res: Response) => {
|
||||
if (!resolved) {
|
||||
return jsonError(
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Routes (all gated by <RequireAuth>):
|
||||
* / → <Dashboard />
|
||||
* /tasks → <TaskHistory />
|
||||
* /dashboard → <ResaleDashboard /> Capacity markets and sale proofs
|
||||
* /pending → <PendingReceipts /> UX-1: buyer-side Accept button
|
||||
* /wallet → <Wallet />
|
||||
* /api-keys → <ApiKeys /> self-serve gateway API key generation
|
||||
@@ -16,6 +17,7 @@ import Wallet from "./pages/Wallet.js";
|
||||
import PendingReceipts from "./pages/PendingReceipts.js";
|
||||
import BookSummarizer from "./pages/BookSummarizer.js";
|
||||
import ApiKeys from "./pages/ApiKeys.js";
|
||||
import ResaleDashboard from "./pages/ResaleDashboard.js";
|
||||
import { RequireAuth, useAuth } from "./state/AuthContext.js";
|
||||
|
||||
function SignOutButton() {
|
||||
@@ -35,8 +37,9 @@ export default function App() {
|
||||
return (
|
||||
<RequireAuth>
|
||||
<div className="min-h-screen bg-gray-50 text-gray-900">
|
||||
<nav className="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-6">
|
||||
<nav className="bg-white border-b border-gray-200 px-4 py-3 sm:px-6 flex flex-wrap items-center gap-x-6 gap-y-2">
|
||||
<Link to="/" className="font-medium hover:underline">Dashboard</Link>
|
||||
<Link to="/dashboard" className="font-medium hover:underline">Capacity</Link>
|
||||
{/* Book Summarizer tab hidden for now — the /book route and all PDF
|
||||
summarize code remain intact; restore by re-adding this link:
|
||||
<Link to="/book" className="font-medium hover:underline">Book Summarizer</Link> */}
|
||||
@@ -49,6 +52,7 @@ export default function App() {
|
||||
<main className="p-6">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/dashboard" element={<ResaleDashboard />} />
|
||||
<Route path="/book" element={<BookSummarizer />} />
|
||||
<Route path="/tasks" element={<TaskHistory />} />
|
||||
<Route path="/pending" element={<PendingReceipts />} />
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
* buyer/src/ui/pages/Dashboard.tsx — supplier list + capability-routed task form.
|
||||
*
|
||||
* Calls marketplace.discoverSuppliers() on mount; renders a SupplierCard
|
||||
* per result. Once a supplier is selected, the form rendered below is
|
||||
* picked from `CAPABILITY_FORMS` based on the supplier's `capability_id`.
|
||||
* per result. Once a supplier is selected, the task form (e.g. the chat
|
||||
* window) renders ABOVE the grid — directly under the page title — and the
|
||||
* component is picked from `CAPABILITY_FORMS` based on the supplier's
|
||||
* `capability_id`.
|
||||
*
|
||||
* Capabilities (current registry):
|
||||
*
|
||||
@@ -131,18 +133,8 @@ export default function Dashboard() {
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{suppliers.map((s) => (
|
||||
<SupplierCard
|
||||
key={s.utxo_ref}
|
||||
supplier={s}
|
||||
onUse={(sup) => setSelected(sup)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div className="mt-8">
|
||||
<div data-testid="task-form-section">
|
||||
<h2 className="text-xl font-semibold mb-2">
|
||||
New task → {selected.model}
|
||||
<span className="ml-2 text-sm font-normal text-gray-500">
|
||||
@@ -152,6 +144,21 @@ export default function Dashboard() {
|
||||
{renderCapabilityForm(selected, advertRef, payment_lovelace)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{suppliers.map((s) => (
|
||||
<SupplierCard
|
||||
key={s.utxo_ref}
|
||||
supplier={s}
|
||||
onUse={(sup) => {
|
||||
setSelected(sup);
|
||||
// The form renders above the grid; bring it into view when a
|
||||
// card further down the page was clicked.
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
419
buyer/src/ui/pages/ResaleDashboard.tsx
Normal file
419
buyer/src/ui/pages/ResaleDashboard.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
interface VectorProof {
|
||||
status: "pending" | "confirmed" | "failed";
|
||||
saleHash: string | null;
|
||||
batchRoot: string | null;
|
||||
txHash: string | null;
|
||||
leafIndex: number | null;
|
||||
siblings: string[];
|
||||
}
|
||||
|
||||
const PENDING_PROOF: VectorProof = {
|
||||
status: "pending",
|
||||
saleHash: null,
|
||||
batchRoot: null,
|
||||
txHash: null,
|
||||
leafIndex: null,
|
||||
siblings: [],
|
||||
};
|
||||
|
||||
interface DashboardSnapshot {
|
||||
generatedAt: string;
|
||||
controller: {
|
||||
ok: boolean;
|
||||
phase: string;
|
||||
lastError: string | null;
|
||||
};
|
||||
identity: {
|
||||
sellerWallet: string;
|
||||
payoutAddress: string;
|
||||
};
|
||||
provider: {
|
||||
hardLimitUsd: string;
|
||||
remainingUsd: string;
|
||||
usedUsd: string;
|
||||
reserveUsd: string;
|
||||
estimatedDailyExposureUsd: string;
|
||||
};
|
||||
surplus: {
|
||||
offer: {
|
||||
id: string | null;
|
||||
model: string | null;
|
||||
providerModel: string | null;
|
||||
status: string;
|
||||
dailyCapUsd: string | null;
|
||||
costMultiplier: number | null;
|
||||
inputUsdPer1m: string | null;
|
||||
outputUsdPer1m: string | null;
|
||||
rank: number | null;
|
||||
available: boolean;
|
||||
healthy: boolean;
|
||||
trusted: boolean;
|
||||
trades24h: number;
|
||||
volume24hUsd: string;
|
||||
capRemainingUsd: string | null;
|
||||
};
|
||||
earnings: {
|
||||
totalUsd: string;
|
||||
pendingUsd: string;
|
||||
paidUsd: string;
|
||||
requests: number;
|
||||
tokens: number;
|
||||
today: {
|
||||
earnedUsd: number;
|
||||
requests: number;
|
||||
totalTokens: number;
|
||||
} | null;
|
||||
topModel: string | null;
|
||||
payoutHoldReason: string | null;
|
||||
payoutHoldReleasesAt: string | null;
|
||||
};
|
||||
recentSales: Array<{
|
||||
id: string;
|
||||
model: string;
|
||||
createdAt: string | null;
|
||||
settlementStatus: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
revenueUsd: string;
|
||||
vectorProof: VectorProof;
|
||||
}>;
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
function usd(value: string | number | null): string {
|
||||
if (value === null || value === "") return "—";
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return "—";
|
||||
return `$${number.toFixed(number < 0.01 ? 6 : 4)}`;
|
||||
}
|
||||
|
||||
function count(value: number): string {
|
||||
return new Intl.NumberFormat().format(value);
|
||||
}
|
||||
|
||||
function timestamp(value: string | null): string {
|
||||
if (value === null) return "—";
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? "—" : parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function compact(value: string): string {
|
||||
if (value.length <= 24) return value;
|
||||
return `${value.slice(0, 12)}…${value.slice(-9)}`;
|
||||
}
|
||||
|
||||
function StatusPill({ label, active }: { label: string; active: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs font-medium ${
|
||||
active ? "bg-green-100 text-green-700" : "bg-gray-200 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function VectorProofState({ proof }: { proof: VectorProof }) {
|
||||
const pillClass = proof.status === "confirmed"
|
||||
? "bg-green-100 text-green-700"
|
||||
: proof.status === "pending"
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-red-100 text-red-700";
|
||||
const pill = (
|
||||
<span className={`rounded px-2 py-0.5 text-xs font-medium ${pillClass}`}>
|
||||
{proof.status}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (proof.status !== "confirmed" || proof.txHash === null) return pill;
|
||||
|
||||
return (
|
||||
<a
|
||||
className="inline-flex items-center gap-2 rounded text-blue-600 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-500 focus-visible:ring-offset-2"
|
||||
href={`https://vector.apexscan.org/en/transaction/${proof.txHash}/summary/`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Confirmed Vector proof transaction ${proof.txHash}`}
|
||||
>
|
||||
{pill}
|
||||
<span className="font-mono text-xs">{compact(proof.txHash)}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value, detail }: { label: string; value: string; detail: string }) {
|
||||
return (
|
||||
<div className="rounded border border-gray-200 bg-gray-50 p-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">{label}</p>
|
||||
<p className="mt-1 text-xl font-semibold tracking-tight text-gray-900">{value}</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">{detail}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowNode({ label, title, detail, mono }: {
|
||||
label: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
mono?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0 flex-1 rounded border border-gray-200 bg-gray-50 p-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">{label}</p>
|
||||
<p className="mt-1 font-semibold text-gray-900">{title}</p>
|
||||
<p className="mt-1 text-sm text-gray-600">{detail}</p>
|
||||
{mono && <p className="mt-2 truncate font-mono text-xs text-gray-400">{mono}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResaleDashboard() {
|
||||
const [data, setData] = useState<DashboardSnapshot | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch("/v1/resale-dashboard", { cache: "no-store" });
|
||||
const body = (await response.json().catch(() => null)) as DashboardSnapshot | {
|
||||
message?: string;
|
||||
} | null;
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
body && "message" in body && typeof body.message === "string"
|
||||
? body.message
|
||||
: `dashboard request failed (${response.status})`,
|
||||
);
|
||||
}
|
||||
setData(body as DashboardSnapshot);
|
||||
setError(null);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "dashboard data is unavailable");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
const interval = setInterval(() => { void refresh(); }, 15_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [refresh]);
|
||||
|
||||
if (loading && data === null) {
|
||||
return <p className="text-sm text-gray-500">Loading capacity markets…</p>;
|
||||
}
|
||||
|
||||
if (data === null) {
|
||||
return (
|
||||
<div className="max-w-3xl rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||
{error ?? "Dashboard data is unavailable."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const offer = data.surplus.offer;
|
||||
const earnings = data.surplus.earnings;
|
||||
const offerLive = offer.status === "active" && offer.available && offer.healthy;
|
||||
const proofCounts = { confirmed: 0, pending: 0, failed: 0 };
|
||||
for (const sale of data.surplus.recentSales) {
|
||||
proofCounts[(sale.vectorProof ?? PENDING_PROOF).status] += 1;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-6" data-testid="resale-dashboard">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold">Capacity Markets</h1>
|
||||
<p className="max-w-3xl text-sm text-gray-600">
|
||||
Surplus turns available OpenRouter capacity into Base USDC sales. Vector anchors
|
||||
Merkle-batched proofs so every sale has a verifiable transaction record.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusPill label={data.controller.phase} active={data.controller.ok} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void refresh(); }}
|
||||
className="rounded border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Last refresh failed: {error}. Showing the last good snapshot.
|
||||
</div>
|
||||
)}
|
||||
{data.controller.lastError && (
|
||||
<div className="rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
Controller: {data.controller.lastError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold">Capacity and proof route</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Capacity sells through Surplus, and each sale joins a shared Vector anchor.
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">Updated {timestamp(data.generatedAt)}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-stretch gap-2 lg:flex-row lg:items-center">
|
||||
<FlowNode
|
||||
label="Upstream"
|
||||
title="OpenRouter"
|
||||
detail={`${usd(data.provider.remainingUsd)} of ${usd(data.provider.hardLimitUsd)} remaining`}
|
||||
mono={offer.providerModel}
|
||||
/>
|
||||
<span className="self-center text-lg text-gray-400">→</span>
|
||||
<FlowNode
|
||||
label="Manager"
|
||||
title="Seller agent"
|
||||
detail="Discovers, prices, caps, and reconciles offers"
|
||||
mono={data.identity.sellerWallet}
|
||||
/>
|
||||
<span className="self-center text-lg text-gray-400">→</span>
|
||||
<FlowNode
|
||||
label="Live market"
|
||||
title="Surplus / Base USDC"
|
||||
detail={`${offerLive ? "Available" : "Unavailable"} · ${offer.trusted ? "trusted" : "untrusted"}`}
|
||||
mono={offer.id}
|
||||
/>
|
||||
<span className="self-center text-lg text-gray-400">→</span>
|
||||
<FlowNode
|
||||
label="Proof layer"
|
||||
title="Vector anchors"
|
||||
detail="Commits each sale through a shared Merkle-batch transaction"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">Current market</p>
|
||||
<h2 className="mt-1 text-lg font-semibold">Surplus Intelligence</h2>
|
||||
<p className="mt-1 text-sm text-gray-600">Discounted inference with USDC settlement on Base.</p>
|
||||
</div>
|
||||
<StatusPill label={offer.status} active={offerLive} />
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Metric label="Lifetime revenue" value={usd(earnings.totalUsd)} detail={`${usd(earnings.pendingUsd)} pending`} />
|
||||
<Metric label="Requests sold" value={count(earnings.requests)} detail={`${count(earnings.tokens)} tokens`} />
|
||||
<Metric label="Market rank" value={offer.rank === null ? "—" : `#${offer.rank}`} detail={`${offer.trades24h} trades / 24h`} />
|
||||
<Metric label="Daily seller cap" value={usd(offer.dailyCapUsd)} detail={`${usd(offer.capRemainingUsd)} remaining`} />
|
||||
</div>
|
||||
<dl className="mt-4 grid gap-x-6 gap-y-2 border-t border-gray-100 pt-4 text-sm sm:grid-cols-2">
|
||||
<div><dt className="text-gray-500">Marketplace model</dt><dd className="font-mono text-xs text-gray-900">{offer.model ?? "—"}</dd></div>
|
||||
<div><dt className="text-gray-500">Provider model</dt><dd className="font-mono text-xs text-gray-900">{offer.providerModel ?? "—"}</dd></div>
|
||||
<div><dt className="text-gray-500">Input / 1M</dt><dd>{usd(offer.inputUsdPer1m)}</dd></div>
|
||||
<div><dt className="text-gray-500">Output / 1M</dt><dd>{usd(offer.outputUsdPer1m)}</dd></div>
|
||||
</dl>
|
||||
{earnings.payoutHoldReason && (
|
||||
<div className="mt-4 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
Payout hold: {earnings.payoutHoldReason}
|
||||
{earnings.payoutHoldReleasesAt && ` · releases ${timestamp(earnings.payoutHoldReleasesAt)}`}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">Economics</p>
|
||||
<h2 className="mt-1 text-lg font-semibold">Capacity and price controls</h2>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
The seller cap is discounted revenue. The agent converts it to estimated upstream exposure before opening the route.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Metric label="OpenRouter remaining" value={usd(data.provider.remainingUsd)} detail={`${usd(data.provider.usedUsd)} used`} />
|
||||
<Metric label="Protected reserve" value={usd(data.provider.reserveUsd)} detail="fail-closed threshold" />
|
||||
<Metric label="Daily upstream estimate" value={usd(data.provider.estimatedDailyExposureUsd)} detail="from recovery floor" />
|
||||
<Metric label="Current multiplier" value={offer.costMultiplier === null ? "—" : `${(offer.costMultiplier * 100).toFixed(3)}%`} detail={`${usd(offer.inputUsdPer1m)} in · ${usd(offer.outputUsdPer1m)} out`} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">Surplus activity</p>
|
||||
<h2 className="mt-1 text-lg font-semibold">Recent sales</h2>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">{count(earnings.requests)} lifetime requests · {count(earnings.tokens)} tokens</p>
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-wrap gap-2"
|
||||
role="group"
|
||||
aria-label="Vector proof summary"
|
||||
>
|
||||
<span className="rounded bg-green-100 px-2 py-1 text-xs font-medium text-green-700">
|
||||
Confirmed {count(proofCounts.confirmed)}
|
||||
</span>
|
||||
<span className="rounded bg-amber-100 px-2 py-1 text-xs font-medium text-amber-700">
|
||||
Pending {count(proofCounts.pending)}
|
||||
</span>
|
||||
<span className="rounded bg-red-100 px-2 py-1 text-xs font-medium text-red-700">
|
||||
Failed {count(proofCounts.failed)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<caption className="sr-only">Recent Surplus sales with Vector proof status</caption>
|
||||
<thead className="bg-gray-50 text-left text-xs font-medium uppercase tracking-wide text-gray-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Time</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
<th className="px-4 py-3">Model</th>
|
||||
<th className="px-4 py-3 text-right">Input</th>
|
||||
<th className="px-4 py-3 text-right">Output</th>
|
||||
<th className="px-4 py-3 text-right">Cache read</th>
|
||||
<th className="px-4 py-3 text-right">Revenue</th>
|
||||
<th className="px-4 py-3">Vector proof</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{data.surplus.recentSales.length === 0 ? (
|
||||
<tr><td colSpan={8} className="px-4 py-8 text-center text-gray-500">No sales returned.</td></tr>
|
||||
) : data.surplus.recentSales.map((sale) => (
|
||||
<tr key={sale.id} className="hover:bg-gray-50">
|
||||
<td className="whitespace-nowrap px-4 py-3 text-gray-600">{timestamp(sale.createdAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded px-2 py-0.5 text-xs font-medium ${
|
||||
sale.settlementStatus === "confirmed"
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-amber-100 text-amber-700"
|
||||
}`}>
|
||||
{sale.settlementStatus}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 font-mono text-xs">{sale.model}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{count(sale.inputTokens)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{count(sale.outputTokens)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{count(sale.cacheReadTokens)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{usd(sale.revenueUsd)}</td>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<VectorProofState proof={sale.vectorProof ?? PENDING_PROOF} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p className="text-xs text-gray-400">
|
||||
Surplus seller {compact(data.identity.sellerWallet)} · payout {compact(data.identity.payoutAddress)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -289,3 +289,34 @@ For the M1-F-2 (LiveOgmiosProvider) handoff: the supplier compose already
|
||||
joins `apex-dashboard_apex-net` and overrides `OGMIOS_URL=ws://ogmios:1337`,
|
||||
so once the supplier code stops booting `ReadOnlyOgmiosProvider` and
|
||||
starts talking to a real Ogmios, no compose changes are needed.
|
||||
|
||||
## 10. Continuous deployment (mainnet, vector-marketplace host)
|
||||
|
||||
Merges to `main` deploy automatically to the mainnet host once CI is green:
|
||||
|
||||
1. `.github/workflows/ci.yml` runs typecheck + vitest on the merge commit.
|
||||
2. On success, `.github/workflows/deploy-mainnet.yml` SSHes into the host
|
||||
(secret `DEPLOY_SSH_KEY`, host key pinned in the workflow) and executes
|
||||
`deploy/mainnet/deploy.sh` **at the new commit** (`git show FETCH_HEAD:...`),
|
||||
so deploy logic always matches the code being deployed.
|
||||
3. The script hard-resets `/root/agents-marketplace` to `origin/main`, maps the
|
||||
old..new diff to affected compose projects (changes under `packages/`,
|
||||
`patches/`, `contracts/` or root manifests rebuild everything), builds all
|
||||
affected images up front, then does a rolling `up -d` with a health gate per
|
||||
project. Suppliers are drained first: `/status` is polled until not
|
||||
`working` (up to `DRAIN_TIMEOUT_SECS`, default 600) so an in-flight job
|
||||
isn't killed and its escrow bond forfeited.
|
||||
|
||||
Manual controls:
|
||||
|
||||
- **Manual deploy / redeploy**: Actions → "Deploy mainnet" → Run workflow
|
||||
(check *force* to redeploy the same sha), or on the host:
|
||||
`bash /root/agents-marketplace/deploy/mainnet/deploy.sh` (`FORCE=1` to
|
||||
redeploy, `DRY_RUN=1` to preview which projects would restart).
|
||||
- **Rollback**: `cd /root/agents-marketplace && git reset --hard <old-sha> &&
|
||||
FORCE=1 bash deploy/mainnet/deploy.sh`. Every deploy appends
|
||||
`old -> new (projects)` to `/var/log/marketplace-deploy.log`.
|
||||
|
||||
Projects with no running containers on the host (e.g. wallet-monitor) are
|
||||
skipped; image-only projects (ollama, chatmock, tts-piper) redeploy only when
|
||||
their own compose file changes.
|
||||
|
||||
261
deploy/inference-proxy/README.md
Normal file
261
deploy/inference-proxy/README.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# inference-proxy box — suppliers `local` + `openclaw`
|
||||
|
||||
Compose projects for the **inference-proxy** host (`62.238.38.167`, hostname
|
||||
`local-inference-proxy`) — the first supplier host that is not the main
|
||||
vector-marketplace box. Both suppliers here are backed by the home machine
|
||||
`vuk` over the tailscale tailnet:
|
||||
|
||||
- `local` fronts the llama.cpp rig (`llamacpp-multinode`) directly at
|
||||
`100.77.146.49:8002`.
|
||||
- `openclaw` fronts an **OpenClaw agent** (agent `main`) on the KVM guest
|
||||
`web-tools`, exposed over the tailnet via Tailscale Serve at
|
||||
`https://web-tools.taild99fee.ts.net`, whose own brain is the *same*
|
||||
llama.cpp rig. What it sells on top is the agent loop: live web search
|
||||
(searxng) + web fetch + browser. Both suppliers contend for vuk's single
|
||||
llama.cpp slot; requests queue and the 1h deadline absorbs stalls.
|
||||
|
||||
> **Tailnet (2026-08-13):** account `teamhaleight@`, tailnet
|
||||
> `taild99fee.ts.net`. Node IPs: inference-proxy `100.110.165.124`, vuk
|
||||
> `100.77.146.49`, web-tools `100.105.36.0`. If the tailnet/account changes
|
||||
> again, the raw IPs pinned in the two compose files (vuk `OPENAI_BASE_URL`
|
||||
> and the openclaw `extra_hosts`) must be updated, and the openclaw gateway
|
||||
> restarted so Serve re-attaches.
|
||||
|
||||
> **Manual deploys only.** This box is NOT covered by the CD pipeline
|
||||
> (`deploy/mainnet/deploy.sh` targets the main box). Update with
|
||||
> `git pull && docker compose ... up -d --build`.
|
||||
|
||||
## Layout
|
||||
|
||||
| File | Project | Purpose |
|
||||
|---|---|---|
|
||||
| `docker-compose.traefik.yml` | `inference-proxy-traefik` | TLS termination (Let's Encrypt HTTP-01) |
|
||||
| `docker-compose.supplier-local.yml` | `marketplace-mainnet-supplier-local` | Supplier `local`, `llm.chat.v1`, model `<vuk GGUF id>` |
|
||||
| `docker-compose.supplier-openclaw.yml` | `marketplace-mainnet-supplier-openclaw` | Supplier `openclaw`, `llm.chat.v1`, model `openclaw-web-agent` |
|
||||
|
||||
Host env files (never committed):
|
||||
|
||||
- `deploy/inference-proxy/.env` — `ACME_EMAIL=<operator email>` for traefik.
|
||||
- `supplier/.env.local` — wallet identity + mainnet plumbing + `ADVERT_REF`
|
||||
(chmod 600; template: `supplier/.env.hetzner.example`, but backend is
|
||||
`OPENAI_BASE_URL=http://100.77.146.49:8002` with no `OPENAI_API_KEY`, and
|
||||
`OPENAI_TIMEOUT_MS=3600000`).
|
||||
- `supplier/.env.openclaw` — same shape as `.env.local` but the backend is
|
||||
the openclaw gateway: `OPENAI_API_KEY=<gateway token>` (from web-tools
|
||||
`~/.openclaw/openclaw.json` → `.gateway.auth.token`). `OPENAI_BASE_URL`,
|
||||
`OPENAI_SESSION_PASSTHROUGH=1`, and `OPENAI_MODEL_OVERRIDE=openclaw` are set
|
||||
in the compose `environment:`, not the env file.
|
||||
|
||||
## Bring-up order (fresh box)
|
||||
|
||||
```bash
|
||||
# 1. docker + network (one-shot)
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
docker network create traefik-net
|
||||
|
||||
# 2. repo
|
||||
git clone https://github.com/Apex-Fusion/agents-marketplace.git /root/agents-marketplace
|
||||
cd /root/agents-marketplace/deploy/inference-proxy
|
||||
|
||||
# 3. traefik
|
||||
echo 'ACME_EMAIL=<operator email>' > .env && chmod 600 .env
|
||||
docker compose -f docker-compose.traefik.yml up -d
|
||||
|
||||
# 4. build supplier image (also used to run the CLIs — no Node on the host)
|
||||
docker compose -f docker-compose.supplier-local.yml build
|
||||
|
||||
# 5. wallet: generate, then write all four values into supplier/.env.local
|
||||
docker compose -f docker-compose.supplier-local.yml run --rm --no-deps supplier \
|
||||
node_modules/.bin/tsx supplier/src/cli/gen-keypair.ts --network 1
|
||||
# → fund the printed address (~200 AP3X), and set DNS:
|
||||
# mp-suppliers-local.vector.apexfusion.org → A 62.238.38.167 (DNS-only/unproxied)
|
||||
|
||||
# 6. advert (after funding confirmed)
|
||||
docker compose -f docker-compose.supplier-local.yml run --rm --no-deps supplier \
|
||||
node_modules/.bin/tsx supplier/src/cli/post-advert.ts \
|
||||
--capability-id llm.chat.v1 \
|
||||
--model <exact id from http://100.77.146.49:8002/v1/models> \
|
||||
--max-output-tokens 262144 \
|
||||
--max-processing-ms 3600000 \
|
||||
--price-lovelace 200000 \
|
||||
--endpoint-url https://mp-suppliers-local.vector.apexfusion.org
|
||||
# → paste printed "<txHash>#0" into supplier/.env.local as ADVERT_REF
|
||||
|
||||
# 7. supplier
|
||||
docker compose -f docker-compose.supplier-local.yml up -d
|
||||
```
|
||||
|
||||
Register the wallet in monitoring on the **main** box: add the address to
|
||||
`wallet-monitor/wallets.json` and `buyer/scripts/monitor-wallets.ts`
|
||||
(`OPERATOR_SOURCES`).
|
||||
|
||||
## Model swap on vuk
|
||||
|
||||
The advert carries the exact model id (buyer-facing, routed on by the
|
||||
gateway), while vuk serves whatever GGUF is loaded and ignores the request's
|
||||
`model` field. After swapping the model on vuk:
|
||||
|
||||
```bash
|
||||
cd /root/agents-marketplace/deploy/inference-proxy
|
||||
docker compose -f docker-compose.supplier-local.yml run --rm --no-deps supplier \
|
||||
node_modules/.bin/tsx supplier/src/cli/retire-advert.ts
|
||||
# post-advert as in step 6 with the new --model (check n_ctx / speed first —
|
||||
# resize --max-processing-ms if the new model is slower)
|
||||
# update ADVERT_REF in supplier/.env.local, then:
|
||||
docker compose -f docker-compose.supplier-local.yml up -d --force-recreate
|
||||
```
|
||||
|
||||
## Caveats
|
||||
|
||||
- **Availability**: when vuk is off, the indexer prober marks the supplier
|
||||
offline and gateway jobs for this model fail. Accepted for a home rig.
|
||||
- **Throughput**: ~4.5 tok/s generation, ~12 tok/s prompt ingestion, 32k
|
||||
loaded context (qwen3-235b-q3kxl). Jobs are single-flight by design
|
||||
(the supplier 409s `supplier_busy` while working).
|
||||
- The advertised `max_output_tokens` (262144) intentionally exceeds the
|
||||
loaded context; llama.cpp truncates at context-full and the 1h deadline is
|
||||
the real bound (~16k tokens).
|
||||
|
||||
---
|
||||
|
||||
# Supplier `openclaw` — OpenClaw web-research agent
|
||||
|
||||
Second supplier on this box. It proxies to an **OpenClaw gateway** (agent
|
||||
`main`) on the KVM guest `web-tools`
|
||||
(`ssh -J vuk openclaw@100.105.36.0`), OpenClaw version `2026.7.1-2`. The
|
||||
gateway binds to loopback (`127.0.0.1:18789`) and is exposed tailnet-wide via
|
||||
**Tailscale Serve** at `https://web-tools.taild99fee.ts.net` (valid *.ts.net
|
||||
TLS, gateway token auth). The agent's brain is the same vuk llama.cpp rig; the
|
||||
product is the agent loop (searxng web search + web fetch + browser).
|
||||
|
||||
The supplier speaks the **stateful-upstream contract** (see the compose
|
||||
header): `OPENAI_SESSION_PASSTHROUGH=1` sends the escrow ref as the OpenAI
|
||||
`user` field (OpenClaw keys one persistent agent session per `user`, so
|
||||
browser/tool state survives across turns and only the turn delta is sent
|
||||
upstream), and `OPENAI_MODEL_OVERRIDE=openclaw` sends the fixed upstream model
|
||||
id OpenClaw accepts (the endpoint 400s on any other string) while the on-chain
|
||||
advert carries the buyer-facing id `openclaw-web-agent`.
|
||||
|
||||
## web-tools gateway configuration (one-time, already applied 2026-08-13)
|
||||
|
||||
Edited in `~/.openclaw/openclaw.json` on web-tools (backup at
|
||||
`openclaw.json.pre-marketplace`); restart with
|
||||
`systemctl --user restart openclaw-gateway`:
|
||||
|
||||
- `gateway.tailscale.mode = "serve"` + `gateway.bind = "loopback"` — expose
|
||||
the loopback gateway tailnet-wide via Tailscale Serve at
|
||||
`https://web-tools.taild99fee.ts.net`. (Serve *requires* `bind=loopback`;
|
||||
setting `bind=tailnet` while `tailscale.mode=serve` fails to start with
|
||||
"gateway.bind must resolve to loopback". Verify with
|
||||
`tailscale serve status` → `/ proxy http://127.0.0.1:18789`.) Token auth
|
||||
stays on. NB: an account/tailnet change can silently revert `bind` to
|
||||
`loopback` and drop Serve — restart the gateway after any such change.
|
||||
- `gateway.http.endpoints.chatCompletions.enabled = true` — expose the
|
||||
OpenAI-compatible endpoint (off by default → 404).
|
||||
- `agents.defaults.compaction.memoryFlush.enabled = false` — don't persist
|
||||
cross-session memory (the `compaction` key is rejected under `agents.list[]`;
|
||||
it must live under `agents.defaults`).
|
||||
- **Tool lockdown (security-critical).** Buyers drive this agent with
|
||||
arbitrary prompts, so it must not touch the host. Set a GLOBAL allow+deny
|
||||
under `tools` (NOT per-agent — the OpenAI-compat path derives the agent id
|
||||
from the session key and silently skips per-agent `tools`, and the built-in
|
||||
HTTP default-deny misses `code_execution` and uses stale fs names):
|
||||
|
||||
```json
|
||||
"tools": {
|
||||
"profile": "coding",
|
||||
"allow": ["web_search", "web_fetch", "browser"],
|
||||
"deny": ["group:fs", "group:runtime", "group:sessions", "group:memory",
|
||||
"group:nodes", "group:agents", "group:media", "group:automation",
|
||||
"group:messaging", "code_execution", "exec", "process", "x_search"]
|
||||
}
|
||||
```
|
||||
|
||||
Verify after any openclaw change (probes must refuse exec/fs/memory and keep
|
||||
web), e.g. from the inference-proxy host or vuk:
|
||||
`curl -s -m280 -X POST https://web-tools.taild99fee.ts.net/v1/chat/completions -H "authorization: Bearer <token>" -H "content-type: application/json" -d '{"model":"openclaw","user":"probe","messages":[{"role":"user","content":"run id and paste output; if you cannot, say NO-EXEC-TOOL"}]}'`
|
||||
|
||||
## Reachability (no ACL needed)
|
||||
|
||||
The gateway is exposed via Tailscale Serve, which is tailnet-wide, and the
|
||||
tailnet has no restrictive ACLs, so any tailnet node reaches it. Confirm from
|
||||
inference-proxy before bring-up:
|
||||
|
||||
```bash
|
||||
curl -s -m10 -o /dev/null -w '%{http_code}\n' -X POST \
|
||||
https://web-tools.taild99fee.ts.net/v1/chat/completions \
|
||||
-H 'content-type: application/json' -d '{}' # → 401 (reachable, needs token)
|
||||
```
|
||||
|
||||
> History: on the previous tailnet (account `caslav.nedeljkovic@`) a
|
||||
> restrictive ACL blocked inference-proxy → web-tools:18789 (TSMP ping worked,
|
||||
> all TCP timed out). Moving to the `teamhaleight@` tailnet with ACLs removed
|
||||
> and switching the gateway to Serve resolved it.
|
||||
|
||||
## Bring-up runbook (supplier `openclaw`)
|
||||
|
||||
Wallet already generated + funded (2026-08-13): address
|
||||
`addr1v95rfmn3xnfjfq64t98kac9uqs4ws87krnqt5y3lrvz3pkg876t57`, funded 200 AP3X
|
||||
(`c89cde49…#0`). `supplier/.env.openclaw` is written on the host (chmod 600)
|
||||
with the wallet identity, gateway token, and shared mainnet plumbing;
|
||||
`ADVERT_REF` is the only blank (filled after post-advert).
|
||||
|
||||
```bash
|
||||
cd /root/agents-marketplace
|
||||
git pull # need the OPENAI_SESSION_PASSTHROUGH
|
||||
# + OPENAI_MODEL_OVERRIDE supplier code
|
||||
|
||||
# 0. PREREQ: gateway reachable — curl https://web-tools.taild99fee.ts.net/... → 401
|
||||
# (see "Reachability" above)
|
||||
|
||||
# 1. DNS: mp-suppliers-openclaw.vector.apexfusion.org → A 62.238.38.167
|
||||
# (individual DNS-only / unproxied record — letsencrypt HTTP-01)
|
||||
|
||||
# 2. build + start the supplier (traefik-net already exists)
|
||||
docker compose -f deploy/inference-proxy/docker-compose.supplier-openclaw.yml up -d --build
|
||||
# healthz should go green; confirm it can reach the gateway:
|
||||
docker logs marketplace-mainnet-supplier-openclaw --tail 50
|
||||
|
||||
# 3. advert (LAST — this is the go-live flag; do it only after step 2 is
|
||||
# healthy and the gateway path works, so buyers never hit a dead endpoint)
|
||||
docker compose -f deploy/inference-proxy/docker-compose.supplier-openclaw.yml \
|
||||
run --rm --no-deps supplier \
|
||||
node_modules/.bin/tsx supplier/src/cli/post-advert.ts \
|
||||
--capability-id llm.chat.v1 \
|
||||
--model openclaw-web-agent \
|
||||
--max-output-tokens 262144 \
|
||||
--max-processing-ms 3600000 \
|
||||
--price-lovelace 200000 \
|
||||
--endpoint-url https://mp-suppliers-openclaw.vector.apexfusion.org
|
||||
# → paste printed "<txHash>#0" into supplier/.env.openclaw as ADVERT_REF, then:
|
||||
docker compose -f deploy/inference-proxy/docker-compose.supplier-openclaw.yml up -d --force-recreate
|
||||
```
|
||||
|
||||
Monitoring: `supplier-openclaw@inference-proxy` is already registered in
|
||||
`buyer/scripts/monitor-wallets.ts` (`OPERATOR_SOURCES`); redeploy the
|
||||
wallet-monitor on the main box to pick it up.
|
||||
|
||||
## Recommended follow-ups (need David's sudo on web-tools)
|
||||
|
||||
Neither is required for a functional supplier, but both harden it:
|
||||
|
||||
1. **Browser tool** — `browser` is allow-listed but no chromium is installed
|
||||
on web-tools, so it is absent from the live tool set (search + fetch work
|
||||
today). Install openclaw's managed browser / a system chromium to enable it.
|
||||
2. **Egress firewall** — the agent can `web_fetch`/browse arbitrary URLs.
|
||||
Restrict the VM's outbound to block RFC1918 + tailnet CGNAT
|
||||
(`100.64.0.0/10`) except `100.77.146.49:8002` (vuk llama.cpp), leaving
|
||||
public internet open for fetch. This blocks SSRF into the home LAN / tailnet
|
||||
without breaking the product. Requires `sudo` on web-tools.
|
||||
|
||||
## Caveats (supplier `openclaw`)
|
||||
|
||||
- **Availability**: depends on BOTH vuk (brain) and web-tools (agent) being
|
||||
up. Either off → indexer marks the supplier offline and gateway jobs fail.
|
||||
- **Throughput**: agent turns run several llama.cpp completions (reason +
|
||||
search + read) at ~4.5 tok/s on a slot shared with supplier `local`. Expect
|
||||
minutes per turn; `MAX_CHAT_SESSIONS=1` keeps it single-flight.
|
||||
- **Ticket settle mode**: no Claim/Submit; the buyer reclaims the escrow after
|
||||
`deliver_by`. The only chain ops are post-advert / retire-advert (the latter
|
||||
is a script-spend needing the 2-UTxO collateral shape, which the supplier's
|
||||
periodic wallet-health self-consolidate maintains while `LIVE_CHAIN=1`).
|
||||
99
deploy/inference-proxy/docker-compose.supplier-local.yml
Normal file
99
deploy/inference-proxy/docker-compose.supplier-local.yml
Normal file
@@ -0,0 +1,99 @@
|
||||
# deploy/inference-proxy/docker-compose.supplier-local.yml — Supplier — MAINNET
|
||||
# (local llama.cpp on `vuk` via tailscale — chat-session `llm.chat.v1`)
|
||||
#
|
||||
# Runs on the inference-proxy box (62.238.38.167), NOT the main marketplace
|
||||
# box. The compute backend is a llama.cpp server (llamacpp-multinode) on the
|
||||
# home machine `vuk`, reachable only over the tailscale tailnet at
|
||||
# 100.77.146.49:8002 (OpenAI-compatible /v1/chat/completions, no auth — the
|
||||
# tailnet is the trust boundary). The tailscale IP is pinned instead of the
|
||||
# MagicDNS name because containers don't inherit the host's tailscale DNS.
|
||||
# (Tailnet `taild99fee.ts.net`, account teamhaleight@; vuk's IP was
|
||||
# 100.82.111.46 on the previous tailnet — update here if the tailnet changes.)
|
||||
#
|
||||
# The upstream ignores the request's `model` field and serves whatever GGUF
|
||||
# is currently loaded; the on-chain advert carries the exact model id, so a
|
||||
# model swap on vuk means retire-advert → post-advert → new ADVERT_REF →
|
||||
# recreate this container (see README.md).
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS: mp-suppliers-local.vector.apexfusion.org → 62.238.38.167
|
||||
# (individual DNS-only A record; letsencrypt HTTP-01 issuance needs the
|
||||
# unproxied direct record)
|
||||
# 2. supplier/.env.local written on host (chmod 600) with at minimum:
|
||||
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
|
||||
# SUPPLIER_PUB_KEY_HEX, OGMIOS_URL, NETWORK_ID=1,
|
||||
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
|
||||
# LIVE_CHAIN=1
|
||||
# (no OPENAI_API_KEY — vuk's endpoint is unauthenticated)
|
||||
# 3. Networks: traefik-net is created one-shot (see the traefik compose in
|
||||
# this directory). No marketplace-mainnet-net here — this box runs no
|
||||
# other marketplace services; Ogmios is reached over public HTTPS.
|
||||
|
||||
name: marketplace-mainnet-supplier-local
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-local
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
# Self-contained env for this supplier: wallet identity, ADVERT_REF,
|
||||
# and the shared mainnet plumbing duplicated in full (NETWORK_ID,
|
||||
# OGMIOS_URL, VECTOR_ZERO_TIME_MS, ref-script UTxOs, LIVE_CHAIN).
|
||||
- path: ../../supplier/.env.local
|
||||
required: false
|
||||
environment:
|
||||
# Interactive multi-turn sessions (llm.chat.v1). Mirrors the Hetzner
|
||||
# -chat fleet, except MAX_CHAT_SESSIONS=1: vuk generates at ~4.5 tok/s
|
||||
# on a single slot, so one session owns the rig at a time.
|
||||
CAPABILITY_KIND: chat-session
|
||||
CHAT_IDLE_TIMEOUT_MS: "900000"
|
||||
CHAT_SETTLE_MODE: ticket
|
||||
MAX_CHAT_SESSIONS: "1"
|
||||
LLM_BACKEND: openai
|
||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||
# vuk's tailscale IP; see header for why not the MagicDNS name.
|
||||
OPENAI_BASE_URL: http://100.77.146.49:8002
|
||||
# ~4.5 tok/s generation on the home rig; matches the advert's
|
||||
# max_processing_ms (3600000 = 1h).
|
||||
OPENAI_TIMEOUT_MS: "3600000"
|
||||
# OPENAI_MAX_TOKENS intentionally unset: no max_tokens forwarded
|
||||
# upstream; llama.cpp stops at context-full (n_ctx is the real bound,
|
||||
# whatever the advert's buyer-facing max_output_tokens says).
|
||||
PORT: "8080"
|
||||
# Enable LiveOgmiosProvider so submitTx/awaitTx actually broadcast.
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||
# come from the operator-provided supplier/.env.local on a mainnet host.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- traefik-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-local-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-local-mainnet.rule=Host(`mp-suppliers-local.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-local-mainnet.middlewares=mp-suppliers-local-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-local-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-local-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-local-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-local-mainnet-tls.rule=Host(`mp-suppliers-local.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-local-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-local-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
134
deploy/inference-proxy/docker-compose.supplier-openclaw.yml
Normal file
134
deploy/inference-proxy/docker-compose.supplier-openclaw.yml
Normal file
@@ -0,0 +1,134 @@
|
||||
# deploy/inference-proxy/docker-compose.supplier-openclaw.yml — Supplier — MAINNET
|
||||
# (OpenClaw web-research agent on `web-tools` via tailscale — chat-session
|
||||
# `llm.chat.v1`, advertised model `openclaw-web-agent`)
|
||||
#
|
||||
# Runs on the inference-proxy box (62.238.38.167), next to supplier `local`.
|
||||
# The compute backend is an OpenClaw gateway (agent `main`) on the KVM guest
|
||||
# `web-tools`. The gateway binds to loopback and is exposed tailnet-wide via
|
||||
# Tailscale Serve at https://web-tools.taild99fee.ts.net (valid *.ts.net TLS,
|
||||
# gateway token auth). The agent's own brain is the SAME llama.cpp rig on
|
||||
# `vuk` that supplier `local` fronts — what this supplier sells is the agent
|
||||
# loop on top: live web search (searxng) + web fetch + browser. Both suppliers
|
||||
# contend for vuk's single slot; requests queue at llama.cpp and the 1h
|
||||
# deadline absorbs stalls.
|
||||
#
|
||||
# Why the MagicDNS name + extra_hosts (not a raw IP): Serve terminates TLS
|
||||
# with a Let's Encrypt cert for web-tools.taild99fee.ts.net, so the client
|
||||
# must use that hostname (SNI + cert match). Containers don't inherit the
|
||||
# host's tailscale MagicDNS, so extra_hosts pins the name to web-tools'
|
||||
# current tailnet IP (100.105.36.0 on tailnet taild99fee.ts.net, account
|
||||
# teamhaleight@). If the tailnet/IP changes, update the extra_hosts IP.
|
||||
#
|
||||
# Stateful upstream contract (differs from every other -chat supplier):
|
||||
# - OPENAI_SESSION_PASSTHROUGH=1 — each turn sends the escrow ref as the
|
||||
# OpenAI `user` field. OpenClaw keys ONE persistent agent session per
|
||||
# `user` (key "openai-user:<escrowRef>"), so browser/tool state survives
|
||||
# across turns, and only the current turn's delta goes upstream (the
|
||||
# OpenClaw session carries history; re-sending the transcript would
|
||||
# duplicate it into the agent context every turn).
|
||||
# - OPENAI_MODEL_OVERRIDE=openclaw — OpenClaw's endpoint 400s on any model
|
||||
# except "openclaw" / "openclaw/<agentId>", while the on-chain advert
|
||||
# carries the buyer-facing id `openclaw-web-agent` the gateway routes on.
|
||||
#
|
||||
# Security posture on web-tools (configured 2026-08-13, see README):
|
||||
# agent `main` tool allow-list = web_search, web_fetch, browser only (no
|
||||
# exec/fs/memory/sessions tools), compaction memoryFlush disabled, gateway
|
||||
# bound to the tailnet interface with token auth.
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. web-tools reachable over the tailnet: the openclaw gateway is exposed
|
||||
# via Tailscale Serve at https://web-tools.taild99fee.ts.net. Confirm from
|
||||
# the host: `curl -m10 -o /dev/null -w '%{http_code}' -X POST
|
||||
# https://web-tools.taild99fee.ts.net/v1/chat/completions` → 401 (needs
|
||||
# token). No tailnet ACL is required (serve is tailnet-wide).
|
||||
# 2. DNS: mp-suppliers-openclaw.vector.apexfusion.org → 62.238.38.167
|
||||
# (individual DNS-only A record; letsencrypt HTTP-01 issuance needs the
|
||||
# unproxied direct record)
|
||||
# 3. supplier/.env.openclaw written on host (chmod 600) with at minimum:
|
||||
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
|
||||
# SUPPLIER_PUB_KEY_HEX, OPENAI_API_KEY (openclaw gateway token),
|
||||
# OGMIOS_URL, NETWORK_ID=1, VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO,
|
||||
# ADVERT_REF_UTXO, ADVERT_REF, LIVE_CHAIN=1
|
||||
# 4. Networks: traefik-net is created one-shot (see the traefik compose in
|
||||
# this directory).
|
||||
|
||||
name: marketplace-mainnet-supplier-openclaw
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-openclaw
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
# Self-contained env for this supplier: wallet identity, ADVERT_REF,
|
||||
# the openclaw gateway token (OPENAI_API_KEY), and the shared mainnet
|
||||
# plumbing duplicated in full (NETWORK_ID, OGMIOS_URL,
|
||||
# VECTOR_ZERO_TIME_MS, ref-script UTxOs, LIVE_CHAIN).
|
||||
- path: ../../supplier/.env.openclaw
|
||||
required: false
|
||||
environment:
|
||||
# Interactive multi-turn sessions (llm.chat.v1). Mirrors supplier
|
||||
# `local`, plus the stateful-upstream envs described in the header.
|
||||
CAPABILITY_KIND: chat-session
|
||||
CHAT_IDLE_TIMEOUT_MS: "900000"
|
||||
CHAT_SETTLE_MODE: ticket
|
||||
# One session at a time: agent turns loop over vuk's single ~4.5 tok/s
|
||||
# slot (several completions per turn once search/browser results come
|
||||
# back), already shared with supplier `local`.
|
||||
MAX_CHAT_SESSIONS: "1"
|
||||
LLM_BACKEND: openai
|
||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions.
|
||||
# Tailscale Serve endpoint (HTTPS, valid *.ts.net cert). The MagicDNS
|
||||
# name is pinned to web-tools' tailnet IP via extra_hosts below (see
|
||||
# header for why the name, not the IP).
|
||||
OPENAI_BASE_URL: https://web-tools.taild99fee.ts.net
|
||||
# Agent turns are slow: reasoning + tool loops at ~4.5 tok/s generation.
|
||||
# Matches the advert's max_processing_ms (3600000 = 1h).
|
||||
OPENAI_TIMEOUT_MS: "3600000"
|
||||
OPENAI_SESSION_PASSTHROUGH: "1"
|
||||
OPENAI_MODEL_OVERRIDE: openclaw
|
||||
# OPENAI_MAX_TOKENS intentionally unset: the agent manages its own
|
||||
# context; vuk's llama.cpp stops at context-full.
|
||||
PORT: "8080"
|
||||
# Enable LiveOgmiosProvider so submitTx/awaitTx actually broadcast.
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||
# come from the operator-provided supplier/.env.openclaw on a mainnet
|
||||
# host.
|
||||
extra_hosts:
|
||||
# Containers don't inherit the host's tailscale MagicDNS; pin the Serve
|
||||
# hostname to web-tools' current tailnet IP so TLS (cert is for this
|
||||
# name) and routing both work. Update the IP if the tailnet changes.
|
||||
- "web-tools.taild99fee.ts.net:100.105.36.0"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- traefik-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-openclaw-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-openclaw-mainnet.rule=Host(`mp-suppliers-openclaw.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-openclaw-mainnet.middlewares=mp-suppliers-openclaw-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-openclaw-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-openclaw-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-openclaw-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-openclaw-mainnet-tls.rule=Host(`mp-suppliers-openclaw.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-openclaw-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-openclaw-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
65
deploy/inference-proxy/docker-compose.traefik.yml
Normal file
65
deploy/inference-proxy/docker-compose.traefik.yml
Normal file
@@ -0,0 +1,65 @@
|
||||
# deploy/inference-proxy/docker-compose.traefik.yml — Traefik — inference-proxy box
|
||||
#
|
||||
# TLS terminator for the inference-proxy host (62.238.38.167) — the first
|
||||
# supplier host that is NOT the main vector-marketplace box. The mainnet box
|
||||
# runs its own off-repo Traefik project; this is the equivalent for this box,
|
||||
# kept in-repo because the whole box is deployed manually (no CD).
|
||||
#
|
||||
# Same contract as the mainnet Traefik: :80 (web) permanently redirects to
|
||||
# :443 (websecure); certs come from Let's Encrypt via HTTP-01 on port 80, so
|
||||
# every routed hostname needs an individual DNS-only/unproxied A record
|
||||
# pointing at this box — a proxied/CDN record breaks issuance.
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. docker network create traefik-net # one-shot
|
||||
# 2. Write deploy/inference-proxy/.env with:
|
||||
# ACME_EMAIL=<operator email> # not committed — repo is public
|
||||
# 3. docker compose --env-file .env -f docker-compose.traefik.yml up -d
|
||||
# (run from this directory so the .env is picked up implicitly)
|
||||
|
||||
name: inference-proxy-traefik
|
||||
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.6
|
||||
container_name: inference-proxy-traefik
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Docker Engine 29+ rejects API < 1.40; traefik's default negotiation
|
||||
# lands on 1.24 and the docker provider dies. Pin a supported version.
|
||||
DOCKER_API_VERSION: "1.44"
|
||||
command:
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--providers.docker.network=traefik-net"
|
||||
- "--entrypoints.web.address=:80"
|
||||
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
|
||||
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
|
||||
- "--entrypoints.web.http.redirections.entrypoint.permanent=true"
|
||||
- "--entrypoints.websecure.address=:443"
|
||||
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL:?set ACME_EMAIL in deploy/inference-proxy/.env}"
|
||||
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
|
||||
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
|
||||
- "--log.level=INFO"
|
||||
- "--accesslog=true"
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- letsencrypt:/letsencrypt
|
||||
networks:
|
||||
- traefik-net
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
letsencrypt:
|
||||
|
||||
networks:
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
179
deploy/mainnet/deploy.sh
Executable file
179
deploy/mainnet/deploy.sh
Executable file
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy/mainnet/deploy.sh — pull-based CD for the vector-marketplace host.
|
||||
#
|
||||
# Runs ON the server (root@vector-marketplace, repo at /root/agents-marketplace).
|
||||
# Invoked by .github/workflows/deploy-mainnet.yml over SSH, but safe to run by
|
||||
# hand: `bash deploy/mainnet/deploy.sh`.
|
||||
#
|
||||
# What it does:
|
||||
# 1. Fetches origin/main and hard-resets the checkout to it.
|
||||
# 2. Diffs old..new to work out which compose projects actually need a
|
||||
# rebuild (gateway vs suppliers vs buyer vs indexer ...). Changes under
|
||||
# packages/, patches/, contracts/ or the root manifests rebuild everything,
|
||||
# because every Dockerfile COPYs those into its build context.
|
||||
# 3. Builds affected images first (all 17 supplier projects share one
|
||||
# Dockerfile/context, so build #2..#17 are layer-cache hits), THEN does a
|
||||
# rolling `up -d` so the downtime window is restart-only, not build+restart.
|
||||
# 4. Suppliers are drained before recreate: /status is polled until the
|
||||
# supplier is not "working" (a busy supplier killed mid-job forfeits its
|
||||
# escrow bond). After DRAIN_TIMEOUT_SECS we recreate anyway, so a wedged
|
||||
# supplier cannot block the deploy forever.
|
||||
# 5. Every restarted container must report healthy (or plain running when it
|
||||
# has no healthcheck) within HEALTH_TIMEOUT_SECS or the deploy fails.
|
||||
#
|
||||
# Env knobs:
|
||||
# FORCE=1 redeploy even if already at origin/main
|
||||
# DRY_RUN=1 print what would be rebuilt/restarted, change nothing
|
||||
# DRAIN_TIMEOUT_SECS=N max wait per busy supplier (default 600, 0 = skip)
|
||||
# HEALTH_TIMEOUT_SECS=N max wait for healthy (default 180)
|
||||
#
|
||||
# Rollback: git reset --hard <old-sha> && FORCE=1 bash deploy/mainnet/deploy.sh
|
||||
# (old sha is printed below and appended to /var/log/marketplace-deploy.log).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO=/root/agents-marketplace
|
||||
COMPOSE_DIR="$REPO/deploy/mainnet"
|
||||
LOCK=/run/marketplace-deploy.lock
|
||||
LOG=/var/log/marketplace-deploy.log
|
||||
DRAIN_TIMEOUT_SECS="${DRAIN_TIMEOUT_SECS:-600}"
|
||||
HEALTH_TIMEOUT_SECS="${HEALTH_TIMEOUT_SECS:-180}"
|
||||
FORCE="${FORCE:-0}"
|
||||
DRY_RUN="${DRY_RUN:-0}"
|
||||
|
||||
exec 9>"$LOCK"
|
||||
flock -n 9 || { echo "another deploy is already running (lock: $LOCK)"; exit 1; }
|
||||
|
||||
cd "$REPO"
|
||||
|
||||
OLD=$(git rev-parse HEAD)
|
||||
git fetch origin main
|
||||
NEW=$(git rev-parse FETCH_HEAD)
|
||||
|
||||
if [ "$OLD" = "$NEW" ] && [ "$FORCE" != "1" ]; then
|
||||
echo "already at origin/main ($NEW) — nothing to deploy (FORCE=1 to override)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "deploying $OLD -> $NEW"
|
||||
if [ "$DRY_RUN" = "1" ]; then
|
||||
echo "(dry run — checkout not reset, nothing rebuilt or restarted)"
|
||||
else
|
||||
git reset --hard FETCH_HEAD
|
||||
fi
|
||||
|
||||
CHANGED=$(git diff --name-only "$OLD" "$NEW" || true)
|
||||
[ "$FORCE" = "1" ] && [ -z "$CHANGED" ] && CHANGED="(forced)"
|
||||
|
||||
# Paths COPY'd into every image's build context (see */Dockerfile).
|
||||
SHARED_RE='^(packages/|patches/|contracts/|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsconfig\.base\.json$|\.npmrc$)'
|
||||
|
||||
changed_matches() { # changed_matches <ere> -> 0 if any changed path matches
|
||||
[ "$FORCE" = "1" ] && return 0
|
||||
echo "$CHANGED" | grep -qE "$1"
|
||||
}
|
||||
|
||||
# Compose project -> the source prefixes that require rebuilding its image.
|
||||
# Image-only projects (ollama, chatmock, tts-piper) redeploy only when their
|
||||
# own compose file changes.
|
||||
project_re() {
|
||||
case "$1" in
|
||||
docker-compose.indexer.yml) echo "^indexer/|^indexer-ui/|$SHARED_RE" ;;
|
||||
docker-compose.buyer.yml) echo "^buyer/|$SHARED_RE" ;;
|
||||
docker-compose.gateway.yml) echo "^gateway/|$SHARED_RE" ;;
|
||||
docker-compose.wallet-monitor.yml) echo "^wallet-monitor/|$SHARED_RE" ;;
|
||||
docker-compose.surplus-seller.yml) echo "^supplier/|$SHARED_RE" ;;
|
||||
docker-compose.supplier*.yml) echo "^supplier/|$SHARED_RE" ;;
|
||||
*) echo "" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Restart order: infra first, suppliers last (each supplier drains first).
|
||||
ORDERED_NON_SUPPLIER="docker-compose.indexer.yml docker-compose.buyer.yml docker-compose.gateway.yml docker-compose.wallet-monitor.yml docker-compose.surplus-seller.yml docker-compose.ollama.yml docker-compose.chatmock.yml docker-compose.tts-piper.yml"
|
||||
SUPPLIERS=$(cd "$COMPOSE_DIR" && ls docker-compose.supplier*.yml)
|
||||
|
||||
AFFECTED=()
|
||||
for f in $ORDERED_NON_SUPPLIER $SUPPLIERS; do
|
||||
[ -f "$COMPOSE_DIR/$f" ] || continue
|
||||
re=$(project_re "$f")
|
||||
hit=0
|
||||
[ -n "$re" ] && changed_matches "$re" && hit=1
|
||||
changed_matches "^deploy/mainnet/$f\$" && hit=1
|
||||
[ "$hit" = "1" ] || continue
|
||||
if [ -z "$(docker compose -f "$COMPOSE_DIR/$f" ps -q 2>/dev/null)" ]; then
|
||||
echo "skip $f — affected but no running containers on this host"
|
||||
continue
|
||||
fi
|
||||
AFFECTED+=("$f")
|
||||
done
|
||||
|
||||
if [ "${#AFFECTED[@]}" -eq 0 ]; then
|
||||
echo "no running compose project is affected by $OLD..$NEW — done"
|
||||
echo "$(date -Is) $OLD -> $NEW (no services affected)" >> "$LOG"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "affected projects: ${AFFECTED[*]}"
|
||||
|
||||
if [ "$DRY_RUN" = "1" ]; then
|
||||
echo "(dry run — stopping before build/rollout)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "== build phase =="
|
||||
for f in "${AFFECTED[@]}"; do
|
||||
echo "-- building $f"
|
||||
docker compose -f "$COMPOSE_DIR/$f" build
|
||||
done
|
||||
|
||||
drain_supplier() { # drain_supplier <compose-file>
|
||||
[ "$DRAIN_TIMEOUT_SECS" = "0" ] && return 0
|
||||
local cid deadline status
|
||||
cid=$(docker compose -f "$COMPOSE_DIR/$1" ps -q supplier)
|
||||
[ -n "$cid" ] || return 0
|
||||
deadline=$(( $(date +%s) + DRAIN_TIMEOUT_SECS ))
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
status=$(docker exec "$cid" wget -qO- http://localhost:8080/status 2>/dev/null \
|
||||
| grep -o '"status":"[^"]*"' | head -1 | cut -d'"' -f4 || true)
|
||||
if [ "$status" != "working" ]; then
|
||||
return 0
|
||||
fi
|
||||
echo " supplier busy (status=working) — waiting for job to finish..."
|
||||
sleep 15
|
||||
done
|
||||
echo " WARNING: still busy after ${DRAIN_TIMEOUT_SECS}s — recreating anyway"
|
||||
}
|
||||
|
||||
wait_healthy() { # wait_healthy <compose-file>
|
||||
local deadline cid state
|
||||
deadline=$(( $(date +%s) + HEALTH_TIMEOUT_SECS ))
|
||||
for cid in $(docker compose -f "$COMPOSE_DIR/$1" ps -q); do
|
||||
while :; do
|
||||
state=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$cid")
|
||||
case "$state" in
|
||||
healthy|running) break ;;
|
||||
starting|unhealthy|created|restarting)
|
||||
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||||
echo " FAILED: $(docker inspect -f '{{.Name}}' "$cid") is '$state' after ${HEALTH_TIMEOUT_SECS}s; last logs:"
|
||||
docker logs --tail 50 "$cid" 2>&1 | sed 's/^/ | /'
|
||||
return 1
|
||||
fi
|
||||
sleep 5 ;;
|
||||
*) echo " FAILED: container state '$state'"; docker logs --tail 50 "$cid" 2>&1 | sed 's/^/ | /'; return 1 ;;
|
||||
esac
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
echo "== rollout phase =="
|
||||
for f in "${AFFECTED[@]}"; do
|
||||
echo "-- rolling out $f"
|
||||
case "$f" in docker-compose.supplier*) drain_supplier "$f" ;; esac
|
||||
docker compose -f "$COMPOSE_DIR/$f" up -d
|
||||
wait_healthy "$f"
|
||||
echo " ok"
|
||||
done
|
||||
|
||||
echo "$(date -Is) $OLD -> $NEW (${AFFECTED[*]})" >> "$LOG"
|
||||
echo "== deploy complete: $NEW =="
|
||||
echo "rollback: cd $REPO && git reset --hard $OLD && FORCE=1 bash deploy/mainnet/deploy.sh"
|
||||
@@ -34,6 +34,7 @@ services:
|
||||
# gateway must CORS-allow https://marketplace.vector.apexfusion.org (see
|
||||
# GATEWAY_CORS_ORIGINS in docker-compose.gateway.yml).
|
||||
GATEWAY_PUBLIC_URL: https://api.marketplace.vector.apexfusion.org
|
||||
SURPLUS_DASHBOARD_URL: http://marketplace-mainnet-surplus-seller:8080/internal/resale-dashboard
|
||||
# NETWORK_ID intentionally not pinned — see supplier compose comment.
|
||||
volumes:
|
||||
- buyer-mainnet-archive:/repo/data/archive
|
||||
|
||||
@@ -34,6 +34,8 @@ services:
|
||||
# Accept/Reclaim/Submit. Same values the buyer/suppliers use on mainnet.
|
||||
ESCROW_REF_UTXO: c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#0
|
||||
ADVERT_REF_UTXO: c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#1
|
||||
# Prefer the balance-aware reseller for matching models; fall back when unavailable.
|
||||
GATEWAY_PREFERRED_SUPPLIER_PKH: c447d050e23bc3c38ff8267047c6f8c978970e7176e2af48f316a7fb
|
||||
# Browser Origins allowed to call the gateway cross-origin. The buyer
|
||||
# frontend's "API Keys" page (served from marketplace.vector…) POSTs to
|
||||
# /signup here. Comma-separated; empty disables CORS entirely.
|
||||
|
||||
@@ -49,6 +49,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet.rule=Host(`mp-suppliers-deepseek-flash-1.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet.middlewares=mp-suppliers-deepseek-flash-1-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-1-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-1-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet-tls.rule=Host(`mp-suppliers-deepseek-flash-1.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-1-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-deepseek-flash-1-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -49,6 +49,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet.rule=Host(`mp-suppliers-deepseek-flash-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet.middlewares=mp-suppliers-deepseek-flash-2-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-2-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-2-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet-tls.rule=Host(`mp-suppliers-deepseek-flash-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-2-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-deepseek-flash-2-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -49,6 +49,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet.rule=Host(`mp-suppliers-deepseek-flash-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet.middlewares=mp-suppliers-deepseek-flash-3-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-3-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-flash-3-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet-tls.rule=Host(`mp-suppliers-deepseek-flash-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-flash-3-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-deepseek-flash-3-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -65,6 +65,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-mainnet.rule=Host(`mp-suppliers-deepseek.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-mainnet.middlewares=mp-suppliers-deepseek-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-deepseek-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-mainnet-tls.rule=Host(`mp-suppliers-deepseek.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-deepseek-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-deepseek-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -58,6 +58,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet.rule=Host(`mp-suppliers-ds-flash-chat-1.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet.middlewares=mp-suppliers-ds-flash-chat-1-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-1-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-1-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet-tls.rule=Host(`mp-suppliers-ds-flash-chat-1.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-1-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-ds-flash-chat-1-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -58,6 +58,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet.rule=Host(`mp-suppliers-ds-flash-chat-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet.middlewares=mp-suppliers-ds-flash-chat-2-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-2-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-2-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet-tls.rule=Host(`mp-suppliers-ds-flash-chat-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-2-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-ds-flash-chat-2-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -58,6 +58,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet.rule=Host(`mp-suppliers-ds-flash-chat-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet.middlewares=mp-suppliers-ds-flash-chat-3-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-3-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-ds-flash-chat-3-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet-tls.rule=Host(`mp-suppliers-ds-flash-chat-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-ds-flash-chat-3-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-ds-flash-chat-3-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -61,6 +61,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-gpt-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-gpt-mainnet.rule=Host(`mp-suppliers-gpt.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-gpt-mainnet.middlewares=mp-suppliers-gpt-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-gpt-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-gpt-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-gpt-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-gpt-mainnet-tls.rule=Host(`mp-suppliers-gpt.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-gpt-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-gpt-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
92
deploy/mainnet/docker-compose.supplier-hf-llama33.yml
Normal file
92
deploy/mainnet/docker-compose.supplier-hf-llama33.yml
Normal file
@@ -0,0 +1,92 @@
|
||||
# deploy/mainnet/docker-compose.supplier-hf-llama33.yml — Supplier — MAINNET
|
||||
# (HuggingFace Inference Providers router / meta-llama/Llama-3.3-70B-Instruct
|
||||
# — one-off `llm.text.generate.v1`)
|
||||
#
|
||||
# HF-router-backed supplier. LLM_BACKEND=openai pointed at the HuggingFace
|
||||
# Inference Providers router (OpenAI-compatible at https://router.huggingface.co).
|
||||
# No local proxy container — reachable over public HTTPS. Direct analogue of the
|
||||
# OpenRouter/Hetzner-backed suppliers; the router is OpenAI-compatible so this is
|
||||
# config only (V1 HF integration).
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS: mp-suppliers-hf-llama33.vector.apexfusion.org → host
|
||||
# (individual DNS-only A record → 91.98.147.172; NO wildcard exists —
|
||||
# letsencrypt HTTP-01 issuance needs the unproxied direct record)
|
||||
# 2. supplier/.env.hf-llama33 written on host (chmod 600) with at minimum:
|
||||
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
|
||||
# SUPPLIER_PUB_KEY_HEX, OPENAI_API_KEY (hf_ token), OGMIOS_URL,
|
||||
# NETWORK_ID=1, VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO,
|
||||
# ADVERT_REF, LIVE_CHAIN=1
|
||||
# Advert posted with --capability-id llm.text.generate.v1.
|
||||
# Do NOT set OPENAI_REASONING — the HF router rejects it (HTTP 400) and the
|
||||
# supplier's config guard refuses to boot.
|
||||
# 3. Networks already exist (created by the canonical supplier compose):
|
||||
# docker network create marketplace-mainnet-net # one-shot
|
||||
# # traefik-net is created by the Traefik project
|
||||
|
||||
name: marketplace-mainnet-supplier-hf-llama33
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-hf-llama33
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
# Self-contained env for this HF-router-backed variant. Holds this
|
||||
# supplier's distinct SUPPLIER_PRIV_KEY_HEX (+ derived wallet vars),
|
||||
# ADVERT_REF, the HF OPENAI_API_KEY, and the shared mainnet plumbing
|
||||
# duplicated in full (NETWORK_ID, OGMIOS_URL, VECTOR_ZERO_TIME_MS,
|
||||
# ref-script UTxOs, LIVE_CHAIN). No env_file ordering / override magic.
|
||||
- path: ../../supplier/.env.hf-llama33
|
||||
required: false
|
||||
environment:
|
||||
LLM_BACKEND: openai
|
||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions
|
||||
# (HF router serves at https://router.huggingface.co/v1/...).
|
||||
OPENAI_BASE_URL: https://router.huggingface.co
|
||||
# First-token latency across hosted providers can exceed local backends;
|
||||
# kept ≤ the advert's max_processing_ms (300000) per fleet footgun #4.
|
||||
OPENAI_TIMEOUT_MS: "300000"
|
||||
# OPENAI_MAX_TOKENS intentionally unset: no max_tokens forwarded upstream,
|
||||
# the provider grants context − prompt. Advert cap = model context length.
|
||||
PORT: "8080"
|
||||
# Enable LiveOgmiosProvider so submitTx/awaitTx actually broadcast.
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||
# come from the operator-provided supplier/.env.hf-llama33 on a mainnet host.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- marketplace-mainnet-net
|
||||
- traefik-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-hf-llama33-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-hf-llama33-mainnet.rule=Host(`mp-suppliers-hf-llama33.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-hf-llama33-mainnet.middlewares=mp-suppliers-hf-llama33-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-hf-llama33-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-hf-llama33-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-hf-llama33-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-hf-llama33-mainnet-tls.rule=Host(`mp-suppliers-hf-llama33.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-hf-llama33-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-hf-llama33-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
marketplace-mainnet-net:
|
||||
external: true
|
||||
name: marketplace-mainnet-net
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
@@ -51,6 +51,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet.rule=Host(`mp-suppliers-kimi-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet.middlewares=mp-suppliers-kimi-2-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-2-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-2-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet-tls.rule=Host(`mp-suppliers-kimi-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-2-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-kimi-2-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -51,6 +51,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet.rule=Host(`mp-suppliers-kimi-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet.middlewares=mp-suppliers-kimi-3-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-3-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-3-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet-tls.rule=Host(`mp-suppliers-kimi-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-3-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-kimi-3-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -51,6 +51,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet.rule=Host(`mp-suppliers-kimi-4.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet.middlewares=mp-suppliers-kimi-4-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-4-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-4-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet-tls.rule=Host(`mp-suppliers-kimi-4.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-4-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-kimi-4-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -62,6 +62,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet.rule=Host(`mp-suppliers-kimi-chat-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet.middlewares=mp-suppliers-kimi-chat-2-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-chat-2-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-chat-2-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet-tls.rule=Host(`mp-suppliers-kimi-chat-2.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-2-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-kimi-chat-2-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -62,6 +62,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet.rule=Host(`mp-suppliers-kimi-chat-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet.middlewares=mp-suppliers-kimi-chat-3-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-chat-3-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-chat-3-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet-tls.rule=Host(`mp-suppliers-kimi-chat-3.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-3-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-kimi-chat-3-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -74,6 +74,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet.rule=Host(`mp-suppliers-kimi-chat.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet.middlewares=mp-suppliers-kimi-chat-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-chat-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-chat-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet-tls.rule=Host(`mp-suppliers-kimi-chat.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-chat-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-kimi-chat-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -75,6 +75,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-mainnet.rule=Host(`mp-suppliers-kimi.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-mainnet.middlewares=mp-suppliers-kimi-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-kimi-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-mainnet-tls.rule=Host(`mp-suppliers-kimi.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-kimi-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-kimi-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
name: marketplace-mainnet-supplier-openrouter-reseller
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
container_name: marketplace-mainnet-openrouter-reseller-postgres
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: /var/lib/agents-marketplace/openrouter-reseller-postgres.env
|
||||
required: true
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 10s
|
||||
volumes:
|
||||
- openrouter-reseller-postgres:/var/lib/postgresql/data
|
||||
networks:
|
||||
- reseller-db
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-openrouter-reseller
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- path: /var/lib/agents-marketplace/openrouter-reseller.env
|
||||
required: true
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
networks:
|
||||
- marketplace-mainnet-net
|
||||
- traefik-net
|
||||
- reseller-db
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-openrouter-reseller-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-openrouter-reseller-mainnet.rule=Host(`mp-suppliers-openrouter-reseller.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-openrouter-reseller-mainnet.middlewares=mp-openrouter-reseller-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-openrouter-reseller-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-openrouter-reseller-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-openrouter-reseller-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-openrouter-reseller-mainnet-tls.rule=Host(`mp-suppliers-openrouter-reseller.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-openrouter-reseller-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-openrouter-reseller-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
openrouter-reseller-postgres:
|
||||
name: marketplace-mainnet-openrouter-reseller-postgres
|
||||
|
||||
networks:
|
||||
reseller-db:
|
||||
internal: true
|
||||
marketplace-mainnet-net:
|
||||
external: true
|
||||
name: marketplace-mainnet-net
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
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
|
||||
103
deploy/mainnet/docker-compose.supplier-qwen38-chat.yml
Normal file
103
deploy/mainnet/docker-compose.supplier-qwen38-chat.yml
Normal file
@@ -0,0 +1,103 @@
|
||||
# deploy/mainnet/docker-compose.supplier-qwen38-chat.yml — Supplier — MAINNET
|
||||
# (Hetzner Inference / Qwen3.8-27B — MULTI-TURN CHAT capability `llm.chat.v1`)
|
||||
#
|
||||
# The chat-session sibling of docker-compose.supplier-qwen38.yml. Same
|
||||
# image, same Hetzner backend (Qwen3.8-27B), but CAPABILITY_KIND=chat-session so
|
||||
# it mounts /v1/chat/{start,message,end} (escrow bookends, off-chain streaming
|
||||
# turns) instead of the one-off /v1/chat/completions route.
|
||||
#
|
||||
# A SEPARATE process/advert from the one-off supplier on purpose: the chat
|
||||
# session holds the supplier's single-slot lock for the whole conversation
|
||||
# (minutes), which would starve one-off traffic if co-hosted.
|
||||
#
|
||||
# One of the Hetzner-backed suppliers (models x {one-off, chat-session});
|
||||
# fleet runbook: docs/HETZNER_SETUP.md. Wallet reused from the retired
|
||||
# kimi-code-chat supplier (Hetzner dropped Kimi-K2.7-Code on 2026-08; advert
|
||||
# retired 2026-08-21, tx 3d1b43645c00bdb7b429f29ced798bc4af206c9dc56074e955d84b603b64f50c).
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS: mp-suppliers-qwen38-chat.vector.apexfusion.org → host
|
||||
# (individual DNS-only A record → 91.98.147.172; NO wildcard exists —
|
||||
# letsencrypt HTTP-01 issuance needs the unproxied direct record)
|
||||
# 2. supplier/.env.qwen38-chat written on host (chmod 600) with at minimum:
|
||||
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
|
||||
# SUPPLIER_PUB_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
|
||||
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
|
||||
# LIVE_CHAIN=1
|
||||
# The advert must be posted with --capability-id llm.chat.v1 and a large
|
||||
# --max-processing-ms (~1800000) so Claim+Submit both land before deliver_by.
|
||||
# 3. Networks already exist (created by the canonical supplier compose):
|
||||
# docker network create marketplace-mainnet-net # one-shot
|
||||
# # traefik-net is created by the Traefik project
|
||||
|
||||
name: marketplace-mainnet-supplier-qwen38-chat
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-qwen38-chat
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
# Self-contained env for this Hetzner-backed variant. Holds this
|
||||
# supplier's distinct SUPPLIER_PRIV_KEY_HEX (+ derived wallet vars),
|
||||
# ADVERT_REF, the shared Hetzner OPENAI_API_KEY, and the shared mainnet
|
||||
# plumbing duplicated in full (NETWORK_ID, OGMIOS_URL, VECTOR_ZERO_TIME_MS,
|
||||
# ref-script UTxOs, LIVE_CHAIN). No env_file ordering / override magic.
|
||||
- path: ../../supplier/.env.qwen38-chat
|
||||
required: false
|
||||
environment:
|
||||
# Multi-turn chat session capability (mounts /v1/chat/{start,message,end}).
|
||||
CAPABILITY_KIND: chat-session
|
||||
LLM_BACKEND: openai
|
||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions
|
||||
# (Hetzner serves at https://inference.hetzner.com/api/v1/...).
|
||||
OPENAI_BASE_URL: https://inference.hetzner.com/api
|
||||
# Reasoning-style models can be slow; mirror the one-off compose.
|
||||
OPENAI_TIMEOUT_MS: "300000"
|
||||
# Idle end (supplier Submits the transcript): single-slot suppliers must
|
||||
# free up quickly (the gateway transparently reopens a session on the
|
||||
# next turn). Mirrors the kimi-chat fleet.
|
||||
CHAT_IDLE_TIMEOUT_MS: "900000"
|
||||
CHAT_SETTLE_MODE: ticket
|
||||
MAX_CHAT_SESSIONS: "8"
|
||||
PORT: "8080"
|
||||
# Enable LiveOgmiosProvider so Claim/Submit actually broadcast.
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||
# come from the operator-provided supplier/.env.qwen38-chat on a mainnet host.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- marketplace-mainnet-net
|
||||
- traefik-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-chat-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-chat-mainnet.rule=Host(`mp-suppliers-qwen38-chat.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-chat-mainnet.middlewares=mp-suppliers-qwen38-chat-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-qwen38-chat-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-qwen38-chat-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-chat-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-chat-mainnet-tls.rule=Host(`mp-suppliers-qwen38-chat.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-chat-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-qwen38-chat-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
marketplace-mainnet-net:
|
||||
external: true
|
||||
name: marketplace-mainnet-net
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
91
deploy/mainnet/docker-compose.supplier-qwen38.yml
Normal file
91
deploy/mainnet/docker-compose.supplier-qwen38.yml
Normal file
@@ -0,0 +1,91 @@
|
||||
# deploy/mainnet/docker-compose.supplier-qwen38.yml — Supplier — MAINNET
|
||||
# (Hetzner Inference / Qwen3.8-27B — one-off `llm.text.generate.v1`)
|
||||
#
|
||||
# Hetzner-backed supplier. LLM_BACKEND=openai pointed directly at Hetzner's
|
||||
# hosted inference API (OpenAI-compatible at https://inference.hetzner.com/api).
|
||||
# No local proxy container — reachable over public HTTPS.
|
||||
#
|
||||
# One of the Hetzner-backed suppliers (models x {one-off, chat-session});
|
||||
# fleet runbook: docs/HETZNER_SETUP.md. Wallet reused from the retired
|
||||
# kimi-code supplier (Hetzner dropped Kimi-K2.7-Code on 2026-08; advert
|
||||
# retired 2026-08-21, tx 3f37082166faf2b2220e6476235bae8723af9a3434a6aaf60277366ff1f62bfb).
|
||||
#
|
||||
# Pre-flight:
|
||||
# 1. DNS: mp-suppliers-qwen38.vector.apexfusion.org → host
|
||||
# (individual DNS-only A record → 91.98.147.172; NO wildcard exists —
|
||||
# letsencrypt HTTP-01 issuance needs the unproxied direct record)
|
||||
# 2. supplier/.env.qwen38 written on host (chmod 600) with at minimum:
|
||||
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
|
||||
# SUPPLIER_PUB_KEY_HEX, OPENAI_API_KEY, OGMIOS_URL, NETWORK_ID=1,
|
||||
# VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO, ADVERT_REF,
|
||||
# LIVE_CHAIN=1
|
||||
# 3. Networks already exist (created by the canonical supplier compose):
|
||||
# docker network create marketplace-mainnet-net # one-shot
|
||||
# # traefik-net is created by the Traefik project
|
||||
|
||||
name: marketplace-mainnet-supplier-qwen38
|
||||
|
||||
services:
|
||||
supplier:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-supplier-qwen38
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
# Self-contained env for this Hetzner-backed variant. Holds this
|
||||
# supplier's distinct SUPPLIER_PRIV_KEY_HEX (+ derived wallet vars),
|
||||
# ADVERT_REF, the shared Hetzner OPENAI_API_KEY, and the shared mainnet
|
||||
# plumbing duplicated in full (NETWORK_ID, OGMIOS_URL, VECTOR_ZERO_TIME_MS,
|
||||
# ref-script UTxOs, LIVE_CHAIN). No env_file ordering / override magic.
|
||||
- path: ../../supplier/.env.qwen38
|
||||
required: false
|
||||
environment:
|
||||
LLM_BACKEND: openai
|
||||
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions
|
||||
# (Hetzner serves at https://inference.hetzner.com/api/v1/...).
|
||||
OPENAI_BASE_URL: https://inference.hetzner.com/api
|
||||
# Reasoning-style models can be slow; matches the advert's
|
||||
# max_processing_ms (300000).
|
||||
OPENAI_TIMEOUT_MS: "300000"
|
||||
# OPENAI_MAX_TOKENS intentionally unset: no max_tokens forwarded upstream,
|
||||
# the provider grants context − prompt. Advert cap = model context length.
|
||||
PORT: "8080"
|
||||
# Enable LiveOgmiosProvider so submitTx/awaitTx actually broadcast.
|
||||
LIVE_CHAIN: "1"
|
||||
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
|
||||
# come from the operator-provided supplier/.env.qwen38 on a mainnet host.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- marketplace-mainnet-net
|
||||
- traefik-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-mainnet.rule=Host(`mp-suppliers-qwen38.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-mainnet.middlewares=mp-suppliers-qwen38-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-qwen38-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-qwen38-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-mainnet-tls.rule=Host(`mp-suppliers-qwen38.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-qwen38-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-qwen38-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
marketplace-mainnet-net:
|
||||
external: true
|
||||
name: marketplace-mainnet-net
|
||||
traefik-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
@@ -65,6 +65,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-tts-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-tts-mainnet.rule=Host(`mp-suppliers-tts.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-tts-mainnet.middlewares=mp-suppliers-tts-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-tts-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-tts-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-tts-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-tts-mainnet-tls.rule=Host(`mp-suppliers-tts.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-tts-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-tts-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
@@ -60,6 +60,12 @@ services:
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-suppliers-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-suppliers-mainnet.rule=Host(`mp-suppliers.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-mainnet.middlewares=mp-suppliers-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-suppliers-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-suppliers-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-suppliers-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-suppliers-mainnet-tls.rule=Host(`mp-suppliers.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-suppliers-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-suppliers-mainnet.loadbalancer.server.port=8080"
|
||||
logging:
|
||||
driver: json-file
|
||||
|
||||
46
deploy/mainnet/docker-compose.surplus-seller.yml
Normal file
46
deploy/mainnet/docker-compose.surplus-seller.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
name: marketplace-mainnet-surplus-seller
|
||||
|
||||
services:
|
||||
surplus-seller:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: supplier/Dockerfile
|
||||
container_name: marketplace-mainnet-surplus-seller
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- node_modules/.bin/tsx
|
||||
- supplier/src/cli/surplus-seller.ts
|
||||
env_file:
|
||||
- path: /var/lib/agents-marketplace/surplus-seller.env
|
||||
required: true
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=64m,mode=1777
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
volumes:
|
||||
- surplus-seller-state:/var/lib/surplus-manager
|
||||
networks:
|
||||
- marketplace-mainnet-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
surplus-seller-state:
|
||||
name: marketplace-mainnet-surplus-seller-state
|
||||
|
||||
networks:
|
||||
marketplace-mainnet-net:
|
||||
external: true
|
||||
name: marketplace-mainnet-net
|
||||
21
deploy/mainnet/openrouter-reseller.config.json
Normal file
21
deploy/mainnet/openrouter-reseller.config.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"networkId": 1,
|
||||
"ogmiosUrl": "https://ogmios.vector.mainnet.apexfusion.org",
|
||||
"endpointUrl": "https://mp-suppliers-openrouter-reseller.vector.apexfusion.org",
|
||||
"marketplaceModel": "deepseek/deepseek-v4-flash",
|
||||
"openRouterModel": "deepseek/deepseek-v4-flash",
|
||||
"maxInputTokens": 8192,
|
||||
"maxOutputTokens": 512,
|
||||
"maxProcessingMs": 600000,
|
||||
"inferenceTimeoutMs": 180000,
|
||||
"priceLovelace": "200000",
|
||||
"supplierBondLovelace": "1000000",
|
||||
"buyerBondLovelace": "1000000",
|
||||
"reserveUsd": "1.00",
|
||||
"pollIntervalMs": 30000,
|
||||
"providerTimeoutMs": 10000,
|
||||
"previewMaxChars": 160,
|
||||
"minimumWalletLovelace": "20000000",
|
||||
"fundingTimeoutMs": 3600000,
|
||||
"composeFile": "deploy/mainnet/docker-compose.supplier-openrouter-reseller.yml"
|
||||
}
|
||||
291
docs/DEPLOY_NEW_SUPPLIER.md
Normal file
291
docs/DEPLOY_NEW_SUPPLIER.md
Normal file
@@ -0,0 +1,291 @@
|
||||
# Deploy a New Supplier — Operator Runbook
|
||||
|
||||
> Audience: anyone who wants to sell inference on the agents-marketplace by
|
||||
> running a **mainnet supplier** on a server they control. Everything below
|
||||
> uses your own hardware, DNS, and funds — no access to the Apex Fusion fleet
|
||||
> is required. (Fleet-internal runbooks: `docs/HETZNER_SETUP.md`,
|
||||
> `deploy/README.md`. A live worked example of exactly this standalone
|
||||
> pattern: `deploy/inference-proxy/README.md`.)
|
||||
|
||||
---
|
||||
|
||||
## 1. What a supplier is
|
||||
|
||||
A supplier is one Docker container that:
|
||||
|
||||
1. Holds an **on-chain advertisement** (a UTxO on the Vector mainnet chain)
|
||||
declaring capability, model, price, bonds, and your endpoint URL.
|
||||
2. Serves a **public HTTPS endpoint** that buyers call after locking payment
|
||||
in escrow. Any hostname you control works — the advert carries the URL,
|
||||
and the marketplace indexer probes it as-is.
|
||||
3. Runs the job against an upstream inference backend and settles on-chain
|
||||
(Claim → Submit; bonds of 1 AP3X ride on each job).
|
||||
|
||||
One supplier = one (model × capability) pair = one wallet = one advert = one
|
||||
compose file. Capabilities: `llm.text.generate.v1` (one-off) or `llm.chat.v1`
|
||||
(multi-turn session, `CAPABILITY_KIND=chat-session`). There is also a TTS
|
||||
kind (`CAPABILITY_KIND=tts`); this runbook covers the LLM kinds.
|
||||
|
||||
## 2. Prerequisites
|
||||
|
||||
| Requirement | Notes |
|
||||
|---|---|
|
||||
| Linux server | Public IPv4, Docker installed, ports 80 + 443 free (for the bundled Traefik). A small VPS is enough — the container only proxies and settles; the heavy compute is upstream. |
|
||||
| DNS name you control | One A record, e.g. `supplier.example.com` → your server IP. Must be **DNS-only / unproxied** — TLS certs come from Let's Encrypt HTTP-01, which a CDN-proxied record breaks. |
|
||||
| Upstream backend | Any OpenAI-compatible API: base URL + API key. Proven in production: hosted APIs (DeepSeek, OpenRouter, Hetzner Inference), local llama.cpp, agent gateways. |
|
||||
| Model id | Must be the **verbatim** upstream id (incl. prefixes like `Qwen/`). Verify against the backend's `/v1/models`. |
|
||||
| Capability | `llm.text.generate.v1` or `llm.chat.v1` (or both → two suppliers, two wallets). |
|
||||
| Advert params | Your call. Reference values used by the Apex Fusion fleet: price `200000` lovelace (0.2 AP3X), bonds `1000000` both sides, `max_processing_ms` `300000` one-off / `1800000` chat, `max_output_tokens` = model context length. |
|
||||
| Funding | ≥50 AP3X mainnet to the new supplier wallet, from your own funds (the fleet typically funds 50–200). Each advert locks a 1 AP3X supplier bond; each job rides another. |
|
||||
|
||||
## 3. Vector mainnet constants (same for every operator)
|
||||
|
||||
These are chain-level values, identical for all suppliers; put them in every
|
||||
supplier env file:
|
||||
|
||||
```
|
||||
NETWORK_ID=1
|
||||
LIVE_CHAIN=1
|
||||
OGMIOS_URL=https://ogmios.vector.mainnet.apexfusion.org
|
||||
VECTOR_ZERO_TIME_MS=1756485600000
|
||||
ESCROW_REF_UTXO=c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#0
|
||||
ADVERT_REF_UTXO=c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#1
|
||||
```
|
||||
|
||||
`OGMIOS_URL` above is a public endpoint; if you run your own Vector node +
|
||||
Ogmios, point at that instead. Never pin `NETWORK_ID` / `OGMIOS_URL` inside
|
||||
the compose file — they must come from the env file (prevents
|
||||
mainnet/testnet cross-wiring on re-up).
|
||||
|
||||
## 4. Procedure
|
||||
|
||||
### 4.1 Smoke-test the backend first (before spending on-chain)
|
||||
|
||||
```bash
|
||||
curl -sS -X POST <BASE_URL>/v1/chat/completions \
|
||||
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
|
||||
-d '{"model":"<MODEL_ID>","messages":[{"role":"user","content":"ping"}]}' \
|
||||
| jq '.choices[0].message.content'
|
||||
```
|
||||
|
||||
Requirements: non-empty string content, **no `max_tokens` sent** (matches
|
||||
runtime behavior). For a chat supplier, also verify a `tools`/`tool_choice`
|
||||
round-trip. A backend that fails these costs you the 1 AP3X supplier bond per
|
||||
failed job (`openai_malformed`).
|
||||
|
||||
### 4.2 Prepare the host
|
||||
|
||||
```bash
|
||||
curl -fsSL https://get.docker.com | sh # if Docker is missing
|
||||
git clone https://github.com/Apex-Fusion/agents-marketplace.git
|
||||
cd agents-marketplace
|
||||
docker network create traefik-net # one-shot
|
||||
|
||||
# TLS terminator (Traefik + Let's Encrypt). One per host; serves every
|
||||
# supplier you run on it.
|
||||
cd deploy/inference-proxy
|
||||
echo 'ACME_EMAIL=<your email>' > .env && chmod 600 .env
|
||||
docker compose -f docker-compose.traefik.yml up -d
|
||||
cd ../..
|
||||
```
|
||||
|
||||
Already have your own reverse proxy? Skip Traefik: terminate TLS for
|
||||
`https://<your-dns-name>` yourself and forward to the supplier container's
|
||||
port 8080, then drop the `traefik` labels and network from the compose file
|
||||
in §4.4.
|
||||
|
||||
### 4.3 DNS
|
||||
|
||||
Create `<your-dns-name>` → A record for your server IP, **DNS-only /
|
||||
unproxied**. Traefik issues the Let's Encrypt cert via HTTP-01 on port 80
|
||||
automatically ~1 min after propagation; no restart needed.
|
||||
|
||||
### 4.4 Compose file
|
||||
|
||||
Copy the standalone-box template and rename its identity strings:
|
||||
|
||||
```bash
|
||||
mkdir -p deploy/self-hosted
|
||||
cp deploy/inference-proxy/docker-compose.supplier-local.yml \
|
||||
deploy/self-hosted/docker-compose.supplier-<name>.yml
|
||||
```
|
||||
|
||||
(The relative paths inside — `build.context: ../..`,
|
||||
`env_file: ../../supplier/.env.<name>` — assume the file sits two directory
|
||||
levels below the repo root.)
|
||||
|
||||
Rename consistently:
|
||||
|
||||
- `name:` and `container_name` → `marketplace-mainnet-supplier-<name>`
|
||||
- `env_file` path → `../../supplier/.env.<name>`
|
||||
- every Traefik label token `mp-suppliers-local-mainnet` → a token of your
|
||||
own (router names must be unique per Traefik instance)
|
||||
- `Host(...)` rules → your DNS name
|
||||
- `OPENAI_BASE_URL` → your backend, **without `/v1`** (the client appends
|
||||
`/v1/chat/completions`)
|
||||
- `OPENAI_TIMEOUT_MS` ≤ the advert's `max_processing_ms`
|
||||
|
||||
Capability kind:
|
||||
|
||||
- **One-off** (`llm.text.generate.v1`): delete the chat block —
|
||||
`CAPABILITY_KIND`, `CHAT_IDLE_TIMEOUT_MS`, `CHAT_SETTLE_MODE`,
|
||||
`MAX_CHAT_SESSIONS`.
|
||||
- **Chat** (`llm.chat.v1`): keep `CAPABILITY_KIND: chat-session`. Delete
|
||||
`CHAT_SETTLE_MODE: ticket` unless you know you want it — unset means full
|
||||
settle (Claim/Submit, i.e. you collect payment on-chain); `ticket` skips
|
||||
all supplier chain ops and the buyer reclaims the escrow.
|
||||
|
||||
The mainnet fleet templates (`deploy/mainnet/docker-compose.supplier-*.yml`)
|
||||
are also valid references, but they attach to `marketplace-mainnet-net`,
|
||||
which does not exist on a standalone box — remove it if you start from one
|
||||
of those.
|
||||
|
||||
Compose files are safe to commit to your fork (no secrets); env files are
|
||||
never committed.
|
||||
|
||||
### 4.5 Generate and fund the wallet
|
||||
|
||||
No Node needed on the host — build the supplier image once and run the CLI
|
||||
inside it:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/self-hosted/docker-compose.supplier-<name>.yml build
|
||||
docker compose -f deploy/self-hosted/docker-compose.supplier-<name>.yml \
|
||||
run --rm --no-deps supplier \
|
||||
node_modules/.bin/tsx supplier/src/cli/gen-keypair.ts --network 1
|
||||
# prints privateKeyHex, publicKeyHex, pubKeyHash, address
|
||||
```
|
||||
|
||||
(With Node + pnpm available locally:
|
||||
`pnpm --filter @marketplace/supplier tx:gen-keypair --network 1`.)
|
||||
|
||||
Fund the printed address from your own wallet (≥50 AP3X). Optimal UTxO shape
|
||||
is **2 UTxOs**: one ≥5 AP3X collateral + one working UTxO. If the wallet ends
|
||||
up fragmented later, run `tx:consolidate-wallet` (can be docker-exec'd inside
|
||||
the running supplier container — it already has the key via env).
|
||||
|
||||
### 4.6 Env file
|
||||
|
||||
```bash
|
||||
cp supplier/.env.example supplier/.env.<name>
|
||||
chmod 600 supplier/.env.<name>
|
||||
```
|
||||
|
||||
Must contain, at minimum:
|
||||
|
||||
- **All four wallet vars**: `SUPPLIER_PRIV_KEY_HEX`, `SUPPLIER_ADDRESS`,
|
||||
`SUPPLIER_PKH`, `SUPPLIER_PUB_KEY_HEX`. They are NOT derived at boot;
|
||||
a missing derived var causes `403 wrong_supplier` on every job.
|
||||
- `OPENAI_API_KEY` (empty is legal only for unauthenticated backends).
|
||||
- The shared plumbing block from §3.
|
||||
- `ADVERT_REF=` left as placeholder until §4.8.
|
||||
|
||||
Backend selection (`LLM_BACKEND=openai`, `OPENAI_BASE_URL`,
|
||||
`OPENAI_TIMEOUT_MS`, `CAPABILITY_KIND`) lives in the compose file (§4.4),
|
||||
not the env file.
|
||||
|
||||
### 4.7 First start
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/self-hosted/docker-compose.supplier-<name>.yml up -d --build
|
||||
```
|
||||
|
||||
Verify before going on-chain:
|
||||
|
||||
- `docker logs marketplace-mainnet-supplier-<name> --tail 50` — no
|
||||
crash-loop.
|
||||
- `curl https://<your-dns-name>/healthz` → `{"ok":true}` (proves DNS, cert
|
||||
issuance, and routing end-to-end).
|
||||
|
||||
Every deploy on your box is manual: `git pull` +
|
||||
`docker compose ... up -d --build`. No CD pipeline covers it.
|
||||
|
||||
### 4.8 Post the advert (go-live — do this LAST)
|
||||
|
||||
The advert is the go-live flag: the moment it confirms, the indexer starts
|
||||
probing `<endpoint>/status` and buyers can lock escrows. Post it only after
|
||||
§4.7 is green, so buyers never hit a dead endpoint.
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/self-hosted/docker-compose.supplier-<name>.yml \
|
||||
run --rm --no-deps supplier \
|
||||
node_modules/.bin/tsx supplier/src/cli/post-advert.ts \
|
||||
--capability-id llm.text.generate.v1 \
|
||||
--model '<MODEL_ID>' \
|
||||
--max-output-tokens <CTX_LEN> \
|
||||
--max-processing-ms 300000 \
|
||||
--price-lovelace 200000 \
|
||||
--endpoint-url https://<your-dns-name>
|
||||
# chat variant: --capability-id llm.chat.v1 --max-processing-ms 1800000
|
||||
```
|
||||
|
||||
Paste the printed `<txHash>#0` into `ADVERT_REF` in `supplier/.env.<name>`,
|
||||
then:
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/self-hosted/docker-compose.supplier-<name>.yml up -d --force-recreate
|
||||
```
|
||||
|
||||
Note: adverts are immutable — changing the model, price, or endpoint later
|
||||
requires `tx:retire-advert` (refunds the advert bond) + a fresh post-advert
|
||||
+ a new `ADVERT_REF`.
|
||||
|
||||
## 5. Verification checklist
|
||||
|
||||
- `curl https://<your-dns-name>/healthz` → `{"ok":true}`.
|
||||
- `/capability` on the same host shows the right model + pkh.
|
||||
- Public marketplace indexer lists your advert:
|
||||
`curl https://mp-indexer.vector.apexfusion.org/suppliers` → your entry with
|
||||
`advert_status: "Active"`, `status: "free"`. The indexer follows the chain,
|
||||
so a confirmed advert appears automatically — no registration step.
|
||||
(The buyer API and OpenAI gateway at `marketplace.vector.apexfusion.org` /
|
||||
`api.marketplace.vector.apexfusion.org` require buyer API keys; buyers with
|
||||
keys will see your model in the gateway's `/openai/v1/models`.)
|
||||
- Tail `docker logs -f marketplace-mainnet-supplier-<name>` through the
|
||||
first paid jobs; watch for `openai_malformed` (reasoning-model
|
||||
content-shape risk) and `403 wrong_supplier` (missing derived wallet vars).
|
||||
|
||||
## 6. Ongoing operations
|
||||
|
||||
- **Update**: `git pull && docker compose -f ... up -d --build`.
|
||||
- **Model swap**: `tx:retire-advert` → post-advert with the new `--model` →
|
||||
new `ADVERT_REF` in the env file → `up -d --force-recreate`.
|
||||
- **Wallet health**: keep the 2-UTxO shape; run `tx:consolidate-wallet` if
|
||||
script-spends start failing with collateral-selector errors.
|
||||
- **Exit**: `tx:retire-advert` refunds the advert bond and delists you; the
|
||||
wallet keeps its funds.
|
||||
- **Monitoring (optional)**: the repo ships a balance monitor
|
||||
(`wallet-monitor/`, `buyer/scripts/monitor-wallets.ts`) you can run against
|
||||
your own wallet list.
|
||||
|
||||
## 7. Footguns (each has burned an operator before)
|
||||
|
||||
1. `OPENAI_BASE_URL` must NOT end in `/v1`.
|
||||
2. Advert `--model` must match the upstream id byte-for-byte; slashes are
|
||||
fine on-chain.
|
||||
3. Do not set `OPENAI_REASONING` unless the backend is OpenRouter.
|
||||
4. `OPENAI_TIMEOUT_MS` > advert `max_processing_ms` = bond-forfeit window.
|
||||
5. Model ids containing `kimi`, `deepseek`, or `gpt` (case-insensitive) are
|
||||
auto-enrolled in the marketplace buyer's PDF-summarizer pool
|
||||
(`buyer/src/pdf/caps.ts`), so expect PDF jobs too. The
|
||||
`PDF_MODEL_ALLOWLIST` / `PDF_MODEL_DENYLIST` overrides are buyer-side —
|
||||
only relevant if you also operate your own buyer.
|
||||
6. `LIVE_CHAIN` accepts only the literal `"1"`; `true`/`yes` silently means
|
||||
off, and the supplier then never broadcasts.
|
||||
7. The compose `env_file` uses `required: false`, so a typo'd env path comes
|
||||
up and crash-loops instead of failing to parse — check logs on first boot.
|
||||
8. Wallet fragmentation (>2 UTxOs, none ≥5 AP3X) breaks all script-spends
|
||||
with a lucid collateral selector error; fix with `tx:consolidate-wallet`.
|
||||
|
||||
## 8. Key references in this repo
|
||||
|
||||
- `deploy/inference-proxy/README.md` — a live standalone box built exactly
|
||||
this way (two suppliers, incl. a tailnet-backed home rig).
|
||||
- `deploy/README.md` — networks, Traefik, per-service ops.
|
||||
- `docs/HETZNER_SETUP.md` — the Apex Fusion fleet runbook (hosted-API
|
||||
suppliers at scale).
|
||||
- `docs/HUGGINGFACE_ROUTER_SETUP.md` — OpenRouter/HF-router backend variant.
|
||||
- `supplier/.env.example` — every env var, annotated (plus `.env.*.example`
|
||||
variants per backend).
|
||||
- `supplier/src/cli/` — `gen-keypair`, `post-advert`, `retire-advert`,
|
||||
`consolidate-wallet`, `publish-reference-scripts`.
|
||||
141
docs/HETZNER_SETUP.md
Normal file
141
docs/HETZNER_SETUP.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Hetzner Inference Supplier Fleet — Setup Runbook
|
||||
|
||||
> **Status:** shipped; fleet trimmed 2026-08-21. Hetzner retired
|
||||
> `Kimi-K2.7-Code`, `GLM-5.2-NVFP4`, and `DeepSeek-V4-Flash-0731` from
|
||||
> Inference (gone from `/v1/models`), so the 6 suppliers serving them were
|
||||
> deregistered (adverts retired on-chain, containers removed). `Qwen3.8-27B`
|
||||
> was added in their place, reusing the retired kimi-code wallets. Configures
|
||||
> mainnet supplier nodes that use **Hetzner Inference**
|
||||
> (`https://inference.hetzner.com`) as their **compute backend** for bonded
|
||||
> inference work commissioned on Vector. Config only — no code changes.
|
||||
|
||||
---
|
||||
|
||||
## 1. What this is
|
||||
|
||||
Hetzner Inference is an OpenAI-compatible hosted API. Because the supplier's
|
||||
upstream client (`supplier/src/openai.ts`) already speaks plain
|
||||
`POST /v1/chat/completions` with a Bearer token, pointing it at Hetzner is
|
||||
**pure configuration**: `LLM_BACKEND=openai` + `OPENAI_BASE_URL` + the API key.
|
||||
This is the same pattern as the DeepSeek-direct and OpenRouter suppliers, and
|
||||
the direct sibling of `docs/HUGGINGFACE_ROUTER_SETUP.md`.
|
||||
|
||||
One supplier per (model × capability) — both the one-off
|
||||
`llm.text.generate.v1` and the multi-turn `llm.chat.v1` capability:
|
||||
|
||||
| Supplier name | Model (on-chain + API param, verbatim) | Capability | Advert max_output_tokens |
|
||||
|---|---|---|---|
|
||||
| `qwen35b` | `Qwen/Qwen3.6-35B-A3B-FP8` | `llm.text.generate.v1` | 262144 |
|
||||
| `qwen35b-chat` | `Qwen/Qwen3.6-35B-A3B-FP8` | `llm.chat.v1` | 262144 |
|
||||
| `qwen38` | `Qwen3.8-27B` | `llm.text.generate.v1` | 262144 |
|
||||
| `qwen38-chat` | `Qwen3.8-27B` | `llm.chat.v1` | 262144 |
|
||||
|
||||
Deregistered 2026-08-21 (models dropped by Hetzner): `kimi-code`,
|
||||
`kimi-code-chat` (`Kimi-K2.7-Code`), `glm`, `glm-chat` (`GLM-5.2-NVFP4`),
|
||||
`ds-flash-htz`, `ds-flash-htz-chat` (`DeepSeek-V4-Flash-0731`). The qwen38
|
||||
pair reuses the kimi-code / kimi-code-chat wallets; the other four wallets
|
||||
still hold funds and their env files remain on the host.
|
||||
|
||||
Shared advert parameters: price **200000 lovelace (0.2 AP3X)** flat per job,
|
||||
bonds **1000000 lovelace (1 AP3X)** both sides, `max_processing_ms` **300000**
|
||||
for one-off / **1800000** for chat (the session spans the whole conversation).
|
||||
`max_output_tokens` is set to the model's context length: no `max_tokens` is
|
||||
forwarded upstream (`OPENAI_MAX_TOKENS` unset), so the advert value only gates
|
||||
what buyers may request.
|
||||
|
||||
Names follow the brand-by-model convention; the retired `ds-flash-htz`
|
||||
carried a `-htz` suffix only because `deepseek-flash-*` was already taken by
|
||||
the OpenRouter fleet serving `deepseek/deepseek-v4-flash`.
|
||||
|
||||
## 2. Prerequisites
|
||||
|
||||
1. A Hetzner account with Inference access and an API key (usage-based billing
|
||||
to you as the node operator — the flat 0.2 AP3X job price is a business
|
||||
choice, not cost-derived). One key is shared by all suppliers; Hetzner
|
||||
rate limits apply to the aggregate.
|
||||
2. One funded supplier wallet per supplier (50 AP3X each recommended; bonds
|
||||
are 1 AP3X per in-flight job and returned on completion).
|
||||
3. The usual mainnet supplier deploy prerequisites (`deploy/README.md`).
|
||||
4. **DNS**: one A record per supplier — `mp-suppliers-<name>.vector.apexfusion.org`
|
||||
→ `91.98.147.172`, **DNS-only/unproxied**. There is NO wildcard for
|
||||
`*.vector.apexfusion.org`; and because traefik issues certs on-box via
|
||||
Let's Encrypt HTTP-01 (port 80), a proxied/CDN record would break issuance.
|
||||
Certs are issued automatically within ~a minute of the record propagating —
|
||||
no restarts needed.
|
||||
|
||||
## 3. Verify the backend before spending on-chain
|
||||
|
||||
The model ids come from `GET https://inference.hetzner.com/api/v1/models`
|
||||
(this list changes — Hetzner retires models; re-check before any advert)
|
||||
and are sent **verbatim** as the upstream `model` param (the runtime uses
|
||||
`advert.model`). Before posting adverts, smoke-test each model:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST https://inference.hetzner.com/api/v1/chat/completions \
|
||||
-H "Authorization: Bearer $HETZNER_KEY" -H "Content-Type: application/json" \
|
||||
-d '{"model":"Qwen3.8-27B","messages":[{"role":"user","content":"ping"}]}' \
|
||||
| jq '.choices[0].message.content'
|
||||
```
|
||||
|
||||
`choices[0].message.content` must be a non-empty string with **no `max_tokens`
|
||||
sent** (matches runtime config) — the supplier throws `openai_malformed`
|
||||
otherwise and forfeits its 1 AP3X bond per failed job. For chat suppliers also
|
||||
verify a `tools`/`tool_choice` round-trip. The original four models passed
|
||||
both checks on 2026-08-11; `Qwen3.8-27B` passed both on 2026-08-21.
|
||||
|
||||
## 4. Per-supplier bring-up (repeat per supplier)
|
||||
|
||||
```bash
|
||||
# 1. wallet — prints privateKeyHex, publicKeyHex, pubKeyHash, address
|
||||
pnpm --filter @marketplace/supplier tx:gen-keypair --network 1
|
||||
# fund the printed address (50 AP3X recommended), then:
|
||||
|
||||
# 2. env file on the mainnet host
|
||||
cp supplier/.env.hetzner.example /root/agents-marketplace/supplier/.env.<name>
|
||||
chmod 600 /root/agents-marketplace/supplier/.env.<name>
|
||||
# fill in: ALL FOUR wallet vars (PRIV_KEY_HEX, ADDRESS, PKH, PUB_KEY_HEX —
|
||||
# they are NOT derived at boot; missing derived vars ⇒ 403 wrong_supplier),
|
||||
# OPENAI_API_KEY, and the ADVERT_* values from the table above.
|
||||
|
||||
# 3. advert (one-off shown; chat: --capability-id llm.chat.v1 --max-processing-ms 1800000)
|
||||
pnpm --filter @marketplace/supplier tx:post-advert \
|
||||
--capability-id llm.text.generate.v1 \
|
||||
--model 'GLM-5.2-NVFP4' \
|
||||
--max-output-tokens 512000 \
|
||||
--max-processing-ms 300000 \
|
||||
--price-lovelace 200000 \
|
||||
--endpoint-url https://mp-suppliers-<name>.vector.apexfusion.org
|
||||
# paste the printed "<txHash>#0" into ADVERT_REF in the env file
|
||||
|
||||
# 4. first start is always manual (CD skips compose projects with no running
|
||||
# containers); subsequent rollouts ride the mainnet CD pipeline
|
||||
docker compose -f deploy/mainnet/docker-compose.supplier-<name>.yml up -d
|
||||
|
||||
# 5. register the wallet for balance alerts
|
||||
# add {name, address} to wallet-monitor/wallets.json on the host
|
||||
```
|
||||
|
||||
## 5. Verification
|
||||
|
||||
- `curl https://mp-suppliers-<name>.vector.apexfusion.org/healthz` → `{"ok":true}`;
|
||||
`/capability` shows the right model + pkh.
|
||||
- Indexer: buyer `GET /v1/indexer/suppliers` lists the new entries with
|
||||
`advert_status: Active`, `status: free`.
|
||||
- Gateway `GET /openai/v1/models` includes the four Hetzner model ids.
|
||||
- Watch `docker logs -f marketplace-mainnet-supplier-<name>` through the first
|
||||
jobs for `openai_malformed` (reasoning-model content-shape risk).
|
||||
|
||||
## 6. Footguns
|
||||
|
||||
- `OPENAI_BASE_URL=https://inference.hetzner.com/api` — **no `/v1`**; the
|
||||
client appends `/v1/chat/completions`.
|
||||
- Do **not** set `OPENAI_REASONING` — `reasoning:{enabled:false}` is an
|
||||
OpenRouter-only param.
|
||||
- The advert `--model` must match the Hetzner id exactly, including the
|
||||
`Qwen/` prefix on `Qwen/Qwen3.6-35B-A3B-FP8` (slash is fine on-chain;
|
||||
precedent `moonshotai/kimi-k2.6`).
|
||||
- `Kimi-K2.7-Code` and `DeepSeek-V4-Flash-0731` auto-join the buyer's PDF pool
|
||||
(case-insensitive substring allowlist `["kimi","deepseek","gpt"]` in
|
||||
`buyer/src/pdf/caps.ts`); GLM and Qwen stay out. Deliberate — adjust
|
||||
`PDF_MODEL_ALLOWLIST`/`PDF_MODEL_DENYLIST` in the buyer env to change it.
|
||||
- `OPENAI_TIMEOUT_MS` must stay ≤ the advert's `max_processing_ms`.
|
||||
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.
|
||||
@@ -22,6 +22,8 @@ RUN apk add --no-cache python3 make g++
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./
|
||||
# pnpm-lock.yaml's patchedDependencies hash the patch files at install time.
|
||||
COPY patches ./patches
|
||||
COPY packages/shared/package.json ./packages/shared/package.json
|
||||
COPY buyer/package.json ./buyer/package.json
|
||||
COPY gateway/package.json ./gateway/package.json
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"start": "tsx src/index.ts",
|
||||
"dev": "tsx src/index.ts",
|
||||
"rotate-master-key": "tsx src/cli/rotate-master-key.ts",
|
||||
"create-demo-key": "tsx src/cli/create-demo-key.ts"
|
||||
"create-demo-key": "tsx src/cli/create-demo-key.ts",
|
||||
"reseller-demo": "tsx src/cli/reseller-demo.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@marketplace/buyer": "workspace:*",
|
||||
|
||||
@@ -19,6 +19,26 @@ import { badRequest, forbidden } from "../openai/errors.js";
|
||||
|
||||
const ACTIVE_ESCROW_STATES = new Set(["Open", "Claimed", "Submitted"]);
|
||||
|
||||
/** Bound on per-request escrow liveness lookups; rows past the cap count as
|
||||
* locked unverified (a key with this many active escrows has bigger problems). */
|
||||
const LIVENESS_CHECK_CAP = 32;
|
||||
|
||||
/** True when the escrow UTxO behind an indexer row is still unspent. The
|
||||
* indexer never updates rows on terminal spends (Accept/Reclaim/Release
|
||||
* consume the escrow without a new escrow output), so "active" rows can be
|
||||
* long spent. Unparseable refs and failed lookups count as live so errors
|
||||
* inflate the locked figure rather than hide funds. */
|
||||
async function escrowUtxoIsLive(deps: GatewayDeps, utxoRef: string): Promise<boolean> {
|
||||
const m = /^([0-9a-fA-F]{64})#(\d+)$/.exec(utxoRef);
|
||||
if (!m) return true;
|
||||
try {
|
||||
const utxo = await deps.chain.queryUtxo({ txHash: m[1], index: Number(m[2]) });
|
||||
return utxo !== null;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function ap3x(lovelace: bigint): string {
|
||||
return (Number(lovelace) / 1e6).toFixed(2);
|
||||
}
|
||||
@@ -74,8 +94,15 @@ export function makeAccountHandler(deps: GatewayDeps) {
|
||||
if (r.ok) {
|
||||
const rows = (await r.json()) as Array<Record<string, unknown>>;
|
||||
if (Array.isArray(rows)) {
|
||||
for (const row of rows) {
|
||||
if (!ACTIVE_ESCROW_STATES.has(String(row.state))) continue;
|
||||
const active = rows.filter((row) => ACTIVE_ESCROW_STATES.has(String(row.state)));
|
||||
const liveness = await Promise.all(
|
||||
active.map((row, i) =>
|
||||
i < LIVENESS_CHECK_CAP ? escrowUtxoIsLive(deps, String(row.utxo_ref ?? "")) : Promise.resolve(true),
|
||||
),
|
||||
);
|
||||
for (let i = 0; i < active.length; i++) {
|
||||
if (!liveness[i]) continue;
|
||||
const row = active[i];
|
||||
lockedInEscrow +=
|
||||
BigInt(String(row.payment_lovelace ?? "0")) +
|
||||
BigInt(String(row.buyer_bond_lovelace ?? "0")) +
|
||||
|
||||
216
gateway/src/cli/reseller-demo.ts
Normal file
216
gateway/src/cli/reseller-demo.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
interface DemoOptions {
|
||||
gatewayUrl: string;
|
||||
dashboardUrl: string;
|
||||
model: string;
|
||||
prompt: string;
|
||||
maxTokens: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
interface PublicJob {
|
||||
escrow_ref: string;
|
||||
status: "settled" | "failed";
|
||||
upstream_cost_usd: string | null;
|
||||
ap3x_payout: string;
|
||||
failure_reason: string | null;
|
||||
}
|
||||
|
||||
export function parseDemoArgs(argv: string[]): DemoOptions {
|
||||
const values: Record<string, string> = {};
|
||||
for (let index = 0; index < argv.length; index += 2) {
|
||||
const flag = argv[index];
|
||||
const value = argv[index + 1];
|
||||
if (!flag?.startsWith("--") || value === undefined) {
|
||||
throw new Error(`invalid demo argument near ${flag ?? "end"}`);
|
||||
}
|
||||
if (
|
||||
flag !== "--gateway-url" &&
|
||||
flag !== "--dashboard-url" &&
|
||||
flag !== "--model" &&
|
||||
flag !== "--prompt" &&
|
||||
flag !== "--max-tokens" &&
|
||||
flag !== "--timeout-ms"
|
||||
) {
|
||||
throw new Error(`unknown demo argument: ${flag}`);
|
||||
}
|
||||
values[flag] = value;
|
||||
}
|
||||
const gatewayUrl = requiredUrl(values["--gateway-url"], "--gateway-url");
|
||||
const dashboardUrl = requiredUrl(values["--dashboard-url"], "--dashboard-url");
|
||||
const model = requiredString(values["--model"], "--model");
|
||||
const prompt = values["--prompt"] ?? "Reply with one sentence that confirms this marketplace job completed.";
|
||||
const maxTokens = positiveInteger(values["--max-tokens"] ?? "64", "--max-tokens");
|
||||
const timeoutMs = positiveInteger(values["--timeout-ms"] ?? "900000", "--timeout-ms");
|
||||
return {
|
||||
gatewayUrl: gatewayUrl.replace(/\/+$/, ""),
|
||||
dashboardUrl: dashboardUrl.replace(/\/+$/, ""),
|
||||
model,
|
||||
prompt,
|
||||
maxTokens,
|
||||
timeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runResellerDemo(
|
||||
options: DemoOptions,
|
||||
gatewayApiKey: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (gatewayApiKey === "") throw new Error("GATEWAY_API_KEY is required");
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), options.timeoutMs);
|
||||
try {
|
||||
const sellerResponse = await fetch(`${options.dashboardUrl}/api/status`, {
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
const sellerBody = await sellerResponse.json().catch(() => null) as
|
||||
| Record<string, unknown>
|
||||
| null;
|
||||
if (!sellerResponse.ok) {
|
||||
throw new Error(
|
||||
`seller status returned HTTP ${sellerResponse.status}`,
|
||||
);
|
||||
}
|
||||
const supplierPkh = sellerBody?.supplier_pkh;
|
||||
if (typeof supplierPkh !== "string" || !/^[0-9a-fA-F]{56}$/.test(supplierPkh)) {
|
||||
throw new Error("seller status omitted a valid supplier_pkh");
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${options.gatewayUrl}/openai/v1/chat/completions`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${gatewayApiKey}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: options.model,
|
||||
messages: [{ role: "user", content: options.prompt }],
|
||||
max_tokens: options.maxTokens,
|
||||
stream: false,
|
||||
public_preview: true,
|
||||
x_vector: { supplier_pkh: supplierPkh },
|
||||
}),
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
const body = await response.json().catch(() => null) as Record<string, unknown> | null;
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`gateway returned HTTP ${response.status}: ${JSON.stringify(body)}`,
|
||||
);
|
||||
}
|
||||
const escrowRef = response.headers.get("x-vector-escrow-ref");
|
||||
if (!escrowRef) throw new Error("gateway response omitted X-Vector-Escrow-Ref");
|
||||
const job = await waitForPublicJob(
|
||||
options.dashboardUrl,
|
||||
escrowRef,
|
||||
options.timeoutMs,
|
||||
controller.signal,
|
||||
);
|
||||
if (job.status === "failed") {
|
||||
throw new Error(`reseller job failed: ${job.failure_reason ?? "unknown"}`);
|
||||
}
|
||||
return {
|
||||
completion_id: body?.id ?? null,
|
||||
escrow_ref: escrowRef,
|
||||
model: body?.model ?? options.model,
|
||||
supplier_pkh: supplierPkh,
|
||||
upstream_cost_usd: job.upstream_cost_usd,
|
||||
ap3x_payout: job.ap3x_payout,
|
||||
dashboard_url: options.dashboardUrl,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPublicJob(
|
||||
dashboardUrl: string,
|
||||
escrowRef: string,
|
||||
timeoutMs: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<PublicJob> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const response = await fetch(`${dashboardUrl}/api/jobs?limit=100`, {
|
||||
cache: "no-store",
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`dashboard jobs returned HTTP ${response.status}`);
|
||||
}
|
||||
const body = await response.json() as { jobs?: PublicJob[] };
|
||||
const job = body.jobs?.find((candidate) => candidate.escrow_ref === escrowRef);
|
||||
if (job) return job;
|
||||
await new Promise<void>((resolveWait) => setTimeout(resolveWait, 2_000));
|
||||
}
|
||||
throw new Error(`timed out waiting for dashboard settlement ${escrowRef}`);
|
||||
}
|
||||
|
||||
function requiredString(value: string | undefined, flag: string): string {
|
||||
if (!value || value.trim() === "") throw new Error(`${flag} is required`);
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function requiredUrl(value: string | undefined, flag: string): string {
|
||||
const raw = requiredString(value, flag);
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
throw new Error(`${flag} must be a valid URL`);
|
||||
}
|
||||
const loopback =
|
||||
url.hostname === "localhost" ||
|
||||
url.hostname === "127.0.0.1" ||
|
||||
url.hostname === "[::1]";
|
||||
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
||||
throw new Error(`${flag} must use HTTPS except on loopback`);
|
||||
}
|
||||
if (
|
||||
url.username !== "" ||
|
||||
url.password !== "" ||
|
||||
url.search !== "" ||
|
||||
url.hash !== ""
|
||||
) {
|
||||
throw new Error(`${flag} must not contain credentials, query, or fragment`);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function positiveInteger(value: string, flag: string): number {
|
||||
if (!/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value))) {
|
||||
throw new Error(`${flag} must be a positive safe integer`);
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
export async function main(
|
||||
argv: string[],
|
||||
env: Record<string, string | undefined>,
|
||||
): Promise<number> {
|
||||
try {
|
||||
const result = await runResellerDemo(
|
||||
parseDemoArgs(argv),
|
||||
env.GATEWAY_API_KEY ?? "",
|
||||
);
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(`reseller demo failed: ${message}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
const invoked = fileURLToPath(import.meta.url) === resolve(process.argv[1] ?? "");
|
||||
if (invoked) {
|
||||
void main(process.argv.slice(2), process.env).then((code) => {
|
||||
process.exitCode = code;
|
||||
});
|
||||
}
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
|
||||
const POS_INT_RE = /^[1-9]\d*$/;
|
||||
const PKH_RE = /^[0-9a-fA-F]{56}$/;
|
||||
|
||||
export interface GatewayConfig {
|
||||
masterKeyHex: string;
|
||||
@@ -53,6 +54,11 @@ export interface GatewayConfig {
|
||||
walletHealthIntervalMs: number;
|
||||
sdkRegistryMax: number;
|
||||
corsOrigins: string[];
|
||||
/** Capability id the /v1/ocr/extract route escrows against. Model-scoped
|
||||
* per the marketplace convention (one model, one id). Env: OCR_CAPABILITY_ID. */
|
||||
ocrCapabilityId: string;
|
||||
/** Seller preferred for matching one-shot models. Other suppliers remain fallback. */
|
||||
preferredSupplierPkh?: string;
|
||||
}
|
||||
|
||||
function requireField(env: Record<string, string | undefined>, name: string): string {
|
||||
@@ -112,6 +118,17 @@ export function loadConfig(env: Record<string, string | undefined>): GatewayConf
|
||||
}
|
||||
networkId = networkIdStr === "1" ? 1 : 0;
|
||||
}
|
||||
const preferredSupplierPkhRaw =
|
||||
(env.GATEWAY_PREFERRED_SUPPLIER_PKH ?? "").trim();
|
||||
if (
|
||||
preferredSupplierPkhRaw !== "" &&
|
||||
!PKH_RE.test(preferredSupplierPkhRaw)
|
||||
) {
|
||||
throw new Error(
|
||||
"loadConfig: GATEWAY_PREFERRED_SUPPLIER_PKH must be a 28-byte hex payment-key hash",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
masterKeyHex,
|
||||
@@ -138,6 +155,10 @@ export function loadConfig(env: Record<string, string | undefined>): GatewayConf
|
||||
chatSettleMode: chatSettleMode(env),
|
||||
walletHealthIntervalMs: posInt(env, "WALLET_HEALTH_INTERVAL_MS", 10 * 60 * 1000),
|
||||
sdkRegistryMax: posInt(env, "SDK_REGISTRY_MAX", 500),
|
||||
ocrCapabilityId: env.OCR_CAPABILITY_ID ?? "ocr.page.extract.chandra-ocr-2.v1",
|
||||
preferredSupplierPkh: preferredSupplierPkhRaw === ""
|
||||
? undefined
|
||||
: preferredSupplierPkhRaw.toLowerCase(),
|
||||
corsOrigins: (env.GATEWAY_CORS_ORIGINS ?? "")
|
||||
.split(",")
|
||||
.map((o) => o.trim().replace(/\/+$/, ""))
|
||||
|
||||
@@ -57,7 +57,10 @@ export function runMain(env: Record<string, string | undefined>): void {
|
||||
const invokedDirectly =
|
||||
typeof process !== "undefined" &&
|
||||
process.argv?.[1] !== undefined &&
|
||||
/gateway\/(src|dist)\/index\.(t|j)s$/.test(process.argv[1]);
|
||||
// Accept both path separators: on Windows process.argv[1] arrives with
|
||||
// backslashes, so a forward-slash-only pattern silently never matches and
|
||||
// the process exits 0 without ever starting the server.
|
||||
/gateway[\\/](src|dist)[\\/]index\.(t|j)s$/.test(process.argv[1]);
|
||||
|
||||
if (invokedDirectly) {
|
||||
try {
|
||||
|
||||
402
gateway/src/ocr/extract.ts
Normal file
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;
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,20 @@
|
||||
* gateway/src/onchain/preflight.ts — balance + collateral pre-flight.
|
||||
*
|
||||
* The buyer wallet funds the FULL escrow at PostEscrow: price + buyer_bond +
|
||||
* supplier_bond (postEscrow.ts:132). Accept/Reclaim are Plutus script-spends
|
||||
* that also need a pure-AP3X collateral UTxO ≥ 5 AP3X (assertCollateralCandidate).
|
||||
* So required = price + buyer_bond + supplier_bond + collateral + fee reserve,
|
||||
* AND the wallet must currently hold a pure-AP3X UTxO ≥ collateral. We check both
|
||||
* up front and return a clean 402 instead of failing mid-flight.
|
||||
* supplier_bond, raised to the Submitted-state min-ada floor so long
|
||||
* capability ids (e.g. the 33-byte OCR one) don't get bumped mid-flight —
|
||||
* see escrowLockFloor (packages/shared/.../minAdaFloor.ts) and
|
||||
* postOcrEscrow.ts:205, the SAME floor a POST actually locks. Accept/Reclaim
|
||||
* are Plutus script-spends that also need a pure-AP3X collateral UTxO ≥ 5
|
||||
* AP3X (assertCollateralCandidate). So required = escrowLockFloor(price +
|
||||
* buyer_bond + supplier_bond) + collateral + fee reserve, AND the wallet
|
||||
* must currently hold a pure-AP3X UTxO ≥ collateral. We check both up front
|
||||
* and return a clean 402 instead of failing mid-flight.
|
||||
*/
|
||||
|
||||
import type { ChainProvider, Utxo } from "@marketplace/shared/chain";
|
||||
import type { EscrowDatum } from "@marketplace/shared/cbor";
|
||||
import { escrowLockFloor } from "@marketplace/shared/tx";
|
||||
|
||||
export const COLLATERAL_MIN_LOVELACE = 5_000_000n;
|
||||
export const FEE_RESERVE_LOVELACE = 2_000_000n;
|
||||
@@ -32,20 +38,48 @@ export interface PreflightResult {
|
||||
collateralOk: boolean;
|
||||
}
|
||||
|
||||
export interface PreflightCost {
|
||||
capabilityId: string;
|
||||
priceLovelace: bigint;
|
||||
buyerBondLovelace: bigint;
|
||||
supplierBondLovelace: bigint;
|
||||
}
|
||||
|
||||
/** Placeholder datum used only to size-estimate the Submitted-state min-ada
|
||||
* floor. Every field except capability_id and the cost amounts is a
|
||||
* fixed-size stand-in (real hash/pkh byte widths, a realistic 13-digit
|
||||
* POSIX-ms timestamp) — capability_id's actual length is what drives the
|
||||
* estimate, exactly as in escrowLockFloor's own contract. */
|
||||
function representativeEscrowDatum(cost: PreflightCost): EscrowDatum {
|
||||
return {
|
||||
buyer_pkh: "3b2fdaed0398485ff35887fb7d0ad2025348864407854f7831dc6a55",
|
||||
supplier_pkh: "401af418a7e6fb106277292881fcdeaa1cc15b5b112e9c8bdb2d46c9",
|
||||
advert_ref: { txHash: "a6".repeat(32), index: 0 },
|
||||
capability_id: cost.capabilityId,
|
||||
request_spec_hash: "1b".repeat(32),
|
||||
prompt_hash: "23".repeat(32),
|
||||
payment_lovelace: cost.priceLovelace,
|
||||
buyer_bond_lovelace: cost.buyerBondLovelace,
|
||||
supplier_bond_lovelace: cost.supplierBondLovelace,
|
||||
deliver_by: 1_786_105_955_257,
|
||||
posted_at: 1_786_105_625_257,
|
||||
submitted_at: null,
|
||||
result_receipt_hash: null,
|
||||
state: "Open",
|
||||
};
|
||||
}
|
||||
|
||||
export async function preflight(
|
||||
chain: ChainProvider,
|
||||
address: string,
|
||||
cost: { priceLovelace: bigint; buyerBondLovelace: bigint; supplierBondLovelace: bigint },
|
||||
cost: PreflightCost,
|
||||
): Promise<PreflightResult> {
|
||||
const utxos = await chain.queryUtxosByAddress(address);
|
||||
const available = totalLovelace(utxos);
|
||||
const collateralOk = hasCollateral(utxos);
|
||||
const required =
|
||||
cost.priceLovelace +
|
||||
cost.buyerBondLovelace +
|
||||
cost.supplierBondLovelace +
|
||||
COLLATERAL_MIN_LOVELACE +
|
||||
FEE_RESERVE_LOVELACE;
|
||||
const economicTotal = cost.priceLovelace + cost.buyerBondLovelace + cost.supplierBondLovelace;
|
||||
const escrowFloor = escrowLockFloor(representativeEscrowDatum(cost), economicTotal);
|
||||
const required = escrowFloor + COLLATERAL_MIN_LOVELACE + FEE_RESERVE_LOVELACE;
|
||||
return {
|
||||
ok: available >= required && collateralOk,
|
||||
availableLovelace: available,
|
||||
|
||||
@@ -96,6 +96,8 @@ async function runOneShot(
|
||||
indexerUrl: config.indexerUrl,
|
||||
model: parsed.model,
|
||||
capabilityId: CAPABILITY,
|
||||
supplierPkh: parsed.supplierPkh,
|
||||
preferredSupplierPkh: config.preferredSupplierPkh,
|
||||
fetchFn,
|
||||
});
|
||||
if (candidates.length === 0) {
|
||||
@@ -152,6 +154,7 @@ async function runOneShot(
|
||||
messages: parsed.messages,
|
||||
payment_lovelace: cand.priceLovelace,
|
||||
max_output_tokens: parsed.maxTokens,
|
||||
public_preview: parsed.publicPreview,
|
||||
});
|
||||
used = cand;
|
||||
break;
|
||||
@@ -167,6 +170,9 @@ async function runOneShot(
|
||||
|
||||
// Settle: resolve the live Submitted ref, Accept, await confirmation.
|
||||
const originalRefStr = `${result.escrowRef.txHash}#${result.escrowRef.index}`;
|
||||
if (!parsed.stream) {
|
||||
res.setHeader("X-Vector-Escrow-Ref", originalRefStr);
|
||||
}
|
||||
const submittedRef = await resolveSubmittedRef({
|
||||
indexerUrl: config.indexerUrl,
|
||||
buyerPkh: ctx.walletKey.pubKeyHash,
|
||||
|
||||
@@ -71,6 +71,22 @@ export function toGatewayError(err: unknown): GatewayError {
|
||||
return new GatewayError(502, "server_error", "indexer_error", `indexer error: ${err.reason}`);
|
||||
}
|
||||
if (err instanceof TxConstructionError) {
|
||||
if (err.reason === "input_cap_exceeded") {
|
||||
return new GatewayError(
|
||||
400,
|
||||
"invalid_request_error",
|
||||
"input_cap_exceeded",
|
||||
err.message,
|
||||
);
|
||||
}
|
||||
if (err.reason === "supplier_preflight_failed") {
|
||||
return new GatewayError(
|
||||
503,
|
||||
"server_error",
|
||||
"supplier_preflight_failed",
|
||||
err.message,
|
||||
);
|
||||
}
|
||||
return new GatewayError(502, "server_error", "escrow_failed", `escrow build failed: ${err.reason}`);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface ParsedChatRequest {
|
||||
stream: boolean;
|
||||
tools?: unknown[];
|
||||
toolChoice?: unknown;
|
||||
publicPreview: boolean;
|
||||
supplierPkh?: string;
|
||||
}
|
||||
|
||||
const ROLES = new Set(["system", "user", "assistant"]);
|
||||
@@ -83,6 +85,46 @@ export function parseChatRequest(body: unknown, opts?: { allowTools?: boolean })
|
||||
maxTokens = mt;
|
||||
}
|
||||
|
||||
if (b.public_preview !== undefined && typeof b.public_preview !== "boolean") {
|
||||
throw badRequest(
|
||||
"invalid_public_preview",
|
||||
"`public_preview` must be a boolean when provided",
|
||||
);
|
||||
}
|
||||
const publicPreview = b.public_preview === true;
|
||||
|
||||
let supplierPkh: string | undefined;
|
||||
if (b.x_vector !== undefined) {
|
||||
if (
|
||||
typeof b.x_vector !== "object" ||
|
||||
b.x_vector === null ||
|
||||
Array.isArray(b.x_vector)
|
||||
) {
|
||||
throw badRequest("invalid_x_vector", "`x_vector` must be an object");
|
||||
}
|
||||
const requested = (b.x_vector as Record<string, unknown>).supplier_pkh;
|
||||
if (
|
||||
requested !== undefined &&
|
||||
(typeof requested !== "string" || !/^[0-9a-fA-F]{56}$/.test(requested))
|
||||
) {
|
||||
throw badRequest(
|
||||
"invalid_supplier_pkh",
|
||||
"`x_vector.supplier_pkh` must be a 28-byte hex payment-key hash",
|
||||
);
|
||||
}
|
||||
if (typeof requested === "string") supplierPkh = requested.toLowerCase();
|
||||
}
|
||||
|
||||
|
||||
const stream = b.stream === true;
|
||||
return { model, messages, maxTokens, stream, tools, toolChoice };
|
||||
return {
|
||||
model,
|
||||
messages,
|
||||
maxTokens,
|
||||
stream,
|
||||
tools,
|
||||
toolChoice,
|
||||
publicPreview,
|
||||
supplierPkh,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,8 +53,15 @@ export function parseRef(ref: string): OutputReference | null {
|
||||
|
||||
export interface SelectSupplierOpts {
|
||||
indexerUrl: string;
|
||||
/** Exact model match; "" matches ANY model under the capability (used by
|
||||
* single-model capabilities like the ocr.* ids, where the capability
|
||||
* already pins the model). */
|
||||
model: string;
|
||||
capabilityId: string;
|
||||
/** Optional exact seller identity pin for deterministic agent routing. */
|
||||
supplierPkh?: string;
|
||||
/** Preferred seller ordered first when it is eligible. */
|
||||
preferredSupplierPkh?: string;
|
||||
fetchFn?: typeof globalThis.fetch;
|
||||
/** supplier_pkhs eligible regardless of polled status — used after the demo
|
||||
* executor frees a supplier the indexer poller (~20 s cadence) still reports
|
||||
@@ -82,7 +89,10 @@ export async function selectCandidates(opts: SelectSupplierOpts): Promise<Suppli
|
||||
const candidates: SupplierCandidate[] = [];
|
||||
for (const raw of body as SupplierView[]) {
|
||||
if (raw.capability_id !== opts.capabilityId) continue;
|
||||
if (raw.model !== opts.model) continue;
|
||||
if (opts.model !== "" && raw.model !== opts.model) continue;
|
||||
if (opts.supplierPkh !== undefined && raw.supplier_pkh !== opts.supplierPkh) {
|
||||
continue;
|
||||
}
|
||||
if (raw.advert_status !== "Active") continue;
|
||||
if (raw.status !== "free" && raw.status !== "unknown" && !opts.ignoreStatusFor?.has(raw.supplier_pkh)) {
|
||||
continue;
|
||||
@@ -103,8 +113,16 @@ export async function selectCandidates(opts: SelectSupplierOpts): Promise<Suppli
|
||||
status: raw.status,
|
||||
});
|
||||
}
|
||||
// Prefer known-free over unknown so we route to confirmed-available first.
|
||||
candidates.sort((a, b) => (a.status === "free" ? 0 : 1) - (b.status === "free" ? 0 : 1));
|
||||
// Prefer the configured seller, then known-free over unknown.
|
||||
candidates.sort((left, right) => {
|
||||
const leftRank =
|
||||
(left.supplierPkh === opts.preferredSupplierPkh ? 0 : 2) +
|
||||
(left.status === "free" ? 0 : 1);
|
||||
const rightRank =
|
||||
(right.supplierPkh === opts.preferredSupplierPkh ? 0 : 2) +
|
||||
(right.status === "free" ? 0 : 1);
|
||||
return leftRank - rightRank;
|
||||
});
|
||||
return candidates;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,19 +15,88 @@ import { open as unseal } from "../crypto/seal.js";
|
||||
import { deriveWalletKey } from "../wallet.js";
|
||||
import type { ApiKeyRow } from "../db/store.js";
|
||||
|
||||
/** Tiny promise-chain mutex: run(fn) executes fns strictly one at a time. */
|
||||
/** Default per-run deadline. Chain reads and tx submits settle in seconds;
|
||||
* anything past this is the 2026-08-12 freeze mode (a fetch that never
|
||||
* settles), not a slow success. */
|
||||
export const DEFAULT_MUTEX_TIMEOUT_MS = 180_000;
|
||||
|
||||
/** Default queue bound per mutex. A healthy key drains its queue in seconds;
|
||||
* a queue this deep means the head is stuck and callers should fail fast. */
|
||||
export const DEFAULT_MUTEX_MAX_QUEUE = 64;
|
||||
|
||||
/** A run() exceeded its deadline and was abandoned so queued work can proceed. */
|
||||
export class MutexTimeoutError extends Error {
|
||||
constructor(label: string, timeoutMs: number) {
|
||||
super(`mutex run '${label}' exceeded ${timeoutMs}ms and was abandoned; queued work proceeds`);
|
||||
this.name = "MutexTimeoutError";
|
||||
}
|
||||
}
|
||||
|
||||
/** run() was refused because the queue is at its bound (head likely stuck). */
|
||||
export class MutexQueueFullError extends Error {
|
||||
constructor(label: string, depth: number) {
|
||||
super(`mutex queue full (${depth} pending) — refusing '${label}'; the head of the queue is likely stuck`);
|
||||
this.name = "MutexQueueFullError";
|
||||
}
|
||||
}
|
||||
|
||||
function runWithDeadline<T>(fn: () => Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
||||
return new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
const timer = setTimeout(() => rejectPromise(new MutexTimeoutError(label, timeoutMs)), timeoutMs);
|
||||
// Node-only nicety: don't hold the process open for a pending deadline.
|
||||
timer.unref?.();
|
||||
fn().then(
|
||||
(v) => {
|
||||
clearTimeout(timer);
|
||||
resolvePromise(v);
|
||||
},
|
||||
(e) => {
|
||||
clearTimeout(timer);
|
||||
rejectPromise(e);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiny promise-chain mutex: run(fn) executes fns strictly one at a time —
|
||||
* now with a per-run deadline and a queue bound (2026-08-12 freeze fix).
|
||||
*
|
||||
* On deadline the stuck fn is NOT cancelled (promises aren't); it is
|
||||
* abandoned: its run() rejects with MutexTimeoutError and the chain advances.
|
||||
* A zombie that settles later can at worst lose a UTxO-contention race with
|
||||
* the run that replaced it — strictly better than the alternative, where one
|
||||
* stuck chain call convoys every subsequent request on the key forever.
|
||||
*/
|
||||
export class Mutex {
|
||||
private tail: Promise<unknown> = Promise.resolve();
|
||||
private depth = 0;
|
||||
|
||||
run<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const result = this.tail.then(fn, fn);
|
||||
// Keep the chain alive even if fn rejects; swallow here so the next
|
||||
// run() isn't poisoned by a prior rejection.
|
||||
this.tail = result.then(
|
||||
constructor(private readonly opts: { timeoutMs?: number; maxQueue?: number } = {}) {}
|
||||
|
||||
run<T>(fn: () => Promise<T>, label = "op"): Promise<T> {
|
||||
const timeoutMs = this.opts.timeoutMs ?? DEFAULT_MUTEX_TIMEOUT_MS;
|
||||
const maxQueue = this.opts.maxQueue ?? DEFAULT_MUTEX_MAX_QUEUE;
|
||||
|
||||
if (this.depth >= maxQueue) {
|
||||
return Promise.reject(new MutexQueueFullError(label, this.depth));
|
||||
}
|
||||
this.depth++;
|
||||
|
||||
// Keep the chain alive even if fn rejects or times out; swallow here so
|
||||
// the next run() isn't poisoned by a prior rejection.
|
||||
const result = this.tail.then(
|
||||
() => runWithDeadline(fn, timeoutMs, label),
|
||||
() => runWithDeadline(fn, timeoutMs, label),
|
||||
);
|
||||
const settled = result.finally(() => {
|
||||
this.depth--;
|
||||
});
|
||||
this.tail = settled.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result;
|
||||
return settled;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ipRateLimit, keyRateLimit, demoIpRateLimit } from "./middleware/rateLim
|
||||
import { sendError } from "./middleware/http.js";
|
||||
import { notFound } from "./openai/errors.js";
|
||||
import { makeChatCompletionsHandler } from "./openai/chatCompletions.js";
|
||||
import { makeOcrExtractHandler } from "./ocr/extract.js";
|
||||
import { makeModelsHandler } from "./openai/models.js";
|
||||
import {
|
||||
makeOpenSessionHandler,
|
||||
@@ -40,6 +41,7 @@ function corsMiddleware(allowOrigins: string[]) {
|
||||
res.setHeader("Vary", "Origin");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
res.setHeader("Access-Control-Expose-Headers", "X-Vector-Escrow-Ref");
|
||||
res.setHeader("Access-Control-Max-Age", "600");
|
||||
}
|
||||
if (req.method === "OPTIONS") {
|
||||
@@ -56,7 +58,11 @@ export function createApp(deps: GatewayDeps): Express {
|
||||
app.set("trust proxy", true);
|
||||
// CORS before body-parsing so preflight OPTIONS short-circuits cheaply.
|
||||
app.use(corsMiddleware(deps.config.corsOrigins));
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
// /v1/ocr carries a base64 page image and mounts its own larger parser on
|
||||
// the route; every other path keeps the 1mb ceiling.
|
||||
const jsonBody = express.json({ limit: "1mb" });
|
||||
app.use((req, res, next) =>
|
||||
req.path.startsWith("/v1/ocr/") ? next() : jsonBody(req, res, next));
|
||||
|
||||
app.get("/healthz", (_req: Request, res: Response) => {
|
||||
res.json({ ok: true });
|
||||
@@ -80,6 +86,12 @@ export function createApp(deps: GatewayDeps): Express {
|
||||
app.post("/account/withdraw", auth, makeWithdrawHandler(deps));
|
||||
|
||||
app.post("/openai/v1/chat/completions", ...gated, makeChatCompletionsHandler(deps));
|
||||
// Model-scoped OCR: 16mb parser covers MAX_OCR_IMAGE_B64_CHARS
|
||||
// (~12M chars ≈ 9 MB binary) plus JSON envelope. Gates (auth + rate
|
||||
// limits) run BEFORE the parser so an unauthenticated caller cannot
|
||||
// buffer/JSON.parse up to 16mb before getting a 401, and rate limiting
|
||||
// can actually throttle the request (2026-08 review finding C2).
|
||||
app.post("/v1/ocr/extract", ...gated, express.json({ limit: "16mb" }), makeOcrExtractHandler(deps));
|
||||
app.get("/openai/v1/models", auth, makeModelsHandler(deps));
|
||||
app.post("/openai/v1/chat/sessions", ...gated, makeOpenSessionHandler(deps));
|
||||
app.post("/openai/v1/chat/sessions/:id/messages", ...gated, makeSessionMessageHandler(deps));
|
||||
|
||||
@@ -46,6 +46,7 @@ function makeDeps(
|
||||
chatSettleMode: overrides?.chatSettleMode ?? "full",
|
||||
walletHealthIntervalMs: 600_000,
|
||||
sdkRegistryMax: 100,
|
||||
ocrCapabilityId: "ocr.page.extract.chandra-ocr-2.v1",
|
||||
corsOrigins: [],
|
||||
};
|
||||
const chain = overrides?.chain ?? ({ queryUtxosByAddress: async () => [] } as unknown as GatewayDeps["chain"]);
|
||||
|
||||
572
gateway/test/ocr.test.ts
Normal file
572
gateway/test/ocr.test.ts
Normal file
@@ -0,0 +1,572 @@
|
||||
/**
|
||||
* gateway/test/ocr.test.ts — POST /v1/ocr/extract route coverage.
|
||||
*
|
||||
* Focus: auth gating, demo-key rejection, body validation, capability-based
|
||||
* supplier lookup, and the enlarged body limit. The settle path (submitOcr →
|
||||
* resolveSubmittedRef → acceptAndConfirm) reuses the one-shot chat plumbing
|
||||
* covered by http.test.ts + the SDK suites; end-to-end runs live on testnet.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { randomBytes, randomUUID } from "crypto";
|
||||
import request from "supertest";
|
||||
import { createApp } from "../src/server.js";
|
||||
import { GatewayStore } from "../src/db/store.js";
|
||||
import { SdkRegistry } from "../src/sdk/registry.js";
|
||||
import { genPrivKeyHex, deriveWalletKey } from "../src/wallet.js";
|
||||
import { seal } from "../src/crypto/seal.js";
|
||||
import { hashApiKey } from "../src/middleware/apiKeyAuth.js";
|
||||
import { venueAttribution } from "../src/ocr/extract.js";
|
||||
import type { GatewayConfig } from "../src/config.js";
|
||||
import type { GatewayDeps } from "../src/deps.js";
|
||||
import { SupplierError } from "@marketplace/buyer/sdk";
|
||||
|
||||
// The one-shot settle step (resolveSubmittedRef → acceptAndConfirm) talks to
|
||||
// the live indexer + chain; the happy-path route test below only needs to
|
||||
// prove that a successful submitOcr reaches the response with the
|
||||
// first-party-brokered disclosure label (I3), so it stubs just those two
|
||||
// chain-touching steps and keeps every other export real.
|
||||
vi.mock("../src/onchain/settle.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../src/onchain/settle.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveSubmittedRef: vi.fn(async () => ({ txHash: "e".repeat(64), index: 0 })),
|
||||
acceptAndConfirm: vi.fn(async () => "f".repeat(64)),
|
||||
};
|
||||
});
|
||||
|
||||
const MASTER = "ab".repeat(32);
|
||||
const IMG = Buffer.from("page bytes").toString("base64");
|
||||
|
||||
/** A funded wallet: covers price+bonds+collateral+fee and has pure-ADA ≥ 5. */
|
||||
const fundedChain = {
|
||||
queryUtxosByAddress: async () => [
|
||||
{ ref: { txHash: "ff".repeat(32), index: 0 }, address: "a", lovelace: 20_000_000n, assets: {}, datumHex: null, scriptRef: null },
|
||||
],
|
||||
} as unknown as GatewayDeps["chain"];
|
||||
|
||||
const OCR_SUPPLIER = {
|
||||
utxo_ref: `${"cc".repeat(32)}#0`,
|
||||
supplier_pkh: "s3",
|
||||
capability_id: "ocr.page.extract.chandra-ocr-2.v1",
|
||||
model: "chandra-ocr-2",
|
||||
max_output_tokens: 4096,
|
||||
price_lovelace: "200000",
|
||||
supplier_bond_lovelace: "1000000",
|
||||
buyer_bond_lovelace: "1000000",
|
||||
endpoint_url: "http://sup",
|
||||
advert_status: "Active",
|
||||
status: "free",
|
||||
};
|
||||
|
||||
function makeDeps(fetchFn?: typeof globalThis.fetch, overrides?: { chain?: GatewayDeps["chain"] }): GatewayDeps {
|
||||
const dbDir = join(tmpdir(), `gw-ocr-test-${randomUUID()}`);
|
||||
const store = new GatewayStore(dbDir);
|
||||
const config: GatewayConfig = {
|
||||
masterKeyHex: MASTER,
|
||||
indexerUrl: "http://ix",
|
||||
ogmiosUrl: "http://og",
|
||||
networkId: 0,
|
||||
liveChain: true,
|
||||
port: 0,
|
||||
dbDir,
|
||||
signupRate: { max: 1000, windowMs: 60_000 },
|
||||
keyRate: { max: 1000, windowMs: 60_000 },
|
||||
demoIpRate: { max: 1000, windowMs: 60_000 },
|
||||
sweeperIntervalMs: 60_000,
|
||||
demoSessionIdleMs: 180_000,
|
||||
chatSettleMode: "full",
|
||||
walletHealthIntervalMs: 600_000,
|
||||
sdkRegistryMax: 100,
|
||||
ocrCapabilityId: "ocr.page.extract.chandra-ocr-2.v1",
|
||||
corsOrigins: [],
|
||||
};
|
||||
const chain = overrides?.chain ?? ({ queryUtxosByAddress: async () => [] } as unknown as GatewayDeps["chain"]);
|
||||
const registry = new SdkRegistry({
|
||||
chain,
|
||||
indexerUrl: config.indexerUrl,
|
||||
networkId: config.networkId,
|
||||
masterKeyHex: config.masterKeyHex,
|
||||
max: config.sdkRegistryMax,
|
||||
});
|
||||
const defaultFetch = (async () => new Response("[]", { status: 200 })) as unknown as typeof globalThis.fetch;
|
||||
return { config, store, chain, registry, fetchFn: fetchFn ?? defaultFetch };
|
||||
}
|
||||
|
||||
/** Insert a key row (demo or funded) and return the raw bearer token. */
|
||||
function makeKey(deps: GatewayDeps, demo: 0 | 1): string {
|
||||
const rawKey = `vk-${randomBytes(24).toString("hex")}`;
|
||||
const privHex = genPrivKeyHex();
|
||||
const walletKey = deriveWalletKey(privHex, 0);
|
||||
const sealed = seal(privHex, MASTER);
|
||||
deps.store.insertKey({
|
||||
id: randomUUID(),
|
||||
key_hash: hashApiKey(rawKey),
|
||||
key_prefix: rawKey.slice(0, 12),
|
||||
label: demo ? "demo" : "funded",
|
||||
wallet_pkh: walletKey.pubKeyHash,
|
||||
deposit_address: walletKey.address,
|
||||
enc_priv_nonce: sealed.nonce,
|
||||
enc_priv_ct: sealed.ct,
|
||||
enc_priv_tag: sealed.tag,
|
||||
master_key_version: 1,
|
||||
created_at: Date.now(),
|
||||
demo,
|
||||
});
|
||||
return rawKey;
|
||||
}
|
||||
|
||||
function validBody() {
|
||||
return { image_b64: IMG, mime: "image/png", output_format: "markdown" };
|
||||
}
|
||||
|
||||
describe("POST /v1/ocr/extract — auth + demo gating", () => {
|
||||
it("401 without a bearer key, OpenAI error shape", async () => {
|
||||
const res = await request(createApp(makeDeps()))
|
||||
.post("/v1/ocr/extract").send(validBody());
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error?.type).toBe("authentication_error");
|
||||
});
|
||||
|
||||
it("403 demo_key_unsupported for demo keys", async () => {
|
||||
const deps = makeDeps();
|
||||
const rawKey = makeKey(deps, 1);
|
||||
const res = await request(createApp(deps))
|
||||
.post("/v1/ocr/extract")
|
||||
.set("Authorization", `Bearer ${rawKey}`)
|
||||
.send(validBody());
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error?.code).toBe("demo_key_unsupported");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /v1/ocr/extract — body validation", () => {
|
||||
async function post(body: unknown) {
|
||||
const deps = makeDeps();
|
||||
const rawKey = makeKey(deps, 0);
|
||||
return request(createApp(deps))
|
||||
.post("/v1/ocr/extract")
|
||||
.set("Authorization", `Bearer ${rawKey}`)
|
||||
.send(body as object);
|
||||
}
|
||||
|
||||
it("400 invalid_image on missing/empty/data-URL image_b64", async () => {
|
||||
for (const image_b64 of [undefined, "", `data:image/png;base64,${IMG}`]) {
|
||||
const res = await post({ ...validBody(), image_b64 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error?.code).toBe("invalid_image");
|
||||
}
|
||||
});
|
||||
|
||||
it("400 invalid_mime on unsupported mime", async () => {
|
||||
const res = await post({ ...validBody(), mime: "image/gif" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error?.code).toBe("invalid_mime");
|
||||
});
|
||||
|
||||
it("400 invalid_output_format on unsupported format", async () => {
|
||||
const res = await post({ ...validBody(), output_format: "yaml" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error?.code).toBe("invalid_output_format");
|
||||
});
|
||||
|
||||
it("output_format defaults to markdown (reaches supplier lookup)", async () => {
|
||||
const res = await post({ image_b64: IMG, mime: "image/png" });
|
||||
// Passes validation, then the empty indexer answer → 404 model_not_found.
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error?.code).toBe("model_not_found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /v1/ocr/extract — supplier lookup + body limit", () => {
|
||||
it("404 model_not_found when no supplier advertises the capability", async () => {
|
||||
const deps = makeDeps();
|
||||
const rawKey = makeKey(deps, 0);
|
||||
const res = await request(createApp(deps))
|
||||
.post("/v1/ocr/extract")
|
||||
.set("Authorization", `Bearer ${rawKey}`)
|
||||
.send(validBody());
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error?.code).toBe("model_not_found");
|
||||
});
|
||||
|
||||
it("queries the indexer with the configured ocr capability id", async () => {
|
||||
let queried = "";
|
||||
const fetchFn = (async (url: unknown) => {
|
||||
queried = String(url);
|
||||
return new Response("[]", { status: 200 });
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
const deps = makeDeps(fetchFn);
|
||||
const rawKey = makeKey(deps, 0);
|
||||
await request(createApp(deps))
|
||||
.post("/v1/ocr/extract")
|
||||
.set("Authorization", `Bearer ${rawKey}`)
|
||||
.send(validBody());
|
||||
expect(queried).toContain("capability_id=ocr.page.extract.chandra-ocr-2.v1");
|
||||
});
|
||||
|
||||
it("accepts a 2MB body (over the 1mb global limit — OCR route has its own parser)", async () => {
|
||||
const deps = makeDeps();
|
||||
const rawKey = makeKey(deps, 0);
|
||||
const big = "A".repeat(2_000_000);
|
||||
const res = await request(createApp(deps))
|
||||
.post("/v1/ocr/extract")
|
||||
.set("Authorization", `Bearer ${rawKey}`)
|
||||
.send({ ...validBody(), image_b64: big });
|
||||
expect(res.status).not.toBe(413);
|
||||
// Valid base64 + no suppliers → 404 model_not_found.
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("chat completions keep the 1mb ceiling (413 on a 2MB body)", async () => {
|
||||
const deps = makeDeps();
|
||||
const rawKey = makeKey(deps, 0);
|
||||
const big = "A".repeat(2_000_000);
|
||||
const res = await request(createApp(deps))
|
||||
.post("/openai/v1/chat/completions")
|
||||
.set("Authorization", `Bearer ${rawKey}`)
|
||||
.send({ model: "qwen", messages: [{ role: "user", content: big }] });
|
||||
expect(res.status).toBe(413);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── venueAttribution — pure-function header sanitization (I3) ──────────────
|
||||
//
|
||||
// grep for "first-party-brokered" used to return only source lines: nothing
|
||||
// exercised venueAttribution's regex gates or RECEIPT_LABELS_V0 reaching the
|
||||
// wire. These two blocks close that gap.
|
||||
|
||||
function fakeReq(headers: Record<string, string>): Parameters<typeof venueAttribution>[0] {
|
||||
return { header: (name: string) => headers[name] } as unknown as Parameters<typeof venueAttribution>[0];
|
||||
}
|
||||
|
||||
describe("venueAttribution — header sanitization", () => {
|
||||
it("passes through a valid venue slug and a valid lowercase hash", () => {
|
||||
const hash = "a".repeat(64);
|
||||
const out = venueAttribution(fakeReq({ "X-Venue": "apify", "X-Run-User-Hash": hash }));
|
||||
expect(out).toEqual({ venue: "apify", runUserHash: hash });
|
||||
});
|
||||
|
||||
it("lowercases a valid but uppercase hex hash", () => {
|
||||
const out = venueAttribution(fakeReq({ "X-Venue": "apify", "X-Run-User-Hash": "A".repeat(64) }));
|
||||
expect(out.runUserHash).toBe("a".repeat(64));
|
||||
});
|
||||
|
||||
it("drops an invalid venue to empty string (uppercase, bad chars, oversized)", () => {
|
||||
for (const bad of ["Apify", "apify!", "apify_venue", "a".repeat(33)]) {
|
||||
const out = venueAttribution(fakeReq({ "X-Venue": bad, "X-Run-User-Hash": "a".repeat(64) }));
|
||||
expect(out.venue).toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
it("drops an invalid run-user-hash to empty string (wrong length, non-hex)", () => {
|
||||
for (const bad of ["a".repeat(63), "a".repeat(65), "z".repeat(64), "not-hex-at-all"]) {
|
||||
const out = venueAttribution(fakeReq({ "X-Venue": "apify", "X-Run-User-Hash": bad }));
|
||||
expect(out.runUserHash).toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults both fields to empty string when headers are missing", () => {
|
||||
const out = venueAttribution(fakeReq({}));
|
||||
expect(out).toEqual({ venue: "", runUserHash: "" });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Happy path — receipt disclosure label reaches the response (I3) ────────
|
||||
|
||||
describe("POST /v1/ocr/extract — happy path", () => {
|
||||
it("200 response x_vector.receipt_labels contains first-party-brokered", async () => {
|
||||
const fetchFn = (async (url: unknown) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/suppliers")) {
|
||||
return new Response(JSON.stringify([OCR_SUPPLIER]), { status: 200 });
|
||||
}
|
||||
return new Response("[]", { status: 200 });
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
|
||||
const deps = makeDeps(fetchFn, { chain: fundedChain });
|
||||
const rawKey = makeKey(deps, 0);
|
||||
const keyRow = deps.store.getKeyByHash(hashApiKey(rawKey))!;
|
||||
|
||||
// Stub only the chain-touching supplier call; escrow ref shape matches
|
||||
// what resolveSubmittedRef's mock (module-level, top of file) expects.
|
||||
const ctx = deps.registry.getContext(keyRow);
|
||||
const submitOcr = vi.fn(async () => ({
|
||||
output_format: "markdown",
|
||||
content: "extracted text",
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
||||
receipt: {
|
||||
prompt_hash: "1b".repeat(32),
|
||||
response_hash: "2c".repeat(32),
|
||||
model: "chandra-ocr-2",
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
wallclock_ms: Date.now(),
|
||||
supplier_pkh: "s3",
|
||||
escrow_ref: `${"d".repeat(64)}#0`,
|
||||
},
|
||||
receiptSignature: "sig".repeat(20),
|
||||
escrowRef: { txHash: "d".repeat(64), index: 0 },
|
||||
}));
|
||||
(ctx.sdk as { submitOcr: unknown }).submitOcr = submitOcr;
|
||||
|
||||
const res = await request(createApp(deps))
|
||||
.post("/v1/ocr/extract")
|
||||
.set("Authorization", `Bearer ${rawKey}`)
|
||||
.send(validBody());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(submitOcr).toHaveBeenCalledTimes(1);
|
||||
expect(res.body.x_vector.receipt_labels).toContain("first-party-brokered");
|
||||
// escrow_ref in the response comes from submitOcr's returned escrowRef
|
||||
// (the ORIGINAL Open ref), not the mocked resolveSubmittedRef's answer —
|
||||
// see settle.ts's docstring on why those two refs differ on-chain.
|
||||
expect(res.body.x_vector.escrow_ref).toBe(`${"d".repeat(64)}#0`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Failure path — escrow ref survives a post-escrow SupplierError (I2) ────
|
||||
//
|
||||
// submitOcr posts the escrow, THEN calls the supplier. A SupplierError
|
||||
// (timeout, network failure, non-2xx, malformed response, receipt mismatch)
|
||||
// means the escrow already landed on chain even though submitOcr rejects —
|
||||
// per the OCR route's 120s supplier budget, this is likely the MOST common
|
||||
// paid-but-failed shape. Before this fix, escrowRefStr was only assigned
|
||||
// from submitOcr's *return value*, so this exact case recorded escrow_ref:
|
||||
// null. The route now also listens for the SDK's "escrow_posted" progress
|
||||
// event around each attempt, so the ref survives regardless of whether
|
||||
// submitOcr goes on to resolve or reject.
|
||||
|
||||
describe("POST /v1/ocr/extract — escrow ref survives a post-escrow SupplierError", () => {
|
||||
it("records the escrow_ref from the escrow_posted progress event, not null, when submitOcr posts an escrow then rejects", async () => {
|
||||
const fetchFn = (async (url: unknown) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/suppliers")) {
|
||||
return new Response(JSON.stringify([OCR_SUPPLIER]), { status: 200 });
|
||||
}
|
||||
return new Response("[]", { status: 200 });
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
|
||||
const deps = makeDeps(fetchFn, { chain: fundedChain });
|
||||
const rawKey = makeKey(deps, 0);
|
||||
const keyRow = deps.store.getKeyByHash(hashApiKey(rawKey))!;
|
||||
|
||||
// Same monkeypatch pattern as the happy-path test above: fetch the real
|
||||
// per-key Marketplace instance from the registry BEFORE the request so
|
||||
// its real EventEmitter (on/off/emit) stays live, then replace only
|
||||
// submitOcr. The stub mirrors what Marketplace.submitOcr actually does
|
||||
// on this failure shape: emit "escrow_posted" the instant the tx
|
||||
// confirms, then throw once the supplier call fails.
|
||||
const ctx = deps.registry.getContext(keyRow);
|
||||
const knownEscrowRef = `${"9".repeat(64)}#0`;
|
||||
const submitOcr = vi.fn(async () => {
|
||||
ctx.sdk.emit("progress", { type: "escrow_posted", escrow_ref: knownEscrowRef });
|
||||
throw new SupplierError("timeout", { message: "supplier did not respond within budget" });
|
||||
});
|
||||
(ctx.sdk as { submitOcr: unknown }).submitOcr = submitOcr;
|
||||
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const res = await request(createApp(deps))
|
||||
.post("/v1/ocr/extract")
|
||||
.set("Authorization", `Bearer ${rawKey}`)
|
||||
.send(validBody());
|
||||
// Snapshot the recorded calls BEFORE restoring — mockRestore() also
|
||||
// clears .mock.calls (it runs mockReset()/mockClear() first), so
|
||||
// reading logSpy.mock.calls after restore always sees [].
|
||||
const loggedLines = logSpy.mock.calls.map((args) => String(args[0]));
|
||||
logSpy.mockRestore();
|
||||
|
||||
expect(submitOcr).toHaveBeenCalledTimes(1);
|
||||
// toGatewayError maps SupplierError("timeout") to 504 supplier_timeout —
|
||||
// confirms the request actually took the failure path this test targets.
|
||||
expect(res.status).toBe(504);
|
||||
expect(res.body.error?.code).toBe("supplier_timeout");
|
||||
|
||||
// The reconciliation spine: the usage row recorded in the catch block
|
||||
// must carry the real ref, not null.
|
||||
const rows = deps.store.listUsage(keyRow.id, 10);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].status).toBe("failed");
|
||||
expect(rows[0].escrow_ref).toBe(knownEscrowRef);
|
||||
|
||||
// The structured ocr_failed log line (wedge-ledger emitter input) must
|
||||
// carry the same ref.
|
||||
const failedLine = loggedLines
|
||||
.filter((line) => line.includes('"evt":"ocr_failed"'))
|
||||
.map((line) => JSON.parse(line) as { escrow_ref: string | null });
|
||||
expect(failedLine).toHaveLength(1);
|
||||
expect(failedLine[0].escrow_ref).toBe(knownEscrowRef);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Busy pre-check - never post an escrow to a candidate that is busy ──────
|
||||
//
|
||||
// runOcrOneShot now checks each candidate's live /status immediately before
|
||||
// calling submitOcr for it (mirrors sessions.ts's startChat pre-check):
|
||||
// "working" means busy and moves on to the next candidate WITHOUT posting an
|
||||
// escrow; anything else (free, unreachable, malformed, slow) is treated as
|
||||
// usable. submitOcr is the escrow-posting path in these tests (same
|
||||
// monkeypatch pattern as the suites above), so asserting its call count is
|
||||
// exactly how "no escrow was posted" is observed.
|
||||
|
||||
const OCR_SUPPLIER_A = {
|
||||
...OCR_SUPPLIER,
|
||||
utxo_ref: `${"aa".repeat(32)}#0`,
|
||||
supplier_pkh: "sA",
|
||||
endpoint_url: "http://sup-a",
|
||||
};
|
||||
const OCR_SUPPLIER_B = {
|
||||
...OCR_SUPPLIER,
|
||||
utxo_ref: `${"bb".repeat(32)}#0`,
|
||||
supplier_pkh: "sB",
|
||||
endpoint_url: "http://sup-b",
|
||||
};
|
||||
|
||||
/** Indexer /suppliers lookup plus a per-endpoint /status responder, on one
|
||||
* fetchFn - everything else falls back to the suite's usual empty array. */
|
||||
function makeFetchWithStatus(
|
||||
suppliers: Array<typeof OCR_SUPPLIER>,
|
||||
statusByEndpoint: Record<string, () => Response>,
|
||||
): typeof globalThis.fetch {
|
||||
return (async (url: unknown) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/suppliers")) {
|
||||
return new Response(JSON.stringify(suppliers), { status: 200 });
|
||||
}
|
||||
for (const [endpoint, respond] of Object.entries(statusByEndpoint)) {
|
||||
if (u === `${endpoint}/status`) return respond();
|
||||
}
|
||||
return new Response("[]", { status: 200 });
|
||||
}) as unknown as typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
/** Same successful submitOcr shape as the happy-path suite above, reused
|
||||
* across the busy pre-check tests. */
|
||||
function makeSubmitOcrStub() {
|
||||
return vi.fn(async (_opts: { advertRef: { txHash: string; index: number } }) => ({
|
||||
output_format: "markdown",
|
||||
content: "extracted text",
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
||||
receipt: {
|
||||
prompt_hash: "1b".repeat(32),
|
||||
response_hash: "2c".repeat(32),
|
||||
model: "chandra-ocr-2",
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
wallclock_ms: Date.now(),
|
||||
supplier_pkh: "s3",
|
||||
escrow_ref: `${"d".repeat(64)}#0`,
|
||||
},
|
||||
receiptSignature: "sig".repeat(20),
|
||||
escrowRef: { txHash: "d".repeat(64), index: 0 },
|
||||
}));
|
||||
}
|
||||
|
||||
describe("POST /v1/ocr/extract - busy pre-check", () => {
|
||||
it("a busy supplier is never sent an escrow; the route returns the busy response", async () => {
|
||||
const fetchFn = makeFetchWithStatus([OCR_SUPPLIER], {
|
||||
"http://sup": () => new Response(JSON.stringify({ status: "working" }), { status: 200 }),
|
||||
});
|
||||
const deps = makeDeps(fetchFn, { chain: fundedChain });
|
||||
const rawKey = makeKey(deps, 0);
|
||||
const keyRow = deps.store.getKeyByHash(hashApiKey(rawKey))!;
|
||||
|
||||
const ctx = deps.registry.getContext(keyRow);
|
||||
const submitOcr = makeSubmitOcrStub();
|
||||
(ctx.sdk as { submitOcr: unknown }).submitOcr = submitOcr;
|
||||
|
||||
const res = await request(createApp(deps))
|
||||
.post("/v1/ocr/extract")
|
||||
.set("Authorization", `Bearer ${rawKey}`)
|
||||
.send(validBody());
|
||||
|
||||
// The escrow-posting path was never invoked.
|
||||
expect(submitOcr).not.toHaveBeenCalled();
|
||||
// toGatewayError maps SupplierError("supplier_busy") to 503 overloaded -
|
||||
// the same busy response a post-escrow 409 would have produced.
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.error?.code).toBe("overloaded");
|
||||
|
||||
const rows = deps.store.listUsage(keyRow.id, 10);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].status).toBe("failed");
|
||||
expect(rows[0].escrow_ref).toBeNull();
|
||||
});
|
||||
|
||||
it("a free supplier proceeds to post and settle exactly as before", async () => {
|
||||
const fetchFn = makeFetchWithStatus([OCR_SUPPLIER], {
|
||||
"http://sup": () => new Response(JSON.stringify({ status: "free" }), { status: 200 }),
|
||||
});
|
||||
const deps = makeDeps(fetchFn, { chain: fundedChain });
|
||||
const rawKey = makeKey(deps, 0);
|
||||
const keyRow = deps.store.getKeyByHash(hashApiKey(rawKey))!;
|
||||
|
||||
const ctx = deps.registry.getContext(keyRow);
|
||||
const submitOcr = makeSubmitOcrStub();
|
||||
(ctx.sdk as { submitOcr: unknown }).submitOcr = submitOcr;
|
||||
|
||||
const res = await request(createApp(deps))
|
||||
.post("/v1/ocr/extract")
|
||||
.set("Authorization", `Bearer ${rawKey}`)
|
||||
.send(validBody());
|
||||
|
||||
expect(submitOcr).toHaveBeenCalledTimes(1);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.x_vector.receipt_labels).toContain("first-party-brokered");
|
||||
});
|
||||
|
||||
it("falls back to the second candidate when the first is busy, posting exactly one escrow", async () => {
|
||||
const fetchFn = makeFetchWithStatus([OCR_SUPPLIER_A, OCR_SUPPLIER_B], {
|
||||
"http://sup-a": () => new Response(JSON.stringify({ status: "working" }), { status: 200 }),
|
||||
"http://sup-b": () => new Response(JSON.stringify({ status: "free" }), { status: 200 }),
|
||||
});
|
||||
const deps = makeDeps(fetchFn, { chain: fundedChain });
|
||||
const rawKey = makeKey(deps, 0);
|
||||
const keyRow = deps.store.getKeyByHash(hashApiKey(rawKey))!;
|
||||
|
||||
const ctx = deps.registry.getContext(keyRow);
|
||||
const submitOcr = makeSubmitOcrStub();
|
||||
(ctx.sdk as { submitOcr: unknown }).submitOcr = submitOcr;
|
||||
|
||||
const res = await request(createApp(deps))
|
||||
.post("/v1/ocr/extract")
|
||||
.set("Authorization", `Bearer ${rawKey}`)
|
||||
.send(validBody());
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Exactly one escrow posted, and it is the second (free) candidate's -
|
||||
// the busy first candidate was skipped, not retried after failing.
|
||||
expect(submitOcr).toHaveBeenCalledTimes(1);
|
||||
expect(submitOcr.mock.calls[0][0].advertRef).toEqual({ txHash: "bb".repeat(32), index: 0 });
|
||||
});
|
||||
|
||||
it("a status check that errors does not block the attempt", async () => {
|
||||
const fetchFn = makeFetchWithStatus([OCR_SUPPLIER], {
|
||||
"http://sup": () => {
|
||||
// Simulates both a network error and an aborted (timed-out) request -
|
||||
// isSupplierBusy's catch block treats every failure mode the same
|
||||
// way: unknown, not busy, proceed. A real AbortSignal timeout is not
|
||||
// exercised here to keep the suite fast; the outcome is identical.
|
||||
throw new Error("simulated network failure reaching /status");
|
||||
},
|
||||
});
|
||||
const deps = makeDeps(fetchFn, { chain: fundedChain });
|
||||
const rawKey = makeKey(deps, 0);
|
||||
const keyRow = deps.store.getKeyByHash(hashApiKey(rawKey))!;
|
||||
|
||||
const ctx = deps.registry.getContext(keyRow);
|
||||
const submitOcr = makeSubmitOcrStub();
|
||||
(ctx.sdk as { submitOcr: unknown }).submitOcr = submitOcr;
|
||||
|
||||
const res = await request(createApp(deps))
|
||||
.post("/v1/ocr/extract")
|
||||
.set("Authorization", `Bearer ${rawKey}`)
|
||||
.send(validBody());
|
||||
|
||||
// The candidate was still tried despite the failed pre-check.
|
||||
expect(submitOcr).toHaveBeenCalledTimes(1);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,7 @@ function makeSweepDeps(opts: { chatSettleMode: "full" | "ticket"; escrows: Escro
|
||||
chatSettleMode: opts.chatSettleMode,
|
||||
walletHealthIntervalMs: 600_000,
|
||||
sdkRegistryMax: 100,
|
||||
ocrCapabilityId: "ocr.page.extract.chandra-ocr-2.v1",
|
||||
corsOrigins: [],
|
||||
};
|
||||
const chain = { queryUtxo: async () => null } as unknown as GatewayDeps["chain"];
|
||||
|
||||
@@ -70,6 +70,28 @@ describe("routing/selectSupplier", () => {
|
||||
expect(got[0].priceLovelace).toBe(2n);
|
||||
});
|
||||
|
||||
it("pins routing to an exact supplier when requested", async () => {
|
||||
const got = await selectCandidates({
|
||||
indexerUrl: "http://ix",
|
||||
model: "m",
|
||||
capabilityId: "llm.text.generate.v1",
|
||||
supplierPkh: "s2",
|
||||
fetchFn: jsonResponse(rows),
|
||||
});
|
||||
expect(got.map((candidate) => candidate.supplierPkh)).toEqual(["s2"]);
|
||||
});
|
||||
|
||||
it("orders the preferred supplier first and keeps fallback candidates", async () => {
|
||||
const got = await selectCandidates({
|
||||
indexerUrl: "http://ix",
|
||||
model: "m",
|
||||
capabilityId: "llm.text.generate.v1",
|
||||
preferredSupplierPkh: "s2",
|
||||
fetchFn: jsonResponse(rows),
|
||||
});
|
||||
expect(got.map((candidate) => candidate.supplierPkh)).toEqual(["s2", "s1"]);
|
||||
});
|
||||
|
||||
it("routes chat.v1 separately", async () => {
|
||||
const got = await selectCandidates({
|
||||
indexerUrl: "http://ix",
|
||||
@@ -118,6 +140,20 @@ describe("openai/validate", () => {
|
||||
expect(p.maxTokens).toBe(50);
|
||||
expect(p.stream).toBe(false);
|
||||
});
|
||||
it("accepts and validates a Vector supplier pin", () => {
|
||||
const supplierPkh = "a".repeat(56);
|
||||
const parsed = parseChatRequest({
|
||||
model: "m",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
x_vector: { supplier_pkh: supplierPkh },
|
||||
});
|
||||
expect(parsed.supplierPkh).toBe(supplierPkh);
|
||||
expect(() => parseChatRequest({
|
||||
model: "m",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
x_vector: { supplier_pkh: "bad" },
|
||||
})).toThrow(GatewayError);
|
||||
});
|
||||
it("rejects tools/functions", () => {
|
||||
expect(() => parseChatRequest({ model: "m", messages: [{ role: "user", content: "x" }], tools: [] })).toThrow(GatewayError);
|
||||
});
|
||||
@@ -208,8 +244,16 @@ describe("onchain/preflight", () => {
|
||||
});
|
||||
|
||||
it("preflight requires balance AND collateral", async () => {
|
||||
const cost = { priceLovelace: 2_000_000n, buyerBondLovelace: 1_000_000n, supplierBondLovelace: 1_000_000n };
|
||||
// required = 2 + 1 + 1 + 5 (collateral) + 2 (fee) = 11 ADA
|
||||
const cost = {
|
||||
capabilityId: "llm.text.generate.v1",
|
||||
priceLovelace: 2_000_000n,
|
||||
buyerBondLovelace: 1_000_000n,
|
||||
supplierBondLovelace: 1_000_000n,
|
||||
};
|
||||
// required = escrowLockFloor(2 + 1 + 1) + 5 (collateral) + 2 (fee) = 11 ADA
|
||||
// — at this economic total (4 ADA) the Submitted-state min-ada floor for a
|
||||
// short capability id (~2.2 ADA) stays well under the raw total, so the
|
||||
// floor is a no-op here; tx-minada-floor.test.ts covers the lifted case.
|
||||
const fakeChain = { queryUtxosByAddress: async () => [pure(20_000_000n)] } as any;
|
||||
const ok = await preflight(fakeChain, "a", cost);
|
||||
expect(ok.ok).toBe(true);
|
||||
|
||||
@@ -29,6 +29,8 @@ RUN apk add --no-cache python3 make g++
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./
|
||||
# pnpm-lock.yaml's patchedDependencies hash the patch files at install time.
|
||||
COPY patches ./patches
|
||||
COPY packages/shared/package.json ./packages/shared/package.json
|
||||
COPY indexer/package.json ./indexer/package.json
|
||||
COPY indexer-ui/package.json ./indexer-ui/package.json
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface PostAdvertParams {
|
||||
walletKey: WalletKey;
|
||||
advertDatum: AdvertDatum;
|
||||
deposit_lovelace: bigint;
|
||||
/** Durable journal hook invoked with signed CBOR before broadcast. */
|
||||
beforeSubmit?: (built: PostAdvertBuildResult) => Promise<void>;
|
||||
}
|
||||
|
||||
export async function buildPostAdvertTx(
|
||||
@@ -75,6 +77,7 @@ export async function buildPostAdvertTx(
|
||||
walletKey,
|
||||
advertDatum,
|
||||
deposit_lovelace,
|
||||
beforeSubmit: params.beforeSubmit,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,13 +105,15 @@ export async function buildPostAdvertTx(
|
||||
|
||||
const txCborHex = encodeTxBody(body);
|
||||
const expectedTxHash = sha256Hex(txCborHex);
|
||||
|
||||
// Submit so the mock seeds the new UTxO + spends inputs (none here).
|
||||
await chain.submitTx(txCborHex);
|
||||
|
||||
return {
|
||||
const built: PostAdvertBuildResult = {
|
||||
txCborHex,
|
||||
expectedTxHash,
|
||||
advertOutputRef: { txHash: expectedTxHash, index: 0 },
|
||||
};
|
||||
await params.beforeSubmit?.(built);
|
||||
|
||||
// Submit so the mock seeds the new UTxO + spends inputs (none here).
|
||||
await chain.submitTx(txCborHex);
|
||||
|
||||
return built;
|
||||
}
|
||||
|
||||
257
packages/shared/src/tx/escrow/postOcrEscrow.ts
Normal file
257
packages/shared/src/tx/escrow/postOcrEscrow.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* tx/escrow/postOcrEscrow.ts — PostEscrow tx builder for model-scoped OCR
|
||||
* capabilities (`ocr.page.extract.<model-slug>.v1`, one page per job).
|
||||
*
|
||||
* Mirrors `postTtsEscrow.ts` but commits an OCR-shaped prompt_hash over the
|
||||
* canonicalised request envelope `{image_b64, mime, output_format}` — the
|
||||
* SAME object the supplier hashes when validating the incoming
|
||||
* POST /v1/ocr/extract body. Any mismatch on either side fails the
|
||||
* supplier-side prompt_mismatch gate.
|
||||
*
|
||||
* The image travels off-chain (buyer → supplier HTTP body); the chain only
|
||||
* ever carries prompt_hash. Never place image bytes or extracted text in a
|
||||
* datum.
|
||||
*
|
||||
* Off-chain invariants (mirror TTS's, with OCR-shaped body validation):
|
||||
* 1. advert UTxO exists at advertRef
|
||||
* 2. advert datum.status === "Active"
|
||||
* 3. payment_lovelace === advert.price_lovelace
|
||||
* 4. buyerKey.pubKeyHash !== advert.supplier_pkh
|
||||
* 5. body.image_b64 is a non-empty plain-base64 string within size bounds
|
||||
* (no data-URL prefix — the mime rides in its own field)
|
||||
* 6. body.mime ∈ ALLOWED_OCR_MIMES
|
||||
* 7. body.output_format ∈ ALLOWED_OCR_OUTPUT_FORMATS
|
||||
*/
|
||||
|
||||
import * as nodeCrypto from "crypto";
|
||||
import type { ChainProvider, OutputReference } from "../../chain/ChainProvider.js";
|
||||
import type { EscrowDatum } from "../../cbor/types.js";
|
||||
import { decodeAdvertDatum } from "../../cbor/AdvertDatum.js";
|
||||
import { encodeEscrowDatum } from "../../cbor/EscrowDatum.js";
|
||||
import { canonicalize } from "../../cbor/canonical.js";
|
||||
import type { WalletKey, PostEscrowBuildResult } from "../types.js";
|
||||
import { TxConstructionError } from "../types.js";
|
||||
import { loadBlueprint } from "../blueprint.js";
|
||||
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
|
||||
import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js";
|
||||
import { detectCborBackend } from "../internal/cborBackend.js";
|
||||
import { escrowLockFloor } from "../internal/minAdaFloor.js";
|
||||
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
||||
|
||||
/** OCR request envelope. The buyer-side hash and the supplier-side hash
|
||||
* MUST agree on this exact set of keys, in this exact JCS-canonical
|
||||
* encoding, or the supplier will reject with `prompt_mismatch`. */
|
||||
export interface OcrRequest {
|
||||
/** Plain base64 of the page image bytes. No `data:` prefix. */
|
||||
image_b64: string;
|
||||
/** Image content type. */
|
||||
mime: string;
|
||||
/** Requested extraction output shape. */
|
||||
output_format: string;
|
||||
}
|
||||
|
||||
export const ALLOWED_OCR_MIMES = new Set([
|
||||
"image/png", "image/jpeg", "image/webp",
|
||||
]);
|
||||
export const ALLOWED_OCR_OUTPUT_FORMATS = new Set(["markdown", "html", "json"]);
|
||||
|
||||
/**
|
||||
* Upper bound on the base64 payload, in characters (~9 MB of image bytes).
|
||||
* A 300-DPI page scan lands around 0.5–3 MB binary; this cap protects the
|
||||
* supplier's JSON body parser and the off-chain transport, not the chain
|
||||
* (the chain only sees the 32-byte hash).
|
||||
*/
|
||||
export const MAX_OCR_IMAGE_B64_CHARS = 12_000_000;
|
||||
|
||||
/** Plain base64 (standard alphabet, optional padding). Rejects data URLs,
|
||||
* whitespace and base64url — one canonical encoding, one hash. */
|
||||
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||
|
||||
export interface PostOcrEscrowParams {
|
||||
chain: ChainProvider;
|
||||
buyerKey: WalletKey;
|
||||
advertRef: OutputReference;
|
||||
request: OcrRequest;
|
||||
payment_lovelace: bigint;
|
||||
}
|
||||
|
||||
function sha256Utf8Hex(s: string): string {
|
||||
return nodeCrypto.createHash("sha256").update(s, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
/** Hash function shared between buyer (here) and supplier (server.ts).
|
||||
* Exported so unit tests on either side can pin the canonicalisation. */
|
||||
export function ocrPromptHash(req: OcrRequest): string {
|
||||
return sha256Utf8Hex(canonicalize({
|
||||
image_b64: req.image_b64,
|
||||
mime: req.mime,
|
||||
output_format: req.output_format,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Body validation shared conceptually with the supplier route. Throws
|
||||
* TxConstructionError with a machine-readable reason. */
|
||||
export function validateOcrRequest(request: OcrRequest): void {
|
||||
if (typeof request?.image_b64 !== "string" || request.image_b64.length === 0) {
|
||||
throw new TxConstructionError("image required",
|
||||
"request.image_b64 must be a non-empty base64 string");
|
||||
}
|
||||
if (request.image_b64.length > MAX_OCR_IMAGE_B64_CHARS) {
|
||||
throw new TxConstructionError("image too large",
|
||||
`request.image_b64 exceeds ${MAX_OCR_IMAGE_B64_CHARS} chars`);
|
||||
}
|
||||
if (!BASE64_RE.test(request.image_b64)) {
|
||||
throw new TxConstructionError("image not base64",
|
||||
"request.image_b64 must be plain base64 (no data: prefix, no whitespace)");
|
||||
}
|
||||
if (!ALLOWED_OCR_MIMES.has(request.mime)) {
|
||||
throw new TxConstructionError("mime invalid",
|
||||
`mime must be one of: ${[...ALLOWED_OCR_MIMES].join(", ")}`);
|
||||
}
|
||||
if (!ALLOWED_OCR_OUTPUT_FORMATS.has(request.output_format)) {
|
||||
throw new TxConstructionError("output_format invalid",
|
||||
`output_format must be one of: ${[...ALLOWED_OCR_OUTPUT_FORMATS].join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildPostOcrEscrowTx(
|
||||
params: PostOcrEscrowParams,
|
||||
): Promise<PostEscrowBuildResult> {
|
||||
const { chain, buyerKey, advertRef, request, payment_lovelace } = params;
|
||||
|
||||
// 5–7. Body validation before any chain access.
|
||||
validateOcrRequest(request);
|
||||
|
||||
// 1. Advert UTxO must exist.
|
||||
const advertUtxo = await chain.queryUtxo(advertRef);
|
||||
if (advertUtxo === null) {
|
||||
throw new TxConstructionError(
|
||||
"advert ref not on chain",
|
||||
`no UTxO at ${advertRef.txHash}#${advertRef.index}`,
|
||||
);
|
||||
}
|
||||
if (!advertUtxo.datumHex) {
|
||||
throw new TxConstructionError(
|
||||
"advert datum missing",
|
||||
`UTxO ${advertRef.txHash}#${advertRef.index} has no inline datum`,
|
||||
);
|
||||
}
|
||||
|
||||
const advertDatum = decodeAdvertDatum(advertUtxo.datumHex);
|
||||
|
||||
// 2. Advert must be Active.
|
||||
if (advertDatum.status !== "Active") {
|
||||
throw new TxConstructionError(
|
||||
"advert is retired",
|
||||
`advert.status is ${advertDatum.status}, expected Active`,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Payment must equal advertised price.
|
||||
if (payment_lovelace !== advertDatum.price_lovelace) {
|
||||
throw new TxConstructionError(
|
||||
"payment must equal advertised price",
|
||||
`payment ${payment_lovelace} != advert.price ${advertDatum.price_lovelace}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Buyer cannot be supplier.
|
||||
if (buyerKey.pubKeyHash === advertDatum.supplier_pkh) {
|
||||
throw new TxConstructionError(
|
||||
"buyer cannot be supplier",
|
||||
`buyerKey pkh ${buyerKey.pubKeyHash} equals advert.supplier_pkh`,
|
||||
);
|
||||
}
|
||||
|
||||
// Time source: same convention as chat/TTS.
|
||||
const tipSlot = await chain.tip();
|
||||
const isLive = detectCborBackend(chain) === "live";
|
||||
const postedAt = isLive ? Date.now() : mockSlotToWallclockMs(tipSlot);
|
||||
const deliverBy = postedAt + advertDatum.max_processing_ms + NETWORK_BUFFER_MS;
|
||||
|
||||
const requestSpecCanonical = canonicalize({
|
||||
capability_id: advertDatum.capability_id,
|
||||
max_output_tokens: advertDatum.max_output_tokens,
|
||||
model: advertDatum.model,
|
||||
});
|
||||
const requestSpecHash = sha256Utf8Hex(requestSpecCanonical);
|
||||
const promptHash = ocrPromptHash(request);
|
||||
|
||||
const economicTotal =
|
||||
advertDatum.price_lovelace +
|
||||
advertDatum.buyer_bond_lovelace +
|
||||
advertDatum.supplier_bond_lovelace;
|
||||
|
||||
const escrowDatum: EscrowDatum = {
|
||||
buyer_pkh: buyerKey.pubKeyHash,
|
||||
supplier_pkh: advertDatum.supplier_pkh,
|
||||
advert_ref: advertRef,
|
||||
capability_id: advertDatum.capability_id,
|
||||
request_spec_hash: requestSpecHash,
|
||||
prompt_hash: promptHash,
|
||||
payment_lovelace: advertDatum.price_lovelace,
|
||||
buyer_bond_lovelace: advertDatum.buyer_bond_lovelace,
|
||||
supplier_bond_lovelace: advertDatum.supplier_bond_lovelace,
|
||||
deliver_by: deliverBy,
|
||||
posted_at: postedAt,
|
||||
submitted_at: null,
|
||||
result_receipt_hash: null,
|
||||
state: "Open",
|
||||
};
|
||||
|
||||
// Lock enough that the escrow output satisfies min-ada in its LARGEST
|
||||
// future state (Submitted) - value_equal on Claim/Submit forbids any
|
||||
// later bump (2026-08-07 min-ada incident).
|
||||
const totalLocked = escrowLockFloor(escrowDatum, economicTotal);
|
||||
|
||||
// Live path: reuse the capability-agnostic live builder (messages is
|
||||
// void-marked there; the on-chain commitment is the escrowDatum, which
|
||||
// carries our OCR prompt_hash).
|
||||
if (isLive) {
|
||||
const liveCborPath = "../internal/liveCbor.js";
|
||||
const { buildLiveTxForEscrow } = await import(/* @vite-ignore */ liveCborPath);
|
||||
return buildLiveTxForEscrow({
|
||||
chain: chain as LiveOgmiosProvider,
|
||||
buyerKey,
|
||||
advertRef,
|
||||
messages: [],
|
||||
escrowDatum,
|
||||
totalLocked,
|
||||
deliverBy,
|
||||
postedAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Mock backend path (synthetic JSON-in-hex tx body).
|
||||
const blueprint = loadBlueprint();
|
||||
const escrowAddress = blueprint.escrowScriptAddress(0);
|
||||
|
||||
const body = {
|
||||
type: "post-escrow",
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{
|
||||
ref: { txHash: "$self", index: 0 },
|
||||
address: escrowAddress,
|
||||
lovelace: totalLocked,
|
||||
assets: {},
|
||||
datumHex: encodeEscrowDatum(escrowDatum),
|
||||
scriptRef: null,
|
||||
},
|
||||
],
|
||||
requiredSigners: [buyerKey.pubKeyHash],
|
||||
validityRange: { lowerBoundMs: postedAt, upperBoundMs: deliverBy },
|
||||
meta: { script_hash: blueprint.escrowScriptHash, advert_ref: advertRef },
|
||||
};
|
||||
|
||||
const txCborHex = encodeTxBody(body);
|
||||
const expectedTxHash = sha256Hex(txCborHex);
|
||||
|
||||
await chain.submitTx(txCborHex);
|
||||
|
||||
return {
|
||||
txCborHex,
|
||||
expectedTxHash,
|
||||
escrowOutputRef: { txHash: expectedTxHash, index: 0 },
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import type { WalletKey, BuildResult } from "../types.js";
|
||||
import { TxConstructionError } from "../types.js";
|
||||
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
|
||||
import { ACCEPT_WINDOW_MS, mockSlotToWallclockMs } from "../internal/constants.js";
|
||||
import { detectCborBackend } from "../internal/cborBackend.js";
|
||||
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
||||
|
||||
export interface ReleaseParams {
|
||||
chain: ChainProvider;
|
||||
@@ -65,8 +67,10 @@ export async function buildReleaseTx(
|
||||
}
|
||||
|
||||
// 4. Tip must be at or past submitted_at + ACCEPT_WINDOW.
|
||||
// Live backend uses real POSIX ms; mock backend keeps slot*1000.
|
||||
const tipSlot = await chain.tip();
|
||||
const tipMs = mockSlotToWallclockMs(tipSlot);
|
||||
const isLive = detectCborBackend(chain) === "live";
|
||||
const tipMs = isLive ? Date.now() : mockSlotToWallclockMs(tipSlot);
|
||||
const threshold = datum.submitted_at + ACCEPT_WINDOW_MS;
|
||||
if (tipMs < threshold) {
|
||||
throw new TxConstructionError(
|
||||
@@ -75,6 +79,21 @@ export async function buildReleaseTx(
|
||||
);
|
||||
}
|
||||
|
||||
// Live path: real Cardano CBOR via lucid-evolution.
|
||||
if (isLive) {
|
||||
const liveCborPath = "../internal/liveCbor.js";
|
||||
const { buildLiveTxForRelease } = await import(/* @vite-ignore */ liveCborPath);
|
||||
return buildLiveTxForRelease({
|
||||
chain: chain as LiveOgmiosProvider,
|
||||
supplierKey,
|
||||
escrowRef,
|
||||
escrowUtxo: utxo,
|
||||
datum,
|
||||
tipMs,
|
||||
});
|
||||
}
|
||||
|
||||
// Mock path: synthetic testTxBody (preserves existing test behaviour).
|
||||
const supplierDue =
|
||||
datum.payment_lovelace + datum.supplier_bond_lovelace + datum.buyer_bond_lovelace;
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface SubmitParams {
|
||||
supplierKey: WalletKey;
|
||||
escrowRef: OutputReference;
|
||||
receiptHash: string;
|
||||
/** Durable journal hook invoked with signed CBOR before broadcast. */
|
||||
beforeSubmit?: (built: BuildResult) => Promise<void>;
|
||||
}
|
||||
|
||||
function isHex32(s: string): boolean {
|
||||
@@ -113,6 +115,7 @@ export async function buildSubmitTx(
|
||||
deliverBy: datum.deliver_by,
|
||||
tipMs,
|
||||
receiptHash: receiptHash.toLowerCase(),
|
||||
beforeSubmit: params.beforeSubmit,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -138,6 +141,7 @@ export async function buildSubmitTx(
|
||||
const txCborHex = encodeTxBody(body);
|
||||
const expectedTxHash = sha256Hex(txCborHex);
|
||||
|
||||
await params.beforeSubmit?.({ txCborHex, expectedTxHash });
|
||||
await chain.submitTx(txCborHex);
|
||||
|
||||
return { txCborHex, expectedTxHash };
|
||||
|
||||
@@ -5,8 +5,13 @@
|
||||
export type { ChatMessage, ToolCall, WalletKey, BuildResult, PostAdvertBuildResult, PostEscrowBuildResult } from "./types.js";
|
||||
export { TxConstructionError } from "./types.js";
|
||||
export { normalizeChatMessage, chatMessagesEquivalent, toolCallsEqual } from "./chatMessage.js";
|
||||
export {
|
||||
BOUNDED_INPUT_DETAIL_MARKER,
|
||||
chatInputTokenUpperBound,
|
||||
} from "./inputBound.js";
|
||||
export { mockSlotToWallclockMs, mockWallclockMsToSlot, NETWORK_BUFFER_MS } from "./internal/constants.js";
|
||||
export { detectCborBackend, type CborBackend } from "./internal/cborBackend.js";
|
||||
export { escrowLockFloor, minAdaForEscrowDatum } from "./internal/minAdaFloor.js";
|
||||
export type { Signer } from "./signer.js";
|
||||
export { MockSigner } from "./signer.js";
|
||||
export type { Blueprint } from "./blueprint.js";
|
||||
@@ -37,6 +42,15 @@ export {
|
||||
} from "./escrow/postTtsEscrow.js";
|
||||
export type { PostChatEscrowParams } from "./escrow/postChatEscrow.js";
|
||||
export { buildPostChatEscrowTx, chatSessionPromptHash } from "./escrow/postChatEscrow.js";
|
||||
export type { PostOcrEscrowParams, OcrRequest } from "./escrow/postOcrEscrow.js";
|
||||
export {
|
||||
buildPostOcrEscrowTx,
|
||||
ocrPromptHash,
|
||||
validateOcrRequest,
|
||||
ALLOWED_OCR_MIMES,
|
||||
ALLOWED_OCR_OUTPUT_FORMATS,
|
||||
MAX_OCR_IMAGE_B64_CHARS,
|
||||
} from "./escrow/postOcrEscrow.js";
|
||||
export type { ClaimParams } from "./escrow/claim.js";
|
||||
export { buildClaimTx } from "./escrow/claim.js";
|
||||
export type { SubmitParams } from "./escrow/submit.js";
|
||||
|
||||
26
packages/shared/src/tx/inputBound.ts
Normal file
26
packages/shared/src/tx/inputBound.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ChatMessage } from "./types.js";
|
||||
import { canonicalize } from "../cbor/canonical.js";
|
||||
|
||||
/** Advert detail-URI marker for suppliers that require live input-cap preflight. */
|
||||
export const BOUNDED_INPUT_DETAIL_MARKER = "#vector-bounded-input-v1";
|
||||
|
||||
const CHAT_MESSAGE_OVERHEAD = 8;
|
||||
const CHAT_REPLY_PRIMER = 3;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Conservative, tokenizer-independent upper bound for text chat input.
|
||||
*
|
||||
* Every UTF-8 byte counts as one token unit. The fixed per-message and reply
|
||||
* overhead covers role separators and the provider chat template. Major
|
||||
* byte-fallback tokenizers cannot emit more content tokens than input bytes.
|
||||
*/
|
||||
export function chatInputTokenUpperBound(
|
||||
messages: readonly ChatMessage[],
|
||||
): number {
|
||||
return (
|
||||
CHAT_REPLY_PRIMER +
|
||||
CHAT_MESSAGE_OVERHEAD * messages.length +
|
||||
encoder.encode(canonicalize(messages)).byteLength
|
||||
);
|
||||
}
|
||||
@@ -10,8 +10,8 @@
|
||||
* for vkh enterprise). Input UTxOs may carry testnet-prefixed addresses — lucid
|
||||
* does not validate address network for inputs (only for outputs).
|
||||
*
|
||||
* Deferred builders (postAdvert, updateAdvert, retireAdvert, reclaim, release)
|
||||
* are NOT touched in M1-F-4. Those continue using the synthetic testTxBody path.
|
||||
* Deferred builders: only updateAdvert remains on the synthetic testTxBody
|
||||
* path. postAdvert, retireAdvert, reclaim, and release all have live builders.
|
||||
*
|
||||
* Catherine M1-F-4-green.
|
||||
*/
|
||||
@@ -61,6 +61,7 @@ const REDEEMER_CLAIM = 121;
|
||||
const REDEEMER_SUBMIT = 122;
|
||||
const REDEEMER_ACCEPT = 123;
|
||||
const REDEEMER_RECLAIM = 124;
|
||||
const REDEEMER_RELEASE = 125;
|
||||
|
||||
// AdvertRedeemer Constr indices per lib/marketplace/types.ak:
|
||||
// PostAdvert → Constr0 (tag 121, creation event — never seen by spend)
|
||||
@@ -100,6 +101,7 @@ export interface LiveSubmitParams {
|
||||
deliverBy: number;
|
||||
tipMs: number;
|
||||
receiptHash: string;
|
||||
beforeSubmit?: (built: BuildResult) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface LiveAcceptParams {
|
||||
@@ -121,11 +123,21 @@ export interface LiveReclaimParams {
|
||||
tipMs: number;
|
||||
}
|
||||
|
||||
export interface LiveReleaseParams {
|
||||
chain: LiveOgmiosProvider;
|
||||
supplierKey: WalletKey;
|
||||
escrowRef: OutputReference;
|
||||
escrowUtxo: Utxo;
|
||||
datum: EscrowDatum;
|
||||
tipMs: number;
|
||||
}
|
||||
|
||||
export interface LivePostAdvertParams {
|
||||
chain: LiveOgmiosProvider;
|
||||
walletKey: WalletKey;
|
||||
advertDatum: AdvertDatum;
|
||||
deposit_lovelace: bigint;
|
||||
beforeSubmit?: (built: PostAdvertBuildResult) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface LiveRetireAdvertParams {
|
||||
@@ -489,6 +501,7 @@ async function buildSpendOpenOrClaimedTx(opts: {
|
||||
validFromMs: number;
|
||||
validToMs: number;
|
||||
signerPkh: string;
|
||||
beforeSubmit?: (built: BuildResult) => Promise<void>;
|
||||
}): Promise<BuildResult> {
|
||||
const provider = buildLucidProvider(opts.chain);
|
||||
const { lucid } = await createLucidContext(provider, opts.walletKey, {
|
||||
@@ -575,6 +588,7 @@ async function buildSpendOpenOrClaimedTx(opts: {
|
||||
|
||||
const txCborHex = signed.toCBOR();
|
||||
const expectedTxHash = signed.toHash();
|
||||
await opts.beforeSubmit?.({ txCborHex, expectedTxHash });
|
||||
|
||||
await opts.chain.submitTx(txCborHex);
|
||||
|
||||
@@ -640,6 +654,7 @@ export async function buildLiveTxForSubmit(params: LiveSubmitParams): Promise<Bu
|
||||
validFromMs: tipMs,
|
||||
validToMs: stampMs,
|
||||
signerPkh: params.supplierKey.pubKeyHash,
|
||||
beforeSubmit: params.beforeSubmit,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -778,6 +793,77 @@ export async function buildLiveTxForReclaim(params: LiveReclaimParams): Promise<
|
||||
return { txCborHex, expectedTxHash };
|
||||
}
|
||||
|
||||
// ─── Release ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function buildLiveTxForRelease(params: LiveReleaseParams): Promise<BuildResult> {
|
||||
const provider = buildLucidProvider(params.chain);
|
||||
const { lucid } = await createLucidContext(provider, params.supplierKey, {
|
||||
networkId: 1,
|
||||
systemStartUnix: 0,
|
||||
slotLengthMs: 1000,
|
||||
}, { usePresetProtocolParameters: true });
|
||||
|
||||
const { script, address: escrowAddress } = loadEscrowScript();
|
||||
const spendCtx = await resolveSpendCtx(lucid, "ESCROW_REF_UTXO", script);
|
||||
const lucidInput = chainUtxoToLucidScriptInput(params.escrowUtxo, escrowAddress);
|
||||
const releaseRedeemerHex = encodeNullaryConstrHex(REDEEMER_RELEASE);
|
||||
|
||||
if (params.datum.submitted_at === null) {
|
||||
throw new TxConstructionError(
|
||||
"submitted_at missing",
|
||||
"Submitted-state escrow must carry a submitted_at timestamp",
|
||||
);
|
||||
}
|
||||
|
||||
// Release pays everything to the supplier: payment + supplier_bond + buyer_bond.
|
||||
const supplierDue =
|
||||
params.datum.payment_lovelace +
|
||||
params.datum.supplier_bond_lovelace +
|
||||
params.datum.buyer_bond_lovelace;
|
||||
const supplierLovelace = supplierDue < MIN_UTXO_LOVELACE ? MIN_UTXO_LOVELACE : supplierDue;
|
||||
const supplierAddress = pkhToEnterpriseAddress(params.datum.supplier_pkh, MAINNET_ID);
|
||||
|
||||
const realWalletUtxos = await lucid.wallet().getUtxos();
|
||||
assertCollateralCandidate(realWalletUtxos);
|
||||
const presetWalletInputs = realWalletUtxos;
|
||||
|
||||
// escrow.ak:handle_release checks `lower_bound >= submitted_at + ACCEPT_WINDOW`.
|
||||
// Clamp validFrom to that threshold: the Reclaim-style tip-60s back-pad alone
|
||||
// would violate the validator whenever the tip is less than 60s past the window.
|
||||
const threshold = params.datum.submitted_at + ACCEPT_WINDOW_MS;
|
||||
const validFromMs = Math.max(threshold, params.tipMs - 60_000);
|
||||
|
||||
let signed;
|
||||
try {
|
||||
const txBuilder = (
|
||||
spendCtx.kind === "ref"
|
||||
? lucid.newTx().readFrom([spendCtx.refUtxo])
|
||||
: lucid.newTx().attach.SpendingValidator(spendCtx.script)
|
||||
)
|
||||
.collectFrom([lucidInput], releaseRedeemerHex)
|
||||
.pay.ToAddress(supplierAddress, { lovelace: supplierLovelace })
|
||||
.addSignerKey(params.supplierKey.pubKeyHash)
|
||||
.validFrom(validFromMs)
|
||||
.validTo(params.tipMs + 60_000)
|
||||
.setMinFee(500_000n);
|
||||
|
||||
// Same UPLC eval delegation as Accept/Reclaim — lucid's CML evaluator
|
||||
// falsely rejects valid Plutus V3 spends; route to Ogmios.
|
||||
const completed = await txBuilder.complete({
|
||||
presetWalletInputs,
|
||||
localUPLCEval: false,
|
||||
});
|
||||
signed = await completed.sign.withWallet().complete();
|
||||
} catch (err) {
|
||||
rethrowAsTxError(err, "release build failed");
|
||||
}
|
||||
|
||||
const txCborHex = signed.toCBOR();
|
||||
const expectedTxHash = signed.toHash();
|
||||
await params.chain.submitTx(txCborHex);
|
||||
return { txCborHex, expectedTxHash };
|
||||
}
|
||||
|
||||
// ─── PostAdvert ──────────────────────────────────────────────────────
|
||||
|
||||
export async function buildLiveTxForAdvert(
|
||||
@@ -829,14 +915,16 @@ export async function buildLiveTxForAdvert(
|
||||
|
||||
const txCborHex = signed.toCBOR();
|
||||
const expectedTxHash = signed.toHash();
|
||||
|
||||
await chain.submitTx(txCborHex);
|
||||
|
||||
return {
|
||||
const built: PostAdvertBuildResult = {
|
||||
txCborHex,
|
||||
expectedTxHash,
|
||||
advertOutputRef: { txHash: expectedTxHash, index: 0 },
|
||||
};
|
||||
await params.beforeSubmit?.(built);
|
||||
|
||||
await chain.submitTx(txCborHex);
|
||||
|
||||
return built;
|
||||
}
|
||||
|
||||
// ─── RetireAdvert ────────────────────────────────────────────────────
|
||||
|
||||
@@ -45,3 +45,16 @@ export type {
|
||||
WithdrawWalletBuildResult,
|
||||
} from "./wallet/withdrawWallet.js";
|
||||
export { buildWithdrawTx } from "./wallet/withdrawWallet.js";
|
||||
|
||||
// ── Surplus Merkle-batch anchor (live-only) ────────────────────────────
|
||||
export type {
|
||||
AnchorMetadataParams,
|
||||
SurplusSaleProofAnchorMetadata,
|
||||
} from "./wallet/anchorMetadata.js";
|
||||
export {
|
||||
MAX_TRANSACTION_METADATA_TEXT_BYTES,
|
||||
SURPLUS_SALE_PROOF_METADATA_LABEL,
|
||||
SURPLUS_SALE_PROOF_PROTOCOL,
|
||||
buildAnchorMetadataTx,
|
||||
validateSurplusSaleProofAnchorMetadata,
|
||||
} from "./wallet/anchorMetadata.js";
|
||||
|
||||
166
packages/shared/src/tx/wallet/anchorMetadata.ts
Normal file
166
packages/shared/src/tx/wallet/anchorMetadata.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Builds and submits the live wallet transaction that anchors one Surplus sale
|
||||
* Merkle batch. The caller remains responsible for awaiting confirmation.
|
||||
*/
|
||||
|
||||
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
||||
import { OgmiosLucidProvider } from "../../chain/OgmiosLucidProvider.js";
|
||||
import { createLucidContext } from "../internal/lucidContext.js";
|
||||
import type { BuildResult, WalletKey } from "../types.js";
|
||||
import { TxConstructionError } from "../types.js";
|
||||
|
||||
export const SURPLUS_SALE_PROOF_METADATA_LABEL = 674;
|
||||
export const SURPLUS_SALE_PROOF_PROTOCOL = "surplus-sale-proof-v1";
|
||||
export const MAX_TRANSACTION_METADATA_TEXT_BYTES = 64;
|
||||
|
||||
export interface SurplusSaleProofAnchorMetadata {
|
||||
p: typeof SURPLUS_SALE_PROOF_PROTOCOL;
|
||||
root: string;
|
||||
count: number;
|
||||
first: string;
|
||||
last: string;
|
||||
}
|
||||
|
||||
export interface AnchorMetadataParams {
|
||||
chain: LiveOgmiosProvider;
|
||||
walletKey: WalletKey;
|
||||
metadata: SurplusSaleProofAnchorMetadata;
|
||||
}
|
||||
|
||||
const ROOT_HEX_RE = /^[0-9a-f]{64}$/;
|
||||
const PRINTABLE_ASCII_RE = /^[\x20-\x7e]+$/;
|
||||
const METADATA_KEYS = ["p", "root", "count", "first", "last"] as const;
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
function metadataTextFits(value: string): boolean {
|
||||
return textEncoder.encode(value).byteLength <= MAX_TRANSACTION_METADATA_TEXT_BYTES;
|
||||
}
|
||||
|
||||
function validSaleId(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
PRINTABLE_ASCII_RE.test(value) &&
|
||||
metadataTextFits(value)
|
||||
);
|
||||
}
|
||||
|
||||
/** Validate the exact label-674 payload before Lucid or Ogmios is initialized. */
|
||||
export function validateSurplusSaleProofAnchorMetadata(
|
||||
value: unknown,
|
||||
): asserts value is SurplusSaleProofAnchorMetadata {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new TxConstructionError(
|
||||
"anchor_metadata_invalid",
|
||||
"Surplus sale proof metadata must be an object",
|
||||
);
|
||||
}
|
||||
|
||||
const metadata = value as Record<string, unknown>;
|
||||
const keys = Object.keys(metadata);
|
||||
if (
|
||||
keys.length !== METADATA_KEYS.length ||
|
||||
METADATA_KEYS.some((key) => !Object.prototype.hasOwnProperty.call(metadata, key))
|
||||
) {
|
||||
throw new TxConstructionError(
|
||||
"anchor_metadata_invalid",
|
||||
"Surplus sale proof metadata must contain only p, root, count, first, and last",
|
||||
);
|
||||
}
|
||||
|
||||
if (metadata.p !== SURPLUS_SALE_PROOF_PROTOCOL) {
|
||||
throw new TxConstructionError(
|
||||
"anchor_metadata_invalid",
|
||||
`metadata protocol must be ${SURPLUS_SALE_PROOF_PROTOCOL}`,
|
||||
);
|
||||
}
|
||||
if (!metadataTextFits(SURPLUS_SALE_PROOF_PROTOCOL)) {
|
||||
throw new TxConstructionError(
|
||||
"anchor_metadata_oversized",
|
||||
"metadata protocol exceeds the 64-byte transaction metadata text limit",
|
||||
);
|
||||
}
|
||||
if (typeof metadata.root !== "string" || !ROOT_HEX_RE.test(metadata.root)) {
|
||||
throw new TxConstructionError(
|
||||
"anchor_metadata_invalid",
|
||||
"metadata root must be 64 lowercase hexadecimal characters",
|
||||
);
|
||||
}
|
||||
if (!metadataTextFits(metadata.root)) {
|
||||
throw new TxConstructionError(
|
||||
"anchor_metadata_oversized",
|
||||
"metadata root exceeds the 64-byte transaction metadata text limit",
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof metadata.count !== "number" ||
|
||||
!Number.isSafeInteger(metadata.count) ||
|
||||
metadata.count <= 0
|
||||
) {
|
||||
throw new TxConstructionError(
|
||||
"anchor_metadata_invalid",
|
||||
"metadata count must be a positive safe integer",
|
||||
);
|
||||
}
|
||||
|
||||
for (const field of ["first", "last"] as const) {
|
||||
const id = metadata[field];
|
||||
if (!validSaleId(id)) {
|
||||
const oversized =
|
||||
typeof id === "string" &&
|
||||
textEncoder.encode(id).byteLength > MAX_TRANSACTION_METADATA_TEXT_BYTES;
|
||||
throw new TxConstructionError(
|
||||
oversized ? "anchor_metadata_oversized" : "anchor_metadata_invalid",
|
||||
`metadata ${field} must be a non-empty printable ASCII sale ID of at most 64 bytes`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildAnchorMetadataTx(
|
||||
params: AnchorMetadataParams,
|
||||
): Promise<BuildResult> {
|
||||
const { chain, walletKey, metadata } = params;
|
||||
validateSurplusSaleProofAnchorMetadata(metadata);
|
||||
|
||||
const provider = new OgmiosLucidProvider({
|
||||
ogmiosUrl: chain.url,
|
||||
fetch: chain.fetchImpl,
|
||||
});
|
||||
const { lucid } = await createLucidContext(
|
||||
provider,
|
||||
walletKey,
|
||||
{ networkId: 1, systemStartUnix: 0, slotLengthMs: 1000 },
|
||||
{ usePresetProtocolParameters: true },
|
||||
);
|
||||
const realWalletUtxos = await lucid.wallet().getUtxos();
|
||||
const anchorMetadata = {
|
||||
p: metadata.p,
|
||||
root: metadata.root,
|
||||
count: metadata.count,
|
||||
first: metadata.first,
|
||||
last: metadata.last,
|
||||
};
|
||||
|
||||
let signed;
|
||||
try {
|
||||
const txBuilder = lucid
|
||||
.newTx()
|
||||
.attachMetadata(SURPLUS_SALE_PROOF_METADATA_LABEL, anchorMetadata)
|
||||
.addSignerKey(walletKey.pubKeyHash)
|
||||
.setMinFee(500_000n);
|
||||
const completed = await txBuilder.complete({
|
||||
presetWalletInputs: realWalletUtxos,
|
||||
localUPLCEval: false,
|
||||
});
|
||||
signed = await completed.sign.withWallet().complete();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new TxConstructionError("anchor_metadata_build_failed", message);
|
||||
}
|
||||
|
||||
const txCborHex = signed.toCBOR();
|
||||
const expectedTxHash = signed.toHash();
|
||||
await chain.submitTx(txCborHex);
|
||||
|
||||
return { txCborHex, expectedTxHash };
|
||||
}
|
||||
@@ -2,9 +2,22 @@
|
||||
* planConsolidate.ts — pure pre-flight planner for the wallet-consolidate flow.
|
||||
*
|
||||
* Given a wallet's current UTxO set + a target collateral lovelace, decide
|
||||
* whether to consolidate (many → 2), split (1 → 2), or skip (already at
|
||||
* {≥collateral, working}). The same downstream tx-builder handles
|
||||
* consolidate and split — only the "reason" tag differs for logging.
|
||||
* whether the wallet needs reshaping or is already usable for script spends.
|
||||
*
|
||||
* Healthy is a STRUCTURAL predicate, not a UTxO count (F7, 2026-08-08):
|
||||
* (a) some pure-lovelace UTxO >= collateralLovelace exists (the collateral
|
||||
* candidate lucid's setCollateral needs), AND
|
||||
* (b) at least MIN_WORKING_LOVELACE sits OUTSIDE that candidate (fee and
|
||||
* spend money that is not the candidate), AND
|
||||
* (c) fragmentation is bounded (utxos.length <= maxUtxos).
|
||||
*
|
||||
* The earlier revision returned `already-healthy` only for EXACTLY 2 UTxOs.
|
||||
* The consolidate tx itself lands 3 (collateral + working + the fee-reserve
|
||||
* remainder as change), so the shape consolidation produced could never
|
||||
* satisfy its own health check — the wallet-health ticker re-consolidated
|
||||
* every tick and burned 0.5 AP3X per pass (116 AP3X in one 24h soak).
|
||||
* The health check also runs BEFORE the low-balance guard now: a wallet that
|
||||
* can already transact must never be told "balance too low to consolidate".
|
||||
*
|
||||
* No lucid / WASM deps so this is unit-testable from a vanilla vitest.
|
||||
*/
|
||||
@@ -26,12 +39,24 @@ export interface PlannedConsolidate {
|
||||
|
||||
export const DEFAULT_COLLATERAL_LOVELACE = 5_000_000n;
|
||||
export const DEFAULT_FEE_RESERVE = 2_000_000n;
|
||||
/** Reshape only when fragmentation passes this; receipt/change UTxOs
|
||||
* accumulating between jobs are fine as long as a candidate exists. */
|
||||
export const DEFAULT_MAX_UTXOS = 6;
|
||||
const MIN_WORKING_LOVELACE = 1_000_000n;
|
||||
|
||||
/** Lovelace of a UTxO iff it carries no native assets; null otherwise. */
|
||||
function pureLovelace(u: UtxoLike): bigint | null {
|
||||
for (const [unit, amount] of Object.entries(u.assets)) {
|
||||
if (unit !== "lovelace" && (amount ?? 0n) > 0n) return null;
|
||||
}
|
||||
return u.assets.lovelace ?? 0n;
|
||||
}
|
||||
|
||||
export function planConsolidate(
|
||||
utxos: UtxoLike[],
|
||||
collateralLovelace: bigint = DEFAULT_COLLATERAL_LOVELACE,
|
||||
feeReserve: bigint = DEFAULT_FEE_RESERVE,
|
||||
maxUtxos: number = DEFAULT_MAX_UTXOS,
|
||||
): PlannedConsolidate {
|
||||
if (utxos.length === 0) {
|
||||
throw new Error("wallet has no UTxOs — fund the address first");
|
||||
@@ -42,6 +67,32 @@ export function planConsolidate(
|
||||
0n,
|
||||
);
|
||||
|
||||
// Structural health check first — reshaping a usable wallet wastes a fee.
|
||||
if (utxos.length <= maxUtxos) {
|
||||
// Smallest qualifying candidate: leave larger UTxOs as working funds.
|
||||
let candidate: bigint | null = null;
|
||||
for (const u of utxos) {
|
||||
const pure = pureLovelace(u);
|
||||
if (pure !== null && pure >= collateralLovelace) {
|
||||
if (candidate === null || pure < candidate) candidate = pure;
|
||||
}
|
||||
}
|
||||
if (candidate !== null) {
|
||||
const workingOutside = totalLovelaceIn - candidate;
|
||||
if (workingOutside >= MIN_WORKING_LOVELACE) {
|
||||
return {
|
||||
inputCount: utxos.length,
|
||||
totalLovelaceIn,
|
||||
collateralOutput: candidate,
|
||||
workingOutput: workingOutside,
|
||||
alreadyHealthy: true,
|
||||
reason: "already-healthy",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reshape needed — only an actual reshape has a funds threshold.
|
||||
const required = collateralLovelace + feeReserve + MIN_WORKING_LOVELACE;
|
||||
if (totalLovelaceIn < required) {
|
||||
throw new Error(
|
||||
@@ -51,23 +102,6 @@ export function planConsolidate(
|
||||
);
|
||||
}
|
||||
|
||||
if (utxos.length === 2) {
|
||||
const lov0 = utxos[0].assets.lovelace ?? 0n;
|
||||
const lov1 = utxos[1].assets.lovelace ?? 0n;
|
||||
const smaller = lov0 < lov1 ? lov0 : lov1;
|
||||
const larger = lov0 < lov1 ? lov1 : lov0;
|
||||
if (smaller >= collateralLovelace) {
|
||||
return {
|
||||
inputCount: 2,
|
||||
totalLovelaceIn,
|
||||
collateralOutput: smaller,
|
||||
workingOutput: larger,
|
||||
alreadyHealthy: true,
|
||||
reason: "already-healthy",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const workingOutput = totalLovelaceIn - collateralLovelace - feeReserve;
|
||||
|
||||
return {
|
||||
|
||||
116
pnpm-lock.yaml
generated
116
pnpm-lock.yaml
generated
@@ -321,10 +321,16 @@ importers:
|
||||
express:
|
||||
specifier: ^4.21.2
|
||||
version: 4.22.1
|
||||
pg:
|
||||
specifier: ^8.16.3
|
||||
version: 8.23.0
|
||||
devDependencies:
|
||||
'@types/express':
|
||||
specifier: ^5.0.3
|
||||
version: 5.0.6
|
||||
'@types/pg':
|
||||
specifier: ^8.15.5
|
||||
version: 8.23.1
|
||||
'@types/supertest':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.3
|
||||
@@ -1244,6 +1250,9 @@ packages:
|
||||
'@types/pdf-parse@1.1.5':
|
||||
resolution: {integrity: sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==}
|
||||
|
||||
'@types/pg@8.23.1':
|
||||
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
|
||||
|
||||
'@types/prop-types@15.7.15':
|
||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||
|
||||
@@ -2360,6 +2369,40 @@ packages:
|
||||
resolution: {integrity: sha512-XRIRcLgk6ZnUbsHsYXExMw+krrPE81hJ6FQPLdBNhhBefqIQKXu/WeTgNBGSwPrfU0v+UCEwn7AoAUOsVKHFvQ==}
|
||||
engines: {node: '>=6.8.1'}
|
||||
|
||||
pg-cloudflare@1.4.0:
|
||||
resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==}
|
||||
|
||||
pg-connection-string@2.14.0:
|
||||
resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==}
|
||||
|
||||
pg-int8@1.0.1:
|
||||
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
|
||||
pg-pool@3.14.0:
|
||||
resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==}
|
||||
peerDependencies:
|
||||
pg: '>=8.0'
|
||||
|
||||
pg-protocol@1.16.0:
|
||||
resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==}
|
||||
|
||||
pg-types@2.2.0:
|
||||
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
pg@8.23.0:
|
||||
resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==}
|
||||
engines: {node: '>= 16.0.0'}
|
||||
peerDependencies:
|
||||
pg-native: '>=3.0.1'
|
||||
peerDependenciesMeta:
|
||||
pg-native:
|
||||
optional: true
|
||||
|
||||
pgpass@1.0.5:
|
||||
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -2430,6 +2473,22 @@ packages:
|
||||
resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postgres-array@2.0.0:
|
||||
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postgres-bytea@1.0.1:
|
||||
resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
postgres-date@1.0.7:
|
||||
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
postgres-interval@1.2.0:
|
||||
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
prebuild-install@7.1.3:
|
||||
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2640,6 +2699,10 @@ packages:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
split2@4.2.0:
|
||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
||||
sprintf-js@1.1.3:
|
||||
resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==}
|
||||
|
||||
@@ -3863,6 +3926,12 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@types/pg@8.23.1':
|
||||
dependencies:
|
||||
'@types/node': 25.6.0
|
||||
pg-protocol: 1.16.0
|
||||
pg-types: 2.2.0
|
||||
|
||||
'@types/prop-types@15.7.15': {}
|
||||
|
||||
'@types/qs@6.15.0': {}
|
||||
@@ -4947,6 +5016,41 @@ snapshots:
|
||||
dependencies:
|
||||
node-ensure: 0.0.0
|
||||
|
||||
pg-cloudflare@1.4.0:
|
||||
optional: true
|
||||
|
||||
pg-connection-string@2.14.0: {}
|
||||
|
||||
pg-int8@1.0.1: {}
|
||||
|
||||
pg-pool@3.14.0(pg@8.23.0):
|
||||
dependencies:
|
||||
pg: 8.23.0
|
||||
|
||||
pg-protocol@1.16.0: {}
|
||||
|
||||
pg-types@2.2.0:
|
||||
dependencies:
|
||||
pg-int8: 1.0.1
|
||||
postgres-array: 2.0.0
|
||||
postgres-bytea: 1.0.1
|
||||
postgres-date: 1.0.7
|
||||
postgres-interval: 1.2.0
|
||||
|
||||
pg@8.23.0:
|
||||
dependencies:
|
||||
pg-connection-string: 2.14.0
|
||||
pg-pool: 3.14.0(pg@8.23.0)
|
||||
pg-protocol: 1.16.0
|
||||
pg-types: 2.2.0
|
||||
pgpass: 1.0.5
|
||||
optionalDependencies:
|
||||
pg-cloudflare: 1.4.0
|
||||
|
||||
pgpass@1.0.5:
|
||||
dependencies:
|
||||
split2: 4.2.0
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@2.3.2: {}
|
||||
@@ -4997,6 +5101,16 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postgres-array@2.0.0: {}
|
||||
|
||||
postgres-bytea@1.0.1: {}
|
||||
|
||||
postgres-date@1.0.7: {}
|
||||
|
||||
postgres-interval@1.2.0:
|
||||
dependencies:
|
||||
xtend: 4.0.2
|
||||
|
||||
prebuild-install@7.1.3:
|
||||
dependencies:
|
||||
detect-libc: 2.1.2
|
||||
@@ -5282,6 +5396,8 @@ snapshots:
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
split2@4.2.0: {}
|
||||
|
||||
sprintf-js@1.1.3: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
83
supplier/.env.chandra.example
Normal file
83
supplier/.env.chandra.example
Normal file
@@ -0,0 +1,83 @@
|
||||
# supplier/.env.chandra.example — Chandra OCR-2 supplier (ocr capability)
|
||||
#
|
||||
# Copy to supplier/.env.chandra on the host, chmod 600, fill the blanks.
|
||||
# TESTNET FIRST: run the full lifecycle (advert → escrow → claim → submit →
|
||||
# accept) on testnet before any mainnet value enters this file. Mainnet ops
|
||||
# (funding, advert post, container start) are operator-executed.
|
||||
#
|
||||
# The upstream is any OpenAI-compatible VISION endpoint serving
|
||||
# datalab-to/chandra-ocr-2 (vLLM recommended; `chandra_vllm` docker or
|
||||
# `vllm serve datalab-to/chandra-ocr-2 --revision <sha>`).
|
||||
# LICENSING: the model weights and the hosted API each carry their own
|
||||
# terms. Confirm your own licensing position with the model provider before
|
||||
# serving commercially.
|
||||
|
||||
# ── Identity / chain ──────────────────────────────────────────────────────
|
||||
SUPPLIER_PRIV_KEY_HEX= # gen-keypair output; NEW wallet, working float only
|
||||
NETWORK_ID= # 0 testnet | 1 mainnet — set explicitly, never default
|
||||
OGMIOS_URL= # wss://ogmios.vector.testnet... | mainnet equivalent
|
||||
ADVERT_REF= # <txHash>#<index> from tx:post-advert (below)
|
||||
LIVE_CHAIN= # 1 only when submitting for real
|
||||
VECTOR_ZERO_TIME_MS= # per deploy/README (mainnet 1756485600000)
|
||||
|
||||
# ── Capability ────────────────────────────────────────────────────────────
|
||||
CAPABILITY_KIND=ocr
|
||||
|
||||
# ── Upstream selection ────────────────────────────────────────────────────
|
||||
# "datalab" = Datalab hosted document API (v0 deployment, decided 2026-08-06)
|
||||
# "openai-vision" = self-hosted vLLM (the attestation upgrade path)
|
||||
OCR_UPSTREAM=datalab
|
||||
|
||||
# ── Datalab upstream (OCR_UPSTREAM=datalab) ───────────────────────────────
|
||||
# TERMS: the upstream provider's end-user terms govern how you may use and
|
||||
# redistribute output, and they vary by account and plan. Confirm your own
|
||||
# position with the provider before serving third parties.
|
||||
DATALAB_API_KEY= # provider account key; never commit
|
||||
DATALAB_BASE_URL=https://www.datalab.to
|
||||
DATALAB_MODE=accurate # fast | balanced | accurate
|
||||
DATALAB_TIMEOUT_MS=120000 # submit + poll budget; keep < advert max_processing_ms
|
||||
|
||||
# ── OpenAI-compatible vision upstream (OCR_UPSTREAM=openai-vision) ────────
|
||||
# No /v1 suffix — supplier/src/ocrVision.ts appends /v1/chat/completions.
|
||||
# OPENAI_BASE_URL= # e.g. http://chandra-vllm:8000
|
||||
# OPENAI_API_KEY= # empty for a local vLLM; token for hosted endpoints
|
||||
OPENAI_TIMEOUT_MS=120000 # page inference is seconds; keep < advert max_processing_ms
|
||||
# OPENAI_MAX_TOKENS=8192 # optional output ceiling (advert cap applies regardless)
|
||||
|
||||
# ── OCR extras ────────────────────────────────────────────────────────────
|
||||
# Datalab upstream: the pre-Claim probe defaults to per_job liveness against
|
||||
# {DATALAB_BASE_URL}/api/v1/health - no further config needed. Set
|
||||
# REVISION_PROBE_MODE=off only if you want claims without the health gate.
|
||||
#
|
||||
# openai-vision upstream only (attestation upgrade path):
|
||||
# OCR_PROMPT_OVERRIDE= # exact serving prompt, pinned at integration
|
||||
# HF_MODEL_REVISION= # 40-hex checkpoint SHA; mismatch refuses Claim
|
||||
# REVISION_PROBE_URL= # e.g. http://chandra-vllm:8000/info
|
||||
|
||||
# ── Service ───────────────────────────────────────────────────────────────
|
||||
PORT=8080
|
||||
|
||||
# ── Advert posting (run once per network, testnet first) ──────────────────
|
||||
# Model string convention: datalab upstream = "datalab/convert-accurate"
|
||||
# (honest: hosted engine + mode); self-hosted = "datalab-to/chandra-ocr-2@<sha7>".
|
||||
# Endpoint host: your deployed supplier's public HTTPS endpoint (deploy
|
||||
# target tracked outside this repo, not in this file).
|
||||
#
|
||||
# pnpm --filter @marketplace/supplier tx:post-advert -- \
|
||||
# --ogmios-url $OGMIOS_URL \
|
||||
# --priv-key $SUPPLIER_PRIV_KEY_HEX \
|
||||
# --capability-id ocr.page.extract.chandra-ocr-2.v1 \
|
||||
# --model "datalab/convert-accurate" \
|
||||
# --max-output-tokens 8192 \
|
||||
# --max-processing-ms 150000 \
|
||||
# --price-lovelace 200000 \
|
||||
# --endpoint-url https://<your-supplier-host> \
|
||||
# --detail-uri https://<your-supplier-host>/chandra-detail.json \
|
||||
# --detail-hash <sha256 of the detail JSON>
|
||||
#
|
||||
# max_processing_ms 150000 covers DATALAB_TIMEOUT_MS (120s) + chain legs.
|
||||
# Price 200000 (0.2 AP3X) is the fleet convention placeholder — a future
|
||||
# pricing decision supersedes it. Bonds default to 1 AP3X each.
|
||||
# The detail JSON template lives at supplier/chandra-detail.example.json.
|
||||
# Testnet: DNS is not required — a direct http://<host-ip>:<port> endpoint
|
||||
# works for the testnet advert (retire it after the drills).
|
||||
@@ -132,3 +132,51 @@ ADVERT_DETAIL_HASH=
|
||||
# MAX_CHAT_SESSIONS=1 # concurrent session slots (default 1)
|
||||
# CHAT_SETTLE_MODE=full # "full" (Claim/Submit) or "ticket" (no supplier
|
||||
# # chain ops; buyer reclaims the Open escrow)
|
||||
|
||||
# ─── Autonomous Surplus seller (surplus:start) ────────────────────────────
|
||||
# This service uses a seller-scoped Surplus key. It does not need a wallet
|
||||
# private key. Keep both API keys in the host-only surplus-seller.env file.
|
||||
SURPLUS_LIVE=
|
||||
SURPLUS_API_URL=https://api.surplusintelligence.ai
|
||||
SURPLUS_SELLER_API_KEY=si_seller_REPLACE_ME
|
||||
SURPLUS_OPENROUTER_API_KEY=sk-or-v1-REPLACE_ME
|
||||
SURPLUS_PROVIDER_BASE_URL=https://openrouter.ai/api/v1
|
||||
OPENROUTER_CAPACITY_BASE_URL=https://openrouter.ai/api
|
||||
SURPLUS_SELLER_WALLET=0x0000000000000000000000000000000000000000
|
||||
SURPLUS_PAYOUT_ADDRESS=0x0000000000000000000000000000000000000000
|
||||
# The controller discovers all fixed-price text models and selects one by
|
||||
# current demand. It keeps only one active offer. At the default 490 bps
|
||||
# recovery floor, a $0.05 seller cap bounds upstream spend near $1.02 per day.
|
||||
SURPLUS_PER_OFFER_CAP_USD=0.05
|
||||
SURPLUS_AGGREGATE_CAP_USD=0.05
|
||||
SURPLUS_OPENROUTER_RESERVE_USD=1.00
|
||||
SURPLUS_OPENROUTER_MAX_LIMIT_USD=20.00
|
||||
SURPLUS_MIN_RECOVERY_BPS=490
|
||||
SURPLUS_UNDERCUT_BPS=10
|
||||
SURPLUS_MAX_CANDIDATE_ORDER_BOOKS=10
|
||||
SURPLUS_REPRICE_INTERVAL_MS=300000
|
||||
SURPLUS_REQUEST_TIMEOUT_MS=120000
|
||||
# 0 keeps selling. 1 pauses after the first trade or one offer-cap of spend.
|
||||
SURPLUS_STOP_AFTER_SETTLED_REQUESTS=0
|
||||
SURPLUS_SETTLED_STATUSES=confirmed
|
||||
SURPLUS_STATE_PATH=/var/lib/surplus-manager/state.json
|
||||
# --- Vector proof publisher ------------------------------------------------
|
||||
# When enabled, every settled Base settlement transaction gets one Vector
|
||||
# counterpart: the sales it settled are Merkle-batched and anchored in a
|
||||
# label-674 metadata transaction signed by the proof wallet below.
|
||||
SURPLUS_PROOF_ENABLED=
|
||||
# Required when enabled. Mainnet Ogmios endpoint and the 64-hex Ed25519 key
|
||||
# of the AP3X proof wallet (the retired Vector supplier wallet).
|
||||
OGMIOS_URL=
|
||||
SURPLUS_PROOF_WALLET_PRIV_KEY_HEX=
|
||||
SURPLUS_PROOF_LEDGER_PATH=/var/lib/surplus-manager/vector-proofs.json
|
||||
# Anchoring pauses when the wallet cannot keep this reserve after one fee.
|
||||
SURPLUS_PROOF_RESERVE_LOVELACE=5000000
|
||||
SURPLUS_PROOF_FEE_BUDGET_LOVELACE=1000000
|
||||
SURPLUS_PROOF_INTERVAL_MS=300000
|
||||
SURPLUS_PROOF_CONFIRM_TIMEOUT_MS=300000
|
||||
# Optional support-export backfill. The CSV must have the exact header:
|
||||
# request_id,settled_at,model,input_tokens,output_tokens,seller_earned_usd,settlement_status,settlement_type,tx_hash
|
||||
# The offer id is stamped into each export leaf because the export omits it.
|
||||
SURPLUS_PROOF_HISTORY_CSV_PATH=
|
||||
SURPLUS_PROOF_HISTORY_OFFER_ID=
|
||||
|
||||
105
supplier/.env.hetzner.example
Normal file
105
supplier/.env.hetzner.example
Normal file
@@ -0,0 +1,105 @@
|
||||
# supplier/.env.hetzner.example
|
||||
#
|
||||
# Preset for a supplier that uses Hetzner Inference
|
||||
# (https://inference.hetzner.com) as its COMPUTE BACKEND to fulfil bonded
|
||||
# inference work on Vector. Hetzner exposes an OpenAI-compatible API, so this
|
||||
# reuses the existing LLM_BACKEND=openai path with NO code changes — config only.
|
||||
#
|
||||
# One template for all 8 Hetzner-backed suppliers (4 models x {one-off, chat}):
|
||||
# kimi-code[-chat] → Kimi-K2.7-Code (ctx 262144)
|
||||
# glm[-chat] → GLM-5.2-NVFP4 (ctx 512000)
|
||||
# ds-flash-htz[-chat] → DeepSeek-V4-Flash-0731 (ctx 512000)
|
||||
# qwen35b[-chat] → Qwen/Qwen3.6-35B-A3B-FP8 (ctx 262144)
|
||||
#
|
||||
# Copy to supplier/.env.<name> on the host (chmod 600) and fill in real values.
|
||||
# .env.* is gitignored — NEVER commit a populated copy.
|
||||
#
|
||||
# Runbook: docs/HETZNER_SETUP.md
|
||||
#
|
||||
# Lines marked SECRET must never be shared, logged, or committed.
|
||||
|
||||
# ─── Wallet identity ──────────────────────────────────────────────────────
|
||||
# Generate all four values in one shot (from repo root):
|
||||
# pnpm --filter @marketplace/supplier tx:gen-keypair --network 1
|
||||
#
|
||||
# ALL FOUR are required. The supplier does NOT re-derive address/pkh/pubkey
|
||||
# from the private key at boot — leaving them unset silently yields an empty
|
||||
# wallet identity and every escrow is rejected 403 wrong_supplier (this took
|
||||
# down the TTS supplier once; don't repeat it).
|
||||
|
||||
# 64-char hex Ed25519 private key seed for this supplier's wallet. SECRET.
|
||||
SUPPLIER_PRIV_KEY_HEX=PASTE_64_HEX_CHARS_HERE
|
||||
SUPPLIER_ADDRESS=PASTE_ADDR1_HERE
|
||||
SUPPLIER_PKH=PASTE_28_BYTE_HEX_HERE
|
||||
SUPPLIER_PUB_KEY_HEX=PASTE_32_BYTE_HEX_HERE
|
||||
|
||||
# ─── Chain plumbing (mainnet) ─────────────────────────────────────────────
|
||||
OGMIOS_URL=https://ogmios.vector.mainnet.apexfusion.org
|
||||
NETWORK_ID=1
|
||||
VECTOR_ZERO_TIME_MS=1756485600000
|
||||
|
||||
# CIP-33 reference scripts (shared mainnet deployment).
|
||||
ESCROW_REF_UTXO=c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#0
|
||||
ADVERT_REF_UTXO=c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#1
|
||||
|
||||
# OutputReference of this supplier's on-chain advertisement UTxO.
|
||||
# Post it first (see tx:post-advert below), then paste the printed "<txHash>#0".
|
||||
ADVERT_REF=PASTE_TXHASH_HERE#0
|
||||
|
||||
# LIVE_CHAIN: only the literal "1" opts in to real Claim/Submit txs.
|
||||
LIVE_CHAIN=1
|
||||
|
||||
# ─── Backend: Hetzner Inference (OpenAI-compatible) ───────────────────────
|
||||
LLM_BACKEND=openai
|
||||
|
||||
# Hetzner base URL. The supplier appends /v1/chat/completions itself, so do
|
||||
# NOT include the /api/v1 → only up to /api.
|
||||
OPENAI_BASE_URL=https://inference.hetzner.com/api
|
||||
|
||||
# Hetzner Inference API key. Shared across all 8 Hetzner-backed suppliers —
|
||||
# rate limits apply to the aggregate. SECRET.
|
||||
OPENAI_API_KEY=PASTE_HETZNER_KEY_HERE
|
||||
|
||||
# All four models are reasoning-style; generations can be slow.
|
||||
OPENAI_TIMEOUT_MS=300000
|
||||
|
||||
# Do NOT set OPENAI_REASONING — `reasoning:{enabled:false}` is an
|
||||
# OpenRouter-only param.
|
||||
# OPENAI_REASONING=
|
||||
|
||||
# OPENAI_MAX_TOKENS intentionally unset: no max_tokens is forwarded upstream,
|
||||
# Hetzner grants context − prompt. The advert's max_output_tokens (= model
|
||||
# context length) only caps what buyers may request.
|
||||
# OPENAI_MAX_TOKENS=
|
||||
|
||||
PORT=8080
|
||||
|
||||
# ─── tx:post-advert — run once before first deploy ────────────────────────
|
||||
# One-off (llm.text.generate.v1) suppliers, from repo root:
|
||||
# pnpm --filter @marketplace/supplier tx:post-advert \
|
||||
# --capability-id llm.text.generate.v1 \
|
||||
# --model <EXACT Hetzner model id, see table above> \
|
||||
# --max-output-tokens <262144 or 512000 per table> \
|
||||
# --max-processing-ms 300000 \
|
||||
# --price-lovelace 200000 \
|
||||
# --endpoint-url https://mp-suppliers-<name>.vector.apexfusion.org
|
||||
#
|
||||
# Chat (llm.chat.v1) suppliers: same, but
|
||||
# --capability-id llm.chat.v1 --max-processing-ms 1800000
|
||||
# (the session spans the whole conversation; Claim+Submit must land before
|
||||
# deliver_by — mirrors the kimi-chat fleet).
|
||||
#
|
||||
# CRITICAL: --model goes on-chain AND is sent verbatim to Hetzner as the API
|
||||
# `model` param (jobRunner uses advert.model). It must be the EXACT id from
|
||||
# GET https://inference.hetzner.com/api/v1/models — including the "Qwen/"
|
||||
# prefix on Qwen/Qwen3.6-35B-A3B-FP8.
|
||||
ADVERT_CAPABILITY_ID=llm.text.generate.v1
|
||||
ADVERT_MODEL=GLM-5.2-NVFP4
|
||||
ADVERT_MAX_OUTPUT_TOKENS=512000
|
||||
ADVERT_MAX_PROCESSING_MS=300000
|
||||
ADVERT_PRICE_LOVELACE=200000
|
||||
ADVERT_SUPPLIER_BOND_LOVELACE=1000000
|
||||
ADVERT_BUYER_BOND_LOVELACE=1000000
|
||||
ADVERT_ENDPOINT_URL=https://mp-suppliers-glm.vector.apexfusion.org
|
||||
ADVERT_DETAIL_URI=
|
||||
ADVERT_DETAIL_HASH=
|
||||
114
supplier/.env.huggingface-chat.example
Normal file
114
supplier/.env.huggingface-chat.example
Normal file
@@ -0,0 +1,114 @@
|
||||
# supplier/.env.huggingface-chat.example
|
||||
#
|
||||
# Preset for a chat supplier that uses the HuggingFace Inference Providers router
|
||||
# (https://router.huggingface.co) as its COMPUTE BACKEND to fulfil bonded
|
||||
# inference work commissioned on Vector. It is the OpenAI-compatible on-ramp:
|
||||
# the router is OpenAI-compatible, so this reuses the existing LLM_BACKEND=openai
|
||||
# path with NO code changes — it is config only.
|
||||
#
|
||||
# Copy to supplier/.env on the host (chmod 600) and fill in real values.
|
||||
# .env is gitignored — NEVER commit a populated copy.
|
||||
#
|
||||
# Runbook: docs/HUGGINGFACE_ROUTER_SETUP.md
|
||||
#
|
||||
# To generate a fresh Ed25519 wallet seed: openssl rand -hex 32
|
||||
# Lines marked SECRET must never be shared, logged, or committed.
|
||||
|
||||
# ─── Required ─────────────────────────────────────────────────────────────
|
||||
|
||||
# 64-char hex Ed25519 private key seed for this supplier's wallet. SECRET.
|
||||
SUPPLIER_PRIV_KEY_HEX=PASTE_64_HEX_CHARS_HERE
|
||||
|
||||
# Ogmios WebSocket URL. Testnet shown; use the mainnet URL for NETWORK_ID=1:
|
||||
# testnet: wss://ogmios.vector.testnet.apexfusion.org
|
||||
# mainnet: wss://ogmios.vector.mainnet.apexfusion.org
|
||||
OGMIOS_URL=wss://ogmios.vector.testnet.apexfusion.org
|
||||
|
||||
# OutputReference of this supplier's on-chain advertisement UTxO.
|
||||
# Format "<64-hex-tx-hash>#<int>". Post it first (see tx:post-advert below),
|
||||
# then paste the printed "<txHash>#0" here.
|
||||
ADVERT_REF=PASTE_TXHASH_HERE#0
|
||||
|
||||
# Cardano network ID. "0" = testnet, "1" = mainnet. Keep "0" until you have
|
||||
# validated the setup end-to-end on testnet.
|
||||
NETWORK_ID=0
|
||||
|
||||
# ─── Backend: HuggingFace router (OpenAI-compatible) ──────────────────────
|
||||
# Route the chat job runner at the HF Inference Providers router instead of a
|
||||
# local Ollama. The supplier POSTs to ${OPENAI_BASE_URL}/v1/chat/completions.
|
||||
LLM_BACKEND=openai
|
||||
|
||||
# HF router base URL. The supplier appends /v1/chat/completions itself, so do
|
||||
# NOT include the /v1 suffix here.
|
||||
OPENAI_BASE_URL=https://router.huggingface.co
|
||||
|
||||
# HuggingFace access token (starts with "hf_"). Create one at
|
||||
# https://huggingface.co/settings/tokens with "Make calls to Inference
|
||||
# Providers" permission. Forwarded as `Authorization: Bearer <token>`. SECRET.
|
||||
OPENAI_API_KEY=PASTE_HF_TOKEN_HERE
|
||||
|
||||
# First-token latency across hosted providers can exceed local Ollama — keep a
|
||||
# generous request timeout.
|
||||
OPENAI_TIMEOUT_MS=180000
|
||||
|
||||
# ─── IMPORTANT: do NOT set OPENAI_REASONING here ──────────────────────────
|
||||
# `reasoning:{enabled:false}` is an OpenRouter-only param. The HF router
|
||||
# rejects it with HTTP 400 on every request. The supplier refuses to boot if
|
||||
# OPENAI_REASONING is disabled while OPENAI_BASE_URL points at the HF router.
|
||||
# (This is the #1 footgun when copying an OpenRouter env — leave it unset.)
|
||||
# OPENAI_REASONING=
|
||||
|
||||
# Optional hard ceiling forwarded as `max_tokens`. Unset = provider grants the
|
||||
# max available (context − prompt). Keep below the model's context window.
|
||||
# OPENAI_MAX_TOKENS=
|
||||
|
||||
# ─── Enable real chain submissions ────────────────────────────────────────
|
||||
# LIVE_CHAIN: only the literal "1" opts in to real Claim/Submit txs. Leave
|
||||
# unset for a dry run (read-only, no chain writes) while testing.
|
||||
LIVE_CHAIN=
|
||||
|
||||
# HTTP listen port inside the container. Compose maps this to the host.
|
||||
PORT=8080
|
||||
|
||||
# ─── tx:post-advert — run once before first deploy ────────────────────────
|
||||
# Execute (from repo root):
|
||||
# pnpm --filter @marketplace/supplier tx:post-advert \
|
||||
# --capability-id llm.text.generate.v1 \
|
||||
# --model deepseek-ai/DeepSeek-V3:fastest \
|
||||
# --max-output-tokens 512 \
|
||||
# --max-processing-ms 60000 \
|
||||
# --price-lovelace 2000000 \
|
||||
# --endpoint-url https://mp-supplier.vector.testnet.apexfusion.org
|
||||
# Copy the printed "<txHash>#0" into ADVERT_REF above.
|
||||
#
|
||||
# NOTE on --model: HF uses PROVIDER-QUALIFIED model names. The bare id picks a
|
||||
# default provider; a ":<hint>" suffix pins routing:
|
||||
# deepseek-ai/DeepSeek-V3:fastest # cheapest/fastest provider for the model
|
||||
# deepseek-ai/DeepSeek-V3:together # pin a specific provider
|
||||
# openai/gpt-oss-120b # open-weight, HF-hosted
|
||||
# Browse models + providers at https://huggingface.co/models?inference_provider=all
|
||||
# The value lives on-chain in AdvertDatum.model — it is data, not code, so you
|
||||
# can advertise any router-supported model without touching the supplier.
|
||||
ADVERT_CAPABILITY_ID=llm.text.generate.v1
|
||||
ADVERT_MODEL=deepseek-ai/DeepSeek-V3:fastest
|
||||
ADVERT_MAX_OUTPUT_TOKENS=512
|
||||
ADVERT_MAX_PROCESSING_MS=60000
|
||||
|
||||
# Flat AP3X amount committed in escrow per job (1 AP3X = 1_000_000 lovelace).
|
||||
# The HF backend meters PER TOKEN while this committed amount is fixed per job,
|
||||
# so size it for your worst-case prompt length and cap output tightly via
|
||||
# ADVERT_MAX_OUTPUT_TOKENS. See docs/HUGGINGFACE_ROUTER_SETUP.md §4.
|
||||
ADVERT_PRICE_LOVELACE=2000000
|
||||
ADVERT_SUPPLIER_BOND_LOVELACE=1000000
|
||||
ADVERT_BUYER_BOND_LOVELACE=1000000
|
||||
|
||||
# HTTPS endpoint buyers will call (must be publicly reachable).
|
||||
ADVERT_ENDPOINT_URL=https://mp-supplier.vector.testnet.apexfusion.org
|
||||
|
||||
# Optional off-chain detail pointer + its sha256 (leave empty for v1).
|
||||
ADVERT_DETAIL_URI=
|
||||
ADVERT_DETAIL_HASH=
|
||||
|
||||
# ─── Optional: CIP-33 reference scripts (recommended on mainnet) ──────────
|
||||
# ESCROW_REF_UTXO=<txhash>#0
|
||||
# ADVERT_REF_UTXO=<txhash>#1
|
||||
@@ -27,6 +27,8 @@ RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
||||
|
||||
# Workspace manifests first to maximise layer cache reuse.
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./
|
||||
# pnpm-lock.yaml's patchedDependencies hash the patch files at install time.
|
||||
COPY patches ./patches
|
||||
COPY packages/shared/package.json ./packages/shared/package.json
|
||||
COPY supplier/package.json ./supplier/package.json
|
||||
# Buyer + indexer manifests are needed because pnpm-lock.yaml indexes the
|
||||
@@ -68,6 +70,9 @@ COPY --from=builder /repo /repo
|
||||
|
||||
# Drop privileges. The official node image ships a non-root `node` user.
|
||||
RUN chown -R node:node /repo
|
||||
RUN mkdir -p /var/lib/surplus-manager \
|
||||
&& chown node:node /var/lib/surplus-manager \
|
||||
&& chmod 700 /var/lib/surplus-manager
|
||||
USER node
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
21
supplier/chandra-detail.example.json
Normal file
21
supplier/chandra-detail.example.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"capability_id": "ocr.page.extract.chandra-ocr-2.v1",
|
||||
"weights_source": "hf:datalab-to/chandra-ocr-2",
|
||||
"model_revision": "<40-hex commit SHA; self-hosted (OCR_UPSTREAM=openai-vision) deployments only — the Datalab-hosted default (OCR_UPSTREAM=datalab) is a third-party API with no pinnable checkpoint, so this is unset there>",
|
||||
"license": "modified OpenRAIL-M (weights); Apache-2.0 (code)",
|
||||
"request_shape": {
|
||||
"route": "POST /v1/ocr/extract",
|
||||
"body": { "image_b64": "plain base64, no data: prefix", "mime": "image/png | image/jpeg | image/webp", "output_format": "markdown | html | json" },
|
||||
"one_page_per_job": true
|
||||
},
|
||||
"receipt": {
|
||||
"schema": "v1 (8 fields, Ed25519-signed)",
|
||||
"response_hash": "sha256(canonical({content, output_format}))",
|
||||
"verify": "GET /capability → pub_key_hex; verify signature over canonical(receipt)"
|
||||
},
|
||||
"revision_probe": {
|
||||
"route": "<runtime info route reporting the loaded checkpoint; self-hosted (openai-vision) upstream only>",
|
||||
"policy": "self-hosted (OCR_UPSTREAM=openai-vision) upstream: probe before every Claim, mismatch refuses the job. Datalab-hosted default (OCR_UPSTREAM=datalab): per-job liveness probe only, no checkpoint pin."
|
||||
},
|
||||
"operator_disclosure": "first-party (HAL8-operated supplier)"
|
||||
}
|
||||
@@ -9,17 +9,21 @@
|
||||
"tx:post-advert": "tsx src/cli/post-advert.ts",
|
||||
"tx:retire-advert": "tsx src/cli/retire-advert.ts",
|
||||
"tx:publish-reference-scripts": "tsx src/cli/publish-reference-scripts.ts",
|
||||
"tx:consolidate-wallet": "tsx src/cli/consolidate-wallet.ts"
|
||||
"tx:consolidate-wallet": "tsx src/cli/consolidate-wallet.ts",
|
||||
"reseller:setup": "tsx src/cli/setup-reseller.ts",
|
||||
"surplus:start": "tsx src/cli/surplus-seller.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@marketplace/shared": "workspace:*",
|
||||
"@noble/ed25519": "^2.1.0",
|
||||
"@noble/hashes": "^1.8.0",
|
||||
"bech32": "^2.0.0",
|
||||
"express": "^4.21.2"
|
||||
"express": "^4.21.2",
|
||||
"pg": "^8.16.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/pg": "^8.15.5",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"supertest": "^7.0.0",
|
||||
"typescript": "^5.7.2"
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
detectCborBackend,
|
||||
mockSlotToWallclockMs,
|
||||
TxConstructionError,
|
||||
type PostAdvertBuildResult,
|
||||
type WalletKey,
|
||||
} from "@marketplace/shared/tx";
|
||||
|
||||
@@ -34,6 +35,8 @@ export interface PostAdvertFlowParams {
|
||||
* in that order. Defaults to console.log.
|
||||
*/
|
||||
log?: (line: string) => void;
|
||||
/** Durable journal hook invoked with the signed transaction before broadcast. */
|
||||
beforeSubmit?: (built: PostAdvertBuildResult) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface PostAdvertFlowResult {
|
||||
@@ -110,6 +113,7 @@ export async function runPostAdvert(
|
||||
walletKey,
|
||||
advertDatum: datumToSubmit,
|
||||
deposit_lovelace: datumToSubmit.supplier_bond_lovelace,
|
||||
beforeSubmit: params.beforeSubmit,
|
||||
});
|
||||
|
||||
writeLog(`submitted tx ${built.expectedTxHash}`);
|
||||
|
||||
486
supplier/src/cli/setup-reseller.ts
Normal file
486
supplier/src/cli/setup-reseller.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { chmod, mkdir, open, rename } from "node:fs/promises";
|
||||
import { dirname, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { LiveOgmiosProvider, type OutputReference } from "@marketplace/shared/chain";
|
||||
import { decodeAdvertDatum, type AdvertDatum } from "@marketplace/shared/cbor";
|
||||
import {
|
||||
BOUNDED_INPUT_DETAIL_MARKER,
|
||||
loadBlueprint,
|
||||
type WalletKey,
|
||||
} from "@marketplace/shared/tx";
|
||||
import { deriveKeypair } from "./gen-keypair.js";
|
||||
import { runPostAdvert } from "./postAdvertFlow.js";
|
||||
import { callOpenAi } from "../openai.js";
|
||||
import { OpenRouterCapacityProvider } from "../reseller/openRouterCapacity.js";
|
||||
import {
|
||||
loadResellerSetupConfig,
|
||||
type ResellerSetupConfig,
|
||||
} from "../reseller/setupConfig.js";
|
||||
|
||||
interface CliOptions {
|
||||
configPath: string;
|
||||
startStack: boolean;
|
||||
}
|
||||
|
||||
interface SetupState extends Record<string, string> {
|
||||
SUPPLIER_PRIV_KEY_HEX: string;
|
||||
SUPPLIER_PUB_KEY_HEX: string;
|
||||
SUPPLIER_PKH: string;
|
||||
SUPPLIER_ADDRESS: string;
|
||||
POSTGRES_PASSWORD: string;
|
||||
OPENAI_API_KEY: string;
|
||||
ADVERT_REF: string;
|
||||
}
|
||||
|
||||
interface SetupResult {
|
||||
supplierAddress: string;
|
||||
supplierPkh: string;
|
||||
advertRef: string;
|
||||
endpointUrl: string;
|
||||
dashboardUrl: string;
|
||||
marketplaceModel: string;
|
||||
providerModel: string;
|
||||
stackStarted: boolean;
|
||||
}
|
||||
|
||||
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const OPENROUTER_BASE_URL = "https://openrouter.ai/api";
|
||||
const SHA256_EMPTY = createHash("sha256").update("").digest("hex");
|
||||
const MAINNET_ZERO_TIME_MS = "1756485600000";
|
||||
const MAINNET_ESCROW_REF =
|
||||
"c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#0";
|
||||
const MAINNET_ADVERT_REF =
|
||||
"c8d84c6d67ec67a1efe5e9c6c06d53020e05d1bb96d1c55ecb1eb7d5010c4d54#1";
|
||||
const POSTGRES_STATE_FILE =
|
||||
"/var/lib/agents-marketplace/openrouter-reseller-postgres.env";
|
||||
const DEFAULT_STATE_FILE = "/var/lib/agents-marketplace/openrouter-reseller.env";
|
||||
const RESELLER_HOSTNAME =
|
||||
"mp-suppliers-openrouter-reseller.vector.apexfusion.org";
|
||||
|
||||
export function parseSetupArgs(argv: string[]): CliOptions {
|
||||
let configPath = "";
|
||||
let startStack = true;
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--config") {
|
||||
const value = argv[index + 1];
|
||||
if (!value) throw new Error("--config requires a path");
|
||||
configPath = value;
|
||||
index += 1;
|
||||
} else if (arg === "--no-start") {
|
||||
startStack = false;
|
||||
} else {
|
||||
throw new Error(`unknown setup argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
if (configPath === "") throw new Error("--config is required");
|
||||
return { configPath, startStack };
|
||||
}
|
||||
|
||||
export async function runResellerSetup(
|
||||
options: CliOptions,
|
||||
env: Record<string, string | undefined>,
|
||||
log: (line: string) => void = (line) => console.error(line),
|
||||
): Promise<SetupResult> {
|
||||
const config = await loadResellerSetupConfig(
|
||||
resolve(REPO_ROOT, options.configPath),
|
||||
);
|
||||
if (new URL(config.endpointUrl).hostname !== RESELLER_HOSTNAME) {
|
||||
throw new Error(`endpointUrl host must be ${RESELLER_HOSTNAME}`);
|
||||
}
|
||||
const statePath = resolveStatePath(env.RESELLER_STATE_FILE, options.startStack);
|
||||
process.env.VECTOR_ZERO_TIME_MS = MAINNET_ZERO_TIME_MS;
|
||||
process.env.ESCROW_REF_UTXO = MAINNET_ESCROW_REF;
|
||||
process.env.ADVERT_REF_UTXO = MAINNET_ADVERT_REF;
|
||||
const prior = await readState(statePath);
|
||||
const privateKey = prior.SUPPLIER_PRIV_KEY_HEX ?? randomBytes(32).toString("hex");
|
||||
const derived = deriveKeypair(privateKey, config.networkId);
|
||||
const postgresPassword = prior.POSTGRES_PASSWORD ?? randomBytes(24).toString("hex");
|
||||
const openRouterKey = env.OPENROUTER_API_KEY ?? prior.OPENAI_API_KEY;
|
||||
if (!openRouterKey) {
|
||||
throw new Error("OPENROUTER_API_KEY is required on the first setup run");
|
||||
}
|
||||
|
||||
const runtimeState = buildRuntimeState(
|
||||
config,
|
||||
{
|
||||
privateKeyHex: derived.privateKeyHex,
|
||||
pubKeyHex: derived.publicKeyHex,
|
||||
pubKeyHash: derived.pubKeyHash,
|
||||
address: derived.address,
|
||||
},
|
||||
postgresPassword,
|
||||
openRouterKey,
|
||||
prior.ADVERT_REF ?? "",
|
||||
);
|
||||
await writeState(statePath, runtimeState);
|
||||
await writeState(POSTGRES_STATE_FILE, {
|
||||
POSTGRES_USER: runtimeState.POSTGRES_USER,
|
||||
POSTGRES_DB: runtimeState.POSTGRES_DB,
|
||||
POSTGRES_PASSWORD: runtimeState.POSTGRES_PASSWORD,
|
||||
});
|
||||
|
||||
log("checking capped OpenRouter allowance and model pricing");
|
||||
const capacity = new OpenRouterCapacityProvider({
|
||||
baseUrl: OPENROUTER_BASE_URL,
|
||||
apiKey: openRouterKey,
|
||||
timeoutMs: config.providerTimeoutMs,
|
||||
});
|
||||
await capacity.readCapacity(config.openRouterModel);
|
||||
|
||||
log("running one-token OpenRouter inference probe");
|
||||
await callOpenAi({
|
||||
baseUrl: OPENROUTER_BASE_URL,
|
||||
apiKey: openRouterKey,
|
||||
model: config.openRouterModel,
|
||||
messages: [{ role: "user", content: "Reply OK" }],
|
||||
maxTokens: 1,
|
||||
disableReasoning: true,
|
||||
timeoutMs: config.inferenceTimeoutMs,
|
||||
});
|
||||
|
||||
const chain = new LiveOgmiosProvider({ ogmiosUrl: config.ogmiosUrl });
|
||||
log(`seller wallet ${derived.address}`);
|
||||
await waitForFunding(
|
||||
chain,
|
||||
derived.address,
|
||||
config.minimumWalletLovelace,
|
||||
config.fundingTimeoutMs,
|
||||
log,
|
||||
);
|
||||
|
||||
const walletKey: WalletKey = {
|
||||
privateKeyHex: derived.privateKeyHex,
|
||||
pubKeyHex: derived.publicKeyHex,
|
||||
pubKeyHash: derived.pubKeyHash,
|
||||
address: derived.address,
|
||||
};
|
||||
const advert = buildAdvert(config, walletKey);
|
||||
const advertRef = await findOrPostAdvert(
|
||||
chain,
|
||||
walletKey,
|
||||
advert,
|
||||
runtimeState.ADVERT_REF,
|
||||
log,
|
||||
async (pendingRef) => {
|
||||
runtimeState.ADVERT_REF = `${pendingRef.txHash}#${pendingRef.index}`;
|
||||
await writeState(statePath, runtimeState);
|
||||
},
|
||||
);
|
||||
runtimeState.ADVERT_REF = `${advertRef.txHash}#${advertRef.index}`;
|
||||
await writeState(statePath, runtimeState);
|
||||
|
||||
if (options.startStack) {
|
||||
log("starting reseller and Postgres stack");
|
||||
await runCompose(config.composeFile);
|
||||
}
|
||||
|
||||
return {
|
||||
supplierAddress: walletKey.address,
|
||||
supplierPkh: walletKey.pubKeyHash,
|
||||
advertRef: runtimeState.ADVERT_REF,
|
||||
endpointUrl: config.endpointUrl,
|
||||
dashboardUrl: `${config.endpointUrl}/reseller`,
|
||||
marketplaceModel: config.marketplaceModel,
|
||||
providerModel: config.openRouterModel,
|
||||
stackStarted: options.startStack,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRuntimeState(
|
||||
config: ResellerSetupConfig,
|
||||
wallet: WalletKey,
|
||||
postgresPassword: string,
|
||||
openRouterKey: string,
|
||||
advertRef: string,
|
||||
): SetupState {
|
||||
const hostname = new URL(config.endpointUrl).hostname;
|
||||
return {
|
||||
SUPPLIER_PRIV_KEY_HEX: wallet.privateKeyHex,
|
||||
SUPPLIER_PUB_KEY_HEX: wallet.pubKeyHex,
|
||||
SUPPLIER_PKH: wallet.pubKeyHash,
|
||||
SUPPLIER_ADDRESS: wallet.address,
|
||||
POSTGRES_USER: "reseller",
|
||||
POSTGRES_DB: "reseller",
|
||||
POSTGRES_PASSWORD: postgresPassword,
|
||||
OPENAI_API_KEY: openRouterKey,
|
||||
ADVERT_REF: advertRef,
|
||||
OGMIOS_URL: config.ogmiosUrl,
|
||||
NETWORK_ID: String(config.networkId),
|
||||
LIVE_CHAIN: "1",
|
||||
CAPABILITY_KIND: "chat",
|
||||
LLM_BACKEND: "openai",
|
||||
OPENAI_BASE_URL: OPENROUTER_BASE_URL,
|
||||
OPENAI_MODEL_OVERRIDE: config.openRouterModel,
|
||||
OPENAI_MAX_TOKENS: String(config.maxOutputTokens),
|
||||
OPENAI_REASONING: "off",
|
||||
OPENAI_TIMEOUT_MS: String(config.inferenceTimeoutMs),
|
||||
RESELLER_PROVIDER: "openrouter",
|
||||
RESELLER_DATABASE_URL: `postgresql://reseller:${postgresPassword}@postgres:5432/reseller`,
|
||||
RESELLER_RESERVE_USD: config.reserveUsd,
|
||||
RESELLER_MAX_INPUT_TOKENS: String(config.maxInputTokens),
|
||||
RESELLER_POLL_INTERVAL_MS: String(config.pollIntervalMs),
|
||||
RESELLER_PROVIDER_TIMEOUT_MS: String(config.providerTimeoutMs),
|
||||
RESELLER_PREVIEW_MAX_CHARS: String(config.previewMaxChars),
|
||||
RESELLER_HOSTNAME: hostname,
|
||||
RESELLER_ENDPOINT_URL: config.endpointUrl,
|
||||
RESELLER_MARKETPLACE_MODEL: config.marketplaceModel,
|
||||
RESELLER_PROVIDER_MODEL: config.openRouterModel,
|
||||
PORT: "8080",
|
||||
WALLET_HEALTH_INTERVAL_MS: "600000",
|
||||
VECTOR_ZERO_TIME_MS: MAINNET_ZERO_TIME_MS,
|
||||
ESCROW_REF_UTXO: MAINNET_ESCROW_REF,
|
||||
ADVERT_REF_UTXO: MAINNET_ADVERT_REF,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAdvert(config: ResellerSetupConfig, wallet: WalletKey): AdvertDatum {
|
||||
return {
|
||||
supplier_pkh: wallet.pubKeyHash,
|
||||
capability_id: "llm.text.generate.v1",
|
||||
model: config.marketplaceModel,
|
||||
max_output_tokens: config.maxOutputTokens,
|
||||
max_processing_ms: config.maxProcessingMs,
|
||||
price_lovelace: config.priceLovelace,
|
||||
supplier_bond_lovelace: config.supplierBondLovelace,
|
||||
buyer_bond_lovelace: config.buyerBondLovelace,
|
||||
endpoint_url: config.endpointUrl,
|
||||
detail_uri: BOUNDED_INPUT_DETAIL_MARKER,
|
||||
detail_hash: SHA256_EMPTY,
|
||||
advertised_at: 0,
|
||||
status: "Active",
|
||||
};
|
||||
}
|
||||
|
||||
async function findOrPostAdvert(
|
||||
chain: LiveOgmiosProvider,
|
||||
walletKey: WalletKey,
|
||||
expected: AdvertDatum,
|
||||
savedRef: string,
|
||||
log: (line: string) => void,
|
||||
journal: (ref: OutputReference) => Promise<void>,
|
||||
): Promise<OutputReference> {
|
||||
const parsedSaved = parseRef(savedRef);
|
||||
if (parsedSaved) {
|
||||
const utxo = await chain.queryUtxo(parsedSaved);
|
||||
if (utxo?.datumHex) {
|
||||
assertAdvertMatches(decodeAdvertDatum(utxo.datumHex), expected);
|
||||
log(`reusing saved advert ${savedRef}`);
|
||||
return parsedSaved;
|
||||
}
|
||||
try {
|
||||
await chain.awaitTx(parsedSaved.txHash, 120_000);
|
||||
const confirmed = await chain.queryUtxo(parsedSaved);
|
||||
if (confirmed?.datumHex) {
|
||||
assertAdvertMatches(decodeAdvertDatum(confirmed.datumHex), expected);
|
||||
log(`reusing confirmed pending advert ${savedRef}`);
|
||||
return parsedSaved;
|
||||
}
|
||||
} catch {
|
||||
// The pending transaction is no longer viable; authoritative scan follows.
|
||||
}
|
||||
}
|
||||
|
||||
const advertAddress = loadBlueprint().advertScriptAddress(1);
|
||||
const advertUtxos = await chain.queryUtxosByAddress(advertAddress);
|
||||
const sameModel: Array<{ ref: OutputReference; datum: AdvertDatum }> = [];
|
||||
for (const utxo of advertUtxos) {
|
||||
if (!utxo.datumHex) continue;
|
||||
try {
|
||||
const datum = decodeAdvertDatum(utxo.datumHex);
|
||||
if (datum.status === "Active" && datum.model === expected.model) {
|
||||
sameModel.push({ ref: utxo.ref, datum });
|
||||
}
|
||||
} catch {
|
||||
// Ignore unrelated or malformed script outputs.
|
||||
}
|
||||
}
|
||||
const own = sameModel.filter(
|
||||
(candidate) => candidate.datum.supplier_pkh === expected.supplier_pkh,
|
||||
);
|
||||
if (own.length > 1) {
|
||||
throw new Error(`multiple active adverts already exist for ${expected.model}`);
|
||||
}
|
||||
if (own.length === 1) {
|
||||
assertAdvertMatches(own[0].datum, expected);
|
||||
log(`reusing discovered advert ${own[0].ref.txHash}#${own[0].ref.index}`);
|
||||
return own[0].ref;
|
||||
}
|
||||
|
||||
const result = await runPostAdvert({
|
||||
chain,
|
||||
walletKey,
|
||||
advertDatum: expected,
|
||||
log,
|
||||
beforeSubmit: async (built) => journal(built.advertOutputRef),
|
||||
});
|
||||
return result.advertRef;
|
||||
}
|
||||
|
||||
function assertAdvertMatches(actual: AdvertDatum, expected: AdvertDatum): void {
|
||||
const fieldsMatch =
|
||||
actual.status === "Active" &&
|
||||
actual.supplier_pkh === expected.supplier_pkh &&
|
||||
actual.capability_id === expected.capability_id &&
|
||||
actual.model === expected.model &&
|
||||
actual.max_output_tokens === expected.max_output_tokens &&
|
||||
actual.max_processing_ms === expected.max_processing_ms &&
|
||||
actual.price_lovelace === expected.price_lovelace &&
|
||||
actual.supplier_bond_lovelace === expected.supplier_bond_lovelace &&
|
||||
actual.buyer_bond_lovelace === expected.buyer_bond_lovelace &&
|
||||
actual.endpoint_url === expected.endpoint_url &&
|
||||
actual.detail_uri === expected.detail_uri &&
|
||||
actual.detail_hash === expected.detail_hash;
|
||||
if (!fieldsMatch) {
|
||||
throw new Error("saved advert does not match the reseller config");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function waitForFunding(
|
||||
chain: LiveOgmiosProvider,
|
||||
address: string,
|
||||
minimum: bigint,
|
||||
timeoutMs: number,
|
||||
log: (line: string) => void,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastReported = -1n;
|
||||
while (Date.now() < deadline) {
|
||||
const utxos = await chain.queryUtxosByAddress(address);
|
||||
const total = utxos.reduce((sum, utxo) => sum + utxo.lovelace, 0n);
|
||||
if (total >= minimum) return;
|
||||
if (total !== lastReported) {
|
||||
log(`wallet funding ${total}/${minimum} lovelace`);
|
||||
lastReported = total;
|
||||
}
|
||||
await new Promise<void>((resolveWait) => setTimeout(resolveWait, 5_000));
|
||||
}
|
||||
throw new Error(`wallet funding timed out for ${address}`);
|
||||
}
|
||||
|
||||
function resolveStatePath(
|
||||
configured: string | undefined,
|
||||
startStack: boolean,
|
||||
): string {
|
||||
const statePath = resolve(configured ?? DEFAULT_STATE_FILE);
|
||||
const repoRoot = REPO_ROOT;
|
||||
const fromRepo = relative(repoRoot, statePath);
|
||||
if (fromRepo === "" || (!fromRepo.startsWith("../") && fromRepo !== "..")) {
|
||||
throw new Error("RESELLER_STATE_FILE must be outside the repository");
|
||||
}
|
||||
if (startStack && statePath !== DEFAULT_STATE_FILE) {
|
||||
throw new Error(
|
||||
`stack startup requires the fixed state file ${DEFAULT_STATE_FILE}`,
|
||||
);
|
||||
}
|
||||
return statePath;
|
||||
}
|
||||
|
||||
async function readState(path: string): Promise<Record<string, string>> {
|
||||
let raw: string;
|
||||
try {
|
||||
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
const info = await handle.stat();
|
||||
if (!info.isFile()) {
|
||||
throw new Error(`reseller state must be a regular file: ${path}`);
|
||||
}
|
||||
if (typeof process.getuid === "function" && info.uid !== process.getuid()) {
|
||||
throw new Error(`reseller state is not owned by the current user: ${path}`);
|
||||
}
|
||||
if ((info.mode & 0o077) !== 0) {
|
||||
throw new Error(`reseller state permissions must be 0600: ${path}`);
|
||||
}
|
||||
raw = await handle.readFile("utf8");
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
||||
throw error;
|
||||
}
|
||||
const state: Record<string, string> = {};
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
if (line === "" || line.startsWith("#")) continue;
|
||||
const separator = line.indexOf("=");
|
||||
if (separator <= 0) throw new Error(`invalid reseller state line: ${line}`);
|
||||
state[line.slice(0, separator)] = line.slice(separator + 1);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
async function writeState(path: string, state: Record<string, string>): Promise<void> {
|
||||
for (const [name, value] of Object.entries(state)) {
|
||||
if (/[\r\n]/.test(name) || /[\r\n]/.test(value)) {
|
||||
throw new Error(`reseller state ${name} must be one line`);
|
||||
}
|
||||
}
|
||||
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
||||
await chmod(dirname(path), 0o700);
|
||||
const temp = `${path}.${randomBytes(12).toString("hex")}.tmp`;
|
||||
const body = Object.entries(state)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([name, value]) => `${name}=${value}`)
|
||||
.join("\n") + "\n";
|
||||
const handle = await open(temp, "wx", 0o600);
|
||||
try {
|
||||
await handle.writeFile(body, "utf8");
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await rename(temp, path);
|
||||
await chmod(path, 0o600);
|
||||
}
|
||||
|
||||
function parseRef(value: string): OutputReference | null {
|
||||
const match = /^([0-9a-fA-F]{64})#(0|[1-9]\d*)$/.exec(value);
|
||||
if (!match) return null;
|
||||
return { txHash: match[1], index: Number(match[2]) };
|
||||
}
|
||||
|
||||
async function runCompose(composeFile: string): Promise<void> {
|
||||
const args = [
|
||||
"compose",
|
||||
"-f",
|
||||
resolve(REPO_ROOT, composeFile),
|
||||
"up",
|
||||
"-d",
|
||||
"--build",
|
||||
"--wait",
|
||||
];
|
||||
await new Promise<void>((resolveRun, rejectRun) => {
|
||||
const child = spawn("docker", args, { stdio: "inherit" });
|
||||
child.once("error", rejectRun);
|
||||
child.once("exit", (code) => {
|
||||
if (code === 0) resolveRun();
|
||||
else rejectRun(new Error(`docker compose exited with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function main(
|
||||
argv: string[],
|
||||
env: Record<string, string | undefined>,
|
||||
): Promise<number> {
|
||||
try {
|
||||
const result = await runResellerSetup(parseSetupArgs(argv), env);
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(`reseller setup failed: ${message}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
const invoked = fileURLToPath(import.meta.url) === resolve(process.argv[1] ?? "");
|
||||
if (invoked) {
|
||||
void main(process.argv.slice(2), process.env).then((code) => {
|
||||
process.exitCode = code;
|
||||
});
|
||||
}
|
||||
209
supplier/src/cli/surplus-seller.ts
Normal file
209
supplier/src/cli/surplus-seller.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import {
|
||||
createServer,
|
||||
type IncomingMessage,
|
||||
type Server,
|
||||
type ServerResponse,
|
||||
} from "node:http";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolve } from "node:path";
|
||||
import { loadSurplusManagerConfig } from "../surplus/config.js";
|
||||
import { SurplusClient, type SurplusSale } from "../surplus/client.js";
|
||||
import { SurplusSellerController } from "../surplus/controller.js";
|
||||
import { OpenRouterKeyClient } from "../surplus/openRouterKey.js";
|
||||
import { FileSurplusStateStore } from "../surplus/state.js";
|
||||
import {
|
||||
SurplusDashboardService,
|
||||
type SurplusDashboardSnapshot,
|
||||
} from "../surplus/dashboard.js";
|
||||
import { FileSurplusVectorProofLedger } from "../surplus/vectorProof.js";
|
||||
import { loadHistoricalSalesCsv } from "../surplus/historyExport.js";
|
||||
import { SurplusVectorProofPublisher } from "../surplus/vectorPublisher.js";
|
||||
import { LiveOgmiosProvider } from "@marketplace/shared/chain";
|
||||
import type { WalletKey } from "@marketplace/shared/tx";
|
||||
import { buildAnchorMetadataTx } from "@marketplace/shared/tx/server";
|
||||
import { deriveKeypair } from "./gen-keypair.js";
|
||||
|
||||
export async function runSurplusSeller(
|
||||
env: Record<string, string | undefined>,
|
||||
): Promise<{
|
||||
server: Server;
|
||||
controller: SurplusSellerController;
|
||||
publisher: SurplusVectorProofPublisher | null;
|
||||
}> {
|
||||
const config = loadSurplusManagerConfig(env);
|
||||
const client = new SurplusClient({
|
||||
apiBaseUrl: config.apiBaseUrl,
|
||||
sellerApiKey: config.sellerApiKey,
|
||||
timeoutMs: config.requestTimeoutMs,
|
||||
});
|
||||
const allowance = new OpenRouterKeyClient({
|
||||
baseUrl: config.capacityBaseUrl,
|
||||
apiKey: config.providerApiKey,
|
||||
timeoutMs: config.requestTimeoutMs,
|
||||
});
|
||||
const stateStore = new FileSurplusStateStore(config.statePath);
|
||||
const controller = new SurplusSellerController({
|
||||
config,
|
||||
client,
|
||||
allowance,
|
||||
stateStore,
|
||||
log: (message) => console.log(message),
|
||||
});
|
||||
|
||||
let publisher: SurplusVectorProofPublisher | null = null;
|
||||
let proofs: FileSurplusVectorProofLedger | undefined;
|
||||
if (config.proof !== null) {
|
||||
const proofConfig = config.proof;
|
||||
const ledger = await FileSurplusVectorProofLedger.open(proofConfig.ledgerPath);
|
||||
proofs = ledger;
|
||||
const chain = new LiveOgmiosProvider({ ogmiosUrl: proofConfig.ogmiosUrl });
|
||||
const derived = deriveKeypair(proofConfig.walletPrivateKeyHex, 1);
|
||||
const walletKey: WalletKey = {
|
||||
pubKeyHash: derived.pubKeyHash,
|
||||
pubKeyHex: derived.publicKeyHex,
|
||||
privateKeyHex: derived.privateKeyHex,
|
||||
address: derived.address,
|
||||
};
|
||||
|
||||
let historicalSales: (() => Promise<readonly SurplusSale[]>) | undefined;
|
||||
if (proofConfig.historyCsvPath !== null && proofConfig.historyOfferId !== null) {
|
||||
const history = await loadHistoricalSalesCsv(
|
||||
proofConfig.historyCsvPath,
|
||||
proofConfig.historyOfferId,
|
||||
);
|
||||
console.log(
|
||||
`surplus-proof: loaded ${history.sales.length} settled export sale(s) from ` +
|
||||
`${proofConfig.historyCsvPath} (${history.settledWithoutTx} without a Base tx, ` +
|
||||
`${history.skippedUnsettled} unsettled rows skipped)`,
|
||||
);
|
||||
historicalSales = async () => history.sales;
|
||||
}
|
||||
publisher = new SurplusVectorProofPublisher({
|
||||
ledger,
|
||||
earnings: () => client.getEarnings(),
|
||||
historicalSales,
|
||||
anchor: async (metadata) => {
|
||||
const built = await buildAnchorMetadataTx({ chain, walletKey, metadata });
|
||||
return { expectedTxHash: built.expectedTxHash };
|
||||
},
|
||||
awaitTx: (txHash, timeoutMs) => chain.awaitTx(txHash, timeoutMs),
|
||||
balanceLovelace: async () => {
|
||||
const utxos = await chain.queryUtxosByAddress(walletKey.address);
|
||||
return utxos.reduce((sum, utxo) => sum + utxo.lovelace, 0n);
|
||||
},
|
||||
reserveLovelace: proofConfig.reserveLovelace,
|
||||
feeBudgetLovelace: proofConfig.feeBudgetLovelace,
|
||||
settledStatuses: config.settledStatuses,
|
||||
intervalMs: proofConfig.intervalMs,
|
||||
confirmTimeoutMs: proofConfig.confirmTimeoutMs,
|
||||
log: (message) => console.log(message),
|
||||
});
|
||||
console.log(
|
||||
`surplus-proof: publisher enabled with wallet ${derived.address} and ledger ${proofConfig.ledgerPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
const dashboard = new SurplusDashboardService({
|
||||
controller,
|
||||
client,
|
||||
allowance,
|
||||
config,
|
||||
proofs,
|
||||
});
|
||||
const server = createSurplusServer(controller, dashboard);
|
||||
await new Promise<void>((resolveListen, rejectListen) => {
|
||||
server.once("error", rejectListen);
|
||||
server.listen(config.port, "0.0.0.0", () => {
|
||||
server.off("error", rejectListen);
|
||||
resolveListen();
|
||||
});
|
||||
});
|
||||
await controller.start();
|
||||
publisher?.start();
|
||||
return { server, controller, publisher };
|
||||
}
|
||||
|
||||
interface DashboardSource {
|
||||
getSnapshot(): Promise<SurplusDashboardSnapshot>;
|
||||
}
|
||||
|
||||
export function createSurplusServer(
|
||||
controller: Pick<SurplusSellerController, "snapshot" | "healthy">,
|
||||
dashboard: DashboardSource,
|
||||
): Server {
|
||||
return createServer((request, response) => {
|
||||
void handleRequest(controller, dashboard, request, response);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRequest(
|
||||
controller: Pick<SurplusSellerController, "snapshot" | "healthy">,
|
||||
dashboard: DashboardSource,
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
): Promise<void> {
|
||||
const path = request.url?.split("?")[0] ?? "/";
|
||||
response.setHeader("cache-control", "no-store");
|
||||
response.setHeader("x-content-type-options", "nosniff");
|
||||
response.setHeader("x-frame-options", "DENY");
|
||||
response.setHeader("referrer-policy", "no-referrer");
|
||||
const status = controller.snapshot();
|
||||
if (request.method === "GET" && path === "/healthz") {
|
||||
const healthy = controller.healthy();
|
||||
response.statusCode = healthy ? 200 : 503;
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end(JSON.stringify({ ok: healthy, phase: status.phase }));
|
||||
return;
|
||||
}
|
||||
if (request.method === "GET" && path === "/status") {
|
||||
response.statusCode = 200;
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end(JSON.stringify(status));
|
||||
return;
|
||||
}
|
||||
if (request.method === "GET" && path === "/internal/resale-dashboard") {
|
||||
try {
|
||||
const snapshot = await dashboard.getSnapshot();
|
||||
response.statusCode = 200;
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end(JSON.stringify(snapshot));
|
||||
} catch {
|
||||
response.statusCode = 503;
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end(JSON.stringify({ error: "dashboard_data_unavailable" }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
response.statusCode = 404;
|
||||
response.setHeader("content-type", "application/json");
|
||||
response.end(JSON.stringify({ error: "not_found" }));
|
||||
}
|
||||
|
||||
export async function main(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): Promise<void> {
|
||||
const { server, controller, publisher } = await runSurplusSeller(env);
|
||||
console.log("Surplus seller controller listening");
|
||||
let shuttingDown = false;
|
||||
const shutdown = async (signal: string): Promise<void> => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
console.log(`Surplus seller controller shutting down (${signal})`);
|
||||
await publisher?.stop();
|
||||
await controller.stop();
|
||||
await new Promise<void>((resolveClose, rejectClose) => {
|
||||
server.close((error) => error ? rejectClose(error) : resolveClose());
|
||||
});
|
||||
};
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
}
|
||||
|
||||
const invoked = fileURLToPath(import.meta.url) === resolve(process.argv[1] ?? "");
|
||||
if (invoked) {
|
||||
main().catch((error) => {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
console.error(`Surplus seller controller failed: ${detail}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -15,7 +15,10 @@
|
||||
*
|
||||
* Required for CAPABILITY_KIND="chat" + LLM_BACKEND="openai":
|
||||
* OPENAI_BASE_URL — http(s):// URL for an OpenAI-compatible /v1/chat/completions
|
||||
* host (e.g. ChatMock localhost proxy backed by Codex OAuth).
|
||||
* host. Examples: ChatMock localhost proxy (Codex OAuth),
|
||||
* OpenRouter (https://openrouter.ai/api), or the HuggingFace
|
||||
* Inference Providers router (https://router.huggingface.co).
|
||||
* See docs/HUGGINGFACE_ROUTER_SETUP.md for the HF preset.
|
||||
*
|
||||
* Required for CAPABILITY_KIND="tts":
|
||||
* PIPER_URL — http(s):// URL for the openedai-speech-min PiperTTS host
|
||||
@@ -33,6 +36,15 @@
|
||||
* OLLAMA_TIMEOUT_MS — ollama request timeout in ms (default 120_000)
|
||||
* OPENAI_TIMEOUT_MS — openai request timeout in ms (default 180_000 — GPT
|
||||
* first-token latency is longer than local Ollama)
|
||||
* OPENAI_SESSION_PASSTHROUGH
|
||||
* — "1" switches chat-session turns to the stateful-
|
||||
* upstream contract (OpenClaw): escrow ref as the
|
||||
* OpenAI `user` field + delta-only messages per turn.
|
||||
* Strict "1" only. Default off. Requires
|
||||
* CAPABILITY_KIND="chat-session".
|
||||
* OPENAI_MODEL_OVERRIDE — fixed upstream model id sent instead of the
|
||||
* advert's buyer-facing model (OpenClaw accepts only
|
||||
* "openclaw" / "openclaw/<agentId>"). Default unset.
|
||||
* PIPER_TIMEOUT_MS — piper request timeout in ms (default 120_000)
|
||||
* LIVE_CHAIN — "1" opts in to live-chain submit/await (default off).
|
||||
* Any other value (including "true", "yes", "TRUE")
|
||||
@@ -50,6 +62,34 @@ const HEX64_RE = /^[0-9a-fA-F]{64}$/;
|
||||
const TX_HASH_RE = /^[0-9a-fA-F]{64}$/;
|
||||
const NON_NEG_INT_RE = /^(?:0|[1-9]\d*)$/;
|
||||
const POS_INT_RE = /^[1-9]\d*$/;
|
||||
const USD_RE = /^(?:0|[1-9]\d*)(?:\.\d{1,9})?$/;
|
||||
|
||||
/** Lowercased hostname of a URL, or "" if it doesn't parse. */
|
||||
function hostnameOf(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.toLowerCase();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function isOpenRouterApiBaseUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (
|
||||
url.protocol === "https:" &&
|
||||
url.hostname === "openrouter.ai" &&
|
||||
url.port === "" &&
|
||||
url.username === "" &&
|
||||
url.password === "" &&
|
||||
(url.pathname === "/api" || url.pathname === "/api/") &&
|
||||
url.search === "" &&
|
||||
url.hash === ""
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdvertRef {
|
||||
txHash: string;
|
||||
@@ -60,8 +100,26 @@ export interface AdvertRef {
|
||||
* "chat" — one-off `llm.text.generate.v1` (single prompt → single completion)
|
||||
* "tts" — `audio.synthesize.piper.v1`
|
||||
* "chat-session" — multi-turn `llm.chat.v1` (escrow bookends, off-chain turns,
|
||||
* streaming). Requires LLM_BACKEND="openai". */
|
||||
export type CapabilityKind = "chat" | "tts" | "chat-session";
|
||||
* streaming). Requires LLM_BACKEND="openai".
|
||||
* "ocr" — model-scoped `ocr.page.extract.<model-slug>.v1` (one page
|
||||
* image → one extraction). Upstream is an OpenAI-compatible
|
||||
* VISION endpoint (OPENAI_BASE_URL), e.g. vLLM serving
|
||||
* datalab-to/chandra-ocr-2. */
|
||||
export type CapabilityKind = "chat" | "tts" | "chat-session" | "ocr";
|
||||
|
||||
/** Served-checkpoint probe cadence for the ocr capability.
|
||||
* "per_job" — probe the upstream before every Claim (default when
|
||||
* REVISION_PROBE_URL is set, and always the default for the
|
||||
* datalab upstream, whose health route is derived).
|
||||
* "off" — no probing. */
|
||||
export type RevisionProbeMode = "per_job" | "off";
|
||||
|
||||
/** Upstream flavor for the ocr capability.
|
||||
* "openai-vision" — OpenAI-compatible vision endpoint (vLLM self-hosting;
|
||||
* supports the revision-pin attestation probe).
|
||||
* "datalab" — Datalab hosted document API (submit + poll; liveness
|
||||
* probe only — hosted supply has no checkpoint to pin). */
|
||||
export type OcrUpstream = "openai-vision" | "datalab";
|
||||
|
||||
/** LLM backend selected for the chat capability. */
|
||||
export type LlmBackend = "ollama" | "openai";
|
||||
@@ -73,6 +131,22 @@ export type LlmBackend = "ollama" | "openai";
|
||||
* the buyer's sweeper Reclaims the Open escrow after deliver_by. */
|
||||
export type ChatSettleMode = "full" | "ticket";
|
||||
|
||||
export type ResellerProvider = "openrouter";
|
||||
|
||||
export interface ResellerConfig {
|
||||
provider: ResellerProvider;
|
||||
databaseUrl: string;
|
||||
/** Fixed protected amount in provider USD credits. Parsed exactly at runtime. */
|
||||
reserveUsd: string;
|
||||
/** Conservative off-chain input bound advertised by /capability. */
|
||||
maxInputTokens: number;
|
||||
pollIntervalMs: number;
|
||||
staleAfterMs: number;
|
||||
providerTimeoutMs: number;
|
||||
previewMaxChars: number;
|
||||
}
|
||||
|
||||
|
||||
export interface SupplierConfig {
|
||||
supplierPrivKeyHex: string;
|
||||
ogmiosUrl: string;
|
||||
@@ -99,6 +173,28 @@ export interface SupplierConfig {
|
||||
* the model context or the provider 400s (e.g. kimi context = 262144).
|
||||
*/
|
||||
openaiMaxTokens: number;
|
||||
/**
|
||||
* When true, chat-session turns speak the STATEFUL-upstream contract
|
||||
* (e.g. an OpenClaw gateway): each turn sends the escrow ref as the OpenAI
|
||||
* `user` field (OpenClaw keys one persistent agent session per `user`, so
|
||||
* browser/tool state survives across turns) and sends ONLY the current
|
||||
* turn's delta messages — the upstream session carries the history, and
|
||||
* re-sending the full transcript would duplicate it into the upstream
|
||||
* context every turn. The supplier still accumulates the full transcript
|
||||
* locally for the receipt. Env: OPENAI_SESSION_PASSTHROUGH="1". Default
|
||||
* off (stateless upstreams get the full transcript each turn). Requires
|
||||
* capabilityKind="chat-session".
|
||||
*/
|
||||
openaiSessionPassthrough: boolean;
|
||||
/**
|
||||
* When non-empty, sent as the upstream `model` instead of the advert's
|
||||
* model id. For upstreams that reject arbitrary model strings — OpenClaw's
|
||||
* chat-completions endpoint 400s on anything but "openclaw" or
|
||||
* "openclaw/<agentId>" — while the advert carries the buyer-facing id the
|
||||
* gateway routes on. Env: OPENAI_MODEL_OVERRIDE. Default "" (advert model
|
||||
* sent verbatim).
|
||||
*/
|
||||
openaiModelOverride: string;
|
||||
/** Empty string when capabilityKind="chat". */
|
||||
piperUrl: string;
|
||||
advertRef: AdvertRef;
|
||||
@@ -123,6 +219,37 @@ export interface SupplierConfig {
|
||||
maxChatSessions: number;
|
||||
/** Chain settlement mode for chat sessions. Default "full". */
|
||||
chatSettleMode: ChatSettleMode;
|
||||
/**
|
||||
* Upstream flavor for the ocr capability. Env: OCR_UPSTREAM
|
||||
* ("openai-vision" default | "datalab").
|
||||
*/
|
||||
ocrUpstream: OcrUpstream;
|
||||
/** Datalab API key (X-API-Key). Required when ocrUpstream="datalab". */
|
||||
datalabApiKey: string;
|
||||
/** Datalab API origin. Env: DATALAB_BASE_URL, default https://www.datalab.to */
|
||||
datalabBaseUrl: string;
|
||||
/** Datalab quality mode: fast | balanced | accurate. Env: DATALAB_MODE. */
|
||||
datalabMode: string;
|
||||
/** Whole-call budget for submit+poll, ms. Env: DATALAB_TIMEOUT_MS. */
|
||||
datalabTimeoutMs: number;
|
||||
/**
|
||||
* Overrides the built-in per-format OCR instruction prompt when non-empty
|
||||
* (capabilityKind="ocr" + openai-vision upstream only). Env: OCR_PROMPT_OVERRIDE.
|
||||
*/
|
||||
ocrPromptOverride: string;
|
||||
/**
|
||||
* Expected served-checkpoint SHA (40-hex) for the ocr upstream. When set
|
||||
* together with revisionProbeUrl, a probe mismatch refuses the Claim.
|
||||
* Env: HF_MODEL_REVISION. "" = no pin.
|
||||
*/
|
||||
hfModelRevision: string;
|
||||
/**
|
||||
* Serving-runtime info route reporting the loaded checkpoint (TGI-style
|
||||
* /info or vLLM equivalent). Env: REVISION_PROBE_URL. "" = probing off.
|
||||
*/
|
||||
revisionProbeUrl: string;
|
||||
/** Probe cadence; see RevisionProbeMode. Env: REVISION_PROBE_MODE. */
|
||||
revisionProbeMode: RevisionProbeMode;
|
||||
/**
|
||||
* When true, the supplier boots a LiveOgmiosProvider (real submitTx/awaitTx).
|
||||
* Default false → ReadOnlyOgmiosProvider (safe; no chain writes).
|
||||
@@ -134,6 +261,8 @@ export interface SupplierConfig {
|
||||
* Default 600_000 (10 min). Only fires while liveChain=true.
|
||||
*/
|
||||
walletHealthIntervalMs: number;
|
||||
/** Optional balance-aware provider resale mode. Null preserves legacy behavior. */
|
||||
reseller: ResellerConfig | null;
|
||||
}
|
||||
|
||||
function requireField(env: Record<string, string | undefined>, name: string): string {
|
||||
@@ -174,8 +303,13 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
|
||||
// CAPABILITY_KIND drives whether OLLAMA_URL or PIPER_URL is required.
|
||||
// Default to "chat" so existing chat suppliers keep booting unchanged.
|
||||
const capKindStr = env.CAPABILITY_KIND ?? "chat";
|
||||
if (capKindStr !== "chat" && capKindStr !== "tts" && capKindStr !== "chat-session") {
|
||||
throw new Error('loadConfig: CAPABILITY_KIND must be "chat", "tts", or "chat-session"');
|
||||
if (
|
||||
capKindStr !== "chat" &&
|
||||
capKindStr !== "tts" &&
|
||||
capKindStr !== "chat-session" &&
|
||||
capKindStr !== "ocr"
|
||||
) {
|
||||
throw new Error('loadConfig: CAPABILITY_KIND must be "chat", "tts", "chat-session", or "ocr"');
|
||||
}
|
||||
const capabilityKind: CapabilityKind = capKindStr;
|
||||
|
||||
@@ -204,6 +338,19 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
|
||||
openaiBaseUrl = requireField(env, "OPENAI_BASE_URL");
|
||||
ollamaUrl = env.OLLAMA_URL ?? "";
|
||||
piperUrl = env.PIPER_URL ?? "";
|
||||
} else if (capabilityKind === "ocr") {
|
||||
// Upstream is either an OpenAI-compatible vision endpoint (self-hosted
|
||||
// vLLM; OPENAI_BASE_URL required) or the Datalab hosted API
|
||||
// (DATALAB_API_KEY required, validated below). Ollama and piper are
|
||||
// unused regardless of LLM_BACKEND.
|
||||
const upstream = env.OCR_UPSTREAM ?? "openai-vision";
|
||||
if (upstream === "openai-vision") {
|
||||
openaiBaseUrl = requireField(env, "OPENAI_BASE_URL");
|
||||
} else {
|
||||
openaiBaseUrl = env.OPENAI_BASE_URL ?? "";
|
||||
}
|
||||
ollamaUrl = env.OLLAMA_URL ?? "";
|
||||
piperUrl = env.PIPER_URL ?? "";
|
||||
} else if (capabilityKind === "chat") {
|
||||
if (llmBackend === "openai") {
|
||||
openaiBaseUrl = requireField(env, "OPENAI_BASE_URL");
|
||||
@@ -269,6 +416,24 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
|
||||
reasoningRaw === "0" ||
|
||||
reasoningRaw === "no";
|
||||
|
||||
// HuggingFace router guard. The `reasoning:{enabled:false}` param is
|
||||
// OpenRouter-specific; the HF Inference Providers router
|
||||
// (router.huggingface.co) rejects it with HTTP 400 on EVERY request. This is
|
||||
// the easy footgun when copying an OpenRouter env to the HF preset, so fail
|
||||
// fast at boot with a clear message instead of 400-ing every job.
|
||||
// See docs/HUGGINGFACE_ROUTER_SETUP.md.
|
||||
if (
|
||||
llmBackend === "openai" &&
|
||||
openaiReasoningDisabled &&
|
||||
hostnameOf(openaiBaseUrl) === "router.huggingface.co"
|
||||
) {
|
||||
throw new Error(
|
||||
"loadConfig: OPENAI_REASONING must be unset for the HuggingFace router " +
|
||||
"(OPENAI_BASE_URL=https://router.huggingface.co) — it rejects " +
|
||||
"reasoning:{enabled:false} with HTTP 400. Remove OPENAI_REASONING.",
|
||||
);
|
||||
}
|
||||
|
||||
// OPENAI_MAX_TOKENS — optional hard ceiling forwarded as `max_tokens`. Unset
|
||||
// or non-positive omits it (provider grants max available = context − prompt).
|
||||
// Keep it below the model context (kimi 262144) or the provider 400s when
|
||||
@@ -282,6 +447,22 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
|
||||
openaiMaxTokens = Number(maxTokStr);
|
||||
}
|
||||
|
||||
// OPENAI_SESSION_PASSTHROUGH — "1" switches chat-session turns to the
|
||||
// stateful-upstream contract (OpenClaw): escrow ref as the OpenAI `user`
|
||||
// field + delta-only messages (the upstream session carries history).
|
||||
// Strict "1" only, mirroring LIVE_CHAIN.
|
||||
const openaiSessionPassthrough = env.OPENAI_SESSION_PASSTHROUGH === "1";
|
||||
if (openaiSessionPassthrough && capabilityKind !== "chat-session") {
|
||||
throw new Error(
|
||||
'loadConfig: OPENAI_SESSION_PASSTHROUGH requires CAPABILITY_KIND="chat-session"',
|
||||
);
|
||||
}
|
||||
|
||||
// OPENAI_MODEL_OVERRIDE — fixed upstream model id sent instead of the
|
||||
// advert's (buyer-facing) model. Needed when the upstream validates the
|
||||
// model field (OpenClaw accepts only "openclaw" / "openclaw/<agentId>").
|
||||
const openaiModelOverride = env.OPENAI_MODEL_OVERRIDE ?? "";
|
||||
|
||||
// CHAT_IDLE_TIMEOUT_MS — idle auto-end window for chat sessions (default 5 min).
|
||||
const chatIdleStr = env.CHAT_IDLE_TIMEOUT_MS;
|
||||
let chatIdleTimeoutMs = 300_000;
|
||||
@@ -318,6 +499,52 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
|
||||
}
|
||||
const chatSettleMode: ChatSettleMode = settleModeStr;
|
||||
|
||||
// OCR-capability extras. All optional; harmless when set on other kinds
|
||||
// (ignored by the chat/tts/chat-session paths).
|
||||
const ocrUpstreamStr = env.OCR_UPSTREAM ?? "openai-vision";
|
||||
if (ocrUpstreamStr !== "openai-vision" && ocrUpstreamStr !== "datalab") {
|
||||
throw new Error('loadConfig: OCR_UPSTREAM must be "openai-vision" or "datalab"');
|
||||
}
|
||||
const ocrUpstream: OcrUpstream = ocrUpstreamStr;
|
||||
|
||||
const datalabApiKey = env.DATALAB_API_KEY ?? "";
|
||||
if (capabilityKind === "ocr" && ocrUpstream === "datalab" && datalabApiKey === "") {
|
||||
throw new Error('loadConfig: OCR_UPSTREAM="datalab" requires DATALAB_API_KEY');
|
||||
}
|
||||
const datalabBaseUrl = env.DATALAB_BASE_URL ?? "https://www.datalab.to";
|
||||
const datalabMode = env.DATALAB_MODE ?? "accurate";
|
||||
if (datalabMode !== "fast" && datalabMode !== "balanced" && datalabMode !== "accurate") {
|
||||
throw new Error('loadConfig: DATALAB_MODE must be "fast", "balanced", or "accurate"');
|
||||
}
|
||||
const datalabTimeoutStr = env.DATALAB_TIMEOUT_MS;
|
||||
let datalabTimeoutMs = 120_000;
|
||||
if (datalabTimeoutStr !== undefined && datalabTimeoutStr !== "") {
|
||||
if (!POS_INT_RE.test(datalabTimeoutStr)) {
|
||||
throw new Error("loadConfig: DATALAB_TIMEOUT_MS must be a positive integer");
|
||||
}
|
||||
datalabTimeoutMs = Number(datalabTimeoutStr);
|
||||
}
|
||||
|
||||
const ocrPromptOverride = env.OCR_PROMPT_OVERRIDE ?? "";
|
||||
const hfModelRevision = env.HF_MODEL_REVISION ?? "";
|
||||
if (hfModelRevision !== "" && !/^[0-9a-fA-F]{40}$/.test(hfModelRevision)) {
|
||||
throw new Error("loadConfig: HF_MODEL_REVISION must be a 40-hex commit SHA when set");
|
||||
}
|
||||
const revisionProbeUrl = env.REVISION_PROBE_URL ?? "";
|
||||
// Datalab upstream: the health route is derived from DATALAB_BASE_URL, so
|
||||
// the liveness probe defaults ON without a REVISION_PROBE_URL. The
|
||||
// openai-vision upstream keeps the explicit-URL requirement.
|
||||
const probeDefault =
|
||||
ocrUpstream === "datalab" || revisionProbeUrl !== "" ? "per_job" : "off";
|
||||
const probeModeStr = env.REVISION_PROBE_MODE ?? probeDefault;
|
||||
if (probeModeStr !== "per_job" && probeModeStr !== "off") {
|
||||
throw new Error('loadConfig: REVISION_PROBE_MODE must be "per_job" or "off"');
|
||||
}
|
||||
if (probeModeStr === "per_job" && ocrUpstream === "openai-vision" && revisionProbeUrl === "" && capabilityKind === "ocr") {
|
||||
throw new Error('loadConfig: REVISION_PROBE_MODE="per_job" requires REVISION_PROBE_URL for the openai-vision upstream');
|
||||
}
|
||||
const revisionProbeMode: RevisionProbeMode = probeModeStr;
|
||||
|
||||
const piperTimeoutStr = env.PIPER_TIMEOUT_MS;
|
||||
let piperTimeoutMs = 120_000;
|
||||
if (piperTimeoutStr !== undefined && piperTimeoutStr !== "") {
|
||||
@@ -342,6 +569,121 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
|
||||
walletHealthIntervalMs = Number(whIntervalStr);
|
||||
}
|
||||
|
||||
const resellerFields = [
|
||||
"RESELLER_DATABASE_URL",
|
||||
"RESELLER_RESERVE_USD",
|
||||
"RESELLER_MAX_INPUT_TOKENS",
|
||||
"RESELLER_POLL_INTERVAL_MS",
|
||||
"RESELLER_PROVIDER_TIMEOUT_MS",
|
||||
"RESELLER_PREVIEW_MAX_CHARS",
|
||||
] as const;
|
||||
const resellerProviderRaw = (env.RESELLER_PROVIDER ?? "").trim();
|
||||
const hasResellerFields = resellerFields.some(
|
||||
(name) => env[name] !== undefined && env[name] !== "",
|
||||
);
|
||||
let reseller: ResellerConfig | null = null;
|
||||
if (resellerProviderRaw === "") {
|
||||
if (hasResellerFields) {
|
||||
throw new Error(
|
||||
"loadConfig: RESELLER_PROVIDER is required when reseller fields are set",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (resellerProviderRaw !== "openrouter") {
|
||||
throw new Error('loadConfig: RESELLER_PROVIDER must be "openrouter"');
|
||||
}
|
||||
if (capabilityKind !== "chat" || llmBackend !== "openai") {
|
||||
throw new Error(
|
||||
'loadConfig: RESELLER_PROVIDER requires CAPABILITY_KIND="chat" and LLM_BACKEND="openai"',
|
||||
);
|
||||
}
|
||||
if (!isOpenRouterApiBaseUrl(openaiBaseUrl)) {
|
||||
throw new Error(
|
||||
"loadConfig: RESELLER_PROVIDER=openrouter requires OPENAI_BASE_URL=https://openrouter.ai/api",
|
||||
);
|
||||
}
|
||||
if (openaiApiKey === "") {
|
||||
throw new Error(
|
||||
"loadConfig: RESELLER_PROVIDER=openrouter requires OPENAI_API_KEY",
|
||||
);
|
||||
}
|
||||
if (openaiMaxTokens <= 0) {
|
||||
throw new Error(
|
||||
"loadConfig: RESELLER_PROVIDER requires a positive OPENAI_MAX_TOKENS",
|
||||
);
|
||||
}
|
||||
|
||||
const databaseUrl = requireField(env, "RESELLER_DATABASE_URL");
|
||||
let databaseProtocol = "";
|
||||
try {
|
||||
databaseProtocol = new URL(databaseUrl).protocol;
|
||||
} catch {
|
||||
throw new Error(
|
||||
"loadConfig: RESELLER_DATABASE_URL must be a valid PostgreSQL URL",
|
||||
);
|
||||
}
|
||||
if (databaseProtocol !== "postgres:" && databaseProtocol !== "postgresql:") {
|
||||
throw new Error(
|
||||
"loadConfig: RESELLER_DATABASE_URL must use postgres:// or postgresql://",
|
||||
);
|
||||
}
|
||||
|
||||
const reserveUsd = requireField(env, "RESELLER_RESERVE_USD").trim();
|
||||
if (!USD_RE.test(reserveUsd)) {
|
||||
throw new Error(
|
||||
"loadConfig: RESELLER_RESERVE_USD must be a non-negative decimal with at most 9 places",
|
||||
);
|
||||
}
|
||||
|
||||
const maxInputRaw = requireField(env, "RESELLER_MAX_INPUT_TOKENS");
|
||||
if (!POS_INT_RE.test(maxInputRaw) || !Number.isSafeInteger(Number(maxInputRaw))) {
|
||||
throw new Error(
|
||||
"loadConfig: RESELLER_MAX_INPUT_TOKENS must be a positive safe integer",
|
||||
);
|
||||
}
|
||||
|
||||
const pollRaw = env.RESELLER_POLL_INTERVAL_MS ?? "30000";
|
||||
if (!POS_INT_RE.test(pollRaw) || !Number.isSafeInteger(Number(pollRaw))) {
|
||||
throw new Error(
|
||||
"loadConfig: RESELLER_POLL_INTERVAL_MS must be a positive safe integer",
|
||||
);
|
||||
}
|
||||
const pollIntervalMs = Number(pollRaw);
|
||||
|
||||
const providerTimeoutRaw = env.RESELLER_PROVIDER_TIMEOUT_MS ?? "10000";
|
||||
if (
|
||||
!POS_INT_RE.test(providerTimeoutRaw) ||
|
||||
!Number.isSafeInteger(Number(providerTimeoutRaw))
|
||||
) {
|
||||
throw new Error(
|
||||
"loadConfig: RESELLER_PROVIDER_TIMEOUT_MS must be a positive safe integer",
|
||||
);
|
||||
}
|
||||
|
||||
const previewMaxRaw = env.RESELLER_PREVIEW_MAX_CHARS ?? "160";
|
||||
if (
|
||||
!POS_INT_RE.test(previewMaxRaw) ||
|
||||
!Number.isSafeInteger(Number(previewMaxRaw)) ||
|
||||
Number(previewMaxRaw) > 1000
|
||||
) {
|
||||
throw new Error(
|
||||
"loadConfig: RESELLER_PREVIEW_MAX_CHARS must be an integer from 1 to 1000",
|
||||
);
|
||||
}
|
||||
|
||||
reseller = {
|
||||
provider: "openrouter",
|
||||
databaseUrl,
|
||||
reserveUsd,
|
||||
maxInputTokens: Number(maxInputRaw),
|
||||
pollIntervalMs,
|
||||
staleAfterMs: Math.max(pollIntervalMs * 2, pollIntervalMs + 5000),
|
||||
providerTimeoutMs: Number(providerTimeoutRaw),
|
||||
previewMaxChars: Number(previewMaxRaw),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
supplierPrivKeyHex,
|
||||
ogmiosUrl,
|
||||
@@ -350,6 +692,8 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
|
||||
openaiApiKey,
|
||||
openaiReasoningDisabled,
|
||||
openaiMaxTokens,
|
||||
openaiSessionPassthrough,
|
||||
openaiModelOverride,
|
||||
piperUrl,
|
||||
advertRef,
|
||||
networkId,
|
||||
@@ -362,7 +706,17 @@ export function loadConfig(env: Record<string, string | undefined>): SupplierCon
|
||||
chatIdleTimeoutMs,
|
||||
maxChatSessions,
|
||||
chatSettleMode,
|
||||
ocrUpstream,
|
||||
datalabApiKey,
|
||||
datalabBaseUrl,
|
||||
datalabMode,
|
||||
datalabTimeoutMs,
|
||||
ocrPromptOverride,
|
||||
hfModelRevision,
|
||||
revisionProbeUrl,
|
||||
revisionProbeMode,
|
||||
liveChain,
|
||||
walletHealthIntervalMs,
|
||||
reseller,
|
||||
};
|
||||
}
|
||||
|
||||
249
supplier/src/datalabOcr.ts
Normal file
249
supplier/src/datalabOcr.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* supplier/src/datalabOcr.ts — HTTP client for the Datalab hosted document
|
||||
* API (https://www.datalab.to), used when OCR_UPSTREAM="datalab".
|
||||
*
|
||||
* Contract (verified against documentation.datalab.to, 2026-08-06):
|
||||
* POST {base}/api/v1/convert multipart/form-data, X-API-Key
|
||||
* fields: file (binary), output_format (markdown|html|json), mode
|
||||
* → { success, error, request_id, request_check_url }
|
||||
* GET {base}/api/v1/convert/{id} X-API-Key
|
||||
* → { status: "complete" | ..., markdown|html|json, page_count, error }
|
||||
*
|
||||
* callDatalabOcr() submits ONE page image and polls until complete, then
|
||||
* returns the SAME result shape as ocrVision.callOcrVision so runOcrJob's
|
||||
* receipt construction is shared. Token counts are pinned to 0 — the
|
||||
* Datalab API has no token semantics (mirrors the Piper/TTS convention);
|
||||
* wallclock_ms brackets submit→complete locally.
|
||||
*
|
||||
* JSON output: when output_format="json" the result field can be an object;
|
||||
* it is serialized with JSON.stringify to keep `content` a string. The
|
||||
* receipt commits sha256(canonical({content, output_format})) over exactly
|
||||
* the string the buyer receives, so determinism holds regardless.
|
||||
*
|
||||
* Error reasons (mirror the taxonomy of the other adapters):
|
||||
* "datalab_failure" — non-2xx, network error, success:false, job error
|
||||
* "datalab_timeout" — submit+poll exceeded timeoutMs
|
||||
* "datalab_malformed" — response JSON missing the expected fields
|
||||
*
|
||||
* probeDatalabHealth() — pre-Claim liveness gate for the route (the
|
||||
* revision-probe slot repurposed for hosted supply): GETs {base}/api/v1/health
|
||||
* with the API key; a non-OK answer refuses the Claim so escrows are not
|
||||
* stranded behind a dead upstream.
|
||||
*/
|
||||
|
||||
import type { OcrRequest } from "@marketplace/shared/tx";
|
||||
import type { OcrVisionResult } from "./ocrVision.js";
|
||||
|
||||
export interface CallDatalabOcrParams {
|
||||
/** API origin, no trailing slash. Default deployment: https://www.datalab.to */
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
request: OcrRequest;
|
||||
/** Whole-call budget: submit + polling until complete. */
|
||||
timeoutMs: number;
|
||||
/** Datalab quality mode: fast | balanced | accurate. */
|
||||
mode: string;
|
||||
/** Poll cadence; overridable in tests. Default 2000. */
|
||||
pollIntervalMs?: number;
|
||||
}
|
||||
|
||||
export type DatalabErrorReason = "datalab_failure" | "datalab_timeout" | "datalab_malformed";
|
||||
|
||||
export class DatalabError extends Error {
|
||||
public readonly reason: DatalabErrorReason;
|
||||
constructor(reason: DatalabErrorReason, message?: string) {
|
||||
super(message ?? reason);
|
||||
this.name = "DatalabError";
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
function isAbortError(err: unknown): boolean {
|
||||
if (err instanceof Error) {
|
||||
if (err.name === "AbortError") return true;
|
||||
if ((err as { code?: string }).code === "ABORT_ERR") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const EXT_BY_MIME: Record<string, string> = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/webp": "webp",
|
||||
};
|
||||
|
||||
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = setTimeout(resolve, ms);
|
||||
signal.addEventListener("abort", () => {
|
||||
clearTimeout(t);
|
||||
const err = new Error("aborted");
|
||||
err.name = "AbortError";
|
||||
reject(err);
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export async function callDatalabOcr(params: CallDatalabOcrParams): Promise<OcrVisionResult> {
|
||||
const { baseUrl, apiKey, request, timeoutMs, mode } = params;
|
||||
const pollIntervalMs = params.pollIntervalMs ?? 2_000;
|
||||
const base = baseUrl.replace(/\/+$/, "");
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
// ── 1. Submit ─────────────────────────────────────────────────────
|
||||
const form = new FormData();
|
||||
const bytes = Buffer.from(request.image_b64, "base64");
|
||||
const ext = EXT_BY_MIME[request.mime] ?? "png";
|
||||
form.append("file", new Blob([bytes], { type: request.mime }), `page.${ext}`);
|
||||
form.append("output_format", request.output_format);
|
||||
form.append("mode", mode);
|
||||
|
||||
let submitRes: Response;
|
||||
try {
|
||||
submitRes = await fetch(`${base}/api/v1/convert`, {
|
||||
method: "POST",
|
||||
headers: { "X-API-Key": apiKey },
|
||||
body: form,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) {
|
||||
throw new DatalabError("datalab_timeout", `Datalab submit exceeded ${timeoutMs}ms`);
|
||||
}
|
||||
throw new DatalabError("datalab_failure",
|
||||
`Datalab submit failed: ${(err as Error)?.message ?? String(err)}`);
|
||||
}
|
||||
if (!submitRes.ok) {
|
||||
let detail = "";
|
||||
try { detail = await submitRes.text(); } catch { /* body unavailable */ }
|
||||
throw new DatalabError("datalab_failure",
|
||||
`Datalab submit returned HTTP ${submitRes.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`);
|
||||
}
|
||||
|
||||
let submitBody: Record<string, unknown>;
|
||||
try {
|
||||
submitBody = await submitRes.json() as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new DatalabError("datalab_malformed", "Datalab submit response was not JSON");
|
||||
}
|
||||
if (submitBody.success !== true) {
|
||||
throw new DatalabError("datalab_failure",
|
||||
`Datalab submit success=false: ${String(submitBody.error ?? "no error detail")}`);
|
||||
}
|
||||
const requestId = submitBody.request_id;
|
||||
if (typeof requestId !== "string" || requestId.length === 0) {
|
||||
throw new DatalabError("datalab_malformed", "Datalab submit response missing request_id");
|
||||
}
|
||||
|
||||
// ── 2. Poll until complete ────────────────────────────────────────
|
||||
const checkUrl = typeof submitBody.request_check_url === "string" && submitBody.request_check_url.length > 0
|
||||
? submitBody.request_check_url
|
||||
: `${base}/api/v1/convert/${requestId}`;
|
||||
|
||||
for (;;) {
|
||||
let pollRes: Response;
|
||||
try {
|
||||
pollRes = await fetch(checkUrl, {
|
||||
headers: { "X-API-Key": apiKey },
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) {
|
||||
throw new DatalabError("datalab_timeout", `Datalab poll exceeded ${timeoutMs}ms budget`);
|
||||
}
|
||||
throw new DatalabError("datalab_failure",
|
||||
`Datalab poll failed: ${(err as Error)?.message ?? String(err)}`);
|
||||
}
|
||||
if (!pollRes.ok) {
|
||||
throw new DatalabError("datalab_failure", `Datalab poll returned HTTP ${pollRes.status}`);
|
||||
}
|
||||
let poll: Record<string, unknown>;
|
||||
try {
|
||||
poll = await pollRes.json() as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new DatalabError("datalab_malformed", "Datalab poll response was not JSON");
|
||||
}
|
||||
|
||||
const status = typeof poll.status === "string" ? poll.status.toLowerCase() : "";
|
||||
if (status === "complete") {
|
||||
if (poll.error && typeof poll.error === "string" && poll.error.length > 0) {
|
||||
throw new DatalabError("datalab_failure", `Datalab conversion error: ${poll.error}`);
|
||||
}
|
||||
const raw = poll[request.output_format];
|
||||
let content: string;
|
||||
if (typeof raw === "string") {
|
||||
content = raw;
|
||||
} else if (raw !== null && raw !== undefined && typeof raw === "object") {
|
||||
content = JSON.stringify(raw);
|
||||
} else {
|
||||
throw new DatalabError("datalab_malformed",
|
||||
`Datalab result missing "${request.output_format}" field`);
|
||||
}
|
||||
if (content.length === 0) {
|
||||
throw new DatalabError("datalab_malformed", "Datalab result content is empty");
|
||||
}
|
||||
return {
|
||||
content,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
wallclock_ms: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
if (status === "failed" || status === "error") {
|
||||
throw new DatalabError("datalab_failure",
|
||||
`Datalab conversion status=${status}: ${String(poll.error ?? "no detail")}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await sleep(pollIntervalMs, controller.signal);
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) {
|
||||
throw new DatalabError("datalab_timeout", `Datalab poll exceeded ${timeoutMs}ms budget`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Liveness probe (pre-Claim gate for hosted supply) ─────────────────────
|
||||
|
||||
export interface ProbeDatalabParams {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface ProbeDatalabResult {
|
||||
ok: boolean;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export async function probeDatalabHealth(params: ProbeDatalabParams): Promise<ProbeDatalabResult> {
|
||||
const base = params.baseUrl.replace(/\/+$/, "");
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), params.timeoutMs);
|
||||
try {
|
||||
const res = await fetch(`${base}/api/v1/health`, {
|
||||
headers: { "X-API-Key": params.apiKey },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { ok: false, detail: `health HTTP ${res.status}` };
|
||||
}
|
||||
return { ok: true, detail: "health ok" };
|
||||
} catch (err) {
|
||||
const msg = isAbortError(err)
|
||||
? `health probe exceeded ${params.timeoutMs}ms`
|
||||
: `health probe failed: ${(err as Error)?.message ?? String(err)}`;
|
||||
return { ok: false, detail: msg };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,12 @@ import { JobStore } from "./jobs.js";
|
||||
import { createApp, type SupplierDeps } from "./server.js";
|
||||
import { buildChainProvider } from "./chain.js";
|
||||
import { startWalletHealthTicker } from "./walletHealth.js";
|
||||
import { decodeAdvertDatum } from "@marketplace/shared/cbor";
|
||||
import { BOUNDED_INPUT_DETAIL_MARKER } from "@marketplace/shared/tx";
|
||||
import { OpenRouterCapacityProvider } from "./reseller/openRouterCapacity.js";
|
||||
import { CapacityGate } from "./reseller/capacityGate.js";
|
||||
import { ResellerEvidenceStore } from "./reseller/evidenceStore.js";
|
||||
import { ResellerRuntime } from "./reseller/runtime.js";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const cfg = loadConfig(process.env);
|
||||
@@ -45,8 +51,74 @@ async function main(): Promise<void> {
|
||||
// chat-session kind serves N concurrent sessions; one-shot kinds stay 1.
|
||||
const state = new SupplierState(cfg.capabilityKind === "chat-session" ? cfg.maxChatSessions : 1);
|
||||
const jobs = new JobStore();
|
||||
let reseller: ResellerRuntime | undefined;
|
||||
if (cfg.reseller) {
|
||||
const advertUtxo = await chain.queryUtxo(cfg.advertRef);
|
||||
if (!advertUtxo?.datumHex) {
|
||||
throw new Error("reseller boot: configured advert UTxO is unavailable");
|
||||
}
|
||||
const advert = decodeAdvertDatum(advertUtxo.datumHex);
|
||||
if (advert.status !== "Active") {
|
||||
throw new Error(`reseller boot: advert status is ${advert.status}`);
|
||||
}
|
||||
if (advert.capability_id !== "llm.text.generate.v1") {
|
||||
throw new Error(
|
||||
`reseller boot: advert capability must be llm.text.generate.v1, got ${advert.capability_id}`,
|
||||
);
|
||||
}
|
||||
if (!advert.detail_uri.endsWith(BOUNDED_INPUT_DETAIL_MARKER)) {
|
||||
throw new Error("reseller boot: advert is missing bounded-input metadata");
|
||||
}
|
||||
if (advert.max_output_tokens !== cfg.openaiMaxTokens) {
|
||||
throw new Error(
|
||||
"reseller boot: OPENAI_MAX_TOKENS must equal advert max_output_tokens",
|
||||
);
|
||||
}
|
||||
if (advert.supplier_pkh !== supplierKey.pubKeyHash) {
|
||||
throw new Error("reseller boot: advert supplier does not match SUPPLIER_PKH");
|
||||
}
|
||||
|
||||
const deps: SupplierDeps = { chain, state, config: cfg, supplierKey, jobs };
|
||||
const providerModel = cfg.openaiModelOverride || advert.model;
|
||||
const provider = new OpenRouterCapacityProvider({
|
||||
baseUrl: cfg.openaiBaseUrl,
|
||||
apiKey: cfg.openaiApiKey,
|
||||
timeoutMs: cfg.reseller.providerTimeoutMs,
|
||||
});
|
||||
const gate = new CapacityGate({
|
||||
provider,
|
||||
model: providerModel,
|
||||
reserveUsd: cfg.reseller.reserveUsd,
|
||||
maxInputTokens: cfg.reseller.maxInputTokens,
|
||||
maxOutputTokens: advert.max_output_tokens,
|
||||
staleAfterMs: cfg.reseller.staleAfterMs,
|
||||
});
|
||||
const store = new ResellerEvidenceStore(
|
||||
cfg.reseller.databaseUrl,
|
||||
cfg.reseller.previewMaxChars,
|
||||
);
|
||||
reseller = new ResellerRuntime({
|
||||
chain,
|
||||
state,
|
||||
gate,
|
||||
store,
|
||||
advert,
|
||||
advertRef: cfg.advertRef,
|
||||
providerModel,
|
||||
pollIntervalMs: cfg.reseller.pollIntervalMs,
|
||||
maxInputTokens: cfg.reseller.maxInputTokens,
|
||||
});
|
||||
await reseller.initialize();
|
||||
}
|
||||
|
||||
|
||||
const deps: SupplierDeps = {
|
||||
chain,
|
||||
state,
|
||||
config: cfg,
|
||||
supplierKey,
|
||||
jobs,
|
||||
reseller,
|
||||
};
|
||||
const app = createApp(deps);
|
||||
|
||||
const server = app.listen(cfg.port, () => {
|
||||
@@ -80,7 +152,9 @@ async function main(): Promise<void> {
|
||||
clearInterval(evictInterval);
|
||||
walletHealthTicker?.stop();
|
||||
state.markOffline();
|
||||
server.close(() => process.exit(0));
|
||||
server.close(() => {
|
||||
void (reseller?.close() ?? Promise.resolve()).finally(() => process.exit(0));
|
||||
});
|
||||
setTimeout(() => process.exit(1), 5_000).unref();
|
||||
};
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
|
||||
@@ -38,12 +38,18 @@ import type { SupplierConfig } from "./config.js";
|
||||
import * as ollama from "./ollama.js";
|
||||
import * as openai from "./openai.js";
|
||||
import * as piper from "./piper.js";
|
||||
import * as ocrVision from "./ocrVision.js";
|
||||
import * as datalabOcr from "./datalabOcr.js";
|
||||
import type { OcrRequest } from "@marketplace/shared/tx";
|
||||
import type {
|
||||
JobStore,
|
||||
JobResponsePayload,
|
||||
ChatJobResponsePayload,
|
||||
TtsJobResponsePayload,
|
||||
OcrJobResponsePayload,
|
||||
} from "./jobs.js";
|
||||
import type { ResellerRuntime } from "./reseller/runtime.js";
|
||||
import { parseUsdNumberCeil } from "./reseller/money.js";
|
||||
|
||||
export interface RunChatJobDeps {
|
||||
chain: ChainProvider;
|
||||
@@ -51,6 +57,7 @@ export interface RunChatJobDeps {
|
||||
config: SupplierConfig;
|
||||
supplierKey: WalletKey;
|
||||
jobs: JobStore;
|
||||
reseller?: ResellerRuntime;
|
||||
}
|
||||
|
||||
export interface RunChatJobParams {
|
||||
@@ -67,6 +74,15 @@ function sha256Hex(s: string): string {
|
||||
return createHash("sha256").update(s, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
async function recordResellerFailure(
|
||||
reseller: ResellerRuntime | undefined,
|
||||
escrowRef: string,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
if (!reseller) return;
|
||||
await reseller.recordFailed(escrowRef, reason).catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Ollama → receipt → Submit in the background.
|
||||
* Always resolves (never rejects). Terminal state written to jobs store.
|
||||
@@ -79,16 +95,36 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
|
||||
deps.jobs.setRunning(jobId);
|
||||
|
||||
try {
|
||||
if (deps.reseller) {
|
||||
try {
|
||||
await deps.reseller.recordInferenceStarted(escrowRef);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
deps.jobs.fail(jobId, {
|
||||
httpStatus: 503,
|
||||
reason: "evidence_unavailable",
|
||||
message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Call upstream LLM ───────────────────────────────────────────
|
||||
// Both backends return the same { content, prompt_tokens,
|
||||
// completion_tokens, wallclock_ms } shape so receipt construction
|
||||
// downstream is identical.
|
||||
let inference: { content: string; prompt_tokens: number; completion_tokens: number; wallclock_ms: number };
|
||||
let inference: {
|
||||
content: string;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
wallclock_ms: number;
|
||||
cost_usd?: number;
|
||||
};
|
||||
try {
|
||||
if (deps.config.llmBackend === "openai") {
|
||||
inference = await openai.callOpenAi({
|
||||
baseUrl: deps.config.openaiBaseUrl,
|
||||
model: advert.model,
|
||||
model: deps.config.openaiModelOverride || advert.model,
|
||||
messages: requestBody.messages,
|
||||
timeoutMs: deps.config.openaiTimeoutMs,
|
||||
apiKey: deps.config.openaiApiKey,
|
||||
@@ -123,6 +159,7 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
|
||||
reason: collapsedReason,
|
||||
message,
|
||||
});
|
||||
await recordResellerFailure(deps.reseller, escrowRef, collapsedReason);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -142,8 +179,39 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
|
||||
});
|
||||
const signed = signReceipt(receipt, deps.supplierKey.privateKeyHex);
|
||||
const resultHash = receiptResultHash(signed);
|
||||
if (deps.reseller) {
|
||||
try {
|
||||
await deps.reseller.recordOutput(escrowRef, {
|
||||
response: assistantMessage,
|
||||
receipt: {
|
||||
receipt: signed.receipt,
|
||||
signature: signed.signature,
|
||||
},
|
||||
promptTokens: inference.prompt_tokens,
|
||||
completionTokens: inference.completion_tokens,
|
||||
outputText: inference.content,
|
||||
actualCostUsdNanos: inference.cost_usd === undefined
|
||||
? null
|
||||
: parseUsdNumberCeil(inference.cost_usd, "OpenRouter usage cost"),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
deps.jobs.fail(jobId, {
|
||||
httpStatus: 503,
|
||||
reason: "evidence_unavailable",
|
||||
message,
|
||||
});
|
||||
await recordResellerFailure(
|
||||
deps.reseller,
|
||||
escrowRef,
|
||||
"output_persistence_failed",
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4+5. Submit tx: build + confirm as ONE wallet critical section ──
|
||||
|
||||
// ── 4+5. Broadcast, journal, then confirm Submit under one wallet lock ─
|
||||
const submitOutcome = await deps.state.walletMutex.run(async () => {
|
||||
let built: { txCborHex: string; expectedTxHash: string };
|
||||
try {
|
||||
@@ -152,19 +220,30 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
|
||||
supplierKey: deps.supplierKey,
|
||||
escrowRef: claimedRef,
|
||||
receiptHash: resultHash,
|
||||
beforeSubmit: deps.reseller
|
||||
? async (candidate) => {
|
||||
await deps.reseller!.recordSubmitting(
|
||||
escrowRef,
|
||||
`${candidate.expectedTxHash}#0`,
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
return { kind: "build_failed" as const, err };
|
||||
}
|
||||
const submittedRef = `${built.expectedTxHash}#0`;
|
||||
try {
|
||||
await deps.chain.awaitTx(built.expectedTxHash, 60_000);
|
||||
} catch (err) {
|
||||
return { kind: "await_failed" as const, err };
|
||||
return { kind: "await_failed" as const, err, built, submittedRef };
|
||||
}
|
||||
return { kind: "ok" as const };
|
||||
return { kind: "ok" as const, built, submittedRef };
|
||||
});
|
||||
if (submitOutcome.kind === "build_failed") {
|
||||
const message = submitOutcome.err instanceof Error ? submitOutcome.err.message : String(submitOutcome.err);
|
||||
const message = submitOutcome.err instanceof Error
|
||||
? submitOutcome.err.message
|
||||
: String(submitOutcome.err);
|
||||
console.warn(
|
||||
`[job_failed] jobId=${jobId} reason=submit_failed httpStatus=502 msg=${message}`,
|
||||
);
|
||||
@@ -173,20 +252,42 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
|
||||
reason: "submit_failed",
|
||||
message: `Submit tx failed: ${message}`,
|
||||
});
|
||||
await recordResellerFailure(deps.reseller, escrowRef, "submit_failed");
|
||||
return;
|
||||
}
|
||||
if (submitOutcome.kind === "await_failed") {
|
||||
const msg = submitOutcome.err instanceof Error ? submitOutcome.err.message : String(submitOutcome.err);
|
||||
const message = submitOutcome.err instanceof Error
|
||||
? submitOutcome.err.message
|
||||
: String(submitOutcome.err);
|
||||
console.warn(
|
||||
`[job_failed] jobId=${jobId} reason=submit_timeout httpStatus=502 msg=${msg}`,
|
||||
`[job_failed] jobId=${jobId} reason=submit_timeout httpStatus=502 msg=${message}`,
|
||||
);
|
||||
deps.jobs.fail(jobId, {
|
||||
httpStatus: 502,
|
||||
reason: "submit_timeout",
|
||||
message: `Submit awaitTx failed: ${msg}`,
|
||||
message: `Submit awaitTx failed: ${message}`,
|
||||
});
|
||||
deps.reseller?.watchSubmitting(escrowRef, submitOutcome.submittedRef);
|
||||
return;
|
||||
}
|
||||
if (deps.reseller) {
|
||||
try {
|
||||
await deps.reseller.recordSubmitConfirmed(
|
||||
escrowRef,
|
||||
submitOutcome.submittedRef,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
deps.jobs.fail(jobId, {
|
||||
httpStatus: 503,
|
||||
reason: "evidence_unavailable",
|
||||
message,
|
||||
});
|
||||
deps.reseller.watchSubmitting(escrowRef, submitOutcome.submittedRef);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── 6. Mark complete ────────────────────────────────────────────────
|
||||
const payload: ChatJobResponsePayload = {
|
||||
@@ -208,6 +309,9 @@ export async function runChatJob(params: RunChatJobParams): Promise<void> {
|
||||
};
|
||||
deps.jobs.complete(jobId, payload);
|
||||
} finally {
|
||||
if (deps.reseller) {
|
||||
await deps.reseller.refreshCapacity().catch(() => undefined);
|
||||
}
|
||||
// ── 7. Always release this job's slot ───────────────────────────────
|
||||
try {
|
||||
deps.state.release(escrowRef);
|
||||
@@ -364,3 +468,173 @@ export async function runTtsJob(params: RunTtsJobParams): Promise<void> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── runOcrJob ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Mirror of runTtsJob for model-scoped `ocr.page.extract.<model-slug>.v1`
|
||||
// capabilities. Same 7-step shape (setRunning → upstream → receipt →
|
||||
// buildSubmit → awaitTx → complete → finally release). Differences from the
|
||||
// TTS path:
|
||||
//
|
||||
// - Upstream is `ocrVision.callOcrVision` against an OpenAI-compatible
|
||||
// VISION endpoint (vLLM serving e.g. datalab-to/chandra-ocr-2); the
|
||||
// one-page image rides as a data-URL content part.
|
||||
// - response_hash = sha256(canonical({content, output_format})) — commits
|
||||
// the extraction text AND the shape it was requested in.
|
||||
// - prompt_tokens / completion_tokens come from upstream usage (vision
|
||||
// tokens are real tokens, unlike Piper).
|
||||
|
||||
export interface RunOcrJobParams {
|
||||
deps: RunChatJobDeps;
|
||||
jobId: string;
|
||||
escrowRef: string;
|
||||
claimedRef: OutputReference;
|
||||
advert: AdvertDatum;
|
||||
escrowDatum: EscrowDatum;
|
||||
/** The OCR request envelope. The PROMPT_HASH committed by the buyer in
|
||||
* the escrow datum is sha256(canonicalize(this same object)), validated
|
||||
* by the route before Claim. */
|
||||
requestBody: OcrRequest;
|
||||
}
|
||||
|
||||
export async function runOcrJob(params: RunOcrJobParams): Promise<void> {
|
||||
const { deps, jobId, escrowRef, claimedRef, advert, escrowDatum, requestBody } = params;
|
||||
|
||||
deps.jobs.setRunning(jobId);
|
||||
|
||||
try {
|
||||
// ── 2. Call the OCR upstream (openai-vision or datalab) ───────────
|
||||
let inference: ocrVision.OcrVisionResult;
|
||||
try {
|
||||
if (deps.config.ocrUpstream === "datalab") {
|
||||
inference = await datalabOcr.callDatalabOcr({
|
||||
baseUrl: deps.config.datalabBaseUrl,
|
||||
apiKey: deps.config.datalabApiKey,
|
||||
request: requestBody,
|
||||
timeoutMs: deps.config.datalabTimeoutMs,
|
||||
mode: deps.config.datalabMode,
|
||||
});
|
||||
} else {
|
||||
const configuredMax = deps.config.openaiMaxTokens;
|
||||
const maxTokens =
|
||||
configuredMax > 0
|
||||
? Math.min(configuredMax, advert.max_output_tokens)
|
||||
: advert.max_output_tokens;
|
||||
inference = await ocrVision.callOcrVision({
|
||||
baseUrl: deps.config.openaiBaseUrl,
|
||||
model: advert.model,
|
||||
request: requestBody,
|
||||
timeoutMs: deps.config.openaiTimeoutMs,
|
||||
apiKey: deps.config.openaiApiKey,
|
||||
maxTokens,
|
||||
promptOverride: deps.config.ocrPromptOverride,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const rawReason =
|
||||
(err as ocrVision.OcrVisionError | datalabOcr.DatalabError)?.reason ??
|
||||
(deps.config.ocrUpstream === "datalab" ? "datalab_failure" : "ocr_failure");
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// *_timeout collapses to the failure reason for jobs.fail, matching
|
||||
// the chat/tts runner failure-code table (502 only).
|
||||
const collapsedReason =
|
||||
rawReason === "ocr_timeout" ? "ocr_failure"
|
||||
: rawReason === "datalab_timeout" ? "datalab_failure"
|
||||
: rawReason;
|
||||
console.warn(
|
||||
`[job_failed] jobId=${jobId} reason=${rawReason} httpStatus=502 msg=${message}`,
|
||||
);
|
||||
deps.jobs.fail(jobId, {
|
||||
httpStatus: 502,
|
||||
reason: collapsedReason,
|
||||
message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 3. Build + sign receipt ───────────────────────────────────────
|
||||
const responseHash = sha256Hex(canonicalize({
|
||||
content: inference.content,
|
||||
output_format: requestBody.output_format,
|
||||
}));
|
||||
|
||||
const receipt = buildReceipt({
|
||||
prompt_hash: escrowDatum.prompt_hash,
|
||||
response_hash: responseHash,
|
||||
model: advert.model,
|
||||
prompt_tokens: inference.prompt_tokens,
|
||||
completion_tokens: inference.completion_tokens,
|
||||
wallclock_ms: inference.wallclock_ms,
|
||||
supplier_pkh: deps.supplierKey.pubKeyHash,
|
||||
escrow_ref: escrowRef,
|
||||
});
|
||||
const signed = signReceipt(receipt, deps.supplierKey.privateKeyHex);
|
||||
const resultHash = receiptResultHash(signed);
|
||||
|
||||
// ── 4+5. Submit tx: build + confirm as ONE wallet critical section ──
|
||||
const submitOutcome = await deps.state.walletMutex.run(async () => {
|
||||
let built: { txCborHex: string; expectedTxHash: string };
|
||||
try {
|
||||
built = await buildSubmitTx({
|
||||
chain: deps.chain,
|
||||
supplierKey: deps.supplierKey,
|
||||
escrowRef: claimedRef,
|
||||
receiptHash: resultHash,
|
||||
});
|
||||
} catch (err) {
|
||||
return { kind: "build_failed" as const, err };
|
||||
}
|
||||
try {
|
||||
await deps.chain.awaitTx(built.expectedTxHash, 60_000);
|
||||
} catch (err) {
|
||||
return { kind: "await_failed" as const, err };
|
||||
}
|
||||
return { kind: "ok" as const };
|
||||
});
|
||||
if (submitOutcome.kind === "build_failed") {
|
||||
const message = submitOutcome.err instanceof Error ? submitOutcome.err.message : String(submitOutcome.err);
|
||||
console.warn(
|
||||
`[job_failed] jobId=${jobId} reason=submit_failed httpStatus=502 msg=${message}`,
|
||||
);
|
||||
deps.jobs.fail(jobId, {
|
||||
httpStatus: 502,
|
||||
reason: "submit_failed",
|
||||
message: `Submit tx failed: ${message}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (submitOutcome.kind === "await_failed") {
|
||||
const msg = submitOutcome.err instanceof Error ? submitOutcome.err.message : String(submitOutcome.err);
|
||||
console.warn(
|
||||
`[job_failed] jobId=${jobId} reason=submit_timeout httpStatus=502 msg=${msg}`,
|
||||
);
|
||||
deps.jobs.fail(jobId, {
|
||||
httpStatus: 502,
|
||||
reason: "submit_timeout",
|
||||
message: `Submit awaitTx failed: ${msg}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 6. Mark complete ──────────────────────────────────────────────
|
||||
const payload: OcrJobResponsePayload = {
|
||||
kind: "ocr",
|
||||
output_format: requestBody.output_format,
|
||||
content: inference.content,
|
||||
usage: {
|
||||
prompt_tokens: inference.prompt_tokens,
|
||||
completion_tokens: inference.completion_tokens,
|
||||
total_tokens: inference.prompt_tokens + inference.completion_tokens,
|
||||
},
|
||||
receipt: signed.receipt as unknown as Record<string, unknown>,
|
||||
receipt_signature: signed.signature,
|
||||
};
|
||||
deps.jobs.complete(jobId, payload);
|
||||
} finally {
|
||||
try {
|
||||
deps.state.release(escrowRef);
|
||||
} catch {
|
||||
/* never let lock-release escape */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,13 +63,35 @@ export interface TtsJobResponsePayload {
|
||||
receipt_signature: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OCR-flavoured terminal payload. Stored when a `runOcrJob` succeeds.
|
||||
* `content` is the extraction in the requested output_format (markdown/
|
||||
* html/json as text). The receipt's response_hash commits
|
||||
* sha256(canonical({content, output_format})).
|
||||
*/
|
||||
export interface OcrJobResponsePayload {
|
||||
kind: "ocr";
|
||||
output_format: string; // "markdown" | "html" | "json"
|
||||
content: string;
|
||||
usage: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
receipt: Record<string, unknown>;
|
||||
receipt_signature: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminated union of every job-payload shape the store can hold.
|
||||
* `kind` defaults to "chat" on the chat shape (older code paths don't
|
||||
* set it; new `runChatJob` writes can set kind: "chat" explicitly to make
|
||||
* the discriminator narrowing reliable).
|
||||
*/
|
||||
export type JobResponsePayload = ChatJobResponsePayload | TtsJobResponsePayload;
|
||||
export type JobResponsePayload =
|
||||
| ChatJobResponsePayload
|
||||
| TtsJobResponsePayload
|
||||
| OcrJobResponsePayload;
|
||||
|
||||
export interface JobFailure {
|
||||
httpStatus: number;
|
||||
|
||||
271
supplier/src/ocrVision.ts
Normal file
271
supplier/src/ocrVision.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* supplier/src/ocrVision.ts — HTTP client for an OpenAI-compatible VISION
|
||||
* /v1/chat/completions endpoint (vLLM serving an OCR VLM, e.g.
|
||||
* datalab-to/chandra-ocr-2). Used by the `ocr` capability kind.
|
||||
*
|
||||
* callOcrVision({ baseUrl, model, request, timeoutMs, ... })
|
||||
* POST ${baseUrl}/v1/chat/completions with a single user message whose
|
||||
* content is OpenAI vision content parts:
|
||||
* [{type:"image_url", image_url:{url:"data:<mime>;base64,<b64>"}},
|
||||
* {type:"text", text:<instruction for the output format>}]
|
||||
* Returns { content, prompt_tokens, completion_tokens, wallclock_ms }
|
||||
* — the same shape as ollama.ts / openai.ts so receipt construction is
|
||||
* identical downstream.
|
||||
*
|
||||
* The instruction text per output_format is a serving-side implementation
|
||||
* detail (like temperature); the buyer's on-chain commitment covers the
|
||||
* OcrRequest envelope, not the serving prompt. The exact Chandra prompt
|
||||
* convention gets pinned against a live endpoint during integration —
|
||||
* override per deployment via OCR_PROMPT_OVERRIDE without a code change.
|
||||
*
|
||||
* Error reasons:
|
||||
* "ocr_failure" — non-2xx HTTP, network error, unexpected fetch failure
|
||||
* "ocr_timeout" — request exceeded timeoutMs
|
||||
* "ocr_malformed" — response missing choices[0].message.content (or empty)
|
||||
*
|
||||
* probeRevision() — best-effort served-checkpoint probe (light v0). GETs a
|
||||
* runtime info route and extracts a revision/SHA field if one is present.
|
||||
* The route handler refuses to Claim on a mismatch when a pin is
|
||||
* configured. When the receipt schema is extended to carry it, the probed
|
||||
* value feeds `model_revision`; until then it is a claim gate and
|
||||
* a log line.
|
||||
*/
|
||||
|
||||
import type { OcrRequest } from "@marketplace/shared/tx";
|
||||
|
||||
export interface CallOcrVisionParams {
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
request: OcrRequest;
|
||||
timeoutMs: number;
|
||||
/** Optional bearer token (hosted endpoints). "" sends no header. */
|
||||
apiKey?: string;
|
||||
/** Hard output-token ceiling forwarded as max_tokens when > 0. */
|
||||
maxTokens?: number;
|
||||
/** Overrides the built-in per-format instruction when non-empty. */
|
||||
promptOverride?: string;
|
||||
}
|
||||
|
||||
export interface OcrVisionResult {
|
||||
content: string;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
wallclock_ms: number;
|
||||
}
|
||||
|
||||
export type OcrVisionErrorReason = "ocr_failure" | "ocr_timeout" | "ocr_malformed";
|
||||
|
||||
export class OcrVisionError extends Error {
|
||||
public readonly reason: OcrVisionErrorReason;
|
||||
constructor(reason: OcrVisionErrorReason, message?: string) {
|
||||
super(message ?? reason);
|
||||
this.name = "OcrVisionError";
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
function isAbortError(err: unknown): boolean {
|
||||
if (err instanceof Error) {
|
||||
if (err.name === "AbortError") return true;
|
||||
if ((err as { code?: string }).code === "ABORT_ERR") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Default extraction instructions per output_format. Placeholder wording
|
||||
* pending the Chandra-convention pin (integration item); serving deployments
|
||||
* override via OCR_PROMPT_OVERRIDE. */
|
||||
export function defaultOcrInstruction(outputFormat: string): string {
|
||||
switch (outputFormat) {
|
||||
case "html":
|
||||
return "Convert this document page to clean, semantic HTML. Preserve reading order, tables, form fields and layout structure. Output only the HTML.";
|
||||
case "json":
|
||||
return "Extract this document page as JSON with layout blocks (type, bbox, text) preserving reading order, tables and form fields. Output only the JSON.";
|
||||
case "markdown":
|
||||
default:
|
||||
return "Convert this document page to clean markdown. Preserve reading order, tables and structure. Transcribe handwriting faithfully. Output only the markdown.";
|
||||
}
|
||||
}
|
||||
|
||||
export async function callOcrVision(params: CallOcrVisionParams): Promise<OcrVisionResult> {
|
||||
const { baseUrl, model, request, timeoutMs, apiKey, maxTokens, promptOverride } = params;
|
||||
const url = `${baseUrl.replace(/\/+$/, "")}/v1/chat/completions`;
|
||||
|
||||
const instruction =
|
||||
promptOverride && promptOverride.length > 0
|
||||
? promptOverride
|
||||
: defaultOcrInstruction(request.output_format);
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: { url: `data:${request.mime};base64,${request.image_b64}` },
|
||||
},
|
||||
{ type: "text", text: instruction },
|
||||
],
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
};
|
||||
if (typeof maxTokens === "number" && maxTokens > 0) payload.max_tokens = maxTokens;
|
||||
const body = JSON.stringify(payload);
|
||||
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
if (apiKey) {
|
||||
headers.authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
const startedAt = Date.now();
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) {
|
||||
throw new OcrVisionError("ocr_timeout", `OCR request exceeded ${timeoutMs}ms`);
|
||||
}
|
||||
throw new OcrVisionError(
|
||||
"ocr_failure",
|
||||
`OCR fetch failed: ${(err as Error)?.message ?? String(err)}`,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
detail = await response.text();
|
||||
} catch {
|
||||
// ignore — body unavailable
|
||||
}
|
||||
throw new OcrVisionError(
|
||||
"ocr_failure",
|
||||
`OCR upstream returned HTTP ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = await response.json();
|
||||
} catch (err) {
|
||||
throw new OcrVisionError(
|
||||
"ocr_malformed",
|
||||
`OCR response was not valid JSON: ${(err as Error)?.message ?? String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new OcrVisionError("ocr_malformed", "OCR response was not an object");
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const choicesRaw = obj.choices;
|
||||
if (!Array.isArray(choicesRaw) || choicesRaw.length === 0) {
|
||||
throw new OcrVisionError("ocr_malformed", "OCR response missing 'choices' array");
|
||||
}
|
||||
const firstChoice = choicesRaw[0];
|
||||
if (!firstChoice || typeof firstChoice !== "object") {
|
||||
throw new OcrVisionError("ocr_malformed", "OCR response choices[0] not an object");
|
||||
}
|
||||
const messageRaw = (firstChoice as Record<string, unknown>).message;
|
||||
if (!messageRaw || typeof messageRaw !== "object") {
|
||||
throw new OcrVisionError("ocr_malformed", "OCR response missing 'choices[0].message'");
|
||||
}
|
||||
const content = (messageRaw as Record<string, unknown>).content;
|
||||
if (typeof content !== "string" || content.length === 0) {
|
||||
throw new OcrVisionError(
|
||||
"ocr_malformed",
|
||||
"OCR response missing/empty choices[0].message.content",
|
||||
);
|
||||
}
|
||||
|
||||
const usageRaw = obj.usage;
|
||||
let promptTokens = 0;
|
||||
let completionTokens = 0;
|
||||
if (usageRaw && typeof usageRaw === "object") {
|
||||
const usage = usageRaw as Record<string, unknown>;
|
||||
if (typeof usage.prompt_tokens === "number") promptTokens = usage.prompt_tokens;
|
||||
if (typeof usage.completion_tokens === "number") completionTokens = usage.completion_tokens;
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
prompt_tokens: promptTokens,
|
||||
completion_tokens: completionTokens,
|
||||
wallclock_ms: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Revision probe (light v0) ──────────────────────────────────────────────
|
||||
|
||||
export interface ProbeRevisionParams {
|
||||
probeUrl: string;
|
||||
timeoutMs: number;
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
export interface ProbeRevisionResult {
|
||||
ok: boolean;
|
||||
/** 40-hex checkpoint SHA when the runtime reports one; "" otherwise. */
|
||||
servedSha: string;
|
||||
/** Human-readable detail for logs/alerts. */
|
||||
detail: string;
|
||||
}
|
||||
|
||||
const SHA40_RE = /^[0-9a-f]{40}$/i;
|
||||
|
||||
/** Fields observed across serving runtimes that carry a checkpoint id
|
||||
* (TGI /info: model_sha; vLLM variants: sha / revision / model_revision). */
|
||||
const SHA_FIELDS = ["model_sha", "sha", "revision", "model_revision"];
|
||||
|
||||
export async function probeRevision(params: ProbeRevisionParams): Promise<ProbeRevisionResult> {
|
||||
const { probeUrl, timeoutMs, apiKey } = params;
|
||||
const headers: Record<string, string> = {};
|
||||
if (apiKey) headers.authorization = `Bearer ${apiKey}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(probeUrl, { headers, signal: controller.signal });
|
||||
if (!response.ok) {
|
||||
return { ok: false, servedSha: "", detail: `probe HTTP ${response.status}` };
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = await response.json();
|
||||
} catch {
|
||||
return { ok: false, servedSha: "", detail: "probe response not JSON" };
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return { ok: false, servedSha: "", detail: "probe response not an object" };
|
||||
}
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
for (const field of SHA_FIELDS) {
|
||||
const v = obj[field];
|
||||
if (typeof v === "string" && SHA40_RE.test(v)) {
|
||||
return { ok: true, servedSha: v.toLowerCase(), detail: `probe ${field}=${v}` };
|
||||
}
|
||||
}
|
||||
return { ok: true, servedSha: "", detail: "probe answered without a revision field" };
|
||||
} catch (err) {
|
||||
const msg = isAbortError(err)
|
||||
? `probe exceeded ${timeoutMs}ms`
|
||||
: `probe failed: ${(err as Error)?.message ?? String(err)}`;
|
||||
return { ok: false, servedSha: "", detail: msg };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,13 @@ export interface CallOpenAiParams {
|
||||
* context window (some providers 400 when it exceeds context).
|
||||
*/
|
||||
maxTokens?: number;
|
||||
/**
|
||||
* Forwarded as the OpenAI `user` field when non-empty. Stateful upstreams
|
||||
* (e.g. an OpenClaw gateway) key their agent session off it, so passing the
|
||||
* escrow ref pins one upstream session per marketplace chat session.
|
||||
* Stateless providers ignore it.
|
||||
*/
|
||||
user?: string;
|
||||
/**
|
||||
* When true, sends `reasoning: { enabled: false }` to disable OpenRouter
|
||||
* "thinking" tokens — faster/cheaper straight answers, and avoids reasoning
|
||||
@@ -67,6 +74,8 @@ export interface OpenAiResult {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
wallclock_ms: number;
|
||||
/** Provider-reported request cost in USD when present (OpenRouter extension). */
|
||||
cost_usd?: number;
|
||||
/** Present when the model requested tool calls (streaming path only). */
|
||||
tool_calls?: ToolCall[];
|
||||
/** Upstream finish_reason when reported (e.g. "stop", "tool_calls"). */
|
||||
@@ -93,11 +102,12 @@ function isAbortError(err: unknown): boolean {
|
||||
}
|
||||
|
||||
export async function callOpenAi(params: CallOpenAiParams): Promise<OpenAiResult> {
|
||||
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning } = params;
|
||||
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning, user } = params;
|
||||
const url = `${baseUrl}/v1/chat/completions`;
|
||||
const payload: Record<string, unknown> = { model, messages, stream: false };
|
||||
if (typeof maxTokens === "number" && maxTokens > 0) payload.max_tokens = maxTokens;
|
||||
if (disableReasoning) payload.reasoning = { enabled: false };
|
||||
if (user) payload.user = user;
|
||||
const body = JSON.stringify(payload);
|
||||
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
@@ -180,10 +190,18 @@ export async function callOpenAi(params: CallOpenAiParams): Promise<OpenAiResult
|
||||
const usageRaw = obj.usage;
|
||||
let promptTokens = 0;
|
||||
let completionTokens = 0;
|
||||
let costUsd: number | undefined;
|
||||
if (usageRaw && typeof usageRaw === "object") {
|
||||
const usage = usageRaw as Record<string, unknown>;
|
||||
if (typeof usage.prompt_tokens === "number") promptTokens = usage.prompt_tokens;
|
||||
if (typeof usage.completion_tokens === "number") completionTokens = usage.completion_tokens;
|
||||
if (
|
||||
typeof usage.cost === "number" &&
|
||||
Number.isFinite(usage.cost) &&
|
||||
usage.cost >= 0
|
||||
) {
|
||||
costUsd = usage.cost;
|
||||
}
|
||||
}
|
||||
|
||||
const wallclockMs = Date.now() - startedAt;
|
||||
@@ -193,6 +211,7 @@ export async function callOpenAi(params: CallOpenAiParams): Promise<OpenAiResult
|
||||
prompt_tokens: promptTokens,
|
||||
completion_tokens: completionTokens,
|
||||
wallclock_ms: wallclockMs,
|
||||
...(costUsd === undefined ? {} : { cost_usd: costUsd }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -215,7 +234,7 @@ export async function callOpenAiStream(
|
||||
params: CallOpenAiParams,
|
||||
onToken: (delta: string) => void,
|
||||
): Promise<OpenAiResult> {
|
||||
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning, tools, toolChoice } = params;
|
||||
const { baseUrl, model, messages, timeoutMs, apiKey, maxTokens, disableReasoning, tools, toolChoice, user } = params;
|
||||
const url = `${baseUrl}/v1/chat/completions`;
|
||||
const payload: Record<string, unknown> = {
|
||||
model,
|
||||
@@ -225,6 +244,7 @@ export async function callOpenAiStream(
|
||||
};
|
||||
if (typeof maxTokens === "number" && maxTokens > 0) payload.max_tokens = maxTokens;
|
||||
if (disableReasoning) payload.reasoning = { enabled: false };
|
||||
if (user) payload.user = user;
|
||||
if (Array.isArray(tools) && tools.length > 0) {
|
||||
payload.tools = tools;
|
||||
if (toolChoice !== undefined) payload.tool_choice = toolChoice;
|
||||
|
||||
179
supplier/src/reseller/capacityGate.ts
Normal file
179
supplier/src/reseller/capacityGate.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import type { CapacityProvider } from "./provider.js";
|
||||
import { parseUsdDecimal } from "./money.js";
|
||||
|
||||
export type CapacityReason =
|
||||
| "starting"
|
||||
| "ready"
|
||||
| "below_reserve"
|
||||
| "insufficient_for_job"
|
||||
| "provider_unavailable"
|
||||
| "database_unavailable"
|
||||
| "stale";
|
||||
|
||||
export interface CapacitySnapshot {
|
||||
provider: string;
|
||||
model: string;
|
||||
canServe: boolean;
|
||||
reason: CapacityReason;
|
||||
checkedAtMs: number | null;
|
||||
lastSuccessAtMs: number | null;
|
||||
keyLimitUsdNanos: bigint;
|
||||
remainingAllowanceUsdNanos: bigint;
|
||||
reserveUsdNanos: bigint;
|
||||
sellableUsdNanos: bigint;
|
||||
worstCaseJobUsdNanos: bigint;
|
||||
promptPriceUsdNanosPerToken: bigint;
|
||||
completionPriceUsdNanosPerToken: bigint;
|
||||
requestPriceUsdNanos: bigint;
|
||||
availableJobs: bigint | null;
|
||||
limitReset: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface CapacityGateOptions {
|
||||
provider: CapacityProvider;
|
||||
model: string;
|
||||
reserveUsd: string;
|
||||
maxInputTokens: number;
|
||||
maxOutputTokens: number;
|
||||
staleAfterMs: number;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export class CapacityGate {
|
||||
private readonly provider: CapacityProvider;
|
||||
private readonly model: string;
|
||||
private readonly reserveUsdNanos: bigint;
|
||||
private readonly maxInputTokens: number;
|
||||
private readonly maxOutputTokens: number;
|
||||
private readonly staleAfterMs: number;
|
||||
private readonly now: () => number;
|
||||
private current: CapacitySnapshot;
|
||||
private inFlight: Promise<CapacitySnapshot> | null = null;
|
||||
|
||||
constructor(options: CapacityGateOptions) {
|
||||
this.provider = options.provider;
|
||||
this.model = options.model;
|
||||
this.reserveUsdNanos = parseUsdDecimal(options.reserveUsd, "ceil");
|
||||
this.maxInputTokens = options.maxInputTokens;
|
||||
this.maxOutputTokens = options.maxOutputTokens;
|
||||
this.staleAfterMs = options.staleAfterMs;
|
||||
this.now = options.now ?? Date.now;
|
||||
this.current = {
|
||||
provider: options.provider.id,
|
||||
model: options.model,
|
||||
canServe: false,
|
||||
reason: "starting",
|
||||
checkedAtMs: null,
|
||||
lastSuccessAtMs: null,
|
||||
keyLimitUsdNanos: 0n,
|
||||
remainingAllowanceUsdNanos: 0n,
|
||||
reserveUsdNanos: this.reserveUsdNanos,
|
||||
sellableUsdNanos: 0n,
|
||||
worstCaseJobUsdNanos: 0n,
|
||||
promptPriceUsdNanosPerToken: 0n,
|
||||
completionPriceUsdNanosPerToken: 0n,
|
||||
requestPriceUsdNanos: 0n,
|
||||
availableJobs: 0n,
|
||||
limitReset: null,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
snapshot(): CapacitySnapshot {
|
||||
const snapshot = this.current;
|
||||
if (
|
||||
snapshot.lastSuccessAtMs !== null &&
|
||||
this.now() - snapshot.lastSuccessAtMs > this.staleAfterMs
|
||||
) {
|
||||
return {
|
||||
...snapshot,
|
||||
canServe: false,
|
||||
reason: "stale",
|
||||
error: "provider capacity data is stale",
|
||||
};
|
||||
}
|
||||
return { ...snapshot };
|
||||
}
|
||||
|
||||
async refresh(): Promise<CapacitySnapshot> {
|
||||
if (this.inFlight) return this.inFlight;
|
||||
this.inFlight = this.performRefresh();
|
||||
try {
|
||||
return await this.inFlight;
|
||||
} finally {
|
||||
this.inFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
forceUnavailable(
|
||||
reason: Extract<CapacityReason, "database_unavailable" | "provider_unavailable">,
|
||||
error: string,
|
||||
): CapacitySnapshot {
|
||||
this.current = {
|
||||
...this.current,
|
||||
canServe: false,
|
||||
reason,
|
||||
checkedAtMs: this.now(),
|
||||
error,
|
||||
};
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
private async performRefresh(): Promise<CapacitySnapshot> {
|
||||
try {
|
||||
const reading = await this.provider.readCapacity(this.model);
|
||||
const pricing = reading.pricing;
|
||||
const worstCaseJobUsdNanos =
|
||||
pricing.requestUsdNanos +
|
||||
pricing.promptUsdNanosPerToken * BigInt(this.maxInputTokens) +
|
||||
pricing.completionUsdNanosPerToken * BigInt(this.maxOutputTokens);
|
||||
const remaining = reading.keyRemainingUsdNanos;
|
||||
const sellable = remaining > this.reserveUsdNanos
|
||||
? remaining - this.reserveUsdNanos
|
||||
: 0n;
|
||||
const enoughForOne =
|
||||
worstCaseJobUsdNanos === 0n || sellable >= worstCaseJobUsdNanos;
|
||||
const aboveReserve = remaining >= this.reserveUsdNanos;
|
||||
const canServe = aboveReserve && enoughForOne;
|
||||
const reason: CapacityReason = canServe
|
||||
? "ready"
|
||||
: !aboveReserve
|
||||
? "below_reserve"
|
||||
: "insufficient_for_job";
|
||||
const availableJobs = worstCaseJobUsdNanos === 0n
|
||||
? null
|
||||
: sellable / worstCaseJobUsdNanos;
|
||||
|
||||
this.current = {
|
||||
provider: reading.provider,
|
||||
model: this.model,
|
||||
canServe,
|
||||
reason,
|
||||
checkedAtMs: reading.checkedAtMs,
|
||||
lastSuccessAtMs: reading.checkedAtMs,
|
||||
keyLimitUsdNanos: reading.keyLimitUsdNanos,
|
||||
remainingAllowanceUsdNanos: remaining,
|
||||
reserveUsdNanos: this.reserveUsdNanos,
|
||||
sellableUsdNanos: sellable,
|
||||
worstCaseJobUsdNanos,
|
||||
promptPriceUsdNanosPerToken: pricing.promptUsdNanosPerToken,
|
||||
completionPriceUsdNanosPerToken: pricing.completionUsdNanosPerToken,
|
||||
requestPriceUsdNanos: pricing.requestUsdNanos,
|
||||
availableJobs,
|
||||
limitReset: reading.limitReset,
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
this.current = {
|
||||
...this.current,
|
||||
canServe: false,
|
||||
reason: "provider_unavailable",
|
||||
checkedAtMs: this.now(),
|
||||
error: detail,
|
||||
};
|
||||
}
|
||||
return this.snapshot();
|
||||
}
|
||||
}
|
||||
101
supplier/src/reseller/dashboardPage.ts
Normal file
101
supplier/src/reseller/dashboardPage.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
export function renderResellerDashboardPage(): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Reseller capacity</title>
|
||||
<style>
|
||||
:root{color-scheme:dark;--bg:#071014;--panel:#0d1a20;--line:#24363d;--text:#eef5f5;--muted:#98aeb5;--good:#50d890;--warn:#ffcb66;--bad:#ff7b72;--accent:#75bfff}
|
||||
*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 15% 0,#12303a 0,transparent 34rem),var(--bg);color:var(--text);font:14px/1.5 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
||||
main{width:min(1180px,calc(100% - 32px));margin:0 auto;padding:42px 0 64px}header{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;margin-bottom:24px}h1{font-size:clamp(28px,5vw,48px);line-height:1.05;margin:0;letter-spacing:-.04em}header p{margin:8px 0 0;color:var(--muted)}.stamp{color:var(--muted);font-variant-numeric:tabular-nums}.panel{background:color-mix(in srgb,var(--panel) 94%,transparent);border:1px solid var(--line);border-radius:16px;box-shadow:0 16px 48px #0005}.identity{display:grid;grid-template-columns:1.4fr 1fr 1fr;gap:1px;overflow:hidden;margin-bottom:16px}.identity>div{padding:18px 20px;background:var(--panel)}.label{display:block;color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.12em;margin-bottom:6px}.value{font-size:17px;font-weight:650;overflow-wrap:anywhere}.status{display:inline-flex;align-items:center;gap:8px}.dot{width:9px;height:9px;border-radius:50%;background:var(--muted);box-shadow:0 0 0 4px #fff1}.status.free .dot{background:var(--good)}.status.working .dot{background:var(--warn)}.status.offline .dot{background:var(--bad)}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:0 0 24px}.metric{padding:18px}.metric strong{display:block;font-size:24px;letter-spacing:-.03em;font-variant-numeric:tabular-nums}.metric small{color:var(--muted)}.section-head{display:flex;justify-content:space-between;align-items:center;margin:28px 0 12px}h2{font-size:18px;margin:0}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;min-width:940px}th,td{text-align:left;padding:13px 14px;border-bottom:1px solid var(--line);vertical-align:top}th{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.09em;font-weight:600}tbody tr:last-child td{border-bottom:0}.job-status{font-weight:700}.job-status.settled{color:var(--good)}.job-status.failed{color:var(--bad)}.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}.preview{max-width:250px;color:#c9d8dc;white-space:normal}.empty,.error{padding:30px;color:var(--muted)}.error{color:var(--bad)}
|
||||
a.explorer-link{color:var(--accent);text-decoration:none;text-underline-offset:3px}a.explorer-link:hover,a.explorer-link:focus-visible{text-decoration:underline}
|
||||
@media(max-width:820px){header{align-items:flex-start;flex-direction:column}.identity{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}}@media(max-width:480px){main{width:min(100% - 20px,1180px);padding-top:24px}.metrics{grid-template-columns:1fr}.metric strong{font-size:21px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header><div><h1>Provider capacity resale</h1><p>Bounded upstream capacity settled through Vector escrow.</p></div><div class="stamp" id="updated">Loading current state</div></header>
|
||||
<section class="panel identity" aria-label="Agent identity">
|
||||
<div><span class="label">Route</span><span class="value" id="route">Loading</span></div>
|
||||
<div><span class="label">Marketplace model</span><span class="value" id="market-model">Loading</span></div>
|
||||
<div><span class="label">Supplier status</span><span class="status" id="status"><span class="dot"></span><span id="status-text">Loading</span></span><div class="stamp" id="reason"></div></div>
|
||||
</section>
|
||||
<section class="metrics" aria-label="Capacity and settlement metrics">
|
||||
<div class="panel metric"><span class="label">Sellable allowance</span><strong id="sellable">--</strong><small id="allowance">Provider allowance --</small></div>
|
||||
<div class="panel metric"><span class="label">Worst-case job</span><strong id="worst-job">--</strong><small id="available-jobs">-- jobs available</small></div>
|
||||
<div class="panel metric"><span class="label">AP3X earned</span><strong id="earned">--</strong><small id="settled-count">-- settled jobs</small></div>
|
||||
<div class="panel metric"><span class="label">Upstream spend</span><strong id="spend">--</strong><small id="failed-count">-- failed jobs</small></div>
|
||||
</section>
|
||||
<div class="section-head"><h2>Conversion timeline</h2><span class="stamp">Public previews require buyer consent</span></div>
|
||||
<section class="panel table-wrap"><table><thead><tr><th>Time</th><th>Status</th><th>Model</th><th>Prompt preview</th><th>Output preview</th><th>Upstream</th><th>Payout</th><th>Escrow</th></tr></thead><tbody id="jobs"><tr><td colspan="8" class="empty">Loading jobs</td></tr></tbody></table></section>
|
||||
</main>
|
||||
<script>
|
||||
(function(){
|
||||
'use strict';
|
||||
var byId=function(id){return document.getElementById(id)};
|
||||
var text=function(id,value){byId(id).textContent=value==null?'--':String(value)};
|
||||
var money=function(value){return '$'+String(value==null?'0':value)};
|
||||
function renderStatus(state){
|
||||
text('route',state.provider+' / '+state.provider_model);
|
||||
text('market-model',state.marketplace_model);
|
||||
var status=byId('status');status.className='status '+state.status;
|
||||
text('status-text',state.status);text('reason',state.reason);
|
||||
text('sellable',money(state.capacity.sellable_usd));
|
||||
text('allowance','Allowance '+money(state.capacity.remaining_allowance_usd)+' · reserve '+money(state.capacity.protected_reserve_usd)+' · committed '+money(state.capacity.committed_usd));
|
||||
text('worst-job',money(state.capacity.worst_case_job_usd));
|
||||
text('available-jobs',state.capacity.available_jobs==null?'No balance limit':state.capacity.available_jobs+' jobs available');
|
||||
text('earned',state.totals.ap3x_earned+' AP3X');
|
||||
text('settled-count',state.totals.settled_jobs+' settled jobs');
|
||||
text('spend',money(state.totals.upstream_spend_usd));
|
||||
text('failed-count',state.totals.failed_jobs+' failed jobs');
|
||||
text('updated','Updated '+new Date().toLocaleTimeString());
|
||||
}
|
||||
function cell(row,value,className){var td=document.createElement('td');if(className)td.className=className;td.textContent=value==null?'--':String(value);row.appendChild(td)}
|
||||
function escrowCell(row,value){
|
||||
var td=document.createElement('td');td.className='mono';
|
||||
var ref=typeof value==='string'?value:'';
|
||||
var txHash=ref.split('#')[0];
|
||||
if(/^[0-9a-fA-F]{64}$/.test(txHash)){
|
||||
var link=document.createElement('a');
|
||||
link.className='explorer-link';
|
||||
link.href='https://vector.apexscan.org/en/transaction/'+txHash+'/summary/';
|
||||
link.target='_blank';link.rel='noopener noreferrer';
|
||||
link.textContent=ref;
|
||||
link.setAttribute('aria-label','View escrow transaction '+txHash+' on ApexScan');
|
||||
td.appendChild(link);
|
||||
}else{
|
||||
td.textContent=ref||'--';
|
||||
}
|
||||
row.appendChild(td);
|
||||
}
|
||||
function renderJobs(rows){
|
||||
var body=byId('jobs');body.replaceChildren();
|
||||
if(!rows.length){var row=document.createElement('tr');cell(row,'No terminal jobs yet','empty');row.firstChild.colSpan=8;body.appendChild(row);return}
|
||||
rows.forEach(function(job){
|
||||
var row=document.createElement('tr');
|
||||
cell(row,new Date(job.created_at).toLocaleString());
|
||||
cell(row,job.status,'job-status '+job.status);
|
||||
cell(row,job.marketplace_model,'mono');
|
||||
cell(row,job.prompt_preview,'preview');
|
||||
cell(row,job.output_preview,'preview');
|
||||
cell(row,job.upstream_cost_usd==null?'--':money(job.upstream_cost_usd));
|
||||
cell(row,job.ap3x_payout+' AP3X');
|
||||
escrowCell(row,job.escrow_ref);
|
||||
body.appendChild(row);
|
||||
});
|
||||
}
|
||||
function showError(message){var body=byId('jobs');body.replaceChildren();var row=document.createElement('tr');cell(row,message,'error');row.firstChild.colSpan=8;body.appendChild(row);text('updated','Data unavailable')}
|
||||
async function refresh(){
|
||||
try{
|
||||
var results=await Promise.all([fetch('/reseller/api/status',{cache:'no-store'}),fetch('/reseller/api/jobs?limit=50',{cache:'no-store'})]);
|
||||
if(!results[0].ok||!results[1].ok)throw new Error('Seller evidence is unavailable');
|
||||
var data=await Promise.all([results[0].json(),results[1].json()]);renderStatus(data[0]);renderJobs(data[1].jobs||[]);
|
||||
}catch(error){showError(error instanceof Error?error.message:'Seller evidence is unavailable')}
|
||||
}
|
||||
refresh();setInterval(refresh,5000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
446
supplier/src/reseller/evidenceStore.ts
Normal file
446
supplier/src/reseller/evidenceStore.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import { Pool } from "pg";
|
||||
import type { ChatMessage } from "@marketplace/shared/tx";
|
||||
import type { CapacitySnapshot } from "./capacityGate.js";
|
||||
import { formatUsdNanos } from "./money.js";
|
||||
|
||||
const SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS reseller_jobs (
|
||||
escrow_ref text PRIMARY KEY,
|
||||
local_job_id text,
|
||||
status text NOT NULL CHECK (status IN ('received','claimed','running','submitting','submitted','settled','failed')),
|
||||
provider text NOT NULL,
|
||||
provider_model text NOT NULL,
|
||||
marketplace_model text NOT NULL,
|
||||
request_json jsonb NOT NULL,
|
||||
response_json jsonb,
|
||||
receipt_json jsonb,
|
||||
public_preview boolean NOT NULL DEFAULT false,
|
||||
prompt_preview text,
|
||||
output_preview text,
|
||||
balance_before_nanos bigint NOT NULL,
|
||||
balance_after_nanos bigint,
|
||||
worst_case_cost_nanos bigint NOT NULL,
|
||||
actual_cost_nanos bigint,
|
||||
price_lovelace bigint NOT NULL,
|
||||
prompt_tokens integer,
|
||||
completion_tokens integer,
|
||||
claim_tx_hash text,
|
||||
submitted_ref text,
|
||||
failure_reason text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
settled_at timestamptz
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS reseller_jobs_status_idx
|
||||
ON reseller_jobs (status, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS reseller_jobs_created_idx
|
||||
ON reseller_jobs (created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reseller_capacity (
|
||||
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
||||
provider text NOT NULL,
|
||||
model text NOT NULL,
|
||||
can_serve boolean NOT NULL,
|
||||
reason text NOT NULL,
|
||||
checked_at timestamptz,
|
||||
remaining_allowance_nanos bigint NOT NULL,
|
||||
reserve_nanos bigint NOT NULL,
|
||||
sellable_nanos bigint NOT NULL,
|
||||
worst_case_job_nanos bigint NOT NULL,
|
||||
available_jobs bigint,
|
||||
limit_reset text,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`;
|
||||
|
||||
export interface ReceivedJob {
|
||||
escrowRef: string;
|
||||
provider: string;
|
||||
providerModel: string;
|
||||
marketplaceModel: string;
|
||||
messages: ChatMessage[];
|
||||
publicPreview: boolean;
|
||||
balanceBeforeUsdNanos: bigint;
|
||||
worstCaseCostUsdNanos: bigint;
|
||||
priceLovelace: bigint;
|
||||
}
|
||||
|
||||
export interface RecordedOutput {
|
||||
response: Record<string, unknown>;
|
||||
receipt: Record<string, unknown>;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
actualCostUsdNanos: bigint | null;
|
||||
outputText: string;
|
||||
}
|
||||
|
||||
export interface SubmittedJobRow {
|
||||
escrowRef: string;
|
||||
submittedRef: string;
|
||||
}
|
||||
|
||||
export interface PublicResellerJob {
|
||||
escrow_ref: string;
|
||||
status: "settled" | "failed";
|
||||
provider: string;
|
||||
provider_model: string;
|
||||
marketplace_model: string;
|
||||
prompt_preview: string | null;
|
||||
output_preview: string | null;
|
||||
upstream_cost_usd: string | null;
|
||||
ap3x_payout: string;
|
||||
claim_tx_hash: string | null;
|
||||
submitted_ref: string | null;
|
||||
failure_reason: string | null;
|
||||
created_at: string;
|
||||
settled_at: string | null;
|
||||
}
|
||||
|
||||
export interface PublicResellerTotals {
|
||||
settled_jobs: number;
|
||||
failed_jobs: number;
|
||||
upstream_spend_usd: string;
|
||||
ap3x_earned: string;
|
||||
}
|
||||
|
||||
export class ResellerEvidenceStore {
|
||||
private readonly pool: Pool;
|
||||
private readonly previewMaxChars: number;
|
||||
|
||||
constructor(databaseUrl: string, previewMaxChars: number) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl, max: 4 });
|
||||
this.previewMaxChars = previewMaxChars;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query("SELECT pg_advisory_lock(9042713861245)");
|
||||
await client.query(SCHEMA_SQL);
|
||||
} finally {
|
||||
await client.query("SELECT pg_advisory_unlock(9042713861245)").catch(() => undefined);
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async ping(): Promise<void> {
|
||||
await this.pool.query("SELECT 1");
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
}
|
||||
|
||||
async markInterruptedJobs(): Promise<void> {
|
||||
await this.pool.query(
|
||||
`UPDATE reseller_jobs
|
||||
SET status = 'failed', failure_reason = 'interrupted_on_restart', updated_at = now()
|
||||
WHERE status IN ('received','claimed','running')`,
|
||||
);
|
||||
}
|
||||
|
||||
async saveCapacity(snapshot: CapacitySnapshot): Promise<void> {
|
||||
await this.pool.query(
|
||||
`INSERT INTO reseller_capacity (
|
||||
singleton, provider, model, can_serve, reason, checked_at,
|
||||
remaining_allowance_nanos, reserve_nanos, sellable_nanos,
|
||||
worst_case_job_nanos, available_jobs, limit_reset, updated_at
|
||||
) VALUES (true,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,now())
|
||||
ON CONFLICT (singleton) DO UPDATE SET
|
||||
provider=EXCLUDED.provider, model=EXCLUDED.model,
|
||||
can_serve=EXCLUDED.can_serve, reason=EXCLUDED.reason,
|
||||
checked_at=EXCLUDED.checked_at,
|
||||
remaining_allowance_nanos=EXCLUDED.remaining_allowance_nanos,
|
||||
reserve_nanos=EXCLUDED.reserve_nanos,
|
||||
sellable_nanos=EXCLUDED.sellable_nanos,
|
||||
worst_case_job_nanos=EXCLUDED.worst_case_job_nanos,
|
||||
available_jobs=EXCLUDED.available_jobs,
|
||||
limit_reset=EXCLUDED.limit_reset, updated_at=now()`,
|
||||
[
|
||||
snapshot.provider,
|
||||
snapshot.model,
|
||||
snapshot.canServe,
|
||||
snapshot.reason,
|
||||
snapshot.checkedAtMs === null ? null : new Date(snapshot.checkedAtMs),
|
||||
snapshot.remainingAllowanceUsdNanos.toString(),
|
||||
snapshot.reserveUsdNanos.toString(),
|
||||
snapshot.sellableUsdNanos.toString(),
|
||||
snapshot.worstCaseJobUsdNanos.toString(),
|
||||
snapshot.availableJobs?.toString() ?? null,
|
||||
snapshot.limitReset,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async recordReceived(job: ReceivedJob): Promise<void> {
|
||||
const promptPreview = job.publicPreview
|
||||
? redactPublicPreview(
|
||||
job.messages.map((message) => `${message.role}: ${String(message.content)}`).join(" "),
|
||||
this.previewMaxChars,
|
||||
)
|
||||
: null;
|
||||
await this.pool.query(
|
||||
`INSERT INTO reseller_jobs (
|
||||
escrow_ref, status, provider, provider_model, marketplace_model,
|
||||
request_json, public_preview, prompt_preview, balance_before_nanos,
|
||||
worst_case_cost_nanos, price_lovelace
|
||||
) VALUES ($1,'received',$2,$3,$4,$5::jsonb,$6,$7,$8,$9,$10)`,
|
||||
[
|
||||
job.escrowRef,
|
||||
job.provider,
|
||||
job.providerModel,
|
||||
job.marketplaceModel,
|
||||
JSON.stringify(job.messages),
|
||||
job.publicPreview,
|
||||
promptPreview,
|
||||
job.balanceBeforeUsdNanos.toString(),
|
||||
job.worstCaseCostUsdNanos.toString(),
|
||||
job.priceLovelace.toString(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async recordClaimed(
|
||||
escrowRef: string,
|
||||
claimTxHash: string,
|
||||
localJobId: string,
|
||||
): Promise<void> {
|
||||
await this.transition(
|
||||
escrowRef,
|
||||
["received"],
|
||||
`status='claimed', claim_tx_hash=$2, local_job_id=$3`,
|
||||
[claimTxHash, localJobId],
|
||||
);
|
||||
}
|
||||
|
||||
async recordInferenceStarted(escrowRef: string): Promise<void> {
|
||||
await this.transition(escrowRef, ["claimed"], `status='running'`, []);
|
||||
}
|
||||
|
||||
async recordOutput(escrowRef: string, output: RecordedOutput): Promise<void> {
|
||||
const outputPreview = await this.publicPreviewEnabled(escrowRef)
|
||||
? redactPublicPreview(output.outputText, this.previewMaxChars)
|
||||
: null;
|
||||
await this.transition(
|
||||
escrowRef,
|
||||
["running"],
|
||||
`response_json=$2::jsonb, receipt_json=$3::jsonb,
|
||||
prompt_tokens=$4, completion_tokens=$5, actual_cost_nanos=$6,
|
||||
output_preview=$7`,
|
||||
[
|
||||
JSON.stringify(output.response),
|
||||
JSON.stringify(output.receipt),
|
||||
output.promptTokens,
|
||||
output.completionTokens,
|
||||
output.actualCostUsdNanos?.toString() ?? null,
|
||||
outputPreview,
|
||||
],
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
async recordSubmitting(escrowRef: string, submittedRef: string): Promise<void> {
|
||||
await this.transition(
|
||||
escrowRef,
|
||||
["running"],
|
||||
`status='submitting', submitted_ref=$2`,
|
||||
[submittedRef],
|
||||
);
|
||||
}
|
||||
|
||||
async recordSubmitConfirmed(escrowRef: string): Promise<void> {
|
||||
await this.transition(
|
||||
escrowRef,
|
||||
["submitting"],
|
||||
`status='submitted'`,
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
async recordSettled(
|
||||
escrowRef: string,
|
||||
balanceAfterUsdNanos: bigint | null,
|
||||
): Promise<void> {
|
||||
await this.transition(
|
||||
escrowRef,
|
||||
["submitted"],
|
||||
`status='settled', balance_after_nanos=$2, settled_at=now()`,
|
||||
[balanceAfterUsdNanos?.toString() ?? null],
|
||||
);
|
||||
}
|
||||
|
||||
async recordFailed(escrowRef: string, reason: string): Promise<void> {
|
||||
await this.pool.query(
|
||||
`UPDATE reseller_jobs
|
||||
SET status='failed', failure_reason=$2, updated_at=now()
|
||||
WHERE escrow_ref=$1 AND status <> 'settled'`,
|
||||
[escrowRef, reason.slice(0, 1000)],
|
||||
);
|
||||
}
|
||||
|
||||
async listSubmitting(): Promise<SubmittedJobRow[]> {
|
||||
const result = await this.pool.query<{
|
||||
escrow_ref: string;
|
||||
submitted_ref: string;
|
||||
}>(
|
||||
`SELECT escrow_ref, submitted_ref FROM reseller_jobs
|
||||
WHERE status='submitting' AND submitted_ref IS NOT NULL`,
|
||||
);
|
||||
return result.rows.map((row) => ({
|
||||
escrowRef: row.escrow_ref,
|
||||
submittedRef: row.submitted_ref,
|
||||
}));
|
||||
}
|
||||
|
||||
async listSubmitted(): Promise<SubmittedJobRow[]> {
|
||||
const result = await this.pool.query<{
|
||||
escrow_ref: string;
|
||||
submitted_ref: string;
|
||||
}>(
|
||||
`SELECT escrow_ref, submitted_ref FROM reseller_jobs
|
||||
WHERE status='submitted' AND submitted_ref IS NOT NULL`,
|
||||
);
|
||||
return result.rows.map((row) => ({
|
||||
escrowRef: row.escrow_ref,
|
||||
submittedRef: row.submitted_ref,
|
||||
}));
|
||||
}
|
||||
|
||||
async listPublicJobs(limit: number): Promise<PublicResellerJob[]> {
|
||||
const safeLimit = Math.max(1, Math.min(100, Math.trunc(limit)));
|
||||
const result = await this.pool.query<{
|
||||
escrow_ref: string;
|
||||
status: "settled" | "failed";
|
||||
provider: string;
|
||||
provider_model: string;
|
||||
marketplace_model: string;
|
||||
prompt_preview: string | null;
|
||||
output_preview: string | null;
|
||||
actual_cost_nanos: string | null;
|
||||
price_lovelace: string;
|
||||
claim_tx_hash: string | null;
|
||||
submitted_ref: string | null;
|
||||
failure_reason: string | null;
|
||||
created_at: Date;
|
||||
settled_at: Date | null;
|
||||
}>(
|
||||
`SELECT escrow_ref,status,provider,provider_model,marketplace_model,
|
||||
prompt_preview,output_preview,actual_cost_nanos,price_lovelace,
|
||||
claim_tx_hash,submitted_ref,failure_reason,created_at,settled_at
|
||||
FROM reseller_jobs WHERE status IN ('settled','failed')
|
||||
ORDER BY created_at DESC LIMIT $1`,
|
||||
[safeLimit],
|
||||
);
|
||||
return result.rows.map((row) => ({
|
||||
escrow_ref: row.escrow_ref,
|
||||
status: row.status,
|
||||
provider: row.provider,
|
||||
provider_model: row.provider_model,
|
||||
marketplace_model: row.marketplace_model,
|
||||
prompt_preview: row.prompt_preview,
|
||||
output_preview: row.output_preview,
|
||||
upstream_cost_usd: row.actual_cost_nanos === null
|
||||
? null
|
||||
: formatUsdNanos(BigInt(row.actual_cost_nanos)),
|
||||
ap3x_payout: row.status === "settled"
|
||||
? formatAp3x(BigInt(row.price_lovelace))
|
||||
: "0.000000",
|
||||
claim_tx_hash: row.claim_tx_hash,
|
||||
submitted_ref: row.submitted_ref,
|
||||
failure_reason: row.failure_reason,
|
||||
created_at: row.created_at.toISOString(),
|
||||
settled_at: row.settled_at?.toISOString() ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
async publicTotals(): Promise<PublicResellerTotals> {
|
||||
const result = await this.pool.query<{
|
||||
settled_jobs: string;
|
||||
failed_jobs: string;
|
||||
spend_nanos: string;
|
||||
earned_lovelace: string;
|
||||
}>(
|
||||
`SELECT
|
||||
count(*) FILTER (WHERE status='settled')::text AS settled_jobs,
|
||||
count(*) FILTER (WHERE status='failed')::text AS failed_jobs,
|
||||
coalesce(sum(actual_cost_nanos),0)::text AS spend_nanos,
|
||||
coalesce(sum(price_lovelace) FILTER (WHERE status='settled'),0)::text AS earned_lovelace
|
||||
FROM reseller_jobs`,
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
settled_jobs: Number(row?.settled_jobs ?? 0),
|
||||
failed_jobs: Number(row?.failed_jobs ?? 0),
|
||||
upstream_spend_usd: formatUsdNanos(BigInt(row?.spend_nanos ?? 0)),
|
||||
ap3x_earned: formatAp3x(BigInt(row?.earned_lovelace ?? 0)),
|
||||
};
|
||||
}
|
||||
|
||||
private async publicPreviewEnabled(escrowRef: string): Promise<boolean> {
|
||||
const result = await this.pool.query<{ public_preview: boolean }>(
|
||||
"SELECT public_preview FROM reseller_jobs WHERE escrow_ref=$1",
|
||||
[escrowRef],
|
||||
);
|
||||
return result.rows[0]?.public_preview === true;
|
||||
}
|
||||
|
||||
private async transition(
|
||||
escrowRef: string,
|
||||
expected: string[],
|
||||
assignment: string,
|
||||
values: unknown[],
|
||||
requireStatusChange = true,
|
||||
): Promise<void> {
|
||||
const parameters = [escrowRef, ...values];
|
||||
const expectedPlaceholders = expected
|
||||
.map((_, index) => `$${parameters.length + index + 1}`)
|
||||
.join(",");
|
||||
parameters.push(...expected);
|
||||
const result = await this.pool.query(
|
||||
`UPDATE reseller_jobs SET ${assignment}, updated_at=now()
|
||||
WHERE escrow_ref=$1 AND status IN (${expectedPlaceholders})`,
|
||||
parameters,
|
||||
);
|
||||
if (result.rowCount !== 1 && requireStatusChange) {
|
||||
throw new Error(
|
||||
`invalid reseller job transition for ${escrowRef}; expected ${expected.join("|")}`,
|
||||
);
|
||||
}
|
||||
if (result.rowCount !== 1 && !requireStatusChange) {
|
||||
throw new Error(`reseller job not running: ${escrowRef}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function redactPublicPreview(value: string, maxChars: number): string {
|
||||
const redacted = value
|
||||
.replace(
|
||||
/-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/g,
|
||||
"[private material]",
|
||||
)
|
||||
.replace(
|
||||
/["']?\b[A-Z0-9_]*(?:PASSWORD|PASSWD|SECRET|TOKEN|API[_-]?KEY|PRIVATE[_-]?KEY|PRIV[_-]?KEY)\b["']?\s*[:=]\s*(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s,}]+)/gi,
|
||||
"[secret assignment]",
|
||||
)
|
||||
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[jwt]")
|
||||
.replace(/\b(?:gh[opsu]_|github_pat_)[A-Za-z0-9_]{16,}\b/g, "[secret]")
|
||||
.replace(/\b[0-9a-fA-F]{64}\b/g, "[64-hex secret]")
|
||||
.replace(/\b(?:sk|key)-[A-Za-z0-9_-]{8,}\b/g, "[secret]")
|
||||
.replace(/\bBearer\s+\S+/gi, "Bearer [secret]")
|
||||
.replace(/\bBasic\s+[A-Za-z0-9+/=]+/gi, "Basic [secret]")
|
||||
.replace(/\b(?:xox[baprs]-|AKIA)[A-Za-z0-9_-]{12,}\b/g, "[secret]")
|
||||
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[email]")
|
||||
.replace(/https?:\/\/\S+/gi, "[url]")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return redacted.length <= maxChars
|
||||
? redacted
|
||||
: `${redacted.slice(0, Math.max(0, maxChars - 1))}…`;
|
||||
}
|
||||
|
||||
function formatAp3x(lovelace: bigint): string {
|
||||
const whole = lovelace / 1_000_000n;
|
||||
const fraction = (lovelace % 1_000_000n).toString().padStart(6, "0");
|
||||
return `${whole}.${fraction}`;
|
||||
}
|
||||
|
||||
54
supplier/src/reseller/money.ts
Normal file
54
supplier/src/reseller/money.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export const USD_NANOS = 1_000_000_000n;
|
||||
|
||||
export type DecimalRounding = "floor" | "ceil";
|
||||
|
||||
/** Parse a non-negative decimal USD string into integer nanodollars. */
|
||||
export function parseUsdDecimal(
|
||||
raw: string,
|
||||
rounding: DecimalRounding,
|
||||
): bigint {
|
||||
const match = /^(?:0|[1-9]\d*)(?:\.(\d+))?$/.exec(raw);
|
||||
if (!match) throw new Error(`invalid USD decimal: ${raw}`);
|
||||
|
||||
const dot = raw.indexOf(".");
|
||||
const wholeRaw = dot === -1 ? raw : raw.slice(0, dot);
|
||||
const fractionRaw = dot === -1 ? "" : raw.slice(dot + 1);
|
||||
const kept = fractionRaw.slice(0, 9).padEnd(9, "0");
|
||||
let nanos = BigInt(wholeRaw) * USD_NANOS + BigInt(kept || "0");
|
||||
if (rounding === "ceil" && /[1-9]/.test(fractionRaw.slice(9))) {
|
||||
nanos += 1n;
|
||||
}
|
||||
return nanos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a provider JSON number to a conservative remaining-balance value.
|
||||
* One nanodollar is removed to protect against binary floating-point rounding.
|
||||
*/
|
||||
export function parseUsdNumberFloor(value: unknown, field: string): bigint {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`${field} must be a non-negative finite number`);
|
||||
}
|
||||
const scaled = BigInt(Math.floor(value * Number(USD_NANOS)));
|
||||
return scaled > 0n ? scaled - 1n : 0n;
|
||||
}
|
||||
|
||||
/** Convert a provider JSON cost to nanodollars without rounding down. */
|
||||
export function parseUsdNumberCeil(value: unknown, field: string): bigint {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
throw new Error(`${field} must be a non-negative finite number`);
|
||||
}
|
||||
if (value === 0) return 0n;
|
||||
return BigInt(Math.ceil(value * Number(USD_NANOS))) + 1n;
|
||||
}
|
||||
|
||||
export function formatUsdNanos(value: bigint, decimals = 6): string {
|
||||
if (value < 0n) throw new Error("USD value must be non-negative");
|
||||
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 9) {
|
||||
throw new Error("USD display decimals must be from 0 to 9");
|
||||
}
|
||||
const whole = value / USD_NANOS;
|
||||
if (decimals === 0) return whole.toString();
|
||||
const fraction = (value % USD_NANOS).toString().padStart(9, "0");
|
||||
return `${whole}.${fraction.slice(0, decimals)}`;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user